Added Jam, remote devices. small fixes
@@ -2,10 +2,6 @@
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="App">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
|
||||
@@ -32,6 +32,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
<application
|
||||
android:name=".FurumiApplication"
|
||||
android:allowBackup="true"
|
||||
android:appCategory="audio"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:description="@string/app_description"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Furumiandroid"
|
||||
android:theme="@style/Theme.Furumi"
|
||||
android:usesCleartextTraffic="true">
|
||||
<service
|
||||
android:name=".playback.FurumiPlaybackService"
|
||||
@@ -41,7 +43,7 @@
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Furumiandroid"
|
||||
android:theme="@style/Theme.Furumi"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -49,7 +51,7 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Furumi SSO Callback">
|
||||
<intent-filter android:label="@string/sso_callback_label">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
@@ -22,7 +22,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import com.example.furumi_android.ui.login.LoginScreen
|
||||
import com.example.furumi_android.ui.login.LoginViewModel
|
||||
import com.example.furumi_android.ui.player.PlayerScreen
|
||||
import com.example.furumi_android.ui.theme.FurumiandroidTheme
|
||||
import com.example.furumi_android.ui.theme.FurumiTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
setContent {
|
||||
FurumiandroidTheme {
|
||||
FurumiTheme {
|
||||
FurumiApp(
|
||||
deepLinkUri = deepLinkUri,
|
||||
onDeepLinkHandled = { deepLinkUri = null }
|
||||
|
||||
@@ -7,20 +7,24 @@ import javax.inject.Inject
|
||||
|
||||
class AccessTokenInterceptor @Inject constructor(
|
||||
private val sessionStorage: AuthSessionStorage,
|
||||
private val authEndpoints: AuthEndpoints
|
||||
private val authEndpoints: AuthEndpoints,
|
||||
private val appClientInfo: AppClientInfo
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val baseRequest = request.newBuilder()
|
||||
.header(HEADER_USER_AGENT, appClientInfo.userAgent)
|
||||
.build()
|
||||
|
||||
if (request.header(HEADER_AUTHORIZATION) != null ||
|
||||
authEndpoints.isLoginOrRefreshPath(request.url.encodedPath)
|
||||
if (baseRequest.header(HEADER_AUTHORIZATION) != null ||
|
||||
authEndpoints.isLoginOrRefreshPath(baseRequest.url.encodedPath)
|
||||
) {
|
||||
return chain.proceed(request)
|
||||
return chain.proceed(baseRequest)
|
||||
}
|
||||
|
||||
val tokens = sessionStorage.getTokens() ?: return chain.proceed(request)
|
||||
val authorizedRequest = request.newBuilder()
|
||||
val tokens = sessionStorage.getTokens() ?: return chain.proceed(baseRequest)
|
||||
val authorizedRequest = baseRequest.newBuilder()
|
||||
.header(HEADER_AUTHORIZATION, tokens.authorizationHeader)
|
||||
.build()
|
||||
|
||||
@@ -29,5 +33,6 @@ class AccessTokenInterceptor @Inject constructor(
|
||||
|
||||
companion object {
|
||||
const val HEADER_AUTHORIZATION = "Authorization"
|
||||
const val HEADER_USER_AGENT = "User-Agent"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ class PlayerEndpoints @Inject constructor() {
|
||||
|
||||
fun releaseDetail(baseUrl: String, releaseId: Long): String = "$baseUrl$RELEASES_PATH/$releaseId"
|
||||
|
||||
fun search(baseUrl: String): String = "$baseUrl$SEARCH_PATH"
|
||||
|
||||
fun history(baseUrl: String): String = "$baseUrl$HISTORY_PATH"
|
||||
|
||||
fun likesToggle(baseUrl: String, trackId: Long): String = "$baseUrl$LIKES_TOGGLE_PATH/$trackId"
|
||||
@@ -19,17 +21,47 @@ class PlayerEndpoints @Inject constructor() {
|
||||
|
||||
fun playlists(baseUrl: String): String = "$baseUrl$PLAYLISTS_PATH"
|
||||
|
||||
fun playlistDetail(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId"
|
||||
|
||||
fun addPlaylistTracks(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId/tracks"
|
||||
|
||||
fun sharePlaylist(baseUrl: String): String = "$baseUrl$SHARE_PLAYLIST_PATH"
|
||||
|
||||
fun shareTrack(baseUrl: String, trackId: Long): String = "${baseUrl.trimEnd('/')}$SHARE_TRACK_PATH/$trackId"
|
||||
|
||||
fun devicesPoll(baseUrl: String): String = "$baseUrl$DEVICES_POLL_PATH"
|
||||
|
||||
fun devicesActive(baseUrl: String): String = "$baseUrl$DEVICES_ACTIVE_PATH"
|
||||
|
||||
fun devicesCommand(baseUrl: String): String = "$baseUrl$DEVICES_COMMAND_PATH"
|
||||
|
||||
fun jams(baseUrl: String): String = "$baseUrl$JAMS_PATH"
|
||||
|
||||
fun jamsJoin(baseUrl: String): String = "$baseUrl$JAMS_JOIN_PATH"
|
||||
|
||||
fun jamsLeave(baseUrl: String): String = "$baseUrl$JAMS_LEAVE_PATH"
|
||||
|
||||
fun jamsUsers(baseUrl: String): String = "$baseUrl$JAMS_USERS_PATH"
|
||||
|
||||
fun jamsInvite(baseUrl: String): String = "$baseUrl$JAMS_INVITE_PATH"
|
||||
|
||||
companion object {
|
||||
private const val ARTISTS_PATH = "/api/player/artists"
|
||||
private const val RELEASES_PATH = "/api/player/releases"
|
||||
private const val SEARCH_PATH = "/api/player/search"
|
||||
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"
|
||||
private const val SHARE_TRACK_PATH = "/share/track"
|
||||
private const val DEVICES_POLL_PATH = "/api/player/devices/poll"
|
||||
private const val DEVICES_ACTIVE_PATH = "/api/player/devices/active"
|
||||
private const val DEVICES_COMMAND_PATH = "/api/player/devices/command"
|
||||
private const val JAMS_PATH = "/api/player/jams"
|
||||
private const val JAMS_USERS_PATH = "/api/player/jams/users"
|
||||
private const val JAMS_JOIN_PATH = "/api/player/jams/join"
|
||||
private const val JAMS_INVITE_PATH = "/api/player/jams/invite"
|
||||
private const val JAMS_LEAVE_PATH = "/api/player/jams/leave"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,20 @@ 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.DeviceActiveRequest
|
||||
import com.example.furumi_android.data.remote.model.DeviceCommandRequest
|
||||
import com.example.furumi_android.data.remote.model.DevicePollRequest
|
||||
import com.example.furumi_android.data.remote.model.DevicePollResponse
|
||||
import com.example.furumi_android.data.remote.model.JamCreateRequest
|
||||
import com.example.furumi_android.data.remote.model.JamInviteRequest
|
||||
import com.example.furumi_android.data.remote.model.JamJoinRequest
|
||||
import com.example.furumi_android.data.remote.model.JamUserResponse
|
||||
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.PlaylistCardResponse
|
||||
import com.example.furumi_android.data.remote.model.PlaylistDetailResponse
|
||||
import com.example.furumi_android.data.remote.model.RecordHistoryRequest
|
||||
import com.example.furumi_android.data.remote.model.SearchResponse
|
||||
import com.example.furumi_android.data.remote.model.SharePlaylistRequest
|
||||
import com.example.furumi_android.data.remote.model.SharePlaylistResponse
|
||||
import retrofit2.Response
|
||||
@@ -41,6 +51,14 @@ interface PlayerApi {
|
||||
@Url url: String
|
||||
): Response<ReleaseDetailResponse>
|
||||
|
||||
@Headers("Accept: application/json")
|
||||
@GET
|
||||
suspend fun search(
|
||||
@Url url: String,
|
||||
@Query("q") query: String,
|
||||
@Query("limit") limit: Int
|
||||
): Response<SearchResponse>
|
||||
|
||||
@Headers("Accept: application/json")
|
||||
@GET
|
||||
suspend fun history(
|
||||
@@ -72,7 +90,13 @@ interface PlayerApi {
|
||||
@GET
|
||||
suspend fun playlists(
|
||||
@Url url: String
|
||||
): Response<PlaylistListResponse>
|
||||
): Response<List<PlaylistCardResponse>>
|
||||
|
||||
@Headers("Accept: application/json")
|
||||
@GET
|
||||
suspend fun playlistDetail(
|
||||
@Url url: String
|
||||
): Response<PlaylistDetailResponse>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
@@ -87,4 +111,61 @@ interface PlayerApi {
|
||||
@Url url: String,
|
||||
@Body body: SharePlaylistRequest
|
||||
): Response<SharePlaylistResponse>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
suspend fun pollDevice(
|
||||
@Url url: String,
|
||||
@Body body: DevicePollRequest
|
||||
): Response<DevicePollResponse>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
suspend fun setActiveDevice(
|
||||
@Url url: String,
|
||||
@Body body: DeviceActiveRequest
|
||||
): Response<DevicePollResponse>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
suspend fun sendDeviceCommand(
|
||||
@Url url: String,
|
||||
@Body body: DeviceCommandRequest
|
||||
): Response<Unit>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
suspend fun createJam(
|
||||
@Url url: String,
|
||||
@Body body: JamCreateRequest
|
||||
): Response<DevicePollResponse>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
suspend fun joinJam(
|
||||
@Url url: String,
|
||||
@Body body: JamJoinRequest
|
||||
): Response<DevicePollResponse>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
suspend fun leaveJam(
|
||||
@Url url: String,
|
||||
@Body body: JamJoinRequest
|
||||
): Response<DevicePollResponse>
|
||||
|
||||
@Headers("Accept: application/json")
|
||||
@GET
|
||||
suspend fun searchJamUsers(
|
||||
@Url url: String,
|
||||
@Query("q") query: String,
|
||||
@Query("limit") limit: Int
|
||||
): Response<List<JamUserResponse>>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
suspend fun inviteToJam(
|
||||
@Url url: String,
|
||||
@Body body: JamInviteRequest
|
||||
): Response<DevicePollResponse>
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package com.example.furumi_android.data.remote.model
|
||||
import com.example.furumi_android.domain.model.ArtistCard
|
||||
import com.example.furumi_android.domain.model.ArtistDetail
|
||||
import com.example.furumi_android.domain.model.ArtistPage
|
||||
import com.example.furumi_android.domain.model.ArtistRef
|
||||
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.PlaylistDetail
|
||||
import com.example.furumi_android.domain.model.ReleaseCard
|
||||
import com.example.furumi_android.domain.model.ReleaseDetail
|
||||
import com.example.furumi_android.domain.model.SearchResults
|
||||
import com.example.furumi_android.domain.model.TrackCard
|
||||
import com.squareup.moshi.Json
|
||||
|
||||
@@ -85,6 +88,12 @@ data class ArtistDetailResponse(
|
||||
@param:Json(name = "featured_tracks") val featuredTracks: List<TrackItemResponse> = emptyList()
|
||||
)
|
||||
|
||||
data class SearchResponse(
|
||||
@param:Json(name = "artists") val artists: List<ArtistCardResponse> = emptyList(),
|
||||
@param:Json(name = "releases") val releases: List<ReleaseCardResponse> = emptyList(),
|
||||
@param:Json(name = "tracks") val tracks: List<TrackItemResponse> = emptyList()
|
||||
)
|
||||
|
||||
data class LikeToggleRequest(
|
||||
@param:Json(name = "track_id") val trackId: Long
|
||||
)
|
||||
@@ -99,16 +108,20 @@ data class LikedTrackIdsResponse(
|
||||
|
||||
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 = "started_at") val startedAt: Long,
|
||||
@param:Json(name = "duration_listened") val durationListened: Int,
|
||||
@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
|
||||
@param:Json(name = "track_count") val trackCount: Int = 0,
|
||||
@param:Json(name = "is_own") val isOwn: Boolean = false,
|
||||
@param:Json(name = "owner_name") val ownerName: String? = null,
|
||||
@param:Json(name = "is_public") val isPublic: Boolean = false,
|
||||
@param:Json(name = "is_saved") val isSaved: Boolean = false,
|
||||
@param:Json(name = "kind") val kind: String? = null
|
||||
)
|
||||
|
||||
data class PlaylistListResponse(
|
||||
@@ -141,6 +154,20 @@ data class ReleaseDetailResponse(
|
||||
@param:Json(name = "tracks") val tracks: List<TrackItemResponse> = emptyList()
|
||||
)
|
||||
|
||||
data class PlaylistDetailResponse(
|
||||
@param:Json(name = "playlist") val playlist: PlaylistCardResponse? = null,
|
||||
@param:Json(name = "id") val id: Long? = null,
|
||||
@param:Json(name = "title") val title: String? = null,
|
||||
@param:Json(name = "description") val description: String? = null,
|
||||
@param:Json(name = "track_count") val trackCount: Int? = null,
|
||||
@param:Json(name = "is_own") val isOwn: Boolean? = null,
|
||||
@param:Json(name = "owner_name") val ownerName: String? = null,
|
||||
@param:Json(name = "is_public") val isPublic: Boolean? = null,
|
||||
@param:Json(name = "is_saved") val isSaved: Boolean? = null,
|
||||
@param:Json(name = "kind") val kind: String? = null,
|
||||
@param:Json(name = "tracks") val tracks: List<TrackItemResponse> = emptyList()
|
||||
)
|
||||
|
||||
fun PlayHistoryPageResponse.toDomain(): ListeningHistoryPage {
|
||||
return ListeningHistoryPage(
|
||||
items = items.map { it.toDomain() },
|
||||
@@ -216,6 +243,27 @@ fun ReleaseDetailResponse.toDomain(baseUrl: String): ReleaseDetail {
|
||||
)
|
||||
}
|
||||
|
||||
fun SearchResponse.toDomain(baseUrl: String): SearchResults {
|
||||
val trackArtists = tracks
|
||||
.flatMap { track -> track.artists.ifEmpty { track.featuredArtists } }
|
||||
.distinctBy { it.id }
|
||||
.map { artist ->
|
||||
ArtistCard(
|
||||
id = artist.id,
|
||||
name = artist.name,
|
||||
imageUrl = null,
|
||||
releaseCount = 0,
|
||||
trackCount = 0
|
||||
)
|
||||
}
|
||||
|
||||
return SearchResults(
|
||||
artistMatches = artists.map { it.toDomain(baseUrl) },
|
||||
trackArtists = trackArtists,
|
||||
tracks = tracks.map { it.toDomain(baseUrl) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun ArtistCardResponse.toDomain(baseUrl: String): ArtistCard {
|
||||
return ArtistCard(
|
||||
id = id,
|
||||
@@ -237,9 +285,11 @@ private fun ReleaseCardResponse.toDomain(baseUrl: String): ReleaseCard {
|
||||
)
|
||||
}
|
||||
|
||||
private fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
||||
val artistNames = artists
|
||||
.ifEmpty { featuredArtists }
|
||||
fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
||||
val mappedArtists = artists.map { it.toDomain() }
|
||||
val mappedFeaturedArtists = featuredArtists.map { it.toDomain() }
|
||||
val artistNames = mappedArtists
|
||||
.ifEmpty { mappedFeaturedArtists }
|
||||
.map { it.name }
|
||||
|
||||
return TrackCard(
|
||||
@@ -249,23 +299,101 @@ private fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
||||
discNumber = discNumber,
|
||||
durationSeconds = durationSeconds.toInt(),
|
||||
artists = artistNames,
|
||||
artistRefs = mappedArtists,
|
||||
featuredArtistRefs = mappedFeaturedArtists,
|
||||
releaseTitle = releaseTitle,
|
||||
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
|
||||
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
fun TrackCard.toTrackItemResponse(): TrackItemResponse {
|
||||
val primaryArtists = artistRefs.ifEmpty {
|
||||
artists.mapIndexed { index, name ->
|
||||
ArtistRef(id = -(index + 1L), name = name)
|
||||
}
|
||||
}
|
||||
|
||||
return TrackItemResponse(
|
||||
id = id,
|
||||
title = title,
|
||||
trackNumber = trackNumber,
|
||||
discNumber = discNumber,
|
||||
durationSeconds = durationSeconds?.toDouble() ?: 0.0,
|
||||
artists = primaryArtists.map { it.toResponse() },
|
||||
featuredArtists = featuredArtistRefs.map { it.toResponse() },
|
||||
releaseTitle = releaseTitle,
|
||||
coverUrl = coverUrl,
|
||||
streamUrl = streamUrl
|
||||
)
|
||||
}
|
||||
|
||||
private fun ArtistRefResponse.toDomain(): ArtistRef {
|
||||
return ArtistRef(
|
||||
id = id,
|
||||
name = name
|
||||
)
|
||||
}
|
||||
|
||||
private fun ArtistRef.toResponse(): ArtistRefResponse {
|
||||
return ArtistRefResponse(
|
||||
id = id,
|
||||
name = name
|
||||
)
|
||||
}
|
||||
|
||||
fun PlaylistListResponse.toDomain(): List<PlaylistCard> {
|
||||
return playlists.map {
|
||||
PlaylistCard(
|
||||
id = it.id,
|
||||
title = it.title,
|
||||
trackCount = it.trackCount,
|
||||
isPublic = it.isPublic
|
||||
isPublic = it.isPublic,
|
||||
isOwn = it.isOwn,
|
||||
ownerName = it.ownerName,
|
||||
isSaved = it.isSaved,
|
||||
kind = it.kind
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun List<PlaylistCardResponse>.toDomain(): List<PlaylistCard> {
|
||||
return map { it.toDomain() }
|
||||
}
|
||||
|
||||
fun PlaylistDetailResponse.toDomain(baseUrl: String): PlaylistDetail {
|
||||
val mappedTracks = tracks.map { it.toDomain(baseUrl) }
|
||||
val mappedPlaylist = playlist?.toDomain() ?: PlaylistCard(
|
||||
id = id ?: 0L,
|
||||
title = title ?: "Untitled playlist",
|
||||
trackCount = trackCount ?: mappedTracks.size,
|
||||
isPublic = isPublic ?: false,
|
||||
isOwn = isOwn ?: false,
|
||||
ownerName = ownerName,
|
||||
isSaved = isSaved ?: false,
|
||||
kind = kind
|
||||
)
|
||||
|
||||
return PlaylistDetail(
|
||||
playlist = mappedPlaylist,
|
||||
description = description,
|
||||
tracks = mappedTracks
|
||||
)
|
||||
}
|
||||
|
||||
private fun PlaylistCardResponse.toDomain(): PlaylistCard {
|
||||
return PlaylistCard(
|
||||
id = id,
|
||||
title = title,
|
||||
trackCount = trackCount,
|
||||
isPublic = isPublic,
|
||||
isOwn = isOwn,
|
||||
ownerName = ownerName,
|
||||
isSaved = isSaved,
|
||||
kind = kind
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.toAbsoluteMediaUrl(baseUrl: String): String? {
|
||||
val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
if (value.startsWith("http://", ignoreCase = true) ||
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
package com.example.furumi_android.data.repository
|
||||
|
||||
import com.example.furumi_android.data.local.AuthSessionStorage
|
||||
import com.example.furumi_android.data.local.ConnectedDeviceStorage
|
||||
import com.example.furumi_android.data.remote.AppClientInfo
|
||||
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.ConnectedCommandPayloadBody
|
||||
import com.example.furumi_android.data.remote.model.DeviceActiveRequest
|
||||
import com.example.furumi_android.data.remote.model.DeviceCommandRequest
|
||||
import com.example.furumi_android.data.remote.model.DevicePollRequest
|
||||
import com.example.furumi_android.data.remote.model.JamCreateRequest
|
||||
import com.example.furumi_android.data.remote.model.JamInviteRequest
|
||||
import com.example.furumi_android.data.remote.model.JamJoinRequest
|
||||
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.toBody
|
||||
import com.example.furumi_android.data.remote.model.toCommandPayloadBody
|
||||
import com.example.furumi_android.data.remote.model.toTrackItemResponse
|
||||
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.ConnectedCommand
|
||||
import com.example.furumi_android.domain.model.ConnectedDevicesState
|
||||
import com.example.furumi_android.domain.model.ConnectedJamUser
|
||||
import com.example.furumi_android.domain.model.ConnectedPlaybackState
|
||||
import com.example.furumi_android.domain.model.ListeningHistoryPage
|
||||
import com.example.furumi_android.domain.model.PlaylistCard
|
||||
import com.example.furumi_android.domain.model.PlaylistDetail
|
||||
import com.example.furumi_android.domain.model.ReleaseDetail
|
||||
import com.example.furumi_android.domain.model.SearchResults
|
||||
import com.example.furumi_android.domain.model.TrackCard
|
||||
import com.example.furumi_android.domain.repository.PlayerRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import javax.inject.Inject
|
||||
@@ -21,6 +39,8 @@ import javax.inject.Inject
|
||||
class PlayerRepositoryImpl @Inject constructor(
|
||||
private val playerApi: PlayerApi,
|
||||
private val sessionStorage: AuthSessionStorage,
|
||||
private val connectedDeviceStorage: ConnectedDeviceStorage,
|
||||
private val appClientInfo: AppClientInfo,
|
||||
private val playerEndpoints: PlayerEndpoints,
|
||||
private val errorParser: AuthApiErrorParser
|
||||
) : PlayerRepository {
|
||||
@@ -91,6 +111,29 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun search(query: String, limit: Int): Result<SearchResults> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.search(
|
||||
url = playerEndpoints.search(baseUrl),
|
||||
query = query,
|
||||
limit = limit
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Search response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
@@ -158,8 +201,8 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun recordListeningHistory(
|
||||
trackId: Long,
|
||||
startedAt: String,
|
||||
durationListened: Double,
|
||||
startedAt: Long,
|
||||
durationListened: Int,
|
||||
completed: Boolean
|
||||
): Result<Unit> {
|
||||
return try {
|
||||
@@ -208,6 +251,27 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.playlistDetail(
|
||||
url = playerEndpoints.playlistDetail(baseUrl, playlistId)
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Playlist response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} 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()
|
||||
@@ -233,11 +297,28 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
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
|
||||
Result.success(playerEndpoints.shareTrack(baseUrl, trackId))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pollConnectedDevice(
|
||||
playbackState: ConnectedPlaybackState?,
|
||||
currentJamId: String?
|
||||
): Result<Pair<ConnectedDevicesState, List<ConnectedCommand>>> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.pollDevice(
|
||||
url = playerEndpoints.devicesPoll(baseUrl),
|
||||
body = DevicePollRequest(
|
||||
deviceId = connectedDeviceStorage.getOrCreateDeviceId(),
|
||||
userAgent = appClientInfo.userAgent,
|
||||
currentJamId = currentJamId,
|
||||
playbackState = playbackState?.toBody()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -245,8 +326,203 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Share response is empty")
|
||||
Result.success(body.url)
|
||||
val body = response.body() ?: throw AuthException("Device poll response is empty")
|
||||
Result.success(body.toDomain(baseUrl) to body.commands.map { it.toDomain(baseUrl) })
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setActiveDevice(deviceId: String): Result<ConnectedDevicesState> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.setActiveDevice(
|
||||
url = playerEndpoints.devicesActive(baseUrl),
|
||||
body = DeviceActiveRequest(
|
||||
deviceId = deviceId,
|
||||
currentDeviceId = connectedDeviceStorage.getOrCreateDeviceId()
|
||||
)
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Active device response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendConnectedDeviceCommand(
|
||||
targetDeviceId: String?,
|
||||
jamId: String?,
|
||||
command: String,
|
||||
payload: ConnectedPlaybackState?,
|
||||
tracks: List<TrackCard>,
|
||||
index: Int?,
|
||||
time: Double?,
|
||||
volume: Double?,
|
||||
shuffle: Boolean?,
|
||||
repeatMode: String?,
|
||||
fromIndex: Int?,
|
||||
toIndex: Int?
|
||||
): Result<Unit> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val commandPayload = payload?.toCommandPayloadBody() ?: ConnectedCommandPayloadBody(
|
||||
tracks = tracks.takeIf { it.isNotEmpty() }?.map { it.toTrackItemResponse() },
|
||||
index = index,
|
||||
time = time,
|
||||
volume = volume,
|
||||
shuffle = shuffle,
|
||||
repeatMode = repeatMode,
|
||||
fromIndex = fromIndex,
|
||||
toIndex = toIndex
|
||||
)
|
||||
val response = playerApi.sendDeviceCommand(
|
||||
url = playerEndpoints.devicesCommand(baseUrl),
|
||||
body = DeviceCommandRequest(
|
||||
targetDeviceId = targetDeviceId,
|
||||
jamId = jamId,
|
||||
command = command,
|
||||
payload = commandPayload
|
||||
)
|
||||
)
|
||||
|
||||
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 createJam(): Result<ConnectedDevicesState> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.createJam(
|
||||
url = playerEndpoints.jams(baseUrl),
|
||||
body = JamCreateRequest(deviceId = connectedDeviceStorage.getOrCreateDeviceId())
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun joinJam(jamId: String): Result<ConnectedDevicesState> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.joinJam(
|
||||
url = playerEndpoints.jamsJoin(baseUrl),
|
||||
body = JamJoinRequest(
|
||||
jamId = jamId,
|
||||
deviceId = connectedDeviceStorage.getOrCreateDeviceId()
|
||||
)
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun leaveJam(jamId: String): Result<ConnectedDevicesState> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.leaveJam(
|
||||
url = playerEndpoints.jamsLeave(baseUrl),
|
||||
body = JamJoinRequest(
|
||||
jamId = jamId,
|
||||
deviceId = connectedDeviceStorage.getOrCreateDeviceId()
|
||||
)
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun searchJamUsers(query: String, limit: Int): Result<List<ConnectedJamUser>> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.searchJamUsers(
|
||||
url = playerEndpoints.jamsUsers(baseUrl),
|
||||
query = query,
|
||||
limit = limit
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam users response is empty")
|
||||
Result.success(body.map { it.toDomain() })
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState> {
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val response = playerApi.inviteToJam(
|
||||
url = playerEndpoints.jamsInvite(baseUrl),
|
||||
body = JamInviteRequest(
|
||||
jamId = jamId,
|
||||
deviceId = connectedDeviceStorage.getOrCreateDeviceId(),
|
||||
inviteeUserIds = inviteeUserIds
|
||||
)
|
||||
)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.example.furumi_android.di
|
||||
|
||||
import com.example.furumi_android.data.local.AuthSessionStorage
|
||||
import com.example.furumi_android.data.remote.AccessTokenInterceptor
|
||||
import com.example.furumi_android.data.remote.AppClientInfo
|
||||
import com.example.furumi_android.data.remote.AuthAuthenticator
|
||||
import com.example.furumi_android.data.remote.AuthEndpoints
|
||||
import com.example.furumi_android.data.remote.api.AuthApi
|
||||
@@ -26,9 +27,10 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideAccessTokenInterceptor(
|
||||
sessionStorage: AuthSessionStorage,
|
||||
authEndpoints: AuthEndpoints
|
||||
authEndpoints: AuthEndpoints,
|
||||
appClientInfo: AppClientInfo
|
||||
): AccessTokenInterceptor {
|
||||
return AccessTokenInterceptor(sessionStorage, authEndpoints)
|
||||
return AccessTokenInterceptor(sessionStorage, authEndpoints, appClientInfo)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -8,6 +8,11 @@ data class ArtistCard(
|
||||
val trackCount: Int
|
||||
)
|
||||
|
||||
data class ArtistRef(
|
||||
val id: Long,
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class ArtistPage(
|
||||
val items: List<ArtistCard>,
|
||||
val total: Long,
|
||||
@@ -32,6 +37,8 @@ data class TrackCard(
|
||||
val discNumber: Int?,
|
||||
val durationSeconds: Int?,
|
||||
val artists: List<String>,
|
||||
val artistRefs: List<ArtistRef> = emptyList(),
|
||||
val featuredArtistRefs: List<ArtistRef> = emptyList(),
|
||||
val releaseTitle: String?,
|
||||
val coverUrl: String?,
|
||||
val streamUrl: String
|
||||
@@ -54,5 +61,21 @@ data class PlaylistCard(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val trackCount: Int,
|
||||
val isPublic: Boolean
|
||||
val isPublic: Boolean,
|
||||
val isOwn: Boolean = false,
|
||||
val ownerName: String? = null,
|
||||
val isSaved: Boolean = false,
|
||||
val kind: String? = null
|
||||
)
|
||||
|
||||
data class PlaylistDetail(
|
||||
val playlist: PlaylistCard,
|
||||
val description: String?,
|
||||
val tracks: List<TrackCard>
|
||||
)
|
||||
|
||||
data class SearchResults(
|
||||
val artistMatches: List<ArtistCard>,
|
||||
val trackArtists: List<ArtistCard>,
|
||||
val tracks: List<TrackCard>
|
||||
)
|
||||
|
||||
@@ -2,9 +2,16 @@ 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.ConnectedCommand
|
||||
import com.example.furumi_android.domain.model.ConnectedDevicesState
|
||||
import com.example.furumi_android.domain.model.ConnectedJamUser
|
||||
import com.example.furumi_android.domain.model.ConnectedPlaybackState
|
||||
import com.example.furumi_android.domain.model.ListeningHistoryPage
|
||||
import com.example.furumi_android.domain.model.PlaylistCard
|
||||
import com.example.furumi_android.domain.model.PlaylistDetail
|
||||
import com.example.furumi_android.domain.model.ReleaseDetail
|
||||
import com.example.furumi_android.domain.model.SearchResults
|
||||
import com.example.furumi_android.domain.model.TrackCard
|
||||
|
||||
interface PlayerRepository {
|
||||
suspend fun getArtists(
|
||||
@@ -17,6 +24,8 @@ interface PlayerRepository {
|
||||
|
||||
suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail>
|
||||
|
||||
suspend fun search(query: String, limit: Int = 20): Result<SearchResults>
|
||||
|
||||
suspend fun getListeningHistory(page: Int = 1, limit: Int = 20): Result<ListeningHistoryPage>
|
||||
|
||||
suspend fun toggleLike(trackId: Long): Result<Boolean>
|
||||
@@ -25,14 +34,48 @@ interface PlayerRepository {
|
||||
|
||||
suspend fun recordListeningHistory(
|
||||
trackId: Long,
|
||||
startedAt: String,
|
||||
durationListened: Double,
|
||||
startedAt: Long,
|
||||
durationListened: Int,
|
||||
completed: Boolean
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun getPlaylists(): Result<List<PlaylistCard>>
|
||||
|
||||
suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail>
|
||||
|
||||
suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit>
|
||||
|
||||
suspend fun shareTrack(trackId: Long, title: String): Result<String>
|
||||
|
||||
suspend fun pollConnectedDevice(
|
||||
playbackState: ConnectedPlaybackState?,
|
||||
currentJamId: String? = null
|
||||
): Result<Pair<ConnectedDevicesState, List<ConnectedCommand>>>
|
||||
|
||||
suspend fun setActiveDevice(deviceId: String): Result<ConnectedDevicesState>
|
||||
|
||||
suspend fun sendConnectedDeviceCommand(
|
||||
targetDeviceId: String?,
|
||||
jamId: String?,
|
||||
command: String,
|
||||
payload: ConnectedPlaybackState? = null,
|
||||
tracks: List<TrackCard> = emptyList(),
|
||||
index: Int? = null,
|
||||
time: Double? = null,
|
||||
volume: Double? = null,
|
||||
shuffle: Boolean? = null,
|
||||
repeatMode: String? = null,
|
||||
fromIndex: Int? = null,
|
||||
toIndex: Int? = null
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun createJam(): Result<ConnectedDevicesState>
|
||||
|
||||
suspend fun joinJam(jamId: String): Result<ConnectedDevicesState>
|
||||
|
||||
suspend fun leaveJam(jamId: String): Result<ConnectedDevicesState>
|
||||
|
||||
suspend fun searchJamUsers(query: String, limit: Int = 10): Result<List<ConnectedJamUser>>
|
||||
|
||||
suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState>
|
||||
}
|
||||
|
||||
@@ -3,15 +3,17 @@ 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
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
@@ -20,6 +22,8 @@ import com.example.furumi_android.MainActivity
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -29,6 +33,7 @@ class FurumiPlaybackService : MediaSessionService() {
|
||||
@Inject lateinit var okHttpClient: OkHttpClient
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
private var bitmapExecutor: ExecutorService? = null
|
||||
|
||||
companion object {
|
||||
const val NOTIFICATION_ID = 1001
|
||||
@@ -49,9 +54,11 @@ class FurumiPlaybackService : MediaSessionService() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
bitmapExecutor = executor
|
||||
val bitmapLoader = CacheBitmapLoader(
|
||||
androidx.media3.datasource.DataSourceBitmapLoader(
|
||||
MoreExecutors.listeningDecorator(java.util.concurrent.Executors.newSingleThreadExecutor()),
|
||||
DataSourceBitmapLoader(
|
||||
MoreExecutors.listeningDecorator(executor),
|
||||
OkHttpDataSource.Factory(okHttpClient)
|
||||
)
|
||||
)
|
||||
@@ -73,30 +80,44 @@ class FurumiPlaybackService : MediaSessionService() {
|
||||
|
||||
@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 placeholder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
val player = session.player
|
||||
val metadata = player.mediaMetadata
|
||||
|
||||
val title = metadata.title ?: metadata.displayTitle ?: "Furumi"
|
||||
val artist = metadata.artist ?: metadata.subtitle ?: "Playing..."
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentTitle(metadata?.title ?: "Furumi")
|
||||
.setContentText(metadata?.artist ?: "Playing...")
|
||||
.setContentTitle(title)
|
||||
.setContentText(artist)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(true)
|
||||
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
|
||||
.build()
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, placeholder, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, placeholder)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (exception: RuntimeException) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
exception.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
|
||||
) {
|
||||
Log.w("FurumiPlaybackService", "Foreground start was not allowed", exception)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -108,14 +129,13 @@ class FurumiPlaybackService : MediaSessionService() {
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name = "Furumi Playback"
|
||||
val importance = NotificationManager.IMPORTANCE_LOW
|
||||
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
val channel = NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
setShowBadge(true)
|
||||
enableVibration(false)
|
||||
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||
enableLights(false)
|
||||
setSound(null, null)
|
||||
}
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
@@ -129,11 +149,12 @@ class FurumiPlaybackService : MediaSessionService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d("FurumiPlaybackService", "onDestroy")
|
||||
mediaSession?.run {
|
||||
release()
|
||||
mediaSession = null
|
||||
}
|
||||
bitmapExecutor?.shutdown()
|
||||
bitmapExecutor = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,23 @@ package com.example.furumi_android.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import com.example.furumi_android.data.repository.MediaImageLoader
|
||||
import com.example.furumi_android.domain.model.TrackCard
|
||||
import com.example.furumi_android.domain.model.ConnectedPlaybackState
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -33,7 +30,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class AudioPlaybackState(
|
||||
val queue: List<TrackCard> = emptyList(),
|
||||
@@ -42,6 +40,9 @@ data class AudioPlaybackState(
|
||||
val isBuffering: Boolean = false,
|
||||
val positionMs: Long = 0L,
|
||||
val durationMs: Long = 0L,
|
||||
val shuffle: Boolean = false,
|
||||
val repeatMode: String = REPEAT_MODE_OFF,
|
||||
val volume: Float = 1f,
|
||||
val errorMessage: String? = null
|
||||
) {
|
||||
val currentTrack: TrackCard?
|
||||
@@ -62,6 +63,7 @@ data class AudioPlaybackState(
|
||||
|
||||
private companion object {
|
||||
const val RESTART_WINDOW_MS = 3_000L
|
||||
const val REPEAT_MODE_OFF = "off"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,16 +71,17 @@ data class AudioPlaybackState(
|
||||
@Singleton
|
||||
class PlaybackController @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val mediaImageLoader: MediaImageLoader,
|
||||
okHttpClient: OkHttpClient
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val _state = MutableStateFlow(AudioPlaybackState())
|
||||
|
||||
private var playStartedAt: Long = 0L
|
||||
private var playStartedAtMs: Long = 0L
|
||||
private var playStartTrackId: Long = -1L
|
||||
private var listenedDurationMs: Long = 0L
|
||||
private var lastPlaybackResumedAtMs: Long = 0L
|
||||
|
||||
var onTrackPlayReported: ((trackId: Long, startedAt: String, durationListened: Double, completed: Boolean) -> Unit)? = null
|
||||
var onTrackPlayReported: ((trackId: Long, startedAt: Long, durationListened: Int, completed: Boolean) -> Unit)? = null
|
||||
|
||||
val player: ExoPlayer
|
||||
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
|
||||
@@ -105,6 +108,7 @@ class PlaybackController @Inject constructor(
|
||||
player.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
updateListeningTimer(isPlaying)
|
||||
publishState()
|
||||
}
|
||||
|
||||
@@ -117,10 +121,12 @@ class PlaybackController @Inject constructor(
|
||||
|
||||
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||
publishState()
|
||||
startPlaybackService()
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
reportCurrentTrackPlay(completed = false)
|
||||
val completed = reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||
reportCurrentTrackPlay(completed = completed)
|
||||
startTrackTimer()
|
||||
publishState()
|
||||
startPlaybackService()
|
||||
@@ -155,19 +161,18 @@ class PlaybackController @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
reportCurrentTrackPlay(completed = false)
|
||||
|
||||
val safeStartIndex = startIndex.coerceIn(0, playableQueue.lastIndex)
|
||||
val mediaItems = playableQueue.map { track ->
|
||||
MediaItem.Builder()
|
||||
.setMediaId(track.id.toString())
|
||||
.setUri(track.streamUrl)
|
||||
.setMediaMetadata(track.toMediaMetadata())
|
||||
.build()
|
||||
}
|
||||
val mediaItems = playableQueue.map { track -> track.toMediaItem() }
|
||||
|
||||
_state.value = AudioPlaybackState(
|
||||
queue = playableQueue,
|
||||
currentIndex = safeStartIndex,
|
||||
isBuffering = true
|
||||
isBuffering = true,
|
||||
shuffle = player.shuffleModeEnabled,
|
||||
repeatMode = player.repeatMode.toFurumiRepeatMode(),
|
||||
volume = player.volume
|
||||
)
|
||||
|
||||
player.setMediaItems(mediaItems, safeStartIndex, 0L)
|
||||
@@ -194,15 +199,27 @@ class PlaybackController @Inject constructor(
|
||||
if (_state.value.currentTrack == null) return
|
||||
|
||||
if (player.isPlaying) {
|
||||
player.pause()
|
||||
pause()
|
||||
} else {
|
||||
player.play()
|
||||
resume()
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
player.pause()
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
if (_state.value.currentTrack == null) return
|
||||
player.play()
|
||||
startPlaybackService()
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun next() {
|
||||
if (player.hasNextMediaItem()) {
|
||||
reportCurrentTrackPlay(completed = false)
|
||||
player.seekToNextMediaItem()
|
||||
player.play()
|
||||
}
|
||||
@@ -213,6 +230,7 @@ class PlaybackController @Inject constructor(
|
||||
if (player.currentPosition > RESTART_WINDOW_MS || !player.hasPreviousMediaItem()) {
|
||||
player.seekTo(0L)
|
||||
} else {
|
||||
reportCurrentTrackPlay(completed = false)
|
||||
player.seekToPreviousMediaItem()
|
||||
}
|
||||
player.play()
|
||||
@@ -225,12 +243,49 @@ class PlaybackController @Inject constructor(
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun seekToSeconds(seconds: Double) {
|
||||
player.seekTo((seconds.coerceAtLeast(0.0) * 1_000.0).toLong())
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun setVolume(volume: Double) {
|
||||
player.volume = volume.toFloat().coerceIn(0f, 1f)
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun setOptions(shuffle: Boolean?, repeatMode: String?) {
|
||||
shuffle?.let { player.shuffleModeEnabled = it }
|
||||
repeatMode?.let { player.repeatMode = it.toPlayerRepeatMode() }
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun playConnectedState(playbackState: ConnectedPlaybackState) {
|
||||
val queue = playbackState.tracks.ifEmpty {
|
||||
playbackState.track?.let(::listOf).orEmpty()
|
||||
}.filter { it.streamUrl.isNotBlank() }
|
||||
if (queue.isEmpty()) return
|
||||
|
||||
setOptions(playbackState.shuffle, playbackState.repeatMode)
|
||||
setVolume(playbackState.volume)
|
||||
playQueue(queue, playbackState.index.coerceIn(0, queue.lastIndex))
|
||||
player.seekTo((playbackState.positionSeconds.coerceAtLeast(0.0) * 1_000.0).toLong())
|
||||
if (playbackState.paused) {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
startPlaybackService()
|
||||
}
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun release() {
|
||||
reportCurrentTrackPlay(completed = false)
|
||||
scope.cancel()
|
||||
player.release()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
reportCurrentTrackPlay(completed = false)
|
||||
player.stop()
|
||||
_state.value = AudioPlaybackState()
|
||||
}
|
||||
@@ -244,11 +299,7 @@ class PlaybackController @Inject constructor(
|
||||
}
|
||||
|
||||
val insertIndex = current.currentIndex + 1
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setMediaId(track.id.toString())
|
||||
.setUri(track.streamUrl)
|
||||
.setMediaMetadata(track.toMediaMetadata())
|
||||
.build()
|
||||
val mediaItem = track.toMediaItem()
|
||||
|
||||
player.addMediaItem(insertIndex, mediaItem)
|
||||
val updatedQueue = current.queue.toMutableList().apply { add(insertIndex, track) }
|
||||
@@ -263,36 +314,129 @@ class PlaybackController @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setMediaId(track.id.toString())
|
||||
.setUri(track.streamUrl)
|
||||
.setMediaMetadata(track.toMediaMetadata())
|
||||
.build()
|
||||
val mediaItem = track.toMediaItem()
|
||||
|
||||
player.addMediaItem(mediaItem)
|
||||
val updatedQueue = current.queue + track
|
||||
_state.value = current.copy(queue = updatedQueue)
|
||||
}
|
||||
|
||||
fun addNext(tracks: List<TrackCard>) {
|
||||
tracks.filter { it.streamUrl.isNotBlank() }.forEachIndexed { offset, track ->
|
||||
val current = _state.value
|
||||
if (current.queue.isEmpty()) {
|
||||
playQueue(listOf(track))
|
||||
} else {
|
||||
val insertIndex = (current.currentIndex + 1 + offset).coerceIn(0, current.queue.size)
|
||||
player.addMediaItem(insertIndex, track.toMediaItem())
|
||||
_state.value = current.copy(
|
||||
queue = current.queue.toMutableList().apply { add(insertIndex, track) }
|
||||
)
|
||||
}
|
||||
}
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun addToEnd(tracks: List<TrackCard>) {
|
||||
val playableTracks = tracks.filter { it.streamUrl.isNotBlank() }
|
||||
if (playableTracks.isEmpty()) return
|
||||
val current = _state.value
|
||||
if (current.queue.isEmpty()) {
|
||||
playQueue(playableTracks)
|
||||
return
|
||||
}
|
||||
|
||||
player.addMediaItems(playableTracks.map { it.toMediaItem() })
|
||||
_state.value = current.copy(queue = current.queue + playableTracks)
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun removeQueueIndex(index: Int) {
|
||||
val current = _state.value
|
||||
if (index !in current.queue.indices) return
|
||||
|
||||
player.removeMediaItem(index)
|
||||
val updatedQueue = current.queue.toMutableList().apply { removeAt(index) }
|
||||
val updatedIndex = when {
|
||||
updatedQueue.isEmpty() -> -1
|
||||
index < current.currentIndex -> current.currentIndex - 1
|
||||
current.currentIndex >= updatedQueue.size -> updatedQueue.lastIndex
|
||||
else -> current.currentIndex
|
||||
}
|
||||
_state.value = current.copy(queue = updatedQueue, currentIndex = updatedIndex)
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
||||
val current = _state.value
|
||||
if (fromIndex !in current.queue.indices || toIndex !in current.queue.indices) return
|
||||
|
||||
player.moveMediaItem(fromIndex, toIndex)
|
||||
val updatedQueue = current.queue.toMutableList().apply {
|
||||
val item = removeAt(fromIndex)
|
||||
add(toIndex, item)
|
||||
}
|
||||
_state.value = current.copy(queue = updatedQueue)
|
||||
publishState()
|
||||
}
|
||||
|
||||
fun clearQueue() {
|
||||
reportCurrentTrackPlay(completed = false)
|
||||
player.clearMediaItems()
|
||||
_state.value = AudioPlaybackState(
|
||||
shuffle = player.shuffleModeEnabled,
|
||||
repeatMode = player.repeatMode.toFurumiRepeatMode(),
|
||||
volume = player.volume
|
||||
)
|
||||
}
|
||||
|
||||
private fun startTrackTimer() {
|
||||
val current = _state.value.currentTrack ?: return
|
||||
playStartedAt = System.currentTimeMillis()
|
||||
val currentQueue = _state.value.queue
|
||||
val currentIndex = player.currentMediaItemIndex
|
||||
.takeIf { it >= 0 }
|
||||
?: _state.value.currentIndex
|
||||
val current = currentQueue.getOrNull(currentIndex) ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
playStartedAtMs = now
|
||||
playStartTrackId = current.id
|
||||
listenedDurationMs = 0L
|
||||
lastPlaybackResumedAtMs = if (player.isPlaying) now else 0L
|
||||
}
|
||||
|
||||
private fun updateListeningTimer(isPlaying: Boolean) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (isPlaying) {
|
||||
if (playStartTrackId >= 0 && lastPlaybackResumedAtMs <= 0L) {
|
||||
lastPlaybackResumedAtMs = now
|
||||
}
|
||||
} else if (lastPlaybackResumedAtMs > 0L) {
|
||||
listenedDurationMs += (now - lastPlaybackResumedAtMs).coerceAtLeast(0L)
|
||||
lastPlaybackResumedAtMs = 0L
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (playStartTrackId < 0 || playStartedAtMs <= 0L) return
|
||||
updateListeningTimer(isPlaying = false)
|
||||
|
||||
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))
|
||||
val durationMs = listenedDurationMs.takeIf { it > 0L }
|
||||
?: (System.currentTimeMillis() - playStartedAtMs).coerceAtLeast(0L)
|
||||
if (durationMs < 1_000L) return
|
||||
val durationSec = (durationMs / 1_000L)
|
||||
.coerceAtLeast(1L)
|
||||
.coerceAtMost(Int.MAX_VALUE.toLong())
|
||||
.toInt()
|
||||
|
||||
onTrackPlayReported?.invoke(playStartTrackId, isoTimestamp, durationSec, completed)
|
||||
onTrackPlayReported?.invoke(
|
||||
playStartTrackId,
|
||||
playStartedAtMs / 1_000L,
|
||||
durationSec,
|
||||
completed
|
||||
)
|
||||
playStartTrackId = -1L
|
||||
playStartedAt = 0L
|
||||
playStartedAtMs = 0L
|
||||
listenedDurationMs = 0L
|
||||
lastPlaybackResumedAtMs = 0L
|
||||
}
|
||||
|
||||
private fun publishState() {
|
||||
@@ -311,6 +455,9 @@ class PlaybackController @Inject constructor(
|
||||
isBuffering = player.playbackState == Player.STATE_BUFFERING,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0L),
|
||||
durationMs = duration,
|
||||
shuffle = player.shuffleModeEnabled,
|
||||
repeatMode = player.repeatMode.toFurumiRepeatMode(),
|
||||
volume = player.volume,
|
||||
errorMessage = existingErrorMessage
|
||||
)
|
||||
}
|
||||
@@ -333,9 +480,35 @@ private fun TrackCard.toMediaMetadata(): MediaMetadata {
|
||||
|
||||
return MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(artist)
|
||||
.setSubtitle(artist)
|
||||
.setAlbumTitle(releaseTitle)
|
||||
.setArtworkUri(coverUrl?.let(Uri::parse))
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun TrackCard.toMediaItem(): MediaItem {
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(id.toString())
|
||||
.setUri(streamUrl)
|
||||
.setMediaMetadata(toMediaMetadata())
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun Int.toFurumiRepeatMode(): String {
|
||||
return when (this) {
|
||||
Player.REPEAT_MODE_ONE -> "one"
|
||||
Player.REPEAT_MODE_ALL -> "all"
|
||||
else -> "off"
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toPlayerRepeatMode(): Int {
|
||||
return when (lowercase()) {
|
||||
"one" -> Player.REPEAT_MODE_ONE
|
||||
"all" -> Player.REPEAT_MODE_ALL
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ private fun LoginControls(
|
||||
value = uiState.serverUrl,
|
||||
onValueChange = onServerUrlChange,
|
||||
label = "Server URL",
|
||||
placeholder = "https://media.example.com",
|
||||
placeholder = "https://music.hexor.cy",
|
||||
enabled = !uiState.isLoading,
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = if (isPasswordLoginVisible) ImeAction.Next else ImeAction.Go,
|
||||
|
||||
@@ -34,7 +34,7 @@ class LoginViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(
|
||||
LoginUiState(
|
||||
serverUrl = authRepository.getSavedServerUrl().orEmpty(),
|
||||
serverUrl = authRepository.getSavedServerUrl()?.takeIf { it.isNotBlank() } ?: DEFAULT_SERVER_URL,
|
||||
isAuthenticated = authRepository.getCurrentSession() != null
|
||||
)
|
||||
)
|
||||
@@ -158,4 +158,8 @@ class LoginViewModel @Inject constructor(
|
||||
private fun String.withoutLineBreaks(): String {
|
||||
return replace("\r", "").replace("\n", "")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_SERVER_URL = "https://music.hexor.cy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
package com.example.furumi_android.ui.player
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.ConnectedCommand
|
||||
import com.example.furumi_android.domain.model.ConnectedDevicesState
|
||||
import com.example.furumi_android.domain.model.ConnectedJamUser
|
||||
import com.example.furumi_android.domain.model.ConnectedPlaybackState
|
||||
import com.example.furumi_android.domain.model.ListeningHistoryItem
|
||||
import com.example.furumi_android.domain.model.PlaylistCard
|
||||
import com.example.furumi_android.domain.model.PlaylistDetail
|
||||
import com.example.furumi_android.domain.model.ReleaseCard
|
||||
import com.example.furumi_android.domain.model.ReleaseDetail
|
||||
import com.example.furumi_android.domain.model.SearchResults
|
||||
import com.example.furumi_android.domain.model.TrackCard
|
||||
import com.example.furumi_android.domain.repository.AuthRepository
|
||||
import com.example.furumi_android.domain.repository.PlayerRepository
|
||||
@@ -22,6 +29,8 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -42,6 +51,17 @@ data class PlayerUiState(
|
||||
val isGlobalArtistsLoading: Boolean = false,
|
||||
val isGlobalArtistsLoadingMore: Boolean = false,
|
||||
val globalArtistsError: String? = null,
|
||||
val libraryArtists: List<ArtistCard> = emptyList(),
|
||||
val libraryArtistsTotal: Long = 0,
|
||||
val libraryArtistsPage: Int = 0,
|
||||
val libraryArtistsHasMore: Boolean = true,
|
||||
val isLibraryArtistsLoading: Boolean = false,
|
||||
val isLibraryArtistsLoadingMore: Boolean = false,
|
||||
val libraryArtistsError: String? = null,
|
||||
val searchQuery: String = "",
|
||||
val searchResults: SearchResults? = null,
|
||||
val isSearchLoading: Boolean = false,
|
||||
val searchError: String? = null,
|
||||
val selectedArtist: ArtistCard? = null,
|
||||
val artistDetail: ArtistDetail? = null,
|
||||
val isArtistDetailLoading: Boolean = false,
|
||||
@@ -55,7 +75,17 @@ data class PlayerUiState(
|
||||
val playback: AudioPlaybackState = AudioPlaybackState(),
|
||||
val likedTrackIds: Set<Long> = emptySet(),
|
||||
val playlists: List<PlaylistCard> = emptyList(),
|
||||
val isPlaylistsLoading: Boolean = false
|
||||
val isPlaylistsLoading: Boolean = false,
|
||||
val playlistsError: String? = null,
|
||||
val selectedPlaylist: PlaylistCard? = null,
|
||||
val playlistDetail: PlaylistDetail? = null,
|
||||
val isPlaylistDetailLoading: Boolean = false,
|
||||
val playlistDetailError: String? = null,
|
||||
val connectedDevicesState: ConnectedDevicesState? = null,
|
||||
val connectedDevicesError: String? = null,
|
||||
val jamInviteQuery: String = "",
|
||||
val jamInviteUsers: List<ConnectedJamUser> = emptyList(),
|
||||
val isJamInviteSearchLoading: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -68,6 +98,13 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
|
||||
private val releaseDetailCache = mutableMapOf<Long, ReleaseDetail>()
|
||||
private val playlistDetailCache = mutableMapOf<Long, PlaylistDetail>()
|
||||
private var searchJob: Job? = null
|
||||
private var jamInviteSearchJob: Job? = null
|
||||
private val handledConnectedCommandIds = ArrayDeque<String>()
|
||||
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
||||
private var wasCurrentDeviceActive = false
|
||||
private var wasControllingRemoteJam = false
|
||||
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
||||
|
||||
@@ -87,6 +124,7 @@ class PlayerViewModel @Inject constructor(
|
||||
loadGlobalArtistsIfNeeded()
|
||||
observePlayback()
|
||||
loadLikedTrackIds()
|
||||
startConnectedDevicesPolling()
|
||||
playbackController.onTrackPlayReported = { trackId, startedAt, durationListened, completed ->
|
||||
reportListeningHistory(trackId, startedAt, durationListened, completed)
|
||||
}
|
||||
@@ -120,6 +158,71 @@ class PlayerViewModel @Inject constructor(
|
||||
loadGlobalArtists(page = state.globalArtistsPage + 1, append = true)
|
||||
}
|
||||
|
||||
fun loadLibraryArtistsIfNeeded() {
|
||||
val state = _uiState.value
|
||||
if (state.libraryArtists.isNotEmpty() ||
|
||||
state.isLibraryArtistsLoading ||
|
||||
state.isLibraryArtistsLoadingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
loadLibraryArtists(page = 1, append = false)
|
||||
}
|
||||
|
||||
fun retryLibraryArtists() {
|
||||
loadLibraryArtists(page = 1, append = false)
|
||||
}
|
||||
|
||||
fun loadMoreLibraryArtists() {
|
||||
val state = _uiState.value
|
||||
if (!state.libraryArtistsHasMore ||
|
||||
state.isLibraryArtistsLoading ||
|
||||
state.isLibraryArtistsLoadingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
loadLibraryArtists(page = state.libraryArtistsPage + 1, append = true)
|
||||
}
|
||||
|
||||
fun loadLibraryIfNeeded() {
|
||||
loadPlaylistsIfNeeded()
|
||||
loadLibraryArtistsIfNeeded()
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
val normalizedQuery = query.withoutLineBreaks()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchQuery = normalizedQuery,
|
||||
searchError = null
|
||||
)
|
||||
|
||||
searchJob?.cancel()
|
||||
if (normalizedQuery.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchResults = null,
|
||||
isSearchLoading = false,
|
||||
searchError = null
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(SEARCH_DEBOUNCE_MS)
|
||||
search(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun retrySearch() {
|
||||
val query = _uiState.value.searchQuery
|
||||
if (query.isBlank()) return
|
||||
searchJob?.cancel()
|
||||
searchJob = viewModelScope.launch {
|
||||
search(query)
|
||||
}
|
||||
}
|
||||
|
||||
fun openArtist(artist: ArtistCard) {
|
||||
val cachedDetail = artistDetailCache[artist.id]
|
||||
|
||||
@@ -155,6 +258,7 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
fun openRelease(release: ReleaseCard) {
|
||||
val cachedDetail = releaseDetailCache[release.id]
|
||||
?.withPreferredReleaseArtwork(release)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedRelease = cachedDetail?.release ?: release,
|
||||
@@ -177,6 +281,35 @@ class PlayerViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
fun openPlaylist(playlist: PlaylistCard) {
|
||||
val cachedDetail = playlistDetailCache[playlist.id]
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedPlaylist = cachedDetail?.playlist ?: playlist,
|
||||
playlistDetail = cachedDetail,
|
||||
isPlaylistDetailLoading = cachedDetail == null,
|
||||
playlistDetailError = null
|
||||
)
|
||||
|
||||
if (cachedDetail == null) {
|
||||
loadPlaylistDetail(playlist.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun closePlaylistDetail() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedPlaylist = null,
|
||||
playlistDetail = null,
|
||||
isPlaylistDetailLoading = false,
|
||||
playlistDetailError = null
|
||||
)
|
||||
}
|
||||
|
||||
fun retryPlaylistDetail() {
|
||||
val playlist = _uiState.value.selectedPlaylist ?: return
|
||||
loadPlaylistDetail(playlist.id)
|
||||
}
|
||||
|
||||
fun retryReleaseDetail() {
|
||||
val release = _uiState.value.selectedRelease ?: return
|
||||
loadReleaseDetail(release.id)
|
||||
@@ -185,6 +318,29 @@ class PlayerViewModel @Inject constructor(
|
||||
fun playTrack(track: TrackCard, queue: List<TrackCard>): Boolean {
|
||||
if (track.streamUrl.isBlank()) return false
|
||||
|
||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||
val playableQueue = queue
|
||||
.ifEmpty { listOf(track) }
|
||||
.filter { it.streamUrl.isNotBlank() }
|
||||
.ifEmpty { listOf(track).filter { it.streamUrl.isNotBlank() } }
|
||||
val startIndex = playableQueue.indexOfFirst { it.id == track.id }
|
||||
.takeIf { it >= 0 }
|
||||
?: 0
|
||||
val remoteState = ConnectedPlaybackState(
|
||||
track = playableQueue.getOrNull(startIndex) ?: track,
|
||||
tracks = playableQueue,
|
||||
index = startIndex,
|
||||
positionSeconds = 0.0,
|
||||
durationSeconds = track.durationSeconds?.toDouble() ?: 0.0,
|
||||
paused = false,
|
||||
shuffle = _uiState.value.connectedDevicesState?.remotePlaybackState?.shuffle ?: false,
|
||||
repeatMode = _uiState.value.connectedDevicesState?.remotePlaybackState?.repeatMode ?: "off",
|
||||
volume = _uiState.value.connectedDevicesState?.remotePlaybackState?.volume ?: 1.0
|
||||
)
|
||||
sendConnectedCommand(targetDeviceId, "play_track", payload = remoteState)
|
||||
return true
|
||||
}
|
||||
|
||||
playbackController.playTrack(track, queue)
|
||||
return true
|
||||
}
|
||||
@@ -196,10 +352,7 @@ class PlayerViewModel @Inject constructor(
|
||||
.filter { it.streamUrl.isNotBlank() }
|
||||
.let { if (shuffle) it.shuffled() else it }
|
||||
|
||||
if (tracks.isEmpty()) return false
|
||||
|
||||
playbackController.playQueue(tracks)
|
||||
return true
|
||||
return playTracks(tracks, shuffle)
|
||||
}
|
||||
|
||||
fun playReleaseTracks(shuffle: Boolean = false): Boolean {
|
||||
@@ -209,25 +362,183 @@ class PlayerViewModel @Inject constructor(
|
||||
.filter { it.streamUrl.isNotBlank() }
|
||||
.let { if (shuffle) it.shuffled() else it }
|
||||
|
||||
return playTracks(tracks, shuffle)
|
||||
}
|
||||
|
||||
fun playPlaylistTracks(shuffle: Boolean = false): Boolean {
|
||||
val tracks = _uiState.value.playlistDetail
|
||||
?.tracks
|
||||
.orEmpty()
|
||||
.filter { it.streamUrl.isNotBlank() }
|
||||
.let { if (shuffle) it.shuffled() else it }
|
||||
|
||||
return playTracks(tracks, shuffle)
|
||||
}
|
||||
|
||||
private fun playTracks(tracks: List<TrackCard>, shuffle: Boolean = false): Boolean {
|
||||
if (tracks.isEmpty()) return false
|
||||
|
||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||
val firstTrack = tracks.first()
|
||||
val remoteState = ConnectedPlaybackState(
|
||||
track = firstTrack,
|
||||
tracks = tracks,
|
||||
index = 0,
|
||||
positionSeconds = 0.0,
|
||||
durationSeconds = firstTrack.durationSeconds?.toDouble() ?: 0.0,
|
||||
paused = false,
|
||||
shuffle = shuffle,
|
||||
repeatMode = _uiState.value.connectedDevicesState?.remotePlaybackState?.repeatMode ?: "off",
|
||||
volume = _uiState.value.connectedDevicesState?.remotePlaybackState?.volume ?: 1.0
|
||||
)
|
||||
sendConnectedCommand(targetDeviceId, "play_track", payload = remoteState)
|
||||
return true
|
||||
}
|
||||
|
||||
playbackController.playQueue(tracks)
|
||||
return true
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||
val remoteState = _uiState.value.connectedDevicesState?.remotePlaybackState
|
||||
sendConnectedCommand(targetDeviceId, if (remoteState?.paused == true) "resume" else "pause")
|
||||
return
|
||||
}
|
||||
|
||||
playbackController.togglePlayPause()
|
||||
}
|
||||
|
||||
fun nextTrack() {
|
||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||
val remoteState = _uiState.value.connectedDevicesState?.remotePlaybackState
|
||||
sendConnectedCommand(
|
||||
targetDeviceId = targetDeviceId,
|
||||
command = "next",
|
||||
shuffle = remoteState?.shuffle,
|
||||
repeatMode = remoteState?.repeatMode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
playbackController.next()
|
||||
}
|
||||
|
||||
fun previousTrack() {
|
||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||
sendConnectedCommand(targetDeviceId, "prev")
|
||||
return
|
||||
}
|
||||
|
||||
playbackController.previous()
|
||||
}
|
||||
|
||||
fun setActiveDevice(deviceId: String) {
|
||||
viewModelScope.launch {
|
||||
playerRepository.setActiveDevice(deviceId)
|
||||
.onSuccess { devicesState ->
|
||||
applyConnectedDevicesState(devicesState)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to switch active device"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createJam() {
|
||||
viewModelScope.launch {
|
||||
playerRepository.createJam()
|
||||
.onSuccess(::applyConnectedDevicesState)
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to create jam"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun joinJam(jamId: String) {
|
||||
viewModelScope.launch {
|
||||
playerRepository.joinJam(jamId)
|
||||
.onSuccess(::applyConnectedDevicesState)
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to join jam"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun leaveJam(jamId: String) {
|
||||
viewModelScope.launch {
|
||||
playerRepository.leaveJam(jamId)
|
||||
.onSuccess(::applyConnectedDevicesState)
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to leave jam"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onJamInviteQueryChange(query: String) {
|
||||
val normalizedQuery = query.withoutLineBreaks()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
jamInviteQuery = normalizedQuery,
|
||||
connectedDevicesError = null
|
||||
)
|
||||
|
||||
jamInviteSearchJob?.cancel()
|
||||
if (normalizedQuery.isBlank()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
jamInviteUsers = emptyList(),
|
||||
isJamInviteSearchLoading = false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
jamInviteSearchJob = viewModelScope.launch {
|
||||
delay(JAM_INVITE_SEARCH_DEBOUNCE_MS)
|
||||
searchJamInviteUsers(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun inviteToJam(jamId: String, userId: Long) {
|
||||
viewModelScope.launch {
|
||||
playerRepository.inviteToJam(jamId, listOf(userId))
|
||||
.onSuccess { devicesState ->
|
||||
applyConnectedDevicesState(devicesState)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
jamInviteQuery = "",
|
||||
jamInviteUsers = emptyList(),
|
||||
isJamInviteSearchLoading = false
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to invite user"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun seekToPlaybackProgress(progress: Float) {
|
||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||
val durationSeconds = _uiState.value.connectedDevicesState
|
||||
?.remotePlaybackState
|
||||
?.durationSeconds
|
||||
?.takeIf { it > 0.0 }
|
||||
?: return
|
||||
sendConnectedCommand(
|
||||
targetDeviceId = targetDeviceId,
|
||||
command = "seek",
|
||||
time = durationSeconds * progress.coerceIn(0f, 1f)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
playbackController.seekToProgress(progress)
|
||||
}
|
||||
|
||||
@@ -241,6 +552,14 @@ class PlayerViewModel @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
val equivalentBitmap = state.mediaImages.findEquivalentMediaImage(imageUrl)
|
||||
if (equivalentBitmap != null) {
|
||||
_uiState.value = state.copy(
|
||||
mediaImages = state.mediaImages + (imageUrl to equivalentBitmap)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.value = state.copy(
|
||||
loadingMediaImageUrls = state.loadingMediaImageUrls + imageUrl
|
||||
)
|
||||
@@ -314,10 +633,20 @@ class PlayerViewModel @Inject constructor(
|
||||
fun isTrackLiked(trackId: Long): Boolean = _uiState.value.likedTrackIds.contains(trackId)
|
||||
|
||||
fun addToPlayNext(track: TrackCard) {
|
||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||
sendConnectedCommand(targetDeviceId, "queue_add_next", tracks = listOf(track))
|
||||
return
|
||||
}
|
||||
|
||||
playbackController.addNext(track)
|
||||
}
|
||||
|
||||
fun addToQueueEnd(track: TrackCard) {
|
||||
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||
sendConnectedCommand(targetDeviceId, "queue_add_end", tracks = listOf(track))
|
||||
return
|
||||
}
|
||||
|
||||
playbackController.addToEnd(track)
|
||||
}
|
||||
|
||||
@@ -332,21 +661,34 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
fun loadPlaylists() {
|
||||
if (_uiState.value.isPlaylistsLoading) return
|
||||
_uiState.value = _uiState.value.copy(isPlaylistsLoading = true)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isPlaylistsLoading = true,
|
||||
playlistsError = null
|
||||
)
|
||||
viewModelScope.launch {
|
||||
playerRepository.getPlaylists()
|
||||
.onSuccess { playlists ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
playlists = playlists,
|
||||
isPlaylistsLoading = false
|
||||
isPlaylistsLoading = false,
|
||||
playlistsError = null
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_uiState.value = _uiState.value.copy(isPlaylistsLoading = false)
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isPlaylistsLoading = false,
|
||||
playlistsError = error.message ?: "Unable to load playlists"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPlaylistsIfNeeded() {
|
||||
val state = _uiState.value
|
||||
if (state.playlists.isNotEmpty() || state.isPlaylistsLoading) return
|
||||
loadPlaylists()
|
||||
}
|
||||
|
||||
fun addTrackToPlaylist(trackId: Long, playlistId: Long) {
|
||||
viewModelScope.launch {
|
||||
playerRepository.addTracksToPlaylist(playlistId, listOf(trackId))
|
||||
@@ -355,12 +697,25 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
private fun reportListeningHistory(
|
||||
trackId: Long,
|
||||
startedAt: String,
|
||||
durationListened: Double,
|
||||
startedAt: Long,
|
||||
durationListened: Int,
|
||||
completed: Boolean
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
playerRepository.recordListeningHistory(trackId, startedAt, durationListened, completed)
|
||||
.onSuccess {
|
||||
Log.d(
|
||||
PLAYER_VIEW_MODEL_TAG,
|
||||
"Recorded play history: trackId=$trackId duration=$durationListened completed=$completed"
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
Log.w(
|
||||
PLAYER_VIEW_MODEL_TAG,
|
||||
"Failed to record play history: trackId=$trackId duration=$durationListened completed=$completed",
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +761,165 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startConnectedDevicesPolling() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
pollConnectedDevices()
|
||||
delay(CONNECTED_DEVICES_POLL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollConnectedDevices() {
|
||||
val state = _uiState.value
|
||||
val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true
|
||||
val playbackState = if (isCurrentDeviceActive && state.connectedDevicesState?.isControllingRemoteJam() != true) {
|
||||
state.playback.toConnectedPlaybackState()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
playerRepository.pollConnectedDevice(
|
||||
playbackState = playbackState,
|
||||
currentJamId = state.connectedDevicesState?.currentJamId
|
||||
).onSuccess { (devicesState, commands) ->
|
||||
applyConnectedDevicesState(devicesState)
|
||||
|
||||
commands.forEach { command ->
|
||||
if (shouldHandleConnectedCommand(command)) {
|
||||
handleConnectedCommand(command)
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to poll connected devices"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyConnectedDevicesState(devicesState: ConnectedDevicesState) {
|
||||
val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive
|
||||
val isControllingRemoteJam = devicesState.isControllingRemoteJam()
|
||||
val startedControllingRemoteJam = !wasControllingRemoteJam && isControllingRemoteJam
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesState = devicesState,
|
||||
connectedDevicesError = null
|
||||
)
|
||||
wasCurrentDeviceActive = devicesState.isCurrentDeviceActive
|
||||
wasControllingRemoteJam = isControllingRemoteJam
|
||||
if ((becameInactive || startedControllingRemoteJam) && _uiState.value.playback.currentTrack != null) {
|
||||
playbackController.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchJamInviteUsers(query: String) {
|
||||
viewModelScope.launch {
|
||||
if (query != _uiState.value.jamInviteQuery || query.isBlank()) return@launch
|
||||
_uiState.value = _uiState.value.copy(isJamInviteSearchLoading = true)
|
||||
playerRepository.searchJamUsers(query)
|
||||
.onSuccess { users ->
|
||||
if (query == _uiState.value.jamInviteQuery) {
|
||||
val existingMemberIds = _uiState.value.connectedDevicesState
|
||||
?.jams
|
||||
?.firstOrNull { it.id == _uiState.value.connectedDevicesState?.currentJamId || it.isActive || it.isOwner }
|
||||
?.members
|
||||
?.map { it.userId }
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
jamInviteUsers = users.filter { it.id !in existingMemberIds },
|
||||
isJamInviteSearchLoading = false
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (query == _uiState.value.jamInviteQuery) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isJamInviteSearchLoading = false,
|
||||
connectedDevicesError = error.message ?: "Unable to search users"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldHandleConnectedCommand(command: ConnectedCommand): Boolean {
|
||||
val id = command.id ?: return true
|
||||
if (!handledConnectedCommandIdSet.add(id)) return false
|
||||
handledConnectedCommandIds.addLast(id)
|
||||
while (handledConnectedCommandIds.size > CONNECTED_COMMAND_ID_CACHE_SIZE) {
|
||||
handledConnectedCommandIdSet.remove(handledConnectedCommandIds.removeFirst())
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleConnectedCommand(command: ConnectedCommand) {
|
||||
val payload = command.payload
|
||||
when (command.command) {
|
||||
"transfer_state",
|
||||
"play_track",
|
||||
"play_from_index" -> payload.playbackState?.let(playbackController::playConnectedState)
|
||||
"pause" -> playbackController.pause()
|
||||
"resume" -> playbackController.resume()
|
||||
"seek" -> payload.time?.let(playbackController::seekToSeconds)
|
||||
"next" -> {
|
||||
playbackController.setOptions(payload.shuffle, payload.repeatMode)
|
||||
playbackController.next()
|
||||
}
|
||||
"prev" -> playbackController.previous()
|
||||
"set_volume" -> payload.volume?.let(playbackController::setVolume)
|
||||
"set_options" -> playbackController.setOptions(payload.shuffle, payload.repeatMode)
|
||||
"queue_add_end" -> playbackController.addToEnd(payload.tracks)
|
||||
"queue_add_next" -> playbackController.addNext(payload.tracks)
|
||||
"queue_remove" -> payload.index?.let(playbackController::removeQueueIndex)
|
||||
"queue_move" -> {
|
||||
val fromIndex = payload.fromIndex ?: return
|
||||
val toIndex = payload.toIndex ?: return
|
||||
playbackController.moveQueueItem(fromIndex, toIndex)
|
||||
}
|
||||
"queue_clear" -> playbackController.clearQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeRemoteDeviceId(): String? {
|
||||
val devicesState = _uiState.value.connectedDevicesState ?: return null
|
||||
if (devicesState.isControllingRemoteJam()) {
|
||||
return devicesState.activeDeviceId ?: devicesState.deviceId
|
||||
}
|
||||
val activeDeviceId = devicesState.activeDeviceId ?: return null
|
||||
return activeDeviceId.takeIf { it != devicesState.deviceId }
|
||||
}
|
||||
|
||||
private fun sendConnectedCommand(
|
||||
targetDeviceId: String,
|
||||
command: String,
|
||||
payload: ConnectedPlaybackState? = null,
|
||||
tracks: List<TrackCard> = emptyList(),
|
||||
time: Double? = null,
|
||||
volume: Double? = null,
|
||||
shuffle: Boolean? = null,
|
||||
repeatMode: String? = null
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val jamId = _uiState.value.connectedDevicesState?.currentJamId
|
||||
playerRepository.sendConnectedDeviceCommand(
|
||||
targetDeviceId = if (jamId == null) targetDeviceId else null,
|
||||
jamId = jamId,
|
||||
command = command,
|
||||
payload = payload,
|
||||
tracks = tracks,
|
||||
time = time,
|
||||
volume = volume,
|
||||
shuffle = shuffle,
|
||||
repeatMode = repeatMode
|
||||
).onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to send device command"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadGlobalArtists(page: Int, append: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val state = _uiState.value
|
||||
@@ -448,6 +962,78 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLibraryArtists(page: Int, append: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val state = _uiState.value
|
||||
_uiState.value = if (append) {
|
||||
state.copy(
|
||||
isLibraryArtistsLoadingMore = true,
|
||||
libraryArtistsError = null
|
||||
)
|
||||
} else {
|
||||
state.copy(
|
||||
isLibraryArtistsLoading = true,
|
||||
isLibraryArtistsLoadingMore = false,
|
||||
libraryArtistsError = null,
|
||||
libraryArtistsHasMore = true
|
||||
)
|
||||
}
|
||||
|
||||
playerRepository.getArtists(page = page, limit = ARTIST_PAGE_SIZE, mine = true)
|
||||
.onSuccess { artistPage ->
|
||||
val currentArtists = if (append) _uiState.value.libraryArtists else emptyList()
|
||||
val mergedArtists = (currentArtists + artistPage.items).distinctBy { it.id }
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
libraryArtists = mergedArtists,
|
||||
libraryArtistsTotal = artistPage.total,
|
||||
libraryArtistsPage = artistPage.page,
|
||||
libraryArtistsHasMore = artistPage.hasMore,
|
||||
isLibraryArtistsLoading = false,
|
||||
isLibraryArtistsLoadingMore = false,
|
||||
libraryArtistsError = null
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLibraryArtistsLoading = false,
|
||||
isLibraryArtistsLoadingMore = false,
|
||||
libraryArtistsError = error.message ?: "Unable to load library artists"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun search(query: String) {
|
||||
if (query != _uiState.value.searchQuery || query.isBlank()) return
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isSearchLoading = true,
|
||||
searchError = null
|
||||
)
|
||||
|
||||
playerRepository.search(query = query, limit = SEARCH_LIMIT)
|
||||
.onSuccess { results ->
|
||||
if (query == _uiState.value.searchQuery) {
|
||||
val displayResults = results.withArtistCards(cachedSearchArtistCards())
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchResults = displayResults,
|
||||
isSearchLoading = false,
|
||||
searchError = null
|
||||
)
|
||||
loadMissingSearchArtistImages(query, displayResults)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (query == _uiState.value.searchQuery) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isSearchLoading = false,
|
||||
searchError = error.message ?: "Unable to search"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadArtistDetail(artistId: Long) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
@@ -474,6 +1060,43 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMissingSearchArtistImages(query: String, results: SearchResults) {
|
||||
val cachedArtistIds = artistDetailCache.keys + _uiState.value.globalArtists.map { it.id }
|
||||
val missingArtists = (results.artistMatches + results.trackArtists)
|
||||
.asSequence()
|
||||
.filter { it.imageUrl.isNullOrBlank() }
|
||||
.distinctBy { it.id }
|
||||
.filter { it.id !in cachedArtistIds }
|
||||
.take(SEARCH_ARTIST_IMAGE_LIMIT)
|
||||
.toList()
|
||||
|
||||
missingArtists.forEach { artist ->
|
||||
viewModelScope.launch {
|
||||
playerRepository.getArtistDetail(artist.id)
|
||||
.onSuccess { detail ->
|
||||
artistDetailCache[artist.id] = detail
|
||||
if (query == _uiState.value.searchQuery) {
|
||||
val currentResults = _uiState.value.searchResults ?: return@onSuccess
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchResults = currentResults.withArtistCards(
|
||||
mapOf(detail.artist.id to detail.artist)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cachedSearchArtistCards(): Map<Long, ArtistCard> {
|
||||
val globalArtistCards = _uiState.value.globalArtists.associateBy { it.id }
|
||||
val libraryArtistCards = _uiState.value.libraryArtists.associateBy { it.id }
|
||||
val detailArtistCards = artistDetailCache.values
|
||||
.map { it.artist }
|
||||
.associateBy { it.id }
|
||||
return globalArtistCards + libraryArtistCards + detailArtistCards
|
||||
}
|
||||
|
||||
private fun loadReleaseDetail(releaseId: Long) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
@@ -483,10 +1106,16 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
playerRepository.getReleaseDetail(releaseId)
|
||||
.onSuccess { detail ->
|
||||
releaseDetailCache[releaseId] = detail
|
||||
val preferredRelease = _uiState.value.selectedRelease
|
||||
val displayDetail = if (preferredRelease != null) {
|
||||
detail.withPreferredReleaseArtwork(preferredRelease)
|
||||
} else {
|
||||
detail
|
||||
}
|
||||
releaseDetailCache[releaseId] = displayDetail
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedRelease = detail.release,
|
||||
releaseDetail = detail,
|
||||
selectedRelease = displayDetail.release,
|
||||
releaseDetail = displayDetail,
|
||||
isReleaseDetailLoading = false,
|
||||
releaseDetailError = null
|
||||
)
|
||||
@@ -500,8 +1129,106 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPlaylistDetail(playlistId: Long) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isPlaylistDetailLoading = true,
|
||||
playlistDetailError = null
|
||||
)
|
||||
|
||||
playerRepository.getPlaylistDetail(playlistId)
|
||||
.onSuccess { detail ->
|
||||
playlistDetailCache[playlistId] = detail
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedPlaylist = detail.playlist,
|
||||
playlistDetail = detail,
|
||||
isPlaylistDetailLoading = false,
|
||||
playlistDetailError = null
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isPlaylistDetailLoading = false,
|
||||
playlistDetailError = error.message ?: "Unable to load playlist"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ARTIST_PAGE_SIZE = 60
|
||||
const val SEARCH_DEBOUNCE_MS = 300L
|
||||
const val SEARCH_LIMIT = 30
|
||||
const val SEARCH_ARTIST_IMAGE_LIMIT = 12
|
||||
const val JAM_INVITE_SEARCH_DEBOUNCE_MS = 250L
|
||||
const val CONNECTED_DEVICES_POLL_MS = 750L
|
||||
const val CONNECTED_COMMAND_ID_CACHE_SIZE = 128
|
||||
const val PLAYER_VIEW_MODEL_TAG = "PlayerViewModel"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun AudioPlaybackState.toConnectedPlaybackState(): ConnectedPlaybackState? {
|
||||
if (currentTrack == null || queue.isEmpty()) return null
|
||||
return ConnectedPlaybackState(
|
||||
track = currentTrack,
|
||||
tracks = queue,
|
||||
index = currentIndex.coerceAtLeast(0),
|
||||
positionSeconds = positionMs / 1_000.0,
|
||||
durationSeconds = durationMs / 1_000.0,
|
||||
paused = !isPlaying,
|
||||
shuffle = shuffle,
|
||||
repeatMode = repeatMode,
|
||||
volume = volume.toDouble()
|
||||
)
|
||||
}
|
||||
|
||||
private fun ConnectedDevicesState.isControllingRemoteJam(): Boolean {
|
||||
val currentJam = jams.firstOrNull { it.id == currentJamId }
|
||||
?: jams.firstOrNull { it.isActive }
|
||||
return currentJam != null && currentJam.isMember && !currentJam.isOwner
|
||||
}
|
||||
|
||||
private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults {
|
||||
if (artistCards.isEmpty()) return this
|
||||
return copy(
|
||||
artistMatches = artistMatches.map { artist -> artistCards[artist.id] ?: artist },
|
||||
trackArtists = trackArtists.map { artist -> artistCards[artist.id] ?: artist }
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.withoutLineBreaks(): String {
|
||||
return replace("\r", "").replace("\n", "")
|
||||
}
|
||||
|
||||
private fun ReleaseDetail.withPreferredReleaseArtwork(preferred: ReleaseCard): ReleaseDetail {
|
||||
val preferredCoverUrl = preferred.coverUrl?.takeIf { it.isNotBlank() } ?: return this
|
||||
if (release.id != preferred.id) return this
|
||||
|
||||
val originalCoverUrl = release.coverUrl
|
||||
val updatedRelease = release.copy(coverUrl = preferredCoverUrl)
|
||||
val updatedTracks = tracks.map { track ->
|
||||
if (track.coverUrl.isNullOrBlank() || sameMediaImageResource(track.coverUrl, originalCoverUrl)) {
|
||||
track.copy(coverUrl = preferredCoverUrl)
|
||||
} else {
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
return copy(
|
||||
release = updatedRelease,
|
||||
tracks = updatedTracks
|
||||
)
|
||||
}
|
||||
|
||||
private fun Map<String, Bitmap>.findEquivalentMediaImage(url: String): Bitmap? {
|
||||
val requestedResource = canonicalMediaImageResource(url)
|
||||
return entries.firstOrNull { (cachedUrl, _) ->
|
||||
canonicalMediaImageResource(cachedUrl) == requestedResource
|
||||
}?.value
|
||||
}
|
||||
|
||||
private fun sameMediaImageResource(first: String?, second: String?): Boolean {
|
||||
if (first.isNullOrBlank() || second.isNullOrBlank()) return false
|
||||
return canonicalMediaImageResource(first) == canonicalMediaImageResource(second)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ private val LightColorScheme = lightColorScheme(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun FurumiandroidTheme(
|
||||
fun FurumiTheme(
|
||||
darkTheme: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/furumi_icon_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/furumi_icon_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.1 KiB |
@@ -7,4 +7,5 @@
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="furumi_icon_background">#080A0E</color>
|
||||
</resources>
|
||||
@@ -1,3 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">furumi-android</string>
|
||||
<string name="app_name">Furumi</string>
|
||||
<string name="app_description">Music player for Furumi</string>
|
||||
<string name="sso_callback_label">Furumi SSO Callback</string>
|
||||
</resources>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Furumiandroid" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<style name="Theme.Furumi" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||