Added Jam, remote devices. small fixes
@@ -2,10 +2,6 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="deploymentTargetSelector">
|
<component name="deploymentTargetSelector">
|
||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="App">
|
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
|
||||||
<DialogSelection />
|
|
||||||
</SelectionState>
|
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DialogSelection />
|
<DialogSelection />
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,15 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".FurumiApplication"
|
android:name=".FurumiApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:appCategory="audio"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:description="@string/app_description"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Furumiandroid"
|
android:theme="@style/Theme.Furumi"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
<service
|
<service
|
||||||
android:name=".playback.FurumiPlaybackService"
|
android:name=".playback.FurumiPlaybackService"
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/Theme.Furumiandroid"
|
android:theme="@style/Theme.Furumi"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -49,7 +51,7 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter android:label="Furumi SSO Callback">
|
<intent-filter android:label="@string/sso_callback_label">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<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.LoginScreen
|
||||||
import com.example.furumi_android.ui.login.LoginViewModel
|
import com.example.furumi_android.ui.login.LoginViewModel
|
||||||
import com.example.furumi_android.ui.player.PlayerScreen
|
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
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -46,7 +46,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
FurumiandroidTheme {
|
FurumiTheme {
|
||||||
FurumiApp(
|
FurumiApp(
|
||||||
deepLinkUri = deepLinkUri,
|
deepLinkUri = deepLinkUri,
|
||||||
onDeepLinkHandled = { deepLinkUri = null }
|
onDeepLinkHandled = { deepLinkUri = null }
|
||||||
|
|||||||
@@ -7,20 +7,24 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class AccessTokenInterceptor @Inject constructor(
|
class AccessTokenInterceptor @Inject constructor(
|
||||||
private val sessionStorage: AuthSessionStorage,
|
private val sessionStorage: AuthSessionStorage,
|
||||||
private val authEndpoints: AuthEndpoints
|
private val authEndpoints: AuthEndpoints,
|
||||||
|
private val appClientInfo: AppClientInfo
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
|
val baseRequest = request.newBuilder()
|
||||||
|
.header(HEADER_USER_AGENT, appClientInfo.userAgent)
|
||||||
|
.build()
|
||||||
|
|
||||||
if (request.header(HEADER_AUTHORIZATION) != null ||
|
if (baseRequest.header(HEADER_AUTHORIZATION) != null ||
|
||||||
authEndpoints.isLoginOrRefreshPath(request.url.encodedPath)
|
authEndpoints.isLoginOrRefreshPath(baseRequest.url.encodedPath)
|
||||||
) {
|
) {
|
||||||
return chain.proceed(request)
|
return chain.proceed(baseRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
val tokens = sessionStorage.getTokens() ?: return chain.proceed(request)
|
val tokens = sessionStorage.getTokens() ?: return chain.proceed(baseRequest)
|
||||||
val authorizedRequest = request.newBuilder()
|
val authorizedRequest = baseRequest.newBuilder()
|
||||||
.header(HEADER_AUTHORIZATION, tokens.authorizationHeader)
|
.header(HEADER_AUTHORIZATION, tokens.authorizationHeader)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -29,5 +33,6 @@ class AccessTokenInterceptor @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val HEADER_AUTHORIZATION = "Authorization"
|
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 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 history(baseUrl: String): String = "$baseUrl$HISTORY_PATH"
|
||||||
|
|
||||||
fun likesToggle(baseUrl: String, trackId: Long): String = "$baseUrl$LIKES_TOGGLE_PATH/$trackId"
|
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 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 addPlaylistTracks(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId/tracks"
|
||||||
|
|
||||||
fun sharePlaylist(baseUrl: String): String = "$baseUrl$SHARE_PLAYLIST_PATH"
|
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 {
|
companion object {
|
||||||
private const val ARTISTS_PATH = "/api/player/artists"
|
private const val ARTISTS_PATH = "/api/player/artists"
|
||||||
private const val RELEASES_PATH = "/api/player/releases"
|
private const val RELEASES_PATH = "/api/player/releases"
|
||||||
|
private const val SEARCH_PATH = "/api/player/search"
|
||||||
private const val HISTORY_PATH = "/api/player/history"
|
private const val HISTORY_PATH = "/api/player/history"
|
||||||
private const val LIKES_PATH = "/api/player/likes"
|
private const val LIKES_PATH = "/api/player/likes"
|
||||||
private const val LIKES_TOGGLE_PATH = "/api/player/likes/toggle"
|
private const val LIKES_TOGGLE_PATH = "/api/player/likes/toggle"
|
||||||
private const val PLAYLISTS_PATH = "/api/player/playlists"
|
private const val PLAYLISTS_PATH = "/api/player/playlists"
|
||||||
private const val SHARE_PLAYLIST_PATH = "/api/player/share-playlist"
|
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.PlayHistoryPageResponse
|
||||||
import com.example.furumi_android.data.remote.model.ReleaseDetailResponse
|
import com.example.furumi_android.data.remote.model.ReleaseDetailResponse
|
||||||
import com.example.furumi_android.data.remote.model.AddPlaylistTracksRequest
|
import com.example.furumi_android.data.remote.model.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.LikeToggleResponse
|
||||||
import com.example.furumi_android.data.remote.model.LikedTrackIdsResponse
|
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.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.SharePlaylistRequest
|
||||||
import com.example.furumi_android.data.remote.model.SharePlaylistResponse
|
import com.example.furumi_android.data.remote.model.SharePlaylistResponse
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
@@ -41,6 +51,14 @@ interface PlayerApi {
|
|||||||
@Url url: String
|
@Url url: String
|
||||||
): Response<ReleaseDetailResponse>
|
): 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")
|
@Headers("Accept: application/json")
|
||||||
@GET
|
@GET
|
||||||
suspend fun history(
|
suspend fun history(
|
||||||
@@ -72,7 +90,13 @@ interface PlayerApi {
|
|||||||
@GET
|
@GET
|
||||||
suspend fun playlists(
|
suspend fun playlists(
|
||||||
@Url url: String
|
@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")
|
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||||
@POST
|
@POST
|
||||||
@@ -87,4 +111,61 @@ interface PlayerApi {
|
|||||||
@Url url: String,
|
@Url url: String,
|
||||||
@Body body: SharePlaylistRequest
|
@Body body: SharePlaylistRequest
|
||||||
): Response<SharePlaylistResponse>
|
): 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.ArtistCard
|
||||||
import com.example.furumi_android.domain.model.ArtistDetail
|
import com.example.furumi_android.domain.model.ArtistDetail
|
||||||
import com.example.furumi_android.domain.model.ArtistPage
|
import com.example.furumi_android.domain.model.ArtistPage
|
||||||
|
import com.example.furumi_android.domain.model.ArtistRef
|
||||||
import com.example.furumi_android.domain.model.ListeningHistoryItem
|
import com.example.furumi_android.domain.model.ListeningHistoryItem
|
||||||
import com.example.furumi_android.domain.model.ListeningHistoryPage
|
import com.example.furumi_android.domain.model.ListeningHistoryPage
|
||||||
import com.example.furumi_android.domain.model.PlaylistCard
|
import com.example.furumi_android.domain.model.PlaylistCard
|
||||||
|
import com.example.furumi_android.domain.model.PlaylistDetail
|
||||||
import com.example.furumi_android.domain.model.ReleaseCard
|
import com.example.furumi_android.domain.model.ReleaseCard
|
||||||
import com.example.furumi_android.domain.model.ReleaseDetail
|
import com.example.furumi_android.domain.model.ReleaseDetail
|
||||||
|
import com.example.furumi_android.domain.model.SearchResults
|
||||||
import com.example.furumi_android.domain.model.TrackCard
|
import com.example.furumi_android.domain.model.TrackCard
|
||||||
import com.squareup.moshi.Json
|
import com.squareup.moshi.Json
|
||||||
|
|
||||||
@@ -85,6 +88,12 @@ data class ArtistDetailResponse(
|
|||||||
@param:Json(name = "featured_tracks") val featuredTracks: List<TrackItemResponse> = emptyList()
|
@param:Json(name = "featured_tracks") val featuredTracks: List<TrackItemResponse> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class 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(
|
data class LikeToggleRequest(
|
||||||
@param:Json(name = "track_id") val trackId: Long
|
@param:Json(name = "track_id") val trackId: Long
|
||||||
)
|
)
|
||||||
@@ -99,16 +108,20 @@ data class LikedTrackIdsResponse(
|
|||||||
|
|
||||||
data class RecordHistoryRequest(
|
data class RecordHistoryRequest(
|
||||||
@param:Json(name = "track_id") val trackId: Long,
|
@param:Json(name = "track_id") val trackId: Long,
|
||||||
@param:Json(name = "started_at") val startedAt: String,
|
@param:Json(name = "started_at") val startedAt: Long,
|
||||||
@param:Json(name = "duration_listened") val durationListened: Double,
|
@param:Json(name = "duration_listened") val durationListened: Int,
|
||||||
@param:Json(name = "completed") val completed: Boolean
|
@param:Json(name = "completed") val completed: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PlaylistCardResponse(
|
data class PlaylistCardResponse(
|
||||||
@param:Json(name = "id") val id: Long,
|
@param:Json(name = "id") val id: Long,
|
||||||
@param:Json(name = "title") val title: String,
|
@param:Json(name = "title") val title: String,
|
||||||
@param:Json(name = "track_count") val trackCount: Int,
|
@param:Json(name = "track_count") val trackCount: Int = 0,
|
||||||
@param:Json(name = "is_public") val isPublic: Boolean
|
@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(
|
data class PlaylistListResponse(
|
||||||
@@ -141,6 +154,20 @@ data class ReleaseDetailResponse(
|
|||||||
@param:Json(name = "tracks") val tracks: List<TrackItemResponse> = emptyList()
|
@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 {
|
fun PlayHistoryPageResponse.toDomain(): ListeningHistoryPage {
|
||||||
return ListeningHistoryPage(
|
return ListeningHistoryPage(
|
||||||
items = items.map { it.toDomain() },
|
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 {
|
private fun ArtistCardResponse.toDomain(baseUrl: String): ArtistCard {
|
||||||
return ArtistCard(
|
return ArtistCard(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -237,9 +285,11 @@ private fun ReleaseCardResponse.toDomain(baseUrl: String): ReleaseCard {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
||||||
val artistNames = artists
|
val mappedArtists = artists.map { it.toDomain() }
|
||||||
.ifEmpty { featuredArtists }
|
val mappedFeaturedArtists = featuredArtists.map { it.toDomain() }
|
||||||
|
val artistNames = mappedArtists
|
||||||
|
.ifEmpty { mappedFeaturedArtists }
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
|
|
||||||
return TrackCard(
|
return TrackCard(
|
||||||
@@ -249,23 +299,101 @@ private fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
|||||||
discNumber = discNumber,
|
discNumber = discNumber,
|
||||||
durationSeconds = durationSeconds.toInt(),
|
durationSeconds = durationSeconds.toInt(),
|
||||||
artists = artistNames,
|
artists = artistNames,
|
||||||
|
artistRefs = mappedArtists,
|
||||||
|
featuredArtistRefs = mappedFeaturedArtists,
|
||||||
releaseTitle = releaseTitle,
|
releaseTitle = releaseTitle,
|
||||||
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
|
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
|
||||||
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
|
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> {
|
fun PlaylistListResponse.toDomain(): List<PlaylistCard> {
|
||||||
return playlists.map {
|
return playlists.map {
|
||||||
PlaylistCard(
|
PlaylistCard(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
title = it.title,
|
title = it.title,
|
||||||
trackCount = it.trackCount,
|
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? {
|
private fun String?.toAbsoluteMediaUrl(baseUrl: String): String? {
|
||||||
val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||||
if (value.startsWith("http://", ignoreCase = true) ||
|
if (value.startsWith("http://", ignoreCase = true) ||
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
package com.example.furumi_android.data.repository
|
package com.example.furumi_android.data.repository
|
||||||
|
|
||||||
import com.example.furumi_android.data.local.AuthSessionStorage
|
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.AuthApiErrorParser
|
||||||
import com.example.furumi_android.data.remote.PlayerEndpoints
|
import com.example.furumi_android.data.remote.PlayerEndpoints
|
||||||
import com.example.furumi_android.data.remote.api.PlayerApi
|
import com.example.furumi_android.data.remote.api.PlayerApi
|
||||||
import com.example.furumi_android.data.remote.model.AddPlaylistTracksRequest
|
import com.example.furumi_android.data.remote.model.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.RecordHistoryRequest
|
||||||
import com.example.furumi_android.data.remote.model.SharePlaylistRequest
|
|
||||||
import com.example.furumi_android.data.remote.model.toDomain
|
import com.example.furumi_android.data.remote.model.toDomain
|
||||||
|
import com.example.furumi_android.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.ArtistDetail
|
||||||
import com.example.furumi_android.domain.model.ArtistPage
|
import com.example.furumi_android.domain.model.ArtistPage
|
||||||
import com.example.furumi_android.domain.model.AuthException
|
import com.example.furumi_android.domain.model.AuthException
|
||||||
|
import com.example.furumi_android.domain.model.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.ListeningHistoryPage
|
||||||
import com.example.furumi_android.domain.model.PlaylistCard
|
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.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 com.example.furumi_android.domain.repository.PlayerRepository
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -21,6 +39,8 @@ import javax.inject.Inject
|
|||||||
class PlayerRepositoryImpl @Inject constructor(
|
class PlayerRepositoryImpl @Inject constructor(
|
||||||
private val playerApi: PlayerApi,
|
private val playerApi: PlayerApi,
|
||||||
private val sessionStorage: AuthSessionStorage,
|
private val sessionStorage: AuthSessionStorage,
|
||||||
|
private val connectedDeviceStorage: ConnectedDeviceStorage,
|
||||||
|
private val appClientInfo: AppClientInfo,
|
||||||
private val playerEndpoints: PlayerEndpoints,
|
private val playerEndpoints: PlayerEndpoints,
|
||||||
private val errorParser: AuthApiErrorParser
|
private val errorParser: AuthApiErrorParser
|
||||||
) : PlayerRepository {
|
) : 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> {
|
override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> {
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
@@ -158,8 +201,8 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun recordListeningHistory(
|
override suspend fun recordListeningHistory(
|
||||||
trackId: Long,
|
trackId: Long,
|
||||||
startedAt: String,
|
startedAt: Long,
|
||||||
durationListened: Double,
|
durationListened: Int,
|
||||||
completed: Boolean
|
completed: Boolean
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
return try {
|
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> {
|
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
@@ -233,11 +297,28 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
val response = playerApi.sharePlaylist(
|
Result.success(playerEndpoints.shareTrack(baseUrl, trackId))
|
||||||
url = playerEndpoints.sharePlaylist(baseUrl),
|
} catch (e: CancellationException) {
|
||||||
body = SharePlaylistRequest(
|
throw e
|
||||||
trackIds = listOf(trackId),
|
} catch (e: Exception) {
|
||||||
title = title
|
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))
|
throw AuthException(errorParser.messageFrom(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Share response is empty")
|
val body = response.body() ?: throw AuthException("Device poll response is empty")
|
||||||
Result.success(body.url)
|
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) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} 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.local.AuthSessionStorage
|
||||||
import com.example.furumi_android.data.remote.AccessTokenInterceptor
|
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.AuthAuthenticator
|
||||||
import com.example.furumi_android.data.remote.AuthEndpoints
|
import com.example.furumi_android.data.remote.AuthEndpoints
|
||||||
import com.example.furumi_android.data.remote.api.AuthApi
|
import com.example.furumi_android.data.remote.api.AuthApi
|
||||||
@@ -26,9 +27,10 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideAccessTokenInterceptor(
|
fun provideAccessTokenInterceptor(
|
||||||
sessionStorage: AuthSessionStorage,
|
sessionStorage: AuthSessionStorage,
|
||||||
authEndpoints: AuthEndpoints
|
authEndpoints: AuthEndpoints,
|
||||||
|
appClientInfo: AppClientInfo
|
||||||
): AccessTokenInterceptor {
|
): AccessTokenInterceptor {
|
||||||
return AccessTokenInterceptor(sessionStorage, authEndpoints)
|
return AccessTokenInterceptor(sessionStorage, authEndpoints, appClientInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ data class ArtistCard(
|
|||||||
val trackCount: Int
|
val trackCount: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ArtistRef(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
data class ArtistPage(
|
data class ArtistPage(
|
||||||
val items: List<ArtistCard>,
|
val items: List<ArtistCard>,
|
||||||
val total: Long,
|
val total: Long,
|
||||||
@@ -32,6 +37,8 @@ data class TrackCard(
|
|||||||
val discNumber: Int?,
|
val discNumber: Int?,
|
||||||
val durationSeconds: Int?,
|
val durationSeconds: Int?,
|
||||||
val artists: List<String>,
|
val artists: List<String>,
|
||||||
|
val artistRefs: List<ArtistRef> = emptyList(),
|
||||||
|
val featuredArtistRefs: List<ArtistRef> = emptyList(),
|
||||||
val releaseTitle: String?,
|
val releaseTitle: String?,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
val streamUrl: String
|
val streamUrl: String
|
||||||
@@ -54,5 +61,21 @@ data class PlaylistCard(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val trackCount: Int,
|
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.ArtistPage
|
||||||
import com.example.furumi_android.domain.model.ArtistDetail
|
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.ListeningHistoryPage
|
||||||
import com.example.furumi_android.domain.model.PlaylistCard
|
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.ReleaseDetail
|
||||||
|
import com.example.furumi_android.domain.model.SearchResults
|
||||||
|
import com.example.furumi_android.domain.model.TrackCard
|
||||||
|
|
||||||
interface PlayerRepository {
|
interface PlayerRepository {
|
||||||
suspend fun getArtists(
|
suspend fun getArtists(
|
||||||
@@ -17,6 +24,8 @@ interface PlayerRepository {
|
|||||||
|
|
||||||
suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail>
|
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 getListeningHistory(page: Int = 1, limit: Int = 20): Result<ListeningHistoryPage>
|
||||||
|
|
||||||
suspend fun toggleLike(trackId: Long): Result<Boolean>
|
suspend fun toggleLike(trackId: Long): Result<Boolean>
|
||||||
@@ -25,14 +34,48 @@ interface PlayerRepository {
|
|||||||
|
|
||||||
suspend fun recordListeningHistory(
|
suspend fun recordListeningHistory(
|
||||||
trackId: Long,
|
trackId: Long,
|
||||||
startedAt: String,
|
startedAt: Long,
|
||||||
durationListened: Double,
|
durationListened: Int,
|
||||||
completed: Boolean
|
completed: Boolean
|
||||||
): Result<Unit>
|
): Result<Unit>
|
||||||
|
|
||||||
suspend fun getPlaylists(): Result<List<PlaylistCard>>
|
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 addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit>
|
||||||
|
|
||||||
suspend fun shareTrack(trackId: Long, title: String): Result<String>
|
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.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.session.CacheBitmapLoader
|
import androidx.media3.session.CacheBitmapLoader
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
@@ -20,6 +22,8 @@ import com.example.furumi_android.MainActivity
|
|||||||
import com.google.common.util.concurrent.MoreExecutors
|
import com.google.common.util.concurrent.MoreExecutors
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -29,6 +33,7 @@ class FurumiPlaybackService : MediaSessionService() {
|
|||||||
@Inject lateinit var okHttpClient: OkHttpClient
|
@Inject lateinit var okHttpClient: OkHttpClient
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
private var mediaSession: MediaSession? = null
|
||||||
|
private var bitmapExecutor: ExecutorService? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NOTIFICATION_ID = 1001
|
const val NOTIFICATION_ID = 1001
|
||||||
@@ -49,9 +54,11 @@ class FurumiPlaybackService : MediaSessionService() {
|
|||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val executor = Executors.newSingleThreadExecutor()
|
||||||
|
bitmapExecutor = executor
|
||||||
val bitmapLoader = CacheBitmapLoader(
|
val bitmapLoader = CacheBitmapLoader(
|
||||||
androidx.media3.datasource.DataSourceBitmapLoader(
|
DataSourceBitmapLoader(
|
||||||
MoreExecutors.listeningDecorator(java.util.concurrent.Executors.newSingleThreadExecutor()),
|
MoreExecutors.listeningDecorator(executor),
|
||||||
OkHttpDataSource.Factory(okHttpClient)
|
OkHttpDataSource.Factory(okHttpClient)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -73,30 +80,44 @@ class FurumiPlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
// Immediately satisfy Android's startForeground contract with a placeholder.
|
|
||||||
// DefaultMediaNotificationProvider (via super) will replace this with the
|
|
||||||
// real notification that auto-updates metadata/artwork on every track change.
|
|
||||||
val session = mediaSession
|
val session = mediaSession
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
val metadata = playbackController.player.currentMediaItem?.mediaMetadata
|
val player = session.player
|
||||||
val placeholder = NotificationCompat.Builder(this, CHANNEL_ID)
|
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)
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
.setContentTitle(metadata?.title ?: "Furumi")
|
.setContentTitle(title)
|
||||||
.setContentText(metadata?.artist ?: "Playing...")
|
.setContentText(artist)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
|
.setStyle(MediaStyleNotificationHelper.MediaStyle(session))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
try {
|
||||||
startForeground(NOTIFICATION_ID, placeholder, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
} else {
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||||
startForeground(NOTIFICATION_ID, placeholder)
|
} else {
|
||||||
|
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)
|
super.onStartCommand(intent, flags, startId)
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
@@ -108,14 +129,13 @@ class FurumiPlaybackService : MediaSessionService() {
|
|||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val name = "Furumi Playback"
|
val name = "Furumi Playback"
|
||||||
val importance = NotificationManager.IMPORTANCE_LOW
|
val channel = NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
|
setShowBadge(true)
|
||||||
setShowBadge(false)
|
|
||||||
setSound(null, null)
|
|
||||||
enableVibration(false)
|
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)
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,11 +149,12 @@ class FurumiPlaybackService : MediaSessionService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.d("FurumiPlaybackService", "onDestroy")
|
|
||||||
mediaSession?.run {
|
mediaSession?.run {
|
||||||
release()
|
release()
|
||||||
mediaSession = null
|
mediaSession = null
|
||||||
}
|
}
|
||||||
|
bitmapExecutor?.shutdown()
|
||||||
|
bitmapExecutor = null
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,23 @@ package com.example.furumi_android.playback
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Timeline
|
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
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.TrackCard
|
||||||
|
import com.example.furumi_android.domain.model.ConnectedPlaybackState
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -33,7 +30,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.io.ByteArrayOutputStream
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
data class AudioPlaybackState(
|
data class AudioPlaybackState(
|
||||||
val queue: List<TrackCard> = emptyList(),
|
val queue: List<TrackCard> = emptyList(),
|
||||||
@@ -42,6 +40,9 @@ data class AudioPlaybackState(
|
|||||||
val isBuffering: Boolean = false,
|
val isBuffering: Boolean = false,
|
||||||
val positionMs: Long = 0L,
|
val positionMs: Long = 0L,
|
||||||
val durationMs: 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 errorMessage: String? = null
|
||||||
) {
|
) {
|
||||||
val currentTrack: TrackCard?
|
val currentTrack: TrackCard?
|
||||||
@@ -62,6 +63,7 @@ data class AudioPlaybackState(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val RESTART_WINDOW_MS = 3_000L
|
const val RESTART_WINDOW_MS = 3_000L
|
||||||
|
const val REPEAT_MODE_OFF = "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,16 +71,17 @@ data class AudioPlaybackState(
|
|||||||
@Singleton
|
@Singleton
|
||||||
class PlaybackController @Inject constructor(
|
class PlaybackController @Inject constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val mediaImageLoader: MediaImageLoader,
|
|
||||||
okHttpClient: OkHttpClient
|
okHttpClient: OkHttpClient
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
private val _state = MutableStateFlow(AudioPlaybackState())
|
private val _state = MutableStateFlow(AudioPlaybackState())
|
||||||
|
|
||||||
private var playStartedAt: Long = 0L
|
private var playStartedAtMs: Long = 0L
|
||||||
private var playStartTrackId: Long = -1L
|
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 player: ExoPlayer
|
||||||
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
|
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
|
||||||
@@ -105,6 +108,7 @@ class PlaybackController @Inject constructor(
|
|||||||
player.addListener(
|
player.addListener(
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
updateListeningTimer(isPlaying)
|
||||||
publishState()
|
publishState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +121,12 @@ class PlaybackController @Inject constructor(
|
|||||||
|
|
||||||
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||||
publishState()
|
publishState()
|
||||||
|
startPlaybackService()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
reportCurrentTrackPlay(completed = false)
|
val completed = reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
||||||
|
reportCurrentTrackPlay(completed = completed)
|
||||||
startTrackTimer()
|
startTrackTimer()
|
||||||
publishState()
|
publishState()
|
||||||
startPlaybackService()
|
startPlaybackService()
|
||||||
@@ -155,19 +161,18 @@ class PlaybackController @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportCurrentTrackPlay(completed = false)
|
||||||
|
|
||||||
val safeStartIndex = startIndex.coerceIn(0, playableQueue.lastIndex)
|
val safeStartIndex = startIndex.coerceIn(0, playableQueue.lastIndex)
|
||||||
val mediaItems = playableQueue.map { track ->
|
val mediaItems = playableQueue.map { track -> track.toMediaItem() }
|
||||||
MediaItem.Builder()
|
|
||||||
.setMediaId(track.id.toString())
|
|
||||||
.setUri(track.streamUrl)
|
|
||||||
.setMediaMetadata(track.toMediaMetadata())
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
_state.value = AudioPlaybackState(
|
_state.value = AudioPlaybackState(
|
||||||
queue = playableQueue,
|
queue = playableQueue,
|
||||||
currentIndex = safeStartIndex,
|
currentIndex = safeStartIndex,
|
||||||
isBuffering = true
|
isBuffering = true,
|
||||||
|
shuffle = player.shuffleModeEnabled,
|
||||||
|
repeatMode = player.repeatMode.toFurumiRepeatMode(),
|
||||||
|
volume = player.volume
|
||||||
)
|
)
|
||||||
|
|
||||||
player.setMediaItems(mediaItems, safeStartIndex, 0L)
|
player.setMediaItems(mediaItems, safeStartIndex, 0L)
|
||||||
@@ -194,15 +199,27 @@ class PlaybackController @Inject constructor(
|
|||||||
if (_state.value.currentTrack == null) return
|
if (_state.value.currentTrack == null) return
|
||||||
|
|
||||||
if (player.isPlaying) {
|
if (player.isPlaying) {
|
||||||
player.pause()
|
pause()
|
||||||
} else {
|
} else {
|
||||||
player.play()
|
resume()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
player.pause()
|
||||||
|
publishState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume() {
|
||||||
|
if (_state.value.currentTrack == null) return
|
||||||
|
player.play()
|
||||||
|
startPlaybackService()
|
||||||
publishState()
|
publishState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun next() {
|
fun next() {
|
||||||
if (player.hasNextMediaItem()) {
|
if (player.hasNextMediaItem()) {
|
||||||
|
reportCurrentTrackPlay(completed = false)
|
||||||
player.seekToNextMediaItem()
|
player.seekToNextMediaItem()
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
@@ -213,6 +230,7 @@ class PlaybackController @Inject constructor(
|
|||||||
if (player.currentPosition > RESTART_WINDOW_MS || !player.hasPreviousMediaItem()) {
|
if (player.currentPosition > RESTART_WINDOW_MS || !player.hasPreviousMediaItem()) {
|
||||||
player.seekTo(0L)
|
player.seekTo(0L)
|
||||||
} else {
|
} else {
|
||||||
|
reportCurrentTrackPlay(completed = false)
|
||||||
player.seekToPreviousMediaItem()
|
player.seekToPreviousMediaItem()
|
||||||
}
|
}
|
||||||
player.play()
|
player.play()
|
||||||
@@ -225,12 +243,49 @@ class PlaybackController @Inject constructor(
|
|||||||
publishState()
|
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() {
|
fun release() {
|
||||||
|
reportCurrentTrackPlay(completed = false)
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
player.release()
|
player.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
reportCurrentTrackPlay(completed = false)
|
||||||
player.stop()
|
player.stop()
|
||||||
_state.value = AudioPlaybackState()
|
_state.value = AudioPlaybackState()
|
||||||
}
|
}
|
||||||
@@ -244,11 +299,7 @@ class PlaybackController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val insertIndex = current.currentIndex + 1
|
val insertIndex = current.currentIndex + 1
|
||||||
val mediaItem = MediaItem.Builder()
|
val mediaItem = track.toMediaItem()
|
||||||
.setMediaId(track.id.toString())
|
|
||||||
.setUri(track.streamUrl)
|
|
||||||
.setMediaMetadata(track.toMediaMetadata())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
player.addMediaItem(insertIndex, mediaItem)
|
player.addMediaItem(insertIndex, mediaItem)
|
||||||
val updatedQueue = current.queue.toMutableList().apply { add(insertIndex, track) }
|
val updatedQueue = current.queue.toMutableList().apply { add(insertIndex, track) }
|
||||||
@@ -263,36 +314,129 @@ class PlaybackController @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaItem = MediaItem.Builder()
|
val mediaItem = track.toMediaItem()
|
||||||
.setMediaId(track.id.toString())
|
|
||||||
.setUri(track.streamUrl)
|
|
||||||
.setMediaMetadata(track.toMediaMetadata())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
player.addMediaItem(mediaItem)
|
player.addMediaItem(mediaItem)
|
||||||
val updatedQueue = current.queue + track
|
val updatedQueue = current.queue + track
|
||||||
_state.value = current.copy(queue = updatedQueue)
|
_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() {
|
private fun startTrackTimer() {
|
||||||
val current = _state.value.currentTrack ?: return
|
val currentQueue = _state.value.queue
|
||||||
playStartedAt = System.currentTimeMillis()
|
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
|
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) {
|
private fun reportCurrentTrackPlay(completed: Boolean) {
|
||||||
if (playStartTrackId < 0 || playStartedAt <= 0L) return
|
if (playStartTrackId < 0 || playStartedAtMs <= 0L) return
|
||||||
val durationMs = System.currentTimeMillis() - playStartedAt
|
updateListeningTimer(isPlaying = false)
|
||||||
val durationSec = durationMs / 1000.0
|
|
||||||
if (durationSec < 1.0) return
|
|
||||||
|
|
||||||
val isoTimestamp = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US)
|
val durationMs = listenedDurationMs.takeIf { it > 0L }
|
||||||
.apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }
|
?: (System.currentTimeMillis() - playStartedAtMs).coerceAtLeast(0L)
|
||||||
.format(java.util.Date(playStartedAt))
|
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
|
playStartTrackId = -1L
|
||||||
playStartedAt = 0L
|
playStartedAtMs = 0L
|
||||||
|
listenedDurationMs = 0L
|
||||||
|
lastPlaybackResumedAtMs = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publishState() {
|
private fun publishState() {
|
||||||
@@ -311,6 +455,9 @@ class PlaybackController @Inject constructor(
|
|||||||
isBuffering = player.playbackState == Player.STATE_BUFFERING,
|
isBuffering = player.playbackState == Player.STATE_BUFFERING,
|
||||||
positionMs = player.currentPosition.coerceAtLeast(0L),
|
positionMs = player.currentPosition.coerceAtLeast(0L),
|
||||||
durationMs = duration,
|
durationMs = duration,
|
||||||
|
shuffle = player.shuffleModeEnabled,
|
||||||
|
repeatMode = player.repeatMode.toFurumiRepeatMode(),
|
||||||
|
volume = player.volume,
|
||||||
errorMessage = existingErrorMessage
|
errorMessage = existingErrorMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -333,9 +480,35 @@ private fun TrackCard.toMediaMetadata(): MediaMetadata {
|
|||||||
|
|
||||||
return MediaMetadata.Builder()
|
return MediaMetadata.Builder()
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
|
.setDisplayTitle(title)
|
||||||
.setArtist(artist)
|
.setArtist(artist)
|
||||||
|
.setSubtitle(artist)
|
||||||
.setAlbumTitle(releaseTitle)
|
.setAlbumTitle(releaseTitle)
|
||||||
.setArtworkUri(coverUrl?.let(Uri::parse))
|
.setArtworkUri(coverUrl?.let(Uri::parse))
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
|
||||||
.build()
|
.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,
|
value = uiState.serverUrl,
|
||||||
onValueChange = onServerUrlChange,
|
onValueChange = onServerUrlChange,
|
||||||
label = "Server URL",
|
label = "Server URL",
|
||||||
placeholder = "https://media.example.com",
|
placeholder = "https://music.hexor.cy",
|
||||||
enabled = !uiState.isLoading,
|
enabled = !uiState.isLoading,
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
imeAction = if (isPasswordLoginVisible) ImeAction.Next else ImeAction.Go,
|
imeAction = if (isPasswordLoginVisible) ImeAction.Next else ImeAction.Go,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class LoginViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
LoginUiState(
|
LoginUiState(
|
||||||
serverUrl = authRepository.getSavedServerUrl().orEmpty(),
|
serverUrl = authRepository.getSavedServerUrl()?.takeIf { it.isNotBlank() } ?: DEFAULT_SERVER_URL,
|
||||||
isAuthenticated = authRepository.getCurrentSession() != null
|
isAuthenticated = authRepository.getCurrentSession() != null
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -158,4 +158,8 @@ class LoginViewModel @Inject constructor(
|
|||||||
private fun String.withoutLineBreaks(): String {
|
private fun String.withoutLineBreaks(): String {
|
||||||
return replace("\r", "").replace("\n", "")
|
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
|
package com.example.furumi_android.ui.player
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.furumi_android.data.repository.MediaImageLoader
|
import com.example.furumi_android.data.repository.MediaImageLoader
|
||||||
import com.example.furumi_android.domain.model.ArtistCard
|
import com.example.furumi_android.domain.model.ArtistCard
|
||||||
import com.example.furumi_android.domain.model.ArtistDetail
|
import com.example.furumi_android.domain.model.ArtistDetail
|
||||||
|
import com.example.furumi_android.domain.model.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.ListeningHistoryItem
|
||||||
import com.example.furumi_android.domain.model.PlaylistCard
|
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.ReleaseCard
|
||||||
import com.example.furumi_android.domain.model.ReleaseDetail
|
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.model.TrackCard
|
||||||
import com.example.furumi_android.domain.repository.AuthRepository
|
import com.example.furumi_android.domain.repository.AuthRepository
|
||||||
import com.example.furumi_android.domain.repository.PlayerRepository
|
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.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -42,6 +51,17 @@ data class PlayerUiState(
|
|||||||
val isGlobalArtistsLoading: Boolean = false,
|
val isGlobalArtistsLoading: Boolean = false,
|
||||||
val isGlobalArtistsLoadingMore: Boolean = false,
|
val isGlobalArtistsLoadingMore: Boolean = false,
|
||||||
val globalArtistsError: String? = null,
|
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 selectedArtist: ArtistCard? = null,
|
||||||
val artistDetail: ArtistDetail? = null,
|
val artistDetail: ArtistDetail? = null,
|
||||||
val isArtistDetailLoading: Boolean = false,
|
val isArtistDetailLoading: Boolean = false,
|
||||||
@@ -55,7 +75,17 @@ data class PlayerUiState(
|
|||||||
val playback: AudioPlaybackState = AudioPlaybackState(),
|
val playback: AudioPlaybackState = AudioPlaybackState(),
|
||||||
val likedTrackIds: Set<Long> = emptySet(),
|
val likedTrackIds: Set<Long> = emptySet(),
|
||||||
val playlists: List<PlaylistCard> = emptyList(),
|
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
|
@HiltViewModel
|
||||||
@@ -68,6 +98,13 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
|
private val artistDetailCache = mutableMapOf<Long, ArtistDetail>()
|
||||||
private val releaseDetailCache = mutableMapOf<Long, ReleaseDetail>()
|
private val releaseDetailCache = mutableMapOf<Long, ReleaseDetail>()
|
||||||
|
private val 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)
|
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
||||||
|
|
||||||
@@ -87,6 +124,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
loadGlobalArtistsIfNeeded()
|
loadGlobalArtistsIfNeeded()
|
||||||
observePlayback()
|
observePlayback()
|
||||||
loadLikedTrackIds()
|
loadLikedTrackIds()
|
||||||
|
startConnectedDevicesPolling()
|
||||||
playbackController.onTrackPlayReported = { trackId, startedAt, durationListened, completed ->
|
playbackController.onTrackPlayReported = { trackId, startedAt, durationListened, completed ->
|
||||||
reportListeningHistory(trackId, startedAt, durationListened, completed)
|
reportListeningHistory(trackId, startedAt, durationListened, completed)
|
||||||
}
|
}
|
||||||
@@ -120,6 +158,71 @@ class PlayerViewModel @Inject constructor(
|
|||||||
loadGlobalArtists(page = state.globalArtistsPage + 1, append = true)
|
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) {
|
fun openArtist(artist: ArtistCard) {
|
||||||
val cachedDetail = artistDetailCache[artist.id]
|
val cachedDetail = artistDetailCache[artist.id]
|
||||||
|
|
||||||
@@ -155,6 +258,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun openRelease(release: ReleaseCard) {
|
fun openRelease(release: ReleaseCard) {
|
||||||
val cachedDetail = releaseDetailCache[release.id]
|
val cachedDetail = releaseDetailCache[release.id]
|
||||||
|
?.withPreferredReleaseArtwork(release)
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
selectedRelease = cachedDetail?.release ?: release,
|
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() {
|
fun retryReleaseDetail() {
|
||||||
val release = _uiState.value.selectedRelease ?: return
|
val release = _uiState.value.selectedRelease ?: return
|
||||||
loadReleaseDetail(release.id)
|
loadReleaseDetail(release.id)
|
||||||
@@ -185,6 +318,29 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun playTrack(track: TrackCard, queue: List<TrackCard>): Boolean {
|
fun playTrack(track: TrackCard, queue: List<TrackCard>): Boolean {
|
||||||
if (track.streamUrl.isBlank()) return false
|
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)
|
playbackController.playTrack(track, queue)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -196,10 +352,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
.filter { it.streamUrl.isNotBlank() }
|
.filter { it.streamUrl.isNotBlank() }
|
||||||
.let { if (shuffle) it.shuffled() else it }
|
.let { if (shuffle) it.shuffled() else it }
|
||||||
|
|
||||||
if (tracks.isEmpty()) return false
|
return playTracks(tracks, shuffle)
|
||||||
|
|
||||||
playbackController.playQueue(tracks)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playReleaseTracks(shuffle: Boolean = false): Boolean {
|
fun playReleaseTracks(shuffle: Boolean = false): Boolean {
|
||||||
@@ -209,25 +362,183 @@ class PlayerViewModel @Inject constructor(
|
|||||||
.filter { it.streamUrl.isNotBlank() }
|
.filter { it.streamUrl.isNotBlank() }
|
||||||
.let { if (shuffle) it.shuffled() else it }
|
.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
|
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)
|
playbackController.playQueue(tracks)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePlayPause() {
|
fun togglePlayPause() {
|
||||||
|
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||||
|
val remoteState = _uiState.value.connectedDevicesState?.remotePlaybackState
|
||||||
|
sendConnectedCommand(targetDeviceId, if (remoteState?.paused == true) "resume" else "pause")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
playbackController.togglePlayPause()
|
playbackController.togglePlayPause()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun nextTrack() {
|
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()
|
playbackController.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun previousTrack() {
|
fun previousTrack() {
|
||||||
|
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||||
|
sendConnectedCommand(targetDeviceId, "prev")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
playbackController.previous()
|
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) {
|
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)
|
playbackController.seekToProgress(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +552,14 @@ class PlayerViewModel @Inject constructor(
|
|||||||
return
|
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(
|
_uiState.value = state.copy(
|
||||||
loadingMediaImageUrls = state.loadingMediaImageUrls + imageUrl
|
loadingMediaImageUrls = state.loadingMediaImageUrls + imageUrl
|
||||||
)
|
)
|
||||||
@@ -314,10 +633,20 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun isTrackLiked(trackId: Long): Boolean = _uiState.value.likedTrackIds.contains(trackId)
|
fun isTrackLiked(trackId: Long): Boolean = _uiState.value.likedTrackIds.contains(trackId)
|
||||||
|
|
||||||
fun addToPlayNext(track: TrackCard) {
|
fun addToPlayNext(track: TrackCard) {
|
||||||
|
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||||
|
sendConnectedCommand(targetDeviceId, "queue_add_next", tracks = listOf(track))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
playbackController.addNext(track)
|
playbackController.addNext(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToQueueEnd(track: TrackCard) {
|
fun addToQueueEnd(track: TrackCard) {
|
||||||
|
activeRemoteDeviceId()?.let { targetDeviceId ->
|
||||||
|
sendConnectedCommand(targetDeviceId, "queue_add_end", tracks = listOf(track))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
playbackController.addToEnd(track)
|
playbackController.addToEnd(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,21 +661,34 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun loadPlaylists() {
|
fun loadPlaylists() {
|
||||||
if (_uiState.value.isPlaylistsLoading) return
|
if (_uiState.value.isPlaylistsLoading) return
|
||||||
_uiState.value = _uiState.value.copy(isPlaylistsLoading = true)
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isPlaylistsLoading = true,
|
||||||
|
playlistsError = null
|
||||||
|
)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerRepository.getPlaylists()
|
playerRepository.getPlaylists()
|
||||||
.onSuccess { playlists ->
|
.onSuccess { playlists ->
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
playlists = playlists,
|
playlists = playlists,
|
||||||
isPlaylistsLoading = false
|
isPlaylistsLoading = false,
|
||||||
|
playlistsError = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure { error ->
|
||||||
_uiState.value = _uiState.value.copy(isPlaylistsLoading = false)
|
_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) {
|
fun addTrackToPlaylist(trackId: Long, playlistId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerRepository.addTracksToPlaylist(playlistId, listOf(trackId))
|
playerRepository.addTracksToPlaylist(playlistId, listOf(trackId))
|
||||||
@@ -355,12 +697,25 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun reportListeningHistory(
|
private fun reportListeningHistory(
|
||||||
trackId: Long,
|
trackId: Long,
|
||||||
startedAt: String,
|
startedAt: Long,
|
||||||
durationListened: Double,
|
durationListened: Int,
|
||||||
completed: Boolean
|
completed: Boolean
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerRepository.recordListeningHistory(trackId, startedAt, durationListened, completed)
|
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) {
|
private fun loadGlobalArtists(page: Int, append: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val state = _uiState.value
|
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) {
|
private fun loadArtistDetail(artistId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(
|
_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) {
|
private fun loadReleaseDetail(releaseId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
@@ -483,10 +1106,16 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
playerRepository.getReleaseDetail(releaseId)
|
playerRepository.getReleaseDetail(releaseId)
|
||||||
.onSuccess { detail ->
|
.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(
|
_uiState.value = _uiState.value.copy(
|
||||||
selectedRelease = detail.release,
|
selectedRelease = displayDetail.release,
|
||||||
releaseDetail = detail,
|
releaseDetail = displayDetail,
|
||||||
isReleaseDetailLoading = false,
|
isReleaseDetailLoading = false,
|
||||||
releaseDetailError = null
|
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 {
|
private companion object {
|
||||||
const val ARTIST_PAGE_SIZE = 60
|
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
|
@Composable
|
||||||
fun FurumiandroidTheme(
|
fun FurumiTheme(
|
||||||
darkTheme: Boolean = true,
|
darkTheme: Boolean = true,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/furumi_icon_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/furumi_icon_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
</adaptive-icon>
|
</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="teal_700">#FF018786</color>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="furumi_icon_background">#080A0E</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
<resources>
|
<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>
|
</resources>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.Furumiandroid" parent="android:Theme.Material.Light.NoActionBar" />
|
<style name="Theme.Furumi" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
</resources>
|
</resources>
|
||||||