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 playlistDetail(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId"
|
||||||
|
|
||||||
|
fun offlineManifest(baseUrl: String): String = "$baseUrl$OFFLINE_MANIFEST_PATH"
|
||||||
|
|
||||||
|
fun tracksByIds(baseUrl: String): String = "$baseUrl$TRACKS_BY_IDS_PATH"
|
||||||
|
|
||||||
fun addPlaylistTracks(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId/tracks"
|
fun addPlaylistTracks(baseUrl: String, playlistId: Long): String = "$baseUrl$PLAYLISTS_PATH/$playlistId/tracks"
|
||||||
|
|
||||||
fun sharePlaylist(baseUrl: String): String = "$baseUrl$SHARE_PLAYLIST_PATH"
|
fun sharePlaylist(baseUrl: String): String = "$baseUrl$SHARE_PLAYLIST_PATH"
|
||||||
@@ -53,6 +57,8 @@ class PlayerEndpoints @Inject constructor() {
|
|||||||
private const val LIKES_PATH = "/api/player/likes"
|
private const val LIKES_PATH = "/api/player/likes"
|
||||||
private const val LIKES_TOGGLE_PATH = "/api/player/likes/toggle"
|
private const val LIKES_TOGGLE_PATH = "/api/player/likes/toggle"
|
||||||
private const val PLAYLISTS_PATH = "/api/player/playlists"
|
private const val PLAYLISTS_PATH = "/api/player/playlists"
|
||||||
|
private const val OFFLINE_MANIFEST_PATH = "/api/player/offline/manifest"
|
||||||
|
private const val TRACKS_BY_IDS_PATH = "/api/player/tracks-by-ids"
|
||||||
private const val SHARE_PLAYLIST_PATH = "/api/player/share-playlist"
|
private const val SHARE_PLAYLIST_PATH = "/api/player/share-playlist"
|
||||||
private const val SHARE_TRACK_PATH = "/share/track"
|
private const val SHARE_TRACK_PATH = "/share/track"
|
||||||
private const val DEVICES_POLL_PATH = "/api/player/devices/poll"
|
private const val DEVICES_POLL_PATH = "/api/player/devices/poll"
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ import cy.hexor.furumi.data.remote.model.JamJoinRequest
|
|||||||
import cy.hexor.furumi.data.remote.model.JamUserResponse
|
import cy.hexor.furumi.data.remote.model.JamUserResponse
|
||||||
import cy.hexor.furumi.data.remote.model.LikeToggleResponse
|
import cy.hexor.furumi.data.remote.model.LikeToggleResponse
|
||||||
import cy.hexor.furumi.data.remote.model.LikedTrackIdsResponse
|
import cy.hexor.furumi.data.remote.model.LikedTrackIdsResponse
|
||||||
|
import cy.hexor.furumi.data.remote.model.OfflineManifestResponse
|
||||||
import cy.hexor.furumi.data.remote.model.PlaylistCardResponse
|
import cy.hexor.furumi.data.remote.model.PlaylistCardResponse
|
||||||
import cy.hexor.furumi.data.remote.model.PlaylistDetailResponse
|
import cy.hexor.furumi.data.remote.model.PlaylistDetailResponse
|
||||||
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
|
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
|
||||||
import cy.hexor.furumi.data.remote.model.SearchResponse
|
import cy.hexor.furumi.data.remote.model.SearchResponse
|
||||||
import cy.hexor.furumi.data.remote.model.SharePlaylistRequest
|
import cy.hexor.furumi.data.remote.model.SharePlaylistRequest
|
||||||
import cy.hexor.furumi.data.remote.model.SharePlaylistResponse
|
import cy.hexor.furumi.data.remote.model.SharePlaylistResponse
|
||||||
|
import cy.hexor.furumi.data.remote.model.TrackItemResponse
|
||||||
|
import cy.hexor.furumi.data.remote.model.TracksByIdsRequest
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
@@ -94,6 +97,19 @@ interface PlayerApi {
|
|||||||
@Url url: String
|
@Url url: String
|
||||||
): Response<List<PlaylistCardResponse>>
|
): Response<List<PlaylistCardResponse>>
|
||||||
|
|
||||||
|
@Headers("Accept: application/json")
|
||||||
|
@GET
|
||||||
|
suspend fun offlineManifest(
|
||||||
|
@Url url: String
|
||||||
|
): Response<OfflineManifestResponse>
|
||||||
|
|
||||||
|
@Headers("Accept: application/json", "Content-Type: application/json")
|
||||||
|
@POST
|
||||||
|
suspend fun tracksByIds(
|
||||||
|
@Url url: String,
|
||||||
|
@Body body: TracksByIdsRequest
|
||||||
|
): Response<List<TrackItemResponse>>
|
||||||
|
|
||||||
@Headers("Accept: application/json")
|
@Headers("Accept: application/json")
|
||||||
@GET
|
@GET
|
||||||
suspend fun playlistDetail(
|
suspend fun playlistDetail(
|
||||||
|
|||||||
@@ -27,11 +27,53 @@ data class TrackItemResponse(
|
|||||||
@param:Json(name = "duration_seconds") val durationSeconds: Double? = null,
|
@param:Json(name = "duration_seconds") val durationSeconds: Double? = null,
|
||||||
@param:Json(name = "artists") val artists: List<ArtistRefResponse> = emptyList(),
|
@param:Json(name = "artists") val artists: List<ArtistRefResponse> = emptyList(),
|
||||||
@param:Json(name = "featured_artists") val featuredArtists: List<ArtistRefResponse> = emptyList(),
|
@param:Json(name = "featured_artists") val featuredArtists: List<ArtistRefResponse> = emptyList(),
|
||||||
|
@param:Json(name = "release_id") val releaseId: Long? = null,
|
||||||
@param:Json(name = "release_title") val releaseTitle: String? = null,
|
@param:Json(name = "release_title") val releaseTitle: String? = null,
|
||||||
|
@param:Json(name = "release_year") val releaseYear: Int? = null,
|
||||||
@param:Json(name = "cover_url") val coverUrl: String? = null,
|
@param:Json(name = "cover_url") val coverUrl: String? = null,
|
||||||
@param:Json(name = "stream_url") val streamUrl: String? = null
|
@param:Json(name = "stream_url") val streamUrl: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class TracksByIdsRequest(
|
||||||
|
@param:Json(name = "ids") val ids: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OfflineManifestResponse(
|
||||||
|
@param:Json(name = "generated_at") val generatedAt: String,
|
||||||
|
@param:Json(name = "tracks") val tracks: List<OfflineManifestTrackResponse> = emptyList(),
|
||||||
|
@param:Json(name = "playlists") val playlists: List<OfflineManifestPlaylistResponse> = emptyList(),
|
||||||
|
@param:Json(name = "liked_track_ids") val likedTrackIds: List<Long> = emptyList(),
|
||||||
|
@param:Json(name = "followed_artist_ids") val followedArtistIds: List<Long> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OfflineManifestTrackResponse(
|
||||||
|
@param:Json(name = "id") val id: Long,
|
||||||
|
@param:Json(name = "updated_at") val updatedAt: String? = null,
|
||||||
|
@param:Json(name = "stream_url") val streamUrl: String,
|
||||||
|
@param:Json(name = "audio_file_id") val audioFileId: Long? = null,
|
||||||
|
@param:Json(name = "audio_hash") val audioHash: String? = null,
|
||||||
|
@param:Json(name = "audio_size_bytes") val audioSizeBytes: Long? = null,
|
||||||
|
@param:Json(name = "audio_mime_type") val audioMimeType: String? = null,
|
||||||
|
@param:Json(name = "audio_updated_at") val audioUpdatedAt: String? = null,
|
||||||
|
@param:Json(name = "cover_file_id") val coverFileId: Long? = null,
|
||||||
|
@param:Json(name = "cover_url") val coverUrl: String? = null,
|
||||||
|
@param:Json(name = "cover_hash") val coverHash: String? = null,
|
||||||
|
@param:Json(name = "cover_updated_at") val coverUpdatedAt: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OfflineManifestPlaylistResponse(
|
||||||
|
@param:Json(name = "id") val id: Long,
|
||||||
|
@param:Json(name = "title") val title: String,
|
||||||
|
@param:Json(name = "description") val description: String? = null,
|
||||||
|
@param:Json(name = "updated_at") val updatedAt: String? = null,
|
||||||
|
@param:Json(name = "is_own") val isOwn: Boolean = false,
|
||||||
|
@param:Json(name = "owner_name") val ownerName: String? = null,
|
||||||
|
@param:Json(name = "is_public") val isPublic: Boolean = false,
|
||||||
|
@param:Json(name = "is_saved") val isSaved: Boolean = false,
|
||||||
|
@param:Json(name = "kind") val kind: String? = null,
|
||||||
|
@param:Json(name = "track_ids") val trackIds: List<Long> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
data class PlayHistoryItemResponse(
|
data class PlayHistoryItemResponse(
|
||||||
@param:Json(name = "id") val id: Long,
|
@param:Json(name = "id") val id: Long,
|
||||||
@param:Json(name = "track_id") val trackId: Long,
|
@param:Json(name = "track_id") val trackId: Long,
|
||||||
@@ -296,6 +338,9 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
|||||||
.ifEmpty { mappedFeaturedArtists }
|
.ifEmpty { mappedFeaturedArtists }
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
|
|
||||||
|
val absoluteCoverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl)
|
||||||
|
val absoluteStreamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
|
||||||
|
|
||||||
return TrackCard(
|
return TrackCard(
|
||||||
id = id ?: 0L,
|
id = id ?: 0L,
|
||||||
title = title,
|
title = title,
|
||||||
@@ -305,9 +350,13 @@ fun TrackItemResponse.toDomain(baseUrl: String): TrackCard {
|
|||||||
artists = artistNames,
|
artists = artistNames,
|
||||||
artistRefs = mappedArtists,
|
artistRefs = mappedArtists,
|
||||||
featuredArtistRefs = mappedFeaturedArtists,
|
featuredArtistRefs = mappedFeaturedArtists,
|
||||||
|
releaseId = releaseId,
|
||||||
|
releaseYear = releaseYear,
|
||||||
releaseTitle = releaseTitle,
|
releaseTitle = releaseTitle,
|
||||||
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl),
|
coverUrl = absoluteCoverUrl,
|
||||||
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty()
|
streamUrl = absoluteStreamUrl,
|
||||||
|
remoteCoverUrl = absoluteCoverUrl,
|
||||||
|
remoteStreamUrl = absoluteStreamUrl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,9 +375,34 @@ fun TrackCard.toTrackItemResponse(): TrackItemResponse {
|
|||||||
durationSeconds = durationSeconds?.toDouble() ?: 0.0,
|
durationSeconds = durationSeconds?.toDouble() ?: 0.0,
|
||||||
artists = primaryArtists.map { it.toResponse() },
|
artists = primaryArtists.map { it.toResponse() },
|
||||||
featuredArtists = featuredArtistRefs.map { it.toResponse() },
|
featuredArtists = featuredArtistRefs.map { it.toResponse() },
|
||||||
|
releaseId = releaseId,
|
||||||
releaseTitle = releaseTitle,
|
releaseTitle = releaseTitle,
|
||||||
coverUrl = coverUrl,
|
releaseYear = releaseYear,
|
||||||
streamUrl = streamUrl
|
coverUrl = remoteCoverUrl ?: coverUrl,
|
||||||
|
streamUrl = remoteStreamUrl.ifBlank { streamUrl }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun OfflineManifestTrackResponse.withAbsoluteUrls(baseUrl: String): OfflineManifestTrackResponse {
|
||||||
|
return copy(
|
||||||
|
streamUrl = streamUrl.toAbsoluteMediaUrl(baseUrl).orEmpty(),
|
||||||
|
coverUrl = coverUrl.toAbsoluteMediaUrl(baseUrl)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TrackCard.withOfflineManifest(manifest: OfflineManifestTrackResponse): TrackCard {
|
||||||
|
return copy(
|
||||||
|
coverUrl = manifest.coverUrl ?: coverUrl,
|
||||||
|
streamUrl = manifest.streamUrl.ifBlank { streamUrl },
|
||||||
|
remoteCoverUrl = manifest.coverUrl ?: remoteCoverUrl,
|
||||||
|
remoteStreamUrl = manifest.streamUrl.ifBlank { remoteStreamUrl },
|
||||||
|
metadataUpdatedAt = manifest.updatedAt ?: metadataUpdatedAt,
|
||||||
|
audioHash = manifest.audioHash ?: audioHash,
|
||||||
|
audioSizeBytes = manifest.audioSizeBytes ?: audioSizeBytes,
|
||||||
|
audioMimeType = manifest.audioMimeType ?: audioMimeType,
|
||||||
|
audioUpdatedAt = manifest.audioUpdatedAt ?: audioUpdatedAt,
|
||||||
|
coverHash = manifest.coverHash ?: coverHash,
|
||||||
|
coverUpdatedAt = manifest.coverUpdatedAt ?: coverUpdatedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package cy.hexor.furumi.data.repository
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -17,6 +19,17 @@ class MediaImageLoader @Inject constructor(
|
|||||||
suspend fun load(url: String): Result<Bitmap> {
|
suspend fun load(url: String): Result<Bitmap> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
if (url.startsWith("file://", ignoreCase = true)) {
|
||||||
|
val path = Uri.parse(url).path ?: throw IOException("Image file path is empty")
|
||||||
|
return@runCatching BitmapFactory.decodeFile(path)
|
||||||
|
?: throw IOException("Image file is not a supported bitmap")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("/", ignoreCase = false)) {
|
||||||
|
return@runCatching BitmapFactory.decodeFile(File(url).absolutePath)
|
||||||
|
?: throw IOException("Image file is not a supported bitmap")
|
||||||
|
}
|
||||||
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package cy.hexor.furumi.data.repository
|
|||||||
|
|
||||||
import cy.hexor.furumi.data.local.AuthSessionStorage
|
import cy.hexor.furumi.data.local.AuthSessionStorage
|
||||||
import cy.hexor.furumi.data.local.ConnectedDeviceStorage
|
import cy.hexor.furumi.data.local.ConnectedDeviceStorage
|
||||||
|
import cy.hexor.furumi.data.local.OfflineLibraryStore
|
||||||
|
import cy.hexor.furumi.data.local.OfflineMediaStore
|
||||||
|
import cy.hexor.furumi.data.local.OfflineSettingsStorage
|
||||||
import cy.hexor.furumi.data.remote.AppClientInfo
|
import cy.hexor.furumi.data.remote.AppClientInfo
|
||||||
import cy.hexor.furumi.data.remote.AuthApiErrorParser
|
import cy.hexor.furumi.data.remote.AuthApiErrorParser
|
||||||
import cy.hexor.furumi.data.remote.PlayerEndpoints
|
import cy.hexor.furumi.data.remote.PlayerEndpoints
|
||||||
@@ -16,10 +19,13 @@ import cy.hexor.furumi.data.remote.model.JamCreateRequest
|
|||||||
import cy.hexor.furumi.data.remote.model.JamInviteRequest
|
import cy.hexor.furumi.data.remote.model.JamInviteRequest
|
||||||
import cy.hexor.furumi.data.remote.model.JamJoinRequest
|
import cy.hexor.furumi.data.remote.model.JamJoinRequest
|
||||||
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
|
import cy.hexor.furumi.data.remote.model.RecordHistoryRequest
|
||||||
|
import cy.hexor.furumi.data.remote.model.TracksByIdsRequest
|
||||||
import cy.hexor.furumi.data.remote.model.toDomain
|
import cy.hexor.furumi.data.remote.model.toDomain
|
||||||
import cy.hexor.furumi.data.remote.model.toBody
|
import cy.hexor.furumi.data.remote.model.toBody
|
||||||
import cy.hexor.furumi.data.remote.model.toCommandPayloadBody
|
import cy.hexor.furumi.data.remote.model.toCommandPayloadBody
|
||||||
import cy.hexor.furumi.data.remote.model.toTrackItemResponse
|
import cy.hexor.furumi.data.remote.model.toTrackItemResponse
|
||||||
|
import cy.hexor.furumi.data.remote.model.withAbsoluteUrls
|
||||||
|
import cy.hexor.furumi.data.remote.model.withOfflineManifest
|
||||||
import cy.hexor.furumi.domain.model.ArtistDetail
|
import cy.hexor.furumi.domain.model.ArtistDetail
|
||||||
import cy.hexor.furumi.domain.model.ArtistPage
|
import cy.hexor.furumi.domain.model.ArtistPage
|
||||||
import cy.hexor.furumi.domain.model.AuthException
|
import cy.hexor.furumi.domain.model.AuthException
|
||||||
@@ -28,6 +34,8 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
|
|||||||
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
||||||
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
||||||
import cy.hexor.furumi.domain.model.ListeningHistoryPage
|
import cy.hexor.furumi.domain.model.ListeningHistoryPage
|
||||||
|
import cy.hexor.furumi.domain.model.OfflineState
|
||||||
|
import cy.hexor.furumi.domain.model.OfflineSyncSummary
|
||||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||||
import cy.hexor.furumi.domain.model.PlaylistDetail
|
import cy.hexor.furumi.domain.model.PlaylistDetail
|
||||||
import cy.hexor.furumi.domain.model.ReleaseDetail
|
import cy.hexor.furumi.domain.model.ReleaseDetail
|
||||||
@@ -35,18 +43,113 @@ import cy.hexor.furumi.domain.model.SearchResults
|
|||||||
import cy.hexor.furumi.domain.model.TrackCard
|
import cy.hexor.furumi.domain.model.TrackCard
|
||||||
import cy.hexor.furumi.domain.repository.PlayerRepository
|
import cy.hexor.furumi.domain.repository.PlayerRepository
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class PlayerRepositoryImpl @Inject constructor(
|
class PlayerRepositoryImpl @Inject constructor(
|
||||||
private val playerApi: PlayerApi,
|
private val playerApi: PlayerApi,
|
||||||
private val sessionStorage: AuthSessionStorage,
|
private val sessionStorage: AuthSessionStorage,
|
||||||
private val connectedDeviceStorage: ConnectedDeviceStorage,
|
private val connectedDeviceStorage: ConnectedDeviceStorage,
|
||||||
|
private val offlineSettingsStorage: OfflineSettingsStorage,
|
||||||
|
private val offlineLibraryStore: OfflineLibraryStore,
|
||||||
|
private val offlineMediaStore: OfflineMediaStore,
|
||||||
private val appClientInfo: AppClientInfo,
|
private val appClientInfo: AppClientInfo,
|
||||||
private val playerEndpoints: PlayerEndpoints,
|
private val playerEndpoints: PlayerEndpoints,
|
||||||
private val errorParser: AuthApiErrorParser
|
private val errorParser: AuthApiErrorParser
|
||||||
) : PlayerRepository {
|
) : PlayerRepository {
|
||||||
|
override val offlineState: StateFlow<OfflineState> = offlineSettingsStorage.state
|
||||||
|
|
||||||
|
override fun setOfflineModeEnabled(enabled: Boolean) {
|
||||||
|
offlineSettingsStorage.setOfflineModeEnabled(enabled)
|
||||||
|
if (!enabled) {
|
||||||
|
offlineSettingsStorage.setNetworkFallback(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSaveListenedTracksEnabled(enabled: Boolean) {
|
||||||
|
offlineSettingsStorage.setSaveListenedTracksEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setOfflineStorageLimit(limitBytes: Long) {
|
||||||
|
offlineSettingsStorage.setStorageLimitBytes(limitBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearOfflineCache(): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
offlineMediaStore.clearCachedMedia()
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary> {
|
||||||
|
offlineSettingsStorage.setSyncing(true)
|
||||||
|
return try {
|
||||||
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
|
?: throw AuthException("Server URL is missing")
|
||||||
|
val manifestResponse = playerApi.offlineManifest(playerEndpoints.offlineManifest(baseUrl))
|
||||||
|
|
||||||
|
if (!manifestResponse.isSuccessful) {
|
||||||
|
throw AuthException(errorParser.messageFrom(manifestResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
val manifest = manifestResponse.body()
|
||||||
|
?: throw AuthException("Offline manifest response is empty")
|
||||||
|
val manifestTracks = manifest.tracks.map { it.withAbsoluteUrls(baseUrl) }
|
||||||
|
val manifestById = manifestTracks.associateBy { it.id }
|
||||||
|
|
||||||
|
offlineLibraryStore.upsertManifestTracks(manifestTracks)
|
||||||
|
offlineLibraryStore.replacePlaylists(manifest.playlists)
|
||||||
|
offlineLibraryStore.replaceLikedTrackIds(manifest.likedTrackIds.toSet())
|
||||||
|
|
||||||
|
val trackCards = manifestTracks
|
||||||
|
.map { it.id }
|
||||||
|
.chunked(TRACKS_BY_IDS_CHUNK_SIZE)
|
||||||
|
.flatMap { ids ->
|
||||||
|
val tracksResponse = playerApi.tracksByIds(
|
||||||
|
url = playerEndpoints.tracksByIds(baseUrl),
|
||||||
|
body = TracksByIdsRequest(ids)
|
||||||
|
)
|
||||||
|
if (!tracksResponse.isSuccessful) {
|
||||||
|
throw AuthException(errorParser.messageFrom(tracksResponse))
|
||||||
|
}
|
||||||
|
tracksResponse.body().orEmpty().map { track ->
|
||||||
|
val domainTrack = track.toDomain(baseUrl)
|
||||||
|
manifestById[domainTrack.id]?.let(domainTrack::withOfflineManifest) ?: domainTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offlineLibraryStore.upsertTracks(trackCards)
|
||||||
|
offlineMediaStore.pruneStaleMedia()
|
||||||
|
offlineMediaStore.publishStorageStats()
|
||||||
|
val syncedAtMs = System.currentTimeMillis()
|
||||||
|
offlineSettingsStorage.setLastSyncedAt(syncedAtMs)
|
||||||
|
offlineSettingsStorage.setNetworkFallback(false)
|
||||||
|
Result.success(
|
||||||
|
OfflineSyncSummary(
|
||||||
|
trackCount = manifestTracks.size,
|
||||||
|
playlistCount = manifest.playlists.size,
|
||||||
|
syncedAtMs = syncedAtMs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
offlineSettingsStorage.setLastError(e.message ?: "Unable to sync offline library")
|
||||||
|
Result.failure(e)
|
||||||
|
} finally {
|
||||||
|
offlineSettingsStorage.setSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getArtists(page: Int, limit: Int, mine: Boolean): Result<ArtistPage> {
|
override suspend fun getArtists(page: Int, limit: Int, mine: Boolean): Result<ArtistPage> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return Result.success(offlineLibraryStore.getArtists(page, limit))
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -62,15 +165,23 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Artists response is empty")
|
val body = response.body() ?: throw AuthException("Artists response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain(baseUrl))
|
Result.success(body.toDomain(baseUrl))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
fallbackToOffline(e) { offlineLibraryStore.getArtists(page, limit) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getArtistDetail(artistId: Long): Result<ArtistDetail> {
|
override suspend fun getArtistDetail(artistId: Long): Result<ArtistDetail> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineLibraryStore.getArtistDetail(artistId)
|
||||||
|
?.withOnlyCachedPlayback()
|
||||||
|
?.let { Result.success(it) }
|
||||||
|
?: offlineUnavailable("Artist is not available offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -83,15 +194,27 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Artist response is empty")
|
val body = response.body() ?: throw AuthException("Artist response is empty")
|
||||||
Result.success(body.toDomain(baseUrl))
|
val detail = body.toDomain(baseUrl).withCachedMedia()
|
||||||
|
offlineLibraryStore.upsertTracks(detail.topTracks + detail.featuredTracks)
|
||||||
|
markOnlineSuccess()
|
||||||
|
Result.success(detail)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
fallbackToOffline(e) {
|
||||||
|
offlineLibraryStore.getArtistDetail(artistId)?.withOnlyCachedPlayback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail> {
|
override suspend fun getReleaseDetail(releaseId: Long): Result<ReleaseDetail> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineLibraryStore.getReleaseDetail(releaseId)
|
||||||
|
?.withOnlyCachedPlayback()
|
||||||
|
?.let { Result.success(it) }
|
||||||
|
?: offlineUnavailable("Release is not available offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -104,15 +227,24 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Release response is empty")
|
val body = response.body() ?: throw AuthException("Release response is empty")
|
||||||
Result.success(body.toDomain(baseUrl))
|
val detail = body.toDomain(baseUrl).withCachedMedia()
|
||||||
|
offlineLibraryStore.upsertTracks(detail.tracks)
|
||||||
|
markOnlineSuccess()
|
||||||
|
Result.success(detail)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
fallbackToOffline(e) {
|
||||||
|
offlineLibraryStore.getReleaseDetail(releaseId)?.withOnlyCachedPlayback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(query: String, limit: Int): Result<SearchResults> {
|
override suspend fun search(query: String, limit: Int): Result<SearchResults> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return Result.success(offlineLibraryStore.search(query, limit).withOnlyCachedPlayback())
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -127,15 +259,22 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Search response is empty")
|
val body = response.body() ?: throw AuthException("Search response is empty")
|
||||||
Result.success(body.toDomain(baseUrl))
|
val results = body.toDomain(baseUrl).withCachedMedia()
|
||||||
|
offlineLibraryStore.upsertTracks(results.tracks)
|
||||||
|
markOnlineSuccess()
|
||||||
|
Result.success(results)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
fallbackToOffline(e) { offlineLibraryStore.search(query, limit).withOnlyCachedPlayback() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> {
|
override suspend fun getListeningHistory(page: Int, limit: Int): Result<ListeningHistoryPage> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Listening history is not available offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -150,6 +289,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Listening history response is empty")
|
val body = response.body() ?: throw AuthException("Listening history response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain())
|
Result.success(body.toDomain())
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -159,6 +299,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun toggleLike(trackId: Long): Result<Boolean> {
|
override suspend fun toggleLike(trackId: Long): Result<Boolean> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Likes cannot be changed offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -171,6 +315,11 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Like toggle response is empty")
|
val body = response.body() ?: throw AuthException("Like toggle response is empty")
|
||||||
|
val likedIds = offlineLibraryStore.getLikedTrackIds()
|
||||||
|
offlineLibraryStore.replaceLikedTrackIds(
|
||||||
|
if (body.liked) likedIds + trackId else likedIds - trackId
|
||||||
|
)
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.liked)
|
Result.success(body.liked)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -180,6 +329,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getLikedTrackIds(): Result<Set<Long>> {
|
override suspend fun getLikedTrackIds(): Result<Set<Long>> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return Result.success(offlineLibraryStore.getLikedTrackIds())
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -192,11 +345,14 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Likes response is empty")
|
val body = response.body() ?: throw AuthException("Likes response is empty")
|
||||||
Result.success(body.trackIds.toSet())
|
val likedIds = body.trackIds.toSet()
|
||||||
|
offlineLibraryStore.replaceLikedTrackIds(likedIds)
|
||||||
|
markOnlineSuccess()
|
||||||
|
Result.success(likedIds)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
fallbackToOffline(e) { offlineLibraryStore.getLikedTrackIds() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +362,17 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
durationListened: Int,
|
durationListened: Int,
|
||||||
completed: Boolean
|
completed: Boolean
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return try {
|
||||||
|
offlineMediaStore.cacheTrackAfterListening(trackId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -223,15 +390,23 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
throw AuthException(errorParser.messageFrom(response))
|
throw AuthException(errorParser.messageFrom(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offlineMediaStore.cacheTrackAfterListening(trackId)
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
runCatching { offlineMediaStore.cacheTrackAfterListening(trackId) }
|
||||||
|
offlineSettingsStorage.setNetworkFallback(true, e.message)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPlaylists(): Result<List<PlaylistCard>> {
|
override suspend fun getPlaylists(): Result<List<PlaylistCard>> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return Result.success(offlineLibraryStore.getPlaylists())
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -244,15 +419,23 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Playlists response is empty")
|
val body = response.body() ?: throw AuthException("Playlists response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain())
|
Result.success(body.toDomain())
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
fallbackToOffline(e) { offlineLibraryStore.getPlaylists() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail> {
|
override suspend fun getPlaylistDetail(playlistId: Long): Result<PlaylistDetail> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineLibraryStore.getPlaylistDetail(playlistId)
|
||||||
|
?.withOnlyCachedPlayback()
|
||||||
|
?.let { Result.success(it) }
|
||||||
|
?: offlineUnavailable("Playlist is not available offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -265,15 +448,24 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Playlist response is empty")
|
val body = response.body() ?: throw AuthException("Playlist response is empty")
|
||||||
Result.success(body.toDomain(baseUrl))
|
val detail = body.toDomain(baseUrl).withCachedMedia()
|
||||||
|
offlineLibraryStore.upsertPlaylistDetail(detail)
|
||||||
|
markOnlineSuccess()
|
||||||
|
Result.success(detail)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
fallbackToOffline(e) {
|
||||||
|
offlineLibraryStore.getPlaylistDetail(playlistId)?.withOnlyCachedPlayback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createPlaylist(title: String): Result<PlaylistCard> {
|
override suspend fun createPlaylist(title: String): Result<PlaylistCard> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Playlists cannot be created offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -287,6 +479,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Create playlist response is empty")
|
val body = response.body() ?: throw AuthException("Create playlist response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain())
|
Result.success(body.toDomain())
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -296,6 +489,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deletePlaylist(playlistId: Long): Result<Unit> {
|
override suspend fun deletePlaylist(playlistId: Long): Result<Unit> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Playlists cannot be deleted offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -307,6 +504,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
throw AuthException(errorParser.messageFrom(response))
|
throw AuthException(errorParser.messageFrom(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -316,6 +514,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
|
override suspend fun addTracksToPlaylist(playlistId: Long, trackIds: List<Long>): Result<Unit> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Playlists cannot be changed offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -328,6 +530,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
throw AuthException(errorParser.messageFrom(response))
|
throw AuthException(errorParser.messageFrom(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -337,9 +540,14 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun shareTrack(trackId: Long, title: String): Result<String> {
|
override suspend fun shareTrack(trackId: Long, title: String): Result<String> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Tracks cannot be shared offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(playerEndpoints.shareTrack(baseUrl, trackId))
|
Result.success(playerEndpoints.shareTrack(baseUrl, trackId))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -352,6 +560,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
playbackState: ConnectedPlaybackState?,
|
playbackState: ConnectedPlaybackState?,
|
||||||
currentJamId: String?
|
currentJamId: String?
|
||||||
): Result<Pair<ConnectedDevicesState, List<ConnectedCommand>>> {
|
): Result<Pair<ConnectedDevicesState, List<ConnectedCommand>>> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Connected devices are unavailable offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -370,6 +582,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Device poll response is empty")
|
val body = response.body() ?: throw AuthException("Device poll response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain(baseUrl) to body.commands.map { it.toDomain(baseUrl) })
|
Result.success(body.toDomain(baseUrl) to body.commands.map { it.toDomain(baseUrl) })
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -379,6 +592,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setActiveDevice(deviceId: String): Result<ConnectedDevicesState> {
|
override suspend fun setActiveDevice(deviceId: String): Result<ConnectedDevicesState> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Connected devices are unavailable offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -395,6 +612,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Active device response is empty")
|
val body = response.body() ?: throw AuthException("Active device response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain(baseUrl))
|
Result.success(body.toDomain(baseUrl))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -417,6 +635,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
fromIndex: Int?,
|
fromIndex: Int?,
|
||||||
toIndex: Int?
|
toIndex: Int?
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Connected devices are unavailable offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -444,6 +666,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
throw AuthException(errorParser.messageFrom(response))
|
throw AuthException(errorParser.messageFrom(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -453,6 +676,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createJam(): Result<ConnectedDevicesState> {
|
override suspend fun createJam(): Result<ConnectedDevicesState> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Jam is unavailable offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -466,6 +693,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain(baseUrl))
|
Result.success(body.toDomain(baseUrl))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -475,6 +703,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun joinJam(jamId: String): Result<ConnectedDevicesState> {
|
override suspend fun joinJam(jamId: String): Result<ConnectedDevicesState> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Jam is unavailable offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -491,6 +723,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain(baseUrl))
|
Result.success(body.toDomain(baseUrl))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -500,6 +733,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun leaveJam(jamId: String): Result<ConnectedDevicesState> {
|
override suspend fun leaveJam(jamId: String): Result<ConnectedDevicesState> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Jam is unavailable offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -516,6 +753,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain(baseUrl))
|
Result.success(body.toDomain(baseUrl))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -525,6 +763,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchJamUsers(query: String, limit: Int): Result<List<ConnectedJamUser>> {
|
override suspend fun searchJamUsers(query: String, limit: Int): Result<List<ConnectedJamUser>> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Jam is unavailable offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -539,6 +781,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Jam users response is empty")
|
val body = response.body() ?: throw AuthException("Jam users response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.map { it.toDomain() })
|
Result.success(body.map { it.toDomain() })
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -548,6 +791,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState> {
|
override suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState> {
|
||||||
|
if (isOfflineModeEnabled()) {
|
||||||
|
return offlineUnavailable("Jam is unavailable offline")
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val baseUrl = sessionStorage.getBaseUrl()
|
val baseUrl = sessionStorage.getBaseUrl()
|
||||||
?: throw AuthException("Server URL is missing")
|
?: throw AuthException("Server URL is missing")
|
||||||
@@ -565,6 +812,7 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body() ?: throw AuthException("Jam response is empty")
|
val body = response.body() ?: throw AuthException("Jam response is empty")
|
||||||
|
markOnlineSuccess()
|
||||||
Result.success(body.toDomain(baseUrl))
|
Result.success(body.toDomain(baseUrl))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -572,4 +820,88 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isOfflineModeEnabled(): Boolean {
|
||||||
|
return offlineSettingsStorage.state.value.settings.offlineModeEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markOnlineSuccess() {
|
||||||
|
offlineSettingsStorage.setNetworkFallback(false)
|
||||||
|
offlineSettingsStorage.setLastError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> fallbackToOffline(error: Exception, block: () -> T?): Result<T> {
|
||||||
|
val localValue = runCatching(block).getOrNull()
|
||||||
|
return if (localValue != null) {
|
||||||
|
offlineSettingsStorage.setNetworkFallback(true, error.message)
|
||||||
|
Result.success(localValue)
|
||||||
|
} else {
|
||||||
|
Result.failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> offlineUnavailable(message: String): Result<T> {
|
||||||
|
return Result.failure(AuthException(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ArtistDetail.withCachedMedia(): ArtistDetail {
|
||||||
|
return copy(
|
||||||
|
topTracks = topTracks.map { it.withCachedMedia() },
|
||||||
|
featuredTracks = featuredTracks.map { it.withCachedMedia() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReleaseDetail.withCachedMedia(): ReleaseDetail {
|
||||||
|
return copy(tracks = tracks.map { it.withCachedMedia() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PlaylistDetail.withCachedMedia(): PlaylistDetail {
|
||||||
|
return copy(tracks = tracks.map { it.withCachedMedia() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SearchResults.withCachedMedia(): SearchResults {
|
||||||
|
return copy(tracks = tracks.map { it.withCachedMedia() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TrackCard.withCachedMedia(): TrackCard {
|
||||||
|
val cached = offlineLibraryStore.getTrack(id, preferLocalMedia = true) ?: return this
|
||||||
|
return copy(
|
||||||
|
streamUrl = if (cached.isAudioCached) cached.streamUrl else streamUrl,
|
||||||
|
coverUrl = if (cached.localCoverPath != null) cached.coverUrl else coverUrl,
|
||||||
|
localAudioPath = cached.localAudioPath,
|
||||||
|
localCoverPath = cached.localCoverPath,
|
||||||
|
isAudioCached = cached.isAudioCached
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ArtistDetail.withOnlyCachedPlayback(): ArtistDetail {
|
||||||
|
return copy(
|
||||||
|
topTracks = topTracks.map { it.withOnlyCachedPlayback() },
|
||||||
|
featuredTracks = featuredTracks.map { it.withOnlyCachedPlayback() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReleaseDetail.withOnlyCachedPlayback(): ReleaseDetail {
|
||||||
|
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PlaylistDetail.withOnlyCachedPlayback(): PlaylistDetail {
|
||||||
|
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SearchResults.withOnlyCachedPlayback(): SearchResults {
|
||||||
|
return copy(tracks = tracks.map { it.withOnlyCachedPlayback() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TrackCard.withOnlyCachedPlayback(): TrackCard {
|
||||||
|
return if (isAudioCached || streamUrl.startsWith("file://", ignoreCase = true)) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
copy(streamUrl = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TRACKS_BY_IDS_CHUNK_SIZE = 500
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,23 @@ data class TrackCard(
|
|||||||
val artists: List<String>,
|
val artists: List<String>,
|
||||||
val artistRefs: List<ArtistRef> = emptyList(),
|
val artistRefs: List<ArtistRef> = emptyList(),
|
||||||
val featuredArtistRefs: List<ArtistRef> = emptyList(),
|
val featuredArtistRefs: List<ArtistRef> = emptyList(),
|
||||||
|
val releaseId: Long? = null,
|
||||||
|
val releaseYear: Int? = null,
|
||||||
val releaseTitle: String?,
|
val releaseTitle: String?,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
val streamUrl: String
|
val streamUrl: String,
|
||||||
|
val remoteCoverUrl: String? = coverUrl,
|
||||||
|
val remoteStreamUrl: String = streamUrl,
|
||||||
|
val metadataUpdatedAt: String? = null,
|
||||||
|
val audioHash: String? = null,
|
||||||
|
val audioSizeBytes: Long? = null,
|
||||||
|
val audioMimeType: String? = null,
|
||||||
|
val audioUpdatedAt: String? = null,
|
||||||
|
val coverHash: String? = null,
|
||||||
|
val coverUpdatedAt: String? = null,
|
||||||
|
val localAudioPath: String? = null,
|
||||||
|
val localCoverPath: String? = null,
|
||||||
|
val isAudioCached: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ArtistDetail(
|
data class ArtistDetail(
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package cy.hexor.furumi.domain.model
|
||||||
|
|
||||||
|
data class OfflineSettings(
|
||||||
|
val offlineModeEnabled: Boolean = false,
|
||||||
|
val saveListenedTracksEnabled: Boolean = false,
|
||||||
|
val storageLimitBytes: Long = DEFAULT_STORAGE_LIMIT_BYTES
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_STORAGE_LIMIT_BYTES: Long = 2L * 1024L * 1024L * 1024L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class OfflineState(
|
||||||
|
val settings: OfflineSettings = OfflineSettings(),
|
||||||
|
val isUsingNetworkFallback: Boolean = false,
|
||||||
|
val isSyncing: Boolean = false,
|
||||||
|
val lastSyncedAtMs: Long = 0L,
|
||||||
|
val storageUsedBytes: Long = 0L,
|
||||||
|
val cachedTrackCount: Int = 0,
|
||||||
|
val lastError: String? = null
|
||||||
|
) {
|
||||||
|
val isOfflineActive: Boolean
|
||||||
|
get() = settings.offlineModeEnabled || isUsingNetworkFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
data class OfflineSyncSummary(
|
||||||
|
val trackCount: Int,
|
||||||
|
val playlistCount: Int,
|
||||||
|
val syncedAtMs: Long
|
||||||
|
)
|
||||||
@@ -7,13 +7,18 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
|
|||||||
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
||||||
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
||||||
import cy.hexor.furumi.domain.model.ListeningHistoryPage
|
import cy.hexor.furumi.domain.model.ListeningHistoryPage
|
||||||
|
import cy.hexor.furumi.domain.model.OfflineState
|
||||||
|
import cy.hexor.furumi.domain.model.OfflineSyncSummary
|
||||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||||
import cy.hexor.furumi.domain.model.PlaylistDetail
|
import cy.hexor.furumi.domain.model.PlaylistDetail
|
||||||
import cy.hexor.furumi.domain.model.ReleaseDetail
|
import cy.hexor.furumi.domain.model.ReleaseDetail
|
||||||
import cy.hexor.furumi.domain.model.SearchResults
|
import cy.hexor.furumi.domain.model.SearchResults
|
||||||
import cy.hexor.furumi.domain.model.TrackCard
|
import cy.hexor.furumi.domain.model.TrackCard
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface PlayerRepository {
|
interface PlayerRepository {
|
||||||
|
val offlineState: StateFlow<OfflineState>
|
||||||
|
|
||||||
suspend fun getArtists(
|
suspend fun getArtists(
|
||||||
page: Int = 1,
|
page: Int = 1,
|
||||||
limit: Int = 60,
|
limit: Int = 60,
|
||||||
@@ -82,4 +87,14 @@ interface PlayerRepository {
|
|||||||
suspend fun searchJamUsers(query: String, limit: Int = 10): Result<List<ConnectedJamUser>>
|
suspend fun searchJamUsers(query: String, limit: Int = 10): Result<List<ConnectedJamUser>>
|
||||||
|
|
||||||
suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState>
|
suspend fun inviteToJam(jamId: String, inviteeUserIds: List<Long>): Result<ConnectedDevicesState>
|
||||||
|
|
||||||
|
fun setOfflineModeEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
fun setSaveListenedTracksEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
fun setOfflineStorageLimit(limitBytes: Long)
|
||||||
|
|
||||||
|
suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary>
|
||||||
|
|
||||||
|
suspend fun clearOfflineCache(): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.util.Log
|
|||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.session.CacheBitmapLoader
|
import androidx.media3.session.CacheBitmapLoader
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -59,7 +60,10 @@ class FurumiPlaybackService : MediaSessionService() {
|
|||||||
val bitmapLoader = CacheBitmapLoader(
|
val bitmapLoader = CacheBitmapLoader(
|
||||||
DataSourceBitmapLoader(
|
DataSourceBitmapLoader(
|
||||||
MoreExecutors.listeningDecorator(executor),
|
MoreExecutors.listeningDecorator(executor),
|
||||||
OkHttpDataSource.Factory(okHttpClient)
|
DefaultDataSource.Factory(
|
||||||
|
this,
|
||||||
|
OkHttpDataSource.Factory(okHttpClient)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.media3.common.Player
|
|||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Timeline
|
import androidx.media3.common.Timeline
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
@@ -87,7 +88,10 @@ class PlaybackController @Inject constructor(
|
|||||||
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
|
val state: StateFlow<AudioPlaybackState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
|
val dataSourceFactory = DefaultDataSource.Factory(
|
||||||
|
context,
|
||||||
|
OkHttpDataSource.Factory(okHttpClient)
|
||||||
|
)
|
||||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||||
|
|
||||||
player = ExoPlayer.Builder(context)
|
player = ExoPlayer.Builder(context)
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ import androidx.compose.material3.ButtonDefaults
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -74,6 +77,9 @@ import cy.hexor.furumi.ui.theme.FurumiNeonViolet
|
|||||||
import cy.hexor.furumi.ui.theme.FurumiSurface
|
import cy.hexor.furumi.ui.theme.FurumiSurface
|
||||||
import cy.hexor.furumi.ui.theme.FurumiSurfaceHigh
|
import cy.hexor.furumi.ui.theme.FurumiSurfaceHigh
|
||||||
import cy.hexor.furumi.ui.theme.FurumiTextMuted
|
import cy.hexor.furumi.ui.theme.FurumiTextMuted
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -139,65 +145,43 @@ internal fun ProfileMenu(
|
|||||||
uiState: PlayerUiState,
|
uiState: PlayerUiState,
|
||||||
onHistoryClick: () -> Unit,
|
onHistoryClick: () -> Unit,
|
||||||
onDeviceClick: (String) -> Unit,
|
onDeviceClick: (String) -> Unit,
|
||||||
|
onOfflineModeChange: (Boolean) -> Unit,
|
||||||
|
onSaveListenedChange: (Boolean) -> Unit,
|
||||||
|
onStorageLimitChange: (Long) -> Unit,
|
||||||
|
onSyncOffline: () -> Unit,
|
||||||
|
onClearOfflineCache: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier.width(284.dp),
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.widthIn(max = 430.dp)
|
||||||
|
.heightIn(max = 640.dp),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
color = FurumiSurfaceHigh,
|
color = FurumiSurfaceHigh,
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
|
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
|
||||||
shadowElevation = 8.dp
|
shadowElevation = 8.dp
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
ProfileSummary(uiState)
|
||||||
text = "Profile",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = uiState.userName,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f, fill = false)
|
|
||||||
)
|
|
||||||
if (uiState.appVersion.isNotBlank()) {
|
|
||||||
Surface(
|
|
||||||
color = FurumiSurface,
|
|
||||||
shape = RoundedCornerShape(4.dp),
|
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "v${uiState.appVersion}",
|
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = FurumiTextMuted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = uiState.serverUrl.ifBlank { "No server selected" },
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = FurumiTextMuted,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
ConnectedDevicesSection(
|
ConnectedDevicesSection(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onDeviceClick = onDeviceClick
|
onDeviceClick = onDeviceClick
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
OfflineControlsSection(
|
||||||
|
uiState = uiState,
|
||||||
|
onOfflineModeChange = onOfflineModeChange,
|
||||||
|
onSaveListenedChange = onSaveListenedChange,
|
||||||
|
onStorageLimitChange = onStorageLimitChange,
|
||||||
|
onSyncOffline = onSyncOffline,
|
||||||
|
onClearOfflineCache = onClearOfflineCache
|
||||||
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onHistoryClick,
|
onClick = onHistoryClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -212,7 +196,6 @@ internal fun ProfileMenu(
|
|||||||
) {
|
) {
|
||||||
Text("Listening history")
|
Text("Listening history")
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onLogout,
|
onClick = onLogout,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -241,13 +224,286 @@ internal fun ProfileMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileSummary(uiState: PlayerUiState) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Profile",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(FurumiNeonPink),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = uiState.userName.firstOrNull()?.uppercaseChar()?.toString() ?: "F",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = FurumiBlack,
|
||||||
|
fontWeight = FontWeight.ExtraBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = uiState.userName,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (uiState.appVersion.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
color = FurumiSurface,
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "v${uiState.appVersion}",
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = FurumiTextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = uiState.serverUrl.ifBlank { "No server selected" },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = FurumiTextMuted,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OfflineControlsSection(
|
||||||
|
uiState: PlayerUiState,
|
||||||
|
onOfflineModeChange: (Boolean) -> Unit,
|
||||||
|
onSaveListenedChange: (Boolean) -> Unit,
|
||||||
|
onStorageLimitChange: (Long) -> Unit,
|
||||||
|
onSyncOffline: () -> Unit,
|
||||||
|
onClearOfflineCache: () -> Unit
|
||||||
|
) {
|
||||||
|
val offlineState = uiState.offlineState
|
||||||
|
val settings = offlineState.settings
|
||||||
|
val storageProgress = if (settings.storageLimitBytes > 0L) {
|
||||||
|
(offlineState.storageUsedBytes.toFloat() / settings.storageLimitBytes.toFloat()).coerceIn(0f, 1f)
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Offline",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
ProfileSwitchRow(
|
||||||
|
title = "Offline mode",
|
||||||
|
subtitle = if (offlineState.isUsingNetworkFallback) "Fallback active" else "Local library",
|
||||||
|
checked = settings.offlineModeEnabled,
|
||||||
|
onCheckedChange = onOfflineModeChange
|
||||||
|
)
|
||||||
|
ProfileSwitchRow(
|
||||||
|
title = "Save listened tracks",
|
||||||
|
subtitle = "${offlineState.cachedTrackCount} cached",
|
||||||
|
checked = settings.saveListenedTracksEnabled,
|
||||||
|
onCheckedChange = onSaveListenedChange
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Storage",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${formatStorageBytes(offlineState.storageUsedBytes)} / ${formatStorageBytes(settings.storageLimitBytes)}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = FurumiTextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { storageProgress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
color = FurumiNeonPink,
|
||||||
|
trackColor = FurumiLine
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
offlineStorageLimitOptions.forEach { limit ->
|
||||||
|
OfflineLimitChip(
|
||||||
|
label = formatStorageBytes(limit),
|
||||||
|
selected = settings.storageLimitBytes == limit,
|
||||||
|
onClick = { onStorageLimitChange(limit) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onSyncOffline,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = !offlineState.isSyncing,
|
||||||
|
shape = CircleShape,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
|
disabledContentColor = FurumiTextMuted
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (offlineState.isSyncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
color = FurumiTextMuted,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Sync now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClearOfflineCache,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = offlineState.storageUsedBytes > 0L && !offlineState.isSyncing,
|
||||||
|
shape = CircleShape,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiLine),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
|
disabledContentColor = FurumiTextMuted
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Clear downloads")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formatOfflineSyncStatus(offlineState.lastSyncedAtMs),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = FurumiTextMuted,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
offlineState.lastError?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = FurumiHotOrange,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileSwitchRow(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = FurumiTextMuted,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = FurumiBlack,
|
||||||
|
checkedTrackColor = FurumiNeonPink,
|
||||||
|
uncheckedThumbColor = FurumiTextMuted,
|
||||||
|
uncheckedTrackColor = FurumiSurface,
|
||||||
|
uncheckedBorderColor = FurumiLine
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OfflineLimitChip(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = if (selected) FurumiNeonPink else FurumiSurface,
|
||||||
|
border = if (selected) null else androidx.compose.foundation.BorderStroke(1.dp, FurumiLine)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = if (selected) FurumiBlack else MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun OfflineStatusBadge(
|
||||||
|
offlineState: cy.hexor.furumi.domain.model.OfflineState,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = CircleShape,
|
||||||
|
color = if (offlineState.settings.offlineModeEnabled) FurumiNeonPink else FurumiHotOrange,
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (offlineState.settings.offlineModeEnabled) "Offline mode" else "Offline fallback",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = FurumiBlack,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConnectedDevicesSection(
|
private fun ConnectedDevicesSection(
|
||||||
uiState: PlayerUiState,
|
uiState: PlayerUiState,
|
||||||
onDeviceClick: (String) -> Unit
|
onDeviceClick: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val devices = uiState.connectedDevicesState?.devices.orEmpty()
|
val devices = uiState.connectedDevicesState?.devices.orEmpty()
|
||||||
if (devices.isEmpty() && uiState.connectedDevicesError == null) return
|
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -255,11 +511,17 @@ private fun ConnectedDevicesSection(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
)
|
)
|
||||||
uiState.connectedDevicesError?.let { error ->
|
val statusText = when {
|
||||||
|
uiState.offlineState.settings.offlineModeEnabled -> "Offline mode"
|
||||||
|
uiState.connectedDevicesError != null -> uiState.connectedDevicesError
|
||||||
|
devices.isEmpty() -> "No connected devices"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
statusText?.let { status ->
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = status,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = FurumiHotOrange,
|
color = if (uiState.connectedDevicesError != null) FurumiHotOrange else FurumiTextMuted,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -566,3 +828,29 @@ internal fun BottomNavItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val offlineStorageLimitOptions = listOf(
|
||||||
|
512L * 1024L * 1024L,
|
||||||
|
1L * 1024L * 1024L * 1024L,
|
||||||
|
2L * 1024L * 1024L * 1024L,
|
||||||
|
5L * 1024L * 1024L * 1024L,
|
||||||
|
10L * 1024L * 1024L * 1024L
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun formatStorageBytes(bytes: Long): String {
|
||||||
|
val safeBytes = bytes.coerceAtLeast(0L)
|
||||||
|
val megabytes = safeBytes / (1024.0 * 1024.0)
|
||||||
|
return if (megabytes >= 1024.0) {
|
||||||
|
String.format(Locale.US, "%.1f GB", megabytes / 1024.0)
|
||||||
|
} else {
|
||||||
|
String.format(Locale.US, "%.0f MB", megabytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatOfflineSyncStatus(timestampMs: Long): String {
|
||||||
|
if (timestampMs <= 0L) return "Never synced"
|
||||||
|
val formatted = DateFormat
|
||||||
|
.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||||
|
.format(Date(timestampMs))
|
||||||
|
return "Synced $formatted"
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ internal fun ArtistDetailTrackRow(
|
|||||||
onAddToPlaylist: () -> Unit
|
onAddToPlaylist: () -> Unit
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val isUnavailable = track.isUnavailableForPlayback()
|
||||||
|
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
|
||||||
|
val primaryColor = if (isUnavailable) {
|
||||||
|
FurumiTextMuted.copy(alpha = 0.72f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onBackground
|
||||||
|
}
|
||||||
|
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||||
|
|
||||||
LaunchedEffect(track.coverUrl) {
|
LaunchedEffect(track.coverUrl) {
|
||||||
onMediaImageNeeded(track.coverUrl)
|
onMediaImageNeeded(track.coverUrl)
|
||||||
@@ -102,7 +110,7 @@ internal fun ArtistDetailTrackRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable { onTrackClick(track) }
|
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -110,13 +118,15 @@ internal fun ArtistDetailTrackRow(
|
|||||||
text = index.toString(),
|
text = index.toString(),
|
||||||
modifier = Modifier.width(28.dp),
|
modifier = Modifier.width(28.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted
|
color = secondaryColor
|
||||||
)
|
)
|
||||||
MediaArtwork(
|
MediaArtwork(
|
||||||
title = track.title,
|
title = track.title,
|
||||||
seedId = track.id,
|
seedId = track.id,
|
||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.unavailableTrackArtworkOverlay(isUnavailable),
|
||||||
cornerRadius = 6
|
cornerRadius = 6
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
@@ -124,7 +134,7 @@ internal fun ArtistDetailTrackRow(
|
|||||||
Text(
|
Text(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = primaryColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -133,7 +143,7 @@ internal fun ArtistDetailTrackRow(
|
|||||||
.ifBlank { track.releaseTitle.orEmpty() }
|
.ifBlank { track.releaseTitle.orEmpty() }
|
||||||
.ifBlank { "Unknown artist" },
|
.ifBlank { "Unknown artist" },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted,
|
color = secondaryColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -144,7 +154,8 @@ internal fun ArtistDetailTrackRow(
|
|||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable(onClick = onToggleLike)
|
.clickable(onClick = onToggleLike)
|
||||||
.padding(4.dp)
|
.padding(4.dp),
|
||||||
|
alpha = contentAlpha
|
||||||
)
|
)
|
||||||
Box {
|
Box {
|
||||||
MoreDotsGlyph(
|
MoreDotsGlyph(
|
||||||
@@ -152,7 +163,8 @@ internal fun ArtistDetailTrackRow(
|
|||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable { menuExpanded = true }
|
.clickable { menuExpanded = true }
|
||||||
.padding(4.dp)
|
.padding(4.dp),
|
||||||
|
color = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||||
)
|
)
|
||||||
TrackContextMenu(
|
TrackContextMenu(
|
||||||
expanded = menuExpanded,
|
expanded = menuExpanded,
|
||||||
@@ -169,7 +181,7 @@ internal fun ArtistDetailTrackRow(
|
|||||||
Text(
|
Text(
|
||||||
text = formatDuration(track.durationSeconds),
|
text = formatDuration(track.durationSeconds),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted
|
color = secondaryColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,12 +199,20 @@ internal fun ReleaseTrackRow(
|
|||||||
onAddToPlaylist: () -> Unit
|
onAddToPlaylist: () -> Unit
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val isUnavailable = track.isUnavailableForPlayback()
|
||||||
|
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
|
||||||
|
val primaryColor = if (isUnavailable) {
|
||||||
|
FurumiTextMuted.copy(alpha = 0.72f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onBackground
|
||||||
|
}
|
||||||
|
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable { onTrackClick(track) }
|
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -200,20 +220,20 @@ internal fun ReleaseTrackRow(
|
|||||||
text = (track.trackNumber ?: fallbackIndex).toString(),
|
text = (track.trackNumber ?: fallbackIndex).toString(),
|
||||||
modifier = Modifier.width(32.dp),
|
modifier = Modifier.width(32.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted
|
color = secondaryColor
|
||||||
)
|
)
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = primaryColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = track.artists.joinToString(", ").ifBlank { "Unknown artist" },
|
text = track.artists.joinToString(", ").ifBlank { "Unknown artist" },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted,
|
color = secondaryColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -224,7 +244,8 @@ internal fun ReleaseTrackRow(
|
|||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable(onClick = onToggleLike)
|
.clickable(onClick = onToggleLike)
|
||||||
.padding(4.dp)
|
.padding(4.dp),
|
||||||
|
alpha = contentAlpha
|
||||||
)
|
)
|
||||||
Box {
|
Box {
|
||||||
MoreDotsGlyph(
|
MoreDotsGlyph(
|
||||||
@@ -232,7 +253,8 @@ internal fun ReleaseTrackRow(
|
|||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable { menuExpanded = true }
|
.clickable { menuExpanded = true }
|
||||||
.padding(4.dp)
|
.padding(4.dp),
|
||||||
|
color = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||||
)
|
)
|
||||||
TrackContextMenu(
|
TrackContextMenu(
|
||||||
expanded = menuExpanded,
|
expanded = menuExpanded,
|
||||||
@@ -249,7 +271,7 @@ internal fun ReleaseTrackRow(
|
|||||||
Text(
|
Text(
|
||||||
text = formatDuration(track.durationSeconds),
|
text = formatDuration(track.durationSeconds),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted
|
color = secondaryColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +291,14 @@ internal fun PlaylistTrackRow(
|
|||||||
onAddToPlaylist: () -> Unit
|
onAddToPlaylist: () -> Unit
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val isUnavailable = track.isUnavailableForPlayback()
|
||||||
|
val contentAlpha = if (isUnavailable) UNAVAILABLE_TRACK_ALPHA else 1f
|
||||||
|
val primaryColor = if (isUnavailable) {
|
||||||
|
FurumiTextMuted.copy(alpha = 0.72f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onBackground
|
||||||
|
}
|
||||||
|
val secondaryColor = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||||
|
|
||||||
LaunchedEffect(track.coverUrl) {
|
LaunchedEffect(track.coverUrl) {
|
||||||
onMediaImageNeeded(track.coverUrl)
|
onMediaImageNeeded(track.coverUrl)
|
||||||
@@ -278,7 +308,7 @@ internal fun PlaylistTrackRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable { onTrackClick(track) }
|
.clickable(enabled = !isUnavailable) { onTrackClick(track) }
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -286,13 +316,15 @@ internal fun PlaylistTrackRow(
|
|||||||
text = index.toString(),
|
text = index.toString(),
|
||||||
modifier = Modifier.width(28.dp),
|
modifier = Modifier.width(28.dp),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted
|
color = secondaryColor
|
||||||
)
|
)
|
||||||
MediaArtwork(
|
MediaArtwork(
|
||||||
title = track.title,
|
title = track.title,
|
||||||
seedId = track.id,
|
seedId = track.id,
|
||||||
bitmap = bitmap,
|
bitmap = bitmap,
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.unavailableTrackArtworkOverlay(isUnavailable),
|
||||||
cornerRadius = 6
|
cornerRadius = 6
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
@@ -300,14 +332,14 @@ internal fun PlaylistTrackRow(
|
|||||||
Text(
|
Text(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = primaryColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = trackSubtitle(track),
|
text = trackSubtitle(track),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted,
|
color = secondaryColor,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -318,7 +350,8 @@ internal fun PlaylistTrackRow(
|
|||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable(onClick = onToggleLike)
|
.clickable(onClick = onToggleLike)
|
||||||
.padding(4.dp)
|
.padding(4.dp),
|
||||||
|
alpha = contentAlpha
|
||||||
)
|
)
|
||||||
Box {
|
Box {
|
||||||
MoreDotsGlyph(
|
MoreDotsGlyph(
|
||||||
@@ -326,7 +359,8 @@ internal fun PlaylistTrackRow(
|
|||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable { menuExpanded = true }
|
.clickable { menuExpanded = true }
|
||||||
.padding(4.dp)
|
.padding(4.dp),
|
||||||
|
color = FurumiTextMuted.copy(alpha = contentAlpha)
|
||||||
)
|
)
|
||||||
TrackContextMenu(
|
TrackContextMenu(
|
||||||
expanded = menuExpanded,
|
expanded = menuExpanded,
|
||||||
@@ -343,7 +377,7 @@ internal fun PlaylistTrackRow(
|
|||||||
Text(
|
Text(
|
||||||
text = formatDuration(track.durationSeconds),
|
text = formatDuration(track.durationSeconds),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = FurumiTextMuted
|
color = secondaryColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,3 +530,17 @@ internal fun QueueTrackRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun TrackCard.isUnavailableForPlayback(): Boolean {
|
||||||
|
return streamUrl.isBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Modifier.unavailableTrackArtworkOverlay(isUnavailable: Boolean): Modifier {
|
||||||
|
if (!isUnavailable) return this
|
||||||
|
return drawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(color = Color.Black.copy(alpha = 0.44f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val UNAVAILABLE_TRACK_ALPHA = 0.42f
|
||||||
|
|||||||
@@ -151,26 +151,11 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val profileScrollConnection = remember {
|
|
||||||
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
|
||||||
override fun onPreScroll(
|
|
||||||
available: Offset,
|
|
||||||
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
|
||||||
): Offset {
|
|
||||||
if (isProfileMenuOpen && source == androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag) {
|
|
||||||
isProfileMenuOpen = false
|
|
||||||
}
|
|
||||||
return Offset.Zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.nestedScroll(profileScrollConnection)
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(
|
Box(
|
||||||
@@ -355,10 +340,24 @@ fun PlayerScreen(
|
|||||||
viewModel.openListeningHistory()
|
viewModel.openListeningHistory()
|
||||||
},
|
},
|
||||||
onDeviceClick = viewModel::setActiveDevice,
|
onDeviceClick = viewModel::setActiveDevice,
|
||||||
|
onOfflineModeChange = viewModel::setOfflineModeEnabled,
|
||||||
|
onSaveListenedChange = viewModel::setSaveListenedTracksEnabled,
|
||||||
|
onStorageLimitChange = viewModel::setOfflineStorageLimit,
|
||||||
|
onSyncOffline = viewModel::syncOfflineLibrary,
|
||||||
|
onClearOfflineCache = viewModel::clearOfflineCache,
|
||||||
onLogout = viewModel::logout,
|
onLogout = viewModel::logout,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopCenter)
|
||||||
.padding(top = 72.dp, end = 20.dp)
|
.padding(horizontal = 12.dp, vertical = 72.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.offlineState.isOfflineActive && !isProfileMenuOpen) {
|
||||||
|
OfflineStatusBadge(
|
||||||
|
offlineState = uiState.offlineState,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 10.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +474,7 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun PlayerUiState.displayedPlayback(): AudioPlaybackState {
|
private fun PlayerUiState.displayedPlayback(): AudioPlaybackState {
|
||||||
|
if (offlineState.isOfflineActive) return playback
|
||||||
val connectedState = connectedDevicesState
|
val connectedState = connectedDevicesState
|
||||||
val remotePlayback = connectedState?.remotePlaybackState
|
val remotePlayback = connectedState?.remotePlaybackState
|
||||||
return if (connectedState != null &&
|
return if (connectedState != null &&
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
|
|||||||
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
||||||
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
||||||
import cy.hexor.furumi.domain.model.ListeningHistoryItem
|
import cy.hexor.furumi.domain.model.ListeningHistoryItem
|
||||||
|
import cy.hexor.furumi.domain.model.OfflineState
|
||||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||||
import cy.hexor.furumi.domain.model.PlaylistDetail
|
import cy.hexor.furumi.domain.model.PlaylistDetail
|
||||||
import cy.hexor.furumi.domain.model.ReleaseCard
|
import cy.hexor.furumi.domain.model.ReleaseCard
|
||||||
@@ -92,7 +93,8 @@ data class PlayerUiState(
|
|||||||
val connectedDevicesError: String? = null,
|
val connectedDevicesError: String? = null,
|
||||||
val jamInviteQuery: String = "",
|
val jamInviteQuery: String = "",
|
||||||
val jamInviteUsers: List<ConnectedJamUser> = emptyList(),
|
val jamInviteUsers: List<ConnectedJamUser> = emptyList(),
|
||||||
val isJamInviteSearchLoading: Boolean = false
|
val isJamInviteSearchLoading: Boolean = false,
|
||||||
|
val offlineState: OfflineState = OfflineState()
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -109,11 +111,13 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private val playlistDetailCache = mutableMapOf<Long, PlaylistDetail>()
|
private val playlistDetailCache = mutableMapOf<Long, PlaylistDetail>()
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
private var jamInviteSearchJob: Job? = null
|
private var jamInviteSearchJob: Job? = null
|
||||||
|
private var offlineModeRefreshJob: Job? = null
|
||||||
private val handledConnectedCommandIds = ArrayDeque<String>()
|
private val handledConnectedCommandIds = ArrayDeque<String>()
|
||||||
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
||||||
private var wasCurrentDeviceActive = false
|
private var wasCurrentDeviceActive = false
|
||||||
private var wasControllingRemoteJam = false
|
private var wasControllingRemoteJam = false
|
||||||
private var hasResolvedStartupActiveDevice = false
|
private var hasResolvedStartupActiveDevice = false
|
||||||
|
private var shouldClaimActiveDeviceOnReconnect = false
|
||||||
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
||||||
|
|
||||||
@@ -133,6 +137,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadGlobalArtistsIfNeeded()
|
loadGlobalArtistsIfNeeded()
|
||||||
|
observeOfflineState()
|
||||||
observePlayback()
|
observePlayback()
|
||||||
loadLikedTrackIds()
|
loadLikedTrackIds()
|
||||||
startConnectedDevicesPolling()
|
startConnectedDevicesPolling()
|
||||||
@@ -481,6 +486,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setActiveDevice(deviceId: String) {
|
fun setActiveDevice(deviceId: String) {
|
||||||
|
if (_uiState.value.offlineState.isOfflineActive) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerRepository.setActiveDevice(deviceId)
|
playerRepository.setActiveDevice(deviceId)
|
||||||
.onSuccess { devicesState ->
|
.onSuccess { devicesState ->
|
||||||
@@ -494,6 +500,49 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setOfflineModeEnabled(enabled: Boolean) {
|
||||||
|
playerRepository.setOfflineModeEnabled(enabled)
|
||||||
|
if (enabled) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
connectedDevicesState = null,
|
||||||
|
connectedDevicesError = null
|
||||||
|
)
|
||||||
|
syncOfflineLibrary()
|
||||||
|
} else {
|
||||||
|
shouldClaimActiveDeviceOnReconnect = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSaveListenedTracksEnabled(enabled: Boolean) {
|
||||||
|
playerRepository.setSaveListenedTracksEnabled(enabled)
|
||||||
|
if (enabled) {
|
||||||
|
syncOfflineLibrary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOfflineStorageLimit(limitBytes: Long) {
|
||||||
|
playerRepository.setOfflineStorageLimit(limitBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncOfflineLibrary() {
|
||||||
|
if (_uiState.value.offlineState.isSyncing) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
playerRepository.syncOfflineLibrary()
|
||||||
|
.onSuccess {
|
||||||
|
refreshAfterOfflineLibraryChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearOfflineCache() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
playerRepository.clearOfflineCache()
|
||||||
|
.onSuccess {
|
||||||
|
refreshAfterOfflineLibraryChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createJam() {
|
fun createJam() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerRepository.createJam()
|
playerRepository.createJam()
|
||||||
@@ -983,6 +1032,35 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeOfflineState() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
playerRepository.offlineState.collect { offlineState ->
|
||||||
|
val current = _uiState.value
|
||||||
|
val offlineModeChanged = current.offlineState.isOfflineActive != offlineState.isOfflineActive
|
||||||
|
if (current.offlineState.isOfflineActive && !offlineState.isOfflineActive) {
|
||||||
|
shouldClaimActiveDeviceOnReconnect = true
|
||||||
|
}
|
||||||
|
_uiState.value = current.copy(
|
||||||
|
offlineState = offlineState,
|
||||||
|
connectedDevicesState = if (offlineState.settings.offlineModeEnabled) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
current.connectedDevicesState
|
||||||
|
},
|
||||||
|
connectedDevicesError = if (offlineState.settings.offlineModeEnabled) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
current.connectedDevicesError
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (offlineModeChanged) {
|
||||||
|
clearModeSensitiveCaches()
|
||||||
|
refreshVisibleContentForOfflineMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startConnectedDevicesPolling() {
|
private fun startConnectedDevicesPolling() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -994,6 +1072,9 @@ class PlayerViewModel @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun pollConnectedDevices() {
|
private suspend fun pollConnectedDevices() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
|
if (state.offlineState.settings.offlineModeEnabled) return
|
||||||
|
val shouldClaimActiveDevice = shouldClaimActiveDeviceOnReconnect ||
|
||||||
|
state.offlineState.isUsingNetworkFallback
|
||||||
val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true
|
val isCurrentDeviceActive = state.connectedDevicesState?.isCurrentDeviceActive == true
|
||||||
|
|
||||||
// Android follows the spec: only the active device sends its playback state.
|
// Android follows the spec: only the active device sends its playback state.
|
||||||
@@ -1008,12 +1089,21 @@ class PlayerViewModel @Inject constructor(
|
|||||||
playbackState = playbackState,
|
playbackState = playbackState,
|
||||||
currentJamId = state.connectedDevicesState?.currentJamId
|
currentJamId = state.connectedDevicesState?.currentJamId
|
||||||
).onSuccess { (devicesState, commands) ->
|
).onSuccess { (devicesState, commands) ->
|
||||||
val resolvedDevicesState = resolveStartupActiveDevice(devicesState)
|
val resolvedDevicesState = if (shouldClaimActiveDevice) {
|
||||||
applyConnectedDevicesState(resolvedDevicesState)
|
claimCurrentDeviceActive(devicesState)
|
||||||
|
} else {
|
||||||
|
resolveStartupActiveDevice(devicesState)
|
||||||
|
}
|
||||||
|
applyConnectedDevicesState(
|
||||||
|
devicesState = resolvedDevicesState,
|
||||||
|
pauseOnLostControl = !shouldClaimActiveDevice
|
||||||
|
)
|
||||||
|
|
||||||
commands.forEach { command ->
|
if (!shouldClaimActiveDevice) {
|
||||||
if (shouldHandleConnectedCommand(command)) {
|
commands.forEach { command ->
|
||||||
handleConnectedCommand(command)
|
if (shouldHandleConnectedCommand(command)) {
|
||||||
|
handleConnectedCommand(command)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
@@ -1037,7 +1127,51 @@ class PlayerViewModel @Inject constructor(
|
|||||||
.getOrElse { devicesState }
|
.getOrElse { devicesState }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyConnectedDevicesState(devicesState: ConnectedDevicesState) {
|
private suspend fun claimCurrentDeviceActive(devicesState: ConnectedDevicesState): ConnectedDevicesState {
|
||||||
|
val activeDevicesState = if (devicesState.isCurrentDeviceActive) {
|
||||||
|
devicesState
|
||||||
|
} else {
|
||||||
|
playerRepository.setActiveDevice(devicesState.deviceId)
|
||||||
|
.onFailure { error ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
connectedDevicesError = error.message ?: "Unable to switch active device"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.getOrElse { return devicesState }
|
||||||
|
}
|
||||||
|
|
||||||
|
val (syncedDevicesState, didSync) = pushLocalPlaybackStateAfterReconnect(activeDevicesState)
|
||||||
|
if (didSync) {
|
||||||
|
shouldClaimActiveDeviceOnReconnect = false
|
||||||
|
}
|
||||||
|
return syncedDevicesState
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pushLocalPlaybackStateAfterReconnect(
|
||||||
|
devicesState: ConnectedDevicesState
|
||||||
|
): Pair<ConnectedDevicesState, Boolean> {
|
||||||
|
val playbackState = _uiState.value.playback.toConnectedPlaybackState()
|
||||||
|
?: return devicesState to true
|
||||||
|
|
||||||
|
return playerRepository.pollConnectedDevice(
|
||||||
|
playbackState = playbackState,
|
||||||
|
currentJamId = devicesState.currentJamId
|
||||||
|
)
|
||||||
|
.onFailure { error ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
connectedDevicesError = error.message ?: "Unable to sync playback state"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fold(
|
||||||
|
onSuccess = { (updatedDevicesState, _) -> updatedDevicesState to true },
|
||||||
|
onFailure = { devicesState to false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyConnectedDevicesState(
|
||||||
|
devicesState: ConnectedDevicesState,
|
||||||
|
pauseOnLostControl: Boolean = true
|
||||||
|
) {
|
||||||
val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive
|
val becameInactive = wasCurrentDeviceActive && !devicesState.isCurrentDeviceActive
|
||||||
val isControllingRemoteJam = devicesState.isControllingRemoteJam()
|
val isControllingRemoteJam = devicesState.isControllingRemoteJam()
|
||||||
val startedControllingRemoteJam = !wasControllingRemoteJam && isControllingRemoteJam
|
val startedControllingRemoteJam = !wasControllingRemoteJam && isControllingRemoteJam
|
||||||
@@ -1047,7 +1181,10 @@ class PlayerViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
wasCurrentDeviceActive = devicesState.isCurrentDeviceActive
|
wasCurrentDeviceActive = devicesState.isCurrentDeviceActive
|
||||||
wasControllingRemoteJam = isControllingRemoteJam
|
wasControllingRemoteJam = isControllingRemoteJam
|
||||||
if ((becameInactive || startedControllingRemoteJam) && _uiState.value.playback.currentTrack != null) {
|
if (pauseOnLostControl &&
|
||||||
|
(becameInactive || startedControllingRemoteJam) &&
|
||||||
|
_uiState.value.playback.currentTrack != null
|
||||||
|
) {
|
||||||
playbackController.pause()
|
playbackController.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1259,8 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun activeRemoteDeviceId(): String? {
|
private fun activeRemoteDeviceId(): String? {
|
||||||
|
if (_uiState.value.offlineState.isOfflineActive) return null
|
||||||
|
if (shouldClaimActiveDeviceOnReconnect) return null
|
||||||
val devicesState = _uiState.value.connectedDevicesState ?: return null
|
val devicesState = _uiState.value.connectedDevicesState ?: return null
|
||||||
if (devicesState.isControllingRemoteJam()) {
|
if (devicesState.isControllingRemoteJam()) {
|
||||||
return devicesState.activeDeviceId ?: devicesState.deviceId
|
return devicesState.activeDeviceId ?: devicesState.deviceId
|
||||||
@@ -1405,6 +1544,54 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearModeSensitiveCaches() {
|
||||||
|
artistDetailCache.clear()
|
||||||
|
releaseDetailCache.clear()
|
||||||
|
playlistDetailCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshVisibleContentForOfflineMode() {
|
||||||
|
offlineModeRefreshJob?.cancel()
|
||||||
|
offlineModeRefreshJob = viewModelScope.launch {
|
||||||
|
val state = _uiState.value
|
||||||
|
when {
|
||||||
|
state.selectedPlaylist != null -> {
|
||||||
|
loadPlaylistDetail(state.selectedPlaylist.id)
|
||||||
|
}
|
||||||
|
state.selectedRelease != null -> {
|
||||||
|
loadReleaseDetail(state.selectedRelease.id)
|
||||||
|
}
|
||||||
|
state.selectedArtist != null -> {
|
||||||
|
loadArtistDetail(state.selectedArtist.id)
|
||||||
|
}
|
||||||
|
state.searchQuery.isNotBlank() && state.searchResults != null -> {
|
||||||
|
searchJob?.cancel()
|
||||||
|
search(state.searchQuery)
|
||||||
|
}
|
||||||
|
state.playlists.isNotEmpty() -> {
|
||||||
|
loadPlaylists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAfterOfflineLibraryChange() {
|
||||||
|
clearModeSensitiveCaches()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
playlists = emptyList(),
|
||||||
|
playlistPreviewCoverUrls = emptyMap(),
|
||||||
|
loadingPlaylistPreviewIds = emptySet()
|
||||||
|
)
|
||||||
|
loadLikedTrackIds()
|
||||||
|
if (_uiState.value.selectedPlaylist != null) {
|
||||||
|
_uiState.value.selectedPlaylist?.id?.let(::loadPlaylistDetail)
|
||||||
|
} else {
|
||||||
|
loadPlaylists()
|
||||||
|
}
|
||||||
|
_uiState.value.selectedArtist?.id?.let(::loadArtistDetail)
|
||||||
|
_uiState.value.selectedRelease?.id?.let(::loadReleaseDetail)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val ARTIST_PAGE_SIZE = 60
|
const val ARTIST_PAGE_SIZE = 60
|
||||||
const val SEARCH_DEBOUNCE_MS = 300L
|
const val SEARCH_DEBOUNCE_MS = 300L
|
||||||
|
|||||||
+5
-5
@@ -28,14 +28,14 @@ RepoType: git
|
|||||||
Repo: https://gt.hexor.cy/ab/furumi_android.git
|
Repo: https://gt.hexor.cy/ab/furumi_android.git
|
||||||
|
|
||||||
Builds:
|
Builds:
|
||||||
- versionName: '1.2'
|
- versionName: '1.3'
|
||||||
versionCode: 2
|
versionCode: 3
|
||||||
commit: v1.2
|
commit: v1.3
|
||||||
subdir: app
|
subdir: app
|
||||||
gradle:
|
gradle:
|
||||||
- yes
|
- yes
|
||||||
|
|
||||||
AutoUpdateMode: Version v%v
|
AutoUpdateMode: Version v%v
|
||||||
UpdateCheckMode: Tags
|
UpdateCheckMode: Tags
|
||||||
CurrentVersion: '1.2'
|
CurrentVersion: '1.3'
|
||||||
CurrentVersionCode: 2
|
CurrentVersionCode: 3
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Offline mode with local library sync and cached playback, faster offline switching, and playback reliability fixes.
|
||||||
+2
-2
@@ -20,5 +20,5 @@ kotlin.code.style=official
|
|||||||
android.disallowKotlinSourceSets=false
|
android.disallowKotlinSourceSets=false
|
||||||
|
|
||||||
# App version used by Gradle for the APK/AAB manifest and BuildConfig.
|
# App version used by Gradle for the APK/AAB manifest and BuildConfig.
|
||||||
furumi.versionCode=2
|
furumi.versionCode=3
|
||||||
furumi.versionName=1.2
|
furumi.versionName=1.3
|
||||||
|
|||||||
Reference in New Issue
Block a user