2 Commits
27 changed files with 3451 additions and 176 deletions
+5 -2
View File
@@ -5,6 +5,9 @@ plugins {
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
} }
val furumiVersionCode = providers.gradleProperty("furumi.versionCode").map(String::toInt).get()
val furumiVersionName = providers.gradleProperty("furumi.versionName").get()
android { android {
namespace = "cy.hexor.furumi" namespace = "cy.hexor.furumi"
compileSdk = 35 compileSdk = 35
@@ -13,8 +16,8 @@ android {
applicationId = "cy.hexor.furumi" applicationId = "cy.hexor.furumi"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = furumiVersionCode
versionName = "1.1" versionName = furumiVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -0,0 +1,874 @@
package cy.hexor.furumi.data.local
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.net.Uri
import com.squareup.moshi.Moshi
import cy.hexor.furumi.data.remote.model.OfflineManifestPlaylistResponse
import cy.hexor.furumi.data.remote.model.OfflineManifestTrackResponse
import cy.hexor.furumi.domain.model.ArtistCard
import cy.hexor.furumi.domain.model.ArtistDetail
import cy.hexor.furumi.domain.model.ArtistPage
import cy.hexor.furumi.domain.model.PlaylistCard
import cy.hexor.furumi.domain.model.PlaylistDetail
import cy.hexor.furumi.domain.model.ReleaseCard
import cy.hexor.furumi.domain.model.ReleaseDetail
import cy.hexor.furumi.domain.model.SearchResults
import cy.hexor.furumi.domain.model.TrackCard
import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OfflineLibraryStore @Inject constructor(
@ApplicationContext context: Context,
moshi: Moshi
) {
private val dbHelper = OfflineDbHelper(context.applicationContext)
private val trackAdapter = moshi.adapter(TrackCard::class.java)
fun upsertTracks(tracks: List<TrackCard>) {
if (tracks.isEmpty()) return
dbHelper.writableDatabase.transaction {
tracks.forEach { track -> upsertTrackLocked(this, track) }
}
}
fun upsertManifestTracks(tracks: List<OfflineManifestTrackResponse>) {
if (tracks.isEmpty()) return
dbHelper.writableDatabase.transaction {
tracks.forEach { manifest ->
val existing = getTrackRecordLocked(this, manifest.id)
val audioChanged = existing?.audioHash != null &&
existing.audioHash != manifest.audioHash
val coverChanged = existing?.coverHash != null &&
existing.coverHash != manifest.coverHash
val values = ContentValues().apply {
put(COL_TRACK_ID, manifest.id)
put(COL_UPDATED_AT, manifest.updatedAt)
put(COL_STREAM_URL, manifest.streamUrl)
put(COL_REMOTE_STREAM_URL, manifest.streamUrl)
put(COL_AUDIO_HASH, manifest.audioHash)
put(COL_AUDIO_SIZE_BYTES, manifest.audioSizeBytes)
put(COL_AUDIO_MIME_TYPE, manifest.audioMimeType)
put(COL_AUDIO_UPDATED_AT, manifest.audioUpdatedAt)
put(COL_COVER_URL, manifest.coverUrl)
put(COL_REMOTE_COVER_URL, manifest.coverUrl)
put(COL_COVER_HASH, manifest.coverHash)
put(COL_COVER_UPDATED_AT, manifest.coverUpdatedAt)
if (audioChanged) {
put(COL_AUDIO_STATUS, STATUS_STALE)
}
if (coverChanged) {
put(COL_COVER_STATUS, STATUS_STALE)
}
}
insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, manifest.id, values)
}
}
}
fun upsertPlaylistDetail(detail: PlaylistDetail) {
dbHelper.writableDatabase.transaction {
detail.tracks.forEach { track -> upsertTrackLocked(this, track) }
val playlist = detail.playlist
insertOrUpdate(TABLE_PLAYLISTS, COL_PLAYLIST_ID, playlist.id, ContentValues().apply {
put(COL_PLAYLIST_ID, playlist.id)
put(COL_PLAYLIST_TITLE, playlist.title)
put(COL_PLAYLIST_DESCRIPTION, detail.description)
put(COL_PLAYLIST_UPDATED_AT, System.currentTimeMillis().toString())
put(COL_PLAYLIST_IS_OWN, playlist.isOwn.toInt())
put(COL_PLAYLIST_OWNER_NAME, playlist.ownerName)
put(COL_PLAYLIST_IS_PUBLIC, playlist.isPublic.toInt())
put(COL_PLAYLIST_IS_SAVED, playlist.isSaved.toInt())
put(COL_PLAYLIST_KIND, playlist.kind)
put(COL_PLAYLIST_TRACK_IDS, detail.tracks.map { it.id }.toJsonArrayString())
})
}
}
fun replacePlaylists(playlists: List<OfflineManifestPlaylistResponse>) {
dbHelper.writableDatabase.transaction {
delete(TABLE_PLAYLISTS, null, null)
playlists.forEach { playlist ->
insert(TABLE_PLAYLISTS, null, ContentValues().apply {
put(COL_PLAYLIST_ID, playlist.id)
put(COL_PLAYLIST_TITLE, playlist.title)
put(COL_PLAYLIST_DESCRIPTION, playlist.description)
put(COL_PLAYLIST_UPDATED_AT, playlist.updatedAt)
put(COL_PLAYLIST_IS_OWN, playlist.isOwn.toInt())
put(COL_PLAYLIST_OWNER_NAME, playlist.ownerName)
put(COL_PLAYLIST_IS_PUBLIC, playlist.isPublic.toInt())
put(COL_PLAYLIST_IS_SAVED, playlist.isSaved.toInt())
put(COL_PLAYLIST_KIND, playlist.kind)
put(COL_PLAYLIST_TRACK_IDS, playlist.trackIds.toJsonArrayString())
})
}
}
}
fun replaceLikedTrackIds(trackIds: Set<Long>) {
dbHelper.writableDatabase.transaction {
delete(TABLE_LIKES, null, null)
trackIds.forEach { trackId ->
insert(TABLE_LIKES, null, ContentValues().apply {
put(COL_LIKE_TRACK_ID, trackId)
})
}
}
}
fun getLikedTrackIds(): Set<Long> {
return dbHelper.readableDatabase.query(
TABLE_LIKES,
arrayOf(COL_LIKE_TRACK_ID),
null,
null,
null,
null,
null
).useCursor { cursor ->
buildSet {
while (cursor.moveToNext()) add(cursor.getLong(COL_LIKE_TRACK_ID))
}
}
}
fun getPlaylists(): List<PlaylistCard> {
return dbHelper.readableDatabase.query(
TABLE_PLAYLISTS,
null,
null,
null,
null,
null,
"$COL_PLAYLIST_IS_OWN DESC, $COL_PLAYLIST_TITLE COLLATE NOCASE"
).useCursor { cursor ->
buildList {
while (cursor.moveToNext()) {
val trackIds = cursor.getString(COL_PLAYLIST_TRACK_IDS).longListFromJson()
add(cursor.toPlaylistCard(trackIds.size))
}
}
}
}
fun getPlaylistDetail(playlistId: Long): PlaylistDetail? {
return dbHelper.readableDatabase.query(
TABLE_PLAYLISTS,
null,
"$COL_PLAYLIST_ID = ?",
arrayOf(playlistId.toString()),
null,
null,
null,
"1"
).useCursor { cursor ->
if (!cursor.moveToFirst()) return@useCursor null
val trackIds = cursor.getString(COL_PLAYLIST_TRACK_IDS).longListFromJson()
PlaylistDetail(
playlist = cursor.toPlaylistCard(trackIds.size),
description = cursor.getStringOrNull(COL_PLAYLIST_DESCRIPTION),
tracks = getTracksByIds(trackIds)
)
}
}
fun search(query: String, limit: Int): SearchResults {
val normalized = query.trim().lowercase()
if (normalized.isBlank()) {
return SearchResults(emptyList(), emptyList(), emptyList())
}
val pattern = "%$normalized%"
val tracks = dbHelper.readableDatabase.query(
TABLE_TRACKS,
null,
"$COL_TITLE_SORT LIKE ? OR lower($COL_RELEASE_TITLE) LIKE ? OR $COL_ARTISTS_SORT LIKE ?",
arrayOf(pattern, pattern, pattern),
null,
null,
"$COL_PLAY_COUNT DESC, $COL_LAST_ACCESSED_AT DESC, $COL_TITLE_SORT",
limit.toString()
).useCursor { cursor ->
buildList {
while (cursor.moveToNext()) cursor.toTrackCard(preferLocalMedia = true)?.let(::add)
}
}
val artistMatches = tracks
.flatMap { track ->
track.artistRefs.map { ArtistSeed(it) }.ifEmpty {
track.artists.map { ArtistSeed(it) }
}
}
.distinctBy { it.artistIdOrName }
.take(limit)
.map { seed ->
ArtistCard(
id = seed.artistId,
name = seed.artistName,
imageUrl = null,
releaseCount = 0,
trackCount = tracks.count { track ->
track.artistRefs.any { it.id == seed.artistId } ||
track.artists.any { it.equals(seed.artistName, ignoreCase = true) }
}
)
}
return SearchResults(
artistMatches = artistMatches,
trackArtists = artistMatches,
tracks = tracks
)
}
fun getArtists(page: Int, limit: Int): ArtistPage {
val tracks = getAllTracks(preferLocalMedia = true)
val artists = tracks
.flatMap { track ->
track.artistRefs.map { ArtistSeed(it) }.ifEmpty {
track.artists.map { ArtistSeed(it) }
}
}
.groupBy { it.artistIdOrName }
.map { (_, seeds) ->
val seed = seeds.first()
ArtistCard(
id = seed.artistId,
name = seed.artistName,
imageUrl = null,
releaseCount = tracks
.filter { track ->
track.artistRefs.any { it.id == seed.artistId } ||
track.artists.any { it.equals(seed.artistName, ignoreCase = true) }
}
.mapNotNull { it.releaseId ?: it.releaseTitle?.hashCode()?.toLong() }
.distinct()
.size,
trackCount = seeds.size
)
}
.sortedBy { it.name.lowercase() }
val safePage = page.coerceAtLeast(1)
val from = ((safePage - 1) * limit).coerceAtMost(artists.size)
val to = (from + limit).coerceAtMost(artists.size)
return ArtistPage(
items = artists.subList(from, to),
total = artists.size.toLong(),
page = safePage,
perPage = limit,
hasMore = to < artists.size
)
}
fun getArtistDetail(artistId: Long): ArtistDetail? {
val tracks = getAllTracks(preferLocalMedia = true).filter { track ->
track.artistRefs.any { it.id == artistId } ||
(artistId < 0 && track.artists.any { it.hashCode().toLong() == -artistId })
}
if (tracks.isEmpty()) return null
val name = tracks.firstNotNullOfOrNull { track ->
track.artistRefs.firstOrNull { it.id == artistId }?.name
} ?: tracks.first().artists.firstOrNull().orEmpty()
val releases = tracks
.groupBy { it.releaseId ?: it.releaseTitle?.hashCode()?.toLong() ?: it.id }
.map { (releaseId, releaseTracks) ->
ReleaseCard(
id = releaseId,
title = releaseTracks.first().releaseTitle ?: "Unknown release",
releaseType = null,
year = releaseTracks.firstNotNullOfOrNull { it.releaseYear },
coverUrl = releaseTracks.firstNotNullOfOrNull { it.coverUrl },
trackCount = releaseTracks.size
)
}
return ArtistDetail(
artist = ArtistCard(
id = artistId,
name = name.ifBlank { "Unknown artist" },
imageUrl = null,
releaseCount = releases.size,
trackCount = tracks.size
),
releases = releases,
topTracks = tracks,
featuredTracks = emptyList()
)
}
fun getReleaseDetail(releaseId: Long): ReleaseDetail? {
val tracks = getAllTracks(preferLocalMedia = true).filter { track ->
track.releaseId == releaseId
}
if (tracks.isEmpty()) return null
val release = ReleaseCard(
id = releaseId,
title = tracks.first().releaseTitle ?: "Unknown release",
releaseType = null,
year = tracks.firstNotNullOfOrNull { it.releaseYear },
coverUrl = tracks.firstNotNullOfOrNull { it.coverUrl },
trackCount = tracks.size
)
val artists = tracks
.flatMap { it.artistRefs }
.distinctBy { it.id }
.map {
ArtistCard(
id = it.id,
name = it.name,
imageUrl = null,
releaseCount = 1,
trackCount = tracks.count { track -> track.artistRefs.any { artist -> artist.id == it.id } }
)
}
return ReleaseDetail(release = release, artists = artists, tracks = tracks)
}
fun getTrack(trackId: Long, preferLocalMedia: Boolean = true): TrackCard? {
return dbHelper.readableDatabase.query(
TABLE_TRACKS,
null,
"$COL_TRACK_ID = ?",
arrayOf(trackId.toString()),
null,
null,
null,
"1"
).useCursor { cursor ->
if (cursor.moveToFirst()) cursor.toTrackCard(preferLocalMedia) else null
}
}
fun getTracksByIds(trackIds: List<Long>, preferLocalMedia: Boolean = true): List<TrackCard> {
if (trackIds.isEmpty()) return emptyList()
return trackIds.mapNotNull { getTrack(it, preferLocalMedia) }
}
fun markTrackAccessed(trackId: Long) {
val now = System.currentTimeMillis()
dbHelper.writableDatabase.execSQL(
"UPDATE $TABLE_TRACKS SET $COL_LAST_ACCESSED_AT = ?, $COL_PLAY_COUNT = $COL_PLAY_COUNT + 1 WHERE $COL_TRACK_ID = ?",
arrayOf(now, trackId)
)
}
fun markAudioCached(trackId: Long, path: String, sizeBytes: Long) {
updateMediaColumns(
trackId = trackId,
localPathColumn = COL_LOCAL_AUDIO_PATH,
statusColumn = COL_AUDIO_STATUS,
bytesColumn = COL_CACHED_AUDIO_BYTES,
path = path,
sizeBytes = sizeBytes
)
}
fun markCoverCached(trackId: Long, path: String, sizeBytes: Long) {
updateMediaColumns(
trackId = trackId,
localPathColumn = COL_LOCAL_COVER_PATH,
statusColumn = COL_COVER_STATUS,
bytesColumn = COL_CACHED_COVER_BYTES,
path = path,
sizeBytes = sizeBytes
)
}
fun markAudioRemoved(trackId: Long) {
dbHelper.writableDatabase.update(
TABLE_TRACKS,
ContentValues().apply {
putNull(COL_LOCAL_AUDIO_PATH)
put(COL_AUDIO_STATUS, STATUS_NONE)
put(COL_CACHED_AUDIO_BYTES, 0L)
},
"$COL_TRACK_ID = ?",
arrayOf(trackId.toString())
)
}
fun markCoverRemoved(trackId: Long) {
dbHelper.writableDatabase.update(
TABLE_TRACKS,
ContentValues().apply {
putNull(COL_LOCAL_COVER_PATH)
put(COL_COVER_STATUS, STATUS_NONE)
put(COL_CACHED_COVER_BYTES, 0L)
},
"$COL_TRACK_ID = ?",
arrayOf(trackId.toString())
)
}
fun clearCachedMedia() {
dbHelper.writableDatabase.update(
TABLE_TRACKS,
ContentValues().apply {
putNull(COL_LOCAL_AUDIO_PATH)
put(COL_AUDIO_STATUS, STATUS_NONE)
put(COL_CACHED_AUDIO_BYTES, 0L)
putNull(COL_LOCAL_COVER_PATH)
put(COL_COVER_STATUS, STATUS_NONE)
put(COL_CACHED_COVER_BYTES, 0L)
},
null,
null
)
}
fun cachedAudioEntriesForEviction(): List<OfflineCachedMediaEntry> {
return dbHelper.readableDatabase.query(
TABLE_TRACKS,
arrayOf(
COL_TRACK_ID,
COL_LOCAL_AUDIO_PATH,
COL_CACHED_AUDIO_BYTES,
COL_LAST_ACCESSED_AT,
COL_PLAY_COUNT
),
"$COL_AUDIO_STATUS = ? AND $COL_LOCAL_AUDIO_PATH IS NOT NULL",
arrayOf(STATUS_READY),
null,
null,
"$COL_LAST_ACCESSED_AT ASC, $COL_PLAY_COUNT ASC"
).useCursor { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
OfflineCachedMediaEntry(
trackId = cursor.getLong(COL_TRACK_ID),
path = cursor.getString(COL_LOCAL_AUDIO_PATH),
sizeBytes = cursor.getLong(COL_CACHED_AUDIO_BYTES),
lastAccessedAtMs = cursor.getLong(COL_LAST_ACCESSED_AT),
playCount = cursor.getInt(COL_PLAY_COUNT)
)
)
}
}
}
}
fun staleMediaPaths(): List<OfflineStaleMediaPath> {
return dbHelper.readableDatabase.query(
TABLE_TRACKS,
arrayOf(COL_TRACK_ID, COL_LOCAL_AUDIO_PATH, COL_AUDIO_STATUS, COL_LOCAL_COVER_PATH, COL_COVER_STATUS),
"$COL_AUDIO_STATUS = ? OR $COL_COVER_STATUS = ?",
arrayOf(STATUS_STALE, STATUS_STALE),
null,
null,
null
).useCursor { cursor ->
buildList {
while (cursor.moveToNext()) {
if (cursor.getStringOrNull(COL_AUDIO_STATUS) == STATUS_STALE) {
cursor.getStringOrNull(COL_LOCAL_AUDIO_PATH)?.let { path ->
add(OfflineStaleMediaPath(cursor.getLong(COL_TRACK_ID), path, isAudio = true))
}
}
if (cursor.getStringOrNull(COL_COVER_STATUS) == STATUS_STALE) {
cursor.getStringOrNull(COL_LOCAL_COVER_PATH)?.let { path ->
add(OfflineStaleMediaPath(cursor.getLong(COL_TRACK_ID), path, isAudio = false))
}
}
}
}
}
}
fun storageStats(): OfflineStorageStats {
return dbHelper.readableDatabase.rawQuery(
"SELECT COALESCE(SUM($COL_CACHED_AUDIO_BYTES + $COL_CACHED_COVER_BYTES), 0), " +
"COALESCE(SUM(CASE WHEN $COL_AUDIO_STATUS = '$STATUS_READY' THEN 1 ELSE 0 END), 0) " +
"FROM $TABLE_TRACKS",
null
).useCursor { cursor ->
if (cursor.moveToFirst()) {
OfflineStorageStats(
usedBytes = cursor.getLong(0),
cachedTrackCount = cursor.getInt(1)
)
} else {
OfflineStorageStats(0L, 0)
}
}
}
private fun getAllTracks(preferLocalMedia: Boolean): List<TrackCard> {
return dbHelper.readableDatabase.query(
TABLE_TRACKS,
null,
"$COL_TRACK_JSON IS NOT NULL",
null,
null,
null,
"$COL_TITLE_SORT"
).useCursor { cursor ->
buildList {
while (cursor.moveToNext()) cursor.toTrackCard(preferLocalMedia)?.let(::add)
}
}
}
private fun upsertTrackLocked(db: SQLiteDatabase, track: TrackCard) {
val existing = getTrackRecordLocked(db, track.id)
val storedTrack = track.copy(
streamUrl = track.remoteStreamUrl.ifBlank { track.streamUrl },
coverUrl = track.remoteCoverUrl ?: track.coverUrl,
localAudioPath = null,
localCoverPath = null,
isAudioCached = false
)
val values = ContentValues().apply {
put(COL_TRACK_ID, track.id)
put(COL_TITLE, track.title)
put(COL_TITLE_SORT, track.title.lowercase())
put(COL_ARTISTS_SORT, track.artists.joinToString(" ").lowercase())
put(COL_RELEASE_ID, track.releaseId)
put(COL_RELEASE_TITLE, track.releaseTitle)
put(COL_RELEASE_YEAR, track.releaseYear)
put(COL_DURATION_SECONDS, track.durationSeconds)
put(COL_COVER_URL, track.remoteCoverUrl ?: track.coverUrl)
put(COL_REMOTE_COVER_URL, track.remoteCoverUrl ?: track.coverUrl)
put(COL_STREAM_URL, track.remoteStreamUrl.ifBlank { track.streamUrl })
put(COL_REMOTE_STREAM_URL, track.remoteStreamUrl.ifBlank { track.streamUrl })
put(COL_TRACK_JSON, trackAdapter.toJson(storedTrack))
put(COL_UPDATED_AT, track.metadataUpdatedAt)
put(COL_AUDIO_HASH, track.audioHash)
put(COL_AUDIO_SIZE_BYTES, track.audioSizeBytes)
put(COL_AUDIO_MIME_TYPE, track.audioMimeType)
put(COL_AUDIO_UPDATED_AT, track.audioUpdatedAt)
put(COL_COVER_HASH, track.coverHash)
put(COL_COVER_UPDATED_AT, track.coverUpdatedAt)
if (existing == null) {
put(COL_AUDIO_STATUS, STATUS_NONE)
put(COL_COVER_STATUS, STATUS_NONE)
put(COL_CACHED_AUDIO_BYTES, 0L)
put(COL_CACHED_COVER_BYTES, 0L)
put(COL_LAST_ACCESSED_AT, 0L)
put(COL_PLAY_COUNT, 0)
}
}
db.insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, track.id, values)
}
private fun getTrackRecordLocked(db: SQLiteDatabase, trackId: Long): OfflineTrackRecord? {
return db.query(
TABLE_TRACKS,
arrayOf(COL_AUDIO_HASH, COL_COVER_HASH),
"$COL_TRACK_ID = ?",
arrayOf(trackId.toString()),
null,
null,
null,
"1"
).useCursor { cursor ->
if (cursor.moveToFirst()) {
OfflineTrackRecord(
audioHash = cursor.getStringOrNull(COL_AUDIO_HASH),
coverHash = cursor.getStringOrNull(COL_COVER_HASH)
)
} else {
null
}
}
}
private fun updateMediaColumns(
trackId: Long,
localPathColumn: String,
statusColumn: String,
bytesColumn: String,
path: String,
sizeBytes: Long
) {
dbHelper.writableDatabase.update(
TABLE_TRACKS,
ContentValues().apply {
put(localPathColumn, path)
put(statusColumn, STATUS_READY)
put(bytesColumn, sizeBytes.coerceAtLeast(0L))
put(COL_LAST_ACCESSED_AT, System.currentTimeMillis())
},
"$COL_TRACK_ID = ?",
arrayOf(trackId.toString())
)
}
private fun Cursor.toTrackCard(preferLocalMedia: Boolean): TrackCard? {
val json = getStringOrNull(COL_TRACK_JSON) ?: return null
val base = runCatching { trackAdapter.fromJson(json) }.getOrNull() ?: return null
val localAudioPath = getStringOrNull(COL_LOCAL_AUDIO_PATH)
?.takeIf { getStringOrNull(COL_AUDIO_STATUS) == STATUS_READY && File(it).exists() }
val localCoverPath = getStringOrNull(COL_LOCAL_COVER_PATH)
?.takeIf { getStringOrNull(COL_COVER_STATUS) == STATUS_READY && File(it).exists() }
val remoteStreamUrl = getStringOrNull(COL_REMOTE_STREAM_URL)
?: base.remoteStreamUrl.ifBlank { base.streamUrl }
val remoteCoverUrl = getStringOrNull(COL_REMOTE_COVER_URL) ?: base.remoteCoverUrl
return base.copy(
streamUrl = if (preferLocalMedia && localAudioPath != null) {
Uri.fromFile(File(localAudioPath)).toString()
} else {
remoteStreamUrl
},
coverUrl = if (preferLocalMedia && localCoverPath != null) {
Uri.fromFile(File(localCoverPath)).toString()
} else {
remoteCoverUrl
},
remoteStreamUrl = remoteStreamUrl,
remoteCoverUrl = remoteCoverUrl,
metadataUpdatedAt = getStringOrNull(COL_UPDATED_AT) ?: base.metadataUpdatedAt,
audioHash = getStringOrNull(COL_AUDIO_HASH) ?: base.audioHash,
audioSizeBytes = getLongOrNull(COL_AUDIO_SIZE_BYTES) ?: base.audioSizeBytes,
audioMimeType = getStringOrNull(COL_AUDIO_MIME_TYPE) ?: base.audioMimeType,
audioUpdatedAt = getStringOrNull(COL_AUDIO_UPDATED_AT) ?: base.audioUpdatedAt,
coverHash = getStringOrNull(COL_COVER_HASH) ?: base.coverHash,
coverUpdatedAt = getStringOrNull(COL_COVER_UPDATED_AT) ?: base.coverUpdatedAt,
localAudioPath = localAudioPath,
localCoverPath = localCoverPath,
isAudioCached = localAudioPath != null
)
}
private fun Cursor.toPlaylistCard(trackCount: Int): PlaylistCard {
return PlaylistCard(
id = getLong(COL_PLAYLIST_ID),
title = getString(COL_PLAYLIST_TITLE),
trackCount = trackCount,
isPublic = getInt(COL_PLAYLIST_IS_PUBLIC) == 1,
isOwn = getInt(COL_PLAYLIST_IS_OWN) == 1,
ownerName = getStringOrNull(COL_PLAYLIST_OWNER_NAME),
isSaved = getInt(COL_PLAYLIST_IS_SAVED) == 1,
kind = getStringOrNull(COL_PLAYLIST_KIND)
)
}
private class OfflineDbHelper(context: Context) : SQLiteOpenHelper(
context,
DB_NAME,
null,
DB_VERSION
) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE $TABLE_TRACKS (
$COL_TRACK_ID INTEGER PRIMARY KEY,
$COL_TITLE TEXT,
$COL_TITLE_SORT TEXT,
$COL_ARTISTS_SORT TEXT,
$COL_RELEASE_ID INTEGER,
$COL_RELEASE_TITLE TEXT,
$COL_RELEASE_YEAR INTEGER,
$COL_DURATION_SECONDS INTEGER,
$COL_COVER_URL TEXT,
$COL_REMOTE_COVER_URL TEXT,
$COL_STREAM_URL TEXT,
$COL_REMOTE_STREAM_URL TEXT,
$COL_TRACK_JSON TEXT,
$COL_UPDATED_AT TEXT,
$COL_AUDIO_HASH TEXT,
$COL_AUDIO_SIZE_BYTES INTEGER,
$COL_AUDIO_MIME_TYPE TEXT,
$COL_AUDIO_UPDATED_AT TEXT,
$COL_COVER_HASH TEXT,
$COL_COVER_UPDATED_AT TEXT,
$COL_LOCAL_AUDIO_PATH TEXT,
$COL_AUDIO_STATUS TEXT NOT NULL DEFAULT '$STATUS_NONE',
$COL_LOCAL_COVER_PATH TEXT,
$COL_COVER_STATUS TEXT NOT NULL DEFAULT '$STATUS_NONE',
$COL_CACHED_AUDIO_BYTES INTEGER NOT NULL DEFAULT 0,
$COL_CACHED_COVER_BYTES INTEGER NOT NULL DEFAULT 0,
$COL_LAST_ACCESSED_AT INTEGER NOT NULL DEFAULT 0,
$COL_PLAY_COUNT INTEGER NOT NULL DEFAULT 0
)
""".trimIndent()
)
db.execSQL("CREATE INDEX idx_offline_tracks_title ON $TABLE_TRACKS($COL_TITLE_SORT)")
db.execSQL("CREATE INDEX idx_offline_tracks_release ON $TABLE_TRACKS($COL_RELEASE_ID)")
db.execSQL("CREATE INDEX idx_offline_tracks_audio_status ON $TABLE_TRACKS($COL_AUDIO_STATUS)")
db.execSQL(
"""
CREATE TABLE $TABLE_PLAYLISTS (
$COL_PLAYLIST_ID INTEGER PRIMARY KEY,
$COL_PLAYLIST_TITLE TEXT NOT NULL,
$COL_PLAYLIST_DESCRIPTION TEXT,
$COL_PLAYLIST_UPDATED_AT TEXT,
$COL_PLAYLIST_IS_OWN INTEGER NOT NULL DEFAULT 0,
$COL_PLAYLIST_OWNER_NAME TEXT,
$COL_PLAYLIST_IS_PUBLIC INTEGER NOT NULL DEFAULT 0,
$COL_PLAYLIST_IS_SAVED INTEGER NOT NULL DEFAULT 0,
$COL_PLAYLIST_KIND TEXT,
$COL_PLAYLIST_TRACK_IDS TEXT NOT NULL DEFAULT '[]'
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE $TABLE_LIKES (
$COL_LIKE_TRACK_ID INTEGER PRIMARY KEY
)
""".trimIndent()
)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS $TABLE_LIKES")
db.execSQL("DROP TABLE IF EXISTS $TABLE_PLAYLISTS")
db.execSQL("DROP TABLE IF EXISTS $TABLE_TRACKS")
onCreate(db)
}
}
private companion object {
const val DB_NAME = "furumi_offline.db"
const val DB_VERSION = 1
const val TABLE_TRACKS = "offline_tracks"
const val TABLE_PLAYLISTS = "offline_playlists"
const val TABLE_LIKES = "offline_likes"
const val COL_TRACK_ID = "track_id"
const val COL_TITLE = "title"
const val COL_TITLE_SORT = "title_sort"
const val COL_ARTISTS_SORT = "artists_sort"
const val COL_RELEASE_ID = "release_id"
const val COL_RELEASE_TITLE = "release_title"
const val COL_RELEASE_YEAR = "release_year"
const val COL_DURATION_SECONDS = "duration_seconds"
const val COL_COVER_URL = "cover_url"
const val COL_REMOTE_COVER_URL = "remote_cover_url"
const val COL_STREAM_URL = "stream_url"
const val COL_REMOTE_STREAM_URL = "remote_stream_url"
const val COL_TRACK_JSON = "track_json"
const val COL_UPDATED_AT = "updated_at"
const val COL_AUDIO_HASH = "audio_hash"
const val COL_AUDIO_SIZE_BYTES = "audio_size_bytes"
const val COL_AUDIO_MIME_TYPE = "audio_mime_type"
const val COL_AUDIO_UPDATED_AT = "audio_updated_at"
const val COL_COVER_HASH = "cover_hash"
const val COL_COVER_UPDATED_AT = "cover_updated_at"
const val COL_LOCAL_AUDIO_PATH = "local_audio_path"
const val COL_AUDIO_STATUS = "audio_status"
const val COL_LOCAL_COVER_PATH = "local_cover_path"
const val COL_COVER_STATUS = "cover_status"
const val COL_CACHED_AUDIO_BYTES = "cached_audio_bytes"
const val COL_CACHED_COVER_BYTES = "cached_cover_bytes"
const val COL_LAST_ACCESSED_AT = "last_accessed_at"
const val COL_PLAY_COUNT = "play_count"
const val COL_PLAYLIST_ID = "playlist_id"
const val COL_PLAYLIST_TITLE = "title"
const val COL_PLAYLIST_DESCRIPTION = "description"
const val COL_PLAYLIST_UPDATED_AT = "updated_at"
const val COL_PLAYLIST_IS_OWN = "is_own"
const val COL_PLAYLIST_OWNER_NAME = "owner_name"
const val COL_PLAYLIST_IS_PUBLIC = "is_public"
const val COL_PLAYLIST_IS_SAVED = "is_saved"
const val COL_PLAYLIST_KIND = "kind"
const val COL_PLAYLIST_TRACK_IDS = "track_ids_json"
const val COL_LIKE_TRACK_ID = "track_id"
const val STATUS_NONE = "none"
const val STATUS_READY = "ready"
const val STATUS_STALE = "stale"
}
}
data class OfflineCachedMediaEntry(
val trackId: Long,
val path: String,
val sizeBytes: Long,
val lastAccessedAtMs: Long,
val playCount: Int
)
data class OfflineStaleMediaPath(
val trackId: Long,
val path: String,
val isAudio: Boolean
)
data class OfflineStorageStats(
val usedBytes: Long,
val cachedTrackCount: Int
)
private data class OfflineTrackRecord(
val audioHash: String?,
val coverHash: String?
)
private data class ArtistSeed(
val artistName: String,
val artistId: Long = -artistName.hashCode().toLong()
) {
constructor(ref: cy.hexor.furumi.domain.model.ArtistRef) : this(ref.name, ref.id)
val artistIdOrName: String
get() = "$artistId:$artistName"
}
private inline fun <T> SQLiteDatabase.transaction(block: SQLiteDatabase.() -> T): T {
beginTransaction()
return try {
val result = block()
setTransactionSuccessful()
result
} finally {
endTransaction()
}
}
private fun SQLiteDatabase.insertOrUpdate(
table: String,
keyColumn: String,
key: Long,
values: ContentValues
) {
val updated = update(table, values, "$keyColumn = ?", arrayOf(key.toString()))
if (updated == 0) {
insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE)
}
}
private fun List<Long>.toJsonArrayString(): String {
val array = JSONArray()
forEach(array::put)
return array.toString()
}
private fun String?.longListFromJson(): List<Long> {
if (isNullOrBlank()) return emptyList()
return runCatching {
val array = JSONArray(this)
List(array.length()) { index -> array.optLong(index) }
}.getOrDefault(emptyList())
}
private fun Boolean.toInt(): Int = if (this) 1 else 0
private fun Cursor.getColumnIndexOrThrowCached(name: String): Int = getColumnIndexOrThrow(name)
private fun Cursor.getString(name: String): String = getString(getColumnIndexOrThrowCached(name))
private fun Cursor.getStringOrNull(name: String): String? {
val index = getColumnIndexOrThrowCached(name)
return if (isNull(index)) null else getString(index)
}
private fun Cursor.getLong(name: String): Long = getLong(getColumnIndexOrThrowCached(name))
private fun Cursor.getLongOrNull(name: String): Long? {
val index = getColumnIndexOrThrowCached(name)
return if (isNull(index)) null else getLong(index)
}
private fun Cursor.getInt(name: String): Int = getInt(getColumnIndexOrThrowCached(name))
private inline fun <T> Cursor.useCursor(block: (Cursor) -> T): T = use(block)
@@ -0,0 +1,163 @@
package cy.hexor.furumi.data.local
import android.content.Context
import cy.hexor.furumi.domain.model.TrackCard
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
@Singleton
class OfflineMediaStore @Inject constructor(
@ApplicationContext context: Context,
private val okHttpClient: OkHttpClient,
private val libraryStore: OfflineLibraryStore,
private val settingsStorage: OfflineSettingsStorage
) {
private val rootDir = File(context.filesDir, "offline")
private val audioDir = File(rootDir, "audio")
private val coversDir = File(rootDir, "covers")
suspend fun cacheTrackAfterListening(trackId: Long) = withContext(Dispatchers.IO) {
if (!settingsStorage.state.value.settings.saveListenedTracksEnabled) {
libraryStore.markTrackAccessed(trackId)
publishStorageStats()
return@withContext
}
val track = libraryStore.getTrack(trackId, preferLocalMedia = false)
?: return@withContext
cacheTrackMedia(track)
}
suspend fun cacheTrackMedia(track: TrackCard) = withContext(Dispatchers.IO) {
libraryStore.upsertTracks(listOf(track))
libraryStore.markTrackAccessed(track.id)
pruneStaleMedia()
val storedTrack = libraryStore.getTrack(track.id, preferLocalMedia = false) ?: track
val audioPath = storedTrack.localAudioPath
if (audioPath.isNullOrBlank() || !File(audioPath).exists()) {
downloadAudio(storedTrack)
}
val coverPath = storedTrack.localCoverPath
if ((coverPath.isNullOrBlank() || !File(coverPath).exists()) &&
!storedTrack.remoteCoverUrl.isNullOrBlank()
) {
downloadCover(storedTrack)
}
enforceStorageLimit()
publishStorageStats()
}
suspend fun clearCachedMedia() = withContext(Dispatchers.IO) {
rootDir.deleteRecursively()
libraryStore.clearCachedMedia()
publishStorageStats()
}
suspend fun pruneStaleMedia() = withContext(Dispatchers.IO) {
libraryStore.staleMediaPaths().forEach { stale ->
File(stale.path).delete()
if (stale.isAudio) {
libraryStore.markAudioRemoved(stale.trackId)
} else {
libraryStore.markCoverRemoved(stale.trackId)
}
}
}
fun publishStorageStats() {
val stats = libraryStore.storageStats()
settingsStorage.setStorageStats(stats.usedBytes, stats.cachedTrackCount)
}
private fun downloadAudio(track: TrackCard) {
val sourceUrl = track.remoteStreamUrl.ifBlank { track.streamUrl }
if (sourceUrl.isBlank()) return
val extension = extensionForMime(track.audioMimeType)
?: extensionFromUrl(sourceUrl)
?: "audio"
val target = File(audioDir, "${track.id}-${track.audioHash ?: track.audioUpdatedAt ?: "audio"}.$extension")
val bytes = downloadToFile(sourceUrl, target)
libraryStore.markAudioCached(track.id, target.absolutePath, bytes)
}
private fun downloadCover(track: TrackCard) {
val sourceUrl = track.remoteCoverUrl ?: track.coverUrl ?: return
if (sourceUrl.isBlank()) return
val extension = extensionFromUrl(sourceUrl) ?: "jpg"
val target = File(coversDir, "${track.id}-${track.coverHash ?: track.coverUpdatedAt ?: "cover"}.$extension")
val bytes = downloadToFile(sourceUrl, target)
libraryStore.markCoverCached(track.id, target.absolutePath, bytes)
}
private fun downloadToFile(url: String, target: File): Long {
target.parentFile?.mkdirs()
val temp = File(target.parentFile, "${target.name}.tmp")
temp.delete()
val request = Request.Builder()
.url(url)
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Offline download failed with HTTP ${response.code}")
}
val body = response.body ?: throw IOException("Offline download response is empty")
temp.outputStream().use { output ->
body.byteStream().use { input ->
input.copyTo(output)
}
}
}
if (!temp.renameTo(target)) {
temp.copyTo(target, overwrite = true)
temp.delete()
}
return target.length()
}
private fun enforceStorageLimit() {
val limitBytes = settingsStorage.state.value.settings.storageLimitBytes
var usedBytes = libraryStore.storageStats().usedBytes
if (usedBytes <= limitBytes) return
for (entry in libraryStore.cachedAudioEntriesForEviction()) {
if (usedBytes <= limitBytes) break
File(entry.path).delete()
libraryStore.markAudioRemoved(entry.trackId)
usedBytes -= entry.sizeBytes
}
}
private fun extensionForMime(mimeType: String?): String? {
return when (mimeType?.lowercase()) {
"audio/mpeg", "audio/mp3" -> "mp3"
"audio/flac" -> "flac"
"audio/ogg", "audio/opus" -> "ogg"
"audio/mp4", "audio/aac" -> "m4a"
"audio/wav", "audio/x-wav" -> "wav"
else -> null
}
}
private fun extensionFromUrl(url: String): String? {
val cleanUrl = url.substringBefore('?').substringBefore('#')
return cleanUrl.substringAfterLast('/', "")
.substringAfterLast('.', "")
.takeIf { it.length in 2..5 && it.all(Char::isLetterOrDigit) }
?.lowercase()
}
}
@@ -0,0 +1,91 @@
package cy.hexor.furumi.data.local
import android.content.Context
import cy.hexor.furumi.domain.model.OfflineSettings
import cy.hexor.furumi.domain.model.OfflineState
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OfflineSettingsStorage @Inject constructor(
@ApplicationContext context: Context
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val _state = MutableStateFlow(loadState())
val state: StateFlow<OfflineState> = _state.asStateFlow()
fun setOfflineModeEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_OFFLINE_MODE, enabled).apply()
updateSettings { it.copy(offlineModeEnabled = enabled) }
}
fun setSaveListenedTracksEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_SAVE_LISTENED, enabled).apply()
updateSettings { it.copy(saveListenedTracksEnabled = enabled) }
}
fun setStorageLimitBytes(limitBytes: Long) {
val safeLimit = limitBytes.coerceAtLeast(MIN_STORAGE_LIMIT_BYTES)
prefs.edit().putLong(KEY_STORAGE_LIMIT_BYTES, safeLimit).apply()
updateSettings { it.copy(storageLimitBytes = safeLimit) }
}
fun setSyncing(syncing: Boolean) {
_state.value = _state.value.copy(isSyncing = syncing)
}
fun setLastSyncedAt(timestampMs: Long) {
prefs.edit().putLong(KEY_LAST_SYNCED_AT_MS, timestampMs).apply()
_state.value = _state.value.copy(lastSyncedAtMs = timestampMs, lastError = null)
}
fun setStorageStats(usedBytes: Long, cachedTrackCount: Int) {
_state.value = _state.value.copy(
storageUsedBytes = usedBytes.coerceAtLeast(0L),
cachedTrackCount = cachedTrackCount.coerceAtLeast(0)
)
}
fun setNetworkFallback(active: Boolean, errorMessage: String? = null) {
_state.value = _state.value.copy(
isUsingNetworkFallback = active,
lastError = errorMessage ?: _state.value.lastError
)
}
fun setLastError(message: String?) {
_state.value = _state.value.copy(lastError = message)
}
private fun updateSettings(block: (OfflineSettings) -> OfflineSettings) {
_state.value = _state.value.copy(settings = block(_state.value.settings))
}
private fun loadState(): OfflineState {
return OfflineState(
settings = OfflineSettings(
offlineModeEnabled = prefs.getBoolean(KEY_OFFLINE_MODE, false),
saveListenedTracksEnabled = prefs.getBoolean(KEY_SAVE_LISTENED, false),
storageLimitBytes = prefs.getLong(
KEY_STORAGE_LIMIT_BYTES,
OfflineSettings.DEFAULT_STORAGE_LIMIT_BYTES
)
),
lastSyncedAtMs = prefs.getLong(KEY_LAST_SYNCED_AT_MS, 0L)
)
}
private companion object {
const val PREFS_NAME = "offline_settings"
const val KEY_OFFLINE_MODE = "offline_mode"
const val KEY_SAVE_LISTENED = "save_listened"
const val KEY_STORAGE_LIMIT_BYTES = "storage_limit_bytes"
const val KEY_LAST_SYNCED_AT_MS = "last_synced_at_ms"
const val MIN_STORAGE_LIMIT_BYTES = 256L * 1024L * 1024L
}
}
@@ -23,6 +23,10 @@ class PlayerEndpoints @Inject constructor() {
fun playlistDetail(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId" fun playlistDetail(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId"
fun offlineManifest(baseUrl: String): String = "$baseUrl$OFFLINE_MANIFEST_PATH"
fun tracksByIds(baseUrl: String): String = "$baseUrl$TRACKS_BY_IDS_PATH"
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"
@@ -53,6 +57,8 @@ class PlayerEndpoints @Inject constructor() {
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 OFFLINE_MANIFEST_PATH = "/api/player/offline/manifest"
private const val TRACKS_BY_IDS_PATH = "/api/player/tracks-by-ids"
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 SHARE_TRACK_PATH = "/share/track"
private const val DEVICES_POLL_PATH = "/api/player/devices/poll" private const val DEVICES_POLL_PATH = "/api/player/devices/poll"
@@ -5,6 +5,7 @@ import cy.hexor.furumi.data.remote.model.ArtistDetailResponse
import cy.hexor.furumi.data.remote.model.PlayHistoryPageResponse import cy.hexor.furumi.data.remote.model.PlayHistoryPageResponse
import cy.hexor.furumi.data.remote.model.ReleaseDetailResponse import cy.hexor.furumi.data.remote.model.ReleaseDetailResponse
import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest
import cy.hexor.furumi.data.remote.model.CreatePlaylistRequest
import cy.hexor.furumi.data.remote.model.DeviceActiveRequest import cy.hexor.furumi.data.remote.model.DeviceActiveRequest
import cy.hexor.furumi.data.remote.model.DeviceCommandRequest import cy.hexor.furumi.data.remote.model.DeviceCommandRequest
import cy.hexor.furumi.data.remote.model.DevicePollRequest import cy.hexor.furumi.data.remote.model.DevicePollRequest
@@ -15,14 +16,18 @@ import cy.hexor.furumi.data.remote.model.JamJoinRequest
import cy.hexor.furumi.data.remote.model.JamUserResponse import cy.hexor.furumi.data.remote.model.JamUserResponse
import cy.hexor.furumi.data.remote.model.LikeToggleResponse import cy.hexor.furumi.data.remote.model.LikeToggleResponse
import cy.hexor.furumi.data.remote.model.LikedTrackIdsResponse import cy.hexor.furumi.data.remote.model.LikedTrackIdsResponse
import cy.hexor.furumi.data.remote.model.OfflineManifestResponse
import cy.hexor.furumi.data.remote.model.PlaylistCardResponse import cy.hexor.furumi.data.remote.model.PlaylistCardResponse
import cy.hexor.furumi.data.remote.model.PlaylistDetailResponse import cy.hexor.furumi.data.remote.model.PlaylistDetailResponse
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
import cy.hexor.furumi.data.remote.model.SearchResponse import cy.hexor.furumi.data.remote.model.SearchResponse
import cy.hexor.furumi.data.remote.model.SharePlaylistRequest import cy.hexor.furumi.data.remote.model.SharePlaylistRequest
import cy.hexor.furumi.data.remote.model.SharePlaylistResponse import cy.hexor.furumi.data.remote.model.SharePlaylistResponse
import cy.hexor.furumi.data.remote.model.TrackItemResponse
import cy.hexor.furumi.data.remote.model.TracksByIdsRequest
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Headers import retrofit2.http.Headers
import retrofit2.http.POST import retrofit2.http.POST
@@ -92,12 +97,38 @@ interface PlayerApi {
@Url url: String @Url url: String
): Response<List<PlaylistCardResponse>> ): Response<List<PlaylistCardResponse>>
@Headers("Accept: application/json")
@GET
suspend fun offlineManifest(
@Url url: String
): Response<OfflineManifestResponse>
@Headers("Accept: application/json", "Content-Type: application/json")
@POST
suspend fun tracksByIds(
@Url url: String,
@Body body: TracksByIdsRequest
): Response<List<TrackItemResponse>>
@Headers("Accept: application/json") @Headers("Accept: application/json")
@GET @GET
suspend fun playlistDetail( suspend fun playlistDetail(
@Url url: String @Url url: String
): Response<PlaylistDetailResponse> ): Response<PlaylistDetailResponse>
@Headers("Accept: application/json", "Content-Type: application/json")
@POST
suspend fun createPlaylist(
@Url url: String,
@Body body: CreatePlaylistRequest
): Response<PlaylistCardResponse>
@Headers("Accept: application/json")
@DELETE
suspend fun deletePlaylist(
@Url url: String
): Response<Unit>
@Headers("Accept: application/json", "Content-Type: application/json") @Headers("Accept: application/json", "Content-Type: application/json")
@POST @POST
suspend fun addTracksToPlaylist( suspend fun addTracksToPlaylist(
@@ -27,11 +27,53 @@ data class TrackItemResponse(
@param:Json(name = "duration_seconds") val durationSeconds: Double? = null, @param:Json(name = "duration_seconds") val durationSeconds: Double? = null,
@param:Json(name = "artists") val artists: List<ArtistRefResponse> = emptyList(), @param:Json(name = "artists") val artists: List<ArtistRefResponse> = emptyList(),
@param:Json(name = "featured_artists") val featuredArtists: List<ArtistRefResponse> = emptyList(), @param:Json(name = "featured_artists") val featuredArtists: List<ArtistRefResponse> = emptyList(),
@param:Json(name = "release_id") val releaseId: Long? = null,
@param:Json(name = "release_title") val releaseTitle: String? = null, @param:Json(name = "release_title") val releaseTitle: String? = null,
@param:Json(name = "release_year") val releaseYear: Int? = null,
@param:Json(name = "cover_url") val coverUrl: String? = null, @param:Json(name = "cover_url") val coverUrl: String? = null,
@param:Json(name = "stream_url") val streamUrl: String? = null @param:Json(name = "stream_url") val streamUrl: String? = null
) )
data class TracksByIdsRequest(
@param:Json(name = "ids") val ids: List<Long>
)
data class OfflineManifestResponse(
@param:Json(name = "generated_at") val generatedAt: String,
@param:Json(name = "tracks") val tracks: List<OfflineManifestTrackResponse> = emptyList(),
@param:Json(name = "playlists") val playlists: List<OfflineManifestPlaylistResponse> = emptyList(),
@param:Json(name = "liked_track_ids") val likedTrackIds: List<Long> = emptyList(),
@param:Json(name = "followed_artist_ids") val followedArtistIds: List<Long> = emptyList()
)
data class OfflineManifestTrackResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "updated_at") val updatedAt: String? = null,
@param:Json(name = "stream_url") val streamUrl: String,
@param:Json(name = "audio_file_id") val audioFileId: Long? = null,
@param:Json(name = "audio_hash") val audioHash: String? = null,
@param:Json(name = "audio_size_bytes") val audioSizeBytes: Long? = null,
@param:Json(name = "audio_mime_type") val audioMimeType: String? = null,
@param:Json(name = "audio_updated_at") val audioUpdatedAt: String? = null,
@param:Json(name = "cover_file_id") val coverFileId: Long? = null,
@param:Json(name = "cover_url") val coverUrl: String? = null,
@param:Json(name = "cover_hash") val coverHash: String? = null,
@param:Json(name = "cover_updated_at") val coverUpdatedAt: String? = null
)
data class OfflineManifestPlaylistResponse(
@param:Json(name = "id") val id: Long,
@param:Json(name = "title") val title: String,
@param:Json(name = "description") val description: String? = null,
@param:Json(name = "updated_at") val updatedAt: String? = null,
@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,
@param:Json(name = "track_ids") val trackIds: List<Long> = emptyList()
)
data class PlayHistoryItemResponse( data class PlayHistoryItemResponse(
@param:Json(name = "id") val id: Long, @param:Json(name = "id") val id: Long,
@param:Json(name = "track_id") val trackId: Long, @param:Json(name = "track_id") val trackId: Long,
@@ -128,6 +170,10 @@ data class PlaylistListResponse(
@param:Json(name = "playlists") val playlists: List<PlaylistCardResponse> @param:Json(name = "playlists") val playlists: List<PlaylistCardResponse>
) )
data class CreatePlaylistRequest(
@param:Json(name = "title") val title: String
)
data class AddPlaylistTracksRequest( data class AddPlaylistTracksRequest(
@param:Json(name = "track_ids") val trackIds: List<Long> @param:Json(name = "track_ids") val trackIds: List<Long>
) )
@@ -292,6 +338,9 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
.ifEmpty { mappedFeaturedArtists } .ifEmpty { mappedFeaturedArtists }
.map { it.name } .map { it.name }
val absoluteCoverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl)
val absoluteStreamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
return TrackCard( return TrackCard(
id = id ?: 0L, id = id ?: 0L,
title = title, title = title,
@@ -301,9 +350,13 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
artists = artistNames, artists = artistNames,
artistRefs = mappedArtists, artistRefs = mappedArtists,
featuredArtistRefs = mappedFeaturedArtists, featuredArtistRefs = mappedFeaturedArtists,
releaseId = releaseId,
releaseYear = releaseYear,
releaseTitle = releaseTitle, releaseTitle = releaseTitle,
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl), coverUrl = absoluteCoverUrl,
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty() streamUrl = absoluteStreamUrl,
remoteCoverUrl = absoluteCoverUrl,
remoteStreamUrl = absoluteStreamUrl
) )
} }
@@ -322,9 +375,34 @@ fun TrackCard.toTrackItemResponse(): TrackItemResponse {
durationSeconds = durationSeconds?.toDouble() ?: 0.0, durationSeconds = durationSeconds?.toDouble() ?: 0.0,
artists = primaryArtists.map { it.toResponse() }, artists = primaryArtists.map { it.toResponse() },
featuredArtists = featuredArtistRefs.map { it.toResponse() }, featuredArtists = featuredArtistRefs.map { it.toResponse() },
releaseId = releaseId,
releaseTitle = releaseTitle, releaseTitle = releaseTitle,
coverUrl = coverUrl, releaseYear = releaseYear,
streamUrl = streamUrl coverUrl = remoteCoverUrl ?: coverUrl,
streamUrl = remoteStreamUrl.ifBlank { streamUrl }
)
}
fun OfflineManifestTrackResponse.withAbsoluteUrls(baseUrl: String): OfflineManifestTrackResponse {
return copy(
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty(),
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl)
)
}
fun TrackCard.withOfflineManifest(manifest: OfflineManifestTrackResponse): TrackCard {
return copy(
coverUrl = manifest.coverUrl ?: coverUrl,
streamUrl = manifest.streamUrl.ifBlank { streamUrl },
remoteCoverUrl = manifest.coverUrl ?: remoteCoverUrl,
remoteStreamUrl = manifest.streamUrl.ifBlank { remoteStreamUrl },
metadataUpdatedAt = manifest.updatedAt ?: metadataUpdatedAt,
audioHash = manifest.audioHash ?: audioHash,
audioSizeBytes = manifest.audioSizeBytes ?: audioSizeBytes,
audioMimeType = manifest.audioMimeType ?: audioMimeType,
audioUpdatedAt = manifest.audioUpdatedAt ?: audioUpdatedAt,
coverHash = manifest.coverHash ?: coverHash,
coverUpdatedAt = manifest.coverUpdatedAt ?: coverUpdatedAt
) )
} }
@@ -381,7 +459,7 @@ fun PlaylistDetailResponse.toDomain(baseUrl: String): PlaylistDetail {
) )
} }
private fun PlaylistCardResponse.toDomain(): PlaylistCard { fun PlaylistCardResponse.toDomain(): PlaylistCard {
return PlaylistCard( return PlaylistCard(
id = id, id = id,
title = title, title = title,
@@ -2,6 +2,8 @@ package cy.hexor.furumi.data.repository
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import java.io.File
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -17,6 +19,17 @@ class MediaImageLoader @Inject constructor(
suspend fun load(url: String): Result<Bitmap> { suspend fun load(url: String): Result<Bitmap> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
runCatching { runCatching {
if (url.startsWith("file://", ignoreCase = true)) {
val path = Uri.parse(url).path ?: throw IOException("Image file path is empty")
return@runCatching BitmapFactory.decodeFile(path)
?: throw IOException("Image file is not a supported bitmap")
}
if (url.startsWith("/", ignoreCase = false)) {
return@runCatching BitmapFactory.decodeFile(File(url).absolutePath)
?: throw IOException("Image file is not a supported bitmap")
}
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.build() .build()
@@ -2,12 +2,16 @@ package cy.hexor.furumi.data.repository
import cy.hexor.furumi.data.local.AuthSessionStorage import cy.hexor.furumi.data.local.AuthSessionStorage
import cy.hexor.furumi.data.local.ConnectedDeviceStorage import cy.hexor.furumi.data.local.ConnectedDeviceStorage
import cy.hexor.furumi.data.local.OfflineLibraryStore
import cy.hexor.furumi.data.local.OfflineMediaStore
import cy.hexor.furumi.data.local.OfflineSettingsStorage
import cy.hexor.furumi.data.remote.AppClientInfo import cy.hexor.furumi.data.remote.AppClientInfo
import cy.hexor.furumi.data.remote.AuthApiErrorParser import cy.hexor.furumi.data.remote.AuthApiErrorParser
import cy.hexor.furumi.data.remote.PlayerEndpoints import cy.hexor.furumi.data.remote.PlayerEndpoints
import cy.hexor.furumi.data.remote.api.PlayerApi import cy.hexor.furumi.data.remote.api.PlayerApi
import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest import cy.hexor.furumi.data.remote.model.AddPlaylistTracksRequest
import cy.hexor.furumi.data.remote.model.ConnectedCommandPayloadBody import cy.hexor.furumi.data.remote.model.ConnectedCommandPayloadBody
import cy.hexor.furumi.data.remote.model.CreatePlaylistRequest
import cy.hexor.furumi.data.remote.model.DeviceActiveRequest import cy.hexor.furumi.data.remote.model.DeviceActiveRequest
import cy.hexor.furumi.data.remote.model.DeviceCommandRequest import cy.hexor.furumi.data.remote.model.DeviceCommandRequest
import cy.hexor.furumi.data.remote.model.DevicePollRequest import cy.hexor.furumi.data.remote.model.DevicePollRequest
@@ -15,10 +19,13 @@ import cy.hexor.furumi.data.remote.model.JamCreateRequest
import cy.hexor.furumi.data.remote.model.JamInviteRequest import cy.hexor.furumi.data.remote.model.JamInviteRequest
import cy.hexor.furumi.data.remote.model.JamJoinRequest import cy.hexor.furumi.data.remote.model.JamJoinRequest
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
import cy.hexor.furumi.data.remote.model.TracksByIdsRequest
import cy.hexor.furumi.data.remote.model.toDomain import cy.hexor.furumi.data.remote.model.toDomain
import cy.hexor.furumi.data.remote.model.toBody import cy.hexor.furumi.data.remote.model.toBody
import cy.hexor.furumi.data.remote.model.toCommandPayloadBody import cy.hexor.furumi.data.remote.model.toCommandPayloadBody
import cy.hexor.furumi.data.remote.model.toTrackItemResponse import cy.hexor.furumi.data.remote.model.toTrackItemResponse
import cy.hexor.furumi.data.remote.model.withAbsoluteUrls
import cy.hexor.furumi.data.remote.model.withOfflineManifest
import cy.hexor.furumi.domain.model.ArtistDetail import cy.hexor.furumi.domain.model.ArtistDetail
import cy.hexor.furumi.domain.model.ArtistPage import cy.hexor.furumi.domain.model.ArtistPage
import cy.hexor.furumi.domain.model.AuthException import cy.hexor.furumi.domain.model.AuthException
@@ -27,6 +34,8 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
import cy.hexor.furumi.domain.model.ConnectedJamUser import cy.hexor.furumi.domain.model.ConnectedJamUser
import cy.hexor.furumi.domain.model.ConnectedPlaybackState import cy.hexor.furumi.domain.model.ConnectedPlaybackState
import cy.hexor.furumi.domain.model.ListeningHistoryPage import cy.hexor.furumi.domain.model.ListeningHistoryPage
import cy.hexor.furumi.domain.model.OfflineState
import cy.hexor.furumi.domain.model.OfflineSyncSummary
import cy.hexor.furumi.domain.model.PlaylistCard import cy.hexor.furumi.domain.model.PlaylistCard
import cy.hexor.furumi.domain.model.PlaylistDetail import cy.hexor.furumi.domain.model.PlaylistDetail
import cy.hexor.furumi.domain.model.ReleaseDetail import cy.hexor.furumi.domain.model.ReleaseDetail
@@ -34,18 +43,113 @@ import cy.hexor.furumi.domain.model.SearchResults
import cy.hexor.furumi.domain.model.TrackCard import cy.hexor.furumi.domain.model.TrackCard
import cy.hexor.furumi.domain.repository.PlayerRepository import cy.hexor.furumi.domain.repository.PlayerRepository
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject 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 connectedDeviceStorage: ConnectedDeviceStorage,
private val offlineSettingsStorage: OfflineSettingsStorage,
private val offlineLibraryStore: OfflineLibraryStore,
private val offlineMediaStore: OfflineMediaStore,
private val appClientInfo: AppClientInfo, private val appClientInfo: AppClientInfo,
private val playerEndpoints: PlayerEndpoints, private val playerEndpoints: PlayerEndpoints,
private val errorParser: AuthApiErrorParser private val errorParser: AuthApiErrorParser
) : PlayerRepository { ) : PlayerRepository {
override val offlineState: StateFlow<OfflineState> = offlineSettingsStorage.state
override fun setOfflineModeEnabled(enabled: Boolean) {
offlineSettingsStorage.setOfflineModeEnabled(enabled)
if (!enabled) {
offlineSettingsStorage.setNetworkFallback(false)
}
}
override fun setSaveListenedTracksEnabled(enabled: Boolean) {
offlineSettingsStorage.setSaveListenedTracksEnabled(enabled)
}
override fun setOfflineStorageLimit(limitBytes: Long) {
offlineSettingsStorage.setStorageLimitBytes(limitBytes)
}
override suspend fun clearOfflineCache(): Result<Unit> {
return try {
offlineMediaStore.clearCachedMedia()
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary> {
offlineSettingsStorage.setSyncing(true)
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val manifestResponse = playerApi.offlineManifest(playerEndpoints.offlineManifest(baseUrl))
if (!manifestResponse.isSuccessful) {
throw AuthException(errorParser.messageFrom(manifestResponse))
}
val manifest = manifestResponse.body()
?: throw AuthException("Offline manifest response is empty")
val manifestTracks = manifest.tracks.map { it.withAbsoluteUrls(baseUrl) }
val manifestById = manifestTracks.associateBy { it.id }
offlineLibraryStore.upsertManifestTracks(manifestTracks)
offlineLibraryStore.replacePlaylists(manifest.playlists)
offlineLibraryStore.replaceLikedTrackIds(manifest.likedTrackIds.toSet())
val trackCards = manifestTracks
.map { it.id }
.chunked(TRACKS_BY_IDS_CHUNK_SIZE)
.flatMap { ids ->
val tracksResponse = playerApi.tracksByIds(
url = playerEndpoints.tracksByIds(baseUrl),
body = TracksByIdsRequest(ids)
)
if (!tracksResponse.isSuccessful) {
throw AuthException(errorParser.messageFrom(tracksResponse))
}
tracksResponse.body().orEmpty().map { track ->
val domainTrack = track.toDomain(baseUrl)
manifestById[domainTrack.id]?.let(domainTrack::withOfflineManifest) ?: domainTrack
}
}
offlineLibraryStore.upsertTracks(trackCards)
offlineMediaStore.pruneStaleMedia()
offlineMediaStore.publishStorageStats()
val syncedAtMs = System.currentTimeMillis()
offlineSettingsStorage.setLastSyncedAt(syncedAtMs)
offlineSettingsStorage.setNetworkFallback(false)
Result.success(
OfflineSyncSummary(
trackCount = manifestTracks.size,
playlistCount = manifest.playlists.size,
syncedAtMs = syncedAtMs
)
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
offlineSettingsStorage.setLastError(e.message ?: "Unable to sync offline library")
Result.failure(e)
} finally {
offlineSettingsStorage.setSyncing(false)
}
}
override suspend fun getArtists(page: Int, limit: Int, mine: Boolean): Result<ArtistPage> { override suspend fun getArtists(page: Int, limit: Int, mine: Boolean): Result<ArtistPage> {
if (isOfflineModeEnabled()) {
return Result.success(offlineLibraryStore.getArtists(page, limit))
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -61,15 +165,23 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Artists response is empty") val body = response.body() ?: throw AuthException("Artists response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl)) Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) fallbackToOffline(e) { offlineLibraryStore.getArtists(page, limit) }
} }
} }
override suspend fun getArtistDetail(artistId: Long): Result<ArtistDetail> { override suspend fun getArtistDetail(artistId: Long): Result<ArtistDetail> {
if (isOfflineModeEnabled()) {
return offlineLibraryStore.getArtistDetail(artistId)
?.withOnlyCachedPlayback()
?.let { Result.success(it) }
?: offlineUnavailable("Artist is not available offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -82,15 +194,27 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Artist response is empty") val body = response.body() ?: throw AuthException("Artist response is empty")
Result.success(body.toDomain(baseUrl)) val detail = body.toDomain(baseUrl).withCachedMedia()
offlineLibraryStore.upsertTracks(detail.topTracks + detail.featuredTracks)
markOnlineSuccess()
Result.success(detail)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) fallbackToOffline(e) {
offlineLibraryStore.getArtistDetail(artistId)?.withOnlyCachedPlayback()
}
} }
} }
override suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail> { override suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail> {
if (isOfflineModeEnabled()) {
return offlineLibraryStore.getReleaseDetail(releaseId)
?.withOnlyCachedPlayback()
?.let { Result.success(it) }
?: offlineUnavailable("Release is not available offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -103,15 +227,24 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Release response is empty") val body = response.body() ?: throw AuthException("Release response is empty")
Result.success(body.toDomain(baseUrl)) val detail = body.toDomain(baseUrl).withCachedMedia()
offlineLibraryStore.upsertTracks(detail.tracks)
markOnlineSuccess()
Result.success(detail)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) fallbackToOffline(e) {
offlineLibraryStore.getReleaseDetail(releaseId)?.withOnlyCachedPlayback()
}
} }
} }
override suspend fun search(query: String, limit: Int): Result<SearchResults> { override suspend fun search(query: String, limit: Int): Result<SearchResults> {
if (isOfflineModeEnabled()) {
return Result.success(offlineLibraryStore.search(query, limit).withOnlyCachedPlayback())
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -126,15 +259,22 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Search response is empty") val body = response.body() ?: throw AuthException("Search response is empty")
Result.success(body.toDomain(baseUrl)) val results = body.toDomain(baseUrl).withCachedMedia()
offlineLibraryStore.upsertTracks(results.tracks)
markOnlineSuccess()
Result.success(results)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) fallbackToOffline(e) { offlineLibraryStore.search(query, limit).withOnlyCachedPlayback() }
} }
} }
override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> { override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Listening history is not available offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -149,6 +289,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Listening history response is empty") val body = response.body() ?: throw AuthException("Listening history response is empty")
markOnlineSuccess()
Result.success(body.toDomain()) Result.success(body.toDomain())
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -158,6 +299,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun toggleLike(trackId: Long): Result<Boolean> { override suspend fun toggleLike(trackId: Long): Result<Boolean> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Likes cannot be changed offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -170,6 +315,11 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Like toggle response is empty") val body = response.body() ?: throw AuthException("Like toggle response is empty")
val likedIds = offlineLibraryStore.getLikedTrackIds()
offlineLibraryStore.replaceLikedTrackIds(
if (body.liked) likedIds + trackId else likedIds - trackId
)
markOnlineSuccess()
Result.success(body.liked) Result.success(body.liked)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -179,6 +329,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun getLikedTrackIds(): Result<Set<Long>> { override suspend fun getLikedTrackIds(): Result<Set<Long>> {
if (isOfflineModeEnabled()) {
return Result.success(offlineLibraryStore.getLikedTrackIds())
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -191,11 +345,14 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Likes response is empty") val body = response.body() ?: throw AuthException("Likes response is empty")
Result.success(body.trackIds.toSet()) val likedIds = body.trackIds.toSet()
offlineLibraryStore.replaceLikedTrackIds(likedIds)
markOnlineSuccess()
Result.success(likedIds)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) fallbackToOffline(e) { offlineLibraryStore.getLikedTrackIds() }
} }
} }
@@ -205,6 +362,17 @@ class PlayerRepositoryImpl @Inject constructor(
durationListened: Int, durationListened: Int,
completed: Boolean completed: Boolean
): Result<Unit> { ): Result<Unit> {
if (isOfflineModeEnabled()) {
return try {
offlineMediaStore.cacheTrackAfterListening(trackId)
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -222,15 +390,23 @@ class PlayerRepositoryImpl @Inject constructor(
throw AuthException(errorParser.messageFrom(response)) throw AuthException(errorParser.messageFrom(response))
} }
offlineMediaStore.cacheTrackAfterListening(trackId)
markOnlineSuccess()
Result.success(Unit) Result.success(Unit)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
runCatching { offlineMediaStore.cacheTrackAfterListening(trackId) }
offlineSettingsStorage.setNetworkFallback(true, e.message)
Result.failure(e) Result.failure(e)
} }
} }
override suspend fun getPlaylists(): Result<List<PlaylistCard>> { override suspend fun getPlaylists(): Result<List<PlaylistCard>> {
if (isOfflineModeEnabled()) {
return Result.success(offlineLibraryStore.getPlaylists())
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -243,15 +419,23 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Playlists response is empty") val body = response.body() ?: throw AuthException("Playlists response is empty")
markOnlineSuccess()
Result.success(body.toDomain()) Result.success(body.toDomain())
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) fallbackToOffline(e) { offlineLibraryStore.getPlaylists() }
} }
} }
override suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail> { override suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail> {
if (isOfflineModeEnabled()) {
return offlineLibraryStore.getPlaylistDetail(playlistId)
?.withOnlyCachedPlayback()
?.let { Result.success(it) }
?: offlineUnavailable("Playlist is not available offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -264,7 +448,64 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Playlist response is empty") val body = response.body() ?: throw AuthException("Playlist response is empty")
Result.success(body.toDomain(baseUrl)) val detail = body.toDomain(baseUrl).withCachedMedia()
offlineLibraryStore.upsertPlaylistDetail(detail)
markOnlineSuccess()
Result.success(detail)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
fallbackToOffline(e) {
offlineLibraryStore.getPlaylistDetail(playlistId)?.withOnlyCachedPlayback()
}
}
}
override suspend fun createPlaylist(title: String): Result<PlaylistCard> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Playlists cannot be created offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.createPlaylist(
url = playerEndpoints.playlists(baseUrl),
body = CreatePlaylistRequest(title = title)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
val body = response.body() ?: throw AuthException("Create playlist response is empty")
markOnlineSuccess()
Result.success(body.toDomain())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun deletePlaylist(playlistId: Long): Result<Unit> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Playlists cannot be deleted offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
val response = playerApi.deletePlaylist(
url = playerEndpoints.playlistDetail(baseUrl, playlistId)
)
if (!response.isSuccessful) {
throw AuthException(errorParser.messageFrom(response))
}
markOnlineSuccess()
Result.success(Unit)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
@@ -273,6 +514,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> { override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Playlists cannot be changed offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -285,6 +530,7 @@ class PlayerRepositoryImpl @Inject constructor(
throw AuthException(errorParser.messageFrom(response)) throw AuthException(errorParser.messageFrom(response))
} }
markOnlineSuccess()
Result.success(Unit) Result.success(Unit)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -294,9 +540,14 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun shareTrack(trackId: Long, title: String): Result<String> { override suspend fun shareTrack(trackId: Long, title: String): Result<String> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Tracks cannot be shared offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
markOnlineSuccess()
Result.success(playerEndpoints.shareTrack(baseUrl, trackId)) Result.success(playerEndpoints.shareTrack(baseUrl, trackId))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -309,6 +560,10 @@ class PlayerRepositoryImpl @Inject constructor(
playbackState: ConnectedPlaybackState?, playbackState: ConnectedPlaybackState?,
currentJamId: String? currentJamId: String?
): Result<Pair<ConnectedDevicesState, List<ConnectedCommand>>> { ): Result<Pair<ConnectedDevicesState, List<ConnectedCommand>>> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Connected devices are unavailable offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -327,6 +582,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Device poll response is empty") val body = response.body() ?: throw AuthException("Device poll response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl) to body.commands.map { it.toDomain(baseUrl) }) Result.success(body.toDomain(baseUrl) to body.commands.map { it.toDomain(baseUrl) })
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -336,6 +592,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun setActiveDevice(deviceId: String): Result<ConnectedDevicesState> { override suspend fun setActiveDevice(deviceId: String): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Connected devices are unavailable offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -352,6 +612,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Active device response is empty") val body = response.body() ?: throw AuthException("Active device response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl)) Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -374,6 +635,10 @@ class PlayerRepositoryImpl @Inject constructor(
fromIndex: Int?, fromIndex: Int?,
toIndex: Int? toIndex: Int?
): Result<Unit> { ): Result<Unit> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Connected devices are unavailable offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -401,6 +666,7 @@ class PlayerRepositoryImpl @Inject constructor(
throw AuthException(errorParser.messageFrom(response)) throw AuthException(errorParser.messageFrom(response))
} }
markOnlineSuccess()
Result.success(Unit) Result.success(Unit)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -410,6 +676,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun createJam(): Result<ConnectedDevicesState> { override suspend fun createJam(): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -423,6 +693,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Jam response is empty") val body = response.body() ?: throw AuthException("Jam response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl)) Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -432,6 +703,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun joinJam(jamId: String): Result<ConnectedDevicesState> { override suspend fun joinJam(jamId: String): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -448,6 +723,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Jam response is empty") val body = response.body() ?: throw AuthException("Jam response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl)) Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -457,6 +733,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun leaveJam(jamId: String): Result<ConnectedDevicesState> { override suspend fun leaveJam(jamId: String): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -473,6 +753,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Jam response is empty") val body = response.body() ?: throw AuthException("Jam response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl)) Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -482,6 +763,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun searchJamUsers(query: String, limit: Int): Result<List<ConnectedJamUser>> { override suspend fun searchJamUsers(query: String, limit: Int): Result<List<ConnectedJamUser>> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -496,6 +781,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Jam users response is empty") val body = response.body() ?: throw AuthException("Jam users response is empty")
markOnlineSuccess()
Result.success(body.map { it.toDomain() }) Result.success(body.map { it.toDomain() })
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -505,6 +791,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState> { override suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try { return try {
val baseUrl = sessionStorage.getBaseUrl() val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing") ?: throw AuthException("Server URL is missing")
@@ -522,6 +812,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Jam response is empty") val body = response.body() ?: throw AuthException("Jam response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl)) Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -529,4 +820,88 @@ class PlayerRepositoryImpl @Inject constructor(
Result.failure(e) Result.failure(e)
} }
} }
private fun isOfflineModeEnabled(): Boolean {
return offlineSettingsStorage.state.value.settings.offlineModeEnabled
}
private fun markOnlineSuccess() {
offlineSettingsStorage.setNetworkFallback(false)
offlineSettingsStorage.setLastError(null)
}
private fun <T> fallbackToOffline(error: Exception, block: () -> T?): Result<T> {
val localValue = runCatching(block).getOrNull()
return if (localValue != null) {
offlineSettingsStorage.setNetworkFallback(true, error.message)
Result.success(localValue)
} else {
Result.failure(error)
}
}
private fun <T> offlineUnavailable(message: String): Result<T> {
return Result.failure(AuthException(message))
}
private fun ArtistDetail.withCachedMedia(): ArtistDetail {
return copy(
topTracks = topTracks.map { it.withCachedMedia() },
featuredTracks = featuredTracks.map { it.withCachedMedia() }
)
}
private fun ReleaseDetail.withCachedMedia(): ReleaseDetail {
return copy(tracks = tracks.map { it.withCachedMedia() })
}
private fun PlaylistDetail.withCachedMedia(): PlaylistDetail {
return copy(tracks = tracks.map { it.withCachedMedia() })
}
private fun SearchResults.withCachedMedia(): SearchResults {
return copy(tracks = tracks.map { it.withCachedMedia() })
}
private fun TrackCard.withCachedMedia(): TrackCard {
val cached = offlineLibraryStore.getTrack(id, preferLocalMedia = true) ?: return this
return copy(
streamUrl = if (cached.isAudioCached) cached.streamUrl else streamUrl,
coverUrl = if (cached.localCoverPath != null) cached.coverUrl else coverUrl,
localAudioPath = cached.localAudioPath,
localCoverPath = cached.localCoverPath,
isAudioCached = cached.isAudioCached
)
}
private fun ArtistDetail.withOnlyCachedPlayback(): ArtistDetail {
return copy(
topTracks = topTracks.map { it.withOnlyCachedPlayback() },
featuredTracks = featuredTracks.map { it.withOnlyCachedPlayback() }
)
}
private fun ReleaseDetail.withOnlyCachedPlayback(): ReleaseDetail {
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
}
private fun PlaylistDetail.withOnlyCachedPlayback(): PlaylistDetail {
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
}
private fun SearchResults.withOnlyCachedPlayback(): SearchResults {
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
}
private fun TrackCard.withOnlyCachedPlayback(): TrackCard {
return if (isAudioCached || streamUrl.startsWith("file://", ignoreCase = true)) {
this
} else {
copy(streamUrl = "")
}
}
private companion object {
const val TRACKS_BY_IDS_CHUNK_SIZE = 500
}
} }
@@ -39,9 +39,23 @@ data class TrackCard(
val artists: List<String>, val artists: List<String>,
val artistRefs: List<ArtistRef> = emptyList(), val artistRefs: List<ArtistRef> = emptyList(),
val featuredArtistRefs: List<ArtistRef> = emptyList(), val featuredArtistRefs: List<ArtistRef> = emptyList(),
val releaseId: Long? = null,
val releaseYear: Int? = null,
val releaseTitle: String?, val releaseTitle: String?,
val coverUrl: String?, val coverUrl: String?,
val streamUrl: String val streamUrl: String,
val remoteCoverUrl: String? = coverUrl,
val remoteStreamUrl: String = streamUrl,
val metadataUpdatedAt: String? = null,
val audioHash: String? = null,
val audioSizeBytes: Long? = null,
val audioMimeType: String? = null,
val audioUpdatedAt: String? = null,
val coverHash: String? = null,
val coverUpdatedAt: String? = null,
val localAudioPath: String? = null,
val localCoverPath: String? = null,
val isAudioCached: Boolean = false
) )
data class ArtistDetail( data class ArtistDetail(
@@ -0,0 +1,30 @@
package cy.hexor.furumi.domain.model
data class OfflineSettings(
val offlineModeEnabled: Boolean = false,
val saveListenedTracksEnabled: Boolean = false,
val storageLimitBytes: Long = DEFAULT_STORAGE_LIMIT_BYTES
) {
companion object {
const val DEFAULT_STORAGE_LIMIT_BYTES: Long = 2L * 1024L * 1024L * 1024L
}
}
data class OfflineState(
val settings: OfflineSettings = OfflineSettings(),
val isUsingNetworkFallback: Boolean = false,
val isSyncing: Boolean = false,
val lastSyncedAtMs: Long = 0L,
val storageUsedBytes: Long = 0L,
val cachedTrackCount: Int = 0,
val lastError: String? = null
) {
val isOfflineActive: Boolean
get() = settings.offlineModeEnabled || isUsingNetworkFallback
}
data class OfflineSyncSummary(
val trackCount: Int,
val playlistCount: Int,
val syncedAtMs: Long
)
@@ -7,13 +7,18 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
import cy.hexor.furumi.domain.model.ConnectedJamUser import cy.hexor.furumi.domain.model.ConnectedJamUser
import cy.hexor.furumi.domain.model.ConnectedPlaybackState import cy.hexor.furumi.domain.model.ConnectedPlaybackState
import cy.hexor.furumi.domain.model.ListeningHistoryPage import cy.hexor.furumi.domain.model.ListeningHistoryPage
import cy.hexor.furumi.domain.model.OfflineState
import cy.hexor.furumi.domain.model.OfflineSyncSummary
import cy.hexor.furumi.domain.model.PlaylistCard import cy.hexor.furumi.domain.model.PlaylistCard
import cy.hexor.furumi.domain.model.PlaylistDetail import cy.hexor.furumi.domain.model.PlaylistDetail
import cy.hexor.furumi.domain.model.ReleaseDetail import cy.hexor.furumi.domain.model.ReleaseDetail
import cy.hexor.furumi.domain.model.SearchResults import cy.hexor.furumi.domain.model.SearchResults
import cy.hexor.furumi.domain.model.TrackCard import cy.hexor.furumi.domain.model.TrackCard
import kotlinx.coroutines.flow.StateFlow
interface PlayerRepository { interface PlayerRepository {
val offlineState: StateFlow<OfflineState>
suspend fun getArtists( suspend fun getArtists(
page: Int = 1, page: Int = 1,
limit: Int = 60, limit: Int = 60,
@@ -43,6 +48,10 @@ interface PlayerRepository {
suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail> suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail>
suspend fun createPlaylist(title: String): Result<PlaylistCard>
suspend fun deletePlaylist(playlistId: Long): Result<Unit>
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>
@@ -78,4 +87,14 @@ interface PlayerRepository {
suspend fun searchJamUsers(query: String, limit: Int = 10): Result<List<ConnectedJamUser>> suspend fun searchJamUsers(query: String, limit: Int = 10): Result<List<ConnectedJamUser>>
suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState> suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState>
fun setOfflineModeEnabled(enabled: Boolean)
fun setSaveListenedTracksEnabled(enabled: Boolean)
fun setOfflineStorageLimit(limitBytes: Long)
suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary>
suspend fun clearOfflineCache(): Result<Unit>
} }
@@ -11,6 +11,7 @@ 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.datasource.DataSourceBitmapLoader import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource
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.common.util.UnstableApi
@@ -59,7 +60,10 @@ class FurumiPlaybackService : MediaSessionService() {
val bitmapLoader = CacheBitmapLoader( val bitmapLoader = CacheBitmapLoader(
DataSourceBitmapLoader( DataSourceBitmapLoader(
MoreExecutors.listeningDecorator(executor), MoreExecutors.listeningDecorator(executor),
OkHttpDataSource.Factory(okHttpClient) DefaultDataSource.Factory(
this,
OkHttpDataSource.Factory(okHttpClient)
)
) )
) )
@@ -13,6 +13,7 @@ import androidx.media3.common.Player
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
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
@@ -87,7 +88,10 @@ class PlaybackController @Inject constructor(
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow() val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
init { init {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient) val dataSourceFactory = DefaultDataSource.Factory(
context,
OkHttpDataSource.Factory(okHttpClient)
)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
player = ExoPlayer.Builder(context) player = ExoPlayer.Builder(context)
@@ -103,6 +107,7 @@ class PlaybackController @Inject constructor(
) )
setHandleAudioBecomingNoisy(true) setHandleAudioBecomingNoisy(true)
setWakeMode(C.WAKE_MODE_LOCAL) setWakeMode(C.WAKE_MODE_LOCAL)
volume = ANDROID_MEDIA_VOLUME_MULTIPLIER
} }
player.addListener( player.addListener(
@@ -249,11 +254,6 @@ class PlaybackController @Inject constructor(
publishState() publishState()
} }
fun setVolume(volume: Double) {
player.volume = volume.toFloat().coerceIn(0f, 1f)
publishState()
}
fun setOptions(shuffle: Boolean?, repeatMode: String?) { fun setOptions(shuffle: Boolean?, repeatMode: String?) {
shuffle?.let { player.shuffleModeEnabled = it } shuffle?.let { player.shuffleModeEnabled = it }
repeatMode?.let { player.repeatMode = it.toPlayerRepeatMode() } repeatMode?.let { player.repeatMode = it.toPlayerRepeatMode() }
@@ -267,7 +267,6 @@ class PlaybackController @Inject constructor(
if (queue.isEmpty()) return if (queue.isEmpty()) return
setOptions(playbackState.shuffle, playbackState.repeatMode) setOptions(playbackState.shuffle, playbackState.repeatMode)
setVolume(playbackState.volume)
playQueue(queue, playbackState.index.coerceIn(0, queue.lastIndex)) playQueue(queue, playbackState.index.coerceIn(0, queue.lastIndex))
player.seekTo((playbackState.positionSeconds.coerceAtLeast(0.0) * 1_000.0).toLong()) player.seekTo((playbackState.positionSeconds.coerceAtLeast(0.0) * 1_000.0).toLong())
if (playbackState.paused) { if (playbackState.paused) {
@@ -477,6 +476,7 @@ class PlaybackController @Inject constructor(
private companion object { private companion object {
const val PROGRESS_TICK_MS = 500L const val PROGRESS_TICK_MS = 500L
const val RESTART_WINDOW_MS = 3_000L const val RESTART_WINDOW_MS = 3_000L
const val ANDROID_MEDIA_VOLUME_MULTIPLIER = 1f
} }
private fun startPlaybackService() { private fun startPlaybackService() {
@@ -25,11 +25,14 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -74,6 +77,9 @@ import cy.hexor.furumi.ui.theme.FurumiNeonViolet
import cy.hexor.furumi.ui.theme.FurumiSurface import cy.hexor.furumi.ui.theme.FurumiSurface
import cy.hexor.furumi.ui.theme.FurumiSurfaceHigh import cy.hexor.furumi.ui.theme.FurumiSurfaceHigh
import cy.hexor.furumi.ui.theme.FurumiTextMuted import cy.hexor.furumi.ui.theme.FurumiTextMuted
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@@ -139,65 +145,43 @@ internal fun ProfileMenu(
uiState: PlayerUiState, uiState: PlayerUiState,
onHistoryClick: () -> Unit, onHistoryClick: () -> Unit,
onDeviceClick: (String) -> Unit, onDeviceClick: (String) -> Unit,
onOfflineModeChange: (Boolean) -> Unit,
onSaveListenedChange: (Boolean) -> Unit,
onStorageLimitChange: (Long) -> Unit,
onSyncOffline: () -> Unit,
onClearOfflineCache: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Surface( Surface(
modifier = modifier.width(284.dp), modifier = modifier
.fillMaxWidth()
.widthIn(max = 430.dp)
.heightIn(max = 640.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = FurumiSurfaceHigh, color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine), border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
shadowElevation = 8.dp shadowElevation = 8.dp
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp) modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
Text( ProfileSummary(uiState)
text = "Profile",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(14.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = uiState.userName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (uiState.appVersion.isNotBlank()) {
Surface(
color = FurumiSurface,
shape = RoundedCornerShape(4.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Text(
text = "v${uiState.appVersion}",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
}
}
}
Text(
text = uiState.serverUrl.ifBlank { "No server selected" },
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(16.dp))
ConnectedDevicesSection( ConnectedDevicesSection(
uiState = uiState, uiState = uiState,
onDeviceClick = onDeviceClick onDeviceClick = onDeviceClick
) )
Spacer(modifier = Modifier.height(10.dp)) OfflineControlsSection(
uiState = uiState,
onOfflineModeChange = onOfflineModeChange,
onSaveListenedChange = onSaveListenedChange,
onStorageLimitChange = onStorageLimitChange,
onSyncOffline = onSyncOffline,
onClearOfflineCache = onClearOfflineCache
)
OutlinedButton( OutlinedButton(
onClick = onHistoryClick, onClick = onHistoryClick,
modifier = Modifier modifier = Modifier
@@ -212,7 +196,6 @@ internal fun ProfileMenu(
) { ) {
Text("Listening history") Text("Listening history")
} }
Spacer(modifier = Modifier.height(10.dp))
Button( Button(
onClick = onLogout, onClick = onLogout,
modifier = Modifier modifier = Modifier
@@ -241,13 +224,286 @@ internal fun ProfileMenu(
} }
} }
@Composable
private fun ProfileSummary(uiState: PlayerUiState) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Profile",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(FurumiNeonPink),
contentAlignment = Alignment.Center
) {
Text(
text = uiState.userName.firstOrNull()?.uppercaseChar()?.toString() ?: "F",
style = MaterialTheme.typography.labelLarge,
color = FurumiBlack,
fontWeight = FontWeight.ExtraBold
)
}
Text(
text = uiState.userName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (uiState.appVersion.isNotBlank()) {
Surface(
color = FurumiSurface,
shape = RoundedCornerShape(4.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Text(
text = "v${uiState.appVersion}",
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
}
}
}
Text(
text = uiState.serverUrl.ifBlank { "No server selected" },
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun OfflineControlsSection(
uiState: PlayerUiState,
onOfflineModeChange: (Boolean) -> Unit,
onSaveListenedChange: (Boolean) -> Unit,
onStorageLimitChange: (Long) -> Unit,
onSyncOffline: () -> Unit,
onClearOfflineCache: () -> Unit
) {
val offlineState = uiState.offlineState
val settings = offlineState.settings
val storageProgress = if (settings.storageLimitBytes > 0L) {
(offlineState.storageUsedBytes.toFloat() / settings.storageLimitBytes.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Offline",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground
)
ProfileSwitchRow(
title = "Offline mode",
subtitle = if (offlineState.isUsingNetworkFallback) "Fallback active" else "Local library",
checked = settings.offlineModeEnabled,
onCheckedChange = onOfflineModeChange
)
ProfileSwitchRow(
title = "Save listened tracks",
subtitle = "${offlineState.cachedTrackCount} cached",
checked = settings.saveListenedTracksEnabled,
onCheckedChange = onSaveListenedChange
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Storage",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f)
)
Text(
text = "${formatStorageBytes(offlineState.storageUsedBytes)} / ${formatStorageBytes(settings.storageLimitBytes)}",
style = MaterialTheme.typography.labelSmall,
color = FurumiTextMuted
)
}
LinearProgressIndicator(
progress = { storageProgress },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(CircleShape),
color = FurumiNeonPink,
trackColor = FurumiLine
)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
offlineStorageLimitOptions.forEach { limit ->
OfflineLimitChip(
label = formatStorageBytes(limit),
selected = settings.storageLimitBytes == limit,
onClick = { onStorageLimitChange(limit) }
)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = onSyncOffline,
modifier = Modifier.weight(1f),
enabled = !offlineState.isSyncing,
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = FurumiTextMuted
)
) {
if (offlineState.isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = FurumiTextMuted,
strokeWidth = 2.dp
)
} else {
Text("Sync now")
}
}
OutlinedButton(
onClick = onClearOfflineCache,
modifier = Modifier.weight(1f),
enabled = offlineState.storageUsedBytes > 0L && !offlineState.isSyncing,
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = FurumiTextMuted
)
) {
Text("Clear downloads")
}
}
Text(
text = formatOfflineSyncStatus(offlineState.lastSyncedAtMs),
style = MaterialTheme.typography.bodySmall,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
offlineState.lastError?.let { error ->
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = FurumiHotOrange,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun ProfileSwitchRow(
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = FurumiTextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = FurumiBlack,
checkedTrackColor = FurumiNeonPink,
uncheckedThumbColor = FurumiTextMuted,
uncheckedTrackColor = FurumiSurface,
uncheckedBorderColor = FurumiLine
)
)
}
}
@Composable
private fun OfflineLimitChip(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.height(32.dp)
.clip(CircleShape)
.clickable(onClick = onClick),
shape = CircleShape,
color = if (selected) FurumiNeonPink else FurumiSurface,
border = if (selected) null else androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Box(
modifier = Modifier.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (selected) FurumiBlack else MaterialTheme.colorScheme.onBackground
)
}
}
}
@Composable
internal fun OfflineStatusBadge(
offlineState: cy.hexor.furumi.domain.model.OfflineState,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
shape = CircleShape,
color = if (offlineState.settings.offlineModeEnabled) FurumiNeonPink else FurumiHotOrange,
shadowElevation = 4.dp
) {
Text(
text = if (offlineState.settings.offlineModeEnabled) "Offline mode" else "Offline fallback",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = FurumiBlack,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable @Composable
private fun ConnectedDevicesSection( private fun ConnectedDevicesSection(
uiState: PlayerUiState, uiState: PlayerUiState,
onDeviceClick: (String) -> Unit onDeviceClick: (String) -> Unit
) { ) {
val devices = uiState.connectedDevicesState?.devices.orEmpty() val devices = uiState.connectedDevicesState?.devices.orEmpty()
if (devices.isEmpty() && uiState.connectedDevicesError == null) return
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text( Text(
@@ -255,11 +511,17 @@ private fun ConnectedDevicesSection(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground color = MaterialTheme.colorScheme.onBackground
) )
uiState.connectedDevicesError?.let { error -> val statusText = when {
uiState.offlineState.settings.offlineModeEnabled -> "Offline mode"
uiState.connectedDevicesError != null -> uiState.connectedDevicesError
devices.isEmpty() -> "No connected devices"
else -> null
}
statusText?.let { status ->
Text( Text(
text = error, text = status,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = FurumiHotOrange, color = if (uiState.connectedDevicesError != null) FurumiHotOrange else FurumiTextMuted,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -385,6 +647,8 @@ internal fun NowPlayingBar(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onToggleShuffle: () -> Unit,
onCycleRepeatMode: () -> Unit,
onToggleLike: () -> Unit onToggleLike: () -> Unit
) { ) {
val track = playback.currentTrack ?: return val track = playback.currentTrack ?: return
@@ -433,6 +697,12 @@ internal fun NowPlayingBar(
) )
} }
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
MiniPlayerIconButton(size = 30, onClick = onToggleShuffle) {
ShuffleGlyph(
modifier = Modifier.size(16.dp),
color = if (playback.shuffle) FurumiNeonPink else MaterialTheme.colorScheme.onBackground
)
}
MiniPlayerIconButton(onClick = onPrevious) { MiniPlayerIconButton(onClick = onPrevious) {
PreviousGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground) PreviousGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
} }
@@ -450,6 +720,17 @@ internal fun NowPlayingBar(
MiniPlayerIconButton(onClick = onNext) { MiniPlayerIconButton(onClick = onNext) {
NextGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground) NextGlyph(Modifier.size(16.dp), MaterialTheme.colorScheme.onBackground)
} }
MiniPlayerIconButton(size = 30, onClick = onCycleRepeatMode) {
RepeatGlyph(
modifier = Modifier.size(18.dp),
color = if (playback.repeatMode.equals("off", ignoreCase = true)) {
MaterialTheme.colorScheme.onBackground
} else {
FurumiNeonPink
},
repeatMode = playback.repeatMode
)
}
MiniPlayerIconButton(onClick = onToggleLike) { MiniPlayerIconButton(onClick = onToggleLike) {
HeartGlyph( HeartGlyph(
isLiked = isLiked, isLiked = isLiked,
@@ -547,3 +828,29 @@ internal fun BottomNavItem(
) )
} }
} }
private val offlineStorageLimitOptions = listOf(
512L * 1024L * 1024L,
1L * 1024L * 1024L * 1024L,
2L * 1024L * 1024L * 1024L,
5L * 1024L * 1024L * 1024L,
10L * 1024L * 1024L * 1024L
)
private fun formatStorageBytes(bytes: Long): String {
val safeBytes = bytes.coerceAtLeast(0L)
val megabytes = safeBytes / (1024.0 * 1024.0)
return if (megabytes >= 1024.0) {
String.format(Locale.US, "%.1f GB", megabytes / 1024.0)
} else {
String.format(Locale.US, "%.0f MB", megabytes)
}
}
private fun formatOfflineSyncStatus(timestampMs: Long): String {
if (timestampMs <= 0L) return "Never synced"
val formatted = DateFormat
.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
.format(Date(timestampMs))
return "Synced $formatted"
}
@@ -57,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import cy.hexor.furumi.domain.model.ArtistCard import cy.hexor.furumi.domain.model.ArtistCard
import cy.hexor.furumi.domain.model.PlaylistCard import cy.hexor.furumi.domain.model.PlaylistCard
@@ -128,6 +129,30 @@ internal fun PlaybackGlyph(
} }
} }
@Composable
internal fun PlusGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val stroke = size.minDimension * 0.14f
drawLine(
color = color,
start = Offset(size.width * 0.5f, size.height * 0.2f),
end = Offset(size.width * 0.5f, size.height * 0.8f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.2f, size.height * 0.5f),
end = Offset(size.width * 0.8f, size.height * 0.5f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
}
@Composable @Composable
internal fun BackGlyph( internal fun BackGlyph(
modifier: Modifier, modifier: Modifier,
@@ -197,6 +222,150 @@ internal fun NextGlyph(
} }
} }
@Composable
internal fun ShuffleGlyph(
modifier: Modifier,
color: Color
) {
Canvas(modifier = modifier) {
val stroke = size.minDimension * 0.10f
drawLine(
color = color,
start = Offset(size.width * 0.14f, size.height * 0.30f),
end = Offset(size.width * 0.34f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.34f, size.height * 0.30f),
end = Offset(size.width * 0.66f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.66f, size.height * 0.70f),
end = Offset(size.width * 0.86f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.14f, size.height * 0.70f),
end = Offset(size.width * 0.34f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.34f, size.height * 0.70f),
end = Offset(size.width * 0.66f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.66f, size.height * 0.30f),
end = Offset(size.width * 0.86f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.20f),
end = Offset(size.width * 0.86f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.40f),
end = Offset(size.width * 0.86f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.60f),
end = Offset(size.width * 0.86f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.80f),
end = Offset(size.width * 0.86f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
}
@Composable
internal fun RepeatGlyph(
modifier: Modifier,
color: Color,
repeatMode: String
) {
Box(modifier = modifier) {
Canvas(modifier = Modifier.fillMaxSize()) {
val stroke = size.minDimension * 0.10f
drawLine(
color = color,
start = Offset(size.width * 0.24f, size.height * 0.30f),
end = Offset(size.width * 0.78f, size.height * 0.30f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.78f, size.height * 0.30f),
end = Offset(size.width * 0.66f, size.height * 0.18f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.78f, size.height * 0.30f),
end = Offset(size.width * 0.66f, size.height * 0.42f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.76f, size.height * 0.70f),
end = Offset(size.width * 0.22f, size.height * 0.70f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.22f, size.height * 0.70f),
end = Offset(size.width * 0.34f, size.height * 0.58f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = color,
start = Offset(size.width * 0.22f, size.height * 0.70f),
end = Offset(size.width * 0.34f, size.height * 0.82f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
if (repeatMode.equals("one", ignoreCase = true)) {
Text(
text = "1",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
color = color,
fontWeight = FontWeight.ExtraBold
)
}
}
}
@Composable @Composable
internal fun GlobalGlyph( internal fun GlobalGlyph(
modifier: Modifier, modifier: Modifier,
@@ -432,13 +601,14 @@ internal fun AudioWaveGlyph(
@Composable @Composable
internal fun MoreDotsGlyph( internal fun MoreDotsGlyph(
modifier: Modifier modifier: Modifier,
color: Color = Color(0xFFC9B8D6)
) { ) {
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val dotRadius = size.minDimension * 0.07f val dotRadius = size.minDimension * 0.07f
val cx = size.width * 0.5f val cx = size.width * 0.5f
drawCircle(color = Color(0xFFC9B8D6), radius = dotRadius, center = Offset(cx, size.height * 0.30f)) drawCircle(color = color, radius = dotRadius, center = Offset(cx, size.height * 0.30f))
drawCircle(color = Color(0xFFC9B8D6), radius = dotRadius, center = Offset(cx, size.height * 0.50f)) drawCircle(color = color, radius = dotRadius, center = Offset(cx, size.height * 0.50f))
drawCircle(color = Color(0xFFC9B8D6), radius = dotRadius, center = Offset(cx, size.height * 0.70f)) drawCircle(color = color, radius = dotRadius, center = Offset(cx, size.height * 0.70f))
} }
} }
@@ -348,6 +348,8 @@ internal fun LibraryContent(
onRetryPlaylists: () -> Unit, onRetryPlaylists: () -> Unit,
onRetryArtists: () -> Unit, onRetryArtists: () -> Unit,
onLoadMoreArtists: () -> Unit, onLoadMoreArtists: () -> Unit,
onCreatePlaylistClick: () -> Unit,
onPlaylistPreviewNeeded: (PlaylistCard) -> Unit,
onPlaylistClick: (PlaylistCard) -> Unit, onPlaylistClick: (PlaylistCard) -> Unit,
onArtistImageNeeded: (String?) -> Unit, onArtistImageNeeded: (String?) -> Unit,
onArtistClick: (ArtistCard) -> Unit onArtistClick: (ArtistCard) -> Unit
@@ -375,7 +377,25 @@ internal fun LibraryContent(
} }
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
SectionTitle("Playlists") Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
SectionTitle("Playlists")
Surface(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.clickable(onClick = onCreatePlaylistClick),
shape = CircleShape,
color = FurumiNeonPink
) {
Box(contentAlignment = Alignment.Center) {
PlusGlyph(Modifier.size(16.dp), FurumiBlack)
}
}
}
} }
when { when {
@@ -407,6 +427,9 @@ internal fun LibraryContent(
) { playlist -> ) { playlist ->
PlaylistTile( PlaylistTile(
playlist = playlist, playlist = playlist,
coverUrls = uiState.playlistPreviewCoverUrls[playlist.id].orEmpty(),
mediaImages = uiState.mediaImages,
onPlaylistPreviewNeeded = onPlaylistPreviewNeeded,
onClick = { onPlaylistClick(playlist) } onClick = { onPlaylistClick(playlist) }
) )
} }
@@ -471,16 +494,27 @@ internal fun LibraryContent(
@Composable @Composable
private fun PlaylistTile( private fun PlaylistTile(
playlist: PlaylistCard, playlist: PlaylistCard,
coverUrls: List<String>,
mediaImages: Map<String, Bitmap>,
onPlaylistPreviewNeeded: (PlaylistCard) -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
LaunchedEffect(playlist.id) {
if (!playlist.isLikesPlaylist()) {
onPlaylistPreviewNeeded(playlist)
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
) { ) {
AlbumArtwork( PlaylistArtwork(
colors = playlistArtworkColors(playlist), playlist = playlist,
coverUrls = coverUrls,
mediaImages = mediaImages,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(1f), .aspectRatio(1f),
@@ -505,6 +539,85 @@ private fun PlaylistTile(
} }
} }
@Composable
private fun PlaylistArtwork(
playlist: PlaylistCard,
coverUrls: List<String>,
mediaImages: Map<String, Bitmap>,
modifier: Modifier,
cornerRadius: Int
) {
val artworkShape = RoundedCornerShape(cornerRadius.dp)
val miniArtworkShape = RoundedCornerShape(5.dp)
if (playlist.isLikesPlaylist()) {
Box(
modifier = modifier
.clip(artworkShape)
.background(Brush.linearGradient(listOf(FurumiSurfaceHigh, FurumiNeonViolet, FurumiBlack))),
contentAlignment = Alignment.Center
) {
HeartGlyph(
isLiked = true,
modifier = Modifier.size(86.dp)
)
}
return
}
val previewCoverUrls = coverUrls.take(4)
if (previewCoverUrls.isEmpty()) {
AlbumArtwork(
colors = playlistArtworkColors(playlist),
modifier = modifier,
cornerRadius = cornerRadius
)
return
}
Box(
modifier = modifier
.clip(artworkShape)
.background(FurumiSurfaceHigh)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
repeat(2) { row ->
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
repeat(2) { column ->
val slotIndex = row * 2 + column
val coverUrl = previewCoverUrls.getOrNull(slotIndex)
val bitmap = coverUrl?.let { mediaImageFor(mediaImages, it) }
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(miniArtworkShape)
.background(Brush.linearGradient(artistArtworkColors(playlist.id + slotIndex)))
) {
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "${playlist.title} cover ${slotIndex + 1}",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
}
}
}
}
}
}
@Composable @Composable
internal fun PlaylistDetailContent( internal fun PlaylistDetailContent(
uiState: PlayerUiState, uiState: PlayerUiState,
@@ -518,7 +631,8 @@ internal fun PlaylistDetailContent(
onPlayNext: (TrackCard) -> Unit, onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit, onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit onAddToPlaylist: (TrackCard) -> Unit,
onDeletePlaylistClick: (PlaylistCard) -> Unit
) { ) {
val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return
val tracks = uiState.playlistDetail?.tracks.orEmpty() val tracks = uiState.playlistDetail?.tracks.orEmpty()
@@ -611,6 +725,24 @@ internal fun PlaylistDetailContent(
) )
} }
if (playlist.canDeleteFromDetail()) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = { onDeletePlaylistClick(playlist) },
modifier = Modifier
.fillMaxWidth()
.height(44.dp),
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiHotOrange.copy(alpha = 0.72f)),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = FurumiHotOrange,
disabledContentColor = FurumiTextMuted
)
) {
Text("Delete playlist")
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
when { when {
@@ -871,6 +1003,10 @@ private fun PlaylistCard.isLikesPlaylist(): Boolean {
return id == -1L || kind.equals("likes", ignoreCase = true) return id == -1L || kind.equals("likes", ignoreCase = true)
} }
private fun PlaylistCard.canDeleteFromDetail(): Boolean {
return isOwn && !isLikesPlaylist()
}
private fun playlistArtworkColors(playlist: PlaylistCard): List<Color> { private fun playlistArtworkColors(playlist: PlaylistCard): List<Color> {
return if (playlist.isLikesPlaylist()) { return if (playlist.isLikesPlaylist()) {
listOf(FurumiNeonPink, FurumiHotOrange, FurumiBlack) listOf(FurumiNeonPink, FurumiHotOrange, FurumiBlack)
@@ -12,6 +12,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -27,6 +29,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -56,6 +59,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import cy.hexor.furumi.domain.model.ArtistCard import cy.hexor.furumi.domain.model.ArtistCard
@@ -118,6 +122,7 @@ internal fun AddToPlaylistDialog(
playlists: List<PlaylistCard>, playlists: List<PlaylistCard>,
isLoading: Boolean, isLoading: Boolean,
onLoadPlaylists: () -> Unit, onLoadPlaylists: () -> Unit,
onCreatePlaylist: () -> Unit,
onPlaylistSelected: (Long) -> Unit, onPlaylistSelected: (Long) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
@@ -151,6 +156,30 @@ internal fun AddToPlaylistDialog(
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onCreatePlaylist),
shape = RoundedCornerShape(8.dp),
color = FurumiNeonPink,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlusGlyph(Modifier.size(18.dp), FurumiBlack)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Create new playlist",
style = MaterialTheme.typography.labelLarge,
color = FurumiBlack
)
}
}
Spacer(modifier = Modifier.height(12.dp))
when { when {
isLoading -> { isLoading -> {
Box( Box(
@@ -221,3 +250,208 @@ internal fun AddToPlaylistDialog(
} }
} }
} }
@Composable
internal fun CreatePlaylistDialog(
isCreating: Boolean,
errorMessage: String?,
onSubmit: (String) -> Unit,
onDismiss: () -> Unit
) {
var title by rememberSaveable { mutableStateOf("") }
val trimmedTitle = title.trim()
val canSubmit = trimmedTitle.isNotBlank() && !isCreating
val submit = {
if (canSubmit) {
onSubmit(trimmedTitle)
}
}
Surface(
modifier = Modifier
.fillMaxSize()
.clickable(enabled = !isCreating, onClick = onDismiss),
color = Color.Black.copy(alpha = 0.6f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(320.dp)
.clickable(enabled = false, onClick = {}),
shape = RoundedCornerShape(16.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "Create playlist",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(14.dp))
OutlinedTextField(
value = title,
onValueChange = { title = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
label = { Text("Name") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() })
)
errorMessage?.let { message ->
Spacer(modifier = Modifier.height(10.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiHotOrange
)
}
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = onDismiss,
enabled = !isCreating,
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = FurumiTextMuted
)
) {
Text("Cancel")
}
Button(
onClick = submit,
enabled = canSubmit,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = FurumiNeonPink,
contentColor = FurumiBlack,
disabledContainerColor = FurumiSurface,
disabledContentColor = FurumiTextMuted
)
) {
if (isCreating) {
CircularProgressIndicator(
color = FurumiBlack,
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp)
)
} else {
Text("Create")
}
}
}
}
}
}
}
}
@Composable
internal fun DeletePlaylistDialog(
playlist: PlaylistCard,
isDeleting: Boolean,
errorMessage: String?,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxSize()
.clickable(enabled = !isDeleting, onClick = onDismiss),
color = Color.Black.copy(alpha = 0.6f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(320.dp)
.clickable(enabled = false, onClick = {}),
shape = RoundedCornerShape(16.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "Delete playlist?",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = playlist.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This cannot be undone.",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
errorMessage?.let { message ->
Spacer(modifier = Modifier.height(10.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = FurumiHotOrange
)
}
Spacer(modifier = Modifier.height(18.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = onDismiss,
enabled = !isDeleting,
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = FurumiTextMuted
)
) {
Text("Cancel")
}
Button(
onClick = onConfirm,
enabled = !isDeleting,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = FurumiHotOrange,
contentColor = FurumiBlack,
disabledContainerColor = FurumiSurface,
disabledContentColor = FurumiTextMuted
)
) {
if (isDeleting) {
CircularProgressIndicator(
color = FurumiBlack,
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp)
)
} else {
Text("Delete")
}
}
}
}
}
}
}
}
@@ -93,6 +93,8 @@ internal fun FullPlayerOverlay(
onPlayPause: () -> Unit, onPlayPause: () -> Unit,
onPrevious: () -> Unit, onPrevious: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onToggleShuffle: () -> Unit,
onCycleRepeatMode: () -> Unit,
onSeekToProgress: (Float) -> Unit, onSeekToProgress: (Float) -> Unit,
onQueueTrackClick: (TrackCard, Int) -> Unit, onQueueTrackClick: (TrackCard, Int) -> Unit,
onToggleLike: (Long) -> Unit, onToggleLike: (Long) -> Unit,
@@ -116,6 +118,8 @@ internal fun FullPlayerOverlay(
val track = playback.currentTrack ?: return val track = playback.currentTrack ?: return
val queue = playback.queue val queue = playback.queue
var isDevicesMenuOpen by rememberSaveable { mutableStateOf(false) } var isDevicesMenuOpen by rememberSaveable { mutableStateOf(false) }
var currentTrackMenuExpanded by remember { mutableStateOf(false) }
var showPreviousQueue by remember { mutableStateOf(false) }
BackHandler(enabled = isDevicesMenuOpen) { BackHandler(enabled = isDevicesMenuOpen) {
isDevicesMenuOpen = false isDevicesMenuOpen = false
@@ -239,6 +243,44 @@ internal fun FullPlayerOverlay(
) )
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Box {
RoundControlButton(
size = 42,
background = FurumiSurface,
onClick = { currentTrackMenuExpanded = true }
) {
MoreDotsGlyph(
modifier = Modifier.size(22.dp),
color = MaterialTheme.colorScheme.onBackground
)
}
TrackContextMenu(
expanded = currentTrackMenuExpanded,
isLiked = likedTrackIds.contains(track.id),
onDismiss = { currentTrackMenuExpanded = false },
onToggleLike = {
currentTrackMenuExpanded = false
onToggleLike(track.id)
},
onPlayNext = {
currentTrackMenuExpanded = false
onPlayNext(track)
},
onPlayLast = {
currentTrackMenuExpanded = false
onPlayLast(track)
},
onShare = {
currentTrackMenuExpanded = false
onShare(track)
},
onAddToPlaylist = {
currentTrackMenuExpanded = false
onAddToPlaylist(track)
}
)
}
Spacer(modifier = Modifier.width(12.dp))
RoundControlButton( RoundControlButton(
size = 42, size = 42,
background = if (connectedDevicesState?.currentJamId != null) FurumiNeonPink else FurumiSurface, background = if (connectedDevicesState?.currentJamId != null) FurumiNeonPink else FurumiSurface,
@@ -335,6 +377,16 @@ internal fun FullPlayerOverlay(
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RoundControlButton(
size = 48,
background = FurumiSurface,
onClick = onToggleShuffle
) {
ShuffleGlyph(
modifier = Modifier.size(20.dp),
color = if (playback.shuffle) FurumiNeonPink else MaterialTheme.colorScheme.onBackground
)
}
RoundControlButton( RoundControlButton(
size = 48, size = 48,
background = FurumiSurface, background = FurumiSurface,
@@ -360,42 +412,42 @@ internal fun FullPlayerOverlay(
) { ) {
NextGlyph(Modifier.size(20.dp), MaterialTheme.colorScheme.onBackground) NextGlyph(Modifier.size(20.dp), MaterialTheme.colorScheme.onBackground)
} }
RoundControlButton(
size = 48,
background = FurumiSurface,
onClick = onCycleRepeatMode
) {
RepeatGlyph(
modifier = Modifier.size(22.dp),
color = if (playback.repeatMode.equals("off", ignoreCase = true)) {
MaterialTheme.colorScheme.onBackground
} else {
FurumiNeonPink
},
repeatMode = playback.repeatMode
)
}
} }
Spacer(modifier = Modifier.height(34.dp)) Spacer(modifier = Modifier.height(34.dp))
SectionTitle("Queue") SectionTitle("Queue")
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
if (queue.isEmpty()) { FullPlayerQueue(
Text( queue = queue,
text = "Queue is empty", playback = playback,
style = MaterialTheme.typography.bodyMedium, mediaImages = mediaImages,
color = FurumiTextMuted likedTrackIds = likedTrackIds,
) showPreviousQueue = showPreviousQueue,
} else { onShowPrevious = { showPreviousQueue = true },
queue.forEachIndexed { index, queueTrack -> onMediaImageNeeded = onMediaImageNeeded,
val isPlayed = index < playback.currentIndex onQueueTrackClick = onQueueTrackClick,
val isCurrent = index == playback.currentIndex onToggleLike = onToggleLike,
onPlayNext = onPlayNext,
QueueTrackRow( onPlayLast = onPlayLast,
track = queueTrack, onShare = onShare,
bitmap = mediaImageFor(mediaImages, queueTrack.coverUrl), onAddToPlaylist = onAddToPlaylist
isLiked = likedTrackIds.contains(queueTrack.id), )
isPlayed = isPlayed,
isCurrent = isCurrent,
onMediaImageNeeded = onMediaImageNeeded,
onClick = { onQueueTrackClick(queueTrack, index) },
onToggleLike = { onToggleLike(queueTrack.id) },
onPlayNext = { onPlayNext(queueTrack) },
onPlayLast = { onPlayLast(queueTrack) },
onShare = { onShare(queueTrack) },
onAddToPlaylist = { onAddToPlaylist(queueTrack) }
)
if (index < queue.lastIndex) {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
} }
if (isDevicesMenuOpen) { if (isDevicesMenuOpen) {
@@ -417,6 +469,94 @@ internal fun FullPlayerOverlay(
} }
} }
@Composable
private fun FullPlayerQueue(
queue: List<TrackCard>,
playback: AudioPlaybackState,
mediaImages: Map<String, Bitmap>,
likedTrackIds: Set<Long>,
showPreviousQueue: Boolean,
onShowPrevious: () -> Unit,
onMediaImageNeeded: (String?) -> Unit,
onQueueTrackClick: (TrackCard, Int) -> Unit,
onToggleLike: (Long) -> Unit,
onPlayNext: (TrackCard) -> Unit,
onPlayLast: (TrackCard) -> Unit,
onShare: (TrackCard) -> Unit,
onAddToPlaylist: (TrackCard) -> Unit
) {
if (queue.isEmpty()) {
Text(
text = "Queue is empty",
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
)
return
}
val currentIndex = playback.currentIndex
.takeIf { it in queue.indices }
?: queue.indexOfFirst { it.id == playback.currentTrack?.id }
.takeIf { it >= 0 }
?: 0
val firstVisibleIndex = if (showPreviousQueue) 0 else currentIndex
val visibleQueue = queue
.mapIndexed { index, queueTrack -> index to queueTrack }
.drop(firstVisibleIndex)
if (!showPreviousQueue && currentIndex > 0) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.height(30.dp)
.clip(CircleShape)
.clickable(onClick = onShowPrevious),
shape = CircleShape,
color = FurumiSurface,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
) {
Box(
modifier = Modifier.padding(horizontal = 14.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Show previous",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
visibleQueue.forEachIndexed { visibleIndex, (index, queueTrack) ->
val isPlayed = index < currentIndex
val isCurrent = index == currentIndex
QueueTrackRow(
track = queueTrack,
bitmap = mediaImageFor(mediaImages, queueTrack.coverUrl),
isLiked = likedTrackIds.contains(queueTrack.id),
isPlayed = isPlayed,
isCurrent = isCurrent,
onMediaImageNeeded = onMediaImageNeeded,
onClick = { onQueueTrackClick(queueTrack, index) },
onToggleLike = { onToggleLike(queueTrack.id) },
onPlayNext = { onPlayNext(queueTrack) },
onPlayLast = { onPlayLast(queueTrack) },
onShare = { onShare(queueTrack) },
onAddToPlaylist = { onAddToPlaylist(queueTrack) }
)
if (visibleIndex < visibleQueue.lastIndex) {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
@Composable @Composable
internal fun PlayerProgress( internal fun PlayerProgress(
playback: AudioPlaybackState, playback: AudioPlaybackState,
@@ -93,6 +93,14 @@ internal fun ArtistDetailTrackRow(
onAddToPlaylist: () -> Unit onAddToPlaylist: () -> Unit
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
val isUnavailable = track.isUnavailableForPlayback()
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
val primaryColor = if (isUnavailable) {
FurumiTextMuted.copy(alpha = 0.72f)
} else {
MaterialTheme.colorScheme.onBackground
}
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
LaunchedEffect(track.coverUrl) { LaunchedEffect(track.coverUrl) {
onMediaImageNeeded(track.coverUrl) onMediaImageNeeded(track.coverUrl)
@@ -102,7 +110,7 @@ internal fun ArtistDetailTrackRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) } .clickable(enabled = !isUnavailable) { onTrackClick(track) }
.padding(vertical = 2.dp), .padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -110,13 +118,15 @@ internal fun ArtistDetailTrackRow(
text = index.toString(), text = index.toString(),
modifier = Modifier.width(28.dp), modifier = Modifier.width(28.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted color = secondaryColor
) )
MediaArtwork( MediaArtwork(
title = track.title, title = track.title,
seedId = track.id, seedId = track.id,
bitmap = bitmap, bitmap = bitmap,
modifier = Modifier.size(48.dp), modifier = Modifier
.size(48.dp)
.unavailableTrackArtworkOverlay(isUnavailable),
cornerRadius = 6 cornerRadius = 6
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
@@ -124,7 +134,7 @@ internal fun ArtistDetailTrackRow(
Text( Text(
text = track.title, text = track.title,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground, color = primaryColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -133,7 +143,7 @@ internal fun ArtistDetailTrackRow(
.ifBlank { track.releaseTitle.orEmpty() } .ifBlank { track.releaseTitle.orEmpty() }
.ifBlank { "Unknown artist" }, .ifBlank { "Unknown artist" },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted, color = secondaryColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -144,7 +154,8 @@ internal fun ArtistDetailTrackRow(
.size(32.dp) .size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onToggleLike) .clickable(onClick = onToggleLike)
.padding(4.dp) .padding(4.dp),
alpha = contentAlpha
) )
Box { Box {
MoreDotsGlyph( MoreDotsGlyph(
@@ -152,7 +163,8 @@ internal fun ArtistDetailTrackRow(
.size(32.dp) .size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable { menuExpanded = true } .clickable { menuExpanded = true }
.padding(4.dp) .padding(4.dp),
color = FurumiTextMuted.copy(alpha = contentAlpha)
) )
TrackContextMenu( TrackContextMenu(
expanded = menuExpanded, expanded = menuExpanded,
@@ -169,7 +181,7 @@ internal fun ArtistDetailTrackRow(
Text( Text(
text = formatDuration(track.durationSeconds), text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted color = secondaryColor
) )
} }
} }
@@ -187,12 +199,20 @@ internal fun ReleaseTrackRow(
onAddToPlaylist: () -> Unit onAddToPlaylist: () -> Unit
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
val isUnavailable = track.isUnavailableForPlayback()
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
val primaryColor = if (isUnavailable) {
FurumiTextMuted.copy(alpha = 0.72f)
} else {
MaterialTheme.colorScheme.onBackground
}
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) } .clickable(enabled = !isUnavailable) { onTrackClick(track) }
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -200,20 +220,20 @@ internal fun ReleaseTrackRow(
text = (track.trackNumber ?: fallbackIndex).toString(), text = (track.trackNumber ?: fallbackIndex).toString(),
modifier = Modifier.width(32.dp), modifier = Modifier.width(32.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted color = secondaryColor
) )
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = track.title, text = track.title,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground, color = primaryColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = track.artists.joinToString(", ").ifBlank { "Unknown artist" }, text = track.artists.joinToString(", ").ifBlank { "Unknown artist" },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted, color = secondaryColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -224,7 +244,8 @@ internal fun ReleaseTrackRow(
.size(32.dp) .size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onToggleLike) .clickable(onClick = onToggleLike)
.padding(4.dp) .padding(4.dp),
alpha = contentAlpha
) )
Box { Box {
MoreDotsGlyph( MoreDotsGlyph(
@@ -232,7 +253,8 @@ internal fun ReleaseTrackRow(
.size(32.dp) .size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable { menuExpanded = true } .clickable { menuExpanded = true }
.padding(4.dp) .padding(4.dp),
color = FurumiTextMuted.copy(alpha = contentAlpha)
) )
TrackContextMenu( TrackContextMenu(
expanded = menuExpanded, expanded = menuExpanded,
@@ -249,7 +271,7 @@ internal fun ReleaseTrackRow(
Text( Text(
text = formatDuration(track.durationSeconds), text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted color = secondaryColor
) )
} }
} }
@@ -269,6 +291,14 @@ internal fun PlaylistTrackRow(
onAddToPlaylist: () -> Unit onAddToPlaylist: () -> Unit
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
val isUnavailable = track.isUnavailableForPlayback()
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
val primaryColor = if (isUnavailable) {
FurumiTextMuted.copy(alpha = 0.72f)
} else {
MaterialTheme.colorScheme.onBackground
}
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
LaunchedEffect(track.coverUrl) { LaunchedEffect(track.coverUrl) {
onMediaImageNeeded(track.coverUrl) onMediaImageNeeded(track.coverUrl)
@@ -278,7 +308,7 @@ internal fun PlaylistTrackRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) } .clickable(enabled = !isUnavailable) { onTrackClick(track) }
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -286,13 +316,15 @@ internal fun PlaylistTrackRow(
text = index.toString(), text = index.toString(),
modifier = Modifier.width(28.dp), modifier = Modifier.width(28.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted color = secondaryColor
) )
MediaArtwork( MediaArtwork(
title = track.title, title = track.title,
seedId = track.id, seedId = track.id,
bitmap = bitmap, bitmap = bitmap,
modifier = Modifier.size(48.dp), modifier = Modifier
.size(48.dp)
.unavailableTrackArtworkOverlay(isUnavailable),
cornerRadius = 6 cornerRadius = 6
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
@@ -300,14 +332,14 @@ internal fun PlaylistTrackRow(
Text( Text(
text = track.title, text = track.title,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground, color = primaryColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = trackSubtitle(track), text = trackSubtitle(track),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted, color = secondaryColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -318,7 +350,8 @@ internal fun PlaylistTrackRow(
.size(32.dp) .size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable(onClick = onToggleLike) .clickable(onClick = onToggleLike)
.padding(4.dp) .padding(4.dp),
alpha = contentAlpha
) )
Box { Box {
MoreDotsGlyph( MoreDotsGlyph(
@@ -326,7 +359,8 @@ internal fun PlaylistTrackRow(
.size(32.dp) .size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable { menuExpanded = true } .clickable { menuExpanded = true }
.padding(4.dp) .padding(4.dp),
color = FurumiTextMuted.copy(alpha = contentAlpha)
) )
TrackContextMenu( TrackContextMenu(
expanded = menuExpanded, expanded = menuExpanded,
@@ -343,7 +377,7 @@ internal fun PlaylistTrackRow(
Text( Text(
text = formatDuration(track.durationSeconds), text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted color = secondaryColor
) )
} }
} }
@@ -496,3 +530,17 @@ internal fun QueueTrackRow(
) )
} }
} }
private fun TrackCard.isUnavailableForPlayback(): Boolean {
return streamUrl.isBlank()
}
private fun Modifier.unavailableTrackArtworkOverlay(isUnavailable: Boolean): Modifier {
if (!isUnavailable) return this
return drawWithContent {
drawContent()
drawRect(color = Color.Black.copy(alpha = 0.44f))
}
}
private const val UNAVAILABLE_TRACK_ALPHA = 0.42f
@@ -89,6 +89,16 @@ fun PlayerScreen(
var isProfileMenuOpen by rememberSaveable { mutableStateOf(false) } var isProfileMenuOpen by rememberSaveable { mutableStateOf(false) }
var isFullPlayerOpen by rememberSaveable { mutableStateOf(false) } var isFullPlayerOpen by rememberSaveable { mutableStateOf(false) }
var fullPlayerDragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) } var fullPlayerDragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
var addToPlaylistTrack by remember { mutableStateOf<TrackCard?>(null) }
var isCreatePlaylistDialogOpen by rememberSaveable { mutableStateOf(false) }
var createPlaylistTrack by remember { mutableStateOf<TrackCard?>(null) }
var deletePlaylistCandidate by remember { mutableStateOf<PlaylistCard?>(null) }
val openCreatePlaylistDialog: (TrackCard?) -> Unit = { track ->
viewModel.clearPlaylistCreateError()
createPlaylistTrack = track
isCreatePlaylistDialogOpen = true
}
BackHandler( BackHandler(
enabled = uiState.selectedPlaylist != null || enabled = uiState.selectedPlaylist != null ||
@@ -96,9 +106,24 @@ fun PlayerScreen(
uiState.selectedArtist != null || uiState.selectedArtist != null ||
isFullPlayerOpen || isFullPlayerOpen ||
isProfileMenuOpen || isProfileMenuOpen ||
uiState.isListeningHistoryVisible uiState.isListeningHistoryVisible ||
addToPlaylistTrack != null ||
isCreatePlaylistDialogOpen ||
deletePlaylistCandidate != null
) { ) {
when { when {
deletePlaylistCandidate != null -> {
if (!uiState.isDeletingPlaylist) {
deletePlaylistCandidate = null
}
}
isCreatePlaylistDialogOpen -> {
if (!uiState.isCreatingPlaylist) {
isCreatePlaylistDialogOpen = false
createPlaylistTrack = null
}
}
addToPlaylistTrack != null -> addToPlaylistTrack = null
uiState.isListeningHistoryVisible -> viewModel.closeListeningHistory() uiState.isListeningHistoryVisible -> viewModel.closeListeningHistory()
isFullPlayerOpen -> isFullPlayerOpen = false isFullPlayerOpen -> isFullPlayerOpen = false
uiState.selectedPlaylist != null -> viewModel.closePlaylistDetail() uiState.selectedPlaylist != null -> viewModel.closePlaylistDetail()
@@ -108,7 +133,6 @@ fun PlayerScreen(
} }
} }
var addToPlaylistTrack by remember { mutableStateOf<TrackCard?>(null) }
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(uiState.isLoggedOut) { LaunchedEffect(uiState.isLoggedOut) {
@@ -127,26 +151,11 @@ fun PlayerScreen(
} }
} }
val profileScrollConnection = remember {
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): Offset {
if (isProfileMenuOpen && source == androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag) {
isProfileMenuOpen = false
}
return Offset.Zero
}
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.nestedScroll(profileScrollConnection)
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Box( Box(
@@ -180,7 +189,11 @@ fun PlayerScreen(
onPlayNext = viewModel::addToPlayNext, onPlayNext = viewModel::addToPlayNext,
onPlayLast = viewModel::addToQueueEnd, onPlayLast = viewModel::addToQueueEnd,
onShare = viewModel::shareTrack, onShare = viewModel::shareTrack,
onAddToPlaylist = { track -> addToPlaylistTrack = track } onAddToPlaylist = { track -> addToPlaylistTrack = track },
onDeletePlaylistClick = { playlist ->
viewModel.clearPlaylistDeleteError()
deletePlaylistCandidate = playlist
}
) )
} }
} else if (uiState.selectedRelease != null) { } else if (uiState.selectedRelease != null) {
@@ -280,6 +293,8 @@ fun PlayerScreen(
onRetryPlaylists = viewModel::loadPlaylists, onRetryPlaylists = viewModel::loadPlaylists,
onRetryArtists = viewModel::retryLibraryArtists, onRetryArtists = viewModel::retryLibraryArtists,
onLoadMoreArtists = viewModel::loadMoreLibraryArtists, onLoadMoreArtists = viewModel::loadMoreLibraryArtists,
onCreatePlaylistClick = { openCreatePlaylistDialog(null) },
onPlaylistPreviewNeeded = viewModel::loadPlaylistPreview,
onPlaylistClick = viewModel::openPlaylist, onPlaylistClick = viewModel::openPlaylist,
onArtistImageNeeded = viewModel::loadMediaImage, onArtistImageNeeded = viewModel::loadMediaImage,
onArtistClick = viewModel::openArtist onArtistClick = viewModel::openArtist
@@ -302,6 +317,8 @@ fun PlayerScreen(
onPlayPause = viewModel::togglePlayPause, onPlayPause = viewModel::togglePlayPause,
onPrevious = viewModel::previousTrack, onPrevious = viewModel::previousTrack,
onNext = viewModel::nextTrack, onNext = viewModel::nextTrack,
onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeatMode = viewModel::cycleRepeatMode,
onToggleLike = { viewModel.toggleLike(track.id) } onToggleLike = { viewModel.toggleLike(track.id) }
) )
} }
@@ -323,10 +340,24 @@ fun PlayerScreen(
viewModel.openListeningHistory() viewModel.openListeningHistory()
}, },
onDeviceClick = viewModel::setActiveDevice, onDeviceClick = viewModel::setActiveDevice,
onOfflineModeChange = viewModel::setOfflineModeEnabled,
onSaveListenedChange = viewModel::setSaveListenedTracksEnabled,
onStorageLimitChange = viewModel::setOfflineStorageLimit,
onSyncOffline = viewModel::syncOfflineLibrary,
onClearOfflineCache = viewModel::clearOfflineCache,
onLogout = viewModel::logout, onLogout = viewModel::logout,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopCenter)
.padding(top = 72.dp, end = 20.dp) .padding(horizontal = 12.dp, vertical = 72.dp)
)
}
if (uiState.offlineState.isOfflineActive && !isProfileMenuOpen) {
OfflineStatusBadge(
offlineState = uiState.offlineState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 10.dp)
) )
} }
@@ -346,6 +377,8 @@ fun PlayerScreen(
onPlayPause = viewModel::togglePlayPause, onPlayPause = viewModel::togglePlayPause,
onPrevious = viewModel::previousTrack, onPrevious = viewModel::previousTrack,
onNext = viewModel::nextTrack, onNext = viewModel::nextTrack,
onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeatMode = viewModel::cycleRepeatMode,
onSeekToProgress = viewModel::seekToPlaybackProgress, onSeekToProgress = viewModel::seekToPlaybackProgress,
onQueueTrackClick = { track, index -> onQueueTrackClick = { track, index ->
viewModel.playTrack(track, displayedPlayback.queue, index) viewModel.playTrack(track, displayedPlayback.queue, index)
@@ -386,6 +419,10 @@ fun PlayerScreen(
playlists = uiState.playlists, playlists = uiState.playlists,
isLoading = uiState.isPlaylistsLoading, isLoading = uiState.isPlaylistsLoading,
onLoadPlaylists = viewModel::loadPlaylists, onLoadPlaylists = viewModel::loadPlaylists,
onCreatePlaylist = {
addToPlaylistTrack = null
openCreatePlaylistDialog(track)
},
onPlaylistSelected = { playlistId -> onPlaylistSelected = { playlistId ->
viewModel.addTrackToPlaylist(track.id, playlistId) viewModel.addTrackToPlaylist(track.id, playlistId)
addToPlaylistTrack = null addToPlaylistTrack = null
@@ -393,10 +430,51 @@ fun PlayerScreen(
onDismiss = { addToPlaylistTrack = null } onDismiss = { addToPlaylistTrack = null }
) )
} }
if (isCreatePlaylistDialogOpen) {
CreatePlaylistDialog(
isCreating = uiState.isCreatingPlaylist,
errorMessage = uiState.playlistCreateError,
onSubmit = { title ->
viewModel.createPlaylist(
title = title,
trackIdToAdd = createPlaylistTrack?.id
) {
isCreatePlaylistDialogOpen = false
createPlaylistTrack = null
}
},
onDismiss = {
if (!uiState.isCreatingPlaylist) {
isCreatePlaylistDialogOpen = false
createPlaylistTrack = null
}
}
)
}
deletePlaylistCandidate?.let { playlist ->
DeletePlaylistDialog(
playlist = playlist,
isDeleting = uiState.isDeletingPlaylist,
errorMessage = uiState.playlistDeleteError,
onConfirm = {
viewModel.deletePlaylist(playlist) {
deletePlaylistCandidate = null
}
},
onDismiss = {
if (!uiState.isDeletingPlaylist) {
deletePlaylistCandidate = null
}
}
)
}
} }
} }
private fun PlayerUiState.displayedPlayback(): AudioPlaybackState { private fun PlayerUiState.displayedPlayback(): AudioPlaybackState {
if (offlineState.isOfflineActive) return playback
val connectedState = connectedDevicesState val connectedState = connectedDevicesState
val remotePlayback = connectedState?.remotePlaybackState val remotePlayback = connectedState?.remotePlaybackState
return if (connectedState != null && return if (connectedState != null &&
@@ -147,14 +147,10 @@ internal fun releaseArtistsText(artists: List<ArtistCard>): String {
internal fun playlistMeta(playlist: PlaylistCard): String { internal fun playlistMeta(playlist: PlaylistCard): String {
val parts = buildList { val parts = buildList {
playlist.kind add(pluralCount(playlist.trackCount, "track"))
?.replace('_', ' ')
?.takeIf { it.isNotBlank() }
?.let { add(it) }
playlist.ownerName playlist.ownerName
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.let { add(it) } ?.let { add(it) }
add(pluralCount(playlist.trackCount, "track"))
} }
return parts.joinToString(" / ") return parts.joinToString(" / ")
@@ -12,6 +12,7 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
import cy.hexor.furumi.domain.model.ConnectedJamUser import cy.hexor.furumi.domain.model.ConnectedJamUser
import cy.hexor.furumi.domain.model.ConnectedPlaybackState import cy.hexor.furumi.domain.model.ConnectedPlaybackState
import cy.hexor.furumi.domain.model.ListeningHistoryItem import cy.hexor.furumi.domain.model.ListeningHistoryItem
import cy.hexor.furumi.domain.model.OfflineState
import cy.hexor.furumi.domain.model.PlaylistCard import cy.hexor.furumi.domain.model.PlaylistCard
import cy.hexor.furumi.domain.model.PlaylistDetail import cy.hexor.furumi.domain.model.PlaylistDetail
import cy.hexor.furumi.domain.model.ReleaseCard import cy.hexor.furumi.domain.model.ReleaseCard
@@ -78,6 +79,12 @@ data class PlayerUiState(
val playlists: List<PlaylistCard> = emptyList(), val playlists: List<PlaylistCard> = emptyList(),
val isPlaylistsLoading: Boolean = false, val isPlaylistsLoading: Boolean = false,
val playlistsError: String? = null, val playlistsError: String? = null,
val playlistPreviewCoverUrls: Map<Long, List<String>> = emptyMap(),
val loadingPlaylistPreviewIds: Set<Long> = emptySet(),
val isCreatingPlaylist: Boolean = false,
val playlistCreateError: String? = null,
val isDeletingPlaylist: Boolean = false,
val playlistDeleteError: String? = null,
val selectedPlaylist: PlaylistCard? = null, val selectedPlaylist: PlaylistCard? = null,
val playlistDetail: PlaylistDetail? = null, val playlistDetail: PlaylistDetail? = null,
val isPlaylistDetailLoading: Boolean = false, val isPlaylistDetailLoading: Boolean = false,
@@ -86,7 +93,8 @@ data class PlayerUiState(
val connectedDevicesError: String? = null, val connectedDevicesError: String? = null,
val jamInviteQuery: String = "", val jamInviteQuery: String = "",
val jamInviteUsers: List<ConnectedJamUser> = emptyList(), val jamInviteUsers: List<ConnectedJamUser> = emptyList(),
val isJamInviteSearchLoading: Boolean = false val isJamInviteSearchLoading: Boolean = false,
val offlineState: OfflineState = OfflineState()
) )
@HiltViewModel @HiltViewModel
@@ -103,10 +111,13 @@ class PlayerViewModel @Inject constructor(
private val playlistDetailCache = mutableMapOf<Long, PlaylistDetail>() private val playlistDetailCache = mutableMapOf<Long, PlaylistDetail>()
private var searchJob: Job? = null private var searchJob: Job? = null
private var jamInviteSearchJob: Job? = null private var jamInviteSearchJob: Job? = null
private var offlineModeRefreshJob: Job? = null
private val handledConnectedCommandIds = ArrayDeque<String>() private val handledConnectedCommandIds = ArrayDeque<String>()
private val handledConnectedCommandIdSet = mutableSetOf<String>() private val handledConnectedCommandIdSet = mutableSetOf<String>()
private var wasCurrentDeviceActive = false private var wasCurrentDeviceActive = false
private var wasControllingRemoteJam = false private var wasControllingRemoteJam = false
private var hasResolvedStartupActiveDevice = false
private var shouldClaimActiveDeviceOnReconnect = 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()
@@ -126,6 +137,7 @@ class PlayerViewModel @Inject constructor(
init { init {
loadGlobalArtistsIfNeeded() loadGlobalArtistsIfNeeded()
observeOfflineState()
observePlayback() observePlayback()
loadLikedTrackIds() loadLikedTrackIds()
startConnectedDevicesPolling() startConnectedDevicesPolling()
@@ -418,6 +430,37 @@ class PlayerViewModel @Inject constructor(
playbackController.togglePlayPause() playbackController.togglePlayPause()
} }
fun toggleShuffle() {
val (currentShuffle, currentRepeatMode) = playbackOptionsForActiveTarget()
activeRemoteDeviceId()?.let { targetDeviceId ->
sendConnectedCommand(
targetDeviceId = targetDeviceId,
command = "set_options",
shuffle = !currentShuffle,
repeatMode = currentRepeatMode
)
return
}
playbackController.setOptions(shuffle = !currentShuffle, repeatMode = null)
}
fun cycleRepeatMode() {
val (currentShuffle, currentRepeatMode) = playbackOptionsForActiveTarget()
val nextRepeatMode = currentRepeatMode.nextPlaybackRepeatMode()
activeRemoteDeviceId()?.let { targetDeviceId ->
sendConnectedCommand(
targetDeviceId = targetDeviceId,
command = "set_options",
shuffle = currentShuffle,
repeatMode = nextRepeatMode
)
return
}
playbackController.setOptions(shuffle = null, repeatMode = nextRepeatMode)
}
fun nextTrack() { fun nextTrack() {
activeRemoteDeviceId()?.let { targetDeviceId -> activeRemoteDeviceId()?.let { targetDeviceId ->
val remoteState = _uiState.value.connectedDevicesState?.remotePlaybackState val remoteState = _uiState.value.connectedDevicesState?.remotePlaybackState
@@ -443,6 +486,7 @@ class PlayerViewModel @Inject constructor(
} }
fun setActiveDevice(deviceId: String) { fun setActiveDevice(deviceId: String) {
if (_uiState.value.offlineState.isOfflineActive) return
viewModelScope.launch { viewModelScope.launch {
playerRepository.setActiveDevice(deviceId) playerRepository.setActiveDevice(deviceId)
.onSuccess { devicesState -> .onSuccess { devicesState ->
@@ -456,6 +500,49 @@ class PlayerViewModel @Inject constructor(
} }
} }
fun setOfflineModeEnabled(enabled: Boolean) {
playerRepository.setOfflineModeEnabled(enabled)
if (enabled) {
_uiState.value = _uiState.value.copy(
connectedDevicesState = null,
connectedDevicesError = null
)
syncOfflineLibrary()
} else {
shouldClaimActiveDeviceOnReconnect = true
}
}
fun setSaveListenedTracksEnabled(enabled: Boolean) {
playerRepository.setSaveListenedTracksEnabled(enabled)
if (enabled) {
syncOfflineLibrary()
}
}
fun setOfflineStorageLimit(limitBytes: Long) {
playerRepository.setOfflineStorageLimit(limitBytes)
}
fun syncOfflineLibrary() {
if (_uiState.value.offlineState.isSyncing) return
viewModelScope.launch {
playerRepository.syncOfflineLibrary()
.onSuccess {
refreshAfterOfflineLibraryChange()
}
}
}
fun clearOfflineCache() {
viewModelScope.launch {
playerRepository.clearOfflineCache()
.onSuccess {
refreshAfterOfflineLibraryChange()
}
}
}
fun createJam() { fun createJam() {
viewModelScope.launch { viewModelScope.launch {
playerRepository.createJam() playerRepository.createJam()
@@ -698,12 +785,187 @@ class PlayerViewModel @Inject constructor(
loadPlaylists() loadPlaylists()
} }
fun loadPlaylistPreview(playlist: PlaylistCard) {
if (playlist.isLikesPlaylistPreview()) return
val state = _uiState.value
if (state.playlistPreviewCoverUrls.containsKey(playlist.id) ||
state.loadingPlaylistPreviewIds.contains(playlist.id)
) {
return
}
playlistDetailCache[playlist.id]?.let { detail ->
setPlaylistPreviewCoverUrls(playlist.id, detail.previewCoverUrls())
return
}
_uiState.value = state.copy(
loadingPlaylistPreviewIds = state.loadingPlaylistPreviewIds + playlist.id
)
viewModelScope.launch {
playerRepository.getPlaylistDetail(playlist.id)
.onSuccess { detail ->
playlistDetailCache[playlist.id] = detail
setPlaylistPreviewCoverUrls(playlist.id, detail.previewCoverUrls())
}
.onFailure {
setPlaylistPreviewCoverUrls(playlist.id, emptyList())
}
}
}
fun clearPlaylistCreateError() {
if (_uiState.value.playlistCreateError == null) return
_uiState.value = _uiState.value.copy(playlistCreateError = null)
}
fun clearPlaylistDeleteError() {
if (_uiState.value.playlistDeleteError == null) return
_uiState.value = _uiState.value.copy(playlistDeleteError = null)
}
fun createPlaylist(
title: String,
trackIdToAdd: Long? = null,
onCreated: () -> Unit = {}
) {
val playlistTitle = title.trim()
if (playlistTitle.isBlank()) {
_uiState.value = _uiState.value.copy(playlistCreateError = "Playlist name is required")
return
}
if (_uiState.value.isCreatingPlaylist) return
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = true,
playlistCreateError = null
)
viewModelScope.launch {
playerRepository.createPlaylist(playlistTitle)
.onSuccess { playlist ->
if (trackIdToAdd == null) {
addPlaylistToState(playlist)
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = false,
playlistCreateError = null
)
onCreated()
return@launch
}
playerRepository.addTracksToPlaylist(playlist.id, listOf(trackIdToAdd))
.onSuccess {
addPlaylistToState(playlist.copy(trackCount = playlist.trackCount + 1))
playlistDetailCache.remove(playlist.id)
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = false,
playlistCreateError = null
)
onCreated()
}
.onFailure { error ->
addPlaylistToState(playlist)
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = false,
playlistCreateError = error.message
?: "Playlist was created, but the track could not be added"
)
}
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isCreatingPlaylist = false,
playlistCreateError = error.message ?: "Unable to create playlist"
)
}
}
}
fun deletePlaylist(
playlist: PlaylistCard,
onDeleted: () -> Unit = {}
) {
if (!playlist.canDeleteUserPlaylist()) return
if (_uiState.value.isDeletingPlaylist) return
_uiState.value = _uiState.value.copy(
isDeletingPlaylist = true,
playlistDeleteError = null
)
viewModelScope.launch {
playerRepository.deletePlaylist(playlist.id)
.onSuccess {
playlistDetailCache.remove(playlist.id)
_uiState.value = _uiState.value.copy(
playlists = _uiState.value.playlists.filterNot { it.id == playlist.id },
playlistPreviewCoverUrls = _uiState.value.playlistPreviewCoverUrls - playlist.id,
loadingPlaylistPreviewIds = _uiState.value.loadingPlaylistPreviewIds - playlist.id,
selectedPlaylist = null,
playlistDetail = null,
isPlaylistDetailLoading = false,
playlistDetailError = null,
isDeletingPlaylist = false,
playlistDeleteError = null
)
onDeleted()
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isDeletingPlaylist = false,
playlistDeleteError = error.message ?: "Unable to delete playlist"
)
}
}
}
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))
.onSuccess {
incrementPlaylistTrackCount(playlistId)
playlistDetailCache.remove(playlistId)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
playlistsError = error.message ?: "Unable to add track to playlist"
)
}
} }
} }
private fun addPlaylistToState(playlist: PlaylistCard) {
val currentPlaylists = _uiState.value.playlists
_uiState.value = _uiState.value.copy(
playlists = listOf(playlist) + currentPlaylists.filterNot { it.id == playlist.id }
)
}
private fun incrementPlaylistTrackCount(playlistId: Long) {
_uiState.value = _uiState.value.copy(
playlists = _uiState.value.playlists.map { playlist ->
if (playlist.id == playlistId) {
playlist.copy(trackCount = playlist.trackCount + 1)
} else {
playlist
}
}
)
}
private fun setPlaylistPreviewCoverUrls(playlistId: Long, coverUrls: List<String>) {
val distinctCoverUrls = coverUrls.distinct().take(PLAYLIST_PREVIEW_IMAGE_LIMIT)
val state = _uiState.value
_uiState.value = state.copy(
playlistPreviewCoverUrls = state.playlistPreviewCoverUrls + (playlistId to distinctCoverUrls),
loadingPlaylistPreviewIds = state.loadingPlaylistPreviewIds - playlistId
)
distinctCoverUrls.forEach(::loadMediaImage)
}
private fun reportListeningHistory( private fun reportListeningHistory(
trackId: Long, trackId: Long,
startedAt: Long, startedAt: Long,
@@ -770,6 +1032,35 @@ class PlayerViewModel @Inject constructor(
} }
} }
private fun observeOfflineState() {
viewModelScope.launch {
playerRepository.offlineState.collect { offlineState ->
val current = _uiState.value
val offlineModeChanged = current.offlineState.isOfflineActive != offlineState.isOfflineActive
if (current.offlineState.isOfflineActive && !offlineState.isOfflineActive) {
shouldClaimActiveDeviceOnReconnect = true
}
_uiState.value = current.copy(
offlineState = offlineState,
connectedDevicesState = if (offlineState.settings.offlineModeEnabled) {
null
} else {
current.connectedDevicesState
},
connectedDevicesError = if (offlineState.settings.offlineModeEnabled) {
null
} else {
current.connectedDevicesError
}
)
if (offlineModeChanged) {
clearModeSensitiveCaches()
refreshVisibleContentForOfflineMode()
}
}
}
}
private fun startConnectedDevicesPolling() { private fun startConnectedDevicesPolling() {
viewModelScope.launch { viewModelScope.launch {
while (true) { while (true) {
@@ -781,6 +1072,9 @@ class PlayerViewModel @Inject constructor(
private suspend fun pollConnectedDevices() { private suspend fun pollConnectedDevices() {
val state = _uiState.value val state = _uiState.value
if (state.offlineState.settings.offlineModeEnabled) return
val shouldClaimActiveDevice = shouldClaimActiveDeviceOnReconnect ||
state.offlineState.isUsingNetworkFallback
val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true
// Android follows the spec: only the active device sends its playback state. // Android follows the spec: only the active device sends its playback state.
@@ -795,11 +1089,21 @@ class PlayerViewModel @Inject constructor(
playbackState = playbackState, playbackState = playbackState,
currentJamId = state.connectedDevicesState?.currentJamId currentJamId = state.connectedDevicesState?.currentJamId
).onSuccess { (devicesState, commands) -> ).onSuccess { (devicesState, commands) ->
applyConnectedDevicesState(devicesState) val resolvedDevicesState = if (shouldClaimActiveDevice) {
claimCurrentDeviceActive(devicesState)
} else {
resolveStartupActiveDevice(devicesState)
}
applyConnectedDevicesState(
devicesState = resolvedDevicesState,
pauseOnLostControl = !shouldClaimActiveDevice
)
commands.forEach { command -> if (!shouldClaimActiveDevice) {
if (shouldHandleConnectedCommand(command)) { commands.forEach { command ->
handleConnectedCommand(command) if (shouldHandleConnectedCommand(command)) {
handleConnectedCommand(command)
}
} }
} }
}.onFailure { error -> }.onFailure { error ->
@@ -809,7 +1113,65 @@ class PlayerViewModel @Inject constructor(
} }
} }
private fun applyConnectedDevicesState(devicesState: ConnectedDevicesState) { private suspend fun resolveStartupActiveDevice(devicesState: ConnectedDevicesState): ConnectedDevicesState {
if (hasResolvedStartupActiveDevice) return devicesState
hasResolvedStartupActiveDevice = true
if (!devicesState.shouldCurrentDeviceBecomeActiveOnStartup()) return devicesState
return playerRepository.setActiveDevice(devicesState.deviceId)
.onFailure { error ->
_uiState.value = _uiState.value.copy(
connectedDevicesError = error.message ?: "Unable to switch active device"
)
}
.getOrElse { devicesState }
}
private suspend fun claimCurrentDeviceActive(devicesState: ConnectedDevicesState): ConnectedDevicesState {
val activeDevicesState = if (devicesState.isCurrentDeviceActive) {
devicesState
} else {
playerRepository.setActiveDevice(devicesState.deviceId)
.onFailure { error ->
_uiState.value = _uiState.value.copy(
connectedDevicesError = error.message ?: "Unable to switch active device"
)
}
.getOrElse { return devicesState }
}
val (syncedDevicesState, didSync) = pushLocalPlaybackStateAfterReconnect(activeDevicesState)
if (didSync) {
shouldClaimActiveDeviceOnReconnect = false
}
return syncedDevicesState
}
private suspend fun pushLocalPlaybackStateAfterReconnect(
devicesState: ConnectedDevicesState
): Pair<ConnectedDevicesState, Boolean> {
val playbackState = _uiState.value.playback.toConnectedPlaybackState()
?: return devicesState to true
return playerRepository.pollConnectedDevice(
playbackState = playbackState,
currentJamId = devicesState.currentJamId
)
.onFailure { error ->
_uiState.value = _uiState.value.copy(
connectedDevicesError = error.message ?: "Unable to sync playback state"
)
}
.fold(
onSuccess = { (updatedDevicesState, _) -> updatedDevicesState to true },
onFailure = { devicesState to false }
)
}
private fun applyConnectedDevicesState(
devicesState: ConnectedDevicesState,
pauseOnLostControl: Boolean = true
) {
val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive
val isControllingRemoteJam = devicesState.isControllingRemoteJam() val isControllingRemoteJam = devicesState.isControllingRemoteJam()
val startedControllingRemoteJam = !wasControllingRemoteJam && isControllingRemoteJam val startedControllingRemoteJam = !wasControllingRemoteJam && isControllingRemoteJam
@@ -819,7 +1181,10 @@ class PlayerViewModel @Inject constructor(
) )
wasCurrentDeviceActive = devicesState.isCurrentDeviceActive wasCurrentDeviceActive = devicesState.isCurrentDeviceActive
wasControllingRemoteJam = isControllingRemoteJam wasControllingRemoteJam = isControllingRemoteJam
if ((becameInactive || startedControllingRemoteJam) && _uiState.value.playback.currentTrack != null) { if (pauseOnLostControl &&
(becameInactive || startedControllingRemoteJam) &&
_uiState.value.playback.currentTrack != null
) {
playbackController.pause() playbackController.pause()
} }
} }
@@ -879,7 +1244,7 @@ class PlayerViewModel @Inject constructor(
playbackController.next() playbackController.next()
} }
"prev" -> playbackController.previous() "prev" -> playbackController.previous()
"set_volume" -> payload.volume?.let(playbackController::setVolume) "set_volume" -> Unit // Android keeps loudness on the system media stream.
"set_options" -> playbackController.setOptions(payload.shuffle, payload.repeatMode) "set_options" -> playbackController.setOptions(payload.shuffle, payload.repeatMode)
"queue_add_end" -> playbackController.addToEnd(payload.tracks) "queue_add_end" -> playbackController.addToEnd(payload.tracks)
"queue_add_next" -> playbackController.addNext(payload.tracks) "queue_add_next" -> playbackController.addNext(payload.tracks)
@@ -894,6 +1259,8 @@ class PlayerViewModel @Inject constructor(
} }
private fun activeRemoteDeviceId(): String? { private fun activeRemoteDeviceId(): String? {
if (_uiState.value.offlineState.isOfflineActive) return null
if (shouldClaimActiveDeviceOnReconnect) return null
val devicesState = _uiState.value.connectedDevicesState ?: return null val devicesState = _uiState.value.connectedDevicesState ?: return null
if (devicesState.isControllingRemoteJam()) { if (devicesState.isControllingRemoteJam()) {
return devicesState.activeDeviceId ?: devicesState.deviceId return devicesState.activeDeviceId ?: devicesState.deviceId
@@ -902,6 +1269,15 @@ class PlayerViewModel @Inject constructor(
return activeDeviceId.takeIf { it != devicesState.deviceId } return activeDeviceId.takeIf { it != devicesState.deviceId }
} }
private fun playbackOptionsForActiveTarget(): Pair<Boolean, String> {
val state = _uiState.value
val remotePlayback = state.connectedDevicesState?.remotePlaybackState
if (activeRemoteDeviceId() != null && remotePlayback != null) {
return remotePlayback.shuffle to remotePlayback.repeatMode
}
return state.playback.shuffle to state.playback.repeatMode
}
private fun sendConnectedCommand( private fun sendConnectedCommand(
targetDeviceId: String, targetDeviceId: String,
command: String, command: String,
@@ -1157,6 +1533,7 @@ class PlayerViewModel @Inject constructor(
isPlaylistDetailLoading = false, isPlaylistDetailLoading = false,
playlistDetailError = null playlistDetailError = null
) )
setPlaylistPreviewCoverUrls(playlistId, detail.previewCoverUrls())
} }
.onFailure { error -> .onFailure { error ->
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -1167,6 +1544,54 @@ class PlayerViewModel @Inject constructor(
} }
} }
private fun clearModeSensitiveCaches() {
artistDetailCache.clear()
releaseDetailCache.clear()
playlistDetailCache.clear()
}
private fun refreshVisibleContentForOfflineMode() {
offlineModeRefreshJob?.cancel()
offlineModeRefreshJob = viewModelScope.launch {
val state = _uiState.value
when {
state.selectedPlaylist != null -> {
loadPlaylistDetail(state.selectedPlaylist.id)
}
state.selectedRelease != null -> {
loadReleaseDetail(state.selectedRelease.id)
}
state.selectedArtist != null -> {
loadArtistDetail(state.selectedArtist.id)
}
state.searchQuery.isNotBlank() && state.searchResults != null -> {
searchJob?.cancel()
search(state.searchQuery)
}
state.playlists.isNotEmpty() -> {
loadPlaylists()
}
}
}
}
private fun refreshAfterOfflineLibraryChange() {
clearModeSensitiveCaches()
_uiState.value = _uiState.value.copy(
playlists = emptyList(),
playlistPreviewCoverUrls = emptyMap(),
loadingPlaylistPreviewIds = emptySet()
)
loadLikedTrackIds()
if (_uiState.value.selectedPlaylist != null) {
_uiState.value.selectedPlaylist?.id?.let(::loadPlaylistDetail)
} else {
loadPlaylists()
}
_uiState.value.selectedArtist?.id?.let(::loadArtistDetail)
_uiState.value.selectedRelease?.id?.let(::loadReleaseDetail)
}
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_DEBOUNCE_MS = 300L
@@ -1191,7 +1616,7 @@ private fun AudioPlaybackState.toConnectedPlaybackState(): ConnectedPlaybackStat
paused = !isPlaying, paused = !isPlaying,
shuffle = shuffle, shuffle = shuffle,
repeatMode = repeatMode, repeatMode = repeatMode,
volume = volume.toDouble() volume = ANDROID_CONNECTED_VOLUME
) )
} }
@@ -1201,6 +1626,29 @@ private fun ConnectedDevicesState.isControllingRemoteJam(): Boolean {
return currentJam != null && currentJam.isMember && !currentJam.isOwner return currentJam != null && currentJam.isMember && !currentJam.isOwner
} }
private fun ConnectedDevicesState.shouldCurrentDeviceBecomeActiveOnStartup(): Boolean {
if (isCurrentDeviceActive || isControllingRemoteJam()) return false
return remotePlaybackState?.isActivelyPlaying() != true
}
private fun ConnectedPlaybackState.isActivelyPlaying(): Boolean {
return !paused && (track != null || tracks.isNotEmpty())
}
private fun PlaylistCard.isLikesPlaylistPreview(): Boolean {
return id == -1L || kind.equals("likes", ignoreCase = true)
}
private fun PlaylistCard.canDeleteUserPlaylist(): Boolean {
return isOwn && !isLikesPlaylistPreview()
}
private fun PlaylistDetail.previewCoverUrls(): List<String> {
return tracks.mapNotNull { track ->
track.coverUrl?.takeIf { it.isNotBlank() }
}.take(PLAYLIST_PREVIEW_IMAGE_LIMIT)
}
private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults { private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults {
if (artistCards.isEmpty()) return this if (artistCards.isEmpty()) return this
return copy( return copy(
@@ -1209,6 +1657,17 @@ private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): S
) )
} }
private const val ANDROID_CONNECTED_VOLUME = 1.0
private const val PLAYLIST_PREVIEW_IMAGE_LIMIT = 4
private fun String.nextPlaybackRepeatMode(): String {
return when (lowercase()) {
"off" -> "all"
"all" -> "one"
else -> "off"
}
}
private fun String.withoutLineBreaks(): String { private fun String.withoutLineBreaks(): String {
return replace("\r", "").replace("\n", "") return replace("\r", "").replace("\n", "")
} }
+5 -5
View File
@@ -28,14 +28,14 @@ RepoType: git
Repo: https://gt.hexor.cy/ab/furumi_android.git Repo: https://gt.hexor.cy/ab/furumi_android.git
Builds: Builds:
- versionName: '1.1' - versionName: '1.3'
versionCode: 1 versionCode: 3
commit: v1.1 commit: v1.3
subdir: app subdir: app
gradle: gradle:
- yes - yes
AutoUpdateMode: Version v%v AutoUpdateMode: Version v%v
UpdateCheckMode: Tags UpdateCheckMode: Tags
CurrentVersion: '1.1' CurrentVersion: '1.3'
CurrentVersionCode: 1 CurrentVersionCode: 3
@@ -0,0 +1 @@
Playback controls, playlist management, playlist artwork, and Android-local volume improvements.
@@ -0,0 +1 @@
Offline mode with local library sync and cached playback, faster offline switching, and playback reliability fixes.
+4
View File
@@ -18,3 +18,7 @@ org.gradle.configuration-cache=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
android.disallowKotlinSourceSets=false android.disallowKotlinSourceSets=false
# App version used by Gradle for the APK/AAB manifest and BuildConfig.
furumi.versionCode=3
furumi.versionName=1.3