Fix queue display, reworked connected devices
This commit is contained in:
@@ -14,7 +14,7 @@ android {
|
|||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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"
|
||||||
|
}
|
||||||
+312
@@ -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
|
import com.squareup.moshi.Json
|
||||||
|
|
||||||
data class ArtistRefResponse(
|
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
|
@param:Json(name = "name") val name: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class TrackItemResponse(
|
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 = "title") val title: String,
|
||||||
@param:Json(name = "track_number") val trackNumber: Int? = null,
|
@param:Json(name = "track_number") val trackNumber: Int? = null,
|
||||||
@param:Json(name = "disc_number") val discNumber: 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 = "artists") val artists: List<ArtistRefResponse> = emptyList(),
|
||||||
@param:Json(name = "featured_artists") val featuredArtists: 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 = "release_title") val releaseTitle: String? = null,
|
||||||
@param:Json(name = "cover_url") val coverUrl: 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(
|
data class PlayHistoryItemResponse(
|
||||||
@@ -246,10 +246,10 @@ fun ReleaseDetailResponse.toDomain(baseUrl: String): ReleaseDetail {
|
|||||||
fun SearchResponse.toDomain(baseUrl: String): SearchResults {
|
fun SearchResponse.toDomain(baseUrl: String): SearchResults {
|
||||||
val trackArtists = tracks
|
val trackArtists = tracks
|
||||||
.flatMap { track -> track.artists.ifEmpty { track.featuredArtists } }
|
.flatMap { track -> track.artists.ifEmpty { track.featuredArtists } }
|
||||||
.distinctBy { it.id }
|
.distinctBy { it.id ?: it.name.hashCode().toLong() }
|
||||||
.map { artist ->
|
.map { artist ->
|
||||||
ArtistCard(
|
ArtistCard(
|
||||||
id = artist.id,
|
id = artist.id ?: artist.name.hashCode().toLong(),
|
||||||
name = artist.name,
|
name = artist.name,
|
||||||
imageUrl = null,
|
imageUrl = null,
|
||||||
releaseCount = 0,
|
releaseCount = 0,
|
||||||
@@ -293,11 +293,11 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
|||||||
.map { it.name }
|
.map { it.name }
|
||||||
|
|
||||||
return TrackCard(
|
return TrackCard(
|
||||||
id = id,
|
id = id ?: 0L,
|
||||||
title = title,
|
title = title,
|
||||||
trackNumber = trackNumber,
|
trackNumber = trackNumber,
|
||||||
discNumber = discNumber,
|
discNumber = discNumber,
|
||||||
durationSeconds = durationSeconds.toInt(),
|
durationSeconds = durationSeconds?.toInt() ?: 0,
|
||||||
artists = artistNames,
|
artists = artistNames,
|
||||||
artistRefs = mappedArtists,
|
artistRefs = mappedArtists,
|
||||||
featuredArtistRefs = mappedFeaturedArtists,
|
featuredArtistRefs = mappedFeaturedArtists,
|
||||||
@@ -330,7 +330,7 @@ fun TrackCard.toTrackItemResponse(): TrackItemResponse {
|
|||||||
|
|
||||||
private fun ArtistRefResponse.toDomain(): ArtistRef {
|
private fun ArtistRefResponse.toDomain(): ArtistRef {
|
||||||
return ArtistRef(
|
return ArtistRef(
|
||||||
id = id,
|
id = id ?: 0L,
|
||||||
name = name
|
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)
|
reportCurrentTrackPlay(completed = completed)
|
||||||
startTrackTimer()
|
startTrackTimer()
|
||||||
publishState()
|
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) {
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
@@ -442,7 +443,18 @@ class PlaybackController @Inject constructor(
|
|||||||
private fun publishState() {
|
private fun publishState() {
|
||||||
val existingErrorMessage = _state.value.errorMessage
|
val existingErrorMessage = _state.value.errorMessage
|
||||||
val currentQueue = _state.value.queue
|
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
|
val duration = player.duration.takeIf { it > 0L } ?: currentQueue
|
||||||
.getOrNull(currentIndex)
|
.getOrNull(currentIndex)
|
||||||
?.durationSeconds
|
?.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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.nestedScroll(profileScrollConnection)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
@@ -153,22 +168,13 @@ fun PlayerScreen(
|
|||||||
onRetry = viewModel::retryPlaylistDetail,
|
onRetry = viewModel::retryPlaylistDetail,
|
||||||
onMediaImageNeeded = viewModel::loadMediaImage,
|
onMediaImageNeeded = viewModel::loadMediaImage,
|
||||||
onPlayClick = {
|
onPlayClick = {
|
||||||
if (viewModel.playPlaylistTracks(shuffle = false)) {
|
viewModel.playPlaylistTracks(shuffle = false)
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onShuffleClick = {
|
onShuffleClick = {
|
||||||
if (viewModel.playPlaylistTracks(shuffle = true)) {
|
viewModel.playPlaylistTracks(shuffle = true)
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onTrackClick = { track ->
|
onTrackClick = { track ->
|
||||||
if (viewModel.playTrack(track, uiState.playlistDetail?.tracks.orEmpty())) {
|
viewModel.playTrack(track, uiState.playlistDetail?.tracks.orEmpty())
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onToggleLike = viewModel::toggleLike,
|
onToggleLike = viewModel::toggleLike,
|
||||||
onPlayNext = viewModel::addToPlayNext,
|
onPlayNext = viewModel::addToPlayNext,
|
||||||
@@ -189,22 +195,13 @@ fun PlayerScreen(
|
|||||||
onRetry = viewModel::retryReleaseDetail,
|
onRetry = viewModel::retryReleaseDetail,
|
||||||
onMediaImageNeeded = viewModel::loadMediaImage,
|
onMediaImageNeeded = viewModel::loadMediaImage,
|
||||||
onPlayClick = {
|
onPlayClick = {
|
||||||
if (viewModel.playReleaseTracks(shuffle = false)) {
|
viewModel.playReleaseTracks(shuffle = false)
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onShuffleClick = {
|
onShuffleClick = {
|
||||||
if (viewModel.playReleaseTracks(shuffle = true)) {
|
viewModel.playReleaseTracks(shuffle = true)
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onTrackClick = { track ->
|
onTrackClick = { track ->
|
||||||
if (viewModel.playTrack(track, uiState.releaseDetail?.tracks.orEmpty())) {
|
viewModel.playTrack(track, uiState.releaseDetail?.tracks.orEmpty())
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onToggleLike = viewModel::toggleLike,
|
onToggleLike = viewModel::toggleLike,
|
||||||
onPlayNext = viewModel::addToPlayNext,
|
onPlayNext = viewModel::addToPlayNext,
|
||||||
@@ -225,22 +222,13 @@ fun PlayerScreen(
|
|||||||
onRetry = viewModel::retryArtistDetail,
|
onRetry = viewModel::retryArtistDetail,
|
||||||
onMediaImageNeeded = viewModel::loadMediaImage,
|
onMediaImageNeeded = viewModel::loadMediaImage,
|
||||||
onPlayClick = {
|
onPlayClick = {
|
||||||
if (viewModel.playArtistTracks(shuffle = false)) {
|
viewModel.playArtistTracks(shuffle = false)
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onRadioClick = {
|
onRadioClick = {
|
||||||
if (viewModel.playArtistTracks(shuffle = true)) {
|
viewModel.playArtistTracks(shuffle = true)
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onTrackClick = { track ->
|
onTrackClick = { track ->
|
||||||
if (viewModel.playTrack(track, uiState.artistDetail?.topTracks.orEmpty())) {
|
viewModel.playTrack(track, uiState.artistDetail?.topTracks.orEmpty())
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onReleaseClick = viewModel::openRelease,
|
onReleaseClick = viewModel::openRelease,
|
||||||
onToggleLike = viewModel::toggleLike,
|
onToggleLike = viewModel::toggleLike,
|
||||||
@@ -273,10 +261,7 @@ fun PlayerScreen(
|
|||||||
onArtistClick = viewModel::openArtist,
|
onArtistClick = viewModel::openArtist,
|
||||||
onMediaImageNeeded = viewModel::loadMediaImage,
|
onMediaImageNeeded = viewModel::loadMediaImage,
|
||||||
onTrackClick = { track ->
|
onTrackClick = { track ->
|
||||||
if (viewModel.playTrack(track, uiState.searchResults?.tracks.orEmpty())) {
|
viewModel.playTrack(track, uiState.searchResults?.tracks.orEmpty())
|
||||||
isFullPlayerOpen = true
|
|
||||||
fullPlayerDragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onToggleLike = viewModel::toggleLike,
|
onToggleLike = viewModel::toggleLike,
|
||||||
onPlayNext = viewModel::addToPlayNext,
|
onPlayNext = viewModel::addToPlayNext,
|
||||||
@@ -317,8 +302,7 @@ fun PlayerScreen(
|
|||||||
onPlayPause = viewModel::togglePlayPause,
|
onPlayPause = viewModel::togglePlayPause,
|
||||||
onPrevious = viewModel::previousTrack,
|
onPrevious = viewModel::previousTrack,
|
||||||
onNext = viewModel::nextTrack,
|
onNext = viewModel::nextTrack,
|
||||||
onToggleLike = { viewModel.toggleLike(track.id) },
|
onToggleLike = { viewModel.toggleLike(track.id) }
|
||||||
onShare = { viewModel.shareTrack(track) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BottomPlayerNav(
|
BottomPlayerNav(
|
||||||
@@ -363,14 +347,18 @@ fun PlayerScreen(
|
|||||||
onPrevious = viewModel::previousTrack,
|
onPrevious = viewModel::previousTrack,
|
||||||
onNext = viewModel::nextTrack,
|
onNext = viewModel::nextTrack,
|
||||||
onSeekToProgress = viewModel::seekToPlaybackProgress,
|
onSeekToProgress = viewModel::seekToPlaybackProgress,
|
||||||
onQueueTrackClick = { track ->
|
onQueueTrackClick = { track, index ->
|
||||||
viewModel.playTrack(track, displayedPlayback.queue)
|
viewModel.playTrack(track, displayedPlayback.queue, index)
|
||||||
},
|
},
|
||||||
onToggleLike = viewModel::toggleLike,
|
onToggleLike = viewModel::toggleLike,
|
||||||
onPlayNext = viewModel::addToPlayNext,
|
onPlayNext = viewModel::addToPlayNext,
|
||||||
onPlayLast = viewModel::addToQueueEnd,
|
onPlayLast = viewModel::addToQueueEnd,
|
||||||
onShare = viewModel::shareTrack,
|
onShare = viewModel::shareTrack,
|
||||||
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
isFullPlayerOpen = false
|
||||||
|
viewModel.openArtist(artist)
|
||||||
|
},
|
||||||
connectedDevicesState = uiState.connectedDevicesState,
|
connectedDevicesState = uiState.connectedDevicesState,
|
||||||
connectedDevicesError = uiState.connectedDevicesError,
|
connectedDevicesError = uiState.connectedDevicesError,
|
||||||
onDeviceClick = viewModel::setActiveDevice,
|
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(
|
data class PlayerUiState(
|
||||||
val userName: String = "Listener",
|
val userName: String = "Listener",
|
||||||
|
val appVersion: String = "",
|
||||||
val serverUrl: String = "",
|
val serverUrl: String = "",
|
||||||
val isLoggingOut: Boolean = false,
|
val isLoggingOut: Boolean = false,
|
||||||
val isLoggedOut: Boolean = false,
|
val isLoggedOut: Boolean = false,
|
||||||
@@ -93,7 +94,8 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val playerRepository: PlayerRepository,
|
private val playerRepository: PlayerRepository,
|
||||||
private val mediaImageLoader: MediaImageLoader,
|
private val mediaImageLoader: MediaImageLoader,
|
||||||
private val playbackController: PlaybackController
|
private val playbackController: PlaybackController,
|
||||||
|
appClientInfo: com.example.furumi_android.data.remote.AppClientInfo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
|
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
|
||||||
@@ -112,10 +114,12 @@ class PlayerViewModel @Inject constructor(
|
|||||||
authRepository.getCurrentSession()?.let { session ->
|
authRepository.getCurrentSession()?.let { session ->
|
||||||
PlayerUiState(
|
PlayerUiState(
|
||||||
userName = session.user.name,
|
userName = session.user.name,
|
||||||
serverUrl = session.serverBaseUrl
|
serverUrl = session.serverBaseUrl,
|
||||||
|
appVersion = appClientInfo.version
|
||||||
)
|
)
|
||||||
} ?: PlayerUiState(
|
} ?: PlayerUiState(
|
||||||
serverUrl = authRepository.getSavedServerUrl().orEmpty()
|
serverUrl = authRepository.getSavedServerUrl().orEmpty(),
|
||||||
|
appVersion = appClientInfo.version
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
||||||
@@ -315,33 +319,38 @@ class PlayerViewModel @Inject constructor(
|
|||||||
loadReleaseDetail(release.id)
|
loadReleaseDetail(release.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTrack(track: TrackCard, queue: List<TrackCard>): Boolean {
|
fun playTrack(track: TrackCard, queue: List<TrackCard>, index: Int? = null): Boolean {
|
||||||
if (track.streamUrl.isBlank()) return false
|
if (track.streamUrl.isBlank() && index == null) return false
|
||||||
|
|
||||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||||
val playableQueue = queue
|
val playableQueue = queue
|
||||||
.ifEmpty { listOf(track) }
|
.ifEmpty { listOf(track) }
|
||||||
.filter { it.streamUrl.isNotBlank() }
|
.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 }
|
.takeIf { it >= 0 }
|
||||||
?: 0
|
?: 0
|
||||||
|
|
||||||
val remoteState = ConnectedPlaybackState(
|
val remoteState = ConnectedPlaybackState(
|
||||||
track = playableQueue.getOrNull(startIndex) ?: track,
|
track = playableQueue.getOrNull(targetIndex) ?: track,
|
||||||
tracks = playableQueue,
|
tracks = playableQueue,
|
||||||
index = startIndex,
|
index = targetIndex,
|
||||||
positionSeconds = 0.0,
|
positionSeconds = 0.0,
|
||||||
durationSeconds = track.durationSeconds?.toDouble() ?: 0.0,
|
durationSeconds = (playableQueue.getOrNull(targetIndex) ?: track).durationSeconds?.toDouble() ?: 0.0,
|
||||||
paused = false,
|
paused = false,
|
||||||
shuffle = _uiState.value.connectedDevicesState?.remotePlaybackState?.shuffle ?: false,
|
shuffle = _uiState.value.connectedDevicesState?.remotePlaybackState?.shuffle ?: false,
|
||||||
repeatMode = _uiState.value.connectedDevicesState?.remotePlaybackState?.repeatMode ?: "off",
|
repeatMode = _uiState.value.connectedDevicesState?.remotePlaybackState?.repeatMode ?: "off",
|
||||||
volume = _uiState.value.connectedDevicesState?.remotePlaybackState?.volume ?: 1.0
|
volume = _uiState.value.connectedDevicesState?.remotePlaybackState?.volume ?: 1.0
|
||||||
)
|
)
|
||||||
sendConnectedCommand(targetDeviceId, "play_track", payload = remoteState)
|
sendConnectedCommand(targetDeviceId, "play_from_index", payload = remoteState)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackController.playTrack(track, queue)
|
if (index != null) {
|
||||||
|
playbackController.playQueue(queue, index)
|
||||||
|
} else {
|
||||||
|
playbackController.playTrack(track, queue)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,7 +782,10 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private suspend fun pollConnectedDevices() {
|
private suspend fun pollConnectedDevices() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true
|
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()
|
state.playback.toConnectedPlaybackState()
|
||||||
} else {
|
} else {
|
||||||
null
|
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 |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Reference in New Issue
Block a user