Fix queue display, reworked connected devices

This commit is contained in:
Ultradesu
2026-06-08 18:36:48 +01:00
parent bc3d3f654e
commit 9d49bd85cd
27 changed files with 5978 additions and 69 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionName = "1.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -0,0 +1,32 @@
package com.example.furumi_android.data.local
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectedDeviceStorage @Inject constructor(
@ApplicationContext context: Context
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getOrCreateDeviceId(): String {
prefs.getString(KEY_DEVICE_ID, null)
?.takeIf { it.matches(DEVICE_ID_REGEX) }
?.let { return it }
val deviceId = "android_${UUID.randomUUID().toString().replace("-", "")}"
prefs.edit()
.putString(KEY_DEVICE_ID, deviceId)
.apply()
return deviceId
}
private companion object {
const val PREFS_NAME = "connected_device"
const val KEY_DEVICE_ID = "device_id"
val DEVICE_ID_REGEX = Regex("^[a-zA-Z0-9_-]{1,128}$")
}
}
@@ -0,0 +1,11 @@
package com.example.furumi_android.data.remote
import com.example.furumi_android.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AppClientInfo @Inject constructor() {
val version: String = BuildConfig.VERSION_NAME
val userAgent: String = "FurumiAndroid/$version Android Mobile"
}
@@ -0,0 +1,312 @@
package com.example.furumi_android.data.remote.model
import com.example.furumi_android.domain.model.ConnectedCommand
import com.example.furumi_android.domain.model.ConnectedCommandPayload
import com.example.furumi_android.domain.model.ConnectedDevice
import com.example.furumi_android.domain.model.ConnectedDevicesState
import com.example.furumi_android.domain.model.ConnectedJam
import com.example.furumi_android.domain.model.ConnectedJamMember
import com.example.furumi_android.domain.model.ConnectedJamUser
import com.example.furumi_android.domain.model.ConnectedPlaybackState
import com.example.furumi_android.domain.model.TrackCard
import com.squareup.moshi.Json
data class ConnectedPlaybackStateBody(
@param:Json(name = "track") val track: TrackItemResponse? = null,
@param:Json(name = "tracks") val tracks: List<TrackItemResponse> = emptyList(),
@param:Json(name = "index") val index: Int = 0,
@param:Json(name = "position_seconds") val positionSeconds: Double = 0.0,
@param:Json(name = "duration_seconds") val durationSeconds: Double = 0.0,
@param:Json(name = "paused") val paused: Boolean = true,
@param:Json(name = "shuffle") val shuffle: Boolean = false,
@param:Json(name = "repeat_mode") val repeatMode: String = "off",
@param:Json(name = "volume") val volume: Double = 1.0
)
data class DevicePollRequest(
@param:Json(name = "device_id") val deviceId: String,
@param:Json(name = "user_agent") val userAgent: String,
@param:Json(name = "current_jam_id") val currentJamId: String?,
@param:Json(name = "playback_state") val playbackState: ConnectedPlaybackStateBody?
)
data class DevicePollResponse(
@param:Json(name = "device_id") val deviceId: String,
@param:Json(name = "active_device_id") val activeDeviceId: String? = null,
@param:Json(name = "devices") val devices: List<ConnectedDeviceResponse> = emptyList(),
@param:Json(name = "jams") val jams: List<JamResponse> = emptyList(),
@param:Json(name = "current_jam_id") val currentJamId: String? = null,
@param:Json(name = "commands") val commands: List<ConnectedCommandResponse> = emptyList(),
@param:Json(name = "playback_state") val playbackState: ConnectedPlaybackStateBody? = null
)
data class ConnectedDeviceResponse(
@param:Json(name = "id") val id: String,
@param:Json(name = "name") val name: String,
@param:Json(name = "kind") val kind: String,
@param:Json(name = "is_current") val isCurrent: Boolean = false,
@param:Json(name = "is_active") val isActive: Boolean = false,
@param:Json(name = "last_seen_ms") val lastSeenMs: Long = 0L
)
data class JamResponse(
@param:Json(name = "id") val id: String,
@param:Json(name = "name") val name: String,
@param:Json(name = "host_user_id") val hostUserId: Long,
@param:Json(name = "host_name") val hostName: String,
@param:Json(name = "is_owner") val isOwner: Boolean = false,
@param:Json(name = "is_member") val isMember: Boolean = false,
@param:Json(name = "is_pending") val isPending: Boolean = false,
@param:Json(name = "is_active") val isActive: Boolean = false,
@param:Json(name = "member_count") val memberCount: Long = 0L,
@param:Json(name = "host_last_seen_ms") val hostLastSeenMs: Long = 0L,
@param:Json(name = "host_device_online") val hostDeviceOnline: Boolean = false,
@param:Json(name = "members") val members: List<JamMemberResponse> = emptyList()
)
data class JamMemberResponse(
@param:Json(name = "user_id") val userId: Long,
@param:Json(name = "name") val name: String,
@param:Json(name = "is_joined") val isJoined: Boolean = false,
@param:Json(name = "is_current_user") val isCurrentUser: Boolean = false,
@param:Json(name = "last_seen_ms") val lastSeenMs: Long = 0L
)
data class JamUserResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "username") val username: String,
@param:Json(name = "display_name") val displayName: String? = null,
@param:Json(name = "email") val email: String? = null
)
data class ConnectedCommandResponse(
@param:Json(name = "id") val id: String? = null,
@param:Json(name = "command") val command: String,
@param:Json(name = "payload") val payload: ConnectedCommandPayloadResponse = ConnectedCommandPayloadResponse()
)
data class ConnectedCommandPayloadResponse(
@param:Json(name = "track") val track: TrackItemResponse? = null,
@param:Json(name = "tracks") val tracks: List<TrackItemResponse> = emptyList(),
@param:Json(name = "index") val index: Int? = null,
@param:Json(name = "position_seconds") val positionSeconds: Double? = null,
@param:Json(name = "duration_seconds") val durationSeconds: Double? = null,
@param:Json(name = "paused") val paused: Boolean? = null,
@param:Json(name = "shuffle") val shuffle: Boolean? = null,
@param:Json(name = "repeat_mode") val repeatMode: String? = null,
@param:Json(name = "volume") val volume: Double? = null,
@param:Json(name = "time") val time: Double? = null,
@param:Json(name = "from_index") val fromIndex: Int? = null,
@param:Json(name = "to_index") val toIndex: Int? = null
)
data class DeviceActiveRequest(
@param:Json(name = "device_id") val deviceId: String,
@param:Json(name = "current_device_id") val currentDeviceId: String
)
data class DeviceCommandRequest(
@param:Json(name = "target_device_id") val targetDeviceId: String?,
@param:Json(name = "jam_id") val jamId: String?,
@param:Json(name = "command") val command: String,
@param:Json(name = "payload") val payload: ConnectedCommandPayloadBody = ConnectedCommandPayloadBody()
)
data class JamCreateRequest(
@param:Json(name = "device_id") val deviceId: String,
@param:Json(name = "invitee_user_ids") val inviteeUserIds: List<Long> = emptyList()
)
data class JamJoinRequest(
@param:Json(name = "jam_id") val jamId: String,
@param:Json(name = "device_id") val deviceId: String
)
data class JamInviteRequest(
@param:Json(name = "jam_id") val jamId: String,
@param:Json(name = "device_id") val deviceId: String,
@param:Json(name = "invitee_user_ids") val inviteeUserIds: List<Long> = emptyList()
)
data class ConnectedCommandPayloadBody(
@param:Json(name = "track") val track: TrackItemResponse? = null,
@param:Json(name = "tracks") val tracks: List<TrackItemResponse>? = null,
@param:Json(name = "index") val index: Int? = null,
@param:Json(name = "position_seconds") val positionSeconds: Double? = null,
@param:Json(name = "duration_seconds") val durationSeconds: Double? = null,
@param:Json(name = "paused") val paused: Boolean? = null,
@param:Json(name = "shuffle") val shuffle: Boolean? = null,
@param:Json(name = "repeat_mode") val repeatMode: String? = null,
@param:Json(name = "volume") val volume: Double? = null,
@param:Json(name = "time") val time: Double? = null,
@param:Json(name = "from_index") val fromIndex: Int? = null,
@param:Json(name = "to_index") val toIndex: Int? = null
)
fun ConnectedPlaybackState.toBody(): ConnectedPlaybackStateBody {
return ConnectedPlaybackStateBody(
track = track?.toTrackItemResponse(),
tracks = tracks.map { it.toTrackItemResponse() },
index = index,
positionSeconds = positionSeconds,
durationSeconds = durationSeconds,
paused = paused,
shuffle = shuffle,
repeatMode = repeatMode,
volume = volume
)
}
fun ConnectedPlaybackState.toCommandPayloadBody(): ConnectedCommandPayloadBody {
return ConnectedCommandPayloadBody(
track = track?.toTrackItemResponse(),
tracks = tracks.map { it.toTrackItemResponse() },
index = index,
positionSeconds = positionSeconds,
durationSeconds = durationSeconds,
paused = paused,
shuffle = shuffle,
repeatMode = repeatMode,
volume = volume
)
}
fun DevicePollResponse.toDomain(baseUrl: String): ConnectedDevicesState {
return ConnectedDevicesState(
deviceId = deviceId,
activeDeviceId = activeDeviceId,
devices = devices.map { it.toDomain() },
jams = jams.map { it.toDomain() },
currentJamId = currentJamId,
remotePlaybackState = playbackState?.toDomain(baseUrl)
)
}
private fun JamResponse.toDomain(): ConnectedJam {
return ConnectedJam(
id = id,
name = name,
hostUserId = hostUserId,
hostName = hostName,
isOwner = isOwner,
isMember = isMember,
isPending = isPending,
isActive = isActive,
memberCount = memberCount,
hostLastSeenMs = hostLastSeenMs,
hostDeviceOnline = hostDeviceOnline,
members = members.map { it.toDomain() }
)
}
private fun JamMemberResponse.toDomain(): ConnectedJamMember {
return ConnectedJamMember(
userId = userId,
name = name,
isJoined = isJoined,
isCurrentUser = isCurrentUser,
lastSeenMs = lastSeenMs
)
}
fun JamUserResponse.toDomain(): ConnectedJamUser {
return ConnectedJamUser(
id = id,
username = username,
displayName = displayName,
email = email
)
}
fun ConnectedCommandResponse.toDomain(baseUrl: String): ConnectedCommand {
return ConnectedCommand(
id = id,
command = command,
payload = payload.toDomain(baseUrl)
)
}
private fun ConnectedDeviceResponse.toDomain(): ConnectedDevice {
return ConnectedDevice(
id = id,
name = name,
kind = kind,
isCurrent = isCurrent,
isActive = isActive,
lastSeenMs = lastSeenMs
)
}
private fun ConnectedCommandPayloadResponse.toDomain(baseUrl: String): ConnectedCommandPayload {
val playbackState = toPlaybackState(baseUrl)
return ConnectedCommandPayload(
playbackState = playbackState,
tracks = tracks.map { it.toDomain(baseUrl) },
index = index,
time = time,
volume = volume,
shuffle = shuffle,
repeatMode = repeatMode,
fromIndex = fromIndex,
toIndex = toIndex
)
}
private fun ConnectedCommandPayloadResponse.toPlaybackState(baseUrl: String): ConnectedPlaybackState? {
val playbackTracks = tracks.map { it.toDomain(baseUrl) }
val playbackTrack = track?.toDomain(baseUrl) ?: playbackTracks.getOrNull(index ?: 0)
if (playbackTrack == null && playbackTracks.isEmpty()) return null
return ConnectedPlaybackState(
track = playbackTrack,
tracks = playbackTracks.ifEmpty { playbackTrack?.let(::listOf).orEmpty() },
index = index ?: playbackTracks.indexOfFirst { it.id == playbackTrack?.id }.coerceAtLeast(0),
positionSeconds = positionSeconds ?: time ?: 0.0,
durationSeconds = durationSeconds ?: playbackTrack?.durationSeconds?.toDouble() ?: 0.0,
paused = paused ?: false,
shuffle = shuffle ?: false,
repeatMode = repeatMode ?: "off",
volume = volume ?: 1.0
)
}
private fun ConnectedPlaybackStateBody.toDomain(baseUrl: String): ConnectedPlaybackState {
val mappedTracks = tracks.map { it.toDomain(baseUrl) }
return ConnectedPlaybackState(
track = track?.toDomain(baseUrl) ?: mappedTracks.getOrNull(index),
tracks = mappedTracks,
index = index,
positionSeconds = positionSeconds,
durationSeconds = durationSeconds,
paused = paused,
shuffle = shuffle,
repeatMode = repeatMode,
volume = volume
)
}
fun AudioPlaybackStateSnapshot.toDomain(): ConnectedPlaybackState {
return ConnectedPlaybackState(
track = track,
tracks = tracks,
index = index,
positionSeconds = positionSeconds,
durationSeconds = durationSeconds,
paused = paused,
shuffle = shuffle,
repeatMode = repeatMode,
volume = volume
)
}
data class AudioPlaybackStateSnapshot(
val track: TrackCard?,
val tracks: List<TrackCard>,
val index: Int,
val positionSeconds: Double,
val durationSeconds: Double,
val paused: Boolean,
val shuffle: Boolean,
val repeatMode: String,
val volume: Double
)
@@ -15,21 +15,21 @@ import com.example.furumi_android.domain.model.TrackCard
import com.squareup.moshi.Json
data class ArtistRefResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "id") val id: Long? = null,
@param:Json(name = "name") val name: String
)
data class TrackItemResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "id") val id: Long? = null,
@param:Json(name = "title") val title: String,
@param:Json(name = "track_number") val trackNumber: Int? = null,
@param:Json(name = "disc_number") val discNumber: Int? = null,
@param:Json(name = "duration_seconds") val durationSeconds: Double,
@param:Json(name = "duration_seconds") val durationSeconds: Double? = null,
@param:Json(name = "artists") val artists: List<ArtistRefResponse> = emptyList(),
@param:Json(name = "featured_artists") val featuredArtists: List<ArtistRefResponse> = emptyList(),
@param:Json(name = "release_title") val releaseTitle: String? = null,
@param:Json(name = "cover_url") val coverUrl: String? = null,
@param:Json(name = "stream_url") val streamUrl: String
@param:Json(name = "stream_url") val streamUrl: String? = null
)
data class PlayHistoryItemResponse(
@@ -246,10 +246,10 @@ fun ReleaseDetailResponse.toDomain(baseUrl: String): ReleaseDetail {
fun SearchResponse.toDomain(baseUrl: String): SearchResults {
val trackArtists = tracks
.flatMap { track -> track.artists.ifEmpty { track.featuredArtists } }
.distinctBy { it.id }
.distinctBy { it.id ?: it.name.hashCode().toLong() }
.map { artist ->
ArtistCard(
id = artist.id,
id = artist.id ?: artist.name.hashCode().toLong(),
name = artist.name,
imageUrl = null,
releaseCount = 0,
@@ -293,11 +293,11 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
.map { it.name }
return TrackCard(
id = id,
id = id ?: 0L,
title = title,
trackNumber = trackNumber,
discNumber = discNumber,
durationSeconds = durationSeconds.toInt(),
durationSeconds = durationSeconds?.toInt() ?: 0,
artists = artistNames,
artistRefs = mappedArtists,
featuredArtistRefs = mappedFeaturedArtists,
@@ -330,7 +330,7 @@ fun TrackCard.toTrackItemResponse(): TrackItemResponse {
private fun ArtistRefResponse.toDomain(): ArtistRef {
return ArtistRef(
id = id,
id = id ?: 0L,
name = name
)
}
@@ -0,0 +1,85 @@
package com.example.furumi_android.domain.model
data class ConnectedDevice(
val id: String,
val name: String,
val kind: String,
val isCurrent: Boolean,
val isActive: Boolean,
val lastSeenMs: Long
)
data class ConnectedDevicesState(
val deviceId: String,
val activeDeviceId: String?,
val devices: List<ConnectedDevice>,
val jams: List<ConnectedJam>,
val currentJamId: String?,
val remotePlaybackState: ConnectedPlaybackState?
) {
val isCurrentDeviceActive: Boolean
get() = activeDeviceId == deviceId
}
data class ConnectedJam(
val id: String,
val name: String,
val hostUserId: Long,
val hostName: String,
val isOwner: Boolean,
val isMember: Boolean,
val isPending: Boolean,
val isActive: Boolean,
val memberCount: Long,
val hostLastSeenMs: Long,
val hostDeviceOnline: Boolean,
val members: List<ConnectedJamMember>
)
data class ConnectedJamMember(
val userId: Long,
val name: String,
val isJoined: Boolean,
val isCurrentUser: Boolean,
val lastSeenMs: Long
)
data class ConnectedJamUser(
val id: Long,
val username: String,
val displayName: String?,
val email: String?
) {
val displayLabel: String
get() = displayName?.takeIf { it.isNotBlank() } ?: username
}
data class ConnectedPlaybackState(
val track: TrackCard?,
val tracks: List<TrackCard>,
val index: Int,
val positionSeconds: Double,
val durationSeconds: Double,
val paused: Boolean,
val shuffle: Boolean,
val repeatMode: String,
val volume: Double
)
data class ConnectedCommand(
val id: String?,
val command: String,
val payload: ConnectedCommandPayload
)
data class ConnectedCommandPayload(
val playbackState: ConnectedPlaybackState?,
val tracks: List<TrackCard>,
val index: Int?,
val time: Double?,
val volume: Double?,
val shuffle: Boolean?,
val repeatMode: String?,
val fromIndex: Int?,
val toIndex: Int?
)
@@ -129,7 +129,8 @@ class PlaybackController @Inject constructor(
reportCurrentTrackPlay(completed = completed)
startTrackTimer()
publishState()
startPlaybackService()
// If service was stopped but player continues (e.g. gapless), ensure it's up
if (player.isPlaying) startPlaybackService()
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
@@ -442,7 +443,18 @@ class PlaybackController @Inject constructor(
private fun publishState() {
val existingErrorMessage = _state.value.errorMessage
val currentQueue = _state.value.queue
val currentIndex = player.currentMediaItemIndex.takeIf { it >= 0 } ?: _state.value.currentIndex
val playerIndex = player.currentMediaItemIndex
val mediaId = player.currentMediaItem?.mediaId
// Sync index by Media ID because player index can drift during shuffle or queue changes
val currentIndex = if (mediaId != null) {
val indexInQueue = currentQueue.indexOfFirst { it.id.toString() == mediaId }
if (indexInQueue != -1) indexInQueue else playerIndex.coerceIn(0, currentQueue.lastIndex.coerceAtLeast(0))
} else {
playerIndex.coerceIn(0, currentQueue.lastIndex.coerceAtLeast(0))
}
val duration = player.duration.takeIf { it > 0L } ?: currentQueue
.getOrNull(currentIndex)
?.durationSeconds
@@ -0,0 +1,138 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun MediaArtwork(
title: String,
seedId: Long,
bitmap: Bitmap?,
modifier: Modifier,
cornerRadius: Int
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(cornerRadius.dp))
.background(Brush.linearGradient(artistArtworkColors(seedId)))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(cornerRadius.dp)),
contentAlignment = Alignment.Center
) {
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "$title image",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Text(
text = title.firstOrNull()?.uppercaseChar()?.toString() ?: "#",
style = MaterialTheme.typography.titleMedium,
color = Color.White,
fontWeight = FontWeight.ExtraBold
)
}
}
}
@Composable
internal fun AlbumArtwork(
colors: List<Color>,
modifier: Modifier,
cornerRadius: Int
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(cornerRadius.dp))
.background(Brush.linearGradient(colors))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(cornerRadius.dp))
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color.White.copy(alpha = 0.16f),
radius = size.minDimension * 0.26f,
center = Offset(size.width * 0.72f, size.height * 0.24f)
)
drawLine(
color = Color.White.copy(alpha = 0.18f),
start = Offset(size.width * 0.10f, size.height * 0.78f),
end = Offset(size.width * 0.92f, size.height * 0.34f),
strokeWidth = size.minDimension * 0.05f,
cap = StrokeCap.Round
)
}
}
}
@@ -0,0 +1,549 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.ConnectedDevice
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun PlayerHeader(
title: String,
subtitle: String,
onProfileClick: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
Row(
modifier = Modifier
.height(38.dp)
.clip(CircleShape)
.background(FurumiSurfaceHigh)
.border(1.dp, FurumiLine, CircleShape)
.clickable(onClick = onProfileClick)
.padding(start = 5.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(FurumiNeonPink),
contentAlignment = Alignment.Center
) {
Text(
text = "F",
style = MaterialTheme.typography.labelSmall,
color = FurumiBlack,
fontWeight = FontWeight.ExtraBold
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Profile",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
@Composable
internal fun ProfileMenu(
uiState: PlayerUiState,
onHistoryClick: () -> Unit,
onDeviceClick: (String) -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.width(284.dp),
shape = RoundedCornerShape(12.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Profile",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(14.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = uiState.userName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (uiState.appVersion.isNotBlank()) {
Surface(
color = FurumiSurface,
shape = RoundedCornerShape(4.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Text(
text = "v${uiState.appVersion}",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
}
}
}
Text(
text = uiState.serverUrl.ifBlank { "No server selected" },
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(16.dp))
ConnectedDevicesSection(
uiState = uiState,
onDeviceClick = onDeviceClick
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedButton(
onClick = onHistoryClick,
modifier = Modifier
.fillMaxWidth()
.height(46.dp),
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = FurumiTextMuted
)
) {
Text("Listening history")
}
Spacer(modifier = Modifier.height(10.dp))
Button(
onClick = onLogout,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
enabled = !uiState.isLoggingOut,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = FurumiNeonPink,
contentColor = FurumiBlack,
disabledContainerColor = FurumiSurface,
disabledContentColor = FurumiTextMuted
)
) {
if (uiState.isLoggingOut) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = FurumiBlack,
strokeWidth = 2.dp
)
} else {
Text("Log out")
}
}
}
}
}
@Composable
private fun ConnectedDevicesSection(
uiState: PlayerUiState,
onDeviceClick: (String) -> Unit
) {
val devices = uiState.connectedDevicesState?.devices.orEmpty()
if (devices.isEmpty() && uiState.connectedDevicesError == null) return
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = "Devices",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground
)
uiState.connectedDevicesError?.let { error ->
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = FurumiHotOrange,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
devices.forEach { device ->
ConnectedDeviceRow(
device = device,
onClick = { onDeviceClick(device.id) }
)
}
}
}
@Composable
private fun ConnectedDeviceRow(
device: ConnectedDevice,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.clickable(onClick = onClick),
shape = RoundedCornerShape(6.dp),
color = if (device.isActive) FurumiSurface else Color.Transparent,
border = if (device.isActive) androidx.compose.foundation.BorderStroke(1.dp, FurumiNeonPink) else null
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (device.isActive) FurumiNeonPink else FurumiLine)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = device.name,
style = MaterialTheme.typography.bodyMedium,
color = if (device.isActive) MaterialTheme.colorScheme.onBackground else FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (device.isCurrent) {
Text(
text = "this",
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted.copy(alpha = 0.7f)
)
}
}
}
}
@Composable
internal fun SectionTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
@Composable
internal fun FilterChips(filters: List<String>) {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
filters.forEachIndexed { index, filter ->
Surface(
shape = CircleShape,
color = if (index == 0) FurumiNeonPink else FurumiSurface,
border = if (index == 0) null else androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Text(
text = filter,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelSmall,
color = if (index == 0) FurumiBlack else MaterialTheme.colorScheme.onBackground
)
}
}
}
}
@Composable
internal fun PillAction(
label: String,
selected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Surface(
modifier = modifier
.height(48.dp)
.clip(CircleShape)
.clickable(onClick = onClick),
shape = CircleShape,
color = if (selected) FurumiNeonPink else FurumiSurface,
border = if (selected) null else androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) FurumiBlack else MaterialTheme.colorScheme.onBackground
)
}
}
}
@Composable
internal fun NowPlayingBar(
playback: AudioPlaybackState,
coverBitmap: Bitmap?,
isLiked: Boolean,
onMediaImageNeeded: (String?) -> Unit,
onClick: () -> Unit,
onPlayPause: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onToggleLike: () -> Unit
) {
val track = playback.currentTrack ?: return
LaunchedEffect(track.coverUrl) {
onMediaImageNeeded(track.coverUrl)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
MediaArtwork(
title = track.title,
seedId = track.id,
bitmap = coverBitmap,
modifier = Modifier.size(42.dp),
cornerRadius = 6
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = playback.errorMessage ?: trackSubtitle(track),
style = MaterialTheme.typography.bodyMedium,
color = if (playback.errorMessage == null) FurumiTextMuted else FurumiHotOrange,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.width(6.dp))
MiniPlayerIconButton(onClick = onPrevious) {
PreviousGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
}
MiniPlayerIconButton(
size = 38,
background = FurumiNeonPink,
onClick = onPlayPause
) {
PlaybackGlyph(
playback = playback,
playSize = 16,
pauseSize = 16
)
}
MiniPlayerIconButton(onClick = onNext) {
NextGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
}
MiniPlayerIconButton(onClick = onToggleLike) {
HeartGlyph(
isLiked = isLiked,
modifier = Modifier.size(18.dp)
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.background(FurumiLine)
) {
Box(
modifier = Modifier
.fillMaxWidth(playback.progress)
.fillMaxHeight()
.background(FurumiNeonPink)
)
}
}
}
}
@Composable
private fun MiniPlayerIconButton(
size: Int = 34,
background: Color = Color.Transparent,
onClick: () -> Unit,
content: @Composable BoxScope.() -> Unit
) {
Box(
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(background)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
content = content
)
}
@Composable
internal fun BottomPlayerNav(
selectedTab: PlayerTab,
onTabSelected: (PlayerTab) -> Unit
) {
Surface(
color = FurumiBlack
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(66.dp)
.padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
PlayerTab.entries.forEach { tab ->
BottomNavItem(
tab = tab,
selected = tab == selectedTab,
onClick = { onTabSelected(tab) }
)
}
}
}
}
@Composable
internal fun BottomNavItem(
tab: PlayerTab,
selected: Boolean,
onClick: () -> Unit
) {
val color = if (selected) FurumiNeonPink else FurumiTextMuted
Column(
modifier = Modifier
.width(86.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (tab) {
PlayerTab.Global -> GlobalGlyph(Modifier.size(22.dp), color)
PlayerTab.Search -> SearchGlyph(Modifier.size(22.dp), color)
PlayerTab.Library -> LibraryGlyph(Modifier.size(22.dp), color)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = tab.label,
style = MaterialTheme.typography.labelSmall,
color = color
)
}
}
@@ -0,0 +1,776 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun GlobalContent(
uiState: PlayerUiState,
onProfileClick: () -> Unit,
onRetry: () -> Unit,
onLoadMore: () -> Unit,
onArtistImageNeeded: (String?) -> Unit,
onArtistClick: (ArtistCard) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 148.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
item(span = { GridItemSpan(maxLineSpan) }) {
PlayerHeader(
title = "Global",
subtitle = artistsSubtitle(uiState),
onProfileClick = onProfileClick
)
}
when {
uiState.isGlobalArtistsLoading && uiState.globalArtists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) { GlobalArtistsLoading() }
}
uiState.globalArtistsError != null && uiState.globalArtists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
GlobalArtistsError(
message = uiState.globalArtistsError,
onRetry = onRetry
)
}
}
uiState.globalArtists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) { GlobalArtistsEmpty() }
}
else -> {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "Artists",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
items(
items = uiState.globalArtists,
key = { artist -> artist.id }
) { artist ->
ArtistTile(
artist = artist,
bitmap = mediaImageFor(uiState.mediaImages, artist.imageUrl),
onArtistImageNeeded = onArtistImageNeeded,
onClick = { onArtistClick(artist) }
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
GlobalArtistsFooter(
uiState = uiState,
onLoadMore = onLoadMore
)
}
}
}
}
}
@Composable
internal fun ArtistTile(
artist: ArtistCard,
bitmap: Bitmap?,
onArtistImageNeeded: (String?) -> Unit,
onClick: () -> Unit
) {
LaunchedEffect(artist.imageUrl) {
onArtistImageNeeded(artist.imageUrl)
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
) {
ArtistArtwork(
artist = artist,
bitmap = bitmap,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = artist.name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "${pluralCount(artist.releaseCount, "release")} / ${pluralCount(artist.trackCount, "track")}",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
internal fun ArtistArtwork(
artist: ArtistCard,
bitmap: Bitmap?,
modifier: Modifier
) {
MediaArtwork(
title = artist.name,
seedId = artist.id,
bitmap = bitmap,
modifier = modifier,
cornerRadius = 8
)
}
@Composable
internal fun ArtistDetailContent(
uiState: PlayerUiState,
onBack: () -> Unit,
onRetry: () -> Unit,
onMediaImageNeeded: (String?) -> Unit,
onPlayClick: () -> Unit,
onRadioClick: () -> Unit,
onTrackClick: (TrackCard) -> Unit,
onReleaseClick: (ReleaseCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
val artist = uiState.artistDetail?.artist ?: uiState.selectedArtist ?: return
val artistImage = mediaImageFor(uiState.mediaImages, artist.imageUrl)
LaunchedEffect(artist.imageUrl) {
onMediaImageNeeded(artist.imageUrl)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 20.dp)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onBack)
.padding(vertical = 8.dp, horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
BackGlyph(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Artist",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground
)
}
Spacer(modifier = Modifier.height(20.dp))
ArtistDetailHero(
artist = artist,
bitmap = artistImage,
onPlayClick = onPlayClick,
onRadioClick = onRadioClick
)
Spacer(modifier = Modifier.height(28.dp))
when {
uiState.isArtistDetailLoading && uiState.artistDetail == null -> ArtistDetailLoading()
uiState.artistDetailError != null && uiState.artistDetail == null -> ArtistDetailError(
message = uiState.artistDetailError,
onRetry = onRetry
)
uiState.artistDetail != null -> {
val detail = uiState.artistDetail
if (detail.topTracks.isNotEmpty()) {
PopularTracksSection(
tracks = detail.topTracks,
mediaImages = uiState.mediaImages,
likedTrackIds = uiState.likedTrackIds,
onMediaImageNeeded = onMediaImageNeeded,
onTrackClick = onTrackClick,
onToggleLike = onToggleLike,
onPlayNext = onPlayNext,
onPlayLast = onPlayLast,
onShare = onShare,
onAddToPlaylist = onAddToPlaylist
)
Spacer(modifier = Modifier.height(28.dp))
}
ReleasesSection(
releases = detail.releases,
mediaImages = uiState.mediaImages,
onMediaImageNeeded = onMediaImageNeeded,
onReleaseClick = onReleaseClick
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
internal fun ArtistDetailHero(
artist: ArtistCard,
bitmap: Bitmap?,
onPlayClick: () -> Unit,
onRadioClick: () -> Unit
) {
Column {
MediaArtwork(
title = artist.name,
seedId = artist.id,
bitmap = bitmap,
modifier = Modifier
.size(210.dp)
.align(Alignment.CenterHorizontally),
cornerRadius = 10
)
Spacer(modifier = Modifier.height(22.dp))
Text(
text = artist.name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${pluralCount(artist.releaseCount, "release")} / ${pluralCount(artist.trackCount, "track")}",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
PillAction(
label = "Play",
selected = true,
modifier = Modifier.weight(1f),
onClick = onPlayClick
)
Spacer(modifier = Modifier.width(12.dp))
PillAction(
label = "Radio",
selected = false,
modifier = Modifier.weight(1f),
onClick = onRadioClick
)
}
}
}
@Composable
internal fun ArtistDetailLoading() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp
)
}
}
@Composable
internal fun ArtistDetailError(
message: String,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Could not load artist",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(14.dp))
PillAction(
label = "Retry",
selected = true,
onClick = onRetry
)
}
}
}
@Composable
internal fun PopularTracksSection(
tracks: List<TrackCard>,
mediaImages: Map<String, Bitmap>,
likedTrackIds: Set<Long>,
onMediaImageNeeded: (String?) -> Unit,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
SectionTitle("Popular")
Spacer(modifier = Modifier.height(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
tracks.take(5).forEachIndexed { index, track ->
ArtistDetailTrackRow(
index = index + 1,
track = track,
bitmap = mediaImageFor(mediaImages, track.coverUrl),
isLiked = likedTrackIds.contains(track.id),
onMediaImageNeeded = onMediaImageNeeded,
onTrackClick = onTrackClick,
onToggleLike = { onToggleLike(track.id) },
onPlayNext = { onPlayNext(track) },
onPlayLast = { onPlayLast(track) },
onShare = { onShare(track) },
onAddToPlaylist = { onAddToPlaylist(track) }
)
}
}
}
@Composable
internal fun ReleasesSection(
releases: List<ReleaseCard>,
mediaImages: Map<String, Bitmap>,
onMediaImageNeeded: (String?) -> Unit,
onReleaseClick: (ReleaseCard) -> Unit
) {
if (releases.isEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Text(
text = "No albums, EPs or singles yet",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
return
}
Column(verticalArrangement = Arrangement.spacedBy(28.dp)) {
releaseSections(releases).forEach { section ->
Column {
SectionTitle(section.title)
Spacer(modifier = Modifier.height(12.dp))
ReleaseGrid(
releases = section.releases,
mediaImages = mediaImages,
onMediaImageNeeded = onMediaImageNeeded,
onReleaseClick = onReleaseClick
)
}
}
}
}
@Composable
private fun ReleaseGrid(
releases: List<ReleaseCard>,
mediaImages: Map<String, Bitmap>,
onMediaImageNeeded: (String?) -> Unit,
onReleaseClick: (ReleaseCard) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
releases.chunked(2).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
row.forEach { release ->
ReleaseTile(
release = release,
bitmap = mediaImageFor(mediaImages, release.coverUrl),
onMediaImageNeeded = onMediaImageNeeded,
onClick = { onReleaseClick(release) },
modifier = Modifier.weight(1f)
)
}
if (row.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
private data class ReleaseSection(
val key: String,
val title: String,
val releases: List<ReleaseCard>
)
private fun releaseSections(releases: List<ReleaseCard>): List<ReleaseSection> {
val grouped = releases.groupBy { it.releaseType.normalizedReleaseType() }
val knownSections = releaseTypeOrder.mapNotNull { key ->
grouped[key]
?.takeIf { it.isNotEmpty() }
?.let { ReleaseSection(key = key, title = releaseTypeTitle(key), releases = it) }
}
val otherSections = grouped
.filterKeys { it !in releaseTypeOrder }
.toSortedMap()
.map { (key, items) ->
ReleaseSection(key = key, title = releaseTypeTitle(key), releases = items)
}
return knownSections + otherSections
}
private fun String?.normalizedReleaseType(): String {
val value = this
?.trim()
?.lowercase()
?.replace('-', '_')
?.takeIf { it.isNotBlank() }
?: return RELEASE_TYPE_OTHER
return when (value) {
"album", "albums", "lp", "full_length", "full_length_album" -> RELEASE_TYPE_ALBUM
"ep", "eps", "mini_album", "mini" -> RELEASE_TYPE_EP
"single", "singles" -> RELEASE_TYPE_SINGLE
else -> value
}
}
private fun releaseTypeTitle(type: String): String {
return when (type) {
RELEASE_TYPE_ALBUM -> "Albums"
RELEASE_TYPE_EP -> "EPs"
RELEASE_TYPE_SINGLE -> "Singles"
RELEASE_TYPE_OTHER -> "Other"
else -> type
.replace('_', ' ')
.split(' ')
.joinToString(" ") { word ->
word.replaceFirstChar { char -> char.titlecase() }
}
}
}
private const val RELEASE_TYPE_ALBUM = "album"
private const val RELEASE_TYPE_EP = "ep"
private const val RELEASE_TYPE_SINGLE = "single"
private const val RELEASE_TYPE_OTHER = "other"
private val releaseTypeOrder = listOf(
RELEASE_TYPE_ALBUM,
RELEASE_TYPE_EP,
RELEASE_TYPE_SINGLE
)
@Composable
internal fun ReleaseTile(
release: ReleaseCard,
bitmap: Bitmap?,
onMediaImageNeeded: (String?) -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
LaunchedEffect(release.coverUrl) {
onMediaImageNeeded(release.coverUrl)
}
Column(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
) {
MediaArtwork(
title = release.title,
seedId = release.id,
bitmap = bitmap,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
cornerRadius = 8
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = release.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = releaseMeta(release),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
internal fun GlobalArtistsFooter(
uiState: PlayerUiState,
onLoadMore: () -> Unit
) {
when {
uiState.isGlobalArtistsLoadingMore -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
uiState.globalArtistsError != null -> {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Could not load more artists",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Retry",
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onLoadMore)
.padding(horizontal = 10.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = FurumiNeonPink
)
}
}
}
uiState.globalArtistsHasMore -> {
LaunchedEffect(uiState.globalArtists.size, uiState.globalArtistsPage) {
onLoadMore()
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
else -> {
Text(
text = "${uiState.globalArtists.size} artists loaded",
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 18.dp),
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
}
}
}
@Composable
internal fun GlobalArtistsLoading() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp
)
}
}
@Composable
internal fun GlobalArtistsError(
message: String,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Could not load artists",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(14.dp))
PillAction(
label = "Retry",
selected = true,
onClick = onRetry
)
}
}
}
@Composable
internal fun GlobalArtistsEmpty() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "No artists yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Artists uploaded to this server will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
}
@@ -0,0 +1,281 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun ListeningHistoryPanel(
uiState: PlayerUiState,
onDismiss: () -> Unit,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "Listening history",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = if (uiState.listeningHistoryTotal > 0) {
"${uiState.listeningHistoryTotal} plays"
} else {
"Recently played tracks"
},
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
Text(
text = "Close",
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onDismiss)
.padding(horizontal = 10.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground
)
}
Spacer(modifier = Modifier.height(22.dp))
when {
uiState.isListeningHistoryLoading -> ListeningHistoryLoading()
uiState.listeningHistoryError != null -> ListeningHistoryError(
message = uiState.listeningHistoryError,
onRetry = onRetry
)
uiState.listeningHistory.isEmpty() -> ListeningHistoryEmpty()
else -> ListeningHistoryList(uiState.listeningHistory)
}
}
}
}
@Composable
internal fun ListeningHistoryLoading() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp
)
}
}
@Composable
internal fun ListeningHistoryError(
message: String,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Could not load history",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(14.dp))
PillAction(
label = "Retry",
selected = true,
onClick = onRetry
)
}
}
}
@Composable
internal fun ListeningHistoryEmpty() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "No plays yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Tracks you play will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
}
@Composable
internal fun ListeningHistoryList(items: List<ListeningHistoryItem>) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
items.forEach { item ->
ListeningHistoryRow(item)
}
}
}
@Composable
internal fun ListeningHistoryRow(item: ListeningHistoryItem) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AlbumArtwork(
colors = historyArtworkColors(item.trackId),
modifier = Modifier.size(54.dp),
cornerRadius = 8
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.artists.joinToString(", ").ifBlank { "Unknown artist" },
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.releaseTitle ?: "Unknown release",
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(horizontalAlignment = Alignment.End) {
Text(
text = formatListenedDuration(item.durationListenedSeconds),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = if (item.completed) "Completed" else "Partial",
style = MaterialTheme.typography.labelSmall,
color = if (item.completed) FurumiNeonPink else FurumiTextMuted
)
}
}
}
}
@@ -0,0 +1,444 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun PlayGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val path = Path().apply {
moveTo(size.width * 0.28f, size.height * 0.18f)
lineTo(size.width * 0.28f, size.height * 0.82f)
lineTo(size.width * 0.82f, size.height * 0.50f)
close()
}
drawPath(path, color)
}
}
@Composable
internal fun PauseGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val barWidth = size.width * 0.18f
drawRoundRect(
color = color,
topLeft = Offset(size.width * 0.24f, size.height * 0.18f),
size = androidx.compose.ui.geometry.Size(barWidth, size.height * 0.64f)
)
drawRoundRect(
color = color,
topLeft = Offset(size.width * 0.58f, size.height * 0.18f),
size = androidx.compose.ui.geometry.Size(barWidth, size.height * 0.64f)
)
}
}
@Composable
internal fun PlaybackGlyph(
playback: AudioPlaybackState,
playSize: Int,
pauseSize: Int
) {
when {
playback.isBuffering -> CircularProgressIndicator(
modifier = Modifier.size(pauseSize.dp),
color = FurumiBlack,
strokeWidth = 2.dp
)
playback.isPlaying -> PauseGlyph(Modifier.size(pauseSize.dp), FurumiBlack)
else -> PlayGlyph(Modifier.size(playSize.dp), FurumiBlack)
}
}
@Composable
internal fun BackGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
drawLine(
color = color,
start = Offset(size.width * 0.72f, size.height * 0.16f),
end = Offset(size.width * 0.28f, size.height * 0.50f),
strokeWidth = size.minDimension * 0.12f,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.28f, size.height * 0.50f),
end = Offset(size.width * 0.72f, size.height * 0.84f),
strokeWidth = size.minDimension * 0.12f,
cap = StrokeCap.Round
)
}
}
@Composable
internal fun PreviousGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val first = Path().apply {
moveTo(size.width * 0.48f, size.height * 0.18f)
lineTo(size.width * 0.48f, size.height * 0.82f)
lineTo(size.width * 0.16f, size.height * 0.50f)
close()
}
val second = Path().apply {
moveTo(size.width * 0.84f, size.height * 0.18f)
lineTo(size.width * 0.84f, size.height * 0.82f)
lineTo(size.width * 0.52f, size.height * 0.50f)
close()
}
drawPath(first, color)
drawPath(second, color)
}
}
@Composable
internal fun NextGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val first = Path().apply {
moveTo(size.width * 0.16f, size.height * 0.18f)
lineTo(size.width * 0.16f, size.height * 0.82f)
lineTo(size.width * 0.48f, size.height * 0.50f)
close()
}
val second = Path().apply {
moveTo(size.width * 0.52f, size.height * 0.18f)
lineTo(size.width * 0.52f, size.height * 0.82f)
lineTo(size.width * 0.84f, size.height * 0.50f)
close()
}
drawPath(first, color)
drawPath(second, color)
}
}
@Composable
internal fun GlobalGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val strokeWidth = size.minDimension * 0.09f
drawCircle(
color = color,
radius = size.minDimension * 0.38f,
center = Offset(size.width * 0.50f, size.height * 0.50f),
style = Stroke(width = strokeWidth)
)
drawLine(
color = color,
start = Offset(size.width * 0.50f, size.height * 0.16f),
end = Offset(size.width * 0.50f, size.height * 0.84f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.16f, size.height * 0.50f),
end = Offset(size.width * 0.84f, size.height * 0.50f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color.copy(alpha = 0.74f),
start = Offset(size.width * 0.24f, size.height * 0.34f),
end = Offset(size.width * 0.76f, size.height * 0.34f),
strokeWidth = strokeWidth * 0.78f,
cap = StrokeCap.Round
)
drawLine(
color = color.copy(alpha = 0.74f),
start = Offset(size.width * 0.24f, size.height * 0.66f),
end = Offset(size.width * 0.76f, size.height * 0.66f),
strokeWidth = strokeWidth * 0.78f,
cap = StrokeCap.Round
)
}
}
@Composable
internal fun SearchGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
drawCircle(
color = color,
radius = size.minDimension * 0.30f,
center = Offset(size.width * 0.42f, size.height * 0.42f),
style = Stroke(width = size.minDimension * 0.10f)
)
drawLine(
color = color,
start = Offset(size.width * 0.64f, size.height * 0.64f),
end = Offset(size.width * 0.88f, size.height * 0.88f),
strokeWidth = size.minDimension * 0.10f,
cap = StrokeCap.Round
)
}
}
@Composable
internal fun LibraryGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val strokeWidth = size.minDimension * 0.10f
drawLine(
color = color,
start = Offset(size.width * 0.18f, size.height * 0.16f),
end = Offset(size.width * 0.18f, size.height * 0.86f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.46f, size.height * 0.16f),
end = Offset(size.width * 0.46f, size.height * 0.86f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.70f, size.height * 0.22f),
end = Offset(size.width * 0.86f, size.height * 0.82f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
@Composable
internal fun ShareGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val strokeWidth = size.minDimension * 0.10f
val nodeRadius = size.minDimension * 0.11f
val left = Offset(size.width * 0.24f, size.height * 0.50f)
val topRight = Offset(size.width * 0.76f, size.height * 0.22f)
val bottomRight = Offset(size.width * 0.76f, size.height * 0.78f)
drawLine(
color = color,
start = left,
end = topRight,
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = left,
end = bottomRight,
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawCircle(color = color, radius = nodeRadius, center = left)
drawCircle(color = color, radius = nodeRadius, center = topRight)
drawCircle(color = color, radius = nodeRadius, center = bottomRight)
}
}
@Composable
internal fun DevicesGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val strokeWidth = size.minDimension * 0.08f
val phoneLeft = size.width * 0.54f
val phoneTop = size.height * 0.16f
val phoneWidth = size.width * 0.28f
val phoneHeight = size.height * 0.58f
drawRoundRect(
color = color,
topLeft = Offset(phoneLeft, phoneTop),
size = androidx.compose.ui.geometry.Size(phoneWidth, phoneHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.08f),
style = Stroke(width = strokeWidth)
)
drawCircle(
color = color,
radius = size.minDimension * 0.025f,
center = Offset(phoneLeft + phoneWidth / 2f, phoneTop + phoneHeight - size.height * 0.07f)
)
val speakerCenter = Offset(size.width * 0.30f, size.height * 0.60f)
drawCircle(
color = color,
radius = size.minDimension * 0.09f,
center = speakerCenter,
style = Stroke(width = strokeWidth)
)
drawLine(
color = color,
start = Offset(size.width * 0.18f, size.height * 0.82f),
end = Offset(size.width * 0.44f, size.height * 0.82f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.30f, size.height * 0.70f),
end = Offset(size.width * 0.30f, size.height * 0.82f),
strokeWidth = strokeWidth,
cap = StrokeCap.Round
)
}
}
@Composable
internal fun HeartGlyph(
isLiked: Boolean,
modifier: Modifier,
alpha: Float = 1.0f
) {
Canvas(modifier = modifier) {
val cx = size.width * 0.5f
val top = size.height * 0.22f
val bottom = size.height * 0.82f
val left = size.width * 0.14f
val right = size.width * 0.86f
val midY = size.height * 0.38f
val path = Path().apply {
moveTo(cx, bottom)
cubicTo(left, size.height * 0.62f, left, top, cx, midY)
moveTo(cx, bottom)
cubicTo(right, size.height * 0.62f, right, top, cx, midY)
}
if (isLiked) {
drawPath(path, color = Color(0xFFFF3EA5).copy(alpha = alpha), style = Fill)
}
drawPath(
path,
color = if (isLiked) Color(0xFFFF3EA5).copy(alpha = alpha) else Color(0xFFC9B8D6).copy(alpha = alpha),
style = Stroke(width = size.minDimension * 0.08f)
)
}
}
@Composable
internal fun AudioWaveGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val barWidth = size.width * 0.12f
val gap = size.width * 0.12f
val heights = listOf(0.4f, 0.8f, 0.6f, 0.9f)
heights.forEachIndexed { i, h ->
val x = size.width * 0.15f + i * (barWidth + gap)
val barHeight = size.height * h
val y = (size.height - barHeight) / 2f
drawRoundRect(
color = color,
topLeft = Offset(x, y),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f)
)
}
}
}
@Composable
internal fun MoreDotsGlyph(
modifier: Modifier
) {
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))
}
}
@@ -0,0 +1,974 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun SearchContent(
uiState: PlayerUiState,
onProfileClick: () -> Unit,
onQueryChange: (String) -> Unit,
onRetry: () -> Unit,
onArtistClick: (ArtistCard) -> Unit,
onMediaImageNeeded: (String?) -> Unit,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
val query = uiState.searchQuery
val results = uiState.searchResults
val artists = searchArtists(uiState)
val tracks = results?.tracks.orEmpty()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 24.dp)
) {
PlayerHeader(title = "Search", subtitle = "Find music on your server", onProfileClick = onProfileClick)
Spacer(modifier = Modifier.height(18.dp))
TextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier
.fillMaxWidth()
.height(54.dp),
shape = RoundedCornerShape(8.dp),
singleLine = true,
placeholder = {
Text(
text = "Artists and tracks",
style = MaterialTheme.typography.bodyLarge
)
},
leadingIcon = {
SearchGlyph(
modifier = Modifier.size(22.dp),
color = FurumiBlack
)
}
)
Spacer(modifier = Modifier.height(22.dp))
when {
query.isBlank() -> SearchEmptyPrompt()
uiState.isSearchLoading && results == null -> SearchLoading()
uiState.searchError != null && results == null -> SearchError(
message = uiState.searchError,
onRetry = onRetry
)
artists.isEmpty() && tracks.isEmpty() && !uiState.isSearchLoading -> SearchNoResults()
else -> {
if (artists.isNotEmpty()) {
SearchArtistsSection(
artists = artists,
mediaImages = uiState.mediaImages,
onMediaImageNeeded = onMediaImageNeeded,
onArtistClick = onArtistClick
)
Spacer(modifier = Modifier.height(26.dp))
}
if (tracks.isNotEmpty()) {
SearchTracksSection(
tracks = tracks,
mediaImages = uiState.mediaImages,
likedTrackIds = uiState.likedTrackIds,
onMediaImageNeeded = onMediaImageNeeded,
onTrackClick = onTrackClick,
onToggleLike = onToggleLike,
onPlayNext = onPlayNext,
onPlayLast = onPlayLast,
onShare = onShare,
onAddToPlaylist = onAddToPlaylist
)
}
if (uiState.isSearchLoading) {
Spacer(modifier = Modifier.height(20.dp))
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp,
modifier = Modifier
.size(24.dp)
.align(Alignment.CenterHorizontally)
)
}
}
}
}
}
@Composable
private fun SearchArtistsSection(
artists: List<ArtistCard>,
mediaImages: Map<String, Bitmap>,
onMediaImageNeeded: (String?) -> Unit,
onArtistClick: (ArtistCard) -> Unit
) {
SectionTitle("Artists")
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
artists.forEach { artist ->
SearchArtistCard(
artist = artist,
bitmap = mediaImageFor(mediaImages, artist.imageUrl),
onMediaImageNeeded = onMediaImageNeeded,
onClick = { onArtistClick(artist) }
)
}
}
}
@Composable
private fun SearchArtistCard(
artist: ArtistCard,
bitmap: Bitmap?,
onMediaImageNeeded: (String?) -> Unit,
onClick: () -> Unit
) {
LaunchedEffect(artist.imageUrl) {
onMediaImageNeeded(artist.imageUrl)
}
Column(
modifier = Modifier
.width(104.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
) {
MediaArtwork(
title = artist.name,
seedId = artist.id,
bitmap = bitmap,
modifier = Modifier.size(104.dp),
cornerRadius = 8
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = artist.name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun SearchTracksSection(
tracks: List<TrackCard>,
mediaImages: Map<String, Bitmap>,
likedTrackIds: Set<Long>,
onMediaImageNeeded: (String?) -> Unit,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
SectionTitle("Tracks")
Spacer(modifier = Modifier.height(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
tracks.forEachIndexed { index, track ->
PlaylistTrackRow(
index = index + 1,
track = track,
bitmap = mediaImageFor(mediaImages, track.coverUrl),
isLiked = likedTrackIds.contains(track.id),
onMediaImageNeeded = onMediaImageNeeded,
onTrackClick = onTrackClick,
onToggleLike = { onToggleLike(track.id) },
onPlayNext = { onPlayNext(track) },
onPlayLast = { onPlayLast(track) },
onShare = { onShare(track) },
onAddToPlaylist = { onAddToPlaylist(track) }
)
}
}
}
@Composable
private fun SearchEmptyPrompt() {
Text(
text = "Start typing to search artists and tracks.",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
@Composable
private fun SearchLoading() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp
)
}
}
@Composable
private fun SearchError(
message: String,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Could not search",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(14.dp))
PillAction(
label = "Retry",
selected = true,
onClick = onRetry
)
}
}
}
@Composable
private fun SearchNoResults() {
Text(
text = "No matches found",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
private fun searchArtists(uiState: PlayerUiState): List<ArtistCard> {
val results = uiState.searchResults ?: return emptyList()
return (results.artistMatches + results.trackArtists).distinctBy { it.id }
}
@Composable
internal fun LibraryContent(
uiState: PlayerUiState,
onProfileClick: () -> Unit,
onLoadLibrary: () -> Unit,
onRetryPlaylists: () -> Unit,
onRetryArtists: () -> Unit,
onLoadMoreArtists: () -> Unit,
onPlaylistClick: (PlaylistCard) -> Unit,
onArtistImageNeeded: (String?) -> Unit,
onArtistClick: (ArtistCard) -> Unit
) {
LaunchedEffect(Unit) {
onLoadLibrary()
}
val playlists = orderedLibraryPlaylists(uiState.playlists)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 148.dp),
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
item(span = { GridItemSpan(maxLineSpan) }) {
PlayerHeader(
title = "Your Library",
subtitle = librarySubtitle(uiState),
onProfileClick = onProfileClick
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
SectionTitle("Playlists")
}
when {
uiState.isPlaylistsLoading && playlists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
LibraryLoading()
}
}
uiState.playlistsError != null && playlists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
LibraryError(
message = uiState.playlistsError,
onRetry = onRetryPlaylists
)
}
}
playlists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
LibraryEmpty()
}
}
else -> {
items(
items = playlists,
key = { playlist -> "playlist-${playlist.id}-${playlist.title}" }
) { playlist ->
PlaylistTile(
playlist = playlist,
onClick = { onPlaylistClick(playlist) }
)
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "Artists",
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
when {
uiState.isLibraryArtistsLoading && uiState.libraryArtists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
LibraryLoading()
}
}
uiState.libraryArtistsError != null && uiState.libraryArtists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
LibraryArtistsError(
message = uiState.libraryArtistsError,
onRetry = onRetryArtists
)
}
}
uiState.libraryArtists.isEmpty() -> {
item(span = { GridItemSpan(maxLineSpan) }) {
LibraryArtistsEmpty()
}
}
else -> {
items(
items = uiState.libraryArtists,
key = { artist -> "artist-${artist.id}" }
) { artist ->
ArtistTile(
artist = artist,
bitmap = mediaImageFor(uiState.mediaImages, artist.imageUrl),
onArtistImageNeeded = onArtistImageNeeded,
onClick = { onArtistClick(artist) }
)
}
item(span = { GridItemSpan(maxLineSpan) }) {
LibraryArtistsFooter(
uiState = uiState,
onLoadMore = onLoadMoreArtists
)
}
}
}
}
}
@Composable
private fun PlaylistTile(
playlist: PlaylistCard,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
) {
AlbumArtwork(
colors = playlistArtworkColors(playlist),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
cornerRadius = 8
)
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(2.dp))
Text(
text = playlistMeta(playlist),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
internal fun PlaylistDetailContent(
uiState: PlayerUiState,
onBack: () -> Unit,
onRetry: () -> Unit,
onMediaImageNeeded: (String?) -> Unit,
onPlayClick: () -> Unit,
onShuffleClick: () -> Unit,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return
val tracks = uiState.playlistDetail?.tracks.orEmpty()
val heroTrack = tracks.firstOrNull { !it.coverUrl.isNullOrBlank() }
val heroImage = mediaImageFor(uiState.mediaImages, heroTrack?.coverUrl)
LaunchedEffect(heroTrack?.coverUrl) {
onMediaImageNeeded(heroTrack?.coverUrl)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 20.dp)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onBack)
.padding(vertical = 8.dp, horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
BackGlyph(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Playlist",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground
)
}
Spacer(modifier = Modifier.height(20.dp))
MediaArtwork(
title = playlist.title,
seedId = playlist.id,
bitmap = heroImage,
modifier = Modifier
.size(190.dp)
.align(Alignment.CenterHorizontally),
cornerRadius = 10
)
Spacer(modifier = Modifier.height(22.dp))
Text(
text = playlist.title,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
uiState.playlistDetail?.description
?.takeIf { it.isNotBlank() }
?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(6.dp))
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = playlistMeta(playlist),
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
PillAction(
label = "Play",
selected = true,
modifier = Modifier.weight(1f),
onClick = onPlayClick
)
Spacer(modifier = Modifier.width(12.dp))
PillAction(
label = "Shuffle",
selected = false,
modifier = Modifier.weight(1f),
onClick = onShuffleClick
)
}
Spacer(modifier = Modifier.height(24.dp))
when {
uiState.isPlaylistDetailLoading && uiState.playlistDetail == null -> PlaylistDetailLoading()
uiState.playlistDetailError != null && uiState.playlistDetail == null -> PlaylistDetailError(
message = uiState.playlistDetailError,
onRetry = onRetry
)
uiState.playlistDetail != null -> {
SectionTitle("Tracks")
Spacer(modifier = Modifier.height(12.dp))
if (tracks.isEmpty()) {
Text(
text = "No tracks yet",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
} else {
tracks.forEachIndexed { index, track ->
PlaylistTrackRow(
index = index + 1,
track = track,
bitmap = mediaImageFor(uiState.mediaImages, track.coverUrl),
isLiked = uiState.likedTrackIds.contains(track.id),
onMediaImageNeeded = onMediaImageNeeded,
onTrackClick = onTrackClick,
onToggleLike = { onToggleLike(track.id) },
onPlayNext = { onPlayNext(track) },
onPlayLast = { onPlayLast(track) },
onShare = { onShare(track) },
onAddToPlaylist = { onAddToPlaylist(track) }
)
if (index < tracks.lastIndex) {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
internal fun LibraryLoading() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp
)
}
}
@Composable
internal fun LibraryError(
message: String,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Could not load playlists",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(14.dp))
PillAction(
label = "Retry",
selected = true,
onClick = onRetry
)
}
}
}
@Composable
internal fun LibraryEmpty() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Text(
text = "No playlists yet",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
@Composable
private fun LibraryArtistsError(
message: String,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Could not load artists",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(14.dp))
PillAction(
label = "Retry",
selected = true,
onClick = onRetry
)
}
}
}
@Composable
private fun LibraryArtistsEmpty() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "No uploaded artists yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Artists from your uploads will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
}
@Composable
private fun LibraryArtistsFooter(
uiState: PlayerUiState,
onLoadMore: () -> Unit
) {
when {
uiState.isLibraryArtistsLoadingMore -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
uiState.libraryArtistsError != null -> {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Could not load more artists",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Retry",
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onLoadMore)
.padding(horizontal = 10.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = FurumiNeonPink
)
}
}
}
uiState.libraryArtistsHasMore -> {
LaunchedEffect(uiState.libraryArtists.size, uiState.libraryArtistsPage) {
onLoadMore()
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
else -> {
Text(
text = "${uiState.libraryArtists.size} artists loaded",
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 18.dp),
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
}
}
}
private fun orderedLibraryPlaylists(playlists: List<PlaylistCard>): List<PlaylistCard> {
val (likes, regular) = playlists.partition { playlist -> playlist.isLikesPlaylist() }
return likes + regular
}
private fun PlaylistCard.isLikesPlaylist(): Boolean {
return id == -1L || kind.equals("likes", ignoreCase = true)
}
private fun playlistArtworkColors(playlist: PlaylistCard): List<Color> {
return if (playlist.isLikesPlaylist()) {
listOf(FurumiNeonPink, FurumiHotOrange, FurumiBlack)
} else {
artistArtworkColors(playlist.id)
}
}
@Composable
internal fun PlaylistDetailLoading() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp
)
}
}
@Composable
internal fun PlaylistDetailError(
message: String,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Could not load playlist",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(14.dp))
PillAction(
label = "Retry",
selected = true,
onClick = onRetry
)
}
}
}
@Composable
internal fun GenreGrid() {
val genres = listOf(
"Synthwave" to listOf(FurumiNeonPink, FurumiNeonViolet),
"Electronic" to listOf(FurumiElectricCyan, FurumiSurfaceHigh),
"Indie" to listOf(FurumiHotOrange, FurumiNeonPink),
"Focus" to listOf(FurumiNeonViolet, FurumiSurface)
)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
genres.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { genre ->
GenreTile(
title = genre.first,
colors = genre.second,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@Composable
internal fun GenreTile(
title: String,
colors: List<Color>,
modifier: Modifier
) {
Box(
modifier = modifier
.height(110.dp)
.clip(RoundedCornerShape(8.dp))
.background(Brush.linearGradient(colors))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(8.dp))
.padding(14.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = Color.White
)
}
}
@@ -0,0 +1,223 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun TrackContextMenu(
expanded: Boolean,
isLiked: Boolean,
onDismiss: () -> Unit,
onToggleLike: () -> Unit,
onPlayNext: () -> Unit,
onPlayLast: () -> Unit,
onShare: () -> Unit,
onAddToPlaylist: () -> Unit
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(
text = { Text(if (isLiked) "Unlike" else "Like") },
onClick = onToggleLike
)
DropdownMenuItem(
text = { Text("Play next") },
onClick = onPlayNext
)
DropdownMenuItem(
text = { Text("Play last") },
onClick = onPlayLast
)
DropdownMenuItem(
text = { Text("Share") },
onClick = onShare
)
DropdownMenuItem(
text = { Text("Add to playlist") },
onClick = onAddToPlaylist
)
}
}
@Composable
internal fun AddToPlaylistDialog(
playlists: List<PlaylistCard>,
isLoading: Boolean,
onLoadPlaylists: () -> Unit,
onPlaylistSelected: (Long) -> Unit,
onDismiss: () -> Unit
) {
LaunchedEffect(Unit) {
onLoadPlaylists()
}
Surface(
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onDismiss),
color = Color.Black.copy(alpha = 0.6f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(300.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 = "Add to playlist",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(16.dp))
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
playlists.isEmpty() -> {
Text(
text = "No playlists found",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
else -> {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
playlists.forEach { playlist ->
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onPlaylistSelected(playlist.id) },
shape = RoundedCornerShape(8.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = playlist.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${playlist.trackCount} tracks",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Cancel",
modifier = Modifier
.align(Alignment.End)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onDismiss)
.padding(horizontal = 10.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = FurumiTextMuted
)
}
}
}
}
}
@@ -0,0 +1,84 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
internal enum class PlayerTab(
val label: String
) {
Global("Global"),
Search("Search"),
Library("Library")
}
@@ -0,0 +1,950 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.ConnectedDevice
import com.example.furumi_android.domain.model.ConnectedDevicesState
import com.example.furumi_android.domain.model.ConnectedJam
import com.example.furumi_android.domain.model.ConnectedJamUser
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun FullPlayerOverlay(
playback: AudioPlaybackState,
coverBitmap: Bitmap?,
mediaImages: Map<String, Bitmap>,
likedTrackIds: Set<Long>,
onMediaImageNeeded: (String?) -> Unit,
dragOffsetY: Float,
onDragOffsetChange: (Float) -> Unit,
onDismiss: () -> Unit,
onPlayPause: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onSeekToProgress: (Float) -> Unit,
onQueueTrackClick: (TrackCard, Int) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit,
onArtistClick: (com.example.furumi_android.domain.model.ArtistCard) -> Unit,
connectedDevicesState: ConnectedDevicesState?,
connectedDevicesError: String?,
onDeviceClick: (String) -> Unit,
onCreateJam: () -> Unit,
onJoinJam: (String) -> Unit,
onLeaveJam: (String) -> Unit,
jamInviteQuery: String,
jamInviteUsers: List<ConnectedJamUser>,
isJamInviteSearchLoading: Boolean,
onJamInviteQueryChange: (String) -> Unit,
onInviteToJam: (String, Long) -> Unit
) {
val track = playback.currentTrack ?: return
val queue = playback.queue
var isDevicesMenuOpen by rememberSaveable { mutableStateOf(false) }
BackHandler(enabled = isDevicesMenuOpen) {
isDevicesMenuOpen = false
}
LaunchedEffect(track.coverUrl) {
onMediaImageNeeded(track.coverUrl)
}
val density = LocalDensity.current
val dismissThresholdPx = with(density) { 150.dp.toPx() }
val flingVelocityThresholdPx = with(density) { 800.dp.toPx() }
val scrollState = rememberScrollState()
val currentOnDismiss by androidx.compose.runtime.rememberUpdatedState(onDismiss)
val nestedScrollConnection = remember(dragOffsetY) {
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
val dy = available.y
if (dy < 0 && dragOffsetY > 0f) {
val consumed = dy.coerceAtLeast(-dragOffsetY)
onDragOffsetChange((dragOffsetY + consumed).coerceAtLeast(0f))
return androidx.compose.ui.geometry.Offset(0f, consumed)
}
return androidx.compose.ui.geometry.Offset.Zero
}
override fun onPostScroll(
consumed: androidx.compose.ui.geometry.Offset,
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
val dy = available.y
// Only allow drag-to-dismiss when content is scrolled to top
// and user is actively dragging (not fling inertia from scrolling up)
if (dy > 0 && scrollState.value == 0
&& source == androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag) {
onDragOffsetChange(dragOffsetY + dy)
return androidx.compose.ui.geometry.Offset(0f, dy)
}
return androidx.compose.ui.geometry.Offset.Zero
}
override suspend fun onPreFling(
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
// Only consider dismiss if we actually have a drag offset
if (dragOffsetY <= 0f) {
return androidx.compose.ui.unit.Velocity.Zero
}
val shouldDismiss = dragOffsetY > dismissThresholdPx ||
available.y > flingVelocityThresholdPx
if (shouldDismiss) {
onDragOffsetChange(0f)
currentOnDismiss()
return available
}
val anim = Animatable(dragOffsetY)
anim.animateTo(0f) { onDragOffsetChange(value) }
onDragOffsetChange(0f)
return androidx.compose.ui.unit.Velocity.Zero
}
override suspend fun onPostFling(
consumed: androidx.compose.ui.unit.Velocity,
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
if (dragOffsetY > 0f) {
val shouldDismiss = dragOffsetY > dismissThresholdPx ||
available.y > flingVelocityThresholdPx
if (shouldDismiss) {
onDragOffsetChange(0f)
currentOnDismiss()
} else {
val anim = Animatable(dragOffsetY)
anim.animateTo(0f) { onDragOffsetChange(value) }
onDragOffsetChange(0f)
}
}
return available
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(0, dragOffsetY.roundToInt().coerceAtLeast(0)) }
.nestedScroll(nestedScrollConnection)
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = 24.dp, vertical = 18.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Now playing",
style = MaterialTheme.typography.labelLarge,
color = FurumiTextMuted
)
Row(verticalAlignment = Alignment.CenterVertically) {
RoundControlButton(
size = 42,
background = FurumiSurface,
onClick = { onShare(track) }
) {
ShareGlyph(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onBackground
)
}
Spacer(modifier = Modifier.width(12.dp))
RoundControlButton(
size = 42,
background = if (connectedDevicesState?.currentJamId != null) FurumiNeonPink else FurumiSurface,
onClick = { isDevicesMenuOpen = true }
) {
DevicesGlyph(
modifier = Modifier.size(22.dp),
color = if (connectedDevicesState?.currentJamId != null) FurumiBlack else MaterialTheme.colorScheme.onBackground
)
}
}
}
Spacer(modifier = Modifier.height(36.dp))
MediaArtwork(
title = track.title,
seedId = track.id,
bitmap = coverBitmap,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
cornerRadius = 12
)
Spacer(modifier = Modifier.height(28.dp))
Text(
text = track.title,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val artists = track.artistRefs.ifEmpty {
track.artists.mapIndexed { i, name -> com.example.furumi_android.domain.model.ArtistRef(id = -(i + 1L), name = name) }
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
artists.forEachIndexed { index, artistRef ->
Text(
text = artistRef.name,
style = MaterialTheme.typography.bodyLarge,
color = if (artistRef.id > 0) FurumiNeonPink else FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(enabled = artistRef.id > 0) {
onArtistClick(
com.example.furumi_android.domain.model.ArtistCard(
id = artistRef.id,
name = artistRef.name,
imageUrl = null,
releaseCount = 0,
trackCount = 0
)
)
}
)
if (index < artists.lastIndex) {
Text(
text = ", ",
style = MaterialTheme.typography.bodyLarge,
color = FurumiTextMuted
)
}
}
if (playback.errorMessage != null) {
Text(
text = "${playback.errorMessage}",
style = MaterialTheme.typography.bodyLarge,
color = FurumiHotOrange,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.height(26.dp))
PlayerProgress(
playback = playback,
onSeekToProgress = onSeekToProgress
)
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
RoundControlButton(
size = 48,
background = FurumiSurface,
onClick = onPrevious
) {
PreviousGlyph(Modifier.size(20.dp), MaterialTheme.colorScheme.onBackground)
}
RoundControlButton(
size = 72,
background = FurumiNeonPink,
onClick = onPlayPause
) {
PlaybackGlyph(
playback = playback,
playSize = 24,
pauseSize = 24
)
}
RoundControlButton(
size = 48,
background = FurumiSurface,
onClick = onNext
) {
NextGlyph(Modifier.size(20.dp), MaterialTheme.colorScheme.onBackground)
}
}
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))
}
}
}
}
if (isDevicesMenuOpen) {
DevicesJamMenu(
connectedDevicesState = connectedDevicesState,
connectedDevicesError = connectedDevicesError,
onDismiss = { isDevicesMenuOpen = false },
onDeviceClick = onDeviceClick,
onCreateJam = onCreateJam,
onJoinJam = onJoinJam,
onLeaveJam = onLeaveJam,
jamInviteQuery = jamInviteQuery,
jamInviteUsers = jamInviteUsers,
isJamInviteSearchLoading = isJamInviteSearchLoading,
onJamInviteQueryChange = onJamInviteQueryChange,
onInviteToJam = onInviteToJam
)
}
}
}
@Composable
internal fun PlayerProgress(
playback: AudioPlaybackState,
onSeekToProgress: (Float) -> Unit
) {
Column {
Slider(
value = playback.progress,
onValueChange = onSeekToProgress,
enabled = playback.durationMs > 0L,
colors = SliderDefaults.colors(
thumbColor = FurumiNeonPink,
activeTrackColor = FurumiNeonPink,
inactiveTrackColor = FurumiLine,
disabledThumbColor = FurumiTextMuted,
disabledActiveTrackColor = FurumiLine,
disabledInactiveTrackColor = FurumiLine
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = formatPlaybackTime(playback.positionMs),
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
Text(
text = formatPlaybackTime(playback.durationMs),
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
}
}
}
@Composable
private fun DevicesJamMenu(
connectedDevicesState: ConnectedDevicesState?,
connectedDevicesError: String?,
onDismiss: () -> Unit,
onDeviceClick: (String) -> Unit,
onCreateJam: () -> Unit,
onJoinJam: (String) -> Unit,
onLeaveJam: (String) -> Unit,
jamInviteQuery: String,
jamInviteUsers: List<ConnectedJamUser>,
isJamInviteSearchLoading: Boolean,
onJamInviteQueryChange: (String) -> Unit,
onInviteToJam: (String, Long) -> Unit
) {
Surface(
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onDismiss),
color = Color.Black.copy(alpha = 0.54f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.78f)
.clickable(enabled = false, onClick = {}),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 18.dp)
) {
Box(
modifier = Modifier
.width(42.dp)
.height(4.dp)
.clip(CircleShape)
.background(FurumiLine)
.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "Connect to a device",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Choose where Furumi plays.",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
connectedDevicesError?.let { error ->
Spacer(modifier = Modifier.height(14.dp))
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
color = FurumiHotOrange,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(18.dp))
DevicesMenuSection(
devices = connectedDevicesState?.devices.orEmpty(),
onDeviceClick = onDeviceClick
)
Spacer(modifier = Modifier.height(24.dp))
JamMenuSection(
connectedDevicesState = connectedDevicesState,
onCreateJam = onCreateJam,
onJoinJam = onJoinJam,
onLeaveJam = onLeaveJam,
jamInviteQuery = jamInviteQuery,
jamInviteUsers = jamInviteUsers,
isJamInviteSearchLoading = isJamInviteSearchLoading,
onJamInviteQueryChange = onJamInviteQueryChange,
onInviteToJam = onInviteToJam
)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
}
@Composable
private fun DevicesMenuSection(
devices: List<ConnectedDevice>,
onDeviceClick: (String) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionTitle("Devices")
if (devices.isEmpty()) {
Text(
text = "Looking for devices...",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
} else {
devices.forEach { device ->
DeviceMenuRow(
device = device,
onClick = { onDeviceClick(device.id) }
)
}
}
}
}
@Composable
private fun DeviceMenuRow(
device: ConnectedDevice,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
color = if (device.isActive) FurumiSurface else Color.Transparent,
border = if (device.isActive) androidx.compose.foundation.BorderStroke(1.dp, FurumiNeonPink) else null
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(if (device.isActive) FurumiNeonPink else FurumiLine)
)
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = device.name,
style = MaterialTheme.typography.labelLarge,
color = if (device.isActive) MaterialTheme.colorScheme.onBackground else FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (device.isCurrent || device.isActive) {
Text(
text = if (device.isCurrent) "Current device" else "Playing",
style = MaterialTheme.typography.bodySmall,
color = if (device.isActive) FurumiNeonPink else FurumiTextMuted,
maxLines = 1
)
}
}
if (device.isActive) {
DevicesGlyph(
modifier = Modifier.size(20.dp),
color = FurumiNeonPink
)
}
}
}
}
@Composable
private fun JamMenuSection(
connectedDevicesState: ConnectedDevicesState?,
onCreateJam: () -> Unit,
onJoinJam: (String) -> Unit,
onLeaveJam: (String) -> Unit,
jamInviteQuery: String,
jamInviteUsers: List<ConnectedJamUser>,
isJamInviteSearchLoading: Boolean,
onJamInviteQueryChange: (String) -> Unit,
onInviteToJam: (String, Long) -> Unit
) {
val jams = connectedDevicesState?.jams.orEmpty()
val currentJam = jams.firstOrNull { it.id == connectedDevicesState?.currentJamId }
?: jams.firstOrNull { it.isActive }
val invitedJams = jams.filter { it.isPending && !it.isMember }
val joinedJam = currentJam ?: jams.firstOrNull { it.isMember }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SectionTitle("Jam")
if (joinedJam != null) {
JamCard(
jam = joinedJam,
onJoin = { onJoinJam(joinedJam.id) },
onLeave = { onLeaveJam(joinedJam.id) },
inviteQuery = jamInviteQuery,
inviteUsers = jamInviteUsers,
isInviteSearchLoading = isJamInviteSearchLoading,
onInviteQueryChange = onJamInviteQueryChange,
onInvite = { userId -> onInviteToJam(joinedJam.id, userId) }
)
} else {
JamStartCard(onCreateJam = onCreateJam)
}
invitedJams.forEach { jam ->
JamInviteRow(
jam = jam,
onJoin = { onJoinJam(jam.id) }
)
}
}
}
@Composable
private fun JamCard(
jam: ConnectedJam,
onJoin: () -> Unit,
onLeave: () -> Unit,
inviteQuery: String,
inviteUsers: List<ConnectedJamUser>,
isInviteSearchLoading: Boolean,
onInviteQueryChange: (String) -> Unit,
onInvite: (Long) -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(FurumiNeonViolet.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Text("J", color = FurumiNeonViolet, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = jam.name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${jam.memberCount} listeners",
style = MaterialTheme.typography.bodySmall,
color = FurumiTextMuted
)
}
PillAction(
label = if (jam.isOwner) "End" else "Leave",
selected = false,
modifier = Modifier.height(32.dp).width(74.dp),
onClick = onLeave
)
}
if (jam.isMember) {
Spacer(modifier = Modifier.height(14.dp))
JamInviteSearch(
query = inviteQuery,
users = inviteUsers,
isLoading = isInviteSearchLoading,
onQueryChange = onInviteQueryChange,
onInvite = onInvite
)
} else {
Spacer(modifier = Modifier.height(12.dp))
PillAction(
label = "Join Jam",
selected = true,
modifier = Modifier.fillMaxWidth().height(40.dp),
onClick = onJoin
)
}
}
}
}
@Composable
private fun JamStartCard(
onCreateJam: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Start a Jam",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "Listen together from this device.",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
Spacer(modifier = Modifier.width(12.dp))
PillAction(
label = "Start",
selected = true,
modifier = Modifier.width(96.dp),
onClick = onCreateJam
)
}
}
}
@Composable
private fun JamInviteSearch(
query: String,
users: List<ConnectedJamUser>,
isLoading: Boolean,
onQueryChange: (String) -> Unit,
onInvite: (Long) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Invite people",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground
)
TextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier
.fillMaxWidth()
.height(54.dp),
shape = RoundedCornerShape(8.dp),
singleLine = true,
placeholder = {
Text(
text = "Search user",
style = MaterialTheme.typography.bodyLarge
)
},
leadingIcon = {
SearchGlyph(
modifier = Modifier.size(20.dp),
color = FurumiBlack
)
}
)
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(42.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = FurumiNeonPink,
strokeWidth = 2.dp
)
}
}
query.isNotBlank() && users.isEmpty() -> {
Text(
text = "No users found",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
else -> users.forEach { user ->
JamInviteUserRow(
user = user,
onInvite = { onInvite(user.id) }
)
}
}
}
}
@Composable
private fun JamInviteUserRow(
user: ConnectedJamUser,
onInvite: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = FurumiBlack.copy(alpha = 0.20f),
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.displayLabel,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = user.email?.takeIf { it.isNotBlank() } ?: "@${user.username}",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.width(12.dp))
PillAction(
label = "Invite",
selected = true,
modifier = Modifier.width(96.dp),
onClick = onInvite
)
}
}
}
@Composable
private fun JamInviteRow(
jam: ConnectedJam,
onJoin: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = FurumiBlack.copy(alpha = 0.20f),
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = jam.name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Invitation from ${jam.hostName}",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.width(12.dp))
PillAction(
label = "Join",
selected = true,
modifier = Modifier.width(92.dp),
onClick = onJoin
)
}
}
}
@Composable
internal fun RoundControlButton(
size: Int,
background: Color,
onClick: () -> Unit,
content: @Composable BoxScope.() -> Unit
) {
Box(
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(background)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
content = content
)
}
@@ -0,0 +1,329 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun ReleaseDetailContent(
uiState: PlayerUiState,
onBack: () -> Unit,
onRetry: () -> Unit,
onMediaImageNeeded: (String?) -> Unit,
onPlayClick: () -> Unit,
onShuffleClick: () -> Unit,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
val release = uiState.releaseDetail?.release ?: uiState.selectedRelease ?: return
val releaseImage = mediaImageFor(uiState.mediaImages, release.coverUrl)
val artists = uiState.releaseDetail?.artists.orEmpty()
LaunchedEffect(release.coverUrl) {
onMediaImageNeeded(release.coverUrl)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 20.dp)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onBack)
.padding(vertical = 8.dp, horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
BackGlyph(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Release",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground
)
}
Spacer(modifier = Modifier.height(20.dp))
ReleaseDetailHero(
release = release,
artistsText = releaseArtistsText(artists),
bitmap = releaseImage,
onPlayClick = onPlayClick,
onShuffleClick = onShuffleClick
)
Spacer(modifier = Modifier.height(28.dp))
when {
uiState.isReleaseDetailLoading && uiState.releaseDetail == null -> ReleaseDetailLoading()
uiState.releaseDetailError != null && uiState.releaseDetail == null -> ReleaseDetailError(
message = uiState.releaseDetailError,
onRetry = onRetry
)
uiState.releaseDetail != null -> ReleaseTracksSection(
tracks = uiState.releaseDetail.tracks,
likedTrackIds = uiState.likedTrackIds,
onTrackClick = onTrackClick,
onToggleLike = onToggleLike,
onPlayNext = onPlayNext,
onPlayLast = onPlayLast,
onShare = onShare,
onAddToPlaylist = onAddToPlaylist
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
internal fun ReleaseDetailHero(
release: ReleaseCard,
artistsText: String,
bitmap: Bitmap?,
onPlayClick: () -> Unit,
onShuffleClick: () -> Unit
) {
Column {
MediaArtwork(
title = release.title,
seedId = release.id,
bitmap = bitmap,
modifier = Modifier
.size(210.dp)
.align(Alignment.CenterHorizontally),
cornerRadius = 10
)
Spacer(modifier = Modifier.height(22.dp))
Text(
text = release.title,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = artistsText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = releaseMeta(release),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
PillAction(
label = "Play",
selected = true,
modifier = Modifier.weight(1f),
onClick = onPlayClick
)
Spacer(modifier = Modifier.width(12.dp))
PillAction(
label = "Shuffle",
selected = false,
modifier = Modifier.weight(1f),
onClick = onShuffleClick
)
}
}
}
@Composable
internal fun ReleaseDetailLoading() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp
)
}
}
@Composable
internal fun ReleaseDetailError(
message: String,
onRetry: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Could not load release",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(14.dp))
PillAction(
label = "Retry",
selected = true,
onClick = onRetry
)
}
}
}
@Composable
internal fun ReleaseTracksSection(
tracks: List<TrackCard>,
likedTrackIds: Set<Long>,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
SectionTitle("Tracks")
Spacer(modifier = Modifier.height(12.dp))
if (tracks.isEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Text(
text = "No tracks yet",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
return
}
val groupedTracks = tracks.groupBy { it.discNumber ?: 1 }.toSortedMap()
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
groupedTracks.forEach { (discNumber, discTracks) ->
if (groupedTracks.size > 1) {
Text(
text = "Disc $discNumber",
style = MaterialTheme.typography.labelLarge,
color = FurumiTextMuted
)
}
discTracks.forEachIndexed { index, track ->
ReleaseTrackRow(
fallbackIndex = index + 1,
track = track,
isLiked = likedTrackIds.contains(track.id),
onTrackClick = onTrackClick,
onToggleLike = { onToggleLike(track.id) },
onPlayNext = { onPlayNext(track) },
onPlayLast = { onPlayLast(track) },
onShare = { onShare(track) },
onAddToPlaylist = { onAddToPlaylist(track) }
)
}
}
}
}
@@ -0,0 +1,498 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
@Composable
internal fun ArtistDetailTrackRow(
index: Int,
track: TrackCard,
bitmap: Bitmap?,
isLiked: Boolean,
onMediaImageNeeded: (String?) -> Unit,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: () -> Unit,
onPlayNext: () -> Unit,
onPlayLast: () -> Unit,
onShare: () -> Unit,
onAddToPlaylist: () -> Unit
) {
var menuExpanded by remember { mutableStateOf(false) }
LaunchedEffect(track.coverUrl) {
onMediaImageNeeded(track.coverUrl)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = index.toString(),
modifier = Modifier.width(28.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
MediaArtwork(
title = track.title,
seedId = track.id,
bitmap = bitmap,
modifier = Modifier.size(48.dp),
cornerRadius = 6
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = track.artists.joinToString(", ")
.ifBlank { track.releaseTitle.orEmpty() }
.ifBlank { "Unknown artist" },
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
HeartGlyph(
isLiked = isLiked,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable(onClick = onToggleLike)
.padding(4.dp)
)
Box {
MoreDotsGlyph(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable { menuExpanded = true }
.padding(4.dp)
)
TrackContextMenu(
expanded = menuExpanded,
isLiked = isLiked,
onDismiss = { menuExpanded = false },
onToggleLike = { menuExpanded = false; onToggleLike() },
onPlayNext = { menuExpanded = false; onPlayNext() },
onPlayLast = { menuExpanded = false; onPlayLast() },
onShare = { menuExpanded = false; onShare() },
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
@Composable
internal fun ReleaseTrackRow(
fallbackIndex: Int,
track: TrackCard,
isLiked: Boolean,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: () -> Unit,
onPlayNext: () -> Unit,
onPlayLast: () -> Unit,
onShare: () -> Unit,
onAddToPlaylist: () -> Unit
) {
var menuExpanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = (track.trackNumber ?: fallbackIndex).toString(),
modifier = Modifier.width(32.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = track.artists.joinToString(", ").ifBlank { "Unknown artist" },
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
HeartGlyph(
isLiked = isLiked,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable(onClick = onToggleLike)
.padding(4.dp)
)
Box {
MoreDotsGlyph(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable { menuExpanded = true }
.padding(4.dp)
)
TrackContextMenu(
expanded = menuExpanded,
isLiked = isLiked,
onDismiss = { menuExpanded = false },
onToggleLike = { menuExpanded = false; onToggleLike() },
onPlayNext = { menuExpanded = false; onPlayNext() },
onPlayLast = { menuExpanded = false; onPlayLast() },
onShare = { menuExpanded = false; onShare() },
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
@Composable
internal fun PlaylistTrackRow(
index: Int,
track: TrackCard,
bitmap: Bitmap?,
isLiked: Boolean,
onMediaImageNeeded: (String?) -> Unit,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: () -> Unit,
onPlayNext: () -> Unit,
onPlayLast: () -> Unit,
onShare: () -> Unit,
onAddToPlaylist: () -> Unit
) {
var menuExpanded by remember { mutableStateOf(false) }
LaunchedEffect(track.coverUrl) {
onMediaImageNeeded(track.coverUrl)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = index.toString(),
modifier = Modifier.width(28.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
MediaArtwork(
title = track.title,
seedId = track.id,
bitmap = bitmap,
modifier = Modifier.size(48.dp),
cornerRadius = 6
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = trackSubtitle(track),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
HeartGlyph(
isLiked = isLiked,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable(onClick = onToggleLike)
.padding(4.dp)
)
Box {
MoreDotsGlyph(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable { menuExpanded = true }
.padding(4.dp)
)
TrackContextMenu(
expanded = menuExpanded,
isLiked = isLiked,
onDismiss = { menuExpanded = false },
onToggleLike = { menuExpanded = false; onToggleLike() },
onPlayNext = { menuExpanded = false; onPlayNext() },
onPlayLast = { menuExpanded = false; onPlayLast() },
onShare = { menuExpanded = false; onShare() },
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
@Composable
internal fun LibraryRows(
playlists: List<PlaylistCard>,
onPlaylistClick: (PlaylistCard) -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
playlists.forEach { playlist ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onPlaylistClick(playlist) },
verticalAlignment = Alignment.CenterVertically
) {
AlbumArtwork(
colors = artistArtworkColors(playlist.id),
modifier = Modifier.size(56.dp),
cornerRadius = 8
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = playlist.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = playlistMeta(playlist),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
}
}
}
@Composable
internal fun QueueTrackRow(
track: TrackCard,
bitmap: Bitmap?,
isLiked: Boolean,
isPlayed: Boolean = false,
isCurrent: Boolean = false,
onMediaImageNeeded: (String?) -> Unit,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onPlayNext: () -> Unit,
onPlayLast: () -> Unit,
onShare: () -> Unit,
onAddToPlaylist: () -> Unit
) {
var menuExpanded by remember { mutableStateOf(false) }
LaunchedEffect(track.coverUrl) {
onMediaImageNeeded(track.coverUrl)
}
val contentAlpha = if (isPlayed) 0.5f else 1.0f
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (isCurrent) FurumiSurface else Color.Transparent)
.clickable(onClick = onClick)
.padding(vertical = 4.dp, horizontal = if (isCurrent) 8.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(contentAlignment = Alignment.Center) {
MediaArtwork(
title = track.title,
seedId = track.id,
bitmap = bitmap,
modifier = Modifier
.size(48.dp)
.drawWithContent {
drawContent()
if (isPlayed) {
drawRect(color = Color.Black.copy(alpha = 0.3f))
}
},
cornerRadius = 6
)
if (isCurrent) {
Box(
modifier = Modifier
.size(48.dp)
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
AudioWaveGlyph(modifier = Modifier.size(20.dp), color = FurumiNeonPink)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = if (isCurrent) MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold) else MaterialTheme.typography.labelLarge,
color = if (isCurrent) FurumiNeonPink else MaterialTheme.colorScheme.onBackground.copy(alpha = contentAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = trackSubtitle(track),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted.copy(alpha = contentAlpha),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
HeartGlyph(
isLiked = isLiked,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable(onClick = onToggleLike)
.padding(4.dp),
alpha = contentAlpha
)
Box {
MoreDotsGlyph(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.clickable { menuExpanded = true }
.padding(4.dp)
)
TrackContextMenu(
expanded = menuExpanded,
isLiked = isLiked,
onDismiss = { menuExpanded = false },
onToggleLike = { menuExpanded = false; onToggleLike() },
onPlayNext = { menuExpanded = false; onPlayNext() },
onPlayLast = { menuExpanded = false; onPlayLast() },
onShare = { menuExpanded = false; onShare() },
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted.copy(alpha = contentAlpha)
)
}
}
@@ -127,11 +127,26 @@ fun PlayerScreen(
}
}
val profileScrollConnection = remember {
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): Offset {
if (isProfileMenuOpen && source == androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag) {
isProfileMenuOpen = false
}
return Offset.Zero
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing)
.nestedScroll(profileScrollConnection)
) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
@@ -153,22 +168,13 @@ fun PlayerScreen(
onRetry = viewModel::retryPlaylistDetail,
onMediaImageNeeded = viewModel::loadMediaImage,
onPlayClick = {
if (viewModel.playPlaylistTracks(shuffle = false)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playPlaylistTracks(shuffle = false)
},
onShuffleClick = {
if (viewModel.playPlaylistTracks(shuffle = true)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playPlaylistTracks(shuffle = true)
},
onTrackClick = { track ->
if (viewModel.playTrack(track, uiState.playlistDetail?.tracks.orEmpty())) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playTrack(track, uiState.playlistDetail?.tracks.orEmpty())
},
onToggleLike = viewModel::toggleLike,
onPlayNext = viewModel::addToPlayNext,
@@ -189,22 +195,13 @@ fun PlayerScreen(
onRetry = viewModel::retryReleaseDetail,
onMediaImageNeeded = viewModel::loadMediaImage,
onPlayClick = {
if (viewModel.playReleaseTracks(shuffle = false)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playReleaseTracks(shuffle = false)
},
onShuffleClick = {
if (viewModel.playReleaseTracks(shuffle = true)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playReleaseTracks(shuffle = true)
},
onTrackClick = { track ->
if (viewModel.playTrack(track, uiState.releaseDetail?.tracks.orEmpty())) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playTrack(track, uiState.releaseDetail?.tracks.orEmpty())
},
onToggleLike = viewModel::toggleLike,
onPlayNext = viewModel::addToPlayNext,
@@ -225,22 +222,13 @@ fun PlayerScreen(
onRetry = viewModel::retryArtistDetail,
onMediaImageNeeded = viewModel::loadMediaImage,
onPlayClick = {
if (viewModel.playArtistTracks(shuffle = false)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playArtistTracks(shuffle = false)
},
onRadioClick = {
if (viewModel.playArtistTracks(shuffle = true)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playArtistTracks(shuffle = true)
},
onTrackClick = { track ->
if (viewModel.playTrack(track, uiState.artistDetail?.topTracks.orEmpty())) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playTrack(track, uiState.artistDetail?.topTracks.orEmpty())
},
onReleaseClick = viewModel::openRelease,
onToggleLike = viewModel::toggleLike,
@@ -273,10 +261,7 @@ fun PlayerScreen(
onArtistClick = viewModel::openArtist,
onMediaImageNeeded = viewModel::loadMediaImage,
onTrackClick = { track ->
if (viewModel.playTrack(track, uiState.searchResults?.tracks.orEmpty())) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
viewModel.playTrack(track, uiState.searchResults?.tracks.orEmpty())
},
onToggleLike = viewModel::toggleLike,
onPlayNext = viewModel::addToPlayNext,
@@ -317,8 +302,7 @@ fun PlayerScreen(
onPlayPause = viewModel::togglePlayPause,
onPrevious = viewModel::previousTrack,
onNext = viewModel::nextTrack,
onToggleLike = { viewModel.toggleLike(track.id) },
onShare = { viewModel.shareTrack(track) }
onToggleLike = { viewModel.toggleLike(track.id) }
)
}
BottomPlayerNav(
@@ -363,14 +347,18 @@ fun PlayerScreen(
onPrevious = viewModel::previousTrack,
onNext = viewModel::nextTrack,
onSeekToProgress = viewModel::seekToPlaybackProgress,
onQueueTrackClick = { track ->
viewModel.playTrack(track, displayedPlayback.queue)
onQueueTrackClick = { track, index ->
viewModel.playTrack(track, displayedPlayback.queue, index)
},
onToggleLike = viewModel::toggleLike,
onPlayNext = viewModel::addToPlayNext,
onPlayLast = viewModel::addToQueueEnd,
onShare = viewModel::shareTrack,
onAddToPlaylist = { track -> addToPlaylistTrack = track },
onArtistClick = { artist ->
isFullPlayerOpen = false
viewModel.openArtist(artist)
},
connectedDevicesState = uiState.connectedDevicesState,
connectedDevicesError = uiState.connectedDevicesError,
onDeviceClick = viewModel::setActiveDevice,
@@ -0,0 +1,211 @@
package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.ui.theme.FurumiBlack
import com.example.furumi_android.ui.theme.FurumiElectricCyan
import com.example.furumi_android.ui.theme.FurumiHotOrange
import com.example.furumi_android.ui.theme.FurumiLine
import com.example.furumi_android.ui.theme.FurumiNeonPink
import com.example.furumi_android.ui.theme.FurumiNeonViolet
import com.example.furumi_android.ui.theme.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
internal fun artistsSubtitle(uiState: PlayerUiState): String {
return if (uiState.globalArtistsTotal > 0) {
pluralCount(uiState.globalArtistsTotal, "artist")
} else {
"Artists on this server"
}
}
internal fun librarySubtitle(uiState: PlayerUiState): String {
return if (uiState.libraryArtistsTotal > 0) {
pluralCount(uiState.libraryArtistsTotal, "uploaded artist")
} else {
"Your uploads"
}
}
internal fun pluralCount(count: Long, singular: String): String {
val label = if (count == 1L) singular else "${singular}s"
return "$count $label"
}
internal fun pluralCount(count: Int, singular: String): String {
return pluralCount(count.toLong(), singular)
}
internal fun formatListenedDuration(seconds: Int?): String {
if (seconds == null || seconds <= 0) return "--"
val minutes = seconds / 60
val remainingSeconds = seconds % 60
return "$minutes:${remainingSeconds.toString().padStart(2, '0')}"
}
internal fun formatDuration(seconds: Int?): String {
if (seconds == null || seconds <= 0) return "--"
val minutes = seconds / 60
val remainingSeconds = seconds % 60
return "$minutes:${remainingSeconds.toString().padStart(2, '0')}"
}
internal fun formatPlaybackTime(milliseconds: Long): String {
if (milliseconds <= 0L) return "0:00"
val totalSeconds = milliseconds / 1_000L
val minutes = totalSeconds / 60L
val seconds = totalSeconds % 60L
return "$minutes:${seconds.toString().padStart(2, '0')}"
}
internal fun trackSubtitle(track: TrackCard): String {
return track.artists.joinToString(", ")
.ifBlank { track.releaseTitle.orEmpty() }
.ifBlank { "Unknown artist" }
}
internal fun releaseMeta(release: ReleaseCard): String {
val parts = buildList {
release.year?.let { add(it.toString()) }
release.releaseType
?.replace('_', ' ')
?.takeIf { it.isNotBlank() }
?.let { add(it) }
add(pluralCount(release.trackCount, "track"))
}
return parts.joinToString(" / ")
}
internal fun releaseArtistsText(artists: List<ArtistCard>): String {
return artists.joinToString(", ") { it.name }.ifBlank { "Unknown artist" }
}
internal fun playlistMeta(playlist: PlaylistCard): String {
val parts = buildList {
playlist.kind
?.replace('_', ' ')
?.takeIf { it.isNotBlank() }
?.let { add(it) }
playlist.ownerName
?.takeIf { it.isNotBlank() }
?.let { add(it) }
add(pluralCount(playlist.trackCount, "track"))
}
return parts.joinToString(" / ")
}
internal fun mediaImageFor(mediaImages: Map<String, Bitmap>, url: String?): Bitmap? {
val imageUrl = url?.takeIf { it.isNotBlank() } ?: return null
mediaImages[imageUrl]?.let { return it }
val requestedResource = canonicalMediaImageResource(imageUrl)
return mediaImages.entries.firstOrNull { (cachedUrl, _) ->
canonicalMediaImageResource(cachedUrl) == requestedResource
}?.value
}
internal fun canonicalMediaImageResource(url: String): String {
val cleanUrl = url.substringBefore('?').trimEnd('/')
val lastSlash = cleanUrl.lastIndexOf('/')
if (lastSlash < 0) return cleanUrl
val variant = cleanUrl.substring(lastSlash + 1).lowercase()
if (variant !in imageVariantSegments) return cleanUrl
val withoutVariant = cleanUrl.substring(0, lastSlash)
val mediaId = withoutVariant.substringAfterLast('/')
return if (mediaId.toLongOrNull() != null) withoutVariant else cleanUrl
}
internal fun artistArtworkColors(artistId: Long): List<Color> {
val palettes = listOf(
listOf(FurumiNeonPink, FurumiNeonViolet, FurumiBlack),
listOf(FurumiElectricCyan, FurumiSurfaceHigh),
listOf(FurumiHotOrange, FurumiNeonPink),
listOf(FurumiNeonViolet, FurumiSurface),
listOf(FurumiSurfaceHigh, FurumiNeonPink)
)
return palettes[paletteIndex(artistId, palettes.size)]
}
internal fun historyArtworkColors(trackId: Long): List<Color> {
val palettes = listOf(
listOf(FurumiNeonPink, FurumiNeonViolet, FurumiBlack),
listOf(FurumiElectricCyan, FurumiSurfaceHigh),
listOf(FurumiHotOrange, FurumiNeonPink),
listOf(FurumiNeonViolet, FurumiSurface)
)
return palettes[paletteIndex(trackId, palettes.size)]
}
private fun paletteIndex(seedId: Long, paletteCount: Int): Int {
val remainder = (seedId % paletteCount).toInt()
return if (remainder >= 0) remainder else remainder + paletteCount
}
internal val imageVariantSegments = setOf("small", "medium", "large")
@@ -36,6 +36,7 @@ import javax.inject.Inject
data class PlayerUiState(
val userName: String = "Listener",
val appVersion: String = "",
val serverUrl: String = "",
val isLoggingOut: Boolean = false,
val isLoggedOut: Boolean = false,
@@ -93,7 +94,8 @@ class PlayerViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val playerRepository: PlayerRepository,
private val mediaImageLoader: MediaImageLoader,
private val playbackController: PlaybackController
private val playbackController: PlaybackController,
appClientInfo: com.example.furumi_android.data.remote.AppClientInfo
) : ViewModel() {
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
@@ -112,10 +114,12 @@ class PlayerViewModel @Inject constructor(
authRepository.getCurrentSession()?.let { session ->
PlayerUiState(
userName = session.user.name,
serverUrl = session.serverBaseUrl
serverUrl = session.serverBaseUrl,
appVersion = appClientInfo.version
)
} ?: PlayerUiState(
serverUrl = authRepository.getSavedServerUrl().orEmpty()
serverUrl = authRepository.getSavedServerUrl().orEmpty(),
appVersion = appClientInfo.version
)
)
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
@@ -315,33 +319,38 @@ class PlayerViewModel @Inject constructor(
loadReleaseDetail(release.id)
}
fun playTrack(track: TrackCard, queue: List<TrackCard>): Boolean {
if (track.streamUrl.isBlank()) return false
fun playTrack(track: TrackCard, queue: List<TrackCard>, index: Int? = null): Boolean {
if (track.streamUrl.isBlank() && index == null) return false
activeRemoteDeviceId()?.let { targetDeviceId ->
val playableQueue = queue
.ifEmpty { listOf(track) }
.filter { it.streamUrl.isNotBlank() }
.ifEmpty { listOf(track).filter { it.streamUrl.isNotBlank() } }
val startIndex = playableQueue.indexOfFirst { it.id == track.id }
val targetIndex = index ?: playableQueue.indexOfFirst { it.id == track.id }
.takeIf { it >= 0 }
?: 0
val remoteState = ConnectedPlaybackState(
track = playableQueue.getOrNull(startIndex) ?: track,
track = playableQueue.getOrNull(targetIndex) ?: track,
tracks = playableQueue,
index = startIndex,
index = targetIndex,
positionSeconds = 0.0,
durationSeconds = track.durationSeconds?.toDouble() ?: 0.0,
durationSeconds = (playableQueue.getOrNull(targetIndex) ?: track).durationSeconds?.toDouble() ?: 0.0,
paused = false,
shuffle = _uiState.value.connectedDevicesState?.remotePlaybackState?.shuffle ?: false,
repeatMode = _uiState.value.connectedDevicesState?.remotePlaybackState?.repeatMode ?: "off",
volume = _uiState.value.connectedDevicesState?.remotePlaybackState?.volume ?: 1.0
)
sendConnectedCommand(targetDeviceId, "play_track", payload = remoteState)
sendConnectedCommand(targetDeviceId, "play_from_index", payload = remoteState)
return true
}
playbackController.playTrack(track, queue)
if (index != null) {
playbackController.playQueue(queue, index)
} else {
playbackController.playTrack(track, queue)
}
return true
}
@@ -773,7 +782,10 @@ class PlayerViewModel @Inject constructor(
private suspend fun pollConnectedDevices() {
val state = _uiState.value
val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true
val playbackState = if (isCurrentDeviceActive && state.connectedDevicesState?.isControllingRemoteJam() != true) {
// Android follows the spec: only the active device sends its playback state.
// If current device is active and not controlling a remote Jam as a member, it sends its state.
val playbackState = if (isCurrentDeviceActive && !state.connectedDevicesState.isControllingRemoteJam()) {
state.playback.toConnectedPlaybackState()
} else {
null
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB