Added offline mode

This commit is contained in:
Ultradesu
2026-07-15 19:01:17 +03:00
parent 26d12caeae
commit f085bf58b2
20 changed files with 2283 additions and 123 deletions
@@ -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"
@@ -16,12 +16,15 @@ 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.DELETE
@@ -94,6 +97,19 @@ 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(
@@ -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,
@@ -296,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,
@@ -305,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
) )
} }
@@ -326,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
) )
} }
@@ -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,6 +2,9 @@ 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
@@ -16,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
@@ -28,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
@@ -35,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")
@@ -62,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")
@@ -83,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")
@@ -104,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")
@@ -127,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")
@@ -150,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
@@ -159,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")
@@ -171,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
@@ -180,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")
@@ -192,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() }
} }
} }
@@ -206,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")
@@ -223,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")
@@ -244,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")
@@ -265,15 +448,24 @@ 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) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) fallbackToOffline(e) {
offlineLibraryStore.getPlaylistDetail(playlistId)?.withOnlyCachedPlayback()
}
} }
} }
override suspend fun createPlaylist(title: String): Result<PlaylistCard> { override suspend fun createPlaylist(title: String): Result<PlaylistCard> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Playlists cannot be created 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")
@@ -287,6 +479,7 @@ class PlayerRepositoryImpl @Inject constructor(
} }
val body = response.body() ?: throw AuthException("Create playlist response is empty") val body = response.body() ?: throw AuthException("Create playlist response is empty")
markOnlineSuccess()
Result.success(body.toDomain()) Result.success(body.toDomain())
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -296,6 +489,10 @@ class PlayerRepositoryImpl @Inject constructor(
} }
override suspend fun deletePlaylist(playlistId: Long): Result<Unit> { override suspend fun deletePlaylist(playlistId: Long): Result<Unit> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Playlists cannot be deleted 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")
@@ -307,6 +504,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
@@ -316,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")
@@ -328,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
@@ -337,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
@@ -352,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")
@@ -370,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
@@ -379,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")
@@ -395,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
@@ -417,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")
@@ -444,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
@@ -453,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")
@@ -466,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
@@ -475,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")
@@ -491,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
@@ -500,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")
@@ -516,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
@@ -525,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")
@@ -539,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
@@ -548,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")
@@ -565,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
@@ -572,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,
@@ -82,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)
@@ -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
) )
@@ -566,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"
}
@@ -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
@@ -151,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(
@@ -355,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)
) )
} }
@@ -475,6 +474,7 @@ fun PlayerScreen(
} }
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 &&
@@ -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
@@ -92,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
@@ -109,11 +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 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()
@@ -133,6 +137,7 @@ class PlayerViewModel @Inject constructor(
init { init {
loadGlobalArtistsIfNeeded() loadGlobalArtistsIfNeeded()
observeOfflineState()
observePlayback() observePlayback()
loadLikedTrackIds() loadLikedTrackIds()
startConnectedDevicesPolling() startConnectedDevicesPolling()
@@ -481,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 ->
@@ -494,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()
@@ -983,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) {
@@ -994,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.
@@ -1008,12 +1089,21 @@ class PlayerViewModel @Inject constructor(
playbackState = playbackState, playbackState = playbackState,
currentJamId = state.connectedDevicesState?.currentJamId currentJamId = state.connectedDevicesState?.currentJamId
).onSuccess { (devicesState, commands) -> ).onSuccess { (devicesState, commands) ->
val resolvedDevicesState = resolveStartupActiveDevice(devicesState) val resolvedDevicesState = if (shouldClaimActiveDevice) {
applyConnectedDevicesState(resolvedDevicesState) 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 ->
@@ -1037,7 +1127,51 @@ class PlayerViewModel @Inject constructor(
.getOrElse { devicesState } .getOrElse { devicesState }
} }
private fun applyConnectedDevicesState(devicesState: ConnectedDevicesState) { 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
@@ -1047,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()
} }
} }
@@ -1122,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
@@ -1405,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
+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.2' - versionName: '1.3'
versionCode: 2 versionCode: 3
commit: v1.2 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.2' CurrentVersion: '1.3'
CurrentVersionCode: 2 CurrentVersionCode: 3
@@ -0,0 +1 @@
Offline mode with local library sync and cached playback, faster offline switching, and playback reliability fixes.
+2 -2
View File
@@ -20,5 +20,5 @@ kotlin.code.style=official
android.disallowKotlinSourceSets=false android.disallowKotlinSourceSets=false
# App version used by Gradle for the APK/AAB manifest and BuildConfig. # App version used by Gradle for the APK/AAB manifest and BuildConfig.
furumi.versionCode=2 furumi.versionCode=3
furumi.versionName=1.2 furumi.versionName=1.3