diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 950a2fa..5a2e70d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ android { minSdk = 24 targetSdk = 35 versionCode = 1 - versionName = "1.0" + versionName = "1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/example/furumi_android/data/local/ConnectedDeviceStorage.kt b/app/src/main/java/com/example/furumi_android/data/local/ConnectedDeviceStorage.kt new file mode 100644 index 0000000..8be04f9 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/data/local/ConnectedDeviceStorage.kt @@ -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}$") + } +} diff --git a/app/src/main/java/com/example/furumi_android/data/remote/AppClientInfo.kt b/app/src/main/java/com/example/furumi_android/data/remote/AppClientInfo.kt new file mode 100644 index 0000000..c907d27 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/data/remote/AppClientInfo.kt @@ -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" +} diff --git a/app/src/main/java/com/example/furumi_android/data/remote/model/ConnectedDeviceModels.kt b/app/src/main/java/com/example/furumi_android/data/remote/model/ConnectedDeviceModels.kt new file mode 100644 index 0000000..8b21177 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/data/remote/model/ConnectedDeviceModels.kt @@ -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 = 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 = emptyList(), + @param:Json(name = "jams") val jams: List = emptyList(), + @param:Json(name = "current_jam_id") val currentJamId: String? = null, + @param:Json(name = "commands") val commands: List = 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 = 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 = 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 = 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 = emptyList() +) + +data class ConnectedCommandPayloadBody( + @param:Json(name = "track") val track: TrackItemResponse? = null, + @param:Json(name = "tracks") val tracks: List? = 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, + val index: Int, + val positionSeconds: Double, + val durationSeconds: Double, + val paused: Boolean, + val shuffle: Boolean, + val repeatMode: String, + val volume: Double +) diff --git a/app/src/main/java/com/example/furumi_android/data/remote/model/PlayerResponses.kt b/app/src/main/java/com/example/furumi_android/data/remote/model/PlayerResponses.kt index c21a44a..e8dbca9 100644 --- a/app/src/main/java/com/example/furumi_android/data/remote/model/PlayerResponses.kt +++ b/app/src/main/java/com/example/furumi_android/data/remote/model/PlayerResponses.kt @@ -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 = emptyList(), @param:Json(name = "featured_artists") val featuredArtists: List = 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 ) } diff --git a/app/src/main/java/com/example/furumi_android/domain/model/ConnectedDevice.kt b/app/src/main/java/com/example/furumi_android/domain/model/ConnectedDevice.kt new file mode 100644 index 0000000..3e98802 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/domain/model/ConnectedDevice.kt @@ -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, + val jams: List, + 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 +) + +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, + 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, + val index: Int?, + val time: Double?, + val volume: Double?, + val shuffle: Boolean?, + val repeatMode: String?, + val fromIndex: Int?, + val toIndex: Int? +) diff --git a/app/src/main/java/com/example/furumi_android/playback/PlaybackController.kt b/app/src/main/java/com/example/furumi_android/playback/PlaybackController.kt index 26df275..81fc34b 100644 --- a/app/src/main/java/com/example/furumi_android/playback/PlaybackController.kt +++ b/app/src/main/java/com/example/furumi_android/playback/PlaybackController.kt @@ -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 diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerArtwork.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerArtwork.kt new file mode 100644 index 0000000..4a428df --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerArtwork.kt @@ -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, + 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 + ) + } + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerChrome.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerChrome.kt new file mode 100644 index 0000000..dbcbcdd --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerChrome.kt @@ -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) { + 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 + ) + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerGlobalContent.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerGlobalContent.kt new file mode 100644 index 0000000..9c60e54 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerGlobalContent.kt @@ -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, + mediaImages: Map, + likedTrackIds: Set, + 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, + mediaImages: Map, + 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, + mediaImages: Map, + 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 +) + +private fun releaseSections(releases: List): List { + 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 + ) + } + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerHistoryContent.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerHistoryContent.kt new file mode 100644 index 0000000..5342f21 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerHistoryContent.kt @@ -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) { + 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 + ) + } + } + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerIcons.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerIcons.kt new file mode 100644 index 0000000..b2611dd --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerIcons.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerLibraryContent.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerLibraryContent.kt new file mode 100644 index 0000000..3bda23e --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerLibraryContent.kt @@ -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, + mediaImages: Map, + 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, + mediaImages: Map, + likedTrackIds: Set, + 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 { + 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): List { + 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 { + 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, + 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 + ) + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerMenus.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerMenus.kt new file mode 100644 index 0000000..7402264 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerMenus.kt @@ -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, + 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 + ) + } + } + } + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerModels.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerModels.kt new file mode 100644 index 0000000..2dbd0a6 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerModels.kt @@ -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") +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerOverlay.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerOverlay.kt new file mode 100644 index 0000000..982c424 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerOverlay.kt @@ -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, + likedTrackIds: Set, + 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, + 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, + 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, + 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, + 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, + 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, + 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 + ) +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerReleaseContent.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerReleaseContent.kt new file mode 100644 index 0000000..58a7d96 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerReleaseContent.kt @@ -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, + likedTrackIds: Set, + 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) } + ) + } + } + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerRows.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerRows.kt new file mode 100644 index 0000000..b619539 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerRows.kt @@ -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, + 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) + ) + } +} diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerScreen.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerScreen.kt index 1f701be..cc9ba39 100644 --- a/app/src/main/java/com/example/furumi_android/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerScreen.kt @@ -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, diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerUiUtils.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerUiUtils.kt new file mode 100644 index 0000000..170e207 --- /dev/null +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerUiUtils.kt @@ -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): 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, 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 { + 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 { + 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") diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerViewModel.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerViewModel.kt index 79012b6..f446b9a 100644 --- a/app/src/main/java/com/example/furumi_android/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerViewModel.kt @@ -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() @@ -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 = _uiState.asStateFlow() @@ -315,33 +319,38 @@ class PlayerViewModel @Inject constructor( loadReleaseDetail(release.id) } - fun playTrack(track: TrackCard, queue: List): Boolean { - if (track.streamUrl.isBlank()) return false + fun playTrack(track: TrackCard, queue: List, 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 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..1981caa Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..47560d2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b72544f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..6492af3 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..16bd219 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/furumi-icon.png b/furumi-icon.png new file mode 100644 index 0000000..10b8851 Binary files /dev/null and b/furumi-icon.png differ