diff --git a/app/src/main/java/cy/hexor/furumi/data/local/OfflineLibraryStore.kt b/app/src/main/java/cy/hexor/furumi/data/local/OfflineLibraryStore.kt new file mode 100644 index 0000000..2277d40 --- /dev/null +++ b/app/src/main/java/cy/hexor/furumi/data/local/OfflineLibraryStore.kt @@ -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) { + if (tracks.isEmpty()) return + dbHelper.writableDatabase.transaction { + tracks.forEach { track -> upsertTrackLocked(this, track) } + } + } + + fun upsertManifestTracks(tracks: List) { + 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) { + 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) { + 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 { + 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 { + 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, preferLocalMedia: Boolean = true): List { + 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 { + 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 { + 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 { + 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 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.toJsonArrayString(): String { + val array = JSONArray() + forEach(array::put) + return array.toString() +} + +private fun String?.longListFromJson(): List { + 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 Cursor.useCursor(block: (Cursor) -> T): T = use(block) diff --git a/app/src/main/java/cy/hexor/furumi/data/local/OfflineMediaStore.kt b/app/src/main/java/cy/hexor/furumi/data/local/OfflineMediaStore.kt new file mode 100644 index 0000000..b50a81f --- /dev/null +++ b/app/src/main/java/cy/hexor/furumi/data/local/OfflineMediaStore.kt @@ -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() + } +} diff --git a/app/src/main/java/cy/hexor/furumi/data/local/OfflineSettingsStorage.kt b/app/src/main/java/cy/hexor/furumi/data/local/OfflineSettingsStorage.kt new file mode 100644 index 0000000..453bcb1 --- /dev/null +++ b/app/src/main/java/cy/hexor/furumi/data/local/OfflineSettingsStorage.kt @@ -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 = _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 + } +} diff --git a/app/src/main/java/cy/hexor/furumi/data/remote/PlayerEndpoints.kt b/app/src/main/java/cy/hexor/furumi/data/remote/PlayerEndpoints.kt index b691ec0..a832f2c 100644 --- a/app/src/main/java/cy/hexor/furumi/data/remote/PlayerEndpoints.kt +++ b/app/src/main/java/cy/hexor/furumi/data/remote/PlayerEndpoints.kt @@ -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" diff --git a/app/src/main/java/cy/hexor/furumi/data/remote/api/PlayerApi.kt b/app/src/main/java/cy/hexor/furumi/data/remote/api/PlayerApi.kt index 2e6ddbe..6ceb139 100644 --- a/app/src/main/java/cy/hexor/furumi/data/remote/api/PlayerApi.kt +++ b/app/src/main/java/cy/hexor/furumi/data/remote/api/PlayerApi.kt @@ -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> + @Headers("Accept: application/json") + @GET + suspend fun offlineManifest( + @Url url: String + ): Response + + @Headers("Accept: application/json", "Content-Type: application/json") + @POST + suspend fun tracksByIds( + @Url url: String, + @Body body: TracksByIdsRequest + ): Response> + @Headers("Accept: application/json") @GET suspend fun playlistDetail( diff --git a/app/src/main/java/cy/hexor/furumi/data/remote/model/PlayerResponses.kt b/app/src/main/java/cy/hexor/furumi/data/remote/model/PlayerResponses.kt index cbd5cb7..7bebfd5 100644 --- a/app/src/main/java/cy/hexor/furumi/data/remote/model/PlayerResponses.kt +++ b/app/src/main/java/cy/hexor/furumi/data/remote/model/PlayerResponses.kt @@ -27,11 +27,53 @@ data class TrackItemResponse( @param:Json(name = "duration_seconds") val durationSeconds: Double? = null, @param:Json(name = "artists") val artists: List = emptyList(), @param:Json(name = "featured_artists") val featuredArtists: List = 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 +) + +data class OfflineManifestResponse( + @param:Json(name = "generated_at") val generatedAt: String, + @param:Json(name = "tracks") val tracks: List = emptyList(), + @param:Json(name = "playlists") val playlists: List = emptyList(), + @param:Json(name = "liked_track_ids") val likedTrackIds: List = emptyList(), + @param:Json(name = "followed_artist_ids") val followedArtistIds: List = 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 = 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 ) } diff --git a/app/src/main/java/cy/hexor/furumi/data/repository/MediaImageLoader.kt b/app/src/main/java/cy/hexor/furumi/data/repository/MediaImageLoader.kt index 5553054..60a0e53 100644 --- a/app/src/main/java/cy/hexor/furumi/data/repository/MediaImageLoader.kt +++ b/app/src/main/java/cy/hexor/furumi/data/repository/MediaImageLoader.kt @@ -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 { 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() diff --git a/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt b/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt index 3f7873a..410e644 100644 --- a/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt +++ b/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt @@ -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 = 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 { + return try { + offlineMediaStore.clearCachedMedia() + Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun syncOfflineLibrary(): Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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> { + 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 { + 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 { + 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 { + 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): Result { + 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 { + 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>> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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): Result { + 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 fallbackToOffline(error: Exception, block: () -> T?): Result { + val localValue = runCatching(block).getOrNull() + return if (localValue != null) { + offlineSettingsStorage.setNetworkFallback(true, error.message) + Result.success(localValue) + } else { + Result.failure(error) + } + } + + private fun offlineUnavailable(message: String): Result { + 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 + } } diff --git a/app/src/main/java/cy/hexor/furumi/domain/model/Artist.kt b/app/src/main/java/cy/hexor/furumi/domain/model/Artist.kt index 2d4c904..a355e76 100644 --- a/app/src/main/java/cy/hexor/furumi/domain/model/Artist.kt +++ b/app/src/main/java/cy/hexor/furumi/domain/model/Artist.kt @@ -39,9 +39,23 @@ data class TrackCard( val artists: List, val artistRefs: List = emptyList(), val featuredArtistRefs: List = 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( diff --git a/app/src/main/java/cy/hexor/furumi/domain/model/OfflineMode.kt b/app/src/main/java/cy/hexor/furumi/domain/model/OfflineMode.kt new file mode 100644 index 0000000..d49cf7f --- /dev/null +++ b/app/src/main/java/cy/hexor/furumi/domain/model/OfflineMode.kt @@ -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 +) diff --git a/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt b/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt index 0d8b0f3..98c7686 100644 --- a/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt +++ b/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt @@ -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 + suspend fun getArtists( page: Int = 1, limit: Int = 60, @@ -82,4 +87,14 @@ interface PlayerRepository { suspend fun searchJamUsers(query: String, limit: Int = 10): Result> suspend fun inviteToJam(jamId: String, inviteeUserIds: List): Result + + fun setOfflineModeEnabled(enabled: Boolean) + + fun setSaveListenedTracksEnabled(enabled: Boolean) + + fun setOfflineStorageLimit(limitBytes: Long) + + suspend fun syncOfflineLibrary(): Result + + suspend fun clearOfflineCache(): Result } diff --git a/app/src/main/java/cy/hexor/furumi/playback/FurumiPlaybackService.kt b/app/src/main/java/cy/hexor/furumi/playback/FurumiPlaybackService.kt index e0eeaed..7e80286 100644 --- a/app/src/main/java/cy/hexor/furumi/playback/FurumiPlaybackService.kt +++ b/app/src/main/java/cy/hexor/furumi/playback/FurumiPlaybackService.kt @@ -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) + ) ) ) diff --git a/app/src/main/java/cy/hexor/furumi/playback/PlaybackController.kt b/app/src/main/java/cy/hexor/furumi/playback/PlaybackController.kt index 1ee9b00..8b683cc 100644 --- a/app/src/main/java/cy/hexor/furumi/playback/PlaybackController.kt +++ b/app/src/main/java/cy/hexor/furumi/playback/PlaybackController.kt @@ -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 = _state.asStateFlow() init { - val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient) + val dataSourceFactory = DefaultDataSource.Factory( + context, + OkHttpDataSource.Factory(okHttpClient) + ) val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) player = ExoPlayer.Builder(context) diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerChrome.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerChrome.kt index 6d431e5..49322c9 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerChrome.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerChrome.kt @@ -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" +} diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerRows.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerRows.kt index 0259a92..8c6471e 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerRows.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerRows.kt @@ -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 diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt index 821f791..792419d 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt @@ -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 && diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt index 788ae2f..94a4a55 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt @@ -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 = 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() private var searchJob: Job? = null private var jamInviteSearchJob: Job? = null + private var offlineModeRefreshJob: Job? = null private val handledConnectedCommandIds = ArrayDeque() private val handledConnectedCommandIdSet = mutableSetOf() private var wasCurrentDeviceActive = false private var wasControllingRemoteJam = false private var hasResolvedStartupActiveDevice = false + private var shouldClaimActiveDeviceOnReconnect = false private val _shareEvent = MutableSharedFlow(extraBufferCapacity = 1) val shareEvent: SharedFlow = _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 { + 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 diff --git a/cy.hexor.furumi.yml b/cy.hexor.furumi.yml index 4553981..6dff263 100644 --- a/cy.hexor.furumi.yml +++ b/cy.hexor.furumi.yml @@ -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 diff --git a/fastlane/metadata/android/en-US/changelogs/3.txt b/fastlane/metadata/android/en-US/changelogs/3.txt new file mode 100644 index 0000000..dcb063f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3.txt @@ -0,0 +1 @@ +Offline mode with local library sync and cached playback, faster offline switching, and playback reliability fixes. diff --git a/gradle.properties b/gradle.properties index d5e09a6..420b43b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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