Added some controls. Improved playlist page. Fixed 'connected device' handling.
This commit is contained in:
@@ -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", "")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user