Added some controls. Improved playlist page. Fixed 'connected device' handling.

This commit is contained in:
Ultradesu
2026-07-15 17:43:40 +03:00
parent be59bcb380
commit 26d12caeae
17 changed files with 1178 additions and 63 deletions
+5 -2
View File
@@ -5,6 +5,9 @@ plugins {
id("com.google.dagger.hilt.android")
}
val furumiVersionCode = providers.gradleProperty("furumi.versionCode").map(String::toInt).get()
val furumiVersionName = providers.gradleProperty("furumi.versionName").get()
android {
namespace = "cy.hexor.furumi"
compileSdk = 35
@@ -13,8 +16,8 @@ android {
applicationId = "cy.hexor.furumi"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.1"
versionCode = furumiVersionCode
versionName = furumiVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -5,6 +5,7 @@ import cy.hexor.furumi.data.remote.model.ArtistDetailResponse
import cy.hexor.furumi.data.remote.model.PlayHistoryPageResponse
import cy.hexor.furumi.data.remote.model.ReleaseDetailResponse
import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest
import cy.hexor.furumi.data.remote.model.CreatePlaylistRequest
import cy.hexor.furumi.data.remote.model.DeviceActiveRequest
import cy.hexor.furumi.data.remote.model.DeviceCommandRequest
import cy.hexor.furumi.data.remote.model.DevicePollRequest
@@ -23,6 +24,7 @@ import cy.hexor.furumi.data.remote.model.SharePlaylistRequest
import cy.hexor.furumi.data.remote.model.SharePlaylistResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
@@ -98,6 +100,19 @@ interface PlayerApi {
@Url url: String
): Response<PlaylistDetailResponse>
@Headers("Accept: application/json", "Content-Type: application/json")
@POST
suspend fun createPlaylist(
@Url url: String,
@Body body: CreatePlaylistRequest
): Response<PlaylistCardResponse>
@Headers("Accept: application/json")
@DELETE
suspend fun deletePlaylist(
@Url url: String
): Response<Unit>
@Headers("Accept: application/json", "Content-Type: application/json")
@POST
suspend fun addTracksToPlaylist(
@@ -128,6 +128,10 @@ data class PlaylistListResponse(
@param:Json(name = "playlists") val playlists: List<PlaylistCardResponse>
)
data class CreatePlaylistRequest(
@param:Json(name = "title") val title: String
)
data class AddPlaylistTracksRequest(
@param:Json(name = "track_ids") val trackIds: List<Long>
)
@@ -381,7 +385,7 @@ fun PlaylistDetailResponse.toDomain(baseUrl: String): PlaylistDetail {
)
}
private fun PlaylistCardResponse.toDomain(): PlaylistCard {
fun PlaylistCardResponse.toDomain(): PlaylistCard {
return PlaylistCard(
id = id,
title = title,
@@ -8,6 +8,7 @@ import cy.hexor.furumi.data.remote.PlayerEndpoints
import cy.hexor.furumi.data.remote.api.PlayerApi
import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest
import cy.hexor.furumi.data.remote.model.ConnectedCommandPayloadBody
import cy.hexor.furumi.data.remote.model.CreatePlaylistRequest
import cy.hexor.furumi.data.remote.model.DeviceActiveRequest
import cy.hexor.furumi.data.remote.model.DeviceCommandRequest
import cy.hexor.furumi.data.remote.model.DevicePollRequest
@@ -272,6 +273,48 @@ class PlayerRepositoryImpl @Inject constructor(
}
}
override suspend fun createPlaylist(title: String): Result<PlaylistCard> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.createPlaylist(
url = playerEndpoints.playlists(baseUrl),
body = CreatePlaylistRequest(title = title)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Create playlist response is empty")
Result.success(body.toDomain())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun deletePlaylist(playlistId: Long): Result<Unit> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.deletePlaylist(
url = playerEndpoints.playlistDetail(baseUrl, playlistId)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
@@ -43,6 +43,10 @@ interface PlayerRepository {
suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail>
suspend fun createPlaylist(title: String): Result<PlaylistCard>
suspend fun deletePlaylist(playlistId: Long): Result<Unit>
suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit>
suspend fun shareTrack(trackId: Long, title: String): Result<String>
@@ -103,6 +103,7 @@ class PlaybackController @Inject constructor(
)
setHandleAudioBecomingNoisy(true)
setWakeMode(C.WAKE_MODE_LOCAL)
volume = ANDROID_MEDIA_VOLUME_MULTIPLIER
}
player.addListener(
@@ -249,11 +250,6 @@ class PlaybackController @Inject constructor(
publishState()
}
fun setVolume(volume: Double) {
player.volume = volume.toFloat().coerceIn(0f, 1f)
publishState()
}
fun setOptions(shuffle: Boolean?, repeatMode: String?) {
shuffle?.let { player.shuffleModeEnabled = it }
repeatMode?.let { player.repeatMode = it.toPlayerRepeatMode() }
@@ -267,7 +263,6 @@ class PlaybackController @Inject constructor(
if (queue.isEmpty()) return
setOptions(playbackState.shuffle, playbackState.repeatMode)
setVolume(playbackState.volume)
playQueue(queue, playbackState.index.coerceIn(0, queue.lastIndex))
player.seekTo((playbackState.positionSeconds.coerceAtLeast(0.0) * 1_000.0).toLong())
if (playbackState.paused) {
@@ -477,6 +472,7 @@ class PlaybackController @Inject constructor(
private companion object {
const val PROGRESS_TICK_MS = 500L
const val RESTART_WINDOW_MS = 3_000L
const val ANDROID_MEDIA_VOLUME_MULTIPLIER = 1f
}
private fun startPlaybackService() {
@@ -385,6 +385,8 @@ internal fun NowPlayingBar(
onPlayPause: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onToggleShuffle: () -> Unit,
onCycleRepeatMode: () -> Unit,
onToggleLike: () -> Unit
) {
val track = playback.currentTrack ?: return
@@ -433,6 +435,12 @@ internal fun NowPlayingBar(
)
}
Spacer(modifier = Modifier.width(6.dp))
MiniPlayerIconButton(size = 30, onClick = onToggleShuffle) {
ShuffleGlyph(
modifier = Modifier.size(16.dp),
color = if (playback.shuffle) FurumiNeonPink else MaterialTheme.colorScheme.onBackground
)
}
MiniPlayerIconButton(onClick = onPrevious) {
PreviousGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
}
@@ -450,6 +458,17 @@ internal fun NowPlayingBar(
MiniPlayerIconButton(onClick = onNext) {
NextGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
}
MiniPlayerIconButton(size = 30, onClick = onCycleRepeatMode) {
RepeatGlyph(
modifier = Modifier.size(18.dp),
color = if (playback.repeatMode.equals("off", ignoreCase = true)) {
MaterialTheme.colorScheme.onBackground
} else {
FurumiNeonPink
},
repeatMode = playback.repeatMode
)
}
MiniPlayerIconButton(onClick = onToggleLike) {
HeartGlyph(
isLiked = isLiked,
@@ -57,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import cy.hexor.furumi.domain.model.ArtistCard
import cy.hexor.furumi.domain.model.PlaylistCard
@@ -128,6 +129,30 @@ internal fun PlaybackGlyph(
}
}
@Composable
internal fun PlusGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val stroke = size.minDimension * 0.14f
drawLine(
color = color,
start = Offset(size.width * 0.5f, size.height * 0.2f),
end = Offset(size.width * 0.5f, size.height * 0.8f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.2f, size.height * 0.5f),
end = Offset(size.width * 0.8f, size.height * 0.5f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
}
@Composable
internal fun BackGlyph(
modifier: Modifier,
@@ -197,6 +222,150 @@ internal fun NextGlyph(
}
}
@Composable
internal fun ShuffleGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val stroke = size.minDimension * 0.10f
drawLine(
color = color,
start = Offset(size.width * 0.14f, size.height * 0.30f),
end = Offset(size.width * 0.34f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.34f, size.height * 0.30f),
end = Offset(size.width * 0.66f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.66f, size.height * 0.70f),
end = Offset(size.width * 0.86f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.14f, size.height * 0.70f),
end = Offset(size.width * 0.34f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.34f, size.height * 0.70f),
end = Offset(size.width * 0.66f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.66f, size.height * 0.30f),
end = Offset(size.width * 0.86f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.20f),
end = Offset(size.width * 0.86f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.40f),
end = Offset(size.width * 0.86f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.60f),
end = Offset(size.width * 0.86f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.80f),
end = Offset(size.width * 0.86f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
}
@Composable
internal fun RepeatGlyph(
modifier: Modifier,
color: Color,
repeatMode: String
) {
Box(modifier = modifier) {
Canvas(modifier = Modifier.fillMaxSize()) {
val stroke = size.minDimension * 0.10f
drawLine(
color = color,
start = Offset(size.width * 0.24f, size.height * 0.30f),
end = Offset(size.width * 0.78f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.78f, size.height * 0.30f),
end = Offset(size.width * 0.66f, size.height * 0.18f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.78f, size.height * 0.30f),
end = Offset(size.width * 0.66f, size.height * 0.42f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.70f),
end = Offset(size.width * 0.22f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.22f, size.height * 0.70f),
end = Offset(size.width * 0.34f, size.height * 0.58f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.22f, size.height * 0.70f),
end = Offset(size.width * 0.34f, size.height * 0.82f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
if (repeatMode.equals("one", ignoreCase = true)) {
Text(
text = "1",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = color,
fontWeight = FontWeight.ExtraBold
)
}
}
}
@Composable
internal fun GlobalGlyph(
modifier: Modifier,
@@ -432,13 +601,14 @@ internal fun AudioWaveGlyph(
@Composable
internal fun MoreDotsGlyph(
modifier: Modifier
modifier: Modifier,
color: Color = Color(0xFFC9B8D6)
) {
Canvas(modifier = modifier) {
val dotRadius = size.minDimension * 0.07f
val cx = size.width * 0.5f
drawCircle(color = Color(0xFFC9B8D6), radius = dotRadius, center = Offset(cx, size.height * 0.30f))
drawCircle(color = Color(0xFFC9B8D6), radius = dotRadius, center = Offset(cx, size.height * 0.50f))
drawCircle(color = Color(0xFFC9B8D6), radius = dotRadius, center = Offset(cx, size.height * 0.70f))
drawCircle(color = color, radius = dotRadius, center = Offset(cx, size.height * 0.30f))
drawCircle(color = color, radius = dotRadius, center = Offset(cx, size.height * 0.50f))
drawCircle(color = color, radius = dotRadius, center = Offset(cx, size.height * 0.70f))
}
}
@@ -348,6 +348,8 @@ internal fun LibraryContent(
onRetryPlaylists: () -> Unit,
onRetryArtists: () -> Unit,
onLoadMoreArtists: () -> Unit,
onCreatePlaylistClick: () -> Unit,
onPlaylistPreviewNeeded: (PlaylistCard) -> Unit,
onPlaylistClick: (PlaylistCard) -> Unit,
onArtistImageNeeded: (String?) -> Unit,
onArtistClick: (ArtistCard) -> Unit
@@ -375,7 +377,25 @@ internal fun LibraryContent(
}
item(span = { GridItemSpan(maxLineSpan) }) {
SectionTitle("Playlists")
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
SectionTitle("Playlists")
Surface(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.clickable(onClick = onCreatePlaylistClick),
shape = CircleShape,
color = FurumiNeonPink
) {
Box(contentAlignment = Alignment.Center) {
PlusGlyph(Modifier.size(16.dp), FurumiBlack)
}
}
}
}
when {
@@ -407,6 +427,9 @@ internal fun LibraryContent(
) { playlist ->
PlaylistTile(
playlist = playlist,
coverUrls = uiState.playlistPreviewCoverUrls[playlist.id].orEmpty(),
mediaImages = uiState.mediaImages,
onPlaylistPreviewNeeded = onPlaylistPreviewNeeded,
onClick = { onPlaylistClick(playlist) }
)
}
@@ -471,16 +494,27 @@ internal fun LibraryContent(
@Composable
private fun PlaylistTile(
playlist: PlaylistCard,
coverUrls: List<String>,
mediaImages: Map<String, Bitmap>,
onPlaylistPreviewNeeded: (PlaylistCard) -> Unit,
onClick: () -> Unit
) {
LaunchedEffect(playlist.id) {
if (!playlist.isLikesPlaylist()) {
onPlaylistPreviewNeeded(playlist)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
) {
AlbumArtwork(
colors = playlistArtworkColors(playlist),
PlaylistArtwork(
playlist = playlist,
coverUrls = coverUrls,
mediaImages = mediaImages,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
@@ -505,6 +539,85 @@ private fun PlaylistTile(
}
}
@Composable
private fun PlaylistArtwork(
playlist: PlaylistCard,
coverUrls: List<String>,
mediaImages: Map<String, Bitmap>,
modifier: Modifier,
cornerRadius: Int
) {
val artworkShape = RoundedCornerShape(cornerRadius.dp)
val miniArtworkShape = RoundedCornerShape(5.dp)
if (playlist.isLikesPlaylist()) {
Box(
modifier = modifier
.clip(artworkShape)
.background(Brush.linearGradient(listOf(FurumiSurfaceHigh, FurumiNeonViolet, FurumiBlack))),
contentAlignment = Alignment.Center
) {
HeartGlyph(
isLiked = true,
modifier = Modifier.size(86.dp)
)
}
return
}
val previewCoverUrls = coverUrls.take(4)
if (previewCoverUrls.isEmpty()) {
AlbumArtwork(
colors = playlistArtworkColors(playlist),
modifier = modifier,
cornerRadius = cornerRadius
)
return
}
Box(
modifier = modifier
.clip(artworkShape)
.background(FurumiSurfaceHigh)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
repeat(2) { row ->
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
repeat(2) { column ->
val slotIndex = row * 2 + column
val coverUrl = previewCoverUrls.getOrNull(slotIndex)
val bitmap = coverUrl?.let { mediaImageFor(mediaImages, it) }
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(miniArtworkShape)
.background(Brush.linearGradient(artistArtworkColors(playlist.id + slotIndex)))
) {
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "${playlist.title} cover ${slotIndex + 1}",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
}
}
}
}
}
}
@Composable
internal fun PlaylistDetailContent(
uiState: PlayerUiState,
@@ -518,7 +631,8 @@ internal fun PlaylistDetailContent(
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
onAddToPlaylist: (TrackCard) -> Unit,
onDeletePlaylistClick: (PlaylistCard) -> Unit
) {
val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return
val tracks = uiState.playlistDetail?.tracks.orEmpty()
@@ -611,6 +725,24 @@ internal fun PlaylistDetailContent(
)
}
if (playlist.canDeleteFromDetail()) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = { onDeletePlaylistClick(playlist) },
modifier = Modifier
.fillMaxWidth()
.height(44.dp),
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiHotOrange.copy(alpha = 0.72f)),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = FurumiHotOrange,
disabledContentColor = FurumiTextMuted
)
) {
Text("Delete playlist")
}
}
Spacer(modifier = Modifier.height(24.dp))
when {
@@ -871,6 +1003,10 @@ private fun PlaylistCard.isLikesPlaylist(): Boolean {
return id == -1L || kind.equals("likes", ignoreCase = true)
}
private fun PlaylistCard.canDeleteFromDetail(): Boolean {
return isOwn && !isLikesPlaylist()
}
private fun playlistArtworkColors(playlist: PlaylistCard): List<Color> {
return if (playlist.isLikesPlaylist()) {
listOf(FurumiNeonPink, FurumiHotOrange, FurumiBlack)
@@ -12,6 +12,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -27,6 +29,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
@@ -56,6 +59,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import cy.hexor.furumi.domain.model.ArtistCard
@@ -118,6 +122,7 @@ internal fun AddToPlaylistDialog(
playlists: List<PlaylistCard>,
isLoading: Boolean,
onLoadPlaylists: () -> Unit,
onCreatePlaylist: () -> Unit,
onPlaylistSelected: (Long) -> Unit,
onDismiss: () -> Unit
) {
@@ -151,6 +156,30 @@ internal fun AddToPlaylistDialog(
)
Spacer(modifier = Modifier.height(16.dp))
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onCreatePlaylist),
shape = RoundedCornerShape(8.dp),
color = FurumiNeonPink,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlusGlyph(Modifier.size(18.dp), FurumiBlack)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Create new playlist",
style = MaterialTheme.typography.labelLarge,
color = FurumiBlack
)
}
}
Spacer(modifier = Modifier.height(12.dp))
when {
isLoading -> {
Box(
@@ -221,3 +250,208 @@ internal fun AddToPlaylistDialog(
}
}
}
@Composable
internal fun CreatePlaylistDialog(
isCreating: Boolean,
errorMessage: String?,
onSubmit: (String) -> Unit,
onDismiss: () -> Unit
) {
var title by rememberSaveable { mutableStateOf("") }
val trimmedTitle = title.trim()
val canSubmit = trimmedTitle.isNotBlank() && !isCreating
val submit = {
if (canSubmit) {
onSubmit(trimmedTitle)
}
}
Surface(
modifier = Modifier
.fillMaxSize()
.clickable(enabled = !isCreating, onClick = onDismiss),
color = Color.Black.copy(alpha = 0.6f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(320.dp)
.clickable(enabled = false, onClick = {}),
shape = RoundedCornerShape(16.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "Create playlist",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(14.dp))
OutlinedTextField(
value = title,
onValueChange = { title = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
label = { Text("Name") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() })
)
errorMessage?.let { message ->
Spacer(modifier = Modifier.height(10.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiHotOrange
)
}
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = onDismiss,
enabled = !isCreating,
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = FurumiTextMuted
)
) {
Text("Cancel")
}
Button(
onClick = submit,
enabled = canSubmit,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = FurumiNeonPink,
contentColor = FurumiBlack,
disabledContainerColor = FurumiSurface,
disabledContentColor = FurumiTextMuted
)
) {
if (isCreating) {
CircularProgressIndicator(
color = FurumiBlack,
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp)
)
} else {
Text("Create")
}
}
}
}
}
}
}
}
@Composable
internal fun DeletePlaylistDialog(
playlist: PlaylistCard,
isDeleting: Boolean,
errorMessage: String?,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxSize()
.clickable(enabled = !isDeleting, onClick = onDismiss),
color = Color.Black.copy(alpha = 0.6f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(320.dp)
.clickable(enabled = false, onClick = {}),
shape = RoundedCornerShape(16.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "Delete playlist?",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = playlist.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This cannot be undone.",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
errorMessage?.let { message ->
Spacer(modifier = Modifier.height(10.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiHotOrange
)
}
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = onDismiss,
enabled = !isDeleting,
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = FurumiTextMuted
)
) {
Text("Cancel")
}
Button(
onClick = onConfirm,
enabled = !isDeleting,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = FurumiHotOrange,
contentColor = FurumiBlack,
disabledContainerColor = FurumiSurface,
disabledContentColor = FurumiTextMuted
)
) {
if (isDeleting) {
CircularProgressIndicator(
color = FurumiBlack,
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp)
)
} else {
Text("Delete")
}
}
}
}
}
}
}
}
@@ -93,6 +93,8 @@ internal fun FullPlayerOverlay(
onPlayPause: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onToggleShuffle: () -> Unit,
onCycleRepeatMode: () -> Unit,
onSeekToProgress: (Float) -> Unit,
onQueueTrackClick: (TrackCard, Int) -> Unit,
onToggleLike: (Long) -> Unit,
@@ -116,6 +118,8 @@ internal fun FullPlayerOverlay(
val track = playback.currentTrack ?: return
val queue = playback.queue
var isDevicesMenuOpen by rememberSaveable { mutableStateOf(false) }
var currentTrackMenuExpanded by remember { mutableStateOf(false) }
var showPreviousQueue by remember { mutableStateOf(false) }
BackHandler(enabled = isDevicesMenuOpen) {
isDevicesMenuOpen = false
@@ -239,6 +243,44 @@ internal fun FullPlayerOverlay(
)
}
Spacer(modifier = Modifier.width(12.dp))
Box {
RoundControlButton(
size = 42,
background = FurumiSurface,
onClick = { currentTrackMenuExpanded = true }
) {
MoreDotsGlyph(
modifier = Modifier.size(22.dp),
color = MaterialTheme.colorScheme.onBackground
)
}
TrackContextMenu(
expanded = currentTrackMenuExpanded,
isLiked = likedTrackIds.contains(track.id),
onDismiss = { currentTrackMenuExpanded = false },
onToggleLike = {
currentTrackMenuExpanded = false
onToggleLike(track.id)
},
onPlayNext = {
currentTrackMenuExpanded = false
onPlayNext(track)
},
onPlayLast = {
currentTrackMenuExpanded = false
onPlayLast(track)
},
onShare = {
currentTrackMenuExpanded = false
onShare(track)
},
onAddToPlaylist = {
currentTrackMenuExpanded = false
onAddToPlaylist(track)
}
)
}
Spacer(modifier = Modifier.width(12.dp))
RoundControlButton(
size = 42,
background = if (connectedDevicesState?.currentJamId != null) FurumiNeonPink else FurumiSurface,
@@ -335,6 +377,16 @@ internal fun FullPlayerOverlay(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
RoundControlButton(
size = 48,
background = FurumiSurface,
onClick = onToggleShuffle
) {
ShuffleGlyph(
modifier = Modifier.size(20.dp),
color = if (playback.shuffle) FurumiNeonPink else MaterialTheme.colorScheme.onBackground
)
}
RoundControlButton(
size = 48,
background = FurumiSurface,
@@ -360,42 +412,42 @@ internal fun FullPlayerOverlay(
) {
NextGlyph(Modifier.size(20.dp), MaterialTheme.colorScheme.onBackground)
}
RoundControlButton(
size = 48,
background = FurumiSurface,
onClick = onCycleRepeatMode
) {
RepeatGlyph(
modifier = Modifier.size(22.dp),
color = if (playback.repeatMode.equals("off", ignoreCase = true)) {
MaterialTheme.colorScheme.onBackground
} else {
FurumiNeonPink
},
repeatMode = playback.repeatMode
)
}
}
Spacer(modifier = Modifier.height(34.dp))
SectionTitle("Queue")
Spacer(modifier = Modifier.height(12.dp))
if (queue.isEmpty()) {
Text(
text = "Queue is empty",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
} else {
queue.forEachIndexed { index, queueTrack ->
val isPlayed = index < playback.currentIndex
val isCurrent = index == playback.currentIndex
QueueTrackRow(
track = queueTrack,
bitmap = mediaImageFor(mediaImages, queueTrack.coverUrl),
isLiked = likedTrackIds.contains(queueTrack.id),
isPlayed = isPlayed,
isCurrent = isCurrent,
onMediaImageNeeded = onMediaImageNeeded,
onClick = { onQueueTrackClick(queueTrack, index) },
onToggleLike = { onToggleLike(queueTrack.id) },
onPlayNext = { onPlayNext(queueTrack) },
onPlayLast = { onPlayLast(queueTrack) },
onShare = { onShare(queueTrack) },
onAddToPlaylist = { onAddToPlaylist(queueTrack) }
)
if (index < queue.lastIndex) {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
FullPlayerQueue(
queue = queue,
playback = playback,
mediaImages = mediaImages,
likedTrackIds = likedTrackIds,
showPreviousQueue = showPreviousQueue,
onShowPrevious = { showPreviousQueue = true },
onMediaImageNeeded = onMediaImageNeeded,
onQueueTrackClick = onQueueTrackClick,
onToggleLike = onToggleLike,
onPlayNext = onPlayNext,
onPlayLast = onPlayLast,
onShare = onShare,
onAddToPlaylist = onAddToPlaylist
)
}
if (isDevicesMenuOpen) {
@@ -417,6 +469,94 @@ internal fun FullPlayerOverlay(
}
}
@Composable
private fun FullPlayerQueue(
queue: List<TrackCard>,
playback: AudioPlaybackState,
mediaImages: Map<String, Bitmap>,
likedTrackIds: Set<Long>,
showPreviousQueue: Boolean,
onShowPrevious: () -> Unit,
onMediaImageNeeded: (String?) -> Unit,
onQueueTrackClick: (TrackCard, Int) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
if (queue.isEmpty()) {
Text(
text = "Queue is empty",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
return
}
val currentIndex = playback.currentIndex
.takeIf { it in queue.indices }
?: queue.indexOfFirst { it.id == playback.currentTrack?.id }
.takeIf { it >= 0 }
?: 0
val firstVisibleIndex = if (showPreviousQueue) 0 else currentIndex
val visibleQueue = queue
.mapIndexed { index, queueTrack -> index to queueTrack }
.drop(firstVisibleIndex)
if (!showPreviousQueue && currentIndex > 0) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.height(30.dp)
.clip(CircleShape)
.clickable(onClick = onShowPrevious),
shape = CircleShape,
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Box(
modifier = Modifier.padding(horizontal = 14.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Show previous",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
visibleQueue.forEachIndexed { visibleIndex, (index, queueTrack) ->
val isPlayed = index < currentIndex
val isCurrent = index == currentIndex
QueueTrackRow(
track = queueTrack,
bitmap = mediaImageFor(mediaImages, queueTrack.coverUrl),
isLiked = likedTrackIds.contains(queueTrack.id),
isPlayed = isPlayed,
isCurrent = isCurrent,
onMediaImageNeeded = onMediaImageNeeded,
onClick = { onQueueTrackClick(queueTrack, index) },
onToggleLike = { onToggleLike(queueTrack.id) },
onPlayNext = { onPlayNext(queueTrack) },
onPlayLast = { onPlayLast(queueTrack) },
onShare = { onShare(queueTrack) },
onAddToPlaylist = { onAddToPlaylist(queueTrack) }
)
if (visibleIndex < visibleQueue.lastIndex) {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
@Composable
internal fun PlayerProgress(
playback: AudioPlaybackState,
@@ -89,6 +89,16 @@ fun PlayerScreen(
var isProfileMenuOpen by rememberSaveable { mutableStateOf(false) }
var isFullPlayerOpen by rememberSaveable { mutableStateOf(false) }
var fullPlayerDragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
var addToPlaylistTrack by remember { mutableStateOf<TrackCard?>(null) }
var isCreatePlaylistDialogOpen by rememberSaveable { mutableStateOf(false) }
var createPlaylistTrack by remember { mutableStateOf<TrackCard?>(null) }
var deletePlaylistCandidate by remember { mutableStateOf<PlaylistCard?>(null) }
val openCreatePlaylistDialog: (TrackCard?) -> Unit = { track ->
viewModel.clearPlaylistCreateError()
createPlaylistTrack = track
isCreatePlaylistDialogOpen = true
}
BackHandler(
enabled = uiState.selectedPlaylist != null ||
@@ -96,9 +106,24 @@ fun PlayerScreen(
uiState.selectedArtist != null ||
isFullPlayerOpen ||
isProfileMenuOpen ||
uiState.isListeningHistoryVisible
uiState.isListeningHistoryVisible ||
addToPlaylistTrack != null ||
isCreatePlaylistDialogOpen ||
deletePlaylistCandidate != null
) {
when {
deletePlaylistCandidate != null -> {
if (!uiState.isDeletingPlaylist) {
deletePlaylistCandidate = null
}
}
isCreatePlaylistDialogOpen -> {
if (!uiState.isCreatingPlaylist) {
isCreatePlaylistDialogOpen = false
createPlaylistTrack = null
}
}
addToPlaylistTrack != null -> addToPlaylistTrack = null
uiState.isListeningHistoryVisible -> viewModel.closeListeningHistory()
isFullPlayerOpen -> isFullPlayerOpen = false
uiState.selectedPlaylist != null -> viewModel.closePlaylistDetail()
@@ -108,7 +133,6 @@ fun PlayerScreen(
}
}
var addToPlaylistTrack by remember { mutableStateOf<TrackCard?>(null) }
val context = LocalContext.current
LaunchedEffect(uiState.isLoggedOut) {
@@ -180,7 +204,11 @@ fun PlayerScreen(
onPlayNext = viewModel::addToPlayNext,
onPlayLast = viewModel::addToQueueEnd,
onShare = viewModel::shareTrack,
onAddToPlaylist = { track -> addToPlaylistTrack = track }
onAddToPlaylist = { track -> addToPlaylistTrack = track },
onDeletePlaylistClick = { playlist ->
viewModel.clearPlaylistDeleteError()
deletePlaylistCandidate = playlist
}
)
}
} else if (uiState.selectedRelease != null) {
@@ -280,6 +308,8 @@ fun PlayerScreen(
onRetryPlaylists = viewModel::loadPlaylists,
onRetryArtists = viewModel::retryLibraryArtists,
onLoadMoreArtists = viewModel::loadMoreLibraryArtists,
onCreatePlaylistClick = { openCreatePlaylistDialog(null) },
onPlaylistPreviewNeeded = viewModel::loadPlaylistPreview,
onPlaylistClick = viewModel::openPlaylist,
onArtistImageNeeded = viewModel::loadMediaImage,
onArtistClick = viewModel::openArtist
@@ -302,6 +332,8 @@ fun PlayerScreen(
onPlayPause = viewModel::togglePlayPause,
onPrevious = viewModel::previousTrack,
onNext = viewModel::nextTrack,
onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeatMode = viewModel::cycleRepeatMode,
onToggleLike = { viewModel.toggleLike(track.id) }
)
}
@@ -346,6 +378,8 @@ fun PlayerScreen(
onPlayPause = viewModel::togglePlayPause,
onPrevious = viewModel::previousTrack,
onNext = viewModel::nextTrack,
onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeatMode = viewModel::cycleRepeatMode,
onSeekToProgress = viewModel::seekToPlaybackProgress,
onQueueTrackClick = { track, index ->
viewModel.playTrack(track, displayedPlayback.queue, index)
@@ -386,6 +420,10 @@ fun PlayerScreen(
playlists = uiState.playlists,
isLoading = uiState.isPlaylistsLoading,
onLoadPlaylists = viewModel::loadPlaylists,
onCreatePlaylist = {
addToPlaylistTrack = null
openCreatePlaylistDialog(track)
},
onPlaylistSelected = { playlistId ->
viewModel.addTrackToPlaylist(track.id, playlistId)
addToPlaylistTrack = null
@@ -393,6 +431,46 @@ fun PlayerScreen(
onDismiss = { addToPlaylistTrack = null }
)
}
if (isCreatePlaylistDialogOpen) {
CreatePlaylistDialog(
isCreating = uiState.isCreatingPlaylist,
errorMessage = uiState.playlistCreateError,
onSubmit = { title ->
viewModel.createPlaylist(
title = title,
trackIdToAdd = createPlaylistTrack?.id
) {
isCreatePlaylistDialogOpen = false
createPlaylistTrack = null
}
},
onDismiss = {
if (!uiState.isCreatingPlaylist) {
isCreatePlaylistDialogOpen = false
createPlaylistTrack = null
}
}
)
}
deletePlaylistCandidate?.let { playlist ->
DeletePlaylistDialog(
playlist = playlist,
isDeleting = uiState.isDeletingPlaylist,
errorMessage = uiState.playlistDeleteError,
onConfirm = {
viewModel.deletePlaylist(playlist) {
deletePlaylistCandidate = null
}
},
onDismiss = {
if (!uiState.isDeletingPlaylist) {
deletePlaylistCandidate = null
}
}
)
}
}
}
@@ -147,14 +147,10 @@ internal fun releaseArtistsText(artists: List<ArtistCard>): String {
internal fun playlistMeta(playlist: PlaylistCard): String {
val parts = buildList {
playlist.kind
?.replace('_', ' ')
?.takeIf { it.isNotBlank() }
?.let { add(it) }
add(pluralCount(playlist.trackCount, "track"))
playlist.ownerName
?.takeIf { it.isNotBlank() }
?.let { add(it) }
add(pluralCount(playlist.trackCount, "track"))
}
return parts.joinToString(" / ")
@@ -78,6 +78,12 @@ data class PlayerUiState(
val playlists: List<PlaylistCard> = emptyList(),
val isPlaylistsLoading: Boolean = false,
val playlistsError: String? = null,
val playlistPreviewCoverUrls: Map<Long, List<String>> = emptyMap(),
val loadingPlaylistPreviewIds: Set<Long> = emptySet(),
val isCreatingPlaylist: Boolean = false,
val playlistCreateError: String? = null,
val isDeletingPlaylist: Boolean = false,
val playlistDeleteError: String? = null,
val selectedPlaylist: PlaylistCard? = null,
val playlistDetail: PlaylistDetail? = null,
val isPlaylistDetailLoading: Boolean = false,
@@ -107,6 +113,7 @@ class PlayerViewModel @Inject constructor(
private val handledConnectedCommandIdSet = mutableSetOf<String>()
private var wasCurrentDeviceActive = false
private var wasControllingRemoteJam = false
private var hasResolvedStartupActiveDevice = false
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
@@ -418,6 +425,37 @@ class PlayerViewModel @Inject constructor(
playbackController.togglePlayPause()
}
fun toggleShuffle() {
val (currentShuffle, currentRepeatMode) = playbackOptionsForActiveTarget()
activeRemoteDeviceId()?.let { targetDeviceId ->
sendConnectedCommand(
targetDeviceId = targetDeviceId,
command = "set_options",
shuffle = !currentShuffle,
repeatMode = currentRepeatMode
)
return
}
playbackController.setOptions(shuffle = !currentShuffle, repeatMode = null)
}
fun cycleRepeatMode() {
val (currentShuffle, currentRepeatMode) = playbackOptionsForActiveTarget()
val nextRepeatMode = currentRepeatMode.nextPlaybackRepeatMode()
activeRemoteDeviceId()?.let { targetDeviceId ->
sendConnectedCommand(
targetDeviceId = targetDeviceId,
command = "set_options",
shuffle = currentShuffle,
repeatMode = nextRepeatMode
)
return
}
playbackController.setOptions(shuffle = null, repeatMode = nextRepeatMode)
}
fun nextTrack() {
activeRemoteDeviceId()?.let { targetDeviceId ->
val remoteState = _uiState.value.connectedDevicesState?.remotePlaybackState
@@ -698,12 +736,187 @@ class PlayerViewModel @Inject constructor(
loadPlaylists()
}
fun loadPlaylistPreview(playlist: PlaylistCard) {
if (playlist.isLikesPlaylistPreview()) return
val state = _uiState.value
if (state.playlistPreviewCoverUrls.containsKey(playlist.id) ||
state.loadingPlaylistPreviewIds.contains(playlist.id)
) {
return
}
playlistDetailCache[playlist.id]?.let { detail ->
setPlaylistPreviewCoverUrls(playlist.id, detail.previewCoverUrls())
return
}
_uiState.value = state.copy(
loadingPlaylistPreviewIds = state.loadingPlaylistPreviewIds + playlist.id
)
viewModelScope.launch {
playerRepository.getPlaylistDetail(playlist.id)
.onSuccess { detail ->
playlistDetailCache[playlist.id] = detail
setPlaylistPreviewCoverUrls(playlist.id, detail.previewCoverUrls())
}
.onFailure {
setPlaylistPreviewCoverUrls(playlist.id, emptyList())
}
}
}
fun clearPlaylistCreateError() {
if (_uiState.value.playlistCreateError == null) return
_uiState.value = _uiState.value.copy(playlistCreateError = null)
}
fun clearPlaylistDeleteError() {
if (_uiState.value.playlistDeleteError == null) return
_uiState.value = _uiState.value.copy(playlistDeleteError = null)
}
fun createPlaylist(
title: String,
trackIdToAdd: Long? = null,
onCreated: () -> Unit = {}
) {
val playlistTitle = title.trim()
if (playlistTitle.isBlank()) {
_uiState.value = _uiState.value.copy(playlistCreateError = "Playlist name is required")
return
}
if (_uiState.value.isCreatingPlaylist) return
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = true,
playlistCreateError = null
)
viewModelScope.launch {
playerRepository.createPlaylist(playlistTitle)
.onSuccess { playlist ->
if (trackIdToAdd == null) {
addPlaylistToState(playlist)
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = false,
playlistCreateError = null
)
onCreated()
return@launch
}
playerRepository.addTracksToPlaylist(playlist.id, listOf(trackIdToAdd))
.onSuccess {
addPlaylistToState(playlist.copy(trackCount = playlist.trackCount + 1))
playlistDetailCache.remove(playlist.id)
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = false,
playlistCreateError = null
)
onCreated()
}
.onFailure { error ->
addPlaylistToState(playlist)
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = false,
playlistCreateError = error.message
?: "Playlist was created, but the track could not be added"
)
}
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = false,
playlistCreateError = error.message ?: "Unable to create playlist"
)
}
}
}
fun deletePlaylist(
playlist: PlaylistCard,
onDeleted: () -> Unit = {}
) {
if (!playlist.canDeleteUserPlaylist()) return
if (_uiState.value.isDeletingPlaylist) return
_uiState.value = _uiState.value.copy(
isDeletingPlaylist = true,
playlistDeleteError = null
)
viewModelScope.launch {
playerRepository.deletePlaylist(playlist.id)
.onSuccess {
playlistDetailCache.remove(playlist.id)
_uiState.value = _uiState.value.copy(
playlists = _uiState.value.playlists.filterNot { it.id == playlist.id },
playlistPreviewCoverUrls = _uiState.value.playlistPreviewCoverUrls - playlist.id,
loadingPlaylistPreviewIds = _uiState.value.loadingPlaylistPreviewIds - playlist.id,
selectedPlaylist = null,
playlistDetail = null,
isPlaylistDetailLoading = false,
playlistDetailError = null,
isDeletingPlaylist = false,
playlistDeleteError = null
)
onDeleted()
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isDeletingPlaylist = false,
playlistDeleteError = error.message ?: "Unable to delete playlist"
)
}
}
}
fun addTrackToPlaylist(trackId: Long, playlistId: Long) {
viewModelScope.launch {
playerRepository.addTracksToPlaylist(playlistId, listOf(trackId))
.onSuccess {
incrementPlaylistTrackCount(playlistId)
playlistDetailCache.remove(playlistId)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
playlistsError = error.message ?: "Unable to add track to playlist"
)
}
}
}
private fun addPlaylistToState(playlist: PlaylistCard) {
val currentPlaylists = _uiState.value.playlists
_uiState.value = _uiState.value.copy(
playlists = listOf(playlist) + currentPlaylists.filterNot { it.id == playlist.id }
)
}
private fun incrementPlaylistTrackCount(playlistId: Long) {
_uiState.value = _uiState.value.copy(
playlists = _uiState.value.playlists.map { playlist ->
if (playlist.id == playlistId) {
playlist.copy(trackCount = playlist.trackCount + 1)
} else {
playlist
}
}
)
}
private fun setPlaylistPreviewCoverUrls(playlistId: Long, coverUrls: List<String>) {
val distinctCoverUrls = coverUrls.distinct().take(PLAYLIST_PREVIEW_IMAGE_LIMIT)
val state = _uiState.value
_uiState.value = state.copy(
playlistPreviewCoverUrls = state.playlistPreviewCoverUrls + (playlistId to distinctCoverUrls),
loadingPlaylistPreviewIds = state.loadingPlaylistPreviewIds - playlistId
)
distinctCoverUrls.forEach(::loadMediaImage)
}
private fun reportListeningHistory(
trackId: Long,
startedAt: Long,
@@ -795,7 +1008,8 @@ class PlayerViewModel @Inject constructor(
playbackState = playbackState,
currentJamId = state.connectedDevicesState?.currentJamId
).onSuccess { (devicesState, commands) ->
applyConnectedDevicesState(devicesState)
val resolvedDevicesState = resolveStartupActiveDevice(devicesState)
applyConnectedDevicesState(resolvedDevicesState)
commands.forEach { command ->
if (shouldHandleConnectedCommand(command)) {
@@ -809,6 +1023,20 @@ class PlayerViewModel @Inject constructor(
}
}
private suspend fun resolveStartupActiveDevice(devicesState: ConnectedDevicesState): ConnectedDevicesState {
if (hasResolvedStartupActiveDevice) return devicesState
hasResolvedStartupActiveDevice = true
if (!devicesState.shouldCurrentDeviceBecomeActiveOnStartup()) return devicesState
return playerRepository.setActiveDevice(devicesState.deviceId)
.onFailure { error ->
_uiState.value = _uiState.value.copy(
connectedDevicesError = error.message ?: "Unable to switch active device"
)
}
.getOrElse { devicesState }
}
private fun applyConnectedDevicesState(devicesState: ConnectedDevicesState) {
val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive
val isControllingRemoteJam = devicesState.isControllingRemoteJam()
@@ -879,7 +1107,7 @@ class PlayerViewModel @Inject constructor(
playbackController.next()
}
"prev" -> playbackController.previous()
"set_volume" -> payload.volume?.let(playbackController::setVolume)
"set_volume" -> Unit // Android keeps loudness on the system media stream.
"set_options" -> playbackController.setOptions(payload.shuffle, payload.repeatMode)
"queue_add_end" -> playbackController.addToEnd(payload.tracks)
"queue_add_next" -> playbackController.addNext(payload.tracks)
@@ -902,6 +1130,15 @@ class PlayerViewModel @Inject constructor(
return activeDeviceId.takeIf { it != devicesState.deviceId }
}
private fun playbackOptionsForActiveTarget(): Pair<Boolean, String> {
val state = _uiState.value
val remotePlayback = state.connectedDevicesState?.remotePlaybackState
if (activeRemoteDeviceId() != null && remotePlayback != null) {
return remotePlayback.shuffle to remotePlayback.repeatMode
}
return state.playback.shuffle to state.playback.repeatMode
}
private fun sendConnectedCommand(
targetDeviceId: String,
command: String,
@@ -1157,6 +1394,7 @@ class PlayerViewModel @Inject constructor(
isPlaylistDetailLoading = false,
playlistDetailError = null
)
setPlaylistPreviewCoverUrls(playlistId, detail.previewCoverUrls())
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
@@ -1191,7 +1429,7 @@ private fun AudioPlaybackState.toConnectedPlaybackState(): ConnectedPlaybackStat
paused = !isPlaying,
shuffle = shuffle,
repeatMode = repeatMode,
volume = volume.toDouble()
volume = ANDROID_CONNECTED_VOLUME
)
}
@@ -1201,6 +1439,29 @@ private fun ConnectedDevicesState.isControllingRemoteJam(): Boolean {
return currentJam != null && currentJam.isMember && !currentJam.isOwner
}
private fun ConnectedDevicesState.shouldCurrentDeviceBecomeActiveOnStartup(): Boolean {
if (isCurrentDeviceActive || isControllingRemoteJam()) return false
return remotePlaybackState?.isActivelyPlaying() != true
}
private fun ConnectedPlaybackState.isActivelyPlaying(): Boolean {
return !paused && (track != null || tracks.isNotEmpty())
}
private fun PlaylistCard.isLikesPlaylistPreview(): Boolean {
return id == -1L || kind.equals("likes", ignoreCase = true)
}
private fun PlaylistCard.canDeleteUserPlaylist(): Boolean {
return isOwn && !isLikesPlaylistPreview()
}
private fun PlaylistDetail.previewCoverUrls(): List<String> {
return tracks.mapNotNull { track ->
track.coverUrl?.takeIf { it.isNotBlank() }
}.take(PLAYLIST_PREVIEW_IMAGE_LIMIT)
}
private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults {
if (artistCards.isEmpty()) return this
return copy(
@@ -1209,6 +1470,17 @@ private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): S
)
}
private const val ANDROID_CONNECTED_VOLUME = 1.0
private const val PLAYLIST_PREVIEW_IMAGE_LIMIT = 4
private fun String.nextPlaybackRepeatMode(): String {
return when (lowercase()) {
"off" -> "all"
"all" -> "one"
else -> "off"
}
}
private fun String.withoutLineBreaks(): String {
return replace("\r", "").replace("\n", "")
}