From 68d8da0f85fce1e9d3d29c6bffebd757da1e71ce Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 15 Jul 2026 19:28:09 +0300 Subject: [PATCH] Added offline mode and manual download option --- .../furumi/data/local/OfflineLibraryStore.kt | 31 ++++- .../furumi/data/local/OfflineMediaStore.kt | 69 +++++++++- .../data/repository/PlayerRepositoryImpl.kt | 26 ++++ .../domain/repository/PlayerRepository.kt | 4 + .../furumi/ui/player/PlayerGlobalContent.kt | 17 ++- .../furumi/ui/player/PlayerLibraryContent.kt | 62 ++++++++- .../cy/hexor/furumi/ui/player/PlayerMenus.kt | 19 ++- .../hexor/furumi/ui/player/PlayerOverlay.kt | 29 ++++- .../furumi/ui/player/PlayerReleaseContent.kt | 17 ++- .../cy/hexor/furumi/ui/player/PlayerRows.kt | 60 +++++++-- .../cy/hexor/furumi/ui/player/PlayerScreen.kt | 22 +++- .../hexor/furumi/ui/player/PlayerViewModel.kt | 120 ++++++++++++++++++ 12 files changed, 438 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/cy/hexor/furumi/data/local/OfflineLibraryStore.kt b/app/src/main/java/cy/hexor/furumi/data/local/OfflineLibraryStore.kt index 2277d40..9553d1f 100644 --- a/app/src/main/java/cy/hexor/furumi/data/local/OfflineLibraryStore.kt +++ b/app/src/main/java/cy/hexor/furumi/data/local/OfflineLibraryStore.kt @@ -385,6 +385,18 @@ class OfflineLibraryStore @Inject constructor( putNull(COL_LOCAL_AUDIO_PATH) put(COL_AUDIO_STATUS, STATUS_NONE) 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 = ?", arrayOf(trackId.toString()) @@ -411,6 +423,7 @@ class OfflineLibraryStore @Inject constructor( putNull(COL_LOCAL_AUDIO_PATH) put(COL_AUDIO_STATUS, STATUS_NONE) put(COL_CACHED_AUDIO_BYTES, 0L) + put(COL_AUDIO_PINNED, 0) putNull(COL_LOCAL_COVER_PATH) put(COL_COVER_STATUS, STATUS_NONE) put(COL_CACHED_COVER_BYTES, 0L) @@ -430,7 +443,7 @@ class OfflineLibraryStore @Inject constructor( COL_LAST_ACCESSED_AT, 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), null, null, @@ -550,6 +563,7 @@ class OfflineLibraryStore @Inject constructor( put(COL_CACHED_COVER_BYTES, 0L) put(COL_LAST_ACCESSED_AT, 0L) put(COL_PLAY_COUNT, 0) + put(COL_AUDIO_PINNED, 0) } } 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_COVER_BYTES 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() ) db.execSQL("CREATE INDEX idx_offline_tracks_title ON $TABLE_TRACKS($COL_TITLE_SORT)") db.execSQL("CREATE INDEX idx_offline_tracks_release ON $TABLE_TRACKS($COL_RELEASE_ID)") db.execSQL("CREATE INDEX idx_offline_tracks_audio_status ON $TABLE_TRACKS($COL_AUDIO_STATUS)") + db.execSQL("CREATE INDEX idx_offline_tracks_audio_pinned ON $TABLE_TRACKS($COL_AUDIO_PINNED)") db.execSQL( """ CREATE TABLE $TABLE_PLAYLISTS ( @@ -717,16 +733,16 @@ class OfflineLibraryStore @Inject constructor( } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - db.execSQL("DROP TABLE IF EXISTS $TABLE_LIKES") - db.execSQL("DROP TABLE IF EXISTS $TABLE_PLAYLISTS") - db.execSQL("DROP TABLE IF EXISTS $TABLE_TRACKS") - onCreate(db) + if (oldVersion < 2) { + db.execSQL("ALTER TABLE $TABLE_TRACKS ADD COLUMN $COL_AUDIO_PINNED INTEGER NOT NULL DEFAULT 0") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_offline_tracks_audio_pinned ON $TABLE_TRACKS($COL_AUDIO_PINNED)") + } } } private companion object { 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_PLAYLISTS = "offline_playlists" @@ -760,6 +776,7 @@ class OfflineLibraryStore @Inject constructor( const val COL_CACHED_COVER_BYTES = "cached_cover_bytes" const val COL_LAST_ACCESSED_AT = "last_accessed_at" const val COL_PLAY_COUNT = "play_count" + const val COL_AUDIO_PINNED = "audio_pinned" const val COL_PLAYLIST_ID = "playlist_id" const val COL_PLAYLIST_TITLE = "title" diff --git a/app/src/main/java/cy/hexor/furumi/data/local/OfflineMediaStore.kt b/app/src/main/java/cy/hexor/furumi/data/local/OfflineMediaStore.kt index b50a81f..a79f2bf 100644 --- a/app/src/main/java/cy/hexor/furumi/data/local/OfflineMediaStore.kt +++ b/app/src/main/java/cy/hexor/furumi/data/local/OfflineMediaStore.kt @@ -36,14 +36,40 @@ class OfflineMediaStore @Inject constructor( } 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) = 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.markTrackAccessed(track.id) pruneStaleMedia() val storedTrack = libraryStore.getTrack(track.id, preferLocalMedia = false) ?: track val audioPath = storedTrack.localAudioPath - if (audioPath.isNullOrBlank() || !File(audioPath).exists()) { + val hadCachedAudio = !audioPath.isNullOrBlank() && File(audioPath).exists() + val hasAudio = if (!hadCachedAudio) { 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 @@ -54,6 +80,9 @@ class OfflineMediaStore @Inject constructor( } enforceStorageLimit() + if (pinAudio) { + enforceManualStorageLimit(storedTrack.id, canRemoveAudio = !hadCachedAudio) + } publishStorageStats() } @@ -79,9 +108,9 @@ class OfflineMediaStore @Inject constructor( settingsStorage.setStorageStats(stats.usedBytes, stats.cachedTrackCount) } - private fun downloadAudio(track: TrackCard) { + private fun downloadAudio(track: TrackCard): Boolean { val sourceUrl = track.remoteStreamUrl.ifBlank { track.streamUrl } - if (sourceUrl.isBlank()) return + if (sourceUrl.isBlank()) return false val extension = extensionForMime(track.audioMimeType) ?: extensionFromUrl(sourceUrl) @@ -89,6 +118,7 @@ class OfflineMediaStore @Inject constructor( val target = File(audioDir, "${track.id}-${track.audioHash ?: track.audioUpdatedAt ?: "audio"}.$extension") val bytes = downloadToFile(sourceUrl, target) libraryStore.markAudioCached(track.id, target.absolutePath, bytes) + return true } private fun downloadCover(track: TrackCard) { @@ -142,6 +172,39 @@ class OfflineMediaStore @Inject constructor( } } + private fun ensureManualDownloadsCanFit(tracks: List) { + 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? { return when (mimeType?.lowercase()) { "audio/mpeg", "audio/mp3" -> "mp3" diff --git a/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt b/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt index 410e644..182941c 100644 --- a/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt +++ b/app/src/main/java/cy/hexor/furumi/data/repository/PlayerRepositoryImpl.kt @@ -145,6 +145,32 @@ class PlayerRepositoryImpl @Inject constructor( } } + override suspend fun downloadTrackForOffline(track: TrackCard): Result { + 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): Result> { + 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 { if (isOfflineModeEnabled()) { return Result.success(offlineLibraryStore.getArtists(page, limit)) diff --git a/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt b/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt index 98c7686..78acb3a 100644 --- a/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt +++ b/app/src/main/java/cy/hexor/furumi/domain/repository/PlayerRepository.kt @@ -96,5 +96,9 @@ interface PlayerRepository { suspend fun syncOfflineLibrary(): Result + suspend fun downloadTrackForOffline(track: TrackCard): Result + + suspend fun downloadTracksForOffline(tracks: List): Result> + suspend fun clearOfflineCache(): Result } diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerGlobalContent.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerGlobalContent.kt index ef468d0..bb170f0 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerGlobalContent.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerGlobalContent.kt @@ -221,7 +221,8 @@ internal fun ArtistDetailContent( onPlayNext: (TrackCard) -> Unit, onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, - onAddToPlaylist: (TrackCard) -> Unit + onAddToPlaylist: (TrackCard) -> Unit, + onDownloadForOffline: (TrackCard) -> Unit ) { val artist = uiState.artistDetail?.artist ?: uiState.selectedArtist ?: return val artistImage = mediaImageFor(uiState.mediaImages, artist.imageUrl) @@ -275,6 +276,7 @@ internal fun ArtistDetailContent( val detail = uiState.artistDetail if (detail.topTracks.isNotEmpty()) { PopularTracksSection( + uiState = uiState, tracks = detail.topTracks, mediaImages = uiState.mediaImages, likedTrackIds = uiState.likedTrackIds, @@ -284,7 +286,8 @@ internal fun ArtistDetailContent( onPlayNext = onPlayNext, onPlayLast = onPlayLast, onShare = onShare, - onAddToPlaylist = onAddToPlaylist + onAddToPlaylist = onAddToPlaylist, + onDownloadForOffline = onDownloadForOffline ) Spacer(modifier = Modifier.height(28.dp)) } @@ -405,6 +408,7 @@ internal fun ArtistDetailError( @Composable internal fun PopularTracksSection( + uiState: PlayerUiState, tracks: List, mediaImages: Map, likedTrackIds: Set, @@ -414,7 +418,8 @@ internal fun PopularTracksSection( onPlayNext: (TrackCard) -> Unit, onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, - onAddToPlaylist: (TrackCard) -> Unit + onAddToPlaylist: (TrackCard) -> Unit, + onDownloadForOffline: (TrackCard) -> Unit ) { SectionTitle("Popular") Spacer(modifier = Modifier.height(12.dp)) @@ -425,13 +430,17 @@ internal fun PopularTracksSection( track = track, bitmap = mediaImageFor(mediaImages, track.coverUrl), isLiked = likedTrackIds.contains(track.id), + isDownloadedForOffline = uiState.isDownloadedForOffline(track), + isDownloadingForOffline = uiState.isDownloadingForOffline(track), + canDownloadForOffline = uiState.canStartOfflineDownload(), onMediaImageNeeded = onMediaImageNeeded, onTrackClick = onTrackClick, onToggleLike = { onToggleLike(track.id) }, onPlayNext = { onPlayNext(track) }, onPlayLast = { onPlayLast(track) }, onShare = { onShare(track) }, - onAddToPlaylist = { onAddToPlaylist(track) } + onAddToPlaylist = { onAddToPlaylist(track) }, + onDownloadForOffline = { onDownloadForOffline(track) } ) } } diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerLibraryContent.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerLibraryContent.kt index 4e34162..6885135 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerLibraryContent.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerLibraryContent.kt @@ -89,7 +89,8 @@ internal fun SearchContent( onPlayNext: (TrackCard) -> Unit, onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, - onAddToPlaylist: (TrackCard) -> Unit + onAddToPlaylist: (TrackCard) -> Unit, + onDownloadForOffline: (TrackCard) -> Unit ) { val query = uiState.searchQuery val results = uiState.searchResults @@ -148,6 +149,7 @@ internal fun SearchContent( if (tracks.isNotEmpty()) { SearchTracksSection( + uiState = uiState, tracks = tracks, mediaImages = uiState.mediaImages, likedTrackIds = uiState.likedTrackIds, @@ -157,7 +159,8 @@ internal fun SearchContent( onPlayNext = onPlayNext, onPlayLast = onPlayLast, onShare = onShare, - onAddToPlaylist = onAddToPlaylist + onAddToPlaylist = onAddToPlaylist, + onDownloadForOffline = onDownloadForOffline ) } @@ -237,6 +240,7 @@ private fun SearchArtistCard( @Composable private fun SearchTracksSection( + uiState: PlayerUiState, tracks: List, mediaImages: Map, likedTrackIds: Set, @@ -246,7 +250,8 @@ private fun SearchTracksSection( onPlayNext: (TrackCard) -> Unit, onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, - onAddToPlaylist: (TrackCard) -> Unit + onAddToPlaylist: (TrackCard) -> Unit, + onDownloadForOffline: (TrackCard) -> Unit ) { SectionTitle("Tracks") Spacer(modifier = Modifier.height(12.dp)) @@ -257,13 +262,17 @@ private fun SearchTracksSection( track = track, bitmap = mediaImageFor(mediaImages, track.coverUrl), isLiked = likedTrackIds.contains(track.id), + isDownloadedForOffline = uiState.isDownloadedForOffline(track), + isDownloadingForOffline = uiState.isDownloadingForOffline(track), + canDownloadForOffline = uiState.canStartOfflineDownload(), onMediaImageNeeded = onMediaImageNeeded, onTrackClick = onTrackClick, onToggleLike = { onToggleLike(track.id) }, onPlayNext = { onPlayNext(track) }, onPlayLast = { onPlayLast(track) }, onShare = { onShare(track) }, - onAddToPlaylist = { onAddToPlaylist(track) } + onAddToPlaylist = { onAddToPlaylist(track) }, + onDownloadForOffline = { onDownloadForOffline(track) } ) } } @@ -632,10 +641,14 @@ internal fun PlaylistDetailContent( onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, onAddToPlaylist: (TrackCard) -> Unit, + onDownloadForOffline: (TrackCard) -> Unit, + onDownloadPlaylistClick: () -> Unit, onDeletePlaylistClick: (PlaylistCard) -> Unit ) { val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return 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 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()) { Spacer(modifier = Modifier.height(12.dp)) OutlinedButton( @@ -767,13 +815,17 @@ internal fun PlaylistDetailContent( track = track, bitmap = mediaImageFor(uiState.mediaImages, track.coverUrl), isLiked = uiState.likedTrackIds.contains(track.id), + isDownloadedForOffline = uiState.isDownloadedForOffline(track), + isDownloadingForOffline = uiState.isDownloadingForOffline(track), + canDownloadForOffline = uiState.canStartOfflineDownload(), onMediaImageNeeded = onMediaImageNeeded, onTrackClick = onTrackClick, onToggleLike = { onToggleLike(track.id) }, onPlayNext = { onPlayNext(track) }, onPlayLast = { onPlayLast(track) }, onShare = { onShare(track) }, - onAddToPlaylist = { onAddToPlaylist(track) } + onAddToPlaylist = { onAddToPlaylist(track) }, + onDownloadForOffline = { onDownloadForOffline(track) } ) if (index < tracks.lastIndex) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerMenus.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerMenus.kt index 3b5497e..0485c86 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerMenus.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerMenus.kt @@ -88,7 +88,11 @@ internal fun TrackContextMenu( onPlayNext: () -> Unit, onPlayLast: () -> Unit, onShare: () -> Unit, - onAddToPlaylist: () -> Unit + onAddToPlaylist: () -> Unit, + isDownloadedForOffline: Boolean, + isDownloadingForOffline: Boolean, + canDownloadForOffline: Boolean, + onDownloadForOffline: () -> Unit ) { DropdownMenu( expanded = expanded, @@ -114,6 +118,19 @@ internal fun TrackContextMenu( text = { Text("Add to playlist") }, onClick = onAddToPlaylist ) + DropdownMenuItem( + text = { + Text( + when { + isDownloadedForOffline -> "Downloaded" + isDownloadingForOffline -> "Downloading..." + else -> "Download offline" + } + ) + }, + enabled = canDownloadForOffline && !isDownloadedForOffline && !isDownloadingForOffline, + onClick = onDownloadForOffline + ) } } diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerOverlay.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerOverlay.kt index 8a575be..65d973d 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerOverlay.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerOverlay.kt @@ -102,6 +102,10 @@ internal fun FullPlayerOverlay( onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, onAddToPlaylist: (TrackCard) -> Unit, + offlineDownloadedTrackIds: Set, + offlineDownloadingTrackIds: Set, + canDownloadForOffline: Boolean, + onDownloadForOffline: (TrackCard) -> Unit, onArtistClick: (cy.hexor.furumi.domain.model.ArtistCard) -> Unit, connectedDevicesState: ConnectedDevicesState?, connectedDevicesError: String?, @@ -277,6 +281,13 @@ internal fun FullPlayerOverlay( onAddToPlaylist = { currentTrackMenuExpanded = false 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, onPlayLast = onPlayLast, onShare = onShare, - onAddToPlaylist = onAddToPlaylist + onAddToPlaylist = onAddToPlaylist, + offlineDownloadedTrackIds = offlineDownloadedTrackIds, + offlineDownloadingTrackIds = offlineDownloadingTrackIds, + canDownloadForOffline = canDownloadForOffline, + onDownloadForOffline = onDownloadForOffline ) } @@ -483,7 +498,11 @@ private fun FullPlayerQueue( onPlayNext: (TrackCard) -> Unit, onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, - onAddToPlaylist: (TrackCard) -> Unit + onAddToPlaylist: (TrackCard) -> Unit, + offlineDownloadedTrackIds: Set, + offlineDownloadingTrackIds: Set, + canDownloadForOffline: Boolean, + onDownloadForOffline: (TrackCard) -> Unit ) { if (queue.isEmpty()) { Text( @@ -543,13 +562,17 @@ private fun FullPlayerQueue( isLiked = likedTrackIds.contains(queueTrack.id), isPlayed = isPlayed, isCurrent = isCurrent, + isDownloadedForOffline = queueTrack.isAudioCached || offlineDownloadedTrackIds.contains(queueTrack.id), + isDownloadingForOffline = offlineDownloadingTrackIds.contains(queueTrack.id), + canDownloadForOffline = canDownloadForOffline, onMediaImageNeeded = onMediaImageNeeded, onClick = { onQueueTrackClick(queueTrack, index) }, onToggleLike = { onToggleLike(queueTrack.id) }, onPlayNext = { onPlayNext(queueTrack) }, onPlayLast = { onPlayLast(queueTrack) }, onShare = { onShare(queueTrack) }, - onAddToPlaylist = { onAddToPlaylist(queueTrack) } + onAddToPlaylist = { onAddToPlaylist(queueTrack) }, + onDownloadForOffline = { onDownloadForOffline(queueTrack) } ) if (visibleIndex < visibleQueue.lastIndex) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerReleaseContent.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerReleaseContent.kt index 3bdb24c..9f444bb 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerReleaseContent.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerReleaseContent.kt @@ -88,7 +88,8 @@ internal fun ReleaseDetailContent( onPlayNext: (TrackCard) -> Unit, onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, - onAddToPlaylist: (TrackCard) -> Unit + onAddToPlaylist: (TrackCard) -> Unit, + onDownloadForOffline: (TrackCard) -> Unit ) { val release = uiState.releaseDetail?.release ?: uiState.selectedRelease ?: return val releaseImage = mediaImageFor(uiState.mediaImages, release.coverUrl) @@ -141,6 +142,7 @@ internal fun ReleaseDetailContent( onRetry = onRetry ) uiState.releaseDetail != null -> ReleaseTracksSection( + uiState = uiState, tracks = uiState.releaseDetail.tracks, likedTrackIds = uiState.likedTrackIds, onTrackClick = onTrackClick, @@ -148,7 +150,8 @@ internal fun ReleaseDetailContent( onPlayNext = onPlayNext, onPlayLast = onPlayLast, onShare = onShare, - onAddToPlaylist = onAddToPlaylist + onAddToPlaylist = onAddToPlaylist, + onDownloadForOffline = onDownloadForOffline ) } @@ -270,6 +273,7 @@ internal fun ReleaseDetailError( @Composable internal fun ReleaseTracksSection( + uiState: PlayerUiState, tracks: List, likedTrackIds: Set, onTrackClick: (TrackCard) -> Unit, @@ -277,7 +281,8 @@ internal fun ReleaseTracksSection( onPlayNext: (TrackCard) -> Unit, onPlayLast: (TrackCard) -> Unit, onShare: (TrackCard) -> Unit, - onAddToPlaylist: (TrackCard) -> Unit + onAddToPlaylist: (TrackCard) -> Unit, + onDownloadForOffline: (TrackCard) -> Unit ) { SectionTitle("Tracks") Spacer(modifier = Modifier.height(12.dp)) @@ -316,12 +321,16 @@ internal fun ReleaseTracksSection( fallbackIndex = index + 1, track = track, isLiked = likedTrackIds.contains(track.id), + isDownloadedForOffline = uiState.isDownloadedForOffline(track), + isDownloadingForOffline = uiState.isDownloadingForOffline(track), + canDownloadForOffline = uiState.canStartOfflineDownload(), onTrackClick = onTrackClick, onToggleLike = { onToggleLike(track.id) }, onPlayNext = { onPlayNext(track) }, onPlayLast = { onPlayLast(track) }, onShare = { onShare(track) }, - onAddToPlaylist = { onAddToPlaylist(track) } + onAddToPlaylist = { onAddToPlaylist(track) }, + onDownloadForOffline = { onDownloadForOffline(track) } ) } } diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerRows.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerRows.kt index 8c6471e..d85945a 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerRows.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerRows.kt @@ -84,13 +84,17 @@ internal fun ArtistDetailTrackRow( track: TrackCard, bitmap: Bitmap?, isLiked: Boolean, + isDownloadedForOffline: Boolean, + isDownloadingForOffline: Boolean, + canDownloadForOffline: Boolean, onMediaImageNeeded: (String?) -> Unit, onTrackClick: (TrackCard) -> Unit, onToggleLike: () -> Unit, onPlayNext: () -> Unit, onPlayLast: () -> Unit, onShare: () -> Unit, - onAddToPlaylist: () -> Unit + onAddToPlaylist: () -> Unit, + onDownloadForOffline: () -> Unit ) { var menuExpanded by remember { mutableStateOf(false) } val isUnavailable = track.isUnavailableForPlayback() @@ -174,7 +178,11 @@ internal fun ArtistDetailTrackRow( onPlayNext = { menuExpanded = false; onPlayNext() }, onPlayLast = { menuExpanded = false; onPlayLast() }, 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)) @@ -191,12 +199,16 @@ internal fun ReleaseTrackRow( fallbackIndex: Int, track: TrackCard, isLiked: Boolean, + isDownloadedForOffline: Boolean, + isDownloadingForOffline: Boolean, + canDownloadForOffline: Boolean, onTrackClick: (TrackCard) -> Unit, onToggleLike: () -> Unit, onPlayNext: () -> Unit, onPlayLast: () -> Unit, onShare: () -> Unit, - onAddToPlaylist: () -> Unit + onAddToPlaylist: () -> Unit, + onDownloadForOffline: () -> Unit ) { var menuExpanded by remember { mutableStateOf(false) } val isUnavailable = track.isUnavailableForPlayback() @@ -264,7 +276,11 @@ internal fun ReleaseTrackRow( onPlayNext = { menuExpanded = false; onPlayNext() }, onPlayLast = { menuExpanded = false; onPlayLast() }, 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)) @@ -282,13 +298,17 @@ internal fun PlaylistTrackRow( track: TrackCard, bitmap: Bitmap?, isLiked: Boolean, + isDownloadedForOffline: Boolean, + isDownloadingForOffline: Boolean, + canDownloadForOffline: Boolean, onMediaImageNeeded: (String?) -> Unit, onTrackClick: (TrackCard) -> Unit, onToggleLike: () -> Unit, onPlayNext: () -> Unit, onPlayLast: () -> Unit, onShare: () -> Unit, - onAddToPlaylist: () -> Unit + onAddToPlaylist: () -> Unit, + onDownloadForOffline: () -> Unit ) { var menuExpanded by remember { mutableStateOf(false) } val isUnavailable = track.isUnavailableForPlayback() @@ -370,7 +390,11 @@ internal fun PlaylistTrackRow( onPlayNext = { menuExpanded = false; onPlayNext() }, onPlayLast = { menuExpanded = false; onPlayLast() }, 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)) @@ -426,13 +450,17 @@ internal fun QueueTrackRow( isLiked: Boolean, isPlayed: Boolean = false, isCurrent: Boolean = false, + isDownloadedForOffline: Boolean, + isDownloadingForOffline: Boolean, + canDownloadForOffline: Boolean, onMediaImageNeeded: (String?) -> Unit, onClick: () -> Unit, onToggleLike: () -> Unit, onPlayNext: () -> Unit, onPlayLast: () -> Unit, onShare: () -> Unit, - onAddToPlaylist: () -> Unit + onAddToPlaylist: () -> Unit, + onDownloadForOffline: () -> Unit ) { var menuExpanded by remember { mutableStateOf(false) } @@ -519,7 +547,11 @@ internal fun QueueTrackRow( onPlayNext = { menuExpanded = false; onPlayNext() }, onPlayLast = { menuExpanded = false; onPlayLast() }, 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)) @@ -535,6 +567,18 @@ private fun TrackCard.isUnavailableForPlayback(): Boolean { 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 { if (!isUnavailable) return this return drawWithContent { diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt index 792419d..5b2cb93 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerScreen.kt @@ -2,6 +2,7 @@ package cy.hexor.furumi.ui.player import android.content.Intent import android.graphics.Bitmap +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable 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( modifier = Modifier .fillMaxSize() @@ -190,6 +197,8 @@ fun PlayerScreen( onPlayLast = viewModel::addToQueueEnd, onShare = viewModel::shareTrack, onAddToPlaylist = { track -> addToPlaylistTrack = track }, + onDownloadForOffline = viewModel::downloadTrackForOffline, + onDownloadPlaylistClick = viewModel::downloadPlaylistForOffline, onDeletePlaylistClick = { playlist -> viewModel.clearPlaylistDeleteError() deletePlaylistCandidate = playlist @@ -220,7 +229,8 @@ fun PlayerScreen( onPlayNext = viewModel::addToPlayNext, onPlayLast = viewModel::addToQueueEnd, onShare = viewModel::shareTrack, - onAddToPlaylist = { track -> addToPlaylistTrack = track } + onAddToPlaylist = { track -> addToPlaylistTrack = track }, + onDownloadForOffline = viewModel::downloadTrackForOffline ) } } else if (uiState.selectedArtist != null) { @@ -248,7 +258,8 @@ fun PlayerScreen( onPlayNext = viewModel::addToPlayNext, onPlayLast = viewModel::addToQueueEnd, onShare = viewModel::shareTrack, - onAddToPlaylist = { track -> addToPlaylistTrack = track } + onAddToPlaylist = { track -> addToPlaylistTrack = track }, + onDownloadForOffline = viewModel::downloadTrackForOffline ) } } else { @@ -280,7 +291,8 @@ fun PlayerScreen( onPlayNext = viewModel::addToPlayNext, onPlayLast = viewModel::addToQueueEnd, onShare = viewModel::shareTrack, - onAddToPlaylist = { track -> addToPlaylistTrack = track } + onAddToPlaylist = { track -> addToPlaylistTrack = track }, + onDownloadForOffline = viewModel::downloadTrackForOffline ) } PlayerTab.Library -> Box( @@ -388,6 +400,10 @@ fun PlayerScreen( onPlayLast = viewModel::addToQueueEnd, onShare = viewModel::shareTrack, onAddToPlaylist = { track -> addToPlaylistTrack = track }, + offlineDownloadedTrackIds = uiState.offlineDownloadedTrackIds, + offlineDownloadingTrackIds = uiState.offlineDownloadingTrackIds, + canDownloadForOffline = uiState.canStartOfflineDownload(), + onDownloadForOffline = viewModel::downloadTrackForOffline, onArtistClick = { artist -> isFullPlayerOpen = false viewModel.openArtist(artist) diff --git a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt index 94a4a55..5cf01b7 100644 --- a/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/cy/hexor/furumi/ui/player/PlayerViewModel.kt @@ -85,6 +85,10 @@ data class PlayerUiState( val playlistCreateError: String? = null, val isDeletingPlaylist: Boolean = false, val playlistDeleteError: String? = null, + val offlineDownloadedTrackIds: Set = emptySet(), + val offlineDownloadingTrackIds: Set = emptySet(), + val offlineDownloadingPlaylistIds: Set = emptySet(), + val offlineDownloadError: String? = null, val selectedPlaylist: PlaylistCard? = null, val playlistDetail: PlaylistDetail? = null, val isPlaylistDetailLoading: Boolean = false, @@ -120,6 +124,8 @@ class PlayerViewModel @Inject constructor( private var shouldClaimActiveDeviceOnReconnect = false private val _shareEvent = MutableSharedFlow(extraBufferCapacity = 1) val shareEvent: SharedFlow = _shareEvent.asSharedFlow() + private val _messageEvent = MutableSharedFlow(extraBufferCapacity = 1) + val messageEvent: SharedFlow = _messageEvent.asSharedFlow() private val _uiState = MutableStateFlow( 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() { if (_uiState.value.isPlaylistsLoading) return _uiState.value = _uiState.value.copy( @@ -1592,6 +1666,29 @@ class PlayerViewModel @Inject constructor( _uiState.value.selectedRelease?.id?.let(::loadReleaseDetail) } + private fun applyDownloadedOfflineTracks(downloadedTracks: List) { + 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 { const val ARTIST_PAGE_SIZE = 60 const val SEARCH_DEBOUNCE_MS = 300L @@ -1649,6 +1746,29 @@ private fun PlaylistDetail.previewCoverUrls(): List { }.take(PLAYLIST_PREVIEW_IMAGE_LIMIT) } +private fun ArtistDetail.withReplacedTracks(replacements: Map): ArtistDetail { + return copy( + topTracks = topTracks.withReplacedTracks(replacements), + featuredTracks = featuredTracks.withReplacedTracks(replacements) + ) +} + +private fun ReleaseDetail.withReplacedTracks(replacements: Map): ReleaseDetail { + return copy(tracks = tracks.withReplacedTracks(replacements)) +} + +private fun PlaylistDetail.withReplacedTracks(replacements: Map): PlaylistDetail { + return copy(tracks = tracks.withReplacedTracks(replacements)) +} + +private fun SearchResults.withReplacedTracks(replacements: Map): SearchResults { + return copy(tracks = tracks.withReplacedTracks(replacements)) +} + +private fun List.withReplacedTracks(replacements: Map): List { + return map { track -> replacements[track.id] ?: track } +} + private fun SearchResults.withArtistCards(artistCards: Map): SearchResults { if (artistCards.isEmpty()) return this return copy(