added some features from web player
This commit is contained in:
Generated
+4
-7
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user