Added offline mode
This commit is contained in:
@@ -0,0 +1,874 @@
|
||||
package cy.hexor.furumi.data.local
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.net.Uri
|
||||
import com.squareup.moshi.Moshi
|
||||
import cy.hexor.furumi.data.remote.model.OfflineManifestPlaylistResponse
|
||||
import cy.hexor.furumi.data.remote.model.OfflineManifestTrackResponse
|
||||
import cy.hexor.furumi.domain.model.ArtistCard
|
||||
import cy.hexor.furumi.domain.model.ArtistDetail
|
||||
import cy.hexor.furumi.domain.model.ArtistPage
|
||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||
import cy.hexor.furumi.domain.model.PlaylistDetail
|
||||
import cy.hexor.furumi.domain.model.ReleaseCard
|
||||
import cy.hexor.furumi.domain.model.ReleaseDetail
|
||||
import cy.hexor.furumi.domain.model.SearchResults
|
||||
import cy.hexor.furumi.domain.model.TrackCard
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class OfflineLibraryStore @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
moshi: Moshi
|
||||
) {
|
||||
private val dbHelper = OfflineDbHelper(context.applicationContext)
|
||||
private val trackAdapter = moshi.adapter(TrackCard::class.java)
|
||||
|
||||
fun upsertTracks(tracks: List<TrackCard>) {
|
||||
if (tracks.isEmpty()) return
|
||||
dbHelper.writableDatabase.transaction {
|
||||
tracks.forEach { track -> upsertTrackLocked(this, track) }
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertManifestTracks(tracks: List<OfflineManifestTrackResponse>) {
|
||||
if (tracks.isEmpty()) return
|
||||
dbHelper.writableDatabase.transaction {
|
||||
tracks.forEach { manifest ->
|
||||
val existing = getTrackRecordLocked(this, manifest.id)
|
||||
val audioChanged = existing?.audioHash != null &&
|
||||
existing.audioHash != manifest.audioHash
|
||||
val coverChanged = existing?.coverHash != null &&
|
||||
existing.coverHash != manifest.coverHash
|
||||
val values = ContentValues().apply {
|
||||
put(COL_TRACK_ID, manifest.id)
|
||||
put(COL_UPDATED_AT, manifest.updatedAt)
|
||||
put(COL_STREAM_URL, manifest.streamUrl)
|
||||
put(COL_REMOTE_STREAM_URL, manifest.streamUrl)
|
||||
put(COL_AUDIO_HASH, manifest.audioHash)
|
||||
put(COL_AUDIO_SIZE_BYTES, manifest.audioSizeBytes)
|
||||
put(COL_AUDIO_MIME_TYPE, manifest.audioMimeType)
|
||||
put(COL_AUDIO_UPDATED_AT, manifest.audioUpdatedAt)
|
||||
put(COL_COVER_URL, manifest.coverUrl)
|
||||
put(COL_REMOTE_COVER_URL, manifest.coverUrl)
|
||||
put(COL_COVER_HASH, manifest.coverHash)
|
||||
put(COL_COVER_UPDATED_AT, manifest.coverUpdatedAt)
|
||||
if (audioChanged) {
|
||||
put(COL_AUDIO_STATUS, STATUS_STALE)
|
||||
}
|
||||
if (coverChanged) {
|
||||
put(COL_COVER_STATUS, STATUS_STALE)
|
||||
}
|
||||
}
|
||||
insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, manifest.id, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertPlaylistDetail(detail: PlaylistDetail) {
|
||||
dbHelper.writableDatabase.transaction {
|
||||
detail.tracks.forEach { track -> upsertTrackLocked(this, track) }
|
||||
val playlist = detail.playlist
|
||||
insertOrUpdate(TABLE_PLAYLISTS, COL_PLAYLIST_ID, playlist.id, ContentValues().apply {
|
||||
put(COL_PLAYLIST_ID, playlist.id)
|
||||
put(COL_PLAYLIST_TITLE, playlist.title)
|
||||
put(COL_PLAYLIST_DESCRIPTION, detail.description)
|
||||
put(COL_PLAYLIST_UPDATED_AT, System.currentTimeMillis().toString())
|
||||
put(COL_PLAYLIST_IS_OWN, playlist.isOwn.toInt())
|
||||
put(COL_PLAYLIST_OWNER_NAME, playlist.ownerName)
|
||||
put(COL_PLAYLIST_IS_PUBLIC, playlist.isPublic.toInt())
|
||||
put(COL_PLAYLIST_IS_SAVED, playlist.isSaved.toInt())
|
||||
put(COL_PLAYLIST_KIND, playlist.kind)
|
||||
put(COL_PLAYLIST_TRACK_IDS, detail.tracks.map { it.id }.toJsonArrayString())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun replacePlaylists(playlists: List<OfflineManifestPlaylistResponse>) {
|
||||
dbHelper.writableDatabase.transaction {
|
||||
delete(TABLE_PLAYLISTS, null, null)
|
||||
playlists.forEach { playlist ->
|
||||
insert(TABLE_PLAYLISTS, null, ContentValues().apply {
|
||||
put(COL_PLAYLIST_ID, playlist.id)
|
||||
put(COL_PLAYLIST_TITLE, playlist.title)
|
||||
put(COL_PLAYLIST_DESCRIPTION, playlist.description)
|
||||
put(COL_PLAYLIST_UPDATED_AT, playlist.updatedAt)
|
||||
put(COL_PLAYLIST_IS_OWN, playlist.isOwn.toInt())
|
||||
put(COL_PLAYLIST_OWNER_NAME, playlist.ownerName)
|
||||
put(COL_PLAYLIST_IS_PUBLIC, playlist.isPublic.toInt())
|
||||
put(COL_PLAYLIST_IS_SAVED, playlist.isSaved.toInt())
|
||||
put(COL_PLAYLIST_KIND, playlist.kind)
|
||||
put(COL_PLAYLIST_TRACK_IDS, playlist.trackIds.toJsonArrayString())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceLikedTrackIds(trackIds: Set<Long>) {
|
||||
dbHelper.writableDatabase.transaction {
|
||||
delete(TABLE_LIKES, null, null)
|
||||
trackIds.forEach { trackId ->
|
||||
insert(TABLE_LIKES, null, ContentValues().apply {
|
||||
put(COL_LIKE_TRACK_ID, trackId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLikedTrackIds(): Set<Long> {
|
||||
return dbHelper.readableDatabase.query(
|
||||
TABLE_LIKES,
|
||||
arrayOf(COL_LIKE_TRACK_ID),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).useCursor { cursor ->
|
||||
buildSet {
|
||||
while (cursor.moveToNext()) add(cursor.getLong(COL_LIKE_TRACK_ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPlaylists(): List<PlaylistCard> {
|
||||
return dbHelper.readableDatabase.query(
|
||||
TABLE_PLAYLISTS,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"$COL_PLAYLIST_IS_OWN DESC, $COL_PLAYLIST_TITLE COLLATE NOCASE"
|
||||
).useCursor { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
val trackIds = cursor.getString(COL_PLAYLIST_TRACK_IDS).longListFromJson()
|
||||
add(cursor.toPlaylistCard(trackIds.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPlaylistDetail(playlistId: Long): PlaylistDetail? {
|
||||
return dbHelper.readableDatabase.query(
|
||||
TABLE_PLAYLISTS,
|
||||
null,
|
||||
"$COL_PLAYLIST_ID = ?",
|
||||
arrayOf(playlistId.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"1"
|
||||
).useCursor { cursor ->
|
||||
if (!cursor.moveToFirst()) return@useCursor null
|
||||
val trackIds = cursor.getString(COL_PLAYLIST_TRACK_IDS).longListFromJson()
|
||||
PlaylistDetail(
|
||||
playlist = cursor.toPlaylistCard(trackIds.size),
|
||||
description = cursor.getStringOrNull(COL_PLAYLIST_DESCRIPTION),
|
||||
tracks = getTracksByIds(trackIds)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String, limit: Int): SearchResults {
|
||||
val normalized = query.trim().lowercase()
|
||||
if (normalized.isBlank()) {
|
||||
return SearchResults(emptyList(), emptyList(), emptyList())
|
||||
}
|
||||
val pattern = "%$normalized%"
|
||||
val tracks = dbHelper.readableDatabase.query(
|
||||
TABLE_TRACKS,
|
||||
null,
|
||||
"$COL_TITLE_SORT LIKE ? OR lower($COL_RELEASE_TITLE) LIKE ? OR $COL_ARTISTS_SORT LIKE ?",
|
||||
arrayOf(pattern, pattern, pattern),
|
||||
null,
|
||||
null,
|
||||
"$COL_PLAY_COUNT DESC, $COL_LAST_ACCESSED_AT DESC, $COL_TITLE_SORT",
|
||||
limit.toString()
|
||||
).useCursor { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) cursor.toTrackCard(preferLocalMedia = true)?.let(::add)
|
||||
}
|
||||
}
|
||||
val artistMatches = tracks
|
||||
.flatMap { track ->
|
||||
track.artistRefs.map { ArtistSeed(it) }.ifEmpty {
|
||||
track.artists.map { ArtistSeed(it) }
|
||||
}
|
||||
}
|
||||
.distinctBy { it.artistIdOrName }
|
||||
.take(limit)
|
||||
.map { seed ->
|
||||
ArtistCard(
|
||||
id = seed.artistId,
|
||||
name = seed.artistName,
|
||||
imageUrl = null,
|
||||
releaseCount = 0,
|
||||
trackCount = tracks.count { track ->
|
||||
track.artistRefs.any { it.id == seed.artistId } ||
|
||||
track.artists.any { it.equals(seed.artistName, ignoreCase = true) }
|
||||
}
|
||||
)
|
||||
}
|
||||
return SearchResults(
|
||||
artistMatches = artistMatches,
|
||||
trackArtists = artistMatches,
|
||||
tracks = tracks
|
||||
)
|
||||
}
|
||||
|
||||
fun getArtists(page: Int, limit: Int): ArtistPage {
|
||||
val tracks = getAllTracks(preferLocalMedia = true)
|
||||
val artists = tracks
|
||||
.flatMap { track ->
|
||||
track.artistRefs.map { ArtistSeed(it) }.ifEmpty {
|
||||
track.artists.map { ArtistSeed(it) }
|
||||
}
|
||||
}
|
||||
.groupBy { it.artistIdOrName }
|
||||
.map { (_, seeds) ->
|
||||
val seed = seeds.first()
|
||||
ArtistCard(
|
||||
id = seed.artistId,
|
||||
name = seed.artistName,
|
||||
imageUrl = null,
|
||||
releaseCount = tracks
|
||||
.filter { track ->
|
||||
track.artistRefs.any { it.id == seed.artistId } ||
|
||||
track.artists.any { it.equals(seed.artistName, ignoreCase = true) }
|
||||
}
|
||||
.mapNotNull { it.releaseId ?: it.releaseTitle?.hashCode()?.toLong() }
|
||||
.distinct()
|
||||
.size,
|
||||
trackCount = seeds.size
|
||||
)
|
||||
}
|
||||
.sortedBy { it.name.lowercase() }
|
||||
val safePage = page.coerceAtLeast(1)
|
||||
val from = ((safePage - 1) * limit).coerceAtMost(artists.size)
|
||||
val to = (from + limit).coerceAtMost(artists.size)
|
||||
return ArtistPage(
|
||||
items = artists.subList(from, to),
|
||||
total = artists.size.toLong(),
|
||||
page = safePage,
|
||||
perPage = limit,
|
||||
hasMore = to < artists.size
|
||||
)
|
||||
}
|
||||
|
||||
fun getArtistDetail(artistId: Long): ArtistDetail? {
|
||||
val tracks = getAllTracks(preferLocalMedia = true).filter { track ->
|
||||
track.artistRefs.any { it.id == artistId } ||
|
||||
(artistId < 0 && track.artists.any { it.hashCode().toLong() == -artistId })
|
||||
}
|
||||
if (tracks.isEmpty()) return null
|
||||
val name = tracks.firstNotNullOfOrNull { track ->
|
||||
track.artistRefs.firstOrNull { it.id == artistId }?.name
|
||||
} ?: tracks.first().artists.firstOrNull().orEmpty()
|
||||
val releases = tracks
|
||||
.groupBy { it.releaseId ?: it.releaseTitle?.hashCode()?.toLong() ?: it.id }
|
||||
.map { (releaseId, releaseTracks) ->
|
||||
ReleaseCard(
|
||||
id = releaseId,
|
||||
title = releaseTracks.first().releaseTitle ?: "Unknown release",
|
||||
releaseType = null,
|
||||
year = releaseTracks.firstNotNullOfOrNull { it.releaseYear },
|
||||
coverUrl = releaseTracks.firstNotNullOfOrNull { it.coverUrl },
|
||||
trackCount = releaseTracks.size
|
||||
)
|
||||
}
|
||||
return ArtistDetail(
|
||||
artist = ArtistCard(
|
||||
id = artistId,
|
||||
name = name.ifBlank { "Unknown artist" },
|
||||
imageUrl = null,
|
||||
releaseCount = releases.size,
|
||||
trackCount = tracks.size
|
||||
),
|
||||
releases = releases,
|
||||
topTracks = tracks,
|
||||
featuredTracks = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
fun getReleaseDetail(releaseId: Long): ReleaseDetail? {
|
||||
val tracks = getAllTracks(preferLocalMedia = true).filter { track ->
|
||||
track.releaseId == releaseId
|
||||
}
|
||||
if (tracks.isEmpty()) return null
|
||||
val release = ReleaseCard(
|
||||
id = releaseId,
|
||||
title = tracks.first().releaseTitle ?: "Unknown release",
|
||||
releaseType = null,
|
||||
year = tracks.firstNotNullOfOrNull { it.releaseYear },
|
||||
coverUrl = tracks.firstNotNullOfOrNull { it.coverUrl },
|
||||
trackCount = tracks.size
|
||||
)
|
||||
val artists = tracks
|
||||
.flatMap { it.artistRefs }
|
||||
.distinctBy { it.id }
|
||||
.map {
|
||||
ArtistCard(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
imageUrl = null,
|
||||
releaseCount = 1,
|
||||
trackCount = tracks.count { track -> track.artistRefs.any { artist -> artist.id == it.id } }
|
||||
)
|
||||
}
|
||||
return ReleaseDetail(release = release, artists = artists, tracks = tracks)
|
||||
}
|
||||
|
||||
fun getTrack(trackId: Long, preferLocalMedia: Boolean = true): TrackCard? {
|
||||
return dbHelper.readableDatabase.query(
|
||||
TABLE_TRACKS,
|
||||
null,
|
||||
"$COL_TRACK_ID = ?",
|
||||
arrayOf(trackId.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"1"
|
||||
).useCursor { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.toTrackCard(preferLocalMedia) else null
|
||||
}
|
||||
}
|
||||
|
||||
fun getTracksByIds(trackIds: List<Long>, preferLocalMedia: Boolean = true): List<TrackCard> {
|
||||
if (trackIds.isEmpty()) return emptyList()
|
||||
return trackIds.mapNotNull { getTrack(it, preferLocalMedia) }
|
||||
}
|
||||
|
||||
fun markTrackAccessed(trackId: Long) {
|
||||
val now = System.currentTimeMillis()
|
||||
dbHelper.writableDatabase.execSQL(
|
||||
"UPDATE $TABLE_TRACKS SET $COL_LAST_ACCESSED_AT = ?, $COL_PLAY_COUNT = $COL_PLAY_COUNT + 1 WHERE $COL_TRACK_ID = ?",
|
||||
arrayOf(now, trackId)
|
||||
)
|
||||
}
|
||||
|
||||
fun markAudioCached(trackId: Long, path: String, sizeBytes: Long) {
|
||||
updateMediaColumns(
|
||||
trackId = trackId,
|
||||
localPathColumn = COL_LOCAL_AUDIO_PATH,
|
||||
statusColumn = COL_AUDIO_STATUS,
|
||||
bytesColumn = COL_CACHED_AUDIO_BYTES,
|
||||
path = path,
|
||||
sizeBytes = sizeBytes
|
||||
)
|
||||
}
|
||||
|
||||
fun markCoverCached(trackId: Long, path: String, sizeBytes: Long) {
|
||||
updateMediaColumns(
|
||||
trackId = trackId,
|
||||
localPathColumn = COL_LOCAL_COVER_PATH,
|
||||
statusColumn = COL_COVER_STATUS,
|
||||
bytesColumn = COL_CACHED_COVER_BYTES,
|
||||
path = path,
|
||||
sizeBytes = sizeBytes
|
||||
)
|
||||
}
|
||||
|
||||
fun markAudioRemoved(trackId: Long) {
|
||||
dbHelper.writableDatabase.update(
|
||||
TABLE_TRACKS,
|
||||
ContentValues().apply {
|
||||
putNull(COL_LOCAL_AUDIO_PATH)
|
||||
put(COL_AUDIO_STATUS, STATUS_NONE)
|
||||
put(COL_CACHED_AUDIO_BYTES, 0L)
|
||||
},
|
||||
"$COL_TRACK_ID = ?",
|
||||
arrayOf(trackId.toString())
|
||||
)
|
||||
}
|
||||
|
||||
fun markCoverRemoved(trackId: Long) {
|
||||
dbHelper.writableDatabase.update(
|
||||
TABLE_TRACKS,
|
||||
ContentValues().apply {
|
||||
putNull(COL_LOCAL_COVER_PATH)
|
||||
put(COL_COVER_STATUS, STATUS_NONE)
|
||||
put(COL_CACHED_COVER_BYTES, 0L)
|
||||
},
|
||||
"$COL_TRACK_ID = ?",
|
||||
arrayOf(trackId.toString())
|
||||
)
|
||||
}
|
||||
|
||||
fun clearCachedMedia() {
|
||||
dbHelper.writableDatabase.update(
|
||||
TABLE_TRACKS,
|
||||
ContentValues().apply {
|
||||
putNull(COL_LOCAL_AUDIO_PATH)
|
||||
put(COL_AUDIO_STATUS, STATUS_NONE)
|
||||
put(COL_CACHED_AUDIO_BYTES, 0L)
|
||||
putNull(COL_LOCAL_COVER_PATH)
|
||||
put(COL_COVER_STATUS, STATUS_NONE)
|
||||
put(COL_CACHED_COVER_BYTES, 0L)
|
||||
},
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
fun cachedAudioEntriesForEviction(): List<OfflineCachedMediaEntry> {
|
||||
return dbHelper.readableDatabase.query(
|
||||
TABLE_TRACKS,
|
||||
arrayOf(
|
||||
COL_TRACK_ID,
|
||||
COL_LOCAL_AUDIO_PATH,
|
||||
COL_CACHED_AUDIO_BYTES,
|
||||
COL_LAST_ACCESSED_AT,
|
||||
COL_PLAY_COUNT
|
||||
),
|
||||
"$COL_AUDIO_STATUS = ? AND $COL_LOCAL_AUDIO_PATH IS NOT NULL",
|
||||
arrayOf(STATUS_READY),
|
||||
null,
|
||||
null,
|
||||
"$COL_LAST_ACCESSED_AT ASC, $COL_PLAY_COUNT ASC"
|
||||
).useCursor { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
add(
|
||||
OfflineCachedMediaEntry(
|
||||
trackId = cursor.getLong(COL_TRACK_ID),
|
||||
path = cursor.getString(COL_LOCAL_AUDIO_PATH),
|
||||
sizeBytes = cursor.getLong(COL_CACHED_AUDIO_BYTES),
|
||||
lastAccessedAtMs = cursor.getLong(COL_LAST_ACCESSED_AT),
|
||||
playCount = cursor.getInt(COL_PLAY_COUNT)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun staleMediaPaths(): List<OfflineStaleMediaPath> {
|
||||
return dbHelper.readableDatabase.query(
|
||||
TABLE_TRACKS,
|
||||
arrayOf(COL_TRACK_ID, COL_LOCAL_AUDIO_PATH, COL_AUDIO_STATUS, COL_LOCAL_COVER_PATH, COL_COVER_STATUS),
|
||||
"$COL_AUDIO_STATUS = ? OR $COL_COVER_STATUS = ?",
|
||||
arrayOf(STATUS_STALE, STATUS_STALE),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).useCursor { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) {
|
||||
if (cursor.getStringOrNull(COL_AUDIO_STATUS) == STATUS_STALE) {
|
||||
cursor.getStringOrNull(COL_LOCAL_AUDIO_PATH)?.let { path ->
|
||||
add(OfflineStaleMediaPath(cursor.getLong(COL_TRACK_ID), path, isAudio = true))
|
||||
}
|
||||
}
|
||||
if (cursor.getStringOrNull(COL_COVER_STATUS) == STATUS_STALE) {
|
||||
cursor.getStringOrNull(COL_LOCAL_COVER_PATH)?.let { path ->
|
||||
add(OfflineStaleMediaPath(cursor.getLong(COL_TRACK_ID), path, isAudio = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun storageStats(): OfflineStorageStats {
|
||||
return dbHelper.readableDatabase.rawQuery(
|
||||
"SELECT COALESCE(SUM($COL_CACHED_AUDIO_BYTES + $COL_CACHED_COVER_BYTES), 0), " +
|
||||
"COALESCE(SUM(CASE WHEN $COL_AUDIO_STATUS = '$STATUS_READY' THEN 1 ELSE 0 END), 0) " +
|
||||
"FROM $TABLE_TRACKS",
|
||||
null
|
||||
).useCursor { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
OfflineStorageStats(
|
||||
usedBytes = cursor.getLong(0),
|
||||
cachedTrackCount = cursor.getInt(1)
|
||||
)
|
||||
} else {
|
||||
OfflineStorageStats(0L, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAllTracks(preferLocalMedia: Boolean): List<TrackCard> {
|
||||
return dbHelper.readableDatabase.query(
|
||||
TABLE_TRACKS,
|
||||
null,
|
||||
"$COL_TRACK_JSON IS NOT NULL",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"$COL_TITLE_SORT"
|
||||
).useCursor { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) cursor.toTrackCard(preferLocalMedia)?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertTrackLocked(db: SQLiteDatabase, track: TrackCard) {
|
||||
val existing = getTrackRecordLocked(db, track.id)
|
||||
val storedTrack = track.copy(
|
||||
streamUrl = track.remoteStreamUrl.ifBlank { track.streamUrl },
|
||||
coverUrl = track.remoteCoverUrl ?: track.coverUrl,
|
||||
localAudioPath = null,
|
||||
localCoverPath = null,
|
||||
isAudioCached = false
|
||||
)
|
||||
val values = ContentValues().apply {
|
||||
put(COL_TRACK_ID, track.id)
|
||||
put(COL_TITLE, track.title)
|
||||
put(COL_TITLE_SORT, track.title.lowercase())
|
||||
put(COL_ARTISTS_SORT, track.artists.joinToString(" ").lowercase())
|
||||
put(COL_RELEASE_ID, track.releaseId)
|
||||
put(COL_RELEASE_TITLE, track.releaseTitle)
|
||||
put(COL_RELEASE_YEAR, track.releaseYear)
|
||||
put(COL_DURATION_SECONDS, track.durationSeconds)
|
||||
put(COL_COVER_URL, track.remoteCoverUrl ?: track.coverUrl)
|
||||
put(COL_REMOTE_COVER_URL, track.remoteCoverUrl ?: track.coverUrl)
|
||||
put(COL_STREAM_URL, track.remoteStreamUrl.ifBlank { track.streamUrl })
|
||||
put(COL_REMOTE_STREAM_URL, track.remoteStreamUrl.ifBlank { track.streamUrl })
|
||||
put(COL_TRACK_JSON, trackAdapter.toJson(storedTrack))
|
||||
put(COL_UPDATED_AT, track.metadataUpdatedAt)
|
||||
put(COL_AUDIO_HASH, track.audioHash)
|
||||
put(COL_AUDIO_SIZE_BYTES, track.audioSizeBytes)
|
||||
put(COL_AUDIO_MIME_TYPE, track.audioMimeType)
|
||||
put(COL_AUDIO_UPDATED_AT, track.audioUpdatedAt)
|
||||
put(COL_COVER_HASH, track.coverHash)
|
||||
put(COL_COVER_UPDATED_AT, track.coverUpdatedAt)
|
||||
if (existing == null) {
|
||||
put(COL_AUDIO_STATUS, STATUS_NONE)
|
||||
put(COL_COVER_STATUS, STATUS_NONE)
|
||||
put(COL_CACHED_AUDIO_BYTES, 0L)
|
||||
put(COL_CACHED_COVER_BYTES, 0L)
|
||||
put(COL_LAST_ACCESSED_AT, 0L)
|
||||
put(COL_PLAY_COUNT, 0)
|
||||
}
|
||||
}
|
||||
db.insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, track.id, values)
|
||||
}
|
||||
|
||||
private fun getTrackRecordLocked(db: SQLiteDatabase, trackId: Long): OfflineTrackRecord? {
|
||||
return db.query(
|
||||
TABLE_TRACKS,
|
||||
arrayOf(COL_AUDIO_HASH, COL_COVER_HASH),
|
||||
"$COL_TRACK_ID = ?",
|
||||
arrayOf(trackId.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"1"
|
||||
).useCursor { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
OfflineTrackRecord(
|
||||
audioHash = cursor.getStringOrNull(COL_AUDIO_HASH),
|
||||
coverHash = cursor.getStringOrNull(COL_COVER_HASH)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMediaColumns(
|
||||
trackId: Long,
|
||||
localPathColumn: String,
|
||||
statusColumn: String,
|
||||
bytesColumn: String,
|
||||
path: String,
|
||||
sizeBytes: Long
|
||||
) {
|
||||
dbHelper.writableDatabase.update(
|
||||
TABLE_TRACKS,
|
||||
ContentValues().apply {
|
||||
put(localPathColumn, path)
|
||||
put(statusColumn, STATUS_READY)
|
||||
put(bytesColumn, sizeBytes.coerceAtLeast(0L))
|
||||
put(COL_LAST_ACCESSED_AT, System.currentTimeMillis())
|
||||
},
|
||||
"$COL_TRACK_ID = ?",
|
||||
arrayOf(trackId.toString())
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.toTrackCard(preferLocalMedia: Boolean): TrackCard? {
|
||||
val json = getStringOrNull(COL_TRACK_JSON) ?: return null
|
||||
val base = runCatching { trackAdapter.fromJson(json) }.getOrNull() ?: return null
|
||||
val localAudioPath = getStringOrNull(COL_LOCAL_AUDIO_PATH)
|
||||
?.takeIf { getStringOrNull(COL_AUDIO_STATUS) == STATUS_READY && File(it).exists() }
|
||||
val localCoverPath = getStringOrNull(COL_LOCAL_COVER_PATH)
|
||||
?.takeIf { getStringOrNull(COL_COVER_STATUS) == STATUS_READY && File(it).exists() }
|
||||
val remoteStreamUrl = getStringOrNull(COL_REMOTE_STREAM_URL)
|
||||
?: base.remoteStreamUrl.ifBlank { base.streamUrl }
|
||||
val remoteCoverUrl = getStringOrNull(COL_REMOTE_COVER_URL) ?: base.remoteCoverUrl
|
||||
return base.copy(
|
||||
streamUrl = if (preferLocalMedia && localAudioPath != null) {
|
||||
Uri.fromFile(File(localAudioPath)).toString()
|
||||
} else {
|
||||
remoteStreamUrl
|
||||
},
|
||||
coverUrl = if (preferLocalMedia && localCoverPath != null) {
|
||||
Uri.fromFile(File(localCoverPath)).toString()
|
||||
} else {
|
||||
remoteCoverUrl
|
||||
},
|
||||
remoteStreamUrl = remoteStreamUrl,
|
||||
remoteCoverUrl = remoteCoverUrl,
|
||||
metadataUpdatedAt = getStringOrNull(COL_UPDATED_AT) ?: base.metadataUpdatedAt,
|
||||
audioHash = getStringOrNull(COL_AUDIO_HASH) ?: base.audioHash,
|
||||
audioSizeBytes = getLongOrNull(COL_AUDIO_SIZE_BYTES) ?: base.audioSizeBytes,
|
||||
audioMimeType = getStringOrNull(COL_AUDIO_MIME_TYPE) ?: base.audioMimeType,
|
||||
audioUpdatedAt = getStringOrNull(COL_AUDIO_UPDATED_AT) ?: base.audioUpdatedAt,
|
||||
coverHash = getStringOrNull(COL_COVER_HASH) ?: base.coverHash,
|
||||
coverUpdatedAt = getStringOrNull(COL_COVER_UPDATED_AT) ?: base.coverUpdatedAt,
|
||||
localAudioPath = localAudioPath,
|
||||
localCoverPath = localCoverPath,
|
||||
isAudioCached = localAudioPath != null
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.toPlaylistCard(trackCount: Int): PlaylistCard {
|
||||
return PlaylistCard(
|
||||
id = getLong(COL_PLAYLIST_ID),
|
||||
title = getString(COL_PLAYLIST_TITLE),
|
||||
trackCount = trackCount,
|
||||
isPublic = getInt(COL_PLAYLIST_IS_PUBLIC) == 1,
|
||||
isOwn = getInt(COL_PLAYLIST_IS_OWN) == 1,
|
||||
ownerName = getStringOrNull(COL_PLAYLIST_OWNER_NAME),
|
||||
isSaved = getInt(COL_PLAYLIST_IS_SAVED) == 1,
|
||||
kind = getStringOrNull(COL_PLAYLIST_KIND)
|
||||
)
|
||||
}
|
||||
|
||||
private class OfflineDbHelper(context: Context) : SQLiteOpenHelper(
|
||||
context,
|
||||
DB_NAME,
|
||||
null,
|
||||
DB_VERSION
|
||||
) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE $TABLE_TRACKS (
|
||||
$COL_TRACK_ID INTEGER PRIMARY KEY,
|
||||
$COL_TITLE TEXT,
|
||||
$COL_TITLE_SORT TEXT,
|
||||
$COL_ARTISTS_SORT TEXT,
|
||||
$COL_RELEASE_ID INTEGER,
|
||||
$COL_RELEASE_TITLE TEXT,
|
||||
$COL_RELEASE_YEAR INTEGER,
|
||||
$COL_DURATION_SECONDS INTEGER,
|
||||
$COL_COVER_URL TEXT,
|
||||
$COL_REMOTE_COVER_URL TEXT,
|
||||
$COL_STREAM_URL TEXT,
|
||||
$COL_REMOTE_STREAM_URL TEXT,
|
||||
$COL_TRACK_JSON TEXT,
|
||||
$COL_UPDATED_AT TEXT,
|
||||
$COL_AUDIO_HASH TEXT,
|
||||
$COL_AUDIO_SIZE_BYTES INTEGER,
|
||||
$COL_AUDIO_MIME_TYPE TEXT,
|
||||
$COL_AUDIO_UPDATED_AT TEXT,
|
||||
$COL_COVER_HASH TEXT,
|
||||
$COL_COVER_UPDATED_AT TEXT,
|
||||
$COL_LOCAL_AUDIO_PATH TEXT,
|
||||
$COL_AUDIO_STATUS TEXT NOT NULL DEFAULT '$STATUS_NONE',
|
||||
$COL_LOCAL_COVER_PATH TEXT,
|
||||
$COL_COVER_STATUS TEXT NOT NULL DEFAULT '$STATUS_NONE',
|
||||
$COL_CACHED_AUDIO_BYTES INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_CACHED_COVER_BYTES INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_LAST_ACCESSED_AT INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_PLAY_COUNT INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL("CREATE INDEX idx_offline_tracks_title ON $TABLE_TRACKS($COL_TITLE_SORT)")
|
||||
db.execSQL("CREATE INDEX idx_offline_tracks_release ON $TABLE_TRACKS($COL_RELEASE_ID)")
|
||||
db.execSQL("CREATE INDEX idx_offline_tracks_audio_status ON $TABLE_TRACKS($COL_AUDIO_STATUS)")
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE $TABLE_PLAYLISTS (
|
||||
$COL_PLAYLIST_ID INTEGER PRIMARY KEY,
|
||||
$COL_PLAYLIST_TITLE TEXT NOT NULL,
|
||||
$COL_PLAYLIST_DESCRIPTION TEXT,
|
||||
$COL_PLAYLIST_UPDATED_AT TEXT,
|
||||
$COL_PLAYLIST_IS_OWN INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_PLAYLIST_OWNER_NAME TEXT,
|
||||
$COL_PLAYLIST_IS_PUBLIC INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_PLAYLIST_IS_SAVED INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_PLAYLIST_KIND TEXT,
|
||||
$COL_PLAYLIST_TRACK_IDS TEXT NOT NULL DEFAULT '[]'
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE $TABLE_LIKES (
|
||||
$COL_LIKE_TRACK_ID INTEGER PRIMARY KEY
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_LIKES")
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_PLAYLISTS")
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_TRACKS")
|
||||
onCreate(db)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DB_NAME = "furumi_offline.db"
|
||||
const val DB_VERSION = 1
|
||||
|
||||
const val TABLE_TRACKS = "offline_tracks"
|
||||
const val TABLE_PLAYLISTS = "offline_playlists"
|
||||
const val TABLE_LIKES = "offline_likes"
|
||||
|
||||
const val COL_TRACK_ID = "track_id"
|
||||
const val COL_TITLE = "title"
|
||||
const val COL_TITLE_SORT = "title_sort"
|
||||
const val COL_ARTISTS_SORT = "artists_sort"
|
||||
const val COL_RELEASE_ID = "release_id"
|
||||
const val COL_RELEASE_TITLE = "release_title"
|
||||
const val COL_RELEASE_YEAR = "release_year"
|
||||
const val COL_DURATION_SECONDS = "duration_seconds"
|
||||
const val COL_COVER_URL = "cover_url"
|
||||
const val COL_REMOTE_COVER_URL = "remote_cover_url"
|
||||
const val COL_STREAM_URL = "stream_url"
|
||||
const val COL_REMOTE_STREAM_URL = "remote_stream_url"
|
||||
const val COL_TRACK_JSON = "track_json"
|
||||
const val COL_UPDATED_AT = "updated_at"
|
||||
const val COL_AUDIO_HASH = "audio_hash"
|
||||
const val COL_AUDIO_SIZE_BYTES = "audio_size_bytes"
|
||||
const val COL_AUDIO_MIME_TYPE = "audio_mime_type"
|
||||
const val COL_AUDIO_UPDATED_AT = "audio_updated_at"
|
||||
const val COL_COVER_HASH = "cover_hash"
|
||||
const val COL_COVER_UPDATED_AT = "cover_updated_at"
|
||||
const val COL_LOCAL_AUDIO_PATH = "local_audio_path"
|
||||
const val COL_AUDIO_STATUS = "audio_status"
|
||||
const val COL_LOCAL_COVER_PATH = "local_cover_path"
|
||||
const val COL_COVER_STATUS = "cover_status"
|
||||
const val COL_CACHED_AUDIO_BYTES = "cached_audio_bytes"
|
||||
const val COL_CACHED_COVER_BYTES = "cached_cover_bytes"
|
||||
const val COL_LAST_ACCESSED_AT = "last_accessed_at"
|
||||
const val COL_PLAY_COUNT = "play_count"
|
||||
|
||||
const val COL_PLAYLIST_ID = "playlist_id"
|
||||
const val COL_PLAYLIST_TITLE = "title"
|
||||
const val COL_PLAYLIST_DESCRIPTION = "description"
|
||||
const val COL_PLAYLIST_UPDATED_AT = "updated_at"
|
||||
const val COL_PLAYLIST_IS_OWN = "is_own"
|
||||
const val COL_PLAYLIST_OWNER_NAME = "owner_name"
|
||||
const val COL_PLAYLIST_IS_PUBLIC = "is_public"
|
||||
const val COL_PLAYLIST_IS_SAVED = "is_saved"
|
||||
const val COL_PLAYLIST_KIND = "kind"
|
||||
const val COL_PLAYLIST_TRACK_IDS = "track_ids_json"
|
||||
|
||||
const val COL_LIKE_TRACK_ID = "track_id"
|
||||
|
||||
const val STATUS_NONE = "none"
|
||||
const val STATUS_READY = "ready"
|
||||
const val STATUS_STALE = "stale"
|
||||
}
|
||||
}
|
||||
|
||||
data class OfflineCachedMediaEntry(
|
||||
val trackId: Long,
|
||||
val path: String,
|
||||
val sizeBytes: Long,
|
||||
val lastAccessedAtMs: Long,
|
||||
val playCount: Int
|
||||
)
|
||||
|
||||
data class OfflineStaleMediaPath(
|
||||
val trackId: Long,
|
||||
val path: String,
|
||||
val isAudio: Boolean
|
||||
)
|
||||
|
||||
data class OfflineStorageStats(
|
||||
val usedBytes: Long,
|
||||
val cachedTrackCount: Int
|
||||
)
|
||||
|
||||
private data class OfflineTrackRecord(
|
||||
val audioHash: String?,
|
||||
val coverHash: String?
|
||||
)
|
||||
|
||||
private data class ArtistSeed(
|
||||
val artistName: String,
|
||||
val artistId: Long = -artistName.hashCode().toLong()
|
||||
) {
|
||||
constructor(ref: cy.hexor.furumi.domain.model.ArtistRef) : this(ref.name, ref.id)
|
||||
|
||||
val artistIdOrName: String
|
||||
get() = "$artistId:$artistName"
|
||||
}
|
||||
|
||||
private inline fun <T> SQLiteDatabase.transaction(block: SQLiteDatabase.() -> T): T {
|
||||
beginTransaction()
|
||||
return try {
|
||||
val result = block()
|
||||
setTransactionSuccessful()
|
||||
result
|
||||
} finally {
|
||||
endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.insertOrUpdate(
|
||||
table: String,
|
||||
keyColumn: String,
|
||||
key: Long,
|
||||
values: ContentValues
|
||||
) {
|
||||
val updated = update(table, values, "$keyColumn = ?", arrayOf(key.toString()))
|
||||
if (updated == 0) {
|
||||
insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Long>.toJsonArrayString(): String {
|
||||
val array = JSONArray()
|
||||
forEach(array::put)
|
||||
return array.toString()
|
||||
}
|
||||
|
||||
private fun String?.longListFromJson(): List<Long> {
|
||||
if (isNullOrBlank()) return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(this)
|
||||
List(array.length()) { index -> array.optLong(index) }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun Boolean.toInt(): Int = if (this) 1 else 0
|
||||
|
||||
private fun Cursor.getColumnIndexOrThrowCached(name: String): Int = getColumnIndexOrThrow(name)
|
||||
|
||||
private fun Cursor.getString(name: String): String = getString(getColumnIndexOrThrowCached(name))
|
||||
|
||||
private fun Cursor.getStringOrNull(name: String): String? {
|
||||
val index = getColumnIndexOrThrowCached(name)
|
||||
return if (isNull(index)) null else getString(index)
|
||||
}
|
||||
|
||||
private fun Cursor.getLong(name: String): Long = getLong(getColumnIndexOrThrowCached(name))
|
||||
|
||||
private fun Cursor.getLongOrNull(name: String): Long? {
|
||||
val index = getColumnIndexOrThrowCached(name)
|
||||
return if (isNull(index)) null else getLong(index)
|
||||
}
|
||||
|
||||
private fun Cursor.getInt(name: String): Int = getInt(getColumnIndexOrThrowCached(name))
|
||||
|
||||
private inline fun <T> Cursor.useCursor(block: (Cursor) -> T): T = use(block)
|
||||
@@ -0,0 +1,163 @@
|
||||
package cy.hexor.furumi.data.local
|
||||
|
||||
import android.content.Context
|
||||
import cy.hexor.furumi.domain.model.TrackCard
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
@Singleton
|
||||
class OfflineMediaStore @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val libraryStore: OfflineLibraryStore,
|
||||
private val settingsStorage: OfflineSettingsStorage
|
||||
) {
|
||||
private val rootDir = File(context.filesDir, "offline")
|
||||
private val audioDir = File(rootDir, "audio")
|
||||
private val coversDir = File(rootDir, "covers")
|
||||
|
||||
suspend fun cacheTrackAfterListening(trackId: Long) = withContext(Dispatchers.IO) {
|
||||
if (!settingsStorage.state.value.settings.saveListenedTracksEnabled) {
|
||||
libraryStore.markTrackAccessed(trackId)
|
||||
publishStorageStats()
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val track = libraryStore.getTrack(trackId, preferLocalMedia = false)
|
||||
?: return@withContext
|
||||
cacheTrackMedia(track)
|
||||
}
|
||||
|
||||
suspend fun cacheTrackMedia(track: TrackCard) = withContext(Dispatchers.IO) {
|
||||
libraryStore.upsertTracks(listOf(track))
|
||||
libraryStore.markTrackAccessed(track.id)
|
||||
pruneStaleMedia()
|
||||
|
||||
val storedTrack = libraryStore.getTrack(track.id, preferLocalMedia = false) ?: track
|
||||
val audioPath = storedTrack.localAudioPath
|
||||
if (audioPath.isNullOrBlank() || !File(audioPath).exists()) {
|
||||
downloadAudio(storedTrack)
|
||||
}
|
||||
|
||||
val coverPath = storedTrack.localCoverPath
|
||||
if ((coverPath.isNullOrBlank() || !File(coverPath).exists()) &&
|
||||
!storedTrack.remoteCoverUrl.isNullOrBlank()
|
||||
) {
|
||||
downloadCover(storedTrack)
|
||||
}
|
||||
|
||||
enforceStorageLimit()
|
||||
publishStorageStats()
|
||||
}
|
||||
|
||||
suspend fun clearCachedMedia() = withContext(Dispatchers.IO) {
|
||||
rootDir.deleteRecursively()
|
||||
libraryStore.clearCachedMedia()
|
||||
publishStorageStats()
|
||||
}
|
||||
|
||||
suspend fun pruneStaleMedia() = withContext(Dispatchers.IO) {
|
||||
libraryStore.staleMediaPaths().forEach { stale ->
|
||||
File(stale.path).delete()
|
||||
if (stale.isAudio) {
|
||||
libraryStore.markAudioRemoved(stale.trackId)
|
||||
} else {
|
||||
libraryStore.markCoverRemoved(stale.trackId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun publishStorageStats() {
|
||||
val stats = libraryStore.storageStats()
|
||||
settingsStorage.setStorageStats(stats.usedBytes, stats.cachedTrackCount)
|
||||
}
|
||||
|
||||
private fun downloadAudio(track: TrackCard) {
|
||||
val sourceUrl = track.remoteStreamUrl.ifBlank { track.streamUrl }
|
||||
if (sourceUrl.isBlank()) return
|
||||
|
||||
val extension = extensionForMime(track.audioMimeType)
|
||||
?: extensionFromUrl(sourceUrl)
|
||||
?: "audio"
|
||||
val target = File(audioDir, "${track.id}-${track.audioHash ?: track.audioUpdatedAt ?: "audio"}.$extension")
|
||||
val bytes = downloadToFile(sourceUrl, target)
|
||||
libraryStore.markAudioCached(track.id, target.absolutePath, bytes)
|
||||
}
|
||||
|
||||
private fun downloadCover(track: TrackCard) {
|
||||
val sourceUrl = track.remoteCoverUrl ?: track.coverUrl ?: return
|
||||
if (sourceUrl.isBlank()) return
|
||||
|
||||
val extension = extensionFromUrl(sourceUrl) ?: "jpg"
|
||||
val target = File(coversDir, "${track.id}-${track.coverHash ?: track.coverUpdatedAt ?: "cover"}.$extension")
|
||||
val bytes = downloadToFile(sourceUrl, target)
|
||||
libraryStore.markCoverCached(track.id, target.absolutePath, bytes)
|
||||
}
|
||||
|
||||
private fun downloadToFile(url: String, target: File): Long {
|
||||
target.parentFile?.mkdirs()
|
||||
val temp = File(target.parentFile, "${target.name}.tmp")
|
||||
temp.delete()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Offline download failed with HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body ?: throw IOException("Offline download response is empty")
|
||||
temp.outputStream().use { output ->
|
||||
body.byteStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!temp.renameTo(target)) {
|
||||
temp.copyTo(target, overwrite = true)
|
||||
temp.delete()
|
||||
}
|
||||
return target.length()
|
||||
}
|
||||
|
||||
private fun enforceStorageLimit() {
|
||||
val limitBytes = settingsStorage.state.value.settings.storageLimitBytes
|
||||
var usedBytes = libraryStore.storageStats().usedBytes
|
||||
if (usedBytes <= limitBytes) return
|
||||
|
||||
for (entry in libraryStore.cachedAudioEntriesForEviction()) {
|
||||
if (usedBytes <= limitBytes) break
|
||||
File(entry.path).delete()
|
||||
libraryStore.markAudioRemoved(entry.trackId)
|
||||
usedBytes -= entry.sizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
private fun extensionForMime(mimeType: String?): String? {
|
||||
return when (mimeType?.lowercase()) {
|
||||
"audio/mpeg", "audio/mp3" -> "mp3"
|
||||
"audio/flac" -> "flac"
|
||||
"audio/ogg", "audio/opus" -> "ogg"
|
||||
"audio/mp4", "audio/aac" -> "m4a"
|
||||
"audio/wav", "audio/x-wav" -> "wav"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extensionFromUrl(url: String): String? {
|
||||
val cleanUrl = url.substringBefore('?').substringBefore('#')
|
||||
return cleanUrl.substringAfterLast('/', "")
|
||||
.substringAfterLast('.', "")
|
||||
.takeIf { it.length in 2..5 && it.all(Char::isLetterOrDigit) }
|
||||
?.lowercase()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package cy.hexor.furumi.data.local
|
||||
|
||||
import android.content.Context
|
||||
import cy.hexor.furumi.domain.model.OfflineSettings
|
||||
import cy.hexor.furumi.domain.model.OfflineState
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class OfflineSettingsStorage @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val _state = MutableStateFlow(loadState())
|
||||
|
||||
val state: StateFlow<OfflineState> = _state.asStateFlow()
|
||||
|
||||
fun setOfflineModeEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_OFFLINE_MODE, enabled).apply()
|
||||
updateSettings { it.copy(offlineModeEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setSaveListenedTracksEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_SAVE_LISTENED, enabled).apply()
|
||||
updateSettings { it.copy(saveListenedTracksEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setStorageLimitBytes(limitBytes: Long) {
|
||||
val safeLimit = limitBytes.coerceAtLeast(MIN_STORAGE_LIMIT_BYTES)
|
||||
prefs.edit().putLong(KEY_STORAGE_LIMIT_BYTES, safeLimit).apply()
|
||||
updateSettings { it.copy(storageLimitBytes = safeLimit) }
|
||||
}
|
||||
|
||||
fun setSyncing(syncing: Boolean) {
|
||||
_state.value = _state.value.copy(isSyncing = syncing)
|
||||
}
|
||||
|
||||
fun setLastSyncedAt(timestampMs: Long) {
|
||||
prefs.edit().putLong(KEY_LAST_SYNCED_AT_MS, timestampMs).apply()
|
||||
_state.value = _state.value.copy(lastSyncedAtMs = timestampMs, lastError = null)
|
||||
}
|
||||
|
||||
fun setStorageStats(usedBytes: Long, cachedTrackCount: Int) {
|
||||
_state.value = _state.value.copy(
|
||||
storageUsedBytes = usedBytes.coerceAtLeast(0L),
|
||||
cachedTrackCount = cachedTrackCount.coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
|
||||
fun setNetworkFallback(active: Boolean, errorMessage: String? = null) {
|
||||
_state.value = _state.value.copy(
|
||||
isUsingNetworkFallback = active,
|
||||
lastError = errorMessage ?: _state.value.lastError
|
||||
)
|
||||
}
|
||||
|
||||
fun setLastError(message: String?) {
|
||||
_state.value = _state.value.copy(lastError = message)
|
||||
}
|
||||
|
||||
private fun updateSettings(block: (OfflineSettings) -> OfflineSettings) {
|
||||
_state.value = _state.value.copy(settings = block(_state.value.settings))
|
||||
}
|
||||
|
||||
private fun loadState(): OfflineState {
|
||||
return OfflineState(
|
||||
settings = OfflineSettings(
|
||||
offlineModeEnabled = prefs.getBoolean(KEY_OFFLINE_MODE, false),
|
||||
saveListenedTracksEnabled = prefs.getBoolean(KEY_SAVE_LISTENED, false),
|
||||
storageLimitBytes = prefs.getLong(
|
||||
KEY_STORAGE_LIMIT_BYTES,
|
||||
OfflineSettings.DEFAULT_STORAGE_LIMIT_BYTES
|
||||
)
|
||||
),
|
||||
lastSyncedAtMs = prefs.getLong(KEY_LAST_SYNCED_AT_MS, 0L)
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "offline_settings"
|
||||
const val KEY_OFFLINE_MODE = "offline_mode"
|
||||
const val KEY_SAVE_LISTENED = "save_listened"
|
||||
const val KEY_STORAGE_LIMIT_BYTES = "storage_limit_bytes"
|
||||
const val KEY_LAST_SYNCED_AT_MS = "last_synced_at_ms"
|
||||
const val MIN_STORAGE_LIMIT_BYTES = 256L * 1024L * 1024L
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ class PlayerEndpoints @Inject constructor() {
|
||||
|
||||
fun playlistDetail(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId"
|
||||
|
||||
fun offlineManifest(baseUrl: String): String = "$baseUrl$OFFLINE_MANIFEST_PATH"
|
||||
|
||||
fun tracksByIds(baseUrl: String): String = "$baseUrl$TRACKS_BY_IDS_PATH"
|
||||
|
||||
fun addPlaylistTracks(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId/tracks"
|
||||
|
||||
fun sharePlaylist(baseUrl: String): String = "$baseUrl$SHARE_PLAYLIST_PATH"
|
||||
@@ -53,6 +57,8 @@ class PlayerEndpoints @Inject constructor() {
|
||||
private const val LIKES_PATH = "/api/player/likes"
|
||||
private const val LIKES_TOGGLE_PATH = "/api/player/likes/toggle"
|
||||
private const val PLAYLISTS_PATH = "/api/player/playlists"
|
||||
private const val OFFLINE_MANIFEST_PATH = "/api/player/offline/manifest"
|
||||
private const val TRACKS_BY_IDS_PATH = "/api/player/tracks-by-ids"
|
||||
private const val SHARE_PLAYLIST_PATH = "/api/player/share-playlist"
|
||||
private const val SHARE_TRACK_PATH = "/share/track"
|
||||
private const val DEVICES_POLL_PATH = "/api/player/devices/poll"
|
||||
|
||||
@@ -16,12 +16,15 @@ import cy.hexor.furumi.data.remote.model.JamJoinRequest
|
||||
import cy.hexor.furumi.data.remote.model.JamUserResponse
|
||||
import cy.hexor.furumi.data.remote.model.LikeToggleResponse
|
||||
import cy.hexor.furumi.data.remote.model.LikedTrackIdsResponse
|
||||
import cy.hexor.furumi.data.remote.model.OfflineManifestResponse
|
||||
import cy.hexor.furumi.data.remote.model.PlaylistCardResponse
|
||||
import cy.hexor.furumi.data.remote.model.PlaylistDetailResponse
|
||||
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
|
||||
import cy.hexor.furumi.data.remote.model.SearchResponse
|
||||
import cy.hexor.furumi.data.remote.model.SharePlaylistRequest
|
||||
import cy.hexor.furumi.data.remote.model.SharePlaylistResponse
|
||||
import cy.hexor.furumi.data.remote.model.TrackItemResponse
|
||||
import cy.hexor.furumi.data.remote.model.TracksByIdsRequest
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
@@ -94,6 +97,19 @@ interface PlayerApi {
|
||||
@Url url: String
|
||||
): Response<List<PlaylistCardResponse>>
|
||||
|
||||
@Headers("Accept: application/json")
|
||||
@GET
|
||||
suspend fun offlineManifest(
|
||||
@Url url: String
|
||||
): Response<OfflineManifestResponse>
|
||||
|
||||
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||
@POST
|
||||
suspend fun tracksByIds(
|
||||
@Url url: String,
|
||||
@Body body: TracksByIdsRequest
|
||||
): Response<List<TrackItemResponse>>
|
||||
|
||||
@Headers("Accept: application/json")
|
||||
@GET
|
||||
suspend fun playlistDetail(
|
||||
|
||||
@@ -27,11 +27,53 @@ data class TrackItemResponse(
|
||||
@param:Json(name = "duration_seconds") val durationSeconds: Double? = null,
|
||||
@param:Json(name = "artists") val artists: List<ArtistRefResponse> = emptyList(),
|
||||
@param:Json(name = "featured_artists") val featuredArtists: List<ArtistRefResponse> = emptyList(),
|
||||
@param:Json(name = "release_id") val releaseId: Long? = null,
|
||||
@param:Json(name = "release_title") val releaseTitle: String? = null,
|
||||
@param:Json(name = "release_year") val releaseYear: Int? = null,
|
||||
@param:Json(name = "cover_url") val coverUrl: String? = null,
|
||||
@param:Json(name = "stream_url") val streamUrl: String? = null
|
||||
)
|
||||
|
||||
data class TracksByIdsRequest(
|
||||
@param:Json(name = "ids") val ids: List<Long>
|
||||
)
|
||||
|
||||
data class OfflineManifestResponse(
|
||||
@param:Json(name = "generated_at") val generatedAt: String,
|
||||
@param:Json(name = "tracks") val tracks: List<OfflineManifestTrackResponse> = emptyList(),
|
||||
@param:Json(name = "playlists") val playlists: List<OfflineManifestPlaylistResponse> = emptyList(),
|
||||
@param:Json(name = "liked_track_ids") val likedTrackIds: List<Long> = emptyList(),
|
||||
@param:Json(name = "followed_artist_ids") val followedArtistIds: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
data class OfflineManifestTrackResponse(
|
||||
@param:Json(name = "id") val id: Long,
|
||||
@param:Json(name = "updated_at") val updatedAt: String? = null,
|
||||
@param:Json(name = "stream_url") val streamUrl: String,
|
||||
@param:Json(name = "audio_file_id") val audioFileId: Long? = null,
|
||||
@param:Json(name = "audio_hash") val audioHash: String? = null,
|
||||
@param:Json(name = "audio_size_bytes") val audioSizeBytes: Long? = null,
|
||||
@param:Json(name = "audio_mime_type") val audioMimeType: String? = null,
|
||||
@param:Json(name = "audio_updated_at") val audioUpdatedAt: String? = null,
|
||||
@param:Json(name = "cover_file_id") val coverFileId: Long? = null,
|
||||
@param:Json(name = "cover_url") val coverUrl: String? = null,
|
||||
@param:Json(name = "cover_hash") val coverHash: String? = null,
|
||||
@param:Json(name = "cover_updated_at") val coverUpdatedAt: String? = null
|
||||
)
|
||||
|
||||
data class OfflineManifestPlaylistResponse(
|
||||
@param:Json(name = "id") val id: Long,
|
||||
@param:Json(name = "title") val title: String,
|
||||
@param:Json(name = "description") val description: String? = null,
|
||||
@param:Json(name = "updated_at") val updatedAt: String? = null,
|
||||
@param:Json(name = "is_own") val isOwn: Boolean = false,
|
||||
@param:Json(name = "owner_name") val ownerName: String? = null,
|
||||
@param:Json(name = "is_public") val isPublic: Boolean = false,
|
||||
@param:Json(name = "is_saved") val isSaved: Boolean = false,
|
||||
@param:Json(name = "kind") val kind: String? = null,
|
||||
@param:Json(name = "track_ids") val trackIds: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
data class PlayHistoryItemResponse(
|
||||
@param:Json(name = "id") val id: Long,
|
||||
@param:Json(name = "track_id") val trackId: Long,
|
||||
@@ -296,6 +338,9 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
||||
.ifEmpty { mappedFeaturedArtists }
|
||||
.map { it.name }
|
||||
|
||||
val absoluteCoverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl)
|
||||
val absoluteStreamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
|
||||
|
||||
return TrackCard(
|
||||
id = id ?: 0L,
|
||||
title = title,
|
||||
@@ -305,9 +350,13 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
||||
artists = artistNames,
|
||||
artistRefs = mappedArtists,
|
||||
featuredArtistRefs = mappedFeaturedArtists,
|
||||
releaseId = releaseId,
|
||||
releaseYear = releaseYear,
|
||||
releaseTitle = releaseTitle,
|
||||
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
|
||||
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
|
||||
coverUrl = absoluteCoverUrl,
|
||||
streamUrl = absoluteStreamUrl,
|
||||
remoteCoverUrl = absoluteCoverUrl,
|
||||
remoteStreamUrl = absoluteStreamUrl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,9 +375,34 @@ fun TrackCard.toTrackItemResponse(): TrackItemResponse {
|
||||
durationSeconds = durationSeconds?.toDouble() ?: 0.0,
|
||||
artists = primaryArtists.map { it.toResponse() },
|
||||
featuredArtists = featuredArtistRefs.map { it.toResponse() },
|
||||
releaseId = releaseId,
|
||||
releaseTitle = releaseTitle,
|
||||
coverUrl = coverUrl,
|
||||
streamUrl = streamUrl
|
||||
releaseYear = releaseYear,
|
||||
coverUrl = remoteCoverUrl ?: coverUrl,
|
||||
streamUrl = remoteStreamUrl.ifBlank { streamUrl }
|
||||
)
|
||||
}
|
||||
|
||||
fun OfflineManifestTrackResponse.withAbsoluteUrls(baseUrl: String): OfflineManifestTrackResponse {
|
||||
return copy(
|
||||
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty(),
|
||||
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl)
|
||||
)
|
||||
}
|
||||
|
||||
fun TrackCard.withOfflineManifest(manifest: OfflineManifestTrackResponse): TrackCard {
|
||||
return copy(
|
||||
coverUrl = manifest.coverUrl ?: coverUrl,
|
||||
streamUrl = manifest.streamUrl.ifBlank { streamUrl },
|
||||
remoteCoverUrl = manifest.coverUrl ?: remoteCoverUrl,
|
||||
remoteStreamUrl = manifest.streamUrl.ifBlank { remoteStreamUrl },
|
||||
metadataUpdatedAt = manifest.updatedAt ?: metadataUpdatedAt,
|
||||
audioHash = manifest.audioHash ?: audioHash,
|
||||
audioSizeBytes = manifest.audioSizeBytes ?: audioSizeBytes,
|
||||
audioMimeType = manifest.audioMimeType ?: audioMimeType,
|
||||
audioUpdatedAt = manifest.audioUpdatedAt ?: audioUpdatedAt,
|
||||
coverHash = manifest.coverHash ?: coverHash,
|
||||
coverUpdatedAt = manifest.coverUpdatedAt ?: coverUpdatedAt
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package cy.hexor.furumi.data.repository
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -17,6 +19,17 @@ class MediaImageLoader @Inject constructor(
|
||||
suspend fun load(url: String): Result<Bitmap> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (url.startsWith("file://", ignoreCase = true)) {
|
||||
val path = Uri.parse(url).path ?: throw IOException("Image file path is empty")
|
||||
return@runCatching BitmapFactory.decodeFile(path)
|
||||
?: throw IOException("Image file is not a supported bitmap")
|
||||
}
|
||||
|
||||
if (url.startsWith("/", ignoreCase = false)) {
|
||||
return@runCatching BitmapFactory.decodeFile(File(url).absolutePath)
|
||||
?: throw IOException("Image file is not a supported bitmap")
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
@@ -2,6 +2,9 @@ package cy.hexor.furumi.data.repository
|
||||
|
||||
import cy.hexor.furumi.data.local.AuthSessionStorage
|
||||
import cy.hexor.furumi.data.local.ConnectedDeviceStorage
|
||||
import cy.hexor.furumi.data.local.OfflineLibraryStore
|
||||
import cy.hexor.furumi.data.local.OfflineMediaStore
|
||||
import cy.hexor.furumi.data.local.OfflineSettingsStorage
|
||||
import cy.hexor.furumi.data.remote.AppClientInfo
|
||||
import cy.hexor.furumi.data.remote.AuthApiErrorParser
|
||||
import cy.hexor.furumi.data.remote.PlayerEndpoints
|
||||
@@ -16,10 +19,13 @@ import cy.hexor.furumi.data.remote.model.JamCreateRequest
|
||||
import cy.hexor.furumi.data.remote.model.JamInviteRequest
|
||||
import cy.hexor.furumi.data.remote.model.JamJoinRequest
|
||||
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
|
||||
import cy.hexor.furumi.data.remote.model.TracksByIdsRequest
|
||||
import cy.hexor.furumi.data.remote.model.toDomain
|
||||
import cy.hexor.furumi.data.remote.model.toBody
|
||||
import cy.hexor.furumi.data.remote.model.toCommandPayloadBody
|
||||
import cy.hexor.furumi.data.remote.model.toTrackItemResponse
|
||||
import cy.hexor.furumi.data.remote.model.withAbsoluteUrls
|
||||
import cy.hexor.furumi.data.remote.model.withOfflineManifest
|
||||
import cy.hexor.furumi.domain.model.ArtistDetail
|
||||
import cy.hexor.furumi.domain.model.ArtistPage
|
||||
import cy.hexor.furumi.domain.model.AuthException
|
||||
@@ -28,6 +34,8 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
|
||||
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
||||
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
||||
import cy.hexor.furumi.domain.model.ListeningHistoryPage
|
||||
import cy.hexor.furumi.domain.model.OfflineState
|
||||
import cy.hexor.furumi.domain.model.OfflineSyncSummary
|
||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||
import cy.hexor.furumi.domain.model.PlaylistDetail
|
||||
import cy.hexor.furumi.domain.model.ReleaseDetail
|
||||
@@ -35,18 +43,113 @@ import cy.hexor.furumi.domain.model.SearchResults
|
||||
import cy.hexor.furumi.domain.model.TrackCard
|
||||
import cy.hexor.furumi.domain.repository.PlayerRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
class PlayerRepositoryImpl @Inject constructor(
|
||||
private val playerApi: PlayerApi,
|
||||
private val sessionStorage: AuthSessionStorage,
|
||||
private val connectedDeviceStorage: ConnectedDeviceStorage,
|
||||
private val offlineSettingsStorage: OfflineSettingsStorage,
|
||||
private val offlineLibraryStore: OfflineLibraryStore,
|
||||
private val offlineMediaStore: OfflineMediaStore,
|
||||
private val appClientInfo: AppClientInfo,
|
||||
private val playerEndpoints: PlayerEndpoints,
|
||||
private val errorParser: AuthApiErrorParser
|
||||
) : PlayerRepository {
|
||||
override val offlineState: StateFlow<OfflineState> = offlineSettingsStorage.state
|
||||
|
||||
override fun setOfflineModeEnabled(enabled: Boolean) {
|
||||
offlineSettingsStorage.setOfflineModeEnabled(enabled)
|
||||
if (!enabled) {
|
||||
offlineSettingsStorage.setNetworkFallback(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setSaveListenedTracksEnabled(enabled: Boolean) {
|
||||
offlineSettingsStorage.setSaveListenedTracksEnabled(enabled)
|
||||
}
|
||||
|
||||
override fun setOfflineStorageLimit(limitBytes: Long) {
|
||||
offlineSettingsStorage.setStorageLimitBytes(limitBytes)
|
||||
}
|
||||
|
||||
override suspend fun clearOfflineCache(): Result<Unit> {
|
||||
return try {
|
||||
offlineMediaStore.clearCachedMedia()
|
||||
Result.success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary> {
|
||||
offlineSettingsStorage.setSyncing(true)
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
val manifestResponse = playerApi.offlineManifest(playerEndpoints.offlineManifest(baseUrl))
|
||||
|
||||
if (!manifestResponse.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(manifestResponse))
|
||||
}
|
||||
|
||||
val manifest = manifestResponse.body()
|
||||
?: throw AuthException("Offline manifest response is empty")
|
||||
val manifestTracks = manifest.tracks.map { it.withAbsoluteUrls(baseUrl) }
|
||||
val manifestById = manifestTracks.associateBy { it.id }
|
||||
|
||||
offlineLibraryStore.upsertManifestTracks(manifestTracks)
|
||||
offlineLibraryStore.replacePlaylists(manifest.playlists)
|
||||
offlineLibraryStore.replaceLikedTrackIds(manifest.likedTrackIds.toSet())
|
||||
|
||||
val trackCards = manifestTracks
|
||||
.map { it.id }
|
||||
.chunked(TRACKS_BY_IDS_CHUNK_SIZE)
|
||||
.flatMap { ids ->
|
||||
val tracksResponse = playerApi.tracksByIds(
|
||||
url = playerEndpoints.tracksByIds(baseUrl),
|
||||
body = TracksByIdsRequest(ids)
|
||||
)
|
||||
if (!tracksResponse.isSuccessful) {
|
||||
throw AuthException(errorParser.messageFrom(tracksResponse))
|
||||
}
|
||||
tracksResponse.body().orEmpty().map { track ->
|
||||
val domainTrack = track.toDomain(baseUrl)
|
||||
manifestById[domainTrack.id]?.let(domainTrack::withOfflineManifest) ?: domainTrack
|
||||
}
|
||||
}
|
||||
|
||||
offlineLibraryStore.upsertTracks(trackCards)
|
||||
offlineMediaStore.pruneStaleMedia()
|
||||
offlineMediaStore.publishStorageStats()
|
||||
val syncedAtMs = System.currentTimeMillis()
|
||||
offlineSettingsStorage.setLastSyncedAt(syncedAtMs)
|
||||
offlineSettingsStorage.setNetworkFallback(false)
|
||||
Result.success(
|
||||
OfflineSyncSummary(
|
||||
trackCount = manifestTracks.size,
|
||||
playlistCount = manifest.playlists.size,
|
||||
syncedAtMs = syncedAtMs
|
||||
)
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
offlineSettingsStorage.setLastError(e.message ?: "Unable to sync offline library")
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
offlineSettingsStorage.setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getArtists(page: Int, limit: Int, mine: Boolean): Result<ArtistPage> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return Result.success(offlineLibraryStore.getArtists(page, limit))
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -62,15 +165,23 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Artists response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
fallbackToOffline(e) { offlineLibraryStore.getArtists(page, limit) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getArtistDetail(artistId: Long): Result<ArtistDetail> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineLibraryStore.getArtistDetail(artistId)
|
||||
?.withOnlyCachedPlayback()
|
||||
?.let { Result.success(it) }
|
||||
?: offlineUnavailable("Artist is not available offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -83,15 +194,27 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Artist response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
val detail = body.toDomain(baseUrl).withCachedMedia()
|
||||
offlineLibraryStore.upsertTracks(detail.topTracks + detail.featuredTracks)
|
||||
markOnlineSuccess()
|
||||
Result.success(detail)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
fallbackToOffline(e) {
|
||||
offlineLibraryStore.getArtistDetail(artistId)?.withOnlyCachedPlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineLibraryStore.getReleaseDetail(releaseId)
|
||||
?.withOnlyCachedPlayback()
|
||||
?.let { Result.success(it) }
|
||||
?: offlineUnavailable("Release is not available offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -104,15 +227,24 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Release response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
val detail = body.toDomain(baseUrl).withCachedMedia()
|
||||
offlineLibraryStore.upsertTracks(detail.tracks)
|
||||
markOnlineSuccess()
|
||||
Result.success(detail)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
fallbackToOffline(e) {
|
||||
offlineLibraryStore.getReleaseDetail(releaseId)?.withOnlyCachedPlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun search(query: String, limit: Int): Result<SearchResults> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return Result.success(offlineLibraryStore.search(query, limit).withOnlyCachedPlayback())
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -127,15 +259,22 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Search response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
val results = body.toDomain(baseUrl).withCachedMedia()
|
||||
offlineLibraryStore.upsertTracks(results.tracks)
|
||||
markOnlineSuccess()
|
||||
Result.success(results)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
fallbackToOffline(e) { offlineLibraryStore.search(query, limit).withOnlyCachedPlayback() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Listening history is not available offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -150,6 +289,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Listening history response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -159,6 +299,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun toggleLike(trackId: Long): Result<Boolean> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Likes cannot be changed offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -171,6 +315,11 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Like toggle response is empty")
|
||||
val likedIds = offlineLibraryStore.getLikedTrackIds()
|
||||
offlineLibraryStore.replaceLikedTrackIds(
|
||||
if (body.liked) likedIds + trackId else likedIds - trackId
|
||||
)
|
||||
markOnlineSuccess()
|
||||
Result.success(body.liked)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -180,6 +329,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun getLikedTrackIds(): Result<Set<Long>> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return Result.success(offlineLibraryStore.getLikedTrackIds())
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -192,11 +345,14 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Likes response is empty")
|
||||
Result.success(body.trackIds.toSet())
|
||||
val likedIds = body.trackIds.toSet()
|
||||
offlineLibraryStore.replaceLikedTrackIds(likedIds)
|
||||
markOnlineSuccess()
|
||||
Result.success(likedIds)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
fallbackToOffline(e) { offlineLibraryStore.getLikedTrackIds() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +362,17 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
durationListened: Int,
|
||||
completed: Boolean
|
||||
): Result<Unit> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return try {
|
||||
offlineMediaStore.cacheTrackAfterListening(trackId)
|
||||
Result.success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -223,15 +390,23 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
offlineMediaStore.cacheTrackAfterListening(trackId)
|
||||
markOnlineSuccess()
|
||||
Result.success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
runCatching { offlineMediaStore.cacheTrackAfterListening(trackId) }
|
||||
offlineSettingsStorage.setNetworkFallback(true, e.message)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPlaylists(): Result<List<PlaylistCard>> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return Result.success(offlineLibraryStore.getPlaylists())
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -244,15 +419,23 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Playlists response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
fallbackToOffline(e) { offlineLibraryStore.getPlaylists() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineLibraryStore.getPlaylistDetail(playlistId)
|
||||
?.withOnlyCachedPlayback()
|
||||
?.let { Result.success(it) }
|
||||
?: offlineUnavailable("Playlist is not available offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -265,15 +448,24 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Playlist response is empty")
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
val detail = body.toDomain(baseUrl).withCachedMedia()
|
||||
offlineLibraryStore.upsertPlaylistDetail(detail)
|
||||
markOnlineSuccess()
|
||||
Result.success(detail)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
fallbackToOffline(e) {
|
||||
offlineLibraryStore.getPlaylistDetail(playlistId)?.withOnlyCachedPlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createPlaylist(title: String): Result<PlaylistCard> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Playlists cannot be created offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -287,6 +479,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Create playlist response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -296,6 +489,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun deletePlaylist(playlistId: Long): Result<Unit> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Playlists cannot be deleted offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -307,6 +504,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
markOnlineSuccess()
|
||||
Result.success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -316,6 +514,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Playlists cannot be changed offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -328,6 +530,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
markOnlineSuccess()
|
||||
Result.success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -337,9 +540,14 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun shareTrack(trackId: Long, title: String): Result<String> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Tracks cannot be shared offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
markOnlineSuccess()
|
||||
Result.success(playerEndpoints.shareTrack(baseUrl, trackId))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -352,6 +560,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
playbackState: ConnectedPlaybackState?,
|
||||
currentJamId: String?
|
||||
): Result<Pair<ConnectedDevicesState, List<ConnectedCommand>>> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Connected devices are unavailable offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -370,6 +582,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Device poll response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain(baseUrl) to body.commands.map { it.toDomain(baseUrl) })
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -379,6 +592,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun setActiveDevice(deviceId: String): Result<ConnectedDevicesState> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Connected devices are unavailable offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -395,6 +612,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Active device response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -417,6 +635,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
fromIndex: Int?,
|
||||
toIndex: Int?
|
||||
): Result<Unit> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Connected devices are unavailable offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -444,6 +666,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
throw AuthException(errorParser.messageFrom(response))
|
||||
}
|
||||
|
||||
markOnlineSuccess()
|
||||
Result.success(Unit)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -453,6 +676,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun createJam(): Result<ConnectedDevicesState> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Jam is unavailable offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -466,6 +693,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -475,6 +703,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun joinJam(jamId: String): Result<ConnectedDevicesState> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Jam is unavailable offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -491,6 +723,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -500,6 +733,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun leaveJam(jamId: String): Result<ConnectedDevicesState> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Jam is unavailable offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -516,6 +753,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -525,6 +763,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun searchJamUsers(query: String, limit: Int): Result<List<ConnectedJamUser>> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Jam is unavailable offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -539,6 +781,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam users response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.map { it.toDomain() })
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -548,6 +791,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return offlineUnavailable("Jam is unavailable offline")
|
||||
}
|
||||
|
||||
return try {
|
||||
val baseUrl = sessionStorage.getBaseUrl()
|
||||
?: throw AuthException("Server URL is missing")
|
||||
@@ -565,6 +812,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||
markOnlineSuccess()
|
||||
Result.success(body.toDomain(baseUrl))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -572,4 +820,88 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isOfflineModeEnabled(): Boolean {
|
||||
return offlineSettingsStorage.state.value.settings.offlineModeEnabled
|
||||
}
|
||||
|
||||
private fun markOnlineSuccess() {
|
||||
offlineSettingsStorage.setNetworkFallback(false)
|
||||
offlineSettingsStorage.setLastError(null)
|
||||
}
|
||||
|
||||
private fun <T> fallbackToOffline(error: Exception, block: () -> T?): Result<T> {
|
||||
val localValue = runCatching(block).getOrNull()
|
||||
return if (localValue != null) {
|
||||
offlineSettingsStorage.setNetworkFallback(true, error.message)
|
||||
Result.success(localValue)
|
||||
} else {
|
||||
Result.failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> offlineUnavailable(message: String): Result<T> {
|
||||
return Result.failure(AuthException(message))
|
||||
}
|
||||
|
||||
private fun ArtistDetail.withCachedMedia(): ArtistDetail {
|
||||
return copy(
|
||||
topTracks = topTracks.map { it.withCachedMedia() },
|
||||
featuredTracks = featuredTracks.map { it.withCachedMedia() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReleaseDetail.withCachedMedia(): ReleaseDetail {
|
||||
return copy(tracks = tracks.map { it.withCachedMedia() })
|
||||
}
|
||||
|
||||
private fun PlaylistDetail.withCachedMedia(): PlaylistDetail {
|
||||
return copy(tracks = tracks.map { it.withCachedMedia() })
|
||||
}
|
||||
|
||||
private fun SearchResults.withCachedMedia(): SearchResults {
|
||||
return copy(tracks = tracks.map { it.withCachedMedia() })
|
||||
}
|
||||
|
||||
private fun TrackCard.withCachedMedia(): TrackCard {
|
||||
val cached = offlineLibraryStore.getTrack(id, preferLocalMedia = true) ?: return this
|
||||
return copy(
|
||||
streamUrl = if (cached.isAudioCached) cached.streamUrl else streamUrl,
|
||||
coverUrl = if (cached.localCoverPath != null) cached.coverUrl else coverUrl,
|
||||
localAudioPath = cached.localAudioPath,
|
||||
localCoverPath = cached.localCoverPath,
|
||||
isAudioCached = cached.isAudioCached
|
||||
)
|
||||
}
|
||||
|
||||
private fun ArtistDetail.withOnlyCachedPlayback(): ArtistDetail {
|
||||
return copy(
|
||||
topTracks = topTracks.map { it.withOnlyCachedPlayback() },
|
||||
featuredTracks = featuredTracks.map { it.withOnlyCachedPlayback() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun ReleaseDetail.withOnlyCachedPlayback(): ReleaseDetail {
|
||||
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
|
||||
}
|
||||
|
||||
private fun PlaylistDetail.withOnlyCachedPlayback(): PlaylistDetail {
|
||||
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
|
||||
}
|
||||
|
||||
private fun SearchResults.withOnlyCachedPlayback(): SearchResults {
|
||||
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
|
||||
}
|
||||
|
||||
private fun TrackCard.withOnlyCachedPlayback(): TrackCard {
|
||||
return if (isAudioCached || streamUrl.startsWith("file://", ignoreCase = true)) {
|
||||
this
|
||||
} else {
|
||||
copy(streamUrl = "")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TRACKS_BY_IDS_CHUNK_SIZE = 500
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,23 @@ data class TrackCard(
|
||||
val artists: List<String>,
|
||||
val artistRefs: List<ArtistRef> = emptyList(),
|
||||
val featuredArtistRefs: List<ArtistRef> = emptyList(),
|
||||
val releaseId: Long? = null,
|
||||
val releaseYear: Int? = null,
|
||||
val releaseTitle: String?,
|
||||
val coverUrl: String?,
|
||||
val streamUrl: String
|
||||
val streamUrl: String,
|
||||
val remoteCoverUrl: String? = coverUrl,
|
||||
val remoteStreamUrl: String = streamUrl,
|
||||
val metadataUpdatedAt: String? = null,
|
||||
val audioHash: String? = null,
|
||||
val audioSizeBytes: Long? = null,
|
||||
val audioMimeType: String? = null,
|
||||
val audioUpdatedAt: String? = null,
|
||||
val coverHash: String? = null,
|
||||
val coverUpdatedAt: String? = null,
|
||||
val localAudioPath: String? = null,
|
||||
val localCoverPath: String? = null,
|
||||
val isAudioCached: Boolean = false
|
||||
)
|
||||
|
||||
data class ArtistDetail(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package cy.hexor.furumi.domain.model
|
||||
|
||||
data class OfflineSettings(
|
||||
val offlineModeEnabled: Boolean = false,
|
||||
val saveListenedTracksEnabled: Boolean = false,
|
||||
val storageLimitBytes: Long = DEFAULT_STORAGE_LIMIT_BYTES
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_STORAGE_LIMIT_BYTES: Long = 2L * 1024L * 1024L * 1024L
|
||||
}
|
||||
}
|
||||
|
||||
data class OfflineState(
|
||||
val settings: OfflineSettings = OfflineSettings(),
|
||||
val isUsingNetworkFallback: Boolean = false,
|
||||
val isSyncing: Boolean = false,
|
||||
val lastSyncedAtMs: Long = 0L,
|
||||
val storageUsedBytes: Long = 0L,
|
||||
val cachedTrackCount: Int = 0,
|
||||
val lastError: String? = null
|
||||
) {
|
||||
val isOfflineActive: Boolean
|
||||
get() = settings.offlineModeEnabled || isUsingNetworkFallback
|
||||
}
|
||||
|
||||
data class OfflineSyncSummary(
|
||||
val trackCount: Int,
|
||||
val playlistCount: Int,
|
||||
val syncedAtMs: Long
|
||||
)
|
||||
@@ -7,13 +7,18 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
|
||||
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
||||
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
||||
import cy.hexor.furumi.domain.model.ListeningHistoryPage
|
||||
import cy.hexor.furumi.domain.model.OfflineState
|
||||
import cy.hexor.furumi.domain.model.OfflineSyncSummary
|
||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||
import cy.hexor.furumi.domain.model.PlaylistDetail
|
||||
import cy.hexor.furumi.domain.model.ReleaseDetail
|
||||
import cy.hexor.furumi.domain.model.SearchResults
|
||||
import cy.hexor.furumi.domain.model.TrackCard
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface PlayerRepository {
|
||||
val offlineState: StateFlow<OfflineState>
|
||||
|
||||
suspend fun getArtists(
|
||||
page: Int = 1,
|
||||
limit: Int = 60,
|
||||
@@ -82,4 +87,14 @@ interface PlayerRepository {
|
||||
suspend fun searchJamUsers(query: String, limit: Int = 10): Result<List<ConnectedJamUser>>
|
||||
|
||||
suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState>
|
||||
|
||||
fun setOfflineModeEnabled(enabled: Boolean)
|
||||
|
||||
fun setSaveListenedTracksEnabled(enabled: Boolean)
|
||||
|
||||
fun setOfflineStorageLimit(limitBytes: Long)
|
||||
|
||||
suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary>
|
||||
|
||||
suspend fun clearOfflineCache(): Result<Unit>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -59,7 +60,10 @@ class FurumiPlaybackService : MediaSessionService() {
|
||||
val bitmapLoader = CacheBitmapLoader(
|
||||
DataSourceBitmapLoader(
|
||||
MoreExecutors.listeningDecorator(executor),
|
||||
OkHttpDataSource.Factory(okHttpClient)
|
||||
DefaultDataSource.Factory(
|
||||
this,
|
||||
OkHttpDataSource.Factory(okHttpClient)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
@@ -87,7 +88,10 @@ class PlaybackController @Inject constructor(
|
||||
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
|
||||
val dataSourceFactory = DefaultDataSource.Factory(
|
||||
context,
|
||||
OkHttpDataSource.Factory(okHttpClient)
|
||||
)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||
|
||||
player = ExoPlayer.Builder(context)
|
||||
|
||||
@@ -25,11 +25,14 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -74,6 +77,9 @@ import cy.hexor.furumi.ui.theme.FurumiNeonViolet
|
||||
import cy.hexor.furumi.ui.theme.FurumiSurface
|
||||
import cy.hexor.furumi.ui.theme.FurumiSurfaceHigh
|
||||
import cy.hexor.furumi.ui.theme.FurumiTextMuted
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
@@ -139,65 +145,43 @@ internal fun ProfileMenu(
|
||||
uiState: PlayerUiState,
|
||||
onHistoryClick: () -> Unit,
|
||||
onDeviceClick: (String) -> Unit,
|
||||
onOfflineModeChange: (Boolean) -> Unit,
|
||||
onSaveListenedChange: (Boolean) -> Unit,
|
||||
onStorageLimitChange: (Long) -> Unit,
|
||||
onSyncOffline: () -> Unit,
|
||||
onClearOfflineCache: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.width(284.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 430.dp)
|
||||
.heightIn(max = 640.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = FurumiSurfaceHigh,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Profile",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = uiState.userName,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
if (uiState.appVersion.isNotBlank()) {
|
||||
Surface(
|
||||
color = FurumiSurface,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
|
||||
) {
|
||||
Text(
|
||||
text = "v${uiState.appVersion}",
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = FurumiTextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = uiState.serverUrl.ifBlank { "No server selected" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ProfileSummary(uiState)
|
||||
ConnectedDevicesSection(
|
||||
uiState = uiState,
|
||||
onDeviceClick = onDeviceClick
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OfflineControlsSection(
|
||||
uiState = uiState,
|
||||
onOfflineModeChange = onOfflineModeChange,
|
||||
onSaveListenedChange = onSaveListenedChange,
|
||||
onStorageLimitChange = onStorageLimitChange,
|
||||
onSyncOffline = onSyncOffline,
|
||||
onClearOfflineCache = onClearOfflineCache
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onHistoryClick,
|
||||
modifier = Modifier
|
||||
@@ -212,7 +196,6 @@ internal fun ProfileMenu(
|
||||
) {
|
||||
Text("Listening history")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier
|
||||
@@ -241,13 +224,286 @@ internal fun ProfileMenu(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileSummary(uiState: PlayerUiState) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "Profile",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(FurumiNeonPink),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = uiState.userName.firstOrNull()?.uppercaseChar()?.toString() ?: "F",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = FurumiBlack,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = uiState.userName,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (uiState.appVersion.isNotBlank()) {
|
||||
Surface(
|
||||
color = FurumiSurface,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
|
||||
) {
|
||||
Text(
|
||||
text = "v${uiState.appVersion}",
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = FurumiTextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = uiState.serverUrl.ifBlank { "No server selected" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineControlsSection(
|
||||
uiState: PlayerUiState,
|
||||
onOfflineModeChange: (Boolean) -> Unit,
|
||||
onSaveListenedChange: (Boolean) -> Unit,
|
||||
onStorageLimitChange: (Long) -> Unit,
|
||||
onSyncOffline: () -> Unit,
|
||||
onClearOfflineCache: () -> Unit
|
||||
) {
|
||||
val offlineState = uiState.offlineState
|
||||
val settings = offlineState.settings
|
||||
val storageProgress = if (settings.storageLimitBytes > 0L) {
|
||||
(offlineState.storageUsedBytes.toFloat() / settings.storageLimitBytes.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "Offline",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
ProfileSwitchRow(
|
||||
title = "Offline mode",
|
||||
subtitle = if (offlineState.isUsingNetworkFallback) "Fallback active" else "Local library",
|
||||
checked = settings.offlineModeEnabled,
|
||||
onCheckedChange = onOfflineModeChange
|
||||
)
|
||||
ProfileSwitchRow(
|
||||
title = "Save listened tracks",
|
||||
subtitle = "${offlineState.cachedTrackCount} cached",
|
||||
checked = settings.saveListenedTracksEnabled,
|
||||
onCheckedChange = onSaveListenedChange
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Storage",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${formatStorageBytes(offlineState.storageUsedBytes)} / ${formatStorageBytes(settings.storageLimitBytes)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = FurumiTextMuted
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { storageProgress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(CircleShape),
|
||||
color = FurumiNeonPink,
|
||||
trackColor = FurumiLine
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
offlineStorageLimitOptions.forEach { limit ->
|
||||
OfflineLimitChip(
|
||||
label = formatStorageBytes(limit),
|
||||
selected = settings.storageLimitBytes == limit,
|
||||
onClick = { onStorageLimitChange(limit) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = onSyncOffline,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !offlineState.isSyncing,
|
||||
shape = CircleShape,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
disabledContentColor = FurumiTextMuted
|
||||
)
|
||||
) {
|
||||
if (offlineState.isSyncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = FurumiTextMuted,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Sync now")
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onClearOfflineCache,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = offlineState.storageUsedBytes > 0L && !offlineState.isSyncing,
|
||||
shape = CircleShape,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
disabledContentColor = FurumiTextMuted
|
||||
)
|
||||
) {
|
||||
Text("Clear downloads")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = formatOfflineSyncStatus(offlineState.lastSyncedAtMs),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = FurumiTextMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
offlineState.lastError?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = FurumiHotOrange,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileSwitchRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = FurumiTextMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = FurumiBlack,
|
||||
checkedTrackColor = FurumiNeonPink,
|
||||
uncheckedThumbColor = FurumiTextMuted,
|
||||
uncheckedTrackColor = FurumiSurface,
|
||||
uncheckedBorderColor = FurumiLine
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineLimitChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
shape = CircleShape,
|
||||
color = if (selected) FurumiNeonPink else FurumiSurface,
|
||||
border = if (selected) null else androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (selected) FurumiBlack else MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OfflineStatusBadge(
|
||||
offlineState: cy.hexor.furumi.domain.model.OfflineState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = CircleShape,
|
||||
color = if (offlineState.settings.offlineModeEnabled) FurumiNeonPink else FurumiHotOrange,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Text(
|
||||
text = if (offlineState.settings.offlineModeEnabled) "Offline mode" else "Offline fallback",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = FurumiBlack,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectedDevicesSection(
|
||||
uiState: PlayerUiState,
|
||||
onDeviceClick: (String) -> Unit
|
||||
) {
|
||||
val devices = uiState.connectedDevicesState?.devices.orEmpty()
|
||||
if (devices.isEmpty() && uiState.connectedDevicesError == null) return
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
@@ -255,11 +511,17 @@ private fun ConnectedDevicesSection(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
uiState.connectedDevicesError?.let { error ->
|
||||
val statusText = when {
|
||||
uiState.offlineState.settings.offlineModeEnabled -> "Offline mode"
|
||||
uiState.connectedDevicesError != null -> uiState.connectedDevicesError
|
||||
devices.isEmpty() -> "No connected devices"
|
||||
else -> null
|
||||
}
|
||||
statusText?.let { status ->
|
||||
Text(
|
||||
text = error,
|
||||
text = status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = FurumiHotOrange,
|
||||
color = if (uiState.connectedDevicesError != null) FurumiHotOrange else FurumiTextMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -566,3 +828,29 @@ internal fun BottomNavItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val offlineStorageLimitOptions = listOf(
|
||||
512L * 1024L * 1024L,
|
||||
1L * 1024L * 1024L * 1024L,
|
||||
2L * 1024L * 1024L * 1024L,
|
||||
5L * 1024L * 1024L * 1024L,
|
||||
10L * 1024L * 1024L * 1024L
|
||||
)
|
||||
|
||||
private fun formatStorageBytes(bytes: Long): String {
|
||||
val safeBytes = bytes.coerceAtLeast(0L)
|
||||
val megabytes = safeBytes / (1024.0 * 1024.0)
|
||||
return if (megabytes >= 1024.0) {
|
||||
String.format(Locale.US, "%.1f GB", megabytes / 1024.0)
|
||||
} else {
|
||||
String.format(Locale.US, "%.0f MB", megabytes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatOfflineSyncStatus(timestampMs: Long): String {
|
||||
if (timestampMs <= 0L) return "Never synced"
|
||||
val formatted = DateFormat
|
||||
.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
.format(Date(timestampMs))
|
||||
return "Synced $formatted"
|
||||
}
|
||||
|
||||
@@ -93,6 +93,14 @@ internal fun ArtistDetailTrackRow(
|
||||
onAddToPlaylist: () -> Unit
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val isUnavailable = track.isUnavailableForPlayback()
|
||||
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
|
||||
val primaryColor = if (isUnavailable) {
|
||||
FurumiTextMuted.copy(alpha = 0.72f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
}
|
||||
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||
|
||||
LaunchedEffect(track.coverUrl) {
|
||||
onMediaImageNeeded(track.coverUrl)
|
||||
@@ -102,7 +110,7 @@ internal fun ArtistDetailTrackRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onTrackClick(track) }
|
||||
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -110,13 +118,15 @@ internal fun ArtistDetailTrackRow(
|
||||
text = index.toString(),
|
||||
modifier = Modifier.width(28.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted
|
||||
color = secondaryColor
|
||||
)
|
||||
MediaArtwork(
|
||||
title = track.title,
|
||||
seedId = track.id,
|
||||
bitmap = bitmap,
|
||||
modifier = Modifier.size(48.dp),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.unavailableTrackArtworkOverlay(isUnavailable),
|
||||
cornerRadius = 6
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
@@ -124,7 +134,7 @@ internal fun ArtistDetailTrackRow(
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
color = primaryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -133,7 +143,7 @@ internal fun ArtistDetailTrackRow(
|
||||
.ifBlank { track.releaseTitle.orEmpty() }
|
||||
.ifBlank { "Unknown artist" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted,
|
||||
color = secondaryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -144,7 +154,8 @@ internal fun ArtistDetailTrackRow(
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onToggleLike)
|
||||
.padding(4.dp)
|
||||
.padding(4.dp),
|
||||
alpha = contentAlpha
|
||||
)
|
||||
Box {
|
||||
MoreDotsGlyph(
|
||||
@@ -152,7 +163,8 @@ internal fun ArtistDetailTrackRow(
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { menuExpanded = true }
|
||||
.padding(4.dp)
|
||||
.padding(4.dp),
|
||||
color = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||
)
|
||||
TrackContextMenu(
|
||||
expanded = menuExpanded,
|
||||
@@ -169,7 +181,7 @@ internal fun ArtistDetailTrackRow(
|
||||
Text(
|
||||
text = formatDuration(track.durationSeconds),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted
|
||||
color = secondaryColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -187,12 +199,20 @@ internal fun ReleaseTrackRow(
|
||||
onAddToPlaylist: () -> Unit
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val isUnavailable = track.isUnavailableForPlayback()
|
||||
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
|
||||
val primaryColor = if (isUnavailable) {
|
||||
FurumiTextMuted.copy(alpha = 0.72f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
}
|
||||
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onTrackClick(track) }
|
||||
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -200,20 +220,20 @@ internal fun ReleaseTrackRow(
|
||||
text = (track.trackNumber ?: fallbackIndex).toString(),
|
||||
modifier = Modifier.width(32.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted
|
||||
color = secondaryColor
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
color = primaryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = track.artists.joinToString(", ").ifBlank { "Unknown artist" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted,
|
||||
color = secondaryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -224,7 +244,8 @@ internal fun ReleaseTrackRow(
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onToggleLike)
|
||||
.padding(4.dp)
|
||||
.padding(4.dp),
|
||||
alpha = contentAlpha
|
||||
)
|
||||
Box {
|
||||
MoreDotsGlyph(
|
||||
@@ -232,7 +253,8 @@ internal fun ReleaseTrackRow(
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { menuExpanded = true }
|
||||
.padding(4.dp)
|
||||
.padding(4.dp),
|
||||
color = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||
)
|
||||
TrackContextMenu(
|
||||
expanded = menuExpanded,
|
||||
@@ -249,7 +271,7 @@ internal fun ReleaseTrackRow(
|
||||
Text(
|
||||
text = formatDuration(track.durationSeconds),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted
|
||||
color = secondaryColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -269,6 +291,14 @@ internal fun PlaylistTrackRow(
|
||||
onAddToPlaylist: () -> Unit
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val isUnavailable = track.isUnavailableForPlayback()
|
||||
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
|
||||
val primaryColor = if (isUnavailable) {
|
||||
FurumiTextMuted.copy(alpha = 0.72f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
}
|
||||
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||
|
||||
LaunchedEffect(track.coverUrl) {
|
||||
onMediaImageNeeded(track.coverUrl)
|
||||
@@ -278,7 +308,7 @@ internal fun PlaylistTrackRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onTrackClick(track) }
|
||||
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -286,13 +316,15 @@ internal fun PlaylistTrackRow(
|
||||
text = index.toString(),
|
||||
modifier = Modifier.width(28.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted
|
||||
color = secondaryColor
|
||||
)
|
||||
MediaArtwork(
|
||||
title = track.title,
|
||||
seedId = track.id,
|
||||
bitmap = bitmap,
|
||||
modifier = Modifier.size(48.dp),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.unavailableTrackArtworkOverlay(isUnavailable),
|
||||
cornerRadius = 6
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
@@ -300,14 +332,14 @@ internal fun PlaylistTrackRow(
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
color = primaryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = trackSubtitle(track),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted,
|
||||
color = secondaryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -318,7 +350,8 @@ internal fun PlaylistTrackRow(
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onToggleLike)
|
||||
.padding(4.dp)
|
||||
.padding(4.dp),
|
||||
alpha = contentAlpha
|
||||
)
|
||||
Box {
|
||||
MoreDotsGlyph(
|
||||
@@ -326,7 +359,8 @@ internal fun PlaylistTrackRow(
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { menuExpanded = true }
|
||||
.padding(4.dp)
|
||||
.padding(4.dp),
|
||||
color = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||
)
|
||||
TrackContextMenu(
|
||||
expanded = menuExpanded,
|
||||
@@ -343,7 +377,7 @@ internal fun PlaylistTrackRow(
|
||||
Text(
|
||||
text = formatDuration(track.durationSeconds),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = FurumiTextMuted
|
||||
color = secondaryColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -496,3 +530,17 @@ internal fun QueueTrackRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TrackCard.isUnavailableForPlayback(): Boolean {
|
||||
return streamUrl.isBlank()
|
||||
}
|
||||
|
||||
private fun Modifier.unavailableTrackArtworkOverlay(isUnavailable: Boolean): Modifier {
|
||||
if (!isUnavailable) return this
|
||||
return drawWithContent {
|
||||
drawContent()
|
||||
drawRect(color = Color.Black.copy(alpha = 0.44f))
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNAVAILABLE_TRACK_ALPHA = 0.42f
|
||||
|
||||
@@ -151,26 +151,11 @@ fun PlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val profileScrollConnection = remember {
|
||||
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
): Offset {
|
||||
if (isProfileMenuOpen && source == androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag) {
|
||||
isProfileMenuOpen = false
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.nestedScroll(profileScrollConnection)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
@@ -355,10 +340,24 @@ fun PlayerScreen(
|
||||
viewModel.openListeningHistory()
|
||||
},
|
||||
onDeviceClick = viewModel::setActiveDevice,
|
||||
onOfflineModeChange = viewModel::setOfflineModeEnabled,
|
||||
onSaveListenedChange = viewModel::setSaveListenedTracksEnabled,
|
||||
onStorageLimitChange = viewModel::setOfflineStorageLimit,
|
||||
onSyncOffline = viewModel::syncOfflineLibrary,
|
||||
onClearOfflineCache = viewModel::clearOfflineCache,
|
||||
onLogout = viewModel::logout,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 72.dp, end = 20.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(horizontal = 12.dp, vertical = 72.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.offlineState.isOfflineActive && !isProfileMenuOpen) {
|
||||
OfflineStatusBadge(
|
||||
offlineState = uiState.offlineState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -475,6 +474,7 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
private fun PlayerUiState.displayedPlayback(): AudioPlaybackState {
|
||||
if (offlineState.isOfflineActive) return playback
|
||||
val connectedState = connectedDevicesState
|
||||
val remotePlayback = connectedState?.remotePlaybackState
|
||||
return if (connectedState != null &&
|
||||
|
||||
@@ -12,6 +12,7 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
|
||||
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
||||
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
||||
import cy.hexor.furumi.domain.model.ListeningHistoryItem
|
||||
import cy.hexor.furumi.domain.model.OfflineState
|
||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||
import cy.hexor.furumi.domain.model.PlaylistDetail
|
||||
import cy.hexor.furumi.domain.model.ReleaseCard
|
||||
@@ -92,7 +93,8 @@ data class PlayerUiState(
|
||||
val connectedDevicesError: String? = null,
|
||||
val jamInviteQuery: String = "",
|
||||
val jamInviteUsers: List<ConnectedJamUser> = emptyList(),
|
||||
val isJamInviteSearchLoading: Boolean = false
|
||||
val isJamInviteSearchLoading: Boolean = false,
|
||||
val offlineState: OfflineState = OfflineState()
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -109,11 +111,13 @@ class PlayerViewModel @Inject constructor(
|
||||
private val playlistDetailCache = mutableMapOf<Long, PlaylistDetail>()
|
||||
private var searchJob: Job? = null
|
||||
private var jamInviteSearchJob: Job? = null
|
||||
private var offlineModeRefreshJob: Job? = null
|
||||
private val handledConnectedCommandIds = ArrayDeque<String>()
|
||||
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
||||
private var wasCurrentDeviceActive = false
|
||||
private var wasControllingRemoteJam = false
|
||||
private var hasResolvedStartupActiveDevice = false
|
||||
private var shouldClaimActiveDeviceOnReconnect = false
|
||||
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
||||
|
||||
@@ -133,6 +137,7 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
loadGlobalArtistsIfNeeded()
|
||||
observeOfflineState()
|
||||
observePlayback()
|
||||
loadLikedTrackIds()
|
||||
startConnectedDevicesPolling()
|
||||
@@ -481,6 +486,7 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun setActiveDevice(deviceId: String) {
|
||||
if (_uiState.value.offlineState.isOfflineActive) return
|
||||
viewModelScope.launch {
|
||||
playerRepository.setActiveDevice(deviceId)
|
||||
.onSuccess { devicesState ->
|
||||
@@ -494,6 +500,49 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setOfflineModeEnabled(enabled: Boolean) {
|
||||
playerRepository.setOfflineModeEnabled(enabled)
|
||||
if (enabled) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesState = null,
|
||||
connectedDevicesError = null
|
||||
)
|
||||
syncOfflineLibrary()
|
||||
} else {
|
||||
shouldClaimActiveDeviceOnReconnect = true
|
||||
}
|
||||
}
|
||||
|
||||
fun setSaveListenedTracksEnabled(enabled: Boolean) {
|
||||
playerRepository.setSaveListenedTracksEnabled(enabled)
|
||||
if (enabled) {
|
||||
syncOfflineLibrary()
|
||||
}
|
||||
}
|
||||
|
||||
fun setOfflineStorageLimit(limitBytes: Long) {
|
||||
playerRepository.setOfflineStorageLimit(limitBytes)
|
||||
}
|
||||
|
||||
fun syncOfflineLibrary() {
|
||||
if (_uiState.value.offlineState.isSyncing) return
|
||||
viewModelScope.launch {
|
||||
playerRepository.syncOfflineLibrary()
|
||||
.onSuccess {
|
||||
refreshAfterOfflineLibraryChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearOfflineCache() {
|
||||
viewModelScope.launch {
|
||||
playerRepository.clearOfflineCache()
|
||||
.onSuccess {
|
||||
refreshAfterOfflineLibraryChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createJam() {
|
||||
viewModelScope.launch {
|
||||
playerRepository.createJam()
|
||||
@@ -983,6 +1032,35 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeOfflineState() {
|
||||
viewModelScope.launch {
|
||||
playerRepository.offlineState.collect { offlineState ->
|
||||
val current = _uiState.value
|
||||
val offlineModeChanged = current.offlineState.isOfflineActive != offlineState.isOfflineActive
|
||||
if (current.offlineState.isOfflineActive && !offlineState.isOfflineActive) {
|
||||
shouldClaimActiveDeviceOnReconnect = true
|
||||
}
|
||||
_uiState.value = current.copy(
|
||||
offlineState = offlineState,
|
||||
connectedDevicesState = if (offlineState.settings.offlineModeEnabled) {
|
||||
null
|
||||
} else {
|
||||
current.connectedDevicesState
|
||||
},
|
||||
connectedDevicesError = if (offlineState.settings.offlineModeEnabled) {
|
||||
null
|
||||
} else {
|
||||
current.connectedDevicesError
|
||||
}
|
||||
)
|
||||
if (offlineModeChanged) {
|
||||
clearModeSensitiveCaches()
|
||||
refreshVisibleContentForOfflineMode()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startConnectedDevicesPolling() {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
@@ -994,6 +1072,9 @@ class PlayerViewModel @Inject constructor(
|
||||
|
||||
private suspend fun pollConnectedDevices() {
|
||||
val state = _uiState.value
|
||||
if (state.offlineState.settings.offlineModeEnabled) return
|
||||
val shouldClaimActiveDevice = shouldClaimActiveDeviceOnReconnect ||
|
||||
state.offlineState.isUsingNetworkFallback
|
||||
val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true
|
||||
|
||||
// Android follows the spec: only the active device sends its playback state.
|
||||
@@ -1008,12 +1089,21 @@ class PlayerViewModel @Inject constructor(
|
||||
playbackState = playbackState,
|
||||
currentJamId = state.connectedDevicesState?.currentJamId
|
||||
).onSuccess { (devicesState, commands) ->
|
||||
val resolvedDevicesState = resolveStartupActiveDevice(devicesState)
|
||||
applyConnectedDevicesState(resolvedDevicesState)
|
||||
val resolvedDevicesState = if (shouldClaimActiveDevice) {
|
||||
claimCurrentDeviceActive(devicesState)
|
||||
} else {
|
||||
resolveStartupActiveDevice(devicesState)
|
||||
}
|
||||
applyConnectedDevicesState(
|
||||
devicesState = resolvedDevicesState,
|
||||
pauseOnLostControl = !shouldClaimActiveDevice
|
||||
)
|
||||
|
||||
commands.forEach { command ->
|
||||
if (shouldHandleConnectedCommand(command)) {
|
||||
handleConnectedCommand(command)
|
||||
if (!shouldClaimActiveDevice) {
|
||||
commands.forEach { command ->
|
||||
if (shouldHandleConnectedCommand(command)) {
|
||||
handleConnectedCommand(command)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
@@ -1037,7 +1127,51 @@ class PlayerViewModel @Inject constructor(
|
||||
.getOrElse { devicesState }
|
||||
}
|
||||
|
||||
private fun applyConnectedDevicesState(devicesState: ConnectedDevicesState) {
|
||||
private suspend fun claimCurrentDeviceActive(devicesState: ConnectedDevicesState): ConnectedDevicesState {
|
||||
val activeDevicesState = if (devicesState.isCurrentDeviceActive) {
|
||||
devicesState
|
||||
} else {
|
||||
playerRepository.setActiveDevice(devicesState.deviceId)
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to switch active device"
|
||||
)
|
||||
}
|
||||
.getOrElse { return devicesState }
|
||||
}
|
||||
|
||||
val (syncedDevicesState, didSync) = pushLocalPlaybackStateAfterReconnect(activeDevicesState)
|
||||
if (didSync) {
|
||||
shouldClaimActiveDeviceOnReconnect = false
|
||||
}
|
||||
return syncedDevicesState
|
||||
}
|
||||
|
||||
private suspend fun pushLocalPlaybackStateAfterReconnect(
|
||||
devicesState: ConnectedDevicesState
|
||||
): Pair<ConnectedDevicesState, Boolean> {
|
||||
val playbackState = _uiState.value.playback.toConnectedPlaybackState()
|
||||
?: return devicesState to true
|
||||
|
||||
return playerRepository.pollConnectedDevice(
|
||||
playbackState = playbackState,
|
||||
currentJamId = devicesState.currentJamId
|
||||
)
|
||||
.onFailure { error ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connectedDevicesError = error.message ?: "Unable to sync playback state"
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { (updatedDevicesState, _) -> updatedDevicesState to true },
|
||||
onFailure = { devicesState to false }
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyConnectedDevicesState(
|
||||
devicesState: ConnectedDevicesState,
|
||||
pauseOnLostControl: Boolean = true
|
||||
) {
|
||||
val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive
|
||||
val isControllingRemoteJam = devicesState.isControllingRemoteJam()
|
||||
val startedControllingRemoteJam = !wasControllingRemoteJam && isControllingRemoteJam
|
||||
@@ -1047,7 +1181,10 @@ class PlayerViewModel @Inject constructor(
|
||||
)
|
||||
wasCurrentDeviceActive = devicesState.isCurrentDeviceActive
|
||||
wasControllingRemoteJam = isControllingRemoteJam
|
||||
if ((becameInactive || startedControllingRemoteJam) && _uiState.value.playback.currentTrack != null) {
|
||||
if (pauseOnLostControl &&
|
||||
(becameInactive || startedControllingRemoteJam) &&
|
||||
_uiState.value.playback.currentTrack != null
|
||||
) {
|
||||
playbackController.pause()
|
||||
}
|
||||
}
|
||||
@@ -1122,6 +1259,8 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun activeRemoteDeviceId(): String? {
|
||||
if (_uiState.value.offlineState.isOfflineActive) return null
|
||||
if (shouldClaimActiveDeviceOnReconnect) return null
|
||||
val devicesState = _uiState.value.connectedDevicesState ?: return null
|
||||
if (devicesState.isControllingRemoteJam()) {
|
||||
return devicesState.activeDeviceId ?: devicesState.deviceId
|
||||
@@ -1405,6 +1544,54 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearModeSensitiveCaches() {
|
||||
artistDetailCache.clear()
|
||||
releaseDetailCache.clear()
|
||||
playlistDetailCache.clear()
|
||||
}
|
||||
|
||||
private fun refreshVisibleContentForOfflineMode() {
|
||||
offlineModeRefreshJob?.cancel()
|
||||
offlineModeRefreshJob = viewModelScope.launch {
|
||||
val state = _uiState.value
|
||||
when {
|
||||
state.selectedPlaylist != null -> {
|
||||
loadPlaylistDetail(state.selectedPlaylist.id)
|
||||
}
|
||||
state.selectedRelease != null -> {
|
||||
loadReleaseDetail(state.selectedRelease.id)
|
||||
}
|
||||
state.selectedArtist != null -> {
|
||||
loadArtistDetail(state.selectedArtist.id)
|
||||
}
|
||||
state.searchQuery.isNotBlank() && state.searchResults != null -> {
|
||||
searchJob?.cancel()
|
||||
search(state.searchQuery)
|
||||
}
|
||||
state.playlists.isNotEmpty() -> {
|
||||
loadPlaylists()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAfterOfflineLibraryChange() {
|
||||
clearModeSensitiveCaches()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
playlists = emptyList(),
|
||||
playlistPreviewCoverUrls = emptyMap(),
|
||||
loadingPlaylistPreviewIds = emptySet()
|
||||
)
|
||||
loadLikedTrackIds()
|
||||
if (_uiState.value.selectedPlaylist != null) {
|
||||
_uiState.value.selectedPlaylist?.id?.let(::loadPlaylistDetail)
|
||||
} else {
|
||||
loadPlaylists()
|
||||
}
|
||||
_uiState.value.selectedArtist?.id?.let(::loadArtistDetail)
|
||||
_uiState.value.selectedRelease?.id?.let(::loadReleaseDetail)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ARTIST_PAGE_SIZE = 60
|
||||
const val SEARCH_DEBOUNCE_MS = 300L
|
||||
|
||||
+5
-5
@@ -28,14 +28,14 @@ RepoType: git
|
||||
Repo: https://gt.hexor.cy/ab/furumi_android.git
|
||||
|
||||
Builds:
|
||||
- versionName: '1.2'
|
||||
versionCode: 2
|
||||
commit: v1.2
|
||||
- versionName: '1.3'
|
||||
versionCode: 3
|
||||
commit: v1.3
|
||||
subdir: app
|
||||
gradle:
|
||||
- yes
|
||||
|
||||
AutoUpdateMode: Version v%v
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: '1.2'
|
||||
CurrentVersionCode: 2
|
||||
CurrentVersion: '1.3'
|
||||
CurrentVersionCode: 3
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Offline mode with local library sync and cached playback, faster offline switching, and playback reliability fixes.
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user