added some features from web player
This commit is contained in:
Generated
+4
-7
@@ -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,14 +47,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
FurumiandroidTheme {
|
FurumiandroidTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
FurumiApp(
|
||||||
Box(modifier = Modifier.padding(innerPadding)) {
|
deepLinkUri = deepLinkUri,
|
||||||
FurumiApp(
|
onDeepLinkHandled = { deepLinkUri = null }
|
||||||
deepLinkUri = deepLinkUri,
|
)
|
||||||
onDeepLinkHandled = { deepLinkUri = null }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ class FurumiPlaybackService : MediaSessionService() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.d("FurumiPlaybackService", "onCreate")
|
Log.d("FurumiPlaybackService", "onCreate")
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
|
||||||
val sessionActivity = PendingIntent.getActivity(
|
val sessionActivity = PendingIntent.getActivity(
|
||||||
@@ -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(
|
Text(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
text = "Now playing",
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
color = FurumiTextMuted
|
||||||
) {
|
)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user