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 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 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_TOGGLE_PATH = "/api/player/likes/toggle"
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_TRACK_PATH = "/share/track"
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.LikeToggleResponse
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.PlaylistDetailResponse
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
import cy.hexor.furumi.data.remote.model.SearchResponse
import cy.hexor.furumi.data.remote.model.SharePlaylistRequest
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.http.Body
import retrofit2.http.DELETE
@@ -94,6 +97,19 @@ interface PlayerApi {
@Url url: String
): 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")
@GET
suspend fun playlistDetail(
@@ -27,11 +27,53 @@ data class TrackItemResponse(
@param:Json(name = "duration_seconds") val durationSeconds: Double? = null,
@param:Json(name = "artists") val artists: 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_year") val releaseYear: Int? = null,
@param:Json(name = "cover_url") val coverUrl: 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(
@param:Json(name = "id") val id: Long,
@param:Json(name = "track_id") val trackId: Long,
@@ -296,6 +338,9 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
.ifEmpty { mappedFeaturedArtists }
.map { it.name }
val absoluteCoverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl)
val absoluteStreamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
return TrackCard(
id = id ?: 0L,
title = title,
@@ -305,9 +350,13 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
artists = artistNames,
artistRefs = mappedArtists,
featuredArtistRefs = mappedFeaturedArtists,
releaseId = releaseId,
releaseYear = releaseYear,
releaseTitle = releaseTitle,
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
coverUrl = absoluteCoverUrl,
streamUrl = absoluteStreamUrl,
remoteCoverUrl = absoluteCoverUrl,
remoteStreamUrl = absoluteStreamUrl
)
}
@@ -326,9 +375,34 @@ fun TrackCard.toTrackItemResponse(): TrackItemResponse {
durationSeconds = durationSeconds?.toDouble() ?: 0.0,
artists = primaryArtists.map { it.toResponse() },
featuredArtists = featuredArtistRefs.map { it.toResponse() },
releaseId = releaseId,
releaseTitle = releaseTitle,
coverUrl = coverUrl,
streamUrl = streamUrl
releaseYear = releaseYear,
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.BitmapFactory
import android.net.Uri
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -17,6 +19,17 @@ class MediaImageLoader @Inject constructor(
suspend fun load(url: String): Result<Bitmap> {
return withContext(Dispatchers.IO) {
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()
.url(url)
.build()
@@ -2,6 +2,9 @@ package cy.hexor.furumi.data.repository
import cy.hexor.furumi.data.local.AuthSessionStorage
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.AuthApiErrorParser
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.JamJoinRequest
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.toBody
import cy.hexor.furumi.data.remote.model.toCommandPayloadBody
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.ArtistPage
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.ConnectedPlaybackState
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.PlaylistDetail
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.repository.PlayerRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
class PlayerRepositoryImpl @Inject constructor(
private val playerApi: PlayerApi,
private val sessionStorage: AuthSessionStorage,
private val connectedDeviceStorage: ConnectedDeviceStorage,
private val offlineSettingsStorage: OfflineSettingsStorage,
private val offlineLibraryStore: OfflineLibraryStore,
private val offlineMediaStore: OfflineMediaStore,
private val appClientInfo: AppClientInfo,
private val playerEndpoints: PlayerEndpoints,
private val errorParser: AuthApiErrorParser
) : 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> {
if (isOfflineModeEnabled()) {
return Result.success(offlineLibraryStore.getArtists(page, limit))
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -62,15 +165,23 @@ class PlayerRepositoryImpl @Inject constructor(
}
val body = response.body() ?: throw AuthException("Artists response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
fallbackToOffline(e) { offlineLibraryStore.getArtists(page, limit) }
}
}
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 {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -83,15 +194,27 @@ class PlayerRepositoryImpl @Inject constructor(
}
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) {
throw e
} catch (e: Exception) {
Result.failure(e)
fallbackToOffline(e) {
offlineLibraryStore.getArtistDetail(artistId)?.withOnlyCachedPlayback()
}
}
}
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 {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -104,15 +227,24 @@ class PlayerRepositoryImpl @Inject constructor(
}
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) {
throw e
} catch (e: Exception) {
Result.failure(e)
fallbackToOffline(e) {
offlineLibraryStore.getReleaseDetail(releaseId)?.withOnlyCachedPlayback()
}
}
}
override suspend fun search(query: String, limit: Int): Result<SearchResults> {
if (isOfflineModeEnabled()) {
return Result.success(offlineLibraryStore.search(query, limit).withOnlyCachedPlayback())
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -127,15 +259,22 @@ class PlayerRepositoryImpl @Inject constructor(
}
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) {
throw e
} catch (e: Exception) {
Result.failure(e)
fallbackToOffline(e) { offlineLibraryStore.search(query, limit).withOnlyCachedPlayback() }
}
}
override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Listening history is not available offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: 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")
markOnlineSuccess()
Result.success(body.toDomain())
} catch (e: CancellationException) {
throw e
@@ -159,6 +299,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun toggleLike(trackId: Long): Result<Boolean> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Likes cannot be changed offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: 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 likedIds = offlineLibraryStore.getLikedTrackIds()
offlineLibraryStore.replaceLikedTrackIds(
if (body.liked) likedIds + trackId else likedIds - trackId
)
markOnlineSuccess()
Result.success(body.liked)
} catch (e: CancellationException) {
throw e
@@ -180,6 +329,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun getLikedTrackIds(): Result<Set<Long>> {
if (isOfflineModeEnabled()) {
return Result.success(offlineLibraryStore.getLikedTrackIds())
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -192,11 +345,14 @@ class PlayerRepositoryImpl @Inject constructor(
}
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) {
throw e
} catch (e: Exception) {
Result.failure(e)
fallbackToOffline(e) { offlineLibraryStore.getLikedTrackIds() }
}
}
@@ -206,6 +362,17 @@ class PlayerRepositoryImpl @Inject constructor(
durationListened: Int,
completed: Boolean
): 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 {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -223,15 +390,23 @@ class PlayerRepositoryImpl @Inject constructor(
throw AuthException(errorParser.messageFrom(response))
}
offlineMediaStore.cacheTrackAfterListening(trackId)
markOnlineSuccess()
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
runCatching { offlineMediaStore.cacheTrackAfterListening(trackId) }
offlineSettingsStorage.setNetworkFallback(true, e.message)
Result.failure(e)
}
}
override suspend fun getPlaylists(): Result<List<PlaylistCard>> {
if (isOfflineModeEnabled()) {
return Result.success(offlineLibraryStore.getPlaylists())
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -244,15 +419,23 @@ class PlayerRepositoryImpl @Inject constructor(
}
val body = response.body() ?: throw AuthException("Playlists response is empty")
markOnlineSuccess()
Result.success(body.toDomain())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
fallbackToOffline(e) { offlineLibraryStore.getPlaylists() }
}
}
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 {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -265,15 +448,24 @@ class PlayerRepositoryImpl @Inject constructor(
}
val body = response.body() ?: throw AuthException("Playlist response is empty")
Result.success(body.toDomain(baseUrl))
val detail = body.toDomain(baseUrl).withCachedMedia()
offlineLibraryStore.upsertPlaylistDetail(detail)
markOnlineSuccess()
Result.success(detail)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
fallbackToOffline(e) {
offlineLibraryStore.getPlaylistDetail(playlistId)?.withOnlyCachedPlayback()
}
}
}
override suspend fun createPlaylist(title: String): Result<PlaylistCard> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Playlists cannot be created offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -287,6 +479,7 @@ class PlayerRepositoryImpl @Inject constructor(
}
val body = response.body() ?: throw AuthException("Create playlist response is empty")
markOnlineSuccess()
Result.success(body.toDomain())
} catch (e: CancellationException) {
throw e
@@ -296,6 +489,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun deletePlaylist(playlistId: Long): Result<Unit> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Playlists cannot be deleted offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -307,6 +504,7 @@ class PlayerRepositoryImpl @Inject constructor(
throw AuthException(errorParser.messageFrom(response))
}
markOnlineSuccess()
Result.success(Unit)
} catch (e: CancellationException) {
throw e
@@ -316,6 +514,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Playlists cannot be changed offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -328,6 +530,7 @@ class PlayerRepositoryImpl @Inject constructor(
throw AuthException(errorParser.messageFrom(response))
}
markOnlineSuccess()
Result.success(Unit)
} catch (e: CancellationException) {
throw e
@@ -337,9 +540,14 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun shareTrack(trackId: Long, title: String): Result<String> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Tracks cannot be shared offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
markOnlineSuccess()
Result.success(playerEndpoints.shareTrack(baseUrl, trackId))
} catch (e: CancellationException) {
throw e
@@ -352,6 +560,10 @@ class PlayerRepositoryImpl @Inject constructor(
playbackState: ConnectedPlaybackState?,
currentJamId: String?
): Result<Pair<ConnectedDevicesState, List<ConnectedCommand>>> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Connected devices are unavailable offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: 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")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl) to body.commands.map { it.toDomain(baseUrl) })
} catch (e: CancellationException) {
throw e
@@ -379,6 +592,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun setActiveDevice(deviceId: String): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Connected devices are unavailable offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: 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")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
@@ -417,6 +635,10 @@ class PlayerRepositoryImpl @Inject constructor(
fromIndex: Int?,
toIndex: Int?
): Result<Unit> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Connected devices are unavailable offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -444,6 +666,7 @@ class PlayerRepositoryImpl @Inject constructor(
throw AuthException(errorParser.messageFrom(response))
}
markOnlineSuccess()
Result.success(Unit)
} catch (e: CancellationException) {
throw e
@@ -453,6 +676,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun createJam(): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -466,6 +693,7 @@ class PlayerRepositoryImpl @Inject constructor(
}
val body = response.body() ?: throw AuthException("Jam response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
@@ -475,6 +703,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun joinJam(jamId: String): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -491,6 +723,7 @@ class PlayerRepositoryImpl @Inject constructor(
}
val body = response.body() ?: throw AuthException("Jam response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
@@ -500,6 +733,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun leaveJam(jamId: String): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -516,6 +753,7 @@ class PlayerRepositoryImpl @Inject constructor(
}
val body = response.body() ?: throw AuthException("Jam response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
@@ -525,6 +763,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun searchJamUsers(query: String, limit: Int): Result<List<ConnectedJamUser>> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: 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")
markOnlineSuccess()
Result.success(body.map { it.toDomain() })
} catch (e: CancellationException) {
throw e
@@ -548,6 +791,10 @@ class PlayerRepositoryImpl @Inject constructor(
}
override suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState> {
if (isOfflineModeEnabled()) {
return offlineUnavailable("Jam is unavailable offline")
}
return try {
val baseUrl = sessionStorage.getBaseUrl()
?: throw AuthException("Server URL is missing")
@@ -565,6 +812,7 @@ class PlayerRepositoryImpl @Inject constructor(
}
val body = response.body() ?: throw AuthException("Jam response is empty")
markOnlineSuccess()
Result.success(body.toDomain(baseUrl))
} catch (e: CancellationException) {
throw e
@@ -572,4 +820,88 @@ class PlayerRepositoryImpl @Inject constructor(
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 artistRefs: List<ArtistRef> = emptyList(),
val featuredArtistRefs: List<ArtistRef> = emptyList(),
val releaseId: Long? = null,
val releaseYear: Int? = null,
val releaseTitle: 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(
@@ -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.ConnectedPlaybackState
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.PlaylistDetail
import cy.hexor.furumi.domain.model.ReleaseDetail
import cy.hexor.furumi.domain.model.SearchResults
import cy.hexor.furumi.domain.model.TrackCard
import kotlinx.coroutines.flow.StateFlow
interface PlayerRepository {
val offlineState: StateFlow<OfflineState>
suspend fun getArtists(
page: Int = 1,
limit: Int = 60,
@@ -82,4 +87,14 @@ interface PlayerRepository {
suspend fun searchJamUsers(query: String, limit: Int = 10): Result<List<ConnectedJamUser>>
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.core.app.NotificationCompat
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.session.CacheBitmapLoader
import androidx.media3.common.util.UnstableApi
@@ -59,7 +60,10 @@ class FurumiPlaybackService : MediaSessionService() {
val bitmapLoader = CacheBitmapLoader(
DataSourceBitmapLoader(
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.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
@@ -87,7 +88,10 @@ class PlaybackController @Inject constructor(
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
init {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
val dataSourceFactory = DefaultDataSource.Factory(
context,
OkHttpDataSource.Factory(okHttpClient)
)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
player = ExoPlayer.Builder(context)
@@ -25,11 +25,14 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.FurumiSurfaceHigh
import cy.hexor.furumi.ui.theme.FurumiTextMuted
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
@Composable
@@ -139,65 +145,43 @@ internal fun ProfileMenu(
uiState: PlayerUiState,
onHistoryClick: () -> Unit,
onDeviceClick: (String) -> Unit,
onOfflineModeChange: (Boolean) -> Unit,
onSaveListenedChange: (Boolean) -> Unit,
onStorageLimitChange: (Long) -> Unit,
onSyncOffline: () -> Unit,
onClearOfflineCache: () -> Unit,
onLogout: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.width(284.dp),
modifier = modifier
.fillMaxWidth()
.widthIn(max = 430.dp)
.heightIn(max = 640.dp),
shape = RoundedCornerShape(12.dp),
color = FurumiSurfaceHigh,
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(16.dp)
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
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))
ProfileSummary(uiState)
ConnectedDevicesSection(
uiState = uiState,
onDeviceClick = onDeviceClick
)
Spacer(modifier = Modifier.height(10.dp))
OfflineControlsSection(
uiState = uiState,
onOfflineModeChange = onOfflineModeChange,
onSaveListenedChange = onSaveListenedChange,
onStorageLimitChange = onStorageLimitChange,
onSyncOffline = onSyncOffline,
onClearOfflineCache = onClearOfflineCache
)
OutlinedButton(
onClick = onHistoryClick,
modifier = Modifier
@@ -212,7 +196,6 @@ internal fun ProfileMenu(
) {
Text("Listening history")
}
Spacer(modifier = Modifier.height(10.dp))
Button(
onClick = onLogout,
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
private fun ConnectedDevicesSection(
uiState: PlayerUiState,
onDeviceClick: (String) -> Unit
) {
val devices = uiState.connectedDevicesState?.devices.orEmpty()
if (devices.isEmpty() && uiState.connectedDevicesError == null) return
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
@@ -255,11 +511,17 @@ private fun ConnectedDevicesSection(
style = MaterialTheme.typography.labelMedium,
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 = error,
text = status,
style = MaterialTheme.typography.bodySmall,
color = FurumiHotOrange,
color = if (uiState.connectedDevicesError != null) FurumiHotOrange else FurumiTextMuted,
maxLines = 1,
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
) {
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) {
onMediaImageNeeded(track.coverUrl)
@@ -102,7 +110,7 @@ internal fun ArtistDetailTrackRow(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) }
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -110,13 +118,15 @@ internal fun ArtistDetailTrackRow(
text = index.toString(),
modifier = Modifier.width(28.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
color = secondaryColor
)
MediaArtwork(
title = track.title,
seedId = track.id,
bitmap = bitmap,
modifier = Modifier.size(48.dp),
modifier = Modifier
.size(48.dp)
.unavailableTrackArtworkOverlay(isUnavailable),
cornerRadius = 6
)
Spacer(modifier = Modifier.width(12.dp))
@@ -124,7 +134,7 @@ internal fun ArtistDetailTrackRow(
Text(
text = track.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
color = primaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -133,7 +143,7 @@ internal fun ArtistDetailTrackRow(
.ifBlank { track.releaseTitle.orEmpty() }
.ifBlank { "Unknown artist" },
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
color = secondaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -144,7 +154,8 @@ internal fun ArtistDetailTrackRow(
.size(32.dp)
.clip(CircleShape)
.clickable(onClick = onToggleLike)
.padding(4.dp)
.padding(4.dp),
alpha = contentAlpha
)
Box {
MoreDotsGlyph(
@@ -152,7 +163,8 @@ internal fun ArtistDetailTrackRow(
.size(32.dp)
.clip(CircleShape)
.clickable { menuExpanded = true }
.padding(4.dp)
.padding(4.dp),
color = FurumiTextMuted.copy(alpha = contentAlpha)
)
TrackContextMenu(
expanded = menuExpanded,
@@ -169,7 +181,7 @@ internal fun ArtistDetailTrackRow(
Text(
text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
color = secondaryColor
)
}
}
@@ -187,12 +199,20 @@ internal fun ReleaseTrackRow(
onAddToPlaylist: () -> Unit
) {
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(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) }
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -200,20 +220,20 @@ internal fun ReleaseTrackRow(
text = (track.trackNumber ?: fallbackIndex).toString(),
modifier = Modifier.width(32.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
color = secondaryColor
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
color = primaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = track.artists.joinToString(", ").ifBlank { "Unknown artist" },
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
color = secondaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -224,7 +244,8 @@ internal fun ReleaseTrackRow(
.size(32.dp)
.clip(CircleShape)
.clickable(onClick = onToggleLike)
.padding(4.dp)
.padding(4.dp),
alpha = contentAlpha
)
Box {
MoreDotsGlyph(
@@ -232,7 +253,8 @@ internal fun ReleaseTrackRow(
.size(32.dp)
.clip(CircleShape)
.clickable { menuExpanded = true }
.padding(4.dp)
.padding(4.dp),
color = FurumiTextMuted.copy(alpha = contentAlpha)
)
TrackContextMenu(
expanded = menuExpanded,
@@ -249,7 +271,7 @@ internal fun ReleaseTrackRow(
Text(
text = formatDuration(track.durationSeconds),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
color = secondaryColor
)
}
}
@@ -269,6 +291,14 @@ internal fun PlaylistTrackRow(
onAddToPlaylist: () -> Unit
) {
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) {
onMediaImageNeeded(track.coverUrl)
@@ -278,7 +308,7 @@ internal fun PlaylistTrackRow(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { onTrackClick(track) }
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -286,13 +316,15 @@ internal fun PlaylistTrackRow(
text = index.toString(),
modifier = Modifier.width(28.dp),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted
color = secondaryColor
)
MediaArtwork(
title = track.title,
seedId = track.id,
bitmap = bitmap,
modifier = Modifier.size(48.dp),
modifier = Modifier
.size(48.dp)
.unavailableTrackArtworkOverlay(isUnavailable),
cornerRadius = 6
)
Spacer(modifier = Modifier.width(12.dp))
@@ -300,14 +332,14 @@ internal fun PlaylistTrackRow(
Text(
text = track.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground,
color = primaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = trackSubtitle(track),
style = MaterialTheme.typography.bodyMedium,
color = FurumiTextMuted,
color = secondaryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -318,7 +350,8 @@ internal fun PlaylistTrackRow(
.size(32.dp)
.clip(CircleShape)
.clickable(onClick = onToggleLike)
.padding(4.dp)
.padding(4.dp),
alpha = contentAlpha
)
Box {
MoreDotsGlyph(
@@ -326,7 +359,8 @@ internal fun PlaylistTrackRow(
.size(32.dp)
.clip(CircleShape)
.clickable { menuExpanded = true }
.padding(4.dp)
.padding(4.dp),
color = FurumiTextMuted.copy(alpha = contentAlpha)
)
TrackContextMenu(
expanded = menuExpanded,
@@ -343,7 +377,7 @@ internal fun PlaylistTrackRow(
Text(
text = formatDuration(track.durationSeconds),
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(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.safeDrawing)
.nestedScroll(profileScrollConnection)
) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
@@ -355,10 +340,24 @@ fun PlayerScreen(
viewModel.openListeningHistory()
},
onDeviceClick = viewModel::setActiveDevice,
onOfflineModeChange = viewModel::setOfflineModeEnabled,
onSaveListenedChange = viewModel::setSaveListenedTracksEnabled,
onStorageLimitChange = viewModel::setOfflineStorageLimit,
onSyncOffline = viewModel::syncOfflineLibrary,
onClearOfflineCache = viewModel::clearOfflineCache,
onLogout = viewModel::logout,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 72.dp, end = 20.dp)
.align(Alignment.TopCenter)
.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 {
if (offlineState.isOfflineActive) return playback
val connectedState = connectedDevicesState
val remotePlayback = connectedState?.remotePlaybackState
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.ConnectedPlaybackState
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.PlaylistDetail
import cy.hexor.furumi.domain.model.ReleaseCard
@@ -92,7 +93,8 @@ data class PlayerUiState(
val connectedDevicesError: String? = null,
val jamInviteQuery: String = "",
val jamInviteUsers: List<ConnectedJamUser> = emptyList(),
val isJamInviteSearchLoading: Boolean = false
val isJamInviteSearchLoading: Boolean = false,
val offlineState: OfflineState = OfflineState()
)
@HiltViewModel
@@ -109,11 +111,13 @@ class PlayerViewModel @Inject constructor(
private val playlistDetailCache = mutableMapOf<Long, PlaylistDetail>()
private var searchJob: Job? = null
private var jamInviteSearchJob: Job? = null
private var offlineModeRefreshJob: Job? = null
private val handledConnectedCommandIds = ArrayDeque<String>()
private val handledConnectedCommandIdSet = mutableSetOf<String>()
private var wasCurrentDeviceActive = false
private var wasControllingRemoteJam = false
private var hasResolvedStartupActiveDevice = false
private var shouldClaimActiveDeviceOnReconnect = false
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
@@ -133,6 +137,7 @@ class PlayerViewModel @Inject constructor(
init {
loadGlobalArtistsIfNeeded()
observeOfflineState()
observePlayback()
loadLikedTrackIds()
startConnectedDevicesPolling()
@@ -481,6 +486,7 @@ class PlayerViewModel @Inject constructor(
}
fun setActiveDevice(deviceId: String) {
if (_uiState.value.offlineState.isOfflineActive) return
viewModelScope.launch {
playerRepository.setActiveDevice(deviceId)
.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() {
viewModelScope.launch {
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() {
viewModelScope.launch {
while (true) {
@@ -994,6 +1072,9 @@ class PlayerViewModel @Inject constructor(
private suspend fun pollConnectedDevices() {
val state = _uiState.value
if (state.offlineState.settings.offlineModeEnabled) return
val shouldClaimActiveDevice = shouldClaimActiveDeviceOnReconnect ||
state.offlineState.isUsingNetworkFallback
val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true
// Android follows the spec: only the active device sends its playback state.
@@ -1008,12 +1089,21 @@ class PlayerViewModel @Inject constructor(
playbackState = playbackState,
currentJamId = state.connectedDevicesState?.currentJamId
).onSuccess { (devicesState, commands) ->
val resolvedDevicesState = resolveStartupActiveDevice(devicesState)
applyConnectedDevicesState(resolvedDevicesState)
val resolvedDevicesState = if (shouldClaimActiveDevice) {
claimCurrentDeviceActive(devicesState)
} else {
resolveStartupActiveDevice(devicesState)
}
applyConnectedDevicesState(
devicesState = resolvedDevicesState,
pauseOnLostControl = !shouldClaimActiveDevice
)
commands.forEach { command ->
if (shouldHandleConnectedCommand(command)) {
handleConnectedCommand(command)
if (!shouldClaimActiveDevice) {
commands.forEach { command ->
if (shouldHandleConnectedCommand(command)) {
handleConnectedCommand(command)
}
}
}
}.onFailure { error ->
@@ -1037,7 +1127,51 @@ class PlayerViewModel @Inject constructor(
.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 isControllingRemoteJam = devicesState.isControllingRemoteJam()
val startedControllingRemoteJam = !wasControllingRemoteJam && isControllingRemoteJam
@@ -1047,7 +1181,10 @@ class PlayerViewModel @Inject constructor(
)
wasCurrentDeviceActive = devicesState.isCurrentDeviceActive
wasControllingRemoteJam = isControllingRemoteJam
if ((becameInactive || startedControllingRemoteJam) && _uiState.value.playback.currentTrack != null) {
if (pauseOnLostControl &&
(becameInactive || startedControllingRemoteJam) &&
_uiState.value.playback.currentTrack != null
) {
playbackController.pause()
}
}
@@ -1122,6 +1259,8 @@ class PlayerViewModel @Inject constructor(
}
private fun activeRemoteDeviceId(): String? {
if (_uiState.value.offlineState.isOfflineActive) return null
if (shouldClaimActiveDeviceOnReconnect) return null
val devicesState = _uiState.value.connectedDevicesState ?: return null
if (devicesState.isControllingRemoteJam()) {
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 {
const val ARTIST_PAGE_SIZE = 60
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
Builds:
- versionName: '1.2'
versionCode: 2
commit: v1.2
- versionName: '1.3'
versionCode: 3
commit: v1.3
subdir: app
gradle:
- yes
AutoUpdateMode: Version v%v
UpdateCheckMode: Tags
CurrentVersion: '1.2'
CurrentVersionCode: 2
CurrentVersion: '1.3'
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
# App version used by Gradle for the APK/AAB manifest and BuildConfig.
furumi.versionCode=2
furumi.versionName=1.2
furumi.versionCode=3
furumi.versionName=1.3