Added Jam, remote devices. small fixes

This commit is contained in:
Ultradesu
2026-06-05 18:07:04 +03:00
parent cc3d12bfb8
commit bc3d3f654e
34 changed files with 1768 additions and 3197 deletions
-4
View File
@@ -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 />
+1
View File
@@ -32,6 +32,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }
+5 -3
View File
@@ -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
@@ -39,7 +44,7 @@ class FurumiPlaybackService : MediaSessionService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d("FurumiPlaybackService", "onCreate") Log.d("FurumiPlaybackService", "onCreate")
createNotificationChannel() createNotificationChannel()
val sessionActivity = PendingIntent.getActivity( val sessionActivity = PendingIntent.getActivity(
@@ -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)
) )
) )
@@ -66,37 +73,51 @@ class FurumiPlaybackService : MediaSessionService() {
.setChannelId(CHANNEL_ID) .setChannelId(CHANNEL_ID)
.setNotificationId(NOTIFICATION_ID) .setNotificationId(NOTIFICATION_ID)
.build() .build()
notificationProvider.setSmallIcon(android.R.drawable.ic_media_play) notificationProvider.setSmallIcon(android.R.drawable.ic_media_play)
setMediaNotificationProvider(notificationProvider) setMediaNotificationProvider(notificationProvider)
} }
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Immediately satisfy Android's startForeground contract with a placeholder.
// DefaultMediaNotificationProvider (via super) will replace this with the
// real notification that auto-updates metadata/artwork on every track change.
val session = mediaSession val session = mediaSession
if (session != null) { if (session != null) {
val metadata = playbackController.player.currentMediaItem?.mediaMetadata val 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"
}
} }
File diff suppressed because it is too large Load Diff
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+2 -1
View File
@@ -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>
</resources> <color name="furumi_icon_background">#080A0E</color>
</resources>
+4 -2
View File
@@ -1,3 +1,5 @@
<resources> <resources>
<string name="app_name">furumi-android</string> <string name="app_name">Furumi</string>
</resources> <string name="app_description">Music player for Furumi</string>
<string name="sso_callback_label">Furumi SSO Callback</string>
</resources>
+2 -2
View File
@@ -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>