Added offline mode and manual download option
This commit is contained in:
@@ -385,6 +385,18 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
putNull(COL_LOCAL_AUDIO_PATH)
|
putNull(COL_LOCAL_AUDIO_PATH)
|
||||||
put(COL_AUDIO_STATUS, STATUS_NONE)
|
put(COL_AUDIO_STATUS, STATUS_NONE)
|
||||||
put(COL_CACHED_AUDIO_BYTES, 0L)
|
put(COL_CACHED_AUDIO_BYTES, 0L)
|
||||||
|
put(COL_AUDIO_PINNED, 0)
|
||||||
|
},
|
||||||
|
"$COL_TRACK_ID = ?",
|
||||||
|
arrayOf(trackId.toString())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAudioPinned(trackId: Long, pinned: Boolean) {
|
||||||
|
dbHelper.writableDatabase.update(
|
||||||
|
TABLE_TRACKS,
|
||||||
|
ContentValues().apply {
|
||||||
|
put(COL_AUDIO_PINNED, pinned.toInt())
|
||||||
},
|
},
|
||||||
"$COL_TRACK_ID = ?",
|
"$COL_TRACK_ID = ?",
|
||||||
arrayOf(trackId.toString())
|
arrayOf(trackId.toString())
|
||||||
@@ -411,6 +423,7 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
putNull(COL_LOCAL_AUDIO_PATH)
|
putNull(COL_LOCAL_AUDIO_PATH)
|
||||||
put(COL_AUDIO_STATUS, STATUS_NONE)
|
put(COL_AUDIO_STATUS, STATUS_NONE)
|
||||||
put(COL_CACHED_AUDIO_BYTES, 0L)
|
put(COL_CACHED_AUDIO_BYTES, 0L)
|
||||||
|
put(COL_AUDIO_PINNED, 0)
|
||||||
putNull(COL_LOCAL_COVER_PATH)
|
putNull(COL_LOCAL_COVER_PATH)
|
||||||
put(COL_COVER_STATUS, STATUS_NONE)
|
put(COL_COVER_STATUS, STATUS_NONE)
|
||||||
put(COL_CACHED_COVER_BYTES, 0L)
|
put(COL_CACHED_COVER_BYTES, 0L)
|
||||||
@@ -430,7 +443,7 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
COL_LAST_ACCESSED_AT,
|
COL_LAST_ACCESSED_AT,
|
||||||
COL_PLAY_COUNT
|
COL_PLAY_COUNT
|
||||||
),
|
),
|
||||||
"$COL_AUDIO_STATUS = ? AND $COL_LOCAL_AUDIO_PATH IS NOT NULL",
|
"$COL_AUDIO_STATUS = ? AND $COL_LOCAL_AUDIO_PATH IS NOT NULL AND $COL_AUDIO_PINNED = 0",
|
||||||
arrayOf(STATUS_READY),
|
arrayOf(STATUS_READY),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -550,6 +563,7 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
put(COL_CACHED_COVER_BYTES, 0L)
|
put(COL_CACHED_COVER_BYTES, 0L)
|
||||||
put(COL_LAST_ACCESSED_AT, 0L)
|
put(COL_LAST_ACCESSED_AT, 0L)
|
||||||
put(COL_PLAY_COUNT, 0)
|
put(COL_PLAY_COUNT, 0)
|
||||||
|
put(COL_AUDIO_PINNED, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, track.id, values)
|
db.insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, track.id, values)
|
||||||
@@ -684,13 +698,15 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
$COL_CACHED_AUDIO_BYTES INTEGER NOT NULL DEFAULT 0,
|
$COL_CACHED_AUDIO_BYTES INTEGER NOT NULL DEFAULT 0,
|
||||||
$COL_CACHED_COVER_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_LAST_ACCESSED_AT INTEGER NOT NULL DEFAULT 0,
|
||||||
$COL_PLAY_COUNT INTEGER NOT NULL DEFAULT 0
|
$COL_PLAY_COUNT INTEGER NOT NULL DEFAULT 0,
|
||||||
|
$COL_AUDIO_PINNED INTEGER NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
db.execSQL("CREATE INDEX idx_offline_tracks_title ON $TABLE_TRACKS($COL_TITLE_SORT)")
|
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_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 INDEX idx_offline_tracks_audio_status ON $TABLE_TRACKS($COL_AUDIO_STATUS)")
|
||||||
|
db.execSQL("CREATE INDEX idx_offline_tracks_audio_pinned ON $TABLE_TRACKS($COL_AUDIO_PINNED)")
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE $TABLE_PLAYLISTS (
|
CREATE TABLE $TABLE_PLAYLISTS (
|
||||||
@@ -717,16 +733,16 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_LIKES")
|
if (oldVersion < 2) {
|
||||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_PLAYLISTS")
|
db.execSQL("ALTER TABLE $TABLE_TRACKS ADD COLUMN $COL_AUDIO_PINNED INTEGER NOT NULL DEFAULT 0")
|
||||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_TRACKS")
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_offline_tracks_audio_pinned ON $TABLE_TRACKS($COL_AUDIO_PINNED)")
|
||||||
onCreate(db)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val DB_NAME = "furumi_offline.db"
|
const val DB_NAME = "furumi_offline.db"
|
||||||
const val DB_VERSION = 1
|
const val DB_VERSION = 2
|
||||||
|
|
||||||
const val TABLE_TRACKS = "offline_tracks"
|
const val TABLE_TRACKS = "offline_tracks"
|
||||||
const val TABLE_PLAYLISTS = "offline_playlists"
|
const val TABLE_PLAYLISTS = "offline_playlists"
|
||||||
@@ -760,6 +776,7 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
const val COL_CACHED_COVER_BYTES = "cached_cover_bytes"
|
const val COL_CACHED_COVER_BYTES = "cached_cover_bytes"
|
||||||
const val COL_LAST_ACCESSED_AT = "last_accessed_at"
|
const val COL_LAST_ACCESSED_AT = "last_accessed_at"
|
||||||
const val COL_PLAY_COUNT = "play_count"
|
const val COL_PLAY_COUNT = "play_count"
|
||||||
|
const val COL_AUDIO_PINNED = "audio_pinned"
|
||||||
|
|
||||||
const val COL_PLAYLIST_ID = "playlist_id"
|
const val COL_PLAYLIST_ID = "playlist_id"
|
||||||
const val COL_PLAYLIST_TITLE = "title"
|
const val COL_PLAYLIST_TITLE = "title"
|
||||||
|
|||||||
@@ -36,14 +36,40 @@ class OfflineMediaStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cacheTrackMedia(track: TrackCard) = withContext(Dispatchers.IO) {
|
suspend fun cacheTrackMedia(track: TrackCard) = withContext(Dispatchers.IO) {
|
||||||
|
cacheTrackMediaBlocking(track = track, pinAudio = false, requireAudio = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadTrackForOffline(track: TrackCard) = withContext(Dispatchers.IO) {
|
||||||
|
ensureManualDownloadsCanFit(listOf(track))
|
||||||
|
cacheTrackMediaBlocking(track = track, pinAudio = true, requireAudio = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun downloadTracksForOffline(tracks: List<TrackCard>) = withContext(Dispatchers.IO) {
|
||||||
|
val uniqueTracks = tracks.distinctBy { it.id }
|
||||||
|
ensureManualDownloadsCanFit(uniqueTracks)
|
||||||
|
uniqueTracks.forEach { track ->
|
||||||
|
cacheTrackMediaBlocking(track = track, pinAudio = true, requireAudio = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun cacheTrackMediaBlocking(track: TrackCard, pinAudio: Boolean, requireAudio: Boolean) {
|
||||||
libraryStore.upsertTracks(listOf(track))
|
libraryStore.upsertTracks(listOf(track))
|
||||||
libraryStore.markTrackAccessed(track.id)
|
libraryStore.markTrackAccessed(track.id)
|
||||||
pruneStaleMedia()
|
pruneStaleMedia()
|
||||||
|
|
||||||
val storedTrack = libraryStore.getTrack(track.id, preferLocalMedia = false) ?: track
|
val storedTrack = libraryStore.getTrack(track.id, preferLocalMedia = false) ?: track
|
||||||
val audioPath = storedTrack.localAudioPath
|
val audioPath = storedTrack.localAudioPath
|
||||||
if (audioPath.isNullOrBlank() || !File(audioPath).exists()) {
|
val hadCachedAudio = !audioPath.isNullOrBlank() && File(audioPath).exists()
|
||||||
|
val hasAudio = if (!hadCachedAudio) {
|
||||||
downloadAudio(storedTrack)
|
downloadAudio(storedTrack)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
if (requireAudio && !hasAudio) {
|
||||||
|
throw IOException("Track has no downloadable audio source")
|
||||||
|
}
|
||||||
|
if (pinAudio && hasAudio) {
|
||||||
|
libraryStore.markAudioPinned(storedTrack.id, pinned = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val coverPath = storedTrack.localCoverPath
|
val coverPath = storedTrack.localCoverPath
|
||||||
@@ -54,6 +80,9 @@ class OfflineMediaStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
enforceStorageLimit()
|
enforceStorageLimit()
|
||||||
|
if (pinAudio) {
|
||||||
|
enforceManualStorageLimit(storedTrack.id, canRemoveAudio = !hadCachedAudio)
|
||||||
|
}
|
||||||
publishStorageStats()
|
publishStorageStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,9 +108,9 @@ class OfflineMediaStore @Inject constructor(
|
|||||||
settingsStorage.setStorageStats(stats.usedBytes, stats.cachedTrackCount)
|
settingsStorage.setStorageStats(stats.usedBytes, stats.cachedTrackCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadAudio(track: TrackCard) {
|
private fun downloadAudio(track: TrackCard): Boolean {
|
||||||
val sourceUrl = track.remoteStreamUrl.ifBlank { track.streamUrl }
|
val sourceUrl = track.remoteStreamUrl.ifBlank { track.streamUrl }
|
||||||
if (sourceUrl.isBlank()) return
|
if (sourceUrl.isBlank()) return false
|
||||||
|
|
||||||
val extension = extensionForMime(track.audioMimeType)
|
val extension = extensionForMime(track.audioMimeType)
|
||||||
?: extensionFromUrl(sourceUrl)
|
?: extensionFromUrl(sourceUrl)
|
||||||
@@ -89,6 +118,7 @@ class OfflineMediaStore @Inject constructor(
|
|||||||
val target = File(audioDir, "${track.id}-${track.audioHash ?: track.audioUpdatedAt ?: "audio"}.$extension")
|
val target = File(audioDir, "${track.id}-${track.audioHash ?: track.audioUpdatedAt ?: "audio"}.$extension")
|
||||||
val bytes = downloadToFile(sourceUrl, target)
|
val bytes = downloadToFile(sourceUrl, target)
|
||||||
libraryStore.markAudioCached(track.id, target.absolutePath, bytes)
|
libraryStore.markAudioCached(track.id, target.absolutePath, bytes)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadCover(track: TrackCard) {
|
private fun downloadCover(track: TrackCard) {
|
||||||
@@ -142,6 +172,39 @@ class OfflineMediaStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ensureManualDownloadsCanFit(tracks: List<TrackCard>) {
|
||||||
|
val limitBytes = settingsStorage.state.value.settings.storageLimitBytes
|
||||||
|
val stats = libraryStore.storageStats()
|
||||||
|
val evictableBytes = libraryStore.cachedAudioEntriesForEviction().sumOf { it.sizeBytes }
|
||||||
|
val protectedUsedBytes = (stats.usedBytes - evictableBytes).coerceAtLeast(0L)
|
||||||
|
val estimatedNeededBytes = tracks.sumOf { track ->
|
||||||
|
val storedTrack = libraryStore.getTrack(track.id, preferLocalMedia = false)
|
||||||
|
if (storedTrack?.isAudioCached == true) {
|
||||||
|
0L
|
||||||
|
} else {
|
||||||
|
track.audioSizeBytes ?: storedTrack?.audioSizeBytes ?: 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (estimatedNeededBytes > 0 && protectedUsedBytes + estimatedNeededBytes > limitBytes) {
|
||||||
|
throw IOException("Not enough offline storage limit for this download")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enforceManualStorageLimit(trackId: Long, canRemoveAudio: Boolean) {
|
||||||
|
val limitBytes = settingsStorage.state.value.settings.storageLimitBytes
|
||||||
|
enforceStorageLimit()
|
||||||
|
if (libraryStore.storageStats().usedBytes <= limitBytes) return
|
||||||
|
|
||||||
|
if (canRemoveAudio) {
|
||||||
|
libraryStore.getTrack(trackId, preferLocalMedia = false)
|
||||||
|
?.localAudioPath
|
||||||
|
?.let { path -> File(path).delete() }
|
||||||
|
libraryStore.markAudioRemoved(trackId)
|
||||||
|
}
|
||||||
|
throw IOException("Not enough offline storage limit for this download")
|
||||||
|
}
|
||||||
|
|
||||||
private fun extensionForMime(mimeType: String?): String? {
|
private fun extensionForMime(mimeType: String?): String? {
|
||||||
return when (mimeType?.lowercase()) {
|
return when (mimeType?.lowercase()) {
|
||||||
"audio/mpeg", "audio/mp3" -> "mp3"
|
"audio/mpeg", "audio/mp3" -> "mp3"
|
||||||
|
|||||||
@@ -145,6 +145,32 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadTrackForOffline(track: TrackCard): Result<TrackCard> {
|
||||||
|
return try {
|
||||||
|
offlineMediaStore.downloadTrackForOffline(track)
|
||||||
|
Result.success(offlineLibraryStore.getTrack(track.id, preferLocalMedia = true) ?: track)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
offlineSettingsStorage.setLastError(e.message ?: "Unable to download track")
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadTracksForOffline(tracks: List<TrackCard>): Result<List<TrackCard>> {
|
||||||
|
return try {
|
||||||
|
offlineMediaStore.downloadTracksForOffline(tracks)
|
||||||
|
Result.success(tracks.map { track ->
|
||||||
|
offlineLibraryStore.getTrack(track.id, preferLocalMedia = true) ?: track
|
||||||
|
})
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
offlineSettingsStorage.setLastError(e.message ?: "Unable to download tracks")
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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()) {
|
if (isOfflineModeEnabled()) {
|
||||||
return Result.success(offlineLibraryStore.getArtists(page, limit))
|
return Result.success(offlineLibraryStore.getArtists(page, limit))
|
||||||
|
|||||||
@@ -96,5 +96,9 @@ interface PlayerRepository {
|
|||||||
|
|
||||||
suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary>
|
suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary>
|
||||||
|
|
||||||
|
suspend fun downloadTrackForOffline(track: TrackCard): Result<TrackCard>
|
||||||
|
|
||||||
|
suspend fun downloadTracksForOffline(tracks: List<TrackCard>): Result<List<TrackCard>>
|
||||||
|
|
||||||
suspend fun clearOfflineCache(): Result<Unit>
|
suspend fun clearOfflineCache(): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ internal fun ArtistDetailContent(
|
|||||||
onPlayNext: (TrackCard) -> Unit,
|
onPlayNext: (TrackCard) -> Unit,
|
||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit
|
||||||
) {
|
) {
|
||||||
val artist = uiState.artistDetail?.artist ?: uiState.selectedArtist ?: return
|
val artist = uiState.artistDetail?.artist ?: uiState.selectedArtist ?: return
|
||||||
val artistImage = mediaImageFor(uiState.mediaImages, artist.imageUrl)
|
val artistImage = mediaImageFor(uiState.mediaImages, artist.imageUrl)
|
||||||
@@ -275,6 +276,7 @@ internal fun ArtistDetailContent(
|
|||||||
val detail = uiState.artistDetail
|
val detail = uiState.artistDetail
|
||||||
if (detail.topTracks.isNotEmpty()) {
|
if (detail.topTracks.isNotEmpty()) {
|
||||||
PopularTracksSection(
|
PopularTracksSection(
|
||||||
|
uiState = uiState,
|
||||||
tracks = detail.topTracks,
|
tracks = detail.topTracks,
|
||||||
mediaImages = uiState.mediaImages,
|
mediaImages = uiState.mediaImages,
|
||||||
likedTrackIds = uiState.likedTrackIds,
|
likedTrackIds = uiState.likedTrackIds,
|
||||||
@@ -284,7 +286,8 @@ internal fun ArtistDetailContent(
|
|||||||
onPlayNext = onPlayNext,
|
onPlayNext = onPlayNext,
|
||||||
onPlayLast = onPlayLast,
|
onPlayLast = onPlayLast,
|
||||||
onShare = onShare,
|
onShare = onShare,
|
||||||
onAddToPlaylist = onAddToPlaylist
|
onAddToPlaylist = onAddToPlaylist,
|
||||||
|
onDownloadForOffline = onDownloadForOffline
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(28.dp))
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
}
|
}
|
||||||
@@ -405,6 +408,7 @@ internal fun ArtistDetailError(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PopularTracksSection(
|
internal fun PopularTracksSection(
|
||||||
|
uiState: PlayerUiState,
|
||||||
tracks: List<TrackCard>,
|
tracks: List<TrackCard>,
|
||||||
mediaImages: Map<String, Bitmap>,
|
mediaImages: Map<String, Bitmap>,
|
||||||
likedTrackIds: Set<Long>,
|
likedTrackIds: Set<Long>,
|
||||||
@@ -414,7 +418,8 @@ internal fun PopularTracksSection(
|
|||||||
onPlayNext: (TrackCard) -> Unit,
|
onPlayNext: (TrackCard) -> Unit,
|
||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit
|
||||||
) {
|
) {
|
||||||
SectionTitle("Popular")
|
SectionTitle("Popular")
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -425,13 +430,17 @@ internal fun PopularTracksSection(
|
|||||||
track = track,
|
track = track,
|
||||||
bitmap = mediaImageFor(mediaImages, track.coverUrl),
|
bitmap = mediaImageFor(mediaImages, track.coverUrl),
|
||||||
isLiked = likedTrackIds.contains(track.id),
|
isLiked = likedTrackIds.contains(track.id),
|
||||||
|
isDownloadedForOffline = uiState.isDownloadedForOffline(track),
|
||||||
|
isDownloadingForOffline = uiState.isDownloadingForOffline(track),
|
||||||
|
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||||
onMediaImageNeeded = onMediaImageNeeded,
|
onMediaImageNeeded = onMediaImageNeeded,
|
||||||
onTrackClick = onTrackClick,
|
onTrackClick = onTrackClick,
|
||||||
onToggleLike = { onToggleLike(track.id) },
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
onPlayNext = { onPlayNext(track) },
|
onPlayNext = { onPlayNext(track) },
|
||||||
onPlayLast = { onPlayLast(track) },
|
onPlayLast = { onPlayLast(track) },
|
||||||
onShare = { onShare(track) },
|
onShare = { onShare(track) },
|
||||||
onAddToPlaylist = { onAddToPlaylist(track) }
|
onAddToPlaylist = { onAddToPlaylist(track) },
|
||||||
|
onDownloadForOffline = { onDownloadForOffline(track) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ internal fun SearchContent(
|
|||||||
onPlayNext: (TrackCard) -> Unit,
|
onPlayNext: (TrackCard) -> Unit,
|
||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit
|
||||||
) {
|
) {
|
||||||
val query = uiState.searchQuery
|
val query = uiState.searchQuery
|
||||||
val results = uiState.searchResults
|
val results = uiState.searchResults
|
||||||
@@ -148,6 +149,7 @@ internal fun SearchContent(
|
|||||||
|
|
||||||
if (tracks.isNotEmpty()) {
|
if (tracks.isNotEmpty()) {
|
||||||
SearchTracksSection(
|
SearchTracksSection(
|
||||||
|
uiState = uiState,
|
||||||
tracks = tracks,
|
tracks = tracks,
|
||||||
mediaImages = uiState.mediaImages,
|
mediaImages = uiState.mediaImages,
|
||||||
likedTrackIds = uiState.likedTrackIds,
|
likedTrackIds = uiState.likedTrackIds,
|
||||||
@@ -157,7 +159,8 @@ internal fun SearchContent(
|
|||||||
onPlayNext = onPlayNext,
|
onPlayNext = onPlayNext,
|
||||||
onPlayLast = onPlayLast,
|
onPlayLast = onPlayLast,
|
||||||
onShare = onShare,
|
onShare = onShare,
|
||||||
onAddToPlaylist = onAddToPlaylist
|
onAddToPlaylist = onAddToPlaylist,
|
||||||
|
onDownloadForOffline = onDownloadForOffline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +240,7 @@ private fun SearchArtistCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchTracksSection(
|
private fun SearchTracksSection(
|
||||||
|
uiState: PlayerUiState,
|
||||||
tracks: List<TrackCard>,
|
tracks: List<TrackCard>,
|
||||||
mediaImages: Map<String, Bitmap>,
|
mediaImages: Map<String, Bitmap>,
|
||||||
likedTrackIds: Set<Long>,
|
likedTrackIds: Set<Long>,
|
||||||
@@ -246,7 +250,8 @@ private fun SearchTracksSection(
|
|||||||
onPlayNext: (TrackCard) -> Unit,
|
onPlayNext: (TrackCard) -> Unit,
|
||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit
|
||||||
) {
|
) {
|
||||||
SectionTitle("Tracks")
|
SectionTitle("Tracks")
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -257,13 +262,17 @@ private fun SearchTracksSection(
|
|||||||
track = track,
|
track = track,
|
||||||
bitmap = mediaImageFor(mediaImages, track.coverUrl),
|
bitmap = mediaImageFor(mediaImages, track.coverUrl),
|
||||||
isLiked = likedTrackIds.contains(track.id),
|
isLiked = likedTrackIds.contains(track.id),
|
||||||
|
isDownloadedForOffline = uiState.isDownloadedForOffline(track),
|
||||||
|
isDownloadingForOffline = uiState.isDownloadingForOffline(track),
|
||||||
|
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||||
onMediaImageNeeded = onMediaImageNeeded,
|
onMediaImageNeeded = onMediaImageNeeded,
|
||||||
onTrackClick = onTrackClick,
|
onTrackClick = onTrackClick,
|
||||||
onToggleLike = { onToggleLike(track.id) },
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
onPlayNext = { onPlayNext(track) },
|
onPlayNext = { onPlayNext(track) },
|
||||||
onPlayLast = { onPlayLast(track) },
|
onPlayLast = { onPlayLast(track) },
|
||||||
onShare = { onShare(track) },
|
onShare = { onShare(track) },
|
||||||
onAddToPlaylist = { onAddToPlaylist(track) }
|
onAddToPlaylist = { onAddToPlaylist(track) },
|
||||||
|
onDownloadForOffline = { onDownloadForOffline(track) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,10 +641,14 @@ internal fun PlaylistDetailContent(
|
|||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit,
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit,
|
||||||
|
onDownloadPlaylistClick: () -> Unit,
|
||||||
onDeletePlaylistClick: (PlaylistCard) -> Unit
|
onDeletePlaylistClick: (PlaylistCard) -> Unit
|
||||||
) {
|
) {
|
||||||
val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return
|
val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return
|
||||||
val tracks = uiState.playlistDetail?.tracks.orEmpty()
|
val tracks = uiState.playlistDetail?.tracks.orEmpty()
|
||||||
|
val isPlaylistDownloading = uiState.offlineDownloadingPlaylistIds.contains(playlist.id)
|
||||||
|
val isPlaylistDownloaded = tracks.isNotEmpty() && tracks.all(uiState::isDownloadedForOffline)
|
||||||
val heroTrack = tracks.firstOrNull { !it.coverUrl.isNullOrBlank() }
|
val heroTrack = tracks.firstOrNull { !it.coverUrl.isNullOrBlank() }
|
||||||
val heroImage = mediaImageFor(uiState.mediaImages, heroTrack?.coverUrl)
|
val heroImage = mediaImageFor(uiState.mediaImages, heroTrack?.coverUrl)
|
||||||
|
|
||||||
@@ -725,6 +738,41 @@ internal fun PlaylistDetailContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDownloadPlaylistClick,
|
||||||
|
enabled = tracks.isNotEmpty() &&
|
||||||
|
!isPlaylistDownloaded &&
|
||||||
|
!isPlaylistDownloading &&
|
||||||
|
uiState.canStartOfflineDownload(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(44.dp),
|
||||||
|
shape = CircleShape,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiNeonPink.copy(alpha = 0.72f)),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = FurumiNeonPink,
|
||||||
|
disabledContentColor = FurumiTextMuted
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
isPlaylistDownloaded -> "Downloaded"
|
||||||
|
isPlaylistDownloading -> "Downloading..."
|
||||||
|
else -> "Download offline"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.offlineDownloadError?.let { error ->
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = FurumiHotOrange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (playlist.canDeleteFromDetail()) {
|
if (playlist.canDeleteFromDetail()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
@@ -767,13 +815,17 @@ internal fun PlaylistDetailContent(
|
|||||||
track = track,
|
track = track,
|
||||||
bitmap = mediaImageFor(uiState.mediaImages, track.coverUrl),
|
bitmap = mediaImageFor(uiState.mediaImages, track.coverUrl),
|
||||||
isLiked = uiState.likedTrackIds.contains(track.id),
|
isLiked = uiState.likedTrackIds.contains(track.id),
|
||||||
|
isDownloadedForOffline = uiState.isDownloadedForOffline(track),
|
||||||
|
isDownloadingForOffline = uiState.isDownloadingForOffline(track),
|
||||||
|
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||||
onMediaImageNeeded = onMediaImageNeeded,
|
onMediaImageNeeded = onMediaImageNeeded,
|
||||||
onTrackClick = onTrackClick,
|
onTrackClick = onTrackClick,
|
||||||
onToggleLike = { onToggleLike(track.id) },
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
onPlayNext = { onPlayNext(track) },
|
onPlayNext = { onPlayNext(track) },
|
||||||
onPlayLast = { onPlayLast(track) },
|
onPlayLast = { onPlayLast(track) },
|
||||||
onShare = { onShare(track) },
|
onShare = { onShare(track) },
|
||||||
onAddToPlaylist = { onAddToPlaylist(track) }
|
onAddToPlaylist = { onAddToPlaylist(track) },
|
||||||
|
onDownloadForOffline = { onDownloadForOffline(track) }
|
||||||
)
|
)
|
||||||
if (index < tracks.lastIndex) {
|
if (index < tracks.lastIndex) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|||||||
@@ -88,7 +88,11 @@ internal fun TrackContextMenu(
|
|||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
onPlayLast: () -> Unit,
|
onPlayLast: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
onAddToPlaylist: () -> Unit
|
onAddToPlaylist: () -> Unit,
|
||||||
|
isDownloadedForOffline: Boolean,
|
||||||
|
isDownloadingForOffline: Boolean,
|
||||||
|
canDownloadForOffline: Boolean,
|
||||||
|
onDownloadForOffline: () -> Unit
|
||||||
) {
|
) {
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
@@ -114,6 +118,19 @@ internal fun TrackContextMenu(
|
|||||||
text = { Text("Add to playlist") },
|
text = { Text("Add to playlist") },
|
||||||
onClick = onAddToPlaylist
|
onClick = onAddToPlaylist
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
isDownloadedForOffline -> "Downloaded"
|
||||||
|
isDownloadingForOffline -> "Downloading..."
|
||||||
|
else -> "Download offline"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = canDownloadForOffline && !isDownloadedForOffline && !isDownloadingForOffline,
|
||||||
|
onClick = onDownloadForOffline
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ internal fun FullPlayerOverlay(
|
|||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit,
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
offlineDownloadedTrackIds: Set<Long>,
|
||||||
|
offlineDownloadingTrackIds: Set<Long>,
|
||||||
|
canDownloadForOffline: Boolean,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit,
|
||||||
onArtistClick: (cy.hexor.furumi.domain.model.ArtistCard) -> Unit,
|
onArtistClick: (cy.hexor.furumi.domain.model.ArtistCard) -> Unit,
|
||||||
connectedDevicesState: ConnectedDevicesState?,
|
connectedDevicesState: ConnectedDevicesState?,
|
||||||
connectedDevicesError: String?,
|
connectedDevicesError: String?,
|
||||||
@@ -277,6 +281,13 @@ internal fun FullPlayerOverlay(
|
|||||||
onAddToPlaylist = {
|
onAddToPlaylist = {
|
||||||
currentTrackMenuExpanded = false
|
currentTrackMenuExpanded = false
|
||||||
onAddToPlaylist(track)
|
onAddToPlaylist(track)
|
||||||
|
},
|
||||||
|
isDownloadedForOffline = track.isAudioCached || offlineDownloadedTrackIds.contains(track.id),
|
||||||
|
isDownloadingForOffline = offlineDownloadingTrackIds.contains(track.id),
|
||||||
|
canDownloadForOffline = canDownloadForOffline,
|
||||||
|
onDownloadForOffline = {
|
||||||
|
currentTrackMenuExpanded = false
|
||||||
|
onDownloadForOffline(track)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -446,7 +457,11 @@ internal fun FullPlayerOverlay(
|
|||||||
onPlayNext = onPlayNext,
|
onPlayNext = onPlayNext,
|
||||||
onPlayLast = onPlayLast,
|
onPlayLast = onPlayLast,
|
||||||
onShare = onShare,
|
onShare = onShare,
|
||||||
onAddToPlaylist = onAddToPlaylist
|
onAddToPlaylist = onAddToPlaylist,
|
||||||
|
offlineDownloadedTrackIds = offlineDownloadedTrackIds,
|
||||||
|
offlineDownloadingTrackIds = offlineDownloadingTrackIds,
|
||||||
|
canDownloadForOffline = canDownloadForOffline,
|
||||||
|
onDownloadForOffline = onDownloadForOffline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +498,11 @@ private fun FullPlayerQueue(
|
|||||||
onPlayNext: (TrackCard) -> Unit,
|
onPlayNext: (TrackCard) -> Unit,
|
||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
offlineDownloadedTrackIds: Set<Long>,
|
||||||
|
offlineDownloadingTrackIds: Set<Long>,
|
||||||
|
canDownloadForOffline: Boolean,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit
|
||||||
) {
|
) {
|
||||||
if (queue.isEmpty()) {
|
if (queue.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -543,13 +562,17 @@ private fun FullPlayerQueue(
|
|||||||
isLiked = likedTrackIds.contains(queueTrack.id),
|
isLiked = likedTrackIds.contains(queueTrack.id),
|
||||||
isPlayed = isPlayed,
|
isPlayed = isPlayed,
|
||||||
isCurrent = isCurrent,
|
isCurrent = isCurrent,
|
||||||
|
isDownloadedForOffline = queueTrack.isAudioCached || offlineDownloadedTrackIds.contains(queueTrack.id),
|
||||||
|
isDownloadingForOffline = offlineDownloadingTrackIds.contains(queueTrack.id),
|
||||||
|
canDownloadForOffline = canDownloadForOffline,
|
||||||
onMediaImageNeeded = onMediaImageNeeded,
|
onMediaImageNeeded = onMediaImageNeeded,
|
||||||
onClick = { onQueueTrackClick(queueTrack, index) },
|
onClick = { onQueueTrackClick(queueTrack, index) },
|
||||||
onToggleLike = { onToggleLike(queueTrack.id) },
|
onToggleLike = { onToggleLike(queueTrack.id) },
|
||||||
onPlayNext = { onPlayNext(queueTrack) },
|
onPlayNext = { onPlayNext(queueTrack) },
|
||||||
onPlayLast = { onPlayLast(queueTrack) },
|
onPlayLast = { onPlayLast(queueTrack) },
|
||||||
onShare = { onShare(queueTrack) },
|
onShare = { onShare(queueTrack) },
|
||||||
onAddToPlaylist = { onAddToPlaylist(queueTrack) }
|
onAddToPlaylist = { onAddToPlaylist(queueTrack) },
|
||||||
|
onDownloadForOffline = { onDownloadForOffline(queueTrack) }
|
||||||
)
|
)
|
||||||
if (visibleIndex < visibleQueue.lastIndex) {
|
if (visibleIndex < visibleQueue.lastIndex) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ internal fun ReleaseDetailContent(
|
|||||||
onPlayNext: (TrackCard) -> Unit,
|
onPlayNext: (TrackCard) -> Unit,
|
||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit
|
||||||
) {
|
) {
|
||||||
val release = uiState.releaseDetail?.release ?: uiState.selectedRelease ?: return
|
val release = uiState.releaseDetail?.release ?: uiState.selectedRelease ?: return
|
||||||
val releaseImage = mediaImageFor(uiState.mediaImages, release.coverUrl)
|
val releaseImage = mediaImageFor(uiState.mediaImages, release.coverUrl)
|
||||||
@@ -141,6 +142,7 @@ internal fun ReleaseDetailContent(
|
|||||||
onRetry = onRetry
|
onRetry = onRetry
|
||||||
)
|
)
|
||||||
uiState.releaseDetail != null -> ReleaseTracksSection(
|
uiState.releaseDetail != null -> ReleaseTracksSection(
|
||||||
|
uiState = uiState,
|
||||||
tracks = uiState.releaseDetail.tracks,
|
tracks = uiState.releaseDetail.tracks,
|
||||||
likedTrackIds = uiState.likedTrackIds,
|
likedTrackIds = uiState.likedTrackIds,
|
||||||
onTrackClick = onTrackClick,
|
onTrackClick = onTrackClick,
|
||||||
@@ -148,7 +150,8 @@ internal fun ReleaseDetailContent(
|
|||||||
onPlayNext = onPlayNext,
|
onPlayNext = onPlayNext,
|
||||||
onPlayLast = onPlayLast,
|
onPlayLast = onPlayLast,
|
||||||
onShare = onShare,
|
onShare = onShare,
|
||||||
onAddToPlaylist = onAddToPlaylist
|
onAddToPlaylist = onAddToPlaylist,
|
||||||
|
onDownloadForOffline = onDownloadForOffline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +273,7 @@ internal fun ReleaseDetailError(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ReleaseTracksSection(
|
internal fun ReleaseTracksSection(
|
||||||
|
uiState: PlayerUiState,
|
||||||
tracks: List<TrackCard>,
|
tracks: List<TrackCard>,
|
||||||
likedTrackIds: Set<Long>,
|
likedTrackIds: Set<Long>,
|
||||||
onTrackClick: (TrackCard) -> Unit,
|
onTrackClick: (TrackCard) -> Unit,
|
||||||
@@ -277,7 +281,8 @@ internal fun ReleaseTracksSection(
|
|||||||
onPlayNext: (TrackCard) -> Unit,
|
onPlayNext: (TrackCard) -> Unit,
|
||||||
onPlayLast: (TrackCard) -> Unit,
|
onPlayLast: (TrackCard) -> Unit,
|
||||||
onShare: (TrackCard) -> Unit,
|
onShare: (TrackCard) -> Unit,
|
||||||
onAddToPlaylist: (TrackCard) -> Unit
|
onAddToPlaylist: (TrackCard) -> Unit,
|
||||||
|
onDownloadForOffline: (TrackCard) -> Unit
|
||||||
) {
|
) {
|
||||||
SectionTitle("Tracks")
|
SectionTitle("Tracks")
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -316,12 +321,16 @@ internal fun ReleaseTracksSection(
|
|||||||
fallbackIndex = index + 1,
|
fallbackIndex = index + 1,
|
||||||
track = track,
|
track = track,
|
||||||
isLiked = likedTrackIds.contains(track.id),
|
isLiked = likedTrackIds.contains(track.id),
|
||||||
|
isDownloadedForOffline = uiState.isDownloadedForOffline(track),
|
||||||
|
isDownloadingForOffline = uiState.isDownloadingForOffline(track),
|
||||||
|
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||||
onTrackClick = onTrackClick,
|
onTrackClick = onTrackClick,
|
||||||
onToggleLike = { onToggleLike(track.id) },
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
onPlayNext = { onPlayNext(track) },
|
onPlayNext = { onPlayNext(track) },
|
||||||
onPlayLast = { onPlayLast(track) },
|
onPlayLast = { onPlayLast(track) },
|
||||||
onShare = { onShare(track) },
|
onShare = { onShare(track) },
|
||||||
onAddToPlaylist = { onAddToPlaylist(track) }
|
onAddToPlaylist = { onAddToPlaylist(track) },
|
||||||
|
onDownloadForOffline = { onDownloadForOffline(track) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,13 +84,17 @@ internal fun ArtistDetailTrackRow(
|
|||||||
track: TrackCard,
|
track: TrackCard,
|
||||||
bitmap: Bitmap?,
|
bitmap: Bitmap?,
|
||||||
isLiked: Boolean,
|
isLiked: Boolean,
|
||||||
|
isDownloadedForOffline: Boolean,
|
||||||
|
isDownloadingForOffline: Boolean,
|
||||||
|
canDownloadForOffline: Boolean,
|
||||||
onMediaImageNeeded: (String?) -> Unit,
|
onMediaImageNeeded: (String?) -> Unit,
|
||||||
onTrackClick: (TrackCard) -> Unit,
|
onTrackClick: (TrackCard) -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
onPlayLast: () -> Unit,
|
onPlayLast: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
onAddToPlaylist: () -> Unit
|
onAddToPlaylist: () -> Unit,
|
||||||
|
onDownloadForOffline: () -> Unit
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
val isUnavailable = track.isUnavailableForPlayback()
|
val isUnavailable = track.isUnavailableForPlayback()
|
||||||
@@ -174,7 +178,11 @@ internal fun ArtistDetailTrackRow(
|
|||||||
onPlayNext = { menuExpanded = false; onPlayNext() },
|
onPlayNext = { menuExpanded = false; onPlayNext() },
|
||||||
onPlayLast = { menuExpanded = false; onPlayLast() },
|
onPlayLast = { menuExpanded = false; onPlayLast() },
|
||||||
onShare = { menuExpanded = false; onShare() },
|
onShare = { menuExpanded = false; onShare() },
|
||||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
|
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() },
|
||||||
|
isDownloadedForOffline = isDownloadedForOffline,
|
||||||
|
isDownloadingForOffline = isDownloadingForOffline,
|
||||||
|
canDownloadForOffline = canDownloadForOffline,
|
||||||
|
onDownloadForOffline = { menuExpanded = false; onDownloadForOffline() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
@@ -191,12 +199,16 @@ internal fun ReleaseTrackRow(
|
|||||||
fallbackIndex: Int,
|
fallbackIndex: Int,
|
||||||
track: TrackCard,
|
track: TrackCard,
|
||||||
isLiked: Boolean,
|
isLiked: Boolean,
|
||||||
|
isDownloadedForOffline: Boolean,
|
||||||
|
isDownloadingForOffline: Boolean,
|
||||||
|
canDownloadForOffline: Boolean,
|
||||||
onTrackClick: (TrackCard) -> Unit,
|
onTrackClick: (TrackCard) -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
onPlayLast: () -> Unit,
|
onPlayLast: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
onAddToPlaylist: () -> Unit
|
onAddToPlaylist: () -> Unit,
|
||||||
|
onDownloadForOffline: () -> Unit
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
val isUnavailable = track.isUnavailableForPlayback()
|
val isUnavailable = track.isUnavailableForPlayback()
|
||||||
@@ -264,7 +276,11 @@ internal fun ReleaseTrackRow(
|
|||||||
onPlayNext = { menuExpanded = false; onPlayNext() },
|
onPlayNext = { menuExpanded = false; onPlayNext() },
|
||||||
onPlayLast = { menuExpanded = false; onPlayLast() },
|
onPlayLast = { menuExpanded = false; onPlayLast() },
|
||||||
onShare = { menuExpanded = false; onShare() },
|
onShare = { menuExpanded = false; onShare() },
|
||||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
|
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() },
|
||||||
|
isDownloadedForOffline = isDownloadedForOffline,
|
||||||
|
isDownloadingForOffline = isDownloadingForOffline,
|
||||||
|
canDownloadForOffline = canDownloadForOffline,
|
||||||
|
onDownloadForOffline = { menuExpanded = false; onDownloadForOffline() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
@@ -282,13 +298,17 @@ internal fun PlaylistTrackRow(
|
|||||||
track: TrackCard,
|
track: TrackCard,
|
||||||
bitmap: Bitmap?,
|
bitmap: Bitmap?,
|
||||||
isLiked: Boolean,
|
isLiked: Boolean,
|
||||||
|
isDownloadedForOffline: Boolean,
|
||||||
|
isDownloadingForOffline: Boolean,
|
||||||
|
canDownloadForOffline: Boolean,
|
||||||
onMediaImageNeeded: (String?) -> Unit,
|
onMediaImageNeeded: (String?) -> Unit,
|
||||||
onTrackClick: (TrackCard) -> Unit,
|
onTrackClick: (TrackCard) -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
onPlayLast: () -> Unit,
|
onPlayLast: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
onAddToPlaylist: () -> Unit
|
onAddToPlaylist: () -> Unit,
|
||||||
|
onDownloadForOffline: () -> Unit
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
val isUnavailable = track.isUnavailableForPlayback()
|
val isUnavailable = track.isUnavailableForPlayback()
|
||||||
@@ -370,7 +390,11 @@ internal fun PlaylistTrackRow(
|
|||||||
onPlayNext = { menuExpanded = false; onPlayNext() },
|
onPlayNext = { menuExpanded = false; onPlayNext() },
|
||||||
onPlayLast = { menuExpanded = false; onPlayLast() },
|
onPlayLast = { menuExpanded = false; onPlayLast() },
|
||||||
onShare = { menuExpanded = false; onShare() },
|
onShare = { menuExpanded = false; onShare() },
|
||||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
|
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() },
|
||||||
|
isDownloadedForOffline = isDownloadedForOffline,
|
||||||
|
isDownloadingForOffline = isDownloadingForOffline,
|
||||||
|
canDownloadForOffline = canDownloadForOffline,
|
||||||
|
onDownloadForOffline = { menuExpanded = false; onDownloadForOffline() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
@@ -426,13 +450,17 @@ internal fun QueueTrackRow(
|
|||||||
isLiked: Boolean,
|
isLiked: Boolean,
|
||||||
isPlayed: Boolean = false,
|
isPlayed: Boolean = false,
|
||||||
isCurrent: Boolean = false,
|
isCurrent: Boolean = false,
|
||||||
|
isDownloadedForOffline: Boolean,
|
||||||
|
isDownloadingForOffline: Boolean,
|
||||||
|
canDownloadForOffline: Boolean,
|
||||||
onMediaImageNeeded: (String?) -> Unit,
|
onMediaImageNeeded: (String?) -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
onToggleLike: () -> Unit,
|
||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
onPlayLast: () -> Unit,
|
onPlayLast: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
onAddToPlaylist: () -> Unit
|
onAddToPlaylist: () -> Unit,
|
||||||
|
onDownloadForOffline: () -> Unit
|
||||||
) {
|
) {
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -519,7 +547,11 @@ internal fun QueueTrackRow(
|
|||||||
onPlayNext = { menuExpanded = false; onPlayNext() },
|
onPlayNext = { menuExpanded = false; onPlayNext() },
|
||||||
onPlayLast = { menuExpanded = false; onPlayLast() },
|
onPlayLast = { menuExpanded = false; onPlayLast() },
|
||||||
onShare = { menuExpanded = false; onShare() },
|
onShare = { menuExpanded = false; onShare() },
|
||||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
|
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() },
|
||||||
|
isDownloadedForOffline = isDownloadedForOffline,
|
||||||
|
isDownloadingForOffline = isDownloadingForOffline,
|
||||||
|
canDownloadForOffline = canDownloadForOffline,
|
||||||
|
onDownloadForOffline = { menuExpanded = false; onDownloadForOffline() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
@@ -535,6 +567,18 @@ private fun TrackCard.isUnavailableForPlayback(): Boolean {
|
|||||||
return streamUrl.isBlank()
|
return streamUrl.isBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun PlayerUiState.isDownloadedForOffline(track: TrackCard): Boolean {
|
||||||
|
return track.isAudioCached || offlineDownloadedTrackIds.contains(track.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PlayerUiState.isDownloadingForOffline(track: TrackCard): Boolean {
|
||||||
|
return offlineDownloadingTrackIds.contains(track.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun PlayerUiState.canStartOfflineDownload(): Boolean {
|
||||||
|
return !offlineState.isOfflineActive
|
||||||
|
}
|
||||||
|
|
||||||
private fun Modifier.unavailableTrackArtworkOverlay(isUnavailable: Boolean): Modifier {
|
private fun Modifier.unavailableTrackArtworkOverlay(isUnavailable: Boolean): Modifier {
|
||||||
if (!isUnavailable) return this
|
if (!isUnavailable) return this
|
||||||
return drawWithContent {
|
return drawWithContent {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cy.hexor.furumi.ui.player
|
|||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
@@ -151,6 +152,12 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.messageEvent.collect { message ->
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -190,6 +197,8 @@ fun PlayerScreen(
|
|||||||
onPlayLast = viewModel::addToQueueEnd,
|
onPlayLast = viewModel::addToQueueEnd,
|
||||||
onShare = viewModel::shareTrack,
|
onShare = viewModel::shareTrack,
|
||||||
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||||
|
onDownloadForOffline = viewModel::downloadTrackForOffline,
|
||||||
|
onDownloadPlaylistClick = viewModel::downloadPlaylistForOffline,
|
||||||
onDeletePlaylistClick = { playlist ->
|
onDeletePlaylistClick = { playlist ->
|
||||||
viewModel.clearPlaylistDeleteError()
|
viewModel.clearPlaylistDeleteError()
|
||||||
deletePlaylistCandidate = playlist
|
deletePlaylistCandidate = playlist
|
||||||
@@ -220,7 +229,8 @@ fun PlayerScreen(
|
|||||||
onPlayNext = viewModel::addToPlayNext,
|
onPlayNext = viewModel::addToPlayNext,
|
||||||
onPlayLast = viewModel::addToQueueEnd,
|
onPlayLast = viewModel::addToQueueEnd,
|
||||||
onShare = viewModel::shareTrack,
|
onShare = viewModel::shareTrack,
|
||||||
onAddToPlaylist = { track -> addToPlaylistTrack = track }
|
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||||
|
onDownloadForOffline = viewModel::downloadTrackForOffline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (uiState.selectedArtist != null) {
|
} else if (uiState.selectedArtist != null) {
|
||||||
@@ -248,7 +258,8 @@ fun PlayerScreen(
|
|||||||
onPlayNext = viewModel::addToPlayNext,
|
onPlayNext = viewModel::addToPlayNext,
|
||||||
onPlayLast = viewModel::addToQueueEnd,
|
onPlayLast = viewModel::addToQueueEnd,
|
||||||
onShare = viewModel::shareTrack,
|
onShare = viewModel::shareTrack,
|
||||||
onAddToPlaylist = { track -> addToPlaylistTrack = track }
|
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||||
|
onDownloadForOffline = viewModel::downloadTrackForOffline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -280,7 +291,8 @@ fun PlayerScreen(
|
|||||||
onPlayNext = viewModel::addToPlayNext,
|
onPlayNext = viewModel::addToPlayNext,
|
||||||
onPlayLast = viewModel::addToQueueEnd,
|
onPlayLast = viewModel::addToQueueEnd,
|
||||||
onShare = viewModel::shareTrack,
|
onShare = viewModel::shareTrack,
|
||||||
onAddToPlaylist = { track -> addToPlaylistTrack = track }
|
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||||
|
onDownloadForOffline = viewModel::downloadTrackForOffline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PlayerTab.Library -> Box(
|
PlayerTab.Library -> Box(
|
||||||
@@ -388,6 +400,10 @@ fun PlayerScreen(
|
|||||||
onPlayLast = viewModel::addToQueueEnd,
|
onPlayLast = viewModel::addToQueueEnd,
|
||||||
onShare = viewModel::shareTrack,
|
onShare = viewModel::shareTrack,
|
||||||
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||||
|
offlineDownloadedTrackIds = uiState.offlineDownloadedTrackIds,
|
||||||
|
offlineDownloadingTrackIds = uiState.offlineDownloadingTrackIds,
|
||||||
|
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||||
|
onDownloadForOffline = viewModel::downloadTrackForOffline,
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
isFullPlayerOpen = false
|
isFullPlayerOpen = false
|
||||||
viewModel.openArtist(artist)
|
viewModel.openArtist(artist)
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ data class PlayerUiState(
|
|||||||
val playlistCreateError: String? = null,
|
val playlistCreateError: String? = null,
|
||||||
val isDeletingPlaylist: Boolean = false,
|
val isDeletingPlaylist: Boolean = false,
|
||||||
val playlistDeleteError: String? = null,
|
val playlistDeleteError: String? = null,
|
||||||
|
val offlineDownloadedTrackIds: Set<Long> = emptySet(),
|
||||||
|
val offlineDownloadingTrackIds: Set<Long> = emptySet(),
|
||||||
|
val offlineDownloadingPlaylistIds: Set<Long> = emptySet(),
|
||||||
|
val offlineDownloadError: String? = null,
|
||||||
val selectedPlaylist: PlaylistCard? = null,
|
val selectedPlaylist: PlaylistCard? = null,
|
||||||
val playlistDetail: PlaylistDetail? = null,
|
val playlistDetail: PlaylistDetail? = null,
|
||||||
val isPlaylistDetailLoading: Boolean = false,
|
val isPlaylistDetailLoading: Boolean = false,
|
||||||
@@ -120,6 +124,8 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private var shouldClaimActiveDeviceOnReconnect = 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()
|
||||||
|
private val _messageEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
val messageEvent: SharedFlow<String> = _messageEvent.asSharedFlow()
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
authRepository.getCurrentSession()?.let { session ->
|
authRepository.getCurrentSession()?.let { session ->
|
||||||
@@ -755,6 +761,74 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun downloadTrackForOffline(track: TrackCard) {
|
||||||
|
if (track.isAudioCached || _uiState.value.offlineDownloadingTrackIds.contains(track.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_uiState.value.offlineState.isOfflineActive) {
|
||||||
|
_messageEvent.tryEmit("Downloads need an online connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
offlineDownloadingTrackIds = _uiState.value.offlineDownloadingTrackIds + track.id,
|
||||||
|
offlineDownloadError = null
|
||||||
|
)
|
||||||
|
viewModelScope.launch {
|
||||||
|
playerRepository.downloadTrackForOffline(track)
|
||||||
|
.onSuccess { downloadedTrack ->
|
||||||
|
applyDownloadedOfflineTracks(listOf(downloadedTrack))
|
||||||
|
_messageEvent.tryEmit("Downloaded for offline")
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
val message = error.message ?: "Unable to download track"
|
||||||
|
_uiState.value = _uiState.value.copy(offlineDownloadError = message)
|
||||||
|
_messageEvent.tryEmit(message)
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
offlineDownloadingTrackIds = _uiState.value.offlineDownloadingTrackIds - track.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadPlaylistForOffline() {
|
||||||
|
val detail = _uiState.value.playlistDetail ?: return
|
||||||
|
val playlistId = detail.playlist.id
|
||||||
|
if (_uiState.value.offlineDownloadingPlaylistIds.contains(playlistId)) return
|
||||||
|
if (_uiState.value.offlineState.isOfflineActive) {
|
||||||
|
_messageEvent.tryEmit("Downloads need an online connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val tracks = detail.tracks.filter { !it.isAudioCached }
|
||||||
|
if (tracks.isEmpty()) {
|
||||||
|
_messageEvent.tryEmit("Playlist is already downloaded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val trackIds = tracks.map { it.id }.toSet()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
offlineDownloadingPlaylistIds = _uiState.value.offlineDownloadingPlaylistIds + playlistId,
|
||||||
|
offlineDownloadingTrackIds = _uiState.value.offlineDownloadingTrackIds + trackIds,
|
||||||
|
offlineDownloadError = null
|
||||||
|
)
|
||||||
|
viewModelScope.launch {
|
||||||
|
playerRepository.downloadTracksForOffline(tracks)
|
||||||
|
.onSuccess { downloadedTracks ->
|
||||||
|
applyDownloadedOfflineTracks(downloadedTracks)
|
||||||
|
_messageEvent.tryEmit("Playlist downloaded for offline")
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
val message = error.message ?: "Unable to download playlist"
|
||||||
|
_uiState.value = _uiState.value.copy(offlineDownloadError = message)
|
||||||
|
_messageEvent.tryEmit(message)
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
offlineDownloadingPlaylistIds = _uiState.value.offlineDownloadingPlaylistIds - playlistId,
|
||||||
|
offlineDownloadingTrackIds = _uiState.value.offlineDownloadingTrackIds - trackIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadPlaylists() {
|
fun loadPlaylists() {
|
||||||
if (_uiState.value.isPlaylistsLoading) return
|
if (_uiState.value.isPlaylistsLoading) return
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
@@ -1592,6 +1666,29 @@ class PlayerViewModel @Inject constructor(
|
|||||||
_uiState.value.selectedRelease?.id?.let(::loadReleaseDetail)
|
_uiState.value.selectedRelease?.id?.let(::loadReleaseDetail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyDownloadedOfflineTracks(downloadedTracks: List<TrackCard>) {
|
||||||
|
if (downloadedTracks.isEmpty()) return
|
||||||
|
val replacements = downloadedTracks.associateBy { it.id }
|
||||||
|
artistDetailCache.keys.toList().forEach { key ->
|
||||||
|
artistDetailCache[key] = artistDetailCache.getValue(key).withReplacedTracks(replacements)
|
||||||
|
}
|
||||||
|
releaseDetailCache.keys.toList().forEach { key ->
|
||||||
|
releaseDetailCache[key] = releaseDetailCache.getValue(key).withReplacedTracks(replacements)
|
||||||
|
}
|
||||||
|
playlistDetailCache.keys.toList().forEach { key ->
|
||||||
|
playlistDetailCache[key] = playlistDetailCache.getValue(key).withReplacedTracks(replacements)
|
||||||
|
}
|
||||||
|
|
||||||
|
val state = _uiState.value
|
||||||
|
_uiState.value = state.copy(
|
||||||
|
offlineDownloadedTrackIds = state.offlineDownloadedTrackIds + replacements.keys,
|
||||||
|
searchResults = state.searchResults?.withReplacedTracks(replacements),
|
||||||
|
artistDetail = state.artistDetail?.withReplacedTracks(replacements),
|
||||||
|
releaseDetail = state.releaseDetail?.withReplacedTracks(replacements),
|
||||||
|
playlistDetail = state.playlistDetail?.withReplacedTracks(replacements)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -1649,6 +1746,29 @@ private fun PlaylistDetail.previewCoverUrls(): List<String> {
|
|||||||
}.take(PLAYLIST_PREVIEW_IMAGE_LIMIT)
|
}.take(PLAYLIST_PREVIEW_IMAGE_LIMIT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ArtistDetail.withReplacedTracks(replacements: Map<Long, TrackCard>): ArtistDetail {
|
||||||
|
return copy(
|
||||||
|
topTracks = topTracks.withReplacedTracks(replacements),
|
||||||
|
featuredTracks = featuredTracks.withReplacedTracks(replacements)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ReleaseDetail.withReplacedTracks(replacements: Map<Long, TrackCard>): ReleaseDetail {
|
||||||
|
return copy(tracks = tracks.withReplacedTracks(replacements))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PlaylistDetail.withReplacedTracks(replacements: Map<Long, TrackCard>): PlaylistDetail {
|
||||||
|
return copy(tracks = tracks.withReplacedTracks(replacements))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SearchResults.withReplacedTracks(replacements: Map<Long, TrackCard>): SearchResults {
|
||||||
|
return copy(tracks = tracks.withReplacedTracks(replacements))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<TrackCard>.withReplacedTracks(replacements: Map<Long, TrackCard>): List<TrackCard> {
|
||||||
|
return map { track -> replacements[track.id] ?: track }
|
||||||
|
}
|
||||||
|
|
||||||
private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults {
|
private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults {
|
||||||
if (artistCards.isEmpty()) return this
|
if (artistCards.isEmpty()) return this
|
||||||
return copy(
|
return copy(
|
||||||
|
|||||||
Reference in New Issue
Block a user