added some features from web player

This commit is contained in:
2026-06-05 14:39:58 +03:00
parent a8e53e344c
commit cc3d12bfb8
12 changed files with 1050 additions and 88 deletions
+4 -7
View File
@@ -2,15 +2,12 @@
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="App">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-05T00:15:24.764079Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R5CW6292TGT" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
@@ -9,16 +9,12 @@ import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -39,7 +35,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
requestNotificationPermissionIfNeeded()
if (deepLinkUri == null) {
@@ -48,14 +47,10 @@ class MainActivity : ComponentActivity() {
setContent {
FurumiandroidTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
FurumiApp(
deepLinkUri = deepLinkUri,
onDeepLinkHandled = { deepLinkUri = null }
)
}
}
FurumiApp(
deepLinkUri = deepLinkUri,
onDeepLinkHandled = { deepLinkUri = null }
)
}
}
}
@@ -11,9 +11,25 @@ class PlayerEndpoints @Inject constructor() {
fun history(baseUrl: String): String = "$baseUrl$HISTORY_PATH"
fun likesToggle(baseUrl: String, trackId: Long): String = "$baseUrl$LIKES_TOGGLE_PATH/$trackId"
fun likes(baseUrl: String): String = "$baseUrl$LIKES_PATH"
fun recordHistory(baseUrl: String): String = "$baseUrl$HISTORY_PATH"
fun playlists(baseUrl: String): String = "$baseUrl$PLAYLISTS_PATH"
fun addPlaylistTracks(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId/tracks"
fun sharePlaylist(baseUrl: String): String = "$baseUrl$SHARE_PLAYLIST_PATH"
companion object {
private const val ARTISTS_PATH = "/api/player/artists"
private const val RELEASES_PATH = "/api/player/releases"
private const val HISTORY_PATH = "/api/player/history"
private const val LIKES_PATH = "/api/player/likes"
private const val LIKES_TOGGLE_PATH = "/api/player/likes/toggle"
private const val PLAYLISTS_PATH = "/api/player/playlists"
private const val SHARE_PLAYLIST_PATH = "/api/player/share-playlist"
}
}
@@ -4,9 +4,18 @@ import com.example.furumi_android.data.remote.model.ArtistPageResponse
import com.example.furumi_android.data.remote.model.ArtistDetailResponse
import com.example.furumi_android.data.remote.model.PlayHistoryPageResponse
import com.example.furumi_android.data.remote.model.ReleaseDetailResponse
import com.example.furumi_android.data.remote.model.AddPlaylistTracksRequest
import com.example.furumi_android.data.remote.model.LikeToggleResponse
import com.example.furumi_android.data.remote.model.LikedTrackIdsResponse
import com.example.furumi_android.data.remote.model.PlaylistListResponse
import com.example.furumi_android.data.remote.model.RecordHistoryRequest
import com.example.furumi_android.data.remote.model.SharePlaylistRequest
import com.example.furumi_android.data.remote.model.SharePlaylistResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Url
@@ -39,4 +48,43 @@ interface PlayerApi {
@Query("page") page: Int,
@Query("limit") limit: Int
): Response<PlayHistoryPageResponse>
@Headers("Accept: application/json")
@POST
suspend fun toggleLike(
@Url url: String
): Response<LikeToggleResponse>
@Headers("Accept: application/json")
@GET
suspend fun likes(
@Url url: String
): Response<LikedTrackIdsResponse>
@Headers("Accept: application/json", "Content-Type: application/json")
@POST
suspend fun recordHistory(
@Url url: String,
@Body body: RecordHistoryRequest
): Response<Unit>
@Headers("Accept: application/json")
@GET
suspend fun playlists(
@Url url: String
): Response<PlaylistListResponse>
@Headers("Accept: application/json", "Content-Type: application/json")
@POST
suspend fun addTracksToPlaylist(
@Url url: String,
@Body body: AddPlaylistTracksRequest
): Response<Unit>
@Headers("Accept: application/json", "Content-Type: application/json")
@POST
suspend fun sharePlaylist(
@Url url: String,
@Body body: SharePlaylistRequest
): Response<SharePlaylistResponse>
}
@@ -5,6 +5,7 @@ import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ArtistPage
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.domain.model.ListeningHistoryPage
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.ReleaseDetail
import com.example.furumi_android.domain.model.TrackCard
@@ -84,6 +85,50 @@ data class ArtistDetailResponse(
@param:Json(name = "featured_tracks") val featuredTracks: List<TrackItemResponse> = emptyList()
)
data class LikeToggleRequest(
@param:Json(name = "track_id") val trackId: Long
)
data class LikeToggleResponse(
@param:Json(name = "liked") val liked: Boolean
)
data class LikedTrackIdsResponse(
@param:Json(name = "track_ids") val trackIds: List<Long>
)
data class RecordHistoryRequest(
@param:Json(name = "track_id") val trackId: Long,
@param:Json(name = "started_at") val startedAt: String,
@param:Json(name = "duration_listened") val durationListened: Double,
@param:Json(name = "completed") val completed: Boolean
)
data class PlaylistCardResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "title") val title: String,
@param:Json(name = "track_count") val trackCount: Int,
@param:Json(name = "is_public") val isPublic: Boolean
)
data class PlaylistListResponse(
@param:Json(name = "playlists") val playlists: List<PlaylistCardResponse>
)
data class AddPlaylistTracksRequest(
@param:Json(name = "track_ids") val trackIds: List<Long>
)
data class SharePlaylistRequest(
@param:Json(name = "track_ids") val trackIds: List<Long>,
@param:Json(name = "title") val title: String
)
data class SharePlaylistResponse(
@param:Json(name = "token") val token: String,
@param:Json(name = "url") val url: String
)
data class ReleaseDetailResponse(
@param:Json(name = "release") val release: ReleaseCardResponse? = null,
@param:Json(name = "id") val id: Long? = null,
@@ -210,6 +255,17 @@ private fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
)
}
fun PlaylistListResponse.toDomain(): List<PlaylistCard> {
return playlists.map {
PlaylistCard(
id = it.id,
title = it.title,
trackCount = it.trackCount,
isPublic = it.isPublic
)
}
}
private fun String?.toAbsoluteMediaUrl(baseUrl: String): String? {
val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return null
if (value.startsWith("http://", ignoreCase = true) ||
@@ -4,11 +4,15 @@ import com.example.furumi_android.data.local.AuthSessionStorage
import com.example.furumi_android.data.remote.AuthApiErrorParser
import com.example.furumi_android.data.remote.PlayerEndpoints
import com.example.furumi_android.data.remote.api.PlayerApi
import com.example.furumi_android.data.remote.model.AddPlaylistTracksRequest
import com.example.furumi_android.data.remote.model.RecordHistoryRequest
import com.example.furumi_android.data.remote.model.SharePlaylistRequest
import com.example.furumi_android.data.remote.model.toDomain
import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ArtistPage
import com.example.furumi_android.domain.model.AuthException
import com.example.furumi_android.domain.model.ListeningHistoryPage
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseDetail
import com.example.furumi_android.domain.repository.PlayerRepository
import kotlinx.coroutines.CancellationException
@@ -109,4 +113,144 @@ class PlayerRepositoryImpl @Inject constructor(
Result.failure(e)
}
}
override suspend fun toggleLike(trackId: Long): Result<Boolean> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.toggleLike(
url = playerEndpoints.likesToggle(baseUrl, trackId)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Like toggle response is empty")
Result.success(body.liked)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getLikedTrackIds(): Result<Set<Long>> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.likes(
url = playerEndpoints.likes(baseUrl)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Likes response is empty")
Result.success(body.trackIds.toSet())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun recordListeningHistory(
trackId: Long,
startedAt: String,
durationListened: Double,
completed: Boolean
): Result<Unit> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.recordHistory(
url = playerEndpoints.recordHistory(baseUrl),
body = RecordHistoryRequest(
trackId = trackId,
startedAt = startedAt,
durationListened = durationListened,
completed = completed
)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getPlaylists(): Result<List<PlaylistCard>> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.playlists(
url = playerEndpoints.playlists(baseUrl)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Playlists response is empty")
Result.success(body.toDomain())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.addTracksToPlaylist(
url = playerEndpoints.addPlaylistTracks(baseUrl, playlistId),
body = AddPlaylistTracksRequest(trackIds = trackIds)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun shareTrack(trackId: Long, title: String): Result<String> {
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.sharePlaylist(
url = playerEndpoints.sharePlaylist(baseUrl),
body = SharePlaylistRequest(
trackIds = listOf(trackId),
title = title
)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Share response is empty")
Result.success(body.url)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -49,3 +49,10 @@ data class ReleaseDetail(
val artists: List<ArtistCard>,
val tracks: List<TrackCard>
)
data class PlaylistCard(
val id: Long,
val title: String,
val trackCount: Int,
val isPublic: Boolean
)
@@ -3,6 +3,7 @@ package com.example.furumi_android.domain.repository
import com.example.furumi_android.domain.model.ArtistPage
import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ListeningHistoryPage
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseDetail
interface PlayerRepository {
@@ -17,4 +18,21 @@ interface PlayerRepository {
suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail>
suspend fun getListeningHistory(page: Int = 1, limit: Int = 20): Result<ListeningHistoryPage>
suspend fun toggleLike(trackId: Long): Result<Boolean>
suspend fun getLikedTrackIds(): Result<Set<Long>>
suspend fun recordListeningHistory(
trackId: Long,
startedAt: String,
durationListened: Double,
completed: Boolean
): Result<Unit>
suspend fun getPlaylists(): Result<List<PlaylistCard>>
suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit>
suspend fun shareTrack(trackId: Long, title: String): Result<String>
}
@@ -3,7 +3,6 @@ package com.example.furumi_android.playback
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
@@ -11,18 +10,23 @@ import android.util.Log
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.session.CacheBitmapLoader
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.MediaStyleNotificationHelper
import com.example.furumi_android.MainActivity
import com.google.common.util.concurrent.MoreExecutors
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.OkHttpClient
import javax.inject.Inject
@AndroidEntryPoint
class FurumiPlaybackService : MediaSessionService() {
@Inject lateinit var playbackController: PlaybackController
@Inject lateinit var okHttpClient: OkHttpClient
private var mediaSession: MediaSession? = null
@@ -35,7 +39,7 @@ class FurumiPlaybackService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
Log.d("FurumiPlaybackService", "onCreate")
createNotificationChannel()
val sessionActivity = PendingIntent.getActivity(
@@ -45,46 +49,54 @@ class FurumiPlaybackService : MediaSessionService() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val bitmapLoader = CacheBitmapLoader(
androidx.media3.datasource.DataSourceBitmapLoader(
MoreExecutors.listeningDecorator(java.util.concurrent.Executors.newSingleThreadExecutor()),
OkHttpDataSource.Factory(okHttpClient)
)
)
mediaSession = MediaSession.Builder(this, playbackController.player)
.setId("FurumiPlaybackSession")
.setSessionActivity(sessionActivity)
.setBitmapLoader(bitmapLoader)
.build()
// Настройка провайдера с иконкой
val notificationProvider = DefaultMediaNotificationProvider.Builder(this)
.setChannelId(CHANNEL_ID)
.setNotificationId(NOTIFICATION_ID)
.build()
// Это важно для появления иконки в статус-баре при автоматических обновлениях Media3
notificationProvider.setSmallIcon(android.R.drawable.ic_media_play)
setMediaNotificationProvider(notificationProvider)
}
@OptIn(UnstableApi::class)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Immediately satisfy Android's startForeground contract with a placeholder.
// DefaultMediaNotificationProvider (via super) will replace this with the
// real notification that auto-updates metadata/artwork on every track change.
val session = mediaSession
if (session != null) {
val metadata = playbackController.player.currentMediaItem?.mediaMetadata
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play) // Иконка для трея
val placeholder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(metadata?.title ?: "Furumi")
.setContentText(metadata?.artist ?: "Playing...")
.setSilent(true)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT) // Сообщаем системе, что это транспорт управления
.setPriority(NotificationCompat.PRIORITY_MAX) // Максимальный приоритет для трея
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
startForeground(NOTIFICATION_ID, placeholder, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(NOTIFICATION_ID, notification)
startForeground(NOTIFICATION_ID, placeholder)
}
}
// super triggers DefaultMediaNotificationProvider which replaces the placeholder
// with a full notification (artwork, media controls) and keeps it updated.
super.onStartCommand(intent, flags, startId)
return START_STICKY
}
@@ -96,13 +108,14 @@ class FurumiPlaybackService : MediaSessionService() {
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Furumi Playback"
// Повышаем важность до HIGH, чтобы иконка не пропадала
val importance = NotificationManager.IMPORTANCE_HIGH
val importance = NotificationManager.IMPORTANCE_LOW
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
setShowBadge(true)
setShowBadge(false)
setSound(null, null)
enableVibration(false)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
@@ -9,8 +9,9 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.PlaybackException
import androidx.media3.common.util.UnstableApi
import androidx.core.content.ContextCompat
import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -74,6 +75,11 @@ class PlaybackController @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow(AudioPlaybackState())
private var playStartedAt: Long = 0L
private var playStartTrackId: Long = -1L
var onTrackPlayReported: ((trackId: Long, startedAt: String, durationListened: Double, completed: Boolean) -> Unit)? = null
val player: ExoPlayer
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
@@ -103,13 +109,25 @@ class PlaybackController @Inject constructor(
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
reportCurrentTrackPlay(completed = true)
}
publishState()
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
publishState()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
reportCurrentTrackPlay(completed = false)
startTrackTimer()
publishState()
startPlaybackService()
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
publishState()
// Просто уведомляем систему, что состояние изменилось.
// MediaSessionService сам обновит уведомление, так как он слушает плеер.
}
override fun onPlayerError(error: PlaybackException) {
@@ -155,7 +173,7 @@ class PlaybackController @Inject constructor(
player.setMediaItems(mediaItems, safeStartIndex, 0L)
player.prepare()
player.playWhenReady = true
startTrackTimer()
startPlaybackService()
}
@@ -217,6 +235,66 @@ class PlaybackController @Inject constructor(
_state.value = AudioPlaybackState()
}
fun addNext(track: TrackCard) {
if (track.streamUrl.isBlank()) return
val current = _state.value
if (current.queue.isEmpty()) {
playQueue(listOf(track))
return
}
val insertIndex = current.currentIndex + 1
val mediaItem = MediaItem.Builder()
.setMediaId(track.id.toString())
.setUri(track.streamUrl)
.setMediaMetadata(track.toMediaMetadata())
.build()
player.addMediaItem(insertIndex, mediaItem)
val updatedQueue = current.queue.toMutableList().apply { add(insertIndex, track) }
_state.value = current.copy(queue = updatedQueue)
}
fun addToEnd(track: TrackCard) {
if (track.streamUrl.isBlank()) return
val current = _state.value
if (current.queue.isEmpty()) {
playQueue(listOf(track))
return
}
val mediaItem = MediaItem.Builder()
.setMediaId(track.id.toString())
.setUri(track.streamUrl)
.setMediaMetadata(track.toMediaMetadata())
.build()
player.addMediaItem(mediaItem)
val updatedQueue = current.queue + track
_state.value = current.copy(queue = updatedQueue)
}
private fun startTrackTimer() {
val current = _state.value.currentTrack ?: return
playStartedAt = System.currentTimeMillis()
playStartTrackId = current.id
}
private fun reportCurrentTrackPlay(completed: Boolean) {
if (playStartTrackId < 0 || playStartedAt <= 0L) return
val durationMs = System.currentTimeMillis() - playStartedAt
val durationSec = durationMs / 1000.0
if (durationSec < 1.0) return
val isoTimestamp = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US)
.apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }
.format(java.util.Date(playStartedAt))
onTrackPlayReported?.invoke(playStartTrackId, isoTimestamp, durationSec, completed)
playStartTrackId = -1L
playStartedAt = 0L
}
private fun publishState() {
val existingErrorMessage = _state.value.errorMessage
val currentQueue = _state.value.queue
@@ -1,12 +1,15 @@
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
@@ -20,6 +23,8 @@ 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
@@ -31,10 +36,14 @@ 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
@@ -42,6 +51,7 @@ 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
@@ -49,6 +59,7 @@ 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
@@ -62,6 +73,7 @@ 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
private enum class PlayerTab(
val label: String
@@ -97,6 +109,7 @@ fun PlayerScreen(
var isProfileMenuOpen by rememberSaveable { mutableStateOf(false) }
var selectedPlaylistTitle by rememberSaveable { mutableStateOf<String?>(null) }
var isFullPlayerOpen by rememberSaveable { mutableStateOf(false) }
var fullPlayerDragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
val selectedPlaylist = selectedPlaylistTitle?.let { title ->
mockPlaylists.firstOrNull { it.title == title }
}
@@ -119,12 +132,25 @@ fun PlayerScreen(
}
}
var addToPlaylistTrack by remember { mutableStateOf<TrackCard?>(null) }
val context = LocalContext.current
LaunchedEffect(uiState.isLoggedOut) {
if (uiState.isLoggedOut) {
onLoggedOut()
}
}
LaunchedEffect(Unit) {
viewModel.shareEvent.collect { url ->
val sendIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, url)
type = "text/plain"
}
context.startActivity(Intent.createChooser(sendIntent, "Share track"))
}
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -138,6 +164,7 @@ fun PlayerScreen(
.fillMaxWidth()
) {
val onProfileClick = { isProfileMenuOpen = !isProfileMenuOpen }
val artistScrollState = rememberScrollState()
if (selectedPlaylist != null) {
Box(
modifier = Modifier
@@ -147,8 +174,8 @@ fun PlayerScreen(
PlaylistDetailContent(
playlist = selectedPlaylist,
onBack = { selectedPlaylistTitle = null },
onTrackClick = { isFullPlayerOpen = true },
onPlayClick = { isFullPlayerOpen = true }
onTrackClick = { isFullPlayerOpen = true; fullPlayerDragOffset = 0f },
onPlayClick = { isFullPlayerOpen = true; fullPlayerDragOffset = 0f }
)
}
} else if (uiState.selectedRelease != null) {
@@ -165,25 +192,33 @@ fun PlayerScreen(
onPlayClick = {
if (viewModel.playReleaseTracks(shuffle = false)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
},
onShuffleClick = {
if (viewModel.playReleaseTracks(shuffle = true)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
},
onTrackClick = { track ->
if (viewModel.playTrack(track, uiState.releaseDetail?.tracks.orEmpty())) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
}
},
onToggleLike = viewModel::toggleLike,
onPlayNext = viewModel::addToPlayNext,
onPlayLast = viewModel::addToQueueEnd,
onShare = viewModel::shareTrack,
onAddToPlaylist = { track -> addToPlaylistTrack = track }
)
}
} else if (uiState.selectedArtist != null) {
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(artistScrollState)
) {
ArtistDetailContent(
uiState = uiState,
@@ -193,19 +228,27 @@ fun PlayerScreen(
onPlayClick = {
if (viewModel.playArtistTracks(shuffle = false)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
},
onRadioClick = {
if (viewModel.playArtistTracks(shuffle = true)) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
},
onTrackClick = { track ->
if (viewModel.playTrack(track, uiState.artistDetail?.topTracks.orEmpty())) {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
}
},
onReleaseClick = viewModel::openRelease
onReleaseClick = viewModel::openRelease,
onToggleLike = viewModel::toggleLike,
onPlayNext = viewModel::addToPlayNext,
onPlayLast = viewModel::addToQueueEnd,
onShare = viewModel::shareTrack,
onAddToPlaylist = { track -> addToPlaylistTrack = track }
)
}
} else {
@@ -244,7 +287,10 @@ fun PlayerScreen(
playback = uiState.playback,
coverBitmap = track.coverUrl?.let { uiState.mediaImages[it] },
onMediaImageNeeded = viewModel::loadMediaImage,
onClick = { isFullPlayerOpen = true },
onClick = {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
},
onPlayPause = viewModel::togglePlayPause
)
}
@@ -253,6 +299,7 @@ fun PlayerScreen(
onTabSelected = {
selectedTabName = it.name
selectedPlaylistTitle = null
viewModel.closeArtistDetail()
}
)
}
@@ -275,15 +322,26 @@ fun PlayerScreen(
FullPlayerOverlay(
playback = uiState.playback,
coverBitmap = uiState.playback.currentTrack?.coverUrl?.let { uiState.mediaImages[it] },
likedTrackIds = uiState.likedTrackIds,
onMediaImageNeeded = viewModel::loadMediaImage,
onDismiss = { isFullPlayerOpen = false },
dragOffsetY = fullPlayerDragOffset,
onDragOffsetChange = { fullPlayerDragOffset = it },
onDismiss = {
isFullPlayerOpen = false
fullPlayerDragOffset = 0f
},
onPlayPause = viewModel::togglePlayPause,
onPrevious = viewModel::previousTrack,
onNext = viewModel::nextTrack,
onSeekToProgress = viewModel::seekToPlaybackProgress,
onQueueTrackClick = { track ->
viewModel.playTrack(track, uiState.playback.queue)
}
},
onToggleLike = viewModel::toggleLike,
onPlayNext = viewModel::addToPlayNext,
onPlayLast = viewModel::addToQueueEnd,
onShare = viewModel::shareTrack,
onAddToPlaylist = { track -> addToPlaylistTrack = track }
)
}
@@ -294,6 +352,19 @@ fun PlayerScreen(
onRetry = viewModel::retryListeningHistory
)
}
addToPlaylistTrack?.let { track ->
AddToPlaylistDialog(
playlists = uiState.playlists,
isLoading = uiState.isPlaylistsLoading,
onLoadPlaylists = viewModel::loadPlaylists,
onPlaylistSelected = { playlistId ->
viewModel.addTrackToPlaylist(track.id, playlistId)
addToPlaylistTrack = null
},
onDismiss = { addToPlaylistTrack = null }
)
}
}
}
@@ -471,7 +542,12 @@ private fun ArtistDetailContent(
onPlayClick: () -> Unit,
onRadioClick: () -> Unit,
onTrackClick: (TrackCard) -> Unit,
onReleaseClick: (ReleaseCard) -> 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 = artist.imageUrl?.let { uiState.mediaImages[it] }
@@ -527,8 +603,14 @@ private fun ArtistDetailContent(
PopularTracksSection(
tracks = detail.topTracks,
mediaImages = uiState.mediaImages,
likedTrackIds = uiState.likedTrackIds,
onMediaImageNeeded = onMediaImageNeeded,
onTrackClick = onTrackClick
onTrackClick = onTrackClick,
onToggleLike = onToggleLike,
onPlayNext = onPlayNext,
onPlayLast = onPlayLast,
onShare = onShare,
onAddToPlaylist = onAddToPlaylist
)
Spacer(modifier = Modifier.height(28.dp))
}
@@ -651,8 +733,14 @@ private fun ArtistDetailError(
private fun PopularTracksSection(
tracks: List<TrackCard>,
mediaImages: Map<String, Bitmap>,
likedTrackIds: Set<Long>,
onMediaImageNeeded: (String?) -> Unit,
onTrackClick: (TrackCard) -> 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))
@@ -662,8 +750,14 @@ private fun PopularTracksSection(
index = index + 1,
track = track,
bitmap = track.coverUrl?.let { mediaImages[it] },
isLiked = likedTrackIds.contains(track.id),
onMediaImageNeeded = onMediaImageNeeded,
onTrackClick = onTrackClick
onTrackClick = onTrackClick,
onToggleLike = { onToggleLike(track.id) },
onPlayNext = { onPlayNext(track) },
onPlayLast = { onPlayLast(track) },
onShare = { onShare(track) },
onAddToPlaylist = { onAddToPlaylist(track) }
)
}
}
@@ -674,9 +768,17 @@ private fun ArtistDetailTrackRow(
index: Int,
track: TrackCard,
bitmap: Bitmap?,
isLiked: Boolean,
onMediaImageNeeded: (String?) -> Unit,
onTrackClick: (TrackCard) -> 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)
}
@@ -721,6 +823,34 @@ private fun ArtistDetailTrackRow(
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,
@@ -833,7 +963,12 @@ private fun ReleaseDetailContent(
onMediaImageNeeded: (String?) -> Unit,
onPlayClick: () -> Unit,
onShuffleClick: () -> Unit,
onTrackClick: (TrackCard) -> 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 = release.coverUrl?.let { uiState.mediaImages[it] }
@@ -887,7 +1022,13 @@ private fun ReleaseDetailContent(
)
uiState.releaseDetail != null -> ReleaseTracksSection(
tracks = uiState.releaseDetail.tracks,
onTrackClick = onTrackClick
likedTrackIds = uiState.likedTrackIds,
onTrackClick = onTrackClick,
onToggleLike = onToggleLike,
onPlayNext = onPlayNext,
onPlayLast = onPlayLast,
onShare = onShare,
onAddToPlaylist = onAddToPlaylist
)
}
@@ -1010,7 +1151,13 @@ private fun ReleaseDetailError(
@Composable
private fun ReleaseTracksSection(
tracks: List<TrackCard>,
onTrackClick: (TrackCard) -> Unit
likedTrackIds: Set<Long>,
onTrackClick: (TrackCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
SectionTitle("Tracks")
Spacer(modifier = Modifier.height(12.dp))
@@ -1048,7 +1195,13 @@ private fun ReleaseTracksSection(
ReleaseTrackRow(
fallbackIndex = index + 1,
track = track,
onTrackClick = onTrackClick
isLiked = likedTrackIds.contains(track.id),
onTrackClick = onTrackClick,
onToggleLike = { onToggleLike(track.id) },
onPlayNext = { onPlayNext(track) },
onPlayLast = { onPlayLast(track) },
onShare = { onShare(track) },
onAddToPlaylist = { onAddToPlaylist(track) }
)
}
}
@@ -1059,8 +1212,16 @@ private fun ReleaseTracksSection(
private fun ReleaseTrackRow(
fallbackIndex: Int,
track: TrackCard,
onTrackClick: (TrackCard) -> Unit
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()
@@ -1091,7 +1252,34 @@ private fun ReleaseTrackRow(
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.width(10.dp))
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,
@@ -2174,13 +2362,21 @@ private fun NowPlayingBar(
private fun FullPlayerOverlay(
playback: AudioPlaybackState,
coverBitmap: Bitmap?,
likedTrackIds: Set<Long>,
onMediaImageNeeded: (String?) -> Unit,
dragOffsetY: Float,
onDragOffsetChange: (Float) -> Unit,
onDismiss: () -> Unit,
onPlayPause: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onSeekToProgress: (Float) -> Unit,
onQueueTrackClick: (TrackCard) -> Unit
onQueueTrackClick: (TrackCard) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
val track = playback.currentTrack ?: return
val upcomingQueue = playback.queue.drop(playback.currentIndex + 1)
@@ -2189,38 +2385,103 @@ private fun FullPlayerOverlay(
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(rememberScrollState())
.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
)
Text(
text = "Done",
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onDismiss)
.padding(horizontal = 10.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground
)
}
Text(
text = "Now playing",
style = MaterialTheme.typography.labelLarge,
color = FurumiTextMuted
)
Spacer(modifier = Modifier.height(36.dp))
@@ -2306,7 +2567,13 @@ private fun FullPlayerOverlay(
upcomingQueue.take(8).forEachIndexed { index, queueTrack ->
QueueTrackRow(
track = queueTrack,
onClick = { onQueueTrackClick(queueTrack) }
isLiked = likedTrackIds.contains(queueTrack.id),
onClick = { onQueueTrackClick(queueTrack) },
onToggleLike = { onToggleLike(queueTrack.id) },
onPlayNext = { onPlayNext(queueTrack) },
onPlayLast = { onPlayLast(queueTrack) },
onShare = { onShare(queueTrack) },
onAddToPlaylist = { onAddToPlaylist(queueTrack) }
)
if (index < upcomingQueue.take(8).lastIndex) {
Spacer(modifier = Modifier.height(12.dp))
@@ -2320,8 +2587,16 @@ private fun FullPlayerOverlay(
@Composable
private fun QueueTrackRow(
track: TrackCard,
onClick: () -> Unit
isLiked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onPlayNext: () -> Unit,
onPlayLast: () -> Unit,
onShare: () -> Unit,
onAddToPlaylist: () -> Unit
) {
var menuExpanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
@@ -2354,6 +2629,34 @@ private fun QueueTrackRow(
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,
@@ -2721,6 +3024,197 @@ private fun LibraryGlyph(
}
}
@Composable
private 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
private fun AddToPlaylistDialog(
playlists: List<PlaylistCard>,
isLoading: Boolean,
onLoadPlaylists: () -> Unit,
onPlaylistSelected: (Long) -> Unit,
onDismiss: () -> Unit
) {
LaunchedEffect(Unit) {
onLoadPlaylists()
}
Surface(
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onDismiss),
color = Color.Black.copy(alpha = 0.6f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(300.dp)
.clickable(enabled = false, onClick = {}),
shape = RoundedCornerShape(16.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "Add to playlist",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(16.dp))
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = FurumiNeonPink,
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp)
)
}
}
playlists.isEmpty() -> {
Text(
text = "No playlists found",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
else -> {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
playlists.forEach { playlist ->
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onPlaylistSelected(playlist.id) },
shape = RoundedCornerShape(8.dp),
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = playlist.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${playlist.trackCount} tracks",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Cancel",
modifier = Modifier
.align(Alignment.End)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onDismiss)
.padding(horizontal = 10.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelLarge,
color = FurumiTextMuted
)
}
}
}
}
}
@Composable
private fun HeartGlyph(
isLiked: Boolean,
modifier: Modifier
) {
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), style = Fill)
}
drawPath(
path,
color = if (isLiked) Color(0xFFFF3EA5) else Color(0xFFC9B8D6),
style = Stroke(width = size.minDimension * 0.08f)
)
}
}
@Composable
private 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))
}
}
private fun artistsSubtitle(uiState: PlayerUiState): String {
return if (uiState.globalArtistsTotal > 0) {
pluralCount(uiState.globalArtistsTotal, "artist")
@@ -7,6 +7,7 @@ import com.example.furumi_android.data.repository.MediaImageLoader
import com.example.furumi_android.domain.model.ArtistCard
import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.domain.model.PlaylistCard
import com.example.furumi_android.domain.model.ReleaseCard
import com.example.furumi_android.domain.model.ReleaseDetail
import com.example.furumi_android.domain.model.TrackCard
@@ -15,8 +16,11 @@ import com.example.furumi_android.domain.repository.PlayerRepository
import com.example.furumi_android.playback.AudioPlaybackState
import com.example.furumi_android.playback.PlaybackController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -48,7 +52,10 @@ data class PlayerUiState(
val releaseDetailError: String? = null,
val mediaImages: Map<String, Bitmap> = emptyMap(),
val loadingMediaImageUrls: Set<String> = emptySet(),
val playback: AudioPlaybackState = AudioPlaybackState()
val playback: AudioPlaybackState = AudioPlaybackState(),
val likedTrackIds: Set<Long> = emptySet(),
val playlists: List<PlaylistCard> = emptyList(),
val isPlaylistsLoading: Boolean = false
)
@HiltViewModel
@@ -61,6 +68,8 @@ class PlayerViewModel @Inject constructor(
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
private val releaseDetailCache = mutableMapOf<Long, ReleaseDetail>()
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
private val _uiState = MutableStateFlow(
authRepository.getCurrentSession()?.let { session ->
@@ -77,6 +86,10 @@ class PlayerViewModel @Inject constructor(
init {
loadGlobalArtistsIfNeeded()
observePlayback()
loadLikedTrackIds()
playbackController.onTrackPlayReported = { trackId, startedAt, durationListened, completed ->
reportListeningHistory(trackId, startedAt, durationListened, completed)
}
}
fun loadGlobalArtistsIfNeeded() {
@@ -277,6 +290,89 @@ class PlayerViewModel @Inject constructor(
}
}
fun toggleLike(trackId: Long) {
val current = _uiState.value.likedTrackIds
val optimistic = if (current.contains(trackId)) current - trackId else current + trackId
_uiState.value = _uiState.value.copy(likedTrackIds = optimistic)
viewModelScope.launch {
playerRepository.toggleLike(trackId)
.onSuccess { liked ->
val updated = if (liked) {
_uiState.value.likedTrackIds + trackId
} else {
_uiState.value.likedTrackIds - trackId
}
_uiState.value = _uiState.value.copy(likedTrackIds = updated)
}
.onFailure {
_uiState.value = _uiState.value.copy(likedTrackIds = current)
}
}
}
fun isTrackLiked(trackId: Long): Boolean = _uiState.value.likedTrackIds.contains(trackId)
fun addToPlayNext(track: TrackCard) {
playbackController.addNext(track)
}
fun addToQueueEnd(track: TrackCard) {
playbackController.addToEnd(track)
}
fun shareTrack(track: TrackCard) {
viewModelScope.launch {
playerRepository.shareTrack(track.id, track.title)
.onSuccess { url ->
_shareEvent.tryEmit(url)
}
}
}
fun loadPlaylists() {
if (_uiState.value.isPlaylistsLoading) return
_uiState.value = _uiState.value.copy(isPlaylistsLoading = true)
viewModelScope.launch {
playerRepository.getPlaylists()
.onSuccess { playlists ->
_uiState.value = _uiState.value.copy(
playlists = playlists,
isPlaylistsLoading = false
)
}
.onFailure {
_uiState.value = _uiState.value.copy(isPlaylistsLoading = false)
}
}
}
fun addTrackToPlaylist(trackId: Long, playlistId: Long) {
viewModelScope.launch {
playerRepository.addTracksToPlaylist(playlistId, listOf(trackId))
}
}
private fun reportListeningHistory(
trackId: Long,
startedAt: String,
durationListened: Double,
completed: Boolean
) {
viewModelScope.launch {
playerRepository.recordListeningHistory(trackId, startedAt, durationListened, completed)
}
}
private fun loadLikedTrackIds() {
viewModelScope.launch {
playerRepository.getLikedTrackIds()
.onSuccess { ids ->
_uiState.value = _uiState.value.copy(likedTrackIds = ids)
}
}
}
private fun loadListeningHistory() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(