diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index be32e44..b52368e 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -2,15 +2,12 @@ + + diff --git a/app/src/main/java/com/example/furumi_android/MainActivity.kt b/app/src/main/java/com/example/furumi_android/MainActivity.kt index 18487e0..6c82f5c 100644 --- a/app/src/main/java/com/example/furumi_android/MainActivity.kt +++ b/app/src/main/java/com/example/furumi_android/MainActivity.kt @@ -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 } + ) } } } diff --git a/app/src/main/java/com/example/furumi_android/data/remote/PlayerEndpoints.kt b/app/src/main/java/com/example/furumi_android/data/remote/PlayerEndpoints.kt index 5725bc1..747e0c9 100644 --- a/app/src/main/java/com/example/furumi_android/data/remote/PlayerEndpoints.kt +++ b/app/src/main/java/com/example/furumi_android/data/remote/PlayerEndpoints.kt @@ -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" } } diff --git a/app/src/main/java/com/example/furumi_android/data/remote/api/PlayerApi.kt b/app/src/main/java/com/example/furumi_android/data/remote/api/PlayerApi.kt index e739a2a..0e4062f 100644 --- a/app/src/main/java/com/example/furumi_android/data/remote/api/PlayerApi.kt +++ b/app/src/main/java/com/example/furumi_android/data/remote/api/PlayerApi.kt @@ -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 + + @Headers("Accept: application/json") + @POST + suspend fun toggleLike( + @Url url: String + ): Response + + @Headers("Accept: application/json") + @GET + suspend fun likes( + @Url url: String + ): Response + + @Headers("Accept: application/json", "Content-Type: application/json") + @POST + suspend fun recordHistory( + @Url url: String, + @Body body: RecordHistoryRequest + ): Response + + @Headers("Accept: application/json") + @GET + suspend fun playlists( + @Url url: String + ): Response + + @Headers("Accept: application/json", "Content-Type: application/json") + @POST + suspend fun addTracksToPlaylist( + @Url url: String, + @Body body: AddPlaylistTracksRequest + ): Response + + @Headers("Accept: application/json", "Content-Type: application/json") + @POST + suspend fun sharePlaylist( + @Url url: String, + @Body body: SharePlaylistRequest + ): Response } diff --git a/app/src/main/java/com/example/furumi_android/data/remote/model/PlayerResponses.kt b/app/src/main/java/com/example/furumi_android/data/remote/model/PlayerResponses.kt index 99953d1..532554b 100644 --- a/app/src/main/java/com/example/furumi_android/data/remote/model/PlayerResponses.kt +++ b/app/src/main/java/com/example/furumi_android/data/remote/model/PlayerResponses.kt @@ -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 = 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 +) + +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 +) + +data class AddPlaylistTracksRequest( + @param:Json(name = "track_ids") val trackIds: List +) + +data class SharePlaylistRequest( + @param:Json(name = "track_ids") val trackIds: List, + @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 { + 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) || diff --git a/app/src/main/java/com/example/furumi_android/data/repository/PlayerRepositoryImpl.kt b/app/src/main/java/com/example/furumi_android/data/repository/PlayerRepositoryImpl.kt index 7da52e3..16d479e 100644 --- a/app/src/main/java/com/example/furumi_android/data/repository/PlayerRepositoryImpl.kt +++ b/app/src/main/java/com/example/furumi_android/data/repository/PlayerRepositoryImpl.kt @@ -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 { + 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> { + 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 { + 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> { + 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): Result { + 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 { + 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) + } + } } diff --git a/app/src/main/java/com/example/furumi_android/domain/model/Artist.kt b/app/src/main/java/com/example/furumi_android/domain/model/Artist.kt index dfdd3c8..414ef0b 100644 --- a/app/src/main/java/com/example/furumi_android/domain/model/Artist.kt +++ b/app/src/main/java/com/example/furumi_android/domain/model/Artist.kt @@ -49,3 +49,10 @@ data class ReleaseDetail( val artists: List, val tracks: List ) + +data class PlaylistCard( + val id: Long, + val title: String, + val trackCount: Int, + val isPublic: Boolean +) diff --git a/app/src/main/java/com/example/furumi_android/domain/repository/PlayerRepository.kt b/app/src/main/java/com/example/furumi_android/domain/repository/PlayerRepository.kt index aff3d44..bdfe770 100644 --- a/app/src/main/java/com/example/furumi_android/domain/repository/PlayerRepository.kt +++ b/app/src/main/java/com/example/furumi_android/domain/repository/PlayerRepository.kt @@ -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 suspend fun getListeningHistory(page: Int = 1, limit: Int = 20): Result + + suspend fun toggleLike(trackId: Long): Result + + suspend fun getLikedTrackIds(): Result> + + suspend fun recordListeningHistory( + trackId: Long, + startedAt: String, + durationListened: Double, + completed: Boolean + ): Result + + suspend fun getPlaylists(): Result> + + suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List): Result + + suspend fun shareTrack(trackId: Long, title: String): Result } diff --git a/app/src/main/java/com/example/furumi_android/playback/FurumiPlaybackService.kt b/app/src/main/java/com/example/furumi_android/playback/FurumiPlaybackService.kt index 29325ae..64dd334 100644 --- a/app/src/main/java/com/example/furumi_android/playback/FurumiPlaybackService.kt +++ b/app/src/main/java/com/example/furumi_android/playback/FurumiPlaybackService.kt @@ -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) } } diff --git a/app/src/main/java/com/example/furumi_android/playback/PlaybackController.kt b/app/src/main/java/com/example/furumi_android/playback/PlaybackController.kt index 38441f8..217ffde 100644 --- a/app/src/main/java/com/example/furumi_android/playback/PlaybackController.kt +++ b/app/src/main/java/com/example/furumi_android/playback/PlaybackController.kt @@ -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 = _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 diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerScreen.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerScreen.kt index 2fe0a11..9a415cf 100644 --- a/app/src/main/java/com/example/furumi_android/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerScreen.kt @@ -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(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(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, mediaImages: Map, + likedTrackIds: Set, 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, - onTrackClick: (TrackCard) -> Unit + likedTrackIds: Set, + onTrackClick: (TrackCard) -> Unit, + onToggleLike: (Long) -> Unit, + onPlayNext: (TrackCard) -> Unit, + onPlayLast: (TrackCard) -> Unit, + onShare: (TrackCard) -> Unit, + onAddToPlaylist: (TrackCard) -> Unit ) { SectionTitle("Tracks") Spacer(modifier = Modifier.height(12.dp)) @@ -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, 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, + 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") diff --git a/app/src/main/java/com/example/furumi_android/ui/player/PlayerViewModel.kt b/app/src/main/java/com/example/furumi_android/ui/player/PlayerViewModel.kt index fe7e26d..116a6b7 100644 --- a/app/src/main/java/com/example/furumi_android/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/example/furumi_android/ui/player/PlayerViewModel.kt @@ -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 = emptyMap(), val loadingMediaImageUrls: Set = emptySet(), - val playback: AudioPlaybackState = AudioPlaybackState() + val playback: AudioPlaybackState = AudioPlaybackState(), + val likedTrackIds: Set = emptySet(), + val playlists: List = emptyList(), + val isPlaylistsLoading: Boolean = false ) @HiltViewModel @@ -61,6 +68,8 @@ class PlayerViewModel @Inject constructor( private val artistDetailCache = mutableMapOf() private val releaseDetailCache = mutableMapOf() + private val _shareEvent = MutableSharedFlow(extraBufferCapacity = 1) + val shareEvent: SharedFlow = _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(