Added offline mode and manual download option

This commit is contained in:
Ultradesu
2026-07-15 19:28:09 +03:00
parent f085bf58b2
commit 68d8da0f85
12 changed files with 438 additions and 38 deletions
@@ -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(