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")
|
id("com.google.dagger.hilt.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val furumiVersionCode = providers.gradleProperty("furumi.versionCode").map(String::toInt).get()
|
||||||
|
val furumiVersionName = providers.gradleProperty("furumi.versionName").get()
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "cy.hexor.furumi"
|
namespace = "cy.hexor.furumi"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -13,8 +16,8 @@ android {
|
|||||||
applicationId = "cy.hexor.furumi"
|
applicationId = "cy.hexor.furumi"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = furumiVersionCode
|
||||||
versionName = "1.1"
|
versionName = furumiVersionName
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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.PlayHistoryPageResponse
|
||||||
import cy.hexor.furumi.data.remote.model.ReleaseDetailResponse
|
import cy.hexor.furumi.data.remote.model.ReleaseDetailResponse
|
||||||
import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest
|
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.DeviceActiveRequest
|
||||||
import cy.hexor.furumi.data.remote.model.DeviceCommandRequest
|
import cy.hexor.furumi.data.remote.model.DeviceCommandRequest
|
||||||
import cy.hexor.furumi.data.remote.model.DevicePollRequest
|
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 cy.hexor.furumi.data.remote.model.SharePlaylistResponse
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Headers
|
import retrofit2.http.Headers
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
@@ -98,6 +100,19 @@ interface PlayerApi {
|
|||||||
@Url url: String
|
@Url url: String
|
||||||
): Response<PlaylistDetailResponse>
|
): 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")
|
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||||
@POST
|
@POST
|
||||||
suspend fun addTracksToPlaylist(
|
suspend fun addTracksToPlaylist(
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ data class PlaylistListResponse(
|
|||||||
@param:Json(name = "playlists") val playlists: List<PlaylistCardResponse>
|
@param:Json(name = "playlists") val playlists: List<PlaylistCardResponse>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CreatePlaylistRequest(
|
||||||
|
@param:Json(name = "title") val title: String
|
||||||
|
)
|
||||||
|
|
||||||
data class AddPlaylistTracksRequest(
|
data class AddPlaylistTracksRequest(
|
||||||
@param:Json(name = "track_ids") val trackIds: List<Long>
|
@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(
|
return PlaylistCard(
|
||||||
id = id,
|
id = id,
|
||||||
title = title,
|
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.api.PlayerApi
|
||||||
import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest
|
import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest
|
||||||
import cy.hexor.furumi.data.remote.model.ConnectedCommandPayloadBody
|
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.DeviceActiveRequest
|
||||||
import cy.hexor.furumi.data.remote.model.DeviceCommandRequest
|
import cy.hexor.furumi.data.remote.model.DeviceCommandRequest
|
||||||
import cy.hexor.furumi.data.remote.model.DevicePollRequest
|
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> {
|
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ interface PlayerRepository {
|
|||||||
|
|
||||||
suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail>
|
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 addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit>
|
||||||
|
|
||||||
suspend fun shareTrack(trackId: Long, title: String): Result<String>
|
suspend fun shareTrack(trackId: Long, title: String): Result<String>
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class PlaybackController @Inject constructor(
|
|||||||
)
|
)
|
||||||
setHandleAudioBecomingNoisy(true)
|
setHandleAudioBecomingNoisy(true)
|
||||||
setWakeMode(C.WAKE_MODE_LOCAL)
|
setWakeMode(C.WAKE_MODE_LOCAL)
|
||||||
|
volume = ANDROID_MEDIA_VOLUME_MULTIPLIER
|
||||||
}
|
}
|
||||||
|
|
||||||
player.addListener(
|
player.addListener(
|
||||||
@@ -249,11 +250,6 @@ class PlaybackController @Inject constructor(
|
|||||||
publishState()
|
publishState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVolume(volume: Double) {
|
|
||||||
player.volume = volume.toFloat().coerceIn(0f, 1f)
|
|
||||||
publishState()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setOptions(shuffle: Boolean?, repeatMode: String?) {
|
fun setOptions(shuffle: Boolean?, repeatMode: String?) {
|
||||||
shuffle?.let { player.shuffleModeEnabled = it }
|
shuffle?.let { player.shuffleModeEnabled = it }
|
||||||
repeatMode?.let { player.repeatMode = it.toPlayerRepeatMode() }
|
repeatMode?.let { player.repeatMode = it.toPlayerRepeatMode() }
|
||||||
@@ -267,7 +263,6 @@ class PlaybackController @Inject constructor(
|
|||||||
if (queue.isEmpty()) return
|
if (queue.isEmpty()) return
|
||||||
|
|
||||||
setOptions(playbackState.shuffle, playbackState.repeatMode)
|
setOptions(playbackState.shuffle, playbackState.repeatMode)
|
||||||
setVolume(playbackState.volume)
|
|
||||||
playQueue(queue, playbackState.index.coerceIn(0, queue.lastIndex))
|
playQueue(queue, playbackState.index.coerceIn(0, queue.lastIndex))
|
||||||
player.seekTo((playbackState.positionSeconds.coerceAtLeast(0.0) * 1_000.0).toLong())
|
player.seekTo((playbackState.positionSeconds.coerceAtLeast(0.0) * 1_000.0).toLong())
|
||||||
if (playbackState.paused) {
|
if (playbackState.paused) {
|
||||||
@@ -477,6 +472,7 @@ class PlaybackController @Inject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val PROGRESS_TICK_MS = 500L
|
const val PROGRESS_TICK_MS = 500L
|
||||||
const val RESTART_WINDOW_MS = 3_000L
|
const val RESTART_WINDOW_MS = 3_000L
|
||||||
|
const val ANDROID_MEDIA_VOLUME_MULTIPLIER = 1f
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPlaybackService() {
|
private fun startPlaybackService() {
|
||||||
|
|||||||
@@ -385,6 +385,8 @@ internal fun NowPlayingBar(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
|
onToggleShuffle: () -> Unit,
|
||||||
|
onCycleRepeatMode: () -> Unit,
|
||||||
onToggleLike: () -> Unit
|
onToggleLike: () -> Unit
|
||||||
) {
|
) {
|
||||||
val track = playback.currentTrack ?: return
|
val track = playback.currentTrack ?: return
|
||||||
@@ -433,6 +435,12 @@ internal fun NowPlayingBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
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) {
|
MiniPlayerIconButton(onClick = onPrevious) {
|
||||||
PreviousGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
|
PreviousGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
|
||||||
}
|
}
|
||||||
@@ -450,6 +458,17 @@ internal fun NowPlayingBar(
|
|||||||
MiniPlayerIconButton(onClick = onNext) {
|
MiniPlayerIconButton(onClick = onNext) {
|
||||||
NextGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
|
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) {
|
MiniPlayerIconButton(onClick = onToggleLike) {
|
||||||
HeartGlyph(
|
HeartGlyph(
|
||||||
isLiked = isLiked,
|
isLiked = isLiked,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import cy.hexor.furumi.domain.model.ArtistCard
|
import cy.hexor.furumi.domain.model.ArtistCard
|
||||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
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
|
@Composable
|
||||||
internal fun BackGlyph(
|
internal fun BackGlyph(
|
||||||
modifier: Modifier,
|
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
|
@Composable
|
||||||
internal fun GlobalGlyph(
|
internal fun GlobalGlyph(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
@@ -432,13 +601,14 @@ internal fun AudioWaveGlyph(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MoreDotsGlyph(
|
internal fun MoreDotsGlyph(
|
||||||
modifier: Modifier
|
modifier: Modifier,
|
||||||
|
color: Color = Color(0xFFC9B8D6)
|
||||||
) {
|
) {
|
||||||
Canvas(modifier = modifier) {
|
Canvas(modifier = modifier) {
|
||||||
val dotRadius = size.minDimension * 0.07f
|
val dotRadius = size.minDimension * 0.07f
|
||||||
val cx = size.width * 0.5f
|
val cx = size.width * 0.5f
|
||||||
drawCircle(color = Color(0xFFC9B8D6), radius = dotRadius, center = Offset(cx, size.height * 0.30f))
|
drawCircle(color = color, 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, 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.70f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ internal fun LibraryContent(
|
|||||||
onRetryPlaylists: () -> Unit,
|
onRetryPlaylists: () -> Unit,
|
||||||
onRetryArtists: () -> Unit,
|
onRetryArtists: () -> Unit,
|
||||||
onLoadMoreArtists: () -> Unit,
|
onLoadMoreArtists: () -> Unit,
|
||||||
|
onCreatePlaylistClick: () -> Unit,
|
||||||
|
onPlaylistPreviewNeeded: (PlaylistCard) -> Unit,
|
||||||
onPlaylistClick: (PlaylistCard) -> Unit,
|
onPlaylistClick: (PlaylistCard) -> Unit,
|
||||||
onArtistImageNeeded: (String?) -> Unit,
|
onArtistImageNeeded: (String?) -> Unit,
|
||||||
onArtistClick: (ArtistCard) -> Unit
|
onArtistClick: (ArtistCard) -> Unit
|
||||||
@@ -375,7 +377,25 @@ internal fun LibraryContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
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 {
|
when {
|
||||||
@@ -407,6 +427,9 @@ internal fun LibraryContent(
|
|||||||
) { playlist ->
|
) { playlist ->
|
||||||
PlaylistTile(
|
PlaylistTile(
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
|
coverUrls = uiState.playlistPreviewCoverUrls[playlist.id].orEmpty(),
|
||||||
|
mediaImages = uiState.mediaImages,
|
||||||
|
onPlaylistPreviewNeeded = onPlaylistPreviewNeeded,
|
||||||
onClick = { onPlaylistClick(playlist) }
|
onClick = { onPlaylistClick(playlist) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -471,16 +494,27 @@ internal fun LibraryContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PlaylistTile(
|
private fun PlaylistTile(
|
||||||
playlist: PlaylistCard,
|
playlist: PlaylistCard,
|
||||||
|
coverUrls: List<String>,
|
||||||
|
mediaImages: Map<String, Bitmap>,
|
||||||
|
onPlaylistPreviewNeeded: (PlaylistCard) -> Unit,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
LaunchedEffect(playlist.id) {
|
||||||
|
if (!playlist.isLikesPlaylist()) {
|
||||||
|
onPlaylistPreviewNeeded(playlist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
) {
|
) {
|
||||||
AlbumArtwork(
|
PlaylistArtwork(
|
||||||
colors = playlistArtworkColors(playlist),
|
playlist = playlist,
|
||||||
|
coverUrls = coverUrls,
|
||||||
|
mediaImages = mediaImages,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(1f),
|
.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
|
@Composable
|
||||||
internal fun PlaylistDetailContent(
|
internal fun PlaylistDetailContent(
|
||||||
uiState: PlayerUiState,
|
uiState: PlayerUiState,
|
||||||
@@ -518,7 +631,8 @@ internal fun PlaylistDetailContent(
|
|||||||
onPlayNext: (TrackCard) -> Unit,
|
onPlayNext: (TrackCard) -> Unit,
|
||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
onDeletePlaylistClick: (PlaylistCard) -> Unit
|
||||||
) {
|
) {
|
||||||
val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return
|
val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return
|
||||||
val tracks = uiState.playlistDetail?.tracks.orEmpty()
|
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))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -871,6 +1003,10 @@ private fun PlaylistCard.isLikesPlaylist(): Boolean {
|
|||||||
return id == -1L || kind.equals("likes", ignoreCase = true)
|
return id == -1L || kind.equals("likes", ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun PlaylistCard.canDeleteFromDetail(): Boolean {
|
||||||
|
return isOwn && !isLikesPlaylist()
|
||||||
|
}
|
||||||
|
|
||||||
private fun playlistArtworkColors(playlist: PlaylistCard): List<Color> {
|
private fun playlistArtworkColors(playlist: PlaylistCard): List<Color> {
|
||||||
return if (playlist.isLikesPlaylist()) {
|
return if (playlist.isLikesPlaylist()) {
|
||||||
listOf(FurumiNeonPink, FurumiHotOrange, FurumiBlack)
|
listOf(FurumiNeonPink, FurumiHotOrange, FurumiBlack)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.*
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
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.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Surface
|
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.layout.ContentScale
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import cy.hexor.furumi.domain.model.ArtistCard
|
import cy.hexor.furumi.domain.model.ArtistCard
|
||||||
@@ -118,6 +122,7 @@ internal fun AddToPlaylistDialog(
|
|||||||
playlists: List<PlaylistCard>,
|
playlists: List<PlaylistCard>,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
onLoadPlaylists: () -> Unit,
|
onLoadPlaylists: () -> Unit,
|
||||||
|
onCreatePlaylist: () -> Unit,
|
||||||
onPlaylistSelected: (Long) -> Unit,
|
onPlaylistSelected: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -151,6 +156,30 @@ internal fun AddToPlaylistDialog(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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 {
|
when {
|
||||||
isLoading -> {
|
isLoading -> {
|
||||||
Box(
|
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,
|
onPlayPause: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
|
onToggleShuffle: () -> Unit,
|
||||||
|
onCycleRepeatMode: () -> Unit,
|
||||||
onSeekToProgress: (Float) -> Unit,
|
onSeekToProgress: (Float) -> Unit,
|
||||||
onQueueTrackClick: (TrackCard, Int) -> Unit,
|
onQueueTrackClick: (TrackCard, Int) -> Unit,
|
||||||
onToggleLike: (Long) -> Unit,
|
onToggleLike: (Long) -> Unit,
|
||||||
@@ -116,6 +118,8 @@ internal fun FullPlayerOverlay(
|
|||||||
val track = playback.currentTrack ?: return
|
val track = playback.currentTrack ?: return
|
||||||
val queue = playback.queue
|
val queue = playback.queue
|
||||||
var isDevicesMenuOpen by rememberSaveable { mutableStateOf(false) }
|
var isDevicesMenuOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var currentTrackMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
var showPreviousQueue by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
BackHandler(enabled = isDevicesMenuOpen) {
|
BackHandler(enabled = isDevicesMenuOpen) {
|
||||||
isDevicesMenuOpen = false
|
isDevicesMenuOpen = false
|
||||||
@@ -239,6 +243,44 @@ internal fun FullPlayerOverlay(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
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(
|
RoundControlButton(
|
||||||
size = 42,
|
size = 42,
|
||||||
background = if (connectedDevicesState?.currentJamId != null) FurumiNeonPink else FurumiSurface,
|
background = if (connectedDevicesState?.currentJamId != null) FurumiNeonPink else FurumiSurface,
|
||||||
@@ -335,6 +377,16 @@ internal fun FullPlayerOverlay(
|
|||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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(
|
RoundControlButton(
|
||||||
size = 48,
|
size = 48,
|
||||||
background = FurumiSurface,
|
background = FurumiSurface,
|
||||||
@@ -360,42 +412,42 @@ internal fun FullPlayerOverlay(
|
|||||||
) {
|
) {
|
||||||
NextGlyph(Modifier.size(20.dp), MaterialTheme.colorScheme.onBackground)
|
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))
|
Spacer(modifier = Modifier.height(34.dp))
|
||||||
|
|
||||||
SectionTitle("Queue")
|
SectionTitle("Queue")
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
if (queue.isEmpty()) {
|
FullPlayerQueue(
|
||||||
Text(
|
queue = queue,
|
||||||
text = "Queue is empty",
|
playback = playback,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
mediaImages = mediaImages,
|
||||||
color = FurumiTextMuted
|
likedTrackIds = likedTrackIds,
|
||||||
)
|
showPreviousQueue = showPreviousQueue,
|
||||||
} else {
|
onShowPrevious = { showPreviousQueue = true },
|
||||||
queue.forEachIndexed { index, queueTrack ->
|
onMediaImageNeeded = onMediaImageNeeded,
|
||||||
val isPlayed = index < playback.currentIndex
|
onQueueTrackClick = onQueueTrackClick,
|
||||||
val isCurrent = index == playback.currentIndex
|
onToggleLike = onToggleLike,
|
||||||
|
onPlayNext = onPlayNext,
|
||||||
QueueTrackRow(
|
onPlayLast = onPlayLast,
|
||||||
track = queueTrack,
|
onShare = onShare,
|
||||||
bitmap = mediaImageFor(mediaImages, queueTrack.coverUrl),
|
onAddToPlaylist = onAddToPlaylist
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDevicesMenuOpen) {
|
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
|
@Composable
|
||||||
internal fun PlayerProgress(
|
internal fun PlayerProgress(
|
||||||
playback: AudioPlaybackState,
|
playback: AudioPlaybackState,
|
||||||
|
|||||||
@@ -89,6 +89,16 @@ fun PlayerScreen(
|
|||||||
var isProfileMenuOpen by rememberSaveable { mutableStateOf(false) }
|
var isProfileMenuOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
var isFullPlayerOpen by rememberSaveable { mutableStateOf(false) }
|
var isFullPlayerOpen by rememberSaveable { mutableStateOf(false) }
|
||||||
var fullPlayerDragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
|
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(
|
BackHandler(
|
||||||
enabled = uiState.selectedPlaylist != null ||
|
enabled = uiState.selectedPlaylist != null ||
|
||||||
@@ -96,9 +106,24 @@ fun PlayerScreen(
|
|||||||
uiState.selectedArtist != null ||
|
uiState.selectedArtist != null ||
|
||||||
isFullPlayerOpen ||
|
isFullPlayerOpen ||
|
||||||
isProfileMenuOpen ||
|
isProfileMenuOpen ||
|
||||||
uiState.isListeningHistoryVisible
|
uiState.isListeningHistoryVisible ||
|
||||||
|
addToPlaylistTrack != null ||
|
||||||
|
isCreatePlaylistDialogOpen ||
|
||||||
|
deletePlaylistCandidate != null
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
|
deletePlaylistCandidate != null -> {
|
||||||
|
if (!uiState.isDeletingPlaylist) {
|
||||||
|
deletePlaylistCandidate = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isCreatePlaylistDialogOpen -> {
|
||||||
|
if (!uiState.isCreatingPlaylist) {
|
||||||
|
isCreatePlaylistDialogOpen = false
|
||||||
|
createPlaylistTrack = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addToPlaylistTrack != null -> addToPlaylistTrack = null
|
||||||
uiState.isListeningHistoryVisible -> viewModel.closeListeningHistory()
|
uiState.isListeningHistoryVisible -> viewModel.closeListeningHistory()
|
||||||
isFullPlayerOpen -> isFullPlayerOpen = false
|
isFullPlayerOpen -> isFullPlayerOpen = false
|
||||||
uiState.selectedPlaylist != null -> viewModel.closePlaylistDetail()
|
uiState.selectedPlaylist != null -> viewModel.closePlaylistDetail()
|
||||||
@@ -108,7 +133,6 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var addToPlaylistTrack by remember { mutableStateOf<TrackCard?>(null) }
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(uiState.isLoggedOut) {
|
LaunchedEffect(uiState.isLoggedOut) {
|
||||||
@@ -180,7 +204,11 @@ fun PlayerScreen(
|
|||||||
onPlayNext = viewModel::addToPlayNext,
|
onPlayNext = viewModel::addToPlayNext,
|
||||||
onPlayLast = viewModel::addToQueueEnd,
|
onPlayLast = viewModel::addToQueueEnd,
|
||||||
onShare = viewModel::shareTrack,
|
onShare = viewModel::shareTrack,
|
||||||
onAddToPlaylist = { track -> addToPlaylistTrack = track }
|
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||||
|
onDeletePlaylistClick = { playlist ->
|
||||||
|
viewModel.clearPlaylistDeleteError()
|
||||||
|
deletePlaylistCandidate = playlist
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (uiState.selectedRelease != null) {
|
} else if (uiState.selectedRelease != null) {
|
||||||
@@ -280,6 +308,8 @@ fun PlayerScreen(
|
|||||||
onRetryPlaylists = viewModel::loadPlaylists,
|
onRetryPlaylists = viewModel::loadPlaylists,
|
||||||
onRetryArtists = viewModel::retryLibraryArtists,
|
onRetryArtists = viewModel::retryLibraryArtists,
|
||||||
onLoadMoreArtists = viewModel::loadMoreLibraryArtists,
|
onLoadMoreArtists = viewModel::loadMoreLibraryArtists,
|
||||||
|
onCreatePlaylistClick = { openCreatePlaylistDialog(null) },
|
||||||
|
onPlaylistPreviewNeeded = viewModel::loadPlaylistPreview,
|
||||||
onPlaylistClick = viewModel::openPlaylist,
|
onPlaylistClick = viewModel::openPlaylist,
|
||||||
onArtistImageNeeded = viewModel::loadMediaImage,
|
onArtistImageNeeded = viewModel::loadMediaImage,
|
||||||
onArtistClick = viewModel::openArtist
|
onArtistClick = viewModel::openArtist
|
||||||
@@ -302,6 +332,8 @@ fun PlayerScreen(
|
|||||||
onPlayPause = viewModel::togglePlayPause,
|
onPlayPause = viewModel::togglePlayPause,
|
||||||
onPrevious = viewModel::previousTrack,
|
onPrevious = viewModel::previousTrack,
|
||||||
onNext = viewModel::nextTrack,
|
onNext = viewModel::nextTrack,
|
||||||
|
onToggleShuffle = viewModel::toggleShuffle,
|
||||||
|
onCycleRepeatMode = viewModel::cycleRepeatMode,
|
||||||
onToggleLike = { viewModel.toggleLike(track.id) }
|
onToggleLike = { viewModel.toggleLike(track.id) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -346,6 +378,8 @@ fun PlayerScreen(
|
|||||||
onPlayPause = viewModel::togglePlayPause,
|
onPlayPause = viewModel::togglePlayPause,
|
||||||
onPrevious = viewModel::previousTrack,
|
onPrevious = viewModel::previousTrack,
|
||||||
onNext = viewModel::nextTrack,
|
onNext = viewModel::nextTrack,
|
||||||
|
onToggleShuffle = viewModel::toggleShuffle,
|
||||||
|
onCycleRepeatMode = viewModel::cycleRepeatMode,
|
||||||
onSeekToProgress = viewModel::seekToPlaybackProgress,
|
onSeekToProgress = viewModel::seekToPlaybackProgress,
|
||||||
onQueueTrackClick = { track, index ->
|
onQueueTrackClick = { track, index ->
|
||||||
viewModel.playTrack(track, displayedPlayback.queue, index)
|
viewModel.playTrack(track, displayedPlayback.queue, index)
|
||||||
@@ -386,6 +420,10 @@ fun PlayerScreen(
|
|||||||
playlists = uiState.playlists,
|
playlists = uiState.playlists,
|
||||||
isLoading = uiState.isPlaylistsLoading,
|
isLoading = uiState.isPlaylistsLoading,
|
||||||
onLoadPlaylists = viewModel::loadPlaylists,
|
onLoadPlaylists = viewModel::loadPlaylists,
|
||||||
|
onCreatePlaylist = {
|
||||||
|
addToPlaylistTrack = null
|
||||||
|
openCreatePlaylistDialog(track)
|
||||||
|
},
|
||||||
onPlaylistSelected = { playlistId ->
|
onPlaylistSelected = { playlistId ->
|
||||||
viewModel.addTrackToPlaylist(track.id, playlistId)
|
viewModel.addTrackToPlaylist(track.id, playlistId)
|
||||||
addToPlaylistTrack = null
|
addToPlaylistTrack = null
|
||||||
@@ -393,6 +431,46 @@ fun PlayerScreen(
|
|||||||
onDismiss = { addToPlaylistTrack = null }
|
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 {
|
internal fun playlistMeta(playlist: PlaylistCard): String {
|
||||||
val parts = buildList {
|
val parts = buildList {
|
||||||
playlist.kind
|
add(pluralCount(playlist.trackCount, "track"))
|
||||||
?.replace('_', ' ')
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.let { add(it) }
|
|
||||||
playlist.ownerName
|
playlist.ownerName
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
?.let { add(it) }
|
?.let { add(it) }
|
||||||
add(pluralCount(playlist.trackCount, "track"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.joinToString(" / ")
|
return parts.joinToString(" / ")
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ data class PlayerUiState(
|
|||||||
val playlists: List<PlaylistCard> = emptyList(),
|
val playlists: List<PlaylistCard> = emptyList(),
|
||||||
val isPlaylistsLoading: Boolean = false,
|
val isPlaylistsLoading: Boolean = false,
|
||||||
val playlistsError: String? = null,
|
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 selectedPlaylist: PlaylistCard? = null,
|
||||||
val playlistDetail: PlaylistDetail? = null,
|
val playlistDetail: PlaylistDetail? = null,
|
||||||
val isPlaylistDetailLoading: Boolean = false,
|
val isPlaylistDetailLoading: Boolean = false,
|
||||||
@@ -107,6 +113,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
||||||
private var wasCurrentDeviceActive = false
|
private var wasCurrentDeviceActive = false
|
||||||
private var wasControllingRemoteJam = false
|
private var wasControllingRemoteJam = false
|
||||||
|
private var hasResolvedStartupActiveDevice = false
|
||||||
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
||||||
|
|
||||||
@@ -418,6 +425,37 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playbackController.togglePlayPause()
|
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() {
|
fun nextTrack() {
|
||||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||||
val remoteState = _uiState.value.connectedDevicesState?.remotePlaybackState
|
val remoteState = _uiState.value.connectedDevicesState?.remotePlaybackState
|
||||||
@@ -698,12 +736,187 @@ class PlayerViewModel @Inject constructor(
|
|||||||
loadPlaylists()
|
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) {
|
fun addTrackToPlaylist(trackId: Long, playlistId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerRepository.addTracksToPlaylist(playlistId, listOf(trackId))
|
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(
|
private fun reportListeningHistory(
|
||||||
trackId: Long,
|
trackId: Long,
|
||||||
startedAt: Long,
|
startedAt: Long,
|
||||||
@@ -795,7 +1008,8 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playbackState = playbackState,
|
playbackState = playbackState,
|
||||||
currentJamId = state.connectedDevicesState?.currentJamId
|
currentJamId = state.connectedDevicesState?.currentJamId
|
||||||
).onSuccess { (devicesState, commands) ->
|
).onSuccess { (devicesState, commands) ->
|
||||||
applyConnectedDevicesState(devicesState)
|
val resolvedDevicesState = resolveStartupActiveDevice(devicesState)
|
||||||
|
applyConnectedDevicesState(resolvedDevicesState)
|
||||||
|
|
||||||
commands.forEach { command ->
|
commands.forEach { command ->
|
||||||
if (shouldHandleConnectedCommand(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) {
|
private fun applyConnectedDevicesState(devicesState: ConnectedDevicesState) {
|
||||||
val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive
|
val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive
|
||||||
val isControllingRemoteJam = devicesState.isControllingRemoteJam()
|
val isControllingRemoteJam = devicesState.isControllingRemoteJam()
|
||||||
@@ -879,7 +1107,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playbackController.next()
|
playbackController.next()
|
||||||
}
|
}
|
||||||
"prev" -> playbackController.previous()
|
"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)
|
"set_options" -> playbackController.setOptions(payload.shuffle, payload.repeatMode)
|
||||||
"queue_add_end" -> playbackController.addToEnd(payload.tracks)
|
"queue_add_end" -> playbackController.addToEnd(payload.tracks)
|
||||||
"queue_add_next" -> playbackController.addNext(payload.tracks)
|
"queue_add_next" -> playbackController.addNext(payload.tracks)
|
||||||
@@ -902,6 +1130,15 @@ class PlayerViewModel @Inject constructor(
|
|||||||
return activeDeviceId.takeIf { it != devicesState.deviceId }
|
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(
|
private fun sendConnectedCommand(
|
||||||
targetDeviceId: String,
|
targetDeviceId: String,
|
||||||
command: String,
|
command: String,
|
||||||
@@ -1157,6 +1394,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
isPlaylistDetailLoading = false,
|
isPlaylistDetailLoading = false,
|
||||||
playlistDetailError = null
|
playlistDetailError = null
|
||||||
)
|
)
|
||||||
|
setPlaylistPreviewCoverUrls(playlistId, detail.previewCoverUrls())
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
@@ -1191,7 +1429,7 @@ private fun AudioPlaybackState.toConnectedPlaybackState(): ConnectedPlaybackStat
|
|||||||
paused = !isPlaying,
|
paused = !isPlaying,
|
||||||
shuffle = shuffle,
|
shuffle = shuffle,
|
||||||
repeatMode = repeatMode,
|
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
|
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 {
|
private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults {
|
||||||
if (artistCards.isEmpty()) return this
|
if (artistCards.isEmpty()) return this
|
||||||
return copy(
|
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 {
|
private fun String.withoutLineBreaks(): String {
|
||||||
return replace("\r", "").replace("\n", "")
|
return replace("\r", "").replace("\n", "")
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -28,14 +28,14 @@ RepoType: git
|
|||||||
Repo: https://gt.hexor.cy/ab/furumi_android.git
|
Repo: https://gt.hexor.cy/ab/furumi_android.git
|
||||||
|
|
||||||
Builds:
|
Builds:
|
||||||
- versionName: '1.1'
|
- versionName: '1.2'
|
||||||
versionCode: 1
|
versionCode: 2
|
||||||
commit: v1.1
|
commit: v1.2
|
||||||
subdir: app
|
subdir: app
|
||||||
gradle:
|
gradle:
|
||||||
- yes
|
- yes
|
||||||
|
|
||||||
AutoUpdateMode: Version v%v
|
AutoUpdateMode: Version v%v
|
||||||
UpdateCheckMode: Tags
|
UpdateCheckMode: Tags
|
||||||
CurrentVersion: '1.1'
|
CurrentVersion: '1.2'
|
||||||
CurrentVersionCode: 1
|
CurrentVersionCode: 2
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Playback controls, playlist management, playlist artwork, and Android-local volume improvements.
|
||||||
@@ -18,3 +18,7 @@ org.gradle.configuration-cache=true
|
|||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.disallowKotlinSourceSets=false
|
android.disallowKotlinSourceSets=false
|
||||||
|
|
||||||
|
# App version used by Gradle for the APK/AAB manifest and BuildConfig.
|
||||||
|
furumi.versionCode=2
|
||||||
|
furumi.versionName=1.2
|
||||||
|
|||||||
Reference in New Issue
Block a user