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"> <project version="4">
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="App">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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 /> <DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
@@ -9,16 +9,12 @@ import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -39,7 +35,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
if (deepLinkUri == null) { if (deepLinkUri == null) {
@@ -48,8 +47,6 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
FurumiandroidTheme { FurumiandroidTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
FurumiApp( FurumiApp(
deepLinkUri = deepLinkUri, deepLinkUri = deepLinkUri,
onDeepLinkHandled = { deepLinkUri = null } onDeepLinkHandled = { deepLinkUri = null }
@@ -57,8 +54,6 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
}
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
@@ -11,9 +11,25 @@ class PlayerEndpoints @Inject constructor() {
fun history(baseUrl: String): String = "$baseUrl$HISTORY_PATH" 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 { companion object {
private const val ARTISTS_PATH = "/api/player/artists" private const val ARTISTS_PATH = "/api/player/artists"
private const val RELEASES_PATH = "/api/player/releases" private const val RELEASES_PATH = "/api/player/releases"
private const val HISTORY_PATH = "/api/player/history" 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.ArtistDetailResponse
import com.example.furumi_android.data.remote.model.PlayHistoryPageResponse 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.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.Response
import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Headers import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
@@ -39,4 +48,43 @@ interface PlayerApi {
@Query("page") page: Int, @Query("page") page: Int,
@Query("limit") limit: Int @Query("limit") limit: Int
): Response<PlayHistoryPageResponse> ): 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.ArtistPage
import com.example.furumi_android.domain.model.ListeningHistoryItem import com.example.furumi_android.domain.model.ListeningHistoryItem
import com.example.furumi_android.domain.model.ListeningHistoryPage 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.ReleaseCard
import com.example.furumi_android.domain.model.ReleaseDetail import com.example.furumi_android.domain.model.ReleaseDetail
import com.example.furumi_android.domain.model.TrackCard 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() @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( data class ReleaseDetailResponse(
@param:Json(name = "release") val release: ReleaseCardResponse? = null, @param:Json(name = "release") val release: ReleaseCardResponse? = null,
@param:Json(name = "id") val id: Long? = 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? { private fun String?.toAbsoluteMediaUrl(baseUrl: String): String? {
val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return null val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return null
if (value.startsWith("http://", ignoreCase = true) || 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.AuthApiErrorParser
import com.example.furumi_android.data.remote.PlayerEndpoints import com.example.furumi_android.data.remote.PlayerEndpoints
import com.example.furumi_android.data.remote.api.PlayerApi 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.data.remote.model.toDomain
import com.example.furumi_android.domain.model.ArtistDetail import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ArtistPage import com.example.furumi_android.domain.model.ArtistPage
import com.example.furumi_android.domain.model.AuthException import com.example.furumi_android.domain.model.AuthException
import com.example.furumi_android.domain.model.ListeningHistoryPage 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.model.ReleaseDetail
import com.example.furumi_android.domain.repository.PlayerRepository import com.example.furumi_android.domain.repository.PlayerRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -109,4 +113,144 @@ class PlayerRepositoryImpl @Inject constructor(
Result.failure(e) 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 artists: List<ArtistCard>,
val tracks: List<TrackCard> 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.ArtistPage
import com.example.furumi_android.domain.model.ArtistDetail import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ListeningHistoryPage 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.model.ReleaseDetail
interface PlayerRepository { interface PlayerRepository {
@@ -17,4 +18,21 @@ interface PlayerRepository {
suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail> suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail>
suspend fun getListeningHistory(page: Int = 1, limit: Int = 20): Result<ListeningHistoryPage> 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.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
@@ -11,18 +10,23 @@ import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi 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.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import androidx.media3.session.MediaStyleNotificationHelper import androidx.media3.session.MediaStyleNotificationHelper
import com.example.furumi_android.MainActivity import com.example.furumi_android.MainActivity
import com.google.common.util.concurrent.MoreExecutors
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class FurumiPlaybackService : MediaSessionService() { class FurumiPlaybackService : MediaSessionService() {
@Inject lateinit var playbackController: PlaybackController @Inject lateinit var playbackController: PlaybackController
@Inject lateinit var okHttpClient: OkHttpClient
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
@@ -45,46 +49,54 @@ class FurumiPlaybackService : MediaSessionService() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT 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) mediaSession = MediaSession.Builder(this, playbackController.player)
.setId("FurumiPlaybackSession") .setId("FurumiPlaybackSession")
.setSessionActivity(sessionActivity) .setSessionActivity(sessionActivity)
.setBitmapLoader(bitmapLoader)
.build() .build()
// Настройка провайдера с иконкой
val notificationProvider = DefaultMediaNotificationProvider.Builder(this) val notificationProvider = DefaultMediaNotificationProvider.Builder(this)
.setChannelId(CHANNEL_ID) .setChannelId(CHANNEL_ID)
.setNotificationId(NOTIFICATION_ID) .setNotificationId(NOTIFICATION_ID)
.build() .build()
// Это важно для появления иконки в статус-баре при автоматических обновлениях Media3
notificationProvider.setSmallIcon(android.R.drawable.ic_media_play) notificationProvider.setSmallIcon(android.R.drawable.ic_media_play)
setMediaNotificationProvider(notificationProvider) setMediaNotificationProvider(notificationProvider)
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 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 val session = mediaSession
if (session != null) { if (session != null) {
val metadata = playbackController.player.currentMediaItem?.mediaMetadata val metadata = playbackController.player.currentMediaItem?.mediaMetadata
val placeholder = NotificationCompat.Builder(this, CHANNEL_ID)
val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_media_play)
.setSmallIcon(android.R.drawable.ic_media_play) // Иконка для трея
.setContentTitle(metadata?.title ?: "Furumi") .setContentTitle(metadata?.title ?: "Furumi")
.setContentText(metadata?.artist ?: "Playing...") .setContentText(metadata?.artist ?: "Playing...")
.setSilent(true)
.setOngoing(true) .setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT) // Сообщаем системе, что это транспорт управления
.setPriority(NotificationCompat.PRIORITY_MAX) // Максимальный приоритет для трея
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setStyle(MediaStyleNotificationHelper.MediaStyle(session)) .setStyle(MediaStyleNotificationHelper.MediaStyle(session))
.build() .build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 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 { } 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) super.onStartCommand(intent, flags, startId)
return START_STICKY return START_STICKY
} }
@@ -96,13 +108,14 @@ class FurumiPlaybackService : MediaSessionService() {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Furumi Playback" val name = "Furumi Playback"
// Повышаем важность до HIGH, чтобы иконка не пропадала val importance = NotificationManager.IMPORTANCE_LOW
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
setShowBadge(true) setShowBadge(false)
setSound(null, null)
enableVibration(false)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
} }
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
} }
@@ -9,8 +9,9 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.PlaybackException
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
@@ -74,6 +75,11 @@ class PlaybackController @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow(AudioPlaybackState()) 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 player: ExoPlayer
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow() val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
@@ -103,13 +109,25 @@ class PlaybackController @Inject constructor(
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
reportCurrentTrackPlay(completed = true)
}
publishState()
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
publishState() publishState()
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
reportCurrentTrackPlay(completed = false)
startTrackTimer()
publishState()
startPlaybackService()
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
publishState() publishState()
// Просто уведомляем систему, что состояние изменилось.
// MediaSessionService сам обновит уведомление, так как он слушает плеер.
} }
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
@@ -155,7 +173,7 @@ class PlaybackController @Inject constructor(
player.setMediaItems(mediaItems, safeStartIndex, 0L) player.setMediaItems(mediaItems, safeStartIndex, 0L)
player.prepare() player.prepare()
player.playWhenReady = true player.playWhenReady = true
startTrackTimer()
startPlaybackService() startPlaybackService()
} }
@@ -217,6 +235,66 @@ class PlaybackController @Inject constructor(
_state.value = AudioPlaybackState() _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() { private fun publishState() {
val existingErrorMessage = _state.value.errorMessage val existingErrorMessage = _state.value.errorMessage
val currentQueue = _state.value.queue val currentQueue = _state.value.queue
@@ -1,12 +1,15 @@
package com.example.furumi_android.ui.player package com.example.furumi_android.ui.player
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@@ -31,10 +36,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush 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.Path
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.example.furumi_android.domain.model.ArtistCard 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.ReleaseCard
import com.example.furumi_android.domain.model.TrackCard import com.example.furumi_android.domain.model.TrackCard
import com.example.furumi_android.domain.model.ListeningHistoryItem 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.FurumiSurface
import com.example.furumi_android.ui.theme.FurumiSurfaceHigh import com.example.furumi_android.ui.theme.FurumiSurfaceHigh
import com.example.furumi_android.ui.theme.FurumiTextMuted import com.example.furumi_android.ui.theme.FurumiTextMuted
import kotlin.math.roundToInt
private enum class PlayerTab( private enum class PlayerTab(
val label: String val label: String
@@ -97,6 +109,7 @@ fun PlayerScreen(
var isProfileMenuOpen by rememberSaveable { mutableStateOf(false) } var isProfileMenuOpen by rememberSaveable { mutableStateOf(false) }
var selectedPlaylistTitle by rememberSaveable { mutableStateOf<String?>(null) } var selectedPlaylistTitle by rememberSaveable { mutableStateOf<String?>(null) }
var isFullPlayerOpen by rememberSaveable { mutableStateOf(false) } var isFullPlayerOpen by rememberSaveable { mutableStateOf(false) }
var fullPlayerDragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
val selectedPlaylist = selectedPlaylistTitle?.let { title -> val selectedPlaylist = selectedPlaylistTitle?.let { title ->
mockPlaylists.firstOrNull { it.title == 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) { LaunchedEffect(uiState.isLoggedOut) {
if (uiState.isLoggedOut) { if (uiState.isLoggedOut) {
onLoggedOut() 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -138,6 +164,7 @@ fun PlayerScreen(
.fillMaxWidth() .fillMaxWidth()
) { ) {
val onProfileClick = { isProfileMenuOpen = !isProfileMenuOpen } val onProfileClick = { isProfileMenuOpen = !isProfileMenuOpen }
val artistScrollState = rememberScrollState()
if (selectedPlaylist != null) { if (selectedPlaylist != null) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -147,8 +174,8 @@ fun PlayerScreen(
PlaylistDetailContent( PlaylistDetailContent(
playlist = selectedPlaylist, playlist = selectedPlaylist,
onBack = { selectedPlaylistTitle = null }, onBack = { selectedPlaylistTitle = null },
onTrackClick = { isFullPlayerOpen = true }, onTrackClick = { isFullPlayerOpen = true; fullPlayerDragOffset = 0f },
onPlayClick = { isFullPlayerOpen = true } onPlayClick = { isFullPlayerOpen = true; fullPlayerDragOffset = 0f }
) )
} }
} else if (uiState.selectedRelease != null) { } else if (uiState.selectedRelease != null) {
@@ -165,25 +192,33 @@ fun PlayerScreen(
onPlayClick = { onPlayClick = {
if (viewModel.playReleaseTracks(shuffle = false)) { if (viewModel.playReleaseTracks(shuffle = false)) {
isFullPlayerOpen = true isFullPlayerOpen = true
fullPlayerDragOffset = 0f
} }
}, },
onShuffleClick = { onShuffleClick = {
if (viewModel.playReleaseTracks(shuffle = true)) { if (viewModel.playReleaseTracks(shuffle = true)) {
isFullPlayerOpen = true isFullPlayerOpen = true
fullPlayerDragOffset = 0f
} }
}, },
onTrackClick = { track -> onTrackClick = { track ->
if (viewModel.playTrack(track, uiState.releaseDetail?.tracks.orEmpty())) { if (viewModel.playTrack(track, uiState.releaseDetail?.tracks.orEmpty())) {
isFullPlayerOpen = true 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) { } else if (uiState.selectedArtist != null) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(artistScrollState)
) { ) {
ArtistDetailContent( ArtistDetailContent(
uiState = uiState, uiState = uiState,
@@ -193,19 +228,27 @@ fun PlayerScreen(
onPlayClick = { onPlayClick = {
if (viewModel.playArtistTracks(shuffle = false)) { if (viewModel.playArtistTracks(shuffle = false)) {
isFullPlayerOpen = true isFullPlayerOpen = true
fullPlayerDragOffset = 0f
} }
}, },
onRadioClick = { onRadioClick = {
if (viewModel.playArtistTracks(shuffle = true)) { if (viewModel.playArtistTracks(shuffle = true)) {
isFullPlayerOpen = true isFullPlayerOpen = true
fullPlayerDragOffset = 0f
} }
}, },
onTrackClick = { track -> onTrackClick = { track ->
if (viewModel.playTrack(track, uiState.artistDetail?.topTracks.orEmpty())) { if (viewModel.playTrack(track, uiState.artistDetail?.topTracks.orEmpty())) {
isFullPlayerOpen = true 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 { } else {
@@ -244,7 +287,10 @@ fun PlayerScreen(
playback = uiState.playback, playback = uiState.playback,
coverBitmap = track.coverUrl?.let { uiState.mediaImages[it] }, coverBitmap = track.coverUrl?.let { uiState.mediaImages[it] },
onMediaImageNeeded = viewModel::loadMediaImage, onMediaImageNeeded = viewModel::loadMediaImage,
onClick = { isFullPlayerOpen = true }, onClick = {
isFullPlayerOpen = true
fullPlayerDragOffset = 0f
},
onPlayPause = viewModel::togglePlayPause onPlayPause = viewModel::togglePlayPause
) )
} }
@@ -253,6 +299,7 @@ fun PlayerScreen(
onTabSelected = { onTabSelected = {
selectedTabName = it.name selectedTabName = it.name
selectedPlaylistTitle = null selectedPlaylistTitle = null
viewModel.closeArtistDetail()
} }
) )
} }
@@ -275,15 +322,26 @@ fun PlayerScreen(
FullPlayerOverlay( FullPlayerOverlay(
playback = uiState.playback, playback = uiState.playback,
coverBitmap = uiState.playback.currentTrack?.coverUrl?.let { uiState.mediaImages[it] }, coverBitmap = uiState.playback.currentTrack?.coverUrl?.let { uiState.mediaImages[it] },
likedTrackIds = uiState.likedTrackIds,
onMediaImageNeeded = viewModel::loadMediaImage, onMediaImageNeeded = viewModel::loadMediaImage,
onDismiss = { isFullPlayerOpen = false }, dragOffsetY = fullPlayerDragOffset,
onDragOffsetChange = { fullPlayerDragOffset = it },
onDismiss = {
isFullPlayerOpen = false
fullPlayerDragOffset = 0f
},
onPlayPause = viewModel::togglePlayPause, onPlayPause = viewModel::togglePlayPause,
onPrevious = viewModel::previousTrack, onPrevious = viewModel::previousTrack,
onNext = viewModel::nextTrack, onNext = viewModel::nextTrack,
onSeekToProgress = viewModel::seekToPlaybackProgress, onSeekToProgress = viewModel::seekToPlaybackProgress,
onQueueTrackClick = { track -> onQueueTrackClick = { track ->
viewModel.playTrack(track, uiState.playback.queue) 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 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, onPlayClick: () -> Unit,
onRadioClick: () -> Unit, onRadioClick: () -> Unit,
onTrackClick: (TrackCard) -> 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 artist = uiState.artistDetail?.artist ?: uiState.selectedArtist ?: return
val artistImage = artist.imageUrl?.let { uiState.mediaImages[it] } val artistImage = artist.imageUrl?.let { uiState.mediaImages[it] }
@@ -527,8 +603,14 @@ private fun ArtistDetailContent(
PopularTracksSection( PopularTracksSection(
tracks = detail.topTracks, tracks = detail.topTracks,
mediaImages = uiState.mediaImages, mediaImages = uiState.mediaImages,
likedTrackIds = uiState.likedTrackIds,
onMediaImageNeeded = onMediaImageNeeded, onMediaImageNeeded = onMediaImageNeeded,
onTrackClick = onTrackClick onTrackClick = onTrackClick,
onToggleLike = onToggleLike,
onPlayNext = onPlayNext,
onPlayLast = onPlayLast,
onShare = onShare,
onAddToPlaylist = onAddToPlaylist
) )
Spacer(modifier = Modifier.height(28.dp)) Spacer(modifier = Modifier.height(28.dp))
} }
@@ -651,8 +733,14 @@ private fun ArtistDetailError(
private fun PopularTracksSection( private fun PopularTracksSection(
tracks: List<TrackCard>, tracks: List<TrackCard>,
mediaImages: Map<String, Bitmap>, mediaImages: Map<String, Bitmap>,
likedTrackIds: Set<Long>,
onMediaImageNeeded: (String?) -> Unit, 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") SectionTitle("Popular")
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -662,8 +750,14 @@ private fun PopularTracksSection(
index = index + 1, index = index + 1,
track = track, track = track,
bitmap = track.coverUrl?.let { mediaImages[it] }, bitmap = track.coverUrl?.let { mediaImages[it] },
isLiked = likedTrackIds.contains(track.id),
onMediaImageNeeded = onMediaImageNeeded, 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, index: Int,
track: TrackCard, track: TrackCard,
bitmap: Bitmap?, bitmap: Bitmap?,
isLiked: Boolean,
onMediaImageNeeded: (String?) -> Unit, 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) { LaunchedEffect(track.coverUrl) {
onMediaImageNeeded(track.coverUrl) onMediaImageNeeded(track.coverUrl)
} }
@@ -721,6 +823,34 @@ private fun ArtistDetailTrackRow(
overflow = TextOverflow.Ellipsis 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(
text = formatDuration(track.durationSeconds), text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -833,7 +963,12 @@ private fun ReleaseDetailContent(
onMediaImageNeeded: (String?) -> Unit, onMediaImageNeeded: (String?) -> Unit,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onShuffleClick: () -> 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 release = uiState.releaseDetail?.release ?: uiState.selectedRelease ?: return
val releaseImage = release.coverUrl?.let { uiState.mediaImages[it] } val releaseImage = release.coverUrl?.let { uiState.mediaImages[it] }
@@ -887,7 +1022,13 @@ private fun ReleaseDetailContent(
) )
uiState.releaseDetail != null -> ReleaseTracksSection( uiState.releaseDetail != null -> ReleaseTracksSection(
tracks = uiState.releaseDetail.tracks, 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 @Composable
private fun ReleaseTracksSection( private fun ReleaseTracksSection(
tracks: List<TrackCard>, 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") SectionTitle("Tracks")
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -1048,7 +1195,13 @@ private fun ReleaseTracksSection(
ReleaseTrackRow( ReleaseTrackRow(
fallbackIndex = index + 1, fallbackIndex = index + 1,
track = track, 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( private fun ReleaseTrackRow(
fallbackIndex: Int, fallbackIndex: Int,
track: TrackCard, 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1091,7 +1252,34 @@ private fun ReleaseTrackRow(
overflow = TextOverflow.Ellipsis 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(
text = formatDuration(track.durationSeconds), text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -2174,13 +2362,21 @@ private fun NowPlayingBar(
private fun FullPlayerOverlay( private fun FullPlayerOverlay(
playback: AudioPlaybackState, playback: AudioPlaybackState,
coverBitmap: Bitmap?, coverBitmap: Bitmap?,
likedTrackIds: Set<Long>,
onMediaImageNeeded: (String?) -> Unit, onMediaImageNeeded: (String?) -> Unit,
dragOffsetY: Float,
onDragOffsetChange: (Float) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onSeekToProgress: (Float) -> 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 track = playback.currentTrack ?: return
val upcomingQueue = playback.queue.drop(playback.currentIndex + 1) val upcomingQueue = playback.queue.drop(playback.currentIndex + 1)
@@ -2189,38 +2385,103 @@ private fun FullPlayerOverlay(
onMediaImageNeeded(track.coverUrl) onMediaImageNeeded(track.coverUrl)
} }
val density = LocalDensity.current
val dismissThresholdPx = with(density) { 150.dp.toPx() }
val flingVelocityThresholdPx = with(density) { 800.dp.toPx() }
val scrollState = rememberScrollState()
val currentOnDismiss by androidx.compose.runtime.rememberUpdatedState(onDismiss)
val nestedScrollConnection = remember(dragOffsetY) {
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
val dy = available.y
if (dy < 0 && dragOffsetY > 0f) {
val consumed = dy.coerceAtLeast(-dragOffsetY)
onDragOffsetChange((dragOffsetY + consumed).coerceAtLeast(0f))
return androidx.compose.ui.geometry.Offset(0f, consumed)
}
return androidx.compose.ui.geometry.Offset.Zero
}
override fun onPostScroll(
consumed: androidx.compose.ui.geometry.Offset,
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
val dy = available.y
// Only allow drag-to-dismiss when content is scrolled to top
// and user is actively dragging (not fling inertia from scrolling up)
if (dy > 0 && scrollState.value == 0
&& source == androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag) {
onDragOffsetChange(dragOffsetY + dy)
return androidx.compose.ui.geometry.Offset(0f, dy)
}
return androidx.compose.ui.geometry.Offset.Zero
}
override suspend fun onPreFling(
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
// Only consider dismiss if we actually have a drag offset
if (dragOffsetY <= 0f) {
return androidx.compose.ui.unit.Velocity.Zero
}
val shouldDismiss = dragOffsetY > dismissThresholdPx ||
available.y > flingVelocityThresholdPx
if (shouldDismiss) {
onDragOffsetChange(0f)
currentOnDismiss()
return available
}
val anim = Animatable(dragOffsetY)
anim.animateTo(0f) { onDragOffsetChange(value) }
onDragOffsetChange(0f)
return androidx.compose.ui.unit.Velocity.Zero
}
override suspend fun onPostFling(
consumed: androidx.compose.ui.unit.Velocity,
available: androidx.compose.ui.unit.Velocity
): androidx.compose.ui.unit.Velocity {
if (dragOffsetY > 0f) {
val shouldDismiss = dragOffsetY > dismissThresholdPx ||
available.y > flingVelocityThresholdPx
if (shouldDismiss) {
onDragOffsetChange(0f)
currentOnDismiss()
} else {
val anim = Animatable(dragOffsetY)
anim.animateTo(0f) { onDragOffsetChange(value) }
onDragOffsetChange(0f)
}
}
return available
}
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.offset { IntOffset(0, dragOffsetY.roundToInt().coerceAtLeast(0)) }
.nestedScroll(nestedScrollConnection)
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(scrollState)
.padding(horizontal = 24.dp, vertical = 18.dp) .padding(horizontal = 24.dp, vertical = 18.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = "Now playing", text = "Now playing",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = FurumiTextMuted 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
)
}
Spacer(modifier = Modifier.height(36.dp)) Spacer(modifier = Modifier.height(36.dp))
@@ -2306,7 +2567,13 @@ private fun FullPlayerOverlay(
upcomingQueue.take(8).forEachIndexed { index, queueTrack -> upcomingQueue.take(8).forEachIndexed { index, queueTrack ->
QueueTrackRow( QueueTrackRow(
track = queueTrack, 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) { if (index < upcomingQueue.take(8).lastIndex) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -2320,8 +2587,16 @@ private fun FullPlayerOverlay(
@Composable @Composable
private fun QueueTrackRow( private fun QueueTrackRow(
track: TrackCard, 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -2354,6 +2629,34 @@ private fun QueueTrackRow(
overflow = TextOverflow.Ellipsis 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(
text = formatDuration(track.durationSeconds), text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium, 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 { private fun artistsSubtitle(uiState: PlayerUiState): String {
return if (uiState.globalArtistsTotal > 0) { return if (uiState.globalArtistsTotal > 0) {
pluralCount(uiState.globalArtistsTotal, "artist") 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.ArtistCard
import com.example.furumi_android.domain.model.ArtistDetail import com.example.furumi_android.domain.model.ArtistDetail
import com.example.furumi_android.domain.model.ListeningHistoryItem 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.ReleaseCard
import com.example.furumi_android.domain.model.ReleaseDetail import com.example.furumi_android.domain.model.ReleaseDetail
import com.example.furumi_android.domain.model.TrackCard 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.AudioPlaybackState
import com.example.furumi_android.playback.PlaybackController import com.example.furumi_android.playback.PlaybackController
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -48,7 +52,10 @@ data class PlayerUiState(
val releaseDetailError: String? = null, val releaseDetailError: String? = null,
val mediaImages: Map<String, Bitmap> = emptyMap(), val mediaImages: Map<String, Bitmap> = emptyMap(),
val loadingMediaImageUrls: Set<String> = emptySet(), 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 @HiltViewModel
@@ -61,6 +68,8 @@ class PlayerViewModel @Inject constructor(
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>() private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
private val releaseDetailCache = mutableMapOf<Long, ReleaseDetail>() private val releaseDetailCache = mutableMapOf<Long, ReleaseDetail>()
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
authRepository.getCurrentSession()?.let { session -> authRepository.getCurrentSession()?.let { session ->
@@ -77,6 +86,10 @@ class PlayerViewModel @Inject constructor(
init { init {
loadGlobalArtistsIfNeeded() loadGlobalArtistsIfNeeded()
observePlayback() observePlayback()
loadLikedTrackIds()
playbackController.onTrackPlayReported = { trackId, startedAt, durationListened, completed ->
reportListeningHistory(trackId, startedAt, durationListened, completed)
}
} }
fun loadGlobalArtistsIfNeeded() { 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() { private fun loadListeningHistory() {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(