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