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