diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c72174..565fdfd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/src/main/java/cy/hexor/furumi/data/remote/api/PlayerApi.kt b/app/src/main/java/cy/hexor/furumi/data/remote/api/PlayerApi.kt index 0c04b74..2e6ddbe 100644 --- a/app/src/main/java/cy/hexor/furumi/data/remote/api/PlayerApi.kt +++ b/app/src/main/java/cy/hexor/furumi/data/remote/api/PlayerApi.kt @@ -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 + @Headers("Accept: application/json", "Content-Type: application/json") + @POST + suspend fun createPlaylist( + @Url url: String, + @Body body: CreatePlaylistRequest + ): Response + + @Headers("Accept: application/json") + @DELETE + suspend fun deletePlaylist( + @Url url: String + ): Response + @Headers("Accept: application/json", "Content-Type: application/json") @POST suspend fun addTracksToPlaylist( diff --git a/app/src/main/java/cy/hexor/furumi/data/remote/model/PlayerResponses.kt b/app/src/main/java/cy/hexor/furumi/data/remote/model/PlayerResponses.kt index a19872b..cbd5cb7 100644 --- a/app/src/main/java/cy/hexor/furumi/data/remote/model/PlayerResponses.kt +++ b/app/src/main/java/cy/hexor/furumi/data/remote/model/PlayerResponses.kt @@ -128,6 +128,10 @@ data class PlaylistListResponse( @param:Json(name = "playlists") val playlists: List ) +data class CreatePlaylistRequest( + @param:Json(name = "title") val title: String +) + data class AddPlaylistTracksRequest( @param:Json(name = "track_ids") val trackIds: List ) @@ -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, diff --git a/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt b/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt index 32b2a02..3f7873a 100644 --- a/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt +++ b/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt @@ -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 { + 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 { + 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): Result { return try { val baseUrl = sessionStorage.getBaseUrl() diff --git a/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt b/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt index e4b34b7..0d8b0f3 100644 --- a/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt +++ b/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt @@ -43,6 +43,10 @@ interface PlayerRepository { suspend fun getPlaylistDetail(playlistId: Long): Result + suspend fun createPlaylist(title: String): Result + + suspend fun deletePlaylist(playlistId: Long): Result + suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List): Result suspend fun shareTrack(trackId: Long, title: String): Result diff --git a/app/src/main/java/cy/hexor/furumi/playback/PlaybackController.kt b/app/src/main/java/cy/hexor/furumi/playback/PlaybackController.kt index 3ed4292..1ee9b00 100644 --- a/app/src/main/java/cy/hexor/furumi/playback/PlaybackController.kt +++ b/app/src/main/java/cy/hexor/furumi/playback/PlaybackController.kt @@ -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() { diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerChrome.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerChrome.kt index 1db1f9b..6d431e5 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerChrome.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerChrome.kt @@ -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, diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerIcons.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerIcons.kt index 71e7b0b..a1135ee 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerIcons.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerIcons.kt @@ -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)) } } diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerLibraryContent.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerLibraryContent.kt index da30ea8..4e34162 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerLibraryContent.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerLibraryContent.kt @@ -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, + mediaImages: Map, + 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, + mediaImages: Map, + 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 { return if (playlist.isLikesPlaylist()) { listOf(FurumiNeonPink, FurumiHotOrange, FurumiBlack) diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerMenus.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerMenus.kt index b6972ca..3b5497e 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerMenus.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerMenus.kt @@ -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, 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") + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerOverlay.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerOverlay.kt index e885f5b..8a575be 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerOverlay.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerOverlay.kt @@ -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, + playback: AudioPlaybackState, + mediaImages: Map, + likedTrackIds: Set, + 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, diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt index 4261e54..821f791 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt @@ -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(null) } + var isCreatePlaylistDialogOpen by rememberSaveable { mutableStateOf(false) } + var createPlaylistTrack by remember { mutableStateOf(null) } + var deletePlaylistCandidate by remember { mutableStateOf(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(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 + } + } + ) + } } } diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerUiUtils.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerUiUtils.kt index 4137bc9..774657e 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerUiUtils.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerUiUtils.kt @@ -147,14 +147,10 @@ internal fun releaseArtistsText(artists: List): 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(" / ") diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt index fa37d7e..788ae2f 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt @@ -78,6 +78,12 @@ data class PlayerUiState( val playlists: List = emptyList(), val isPlaylistsLoading: Boolean = false, val playlistsError: String? = null, + val playlistPreviewCoverUrls: Map> = emptyMap(), + val loadingPlaylistPreviewIds: Set = 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() private var wasCurrentDeviceActive = false private var wasControllingRemoteJam = false + private var hasResolvedStartupActiveDevice = false private val _shareEvent = MutableSharedFlow(extraBufferCapacity = 1) val shareEvent: SharedFlow = _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) { + 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 { + 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 { + return tracks.mapNotNull { track -> + track.coverUrl?.takeIf { it.isNotBlank() } + }.take(PLAYLIST_PREVIEW_IMAGE_LIMIT) +} + private fun SearchResults.withArtistCards(artistCards: Map): SearchResults { if (artistCards.isEmpty()) return this return copy( @@ -1209,6 +1470,17 @@ private fun SearchResults.withArtistCards(artistCards: Map): 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", "") } diff --git a/cy.hexor.furumi.yml b/cy.hexor.furumi.yml index d9f4e56..4553981 100644 --- a/cy.hexor.furumi.yml +++ b/cy.hexor.furumi.yml @@ -28,14 +28,14 @@ RepoType: git Repo: https://gt.hexor.cy/ab/furumi_android.git Builds: - - versionName: '1.1' - versionCode: 1 - commit: v1.1 + - versionName: '1.2' + versionCode: 2 + commit: v1.2 subdir: app gradle: - yes AutoUpdateMode: Version v%v UpdateCheckMode: Tags -CurrentVersion: '1.1' -CurrentVersionCode: 1 +CurrentVersion: '1.2' +CurrentVersionCode: 2 diff --git a/fastlane/metadata/android/en-US/changelogs/2.txt b/fastlane/metadata/android/en-US/changelogs/2.txt new file mode 100644 index 0000000..8faa3ad --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2.txt @@ -0,0 +1 @@ +Playback controls, playlist management, playlist artwork, and Android-local volume improvements. diff --git a/gradle.properties b/gradle.properties index 581b675..d5e09a6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,3 +18,7 @@ org.gradle.configuration-cache=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official android.disallowKotlinSourceSets=false + +# App version used by Gradle for the APK/AAB manifest and BuildConfig. +furumi.versionCode=2 +furumi.versionName=1.2