diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4d394e4..c2fc4d0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + + diff --git a/app/src/main/java/cy/hexor/furumi/data/local/OfflineDownloadJobScheduler.kt b/app/src/main/java/cy/hexor/furumi/data/local/OfflineDownloadJobScheduler.kt new file mode 100644 index 0000000..03237f2 --- /dev/null +++ b/app/src/main/java/cy/hexor/furumi/data/local/OfflineDownloadJobScheduler.kt @@ -0,0 +1,55 @@ +package cy.hexor.furumi.data.local + +import android.app.job.JobInfo +import android.app.job.JobScheduler +import android.content.ComponentName +import android.content.Context +import android.os.Build +import cy.hexor.furumi.domain.model.AuthException +import cy.hexor.furumi.offline.OfflineDownloadJobService +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OfflineDownloadJobScheduler @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private val jobScheduler = context.getSystemService(JobScheduler::class.java) + + fun schedule(estimatedDownloadBytes: Long) { + if (jobScheduler.allPendingJobs.any { it.id == OFFLINE_DOWNLOAD_JOB_ID }) return + + val result = jobScheduler.schedule(buildJobInfo(userInitiated = true, estimatedDownloadBytes)) + if (result == JobScheduler.RESULT_SUCCESS) return + + val fallbackResult = jobScheduler.schedule(buildJobInfo(userInitiated = false, estimatedDownloadBytes)) + if (fallbackResult != JobScheduler.RESULT_SUCCESS) { + throw AuthException("Unable to schedule offline download") + } + } + + private fun buildJobInfo(userInitiated: Boolean, estimatedDownloadBytes: Long): JobInfo { + val builder = JobInfo.Builder( + OFFLINE_DOWNLOAD_JOB_ID, + ComponentName(context, OfflineDownloadJobService::class.java) + ) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setRequiresStorageNotLow(true) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && estimatedDownloadBytes > 0L) { + builder.setEstimatedNetworkBytes(estimatedDownloadBytes, 0L) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + userInitiated && + jobScheduler.canRunUserInitiatedJobs() + ) { + builder.setUserInitiated(true) + } + return builder.build() + } + + companion object { + const val OFFLINE_DOWNLOAD_JOB_ID = 0x4675_0D1 + } +} diff --git a/app/src/main/java/cy/hexor/furumi/data/local/OfflineDownloadQueueStore.kt b/app/src/main/java/cy/hexor/furumi/data/local/OfflineDownloadQueueStore.kt new file mode 100644 index 0000000..358d33c --- /dev/null +++ b/app/src/main/java/cy/hexor/furumi/data/local/OfflineDownloadQueueStore.kt @@ -0,0 +1,49 @@ +package cy.hexor.furumi.data.local + +import cy.hexor.furumi.domain.model.OfflineDownloadQueueState +import cy.hexor.furumi.domain.model.TrackCard +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OfflineDownloadQueueStore @Inject constructor( + private val libraryStore: OfflineLibraryStore +) { + private val _state = MutableStateFlow(libraryStore.offlineDownloadQueueState()) + + val state: StateFlow = _state.asStateFlow() + + @Synchronized + fun enqueueDownloads(tracks: List): OfflineDownloadQueueState { + _state.value = libraryStore.enqueueOfflineDownloadTracks(tracks) + return _state.value + } + + @Synchronized + fun nextPendingDownloadTrack(): TrackCard? { + return libraryStore.nextPendingOfflineDownloadTrack() + } + + @Synchronized + fun markRunning(trackId: Long) { + _state.value = libraryStore.markOfflineDownloadRunning(trackId) + } + + @Synchronized + fun markCompleted(trackId: Long) { + _state.value = libraryStore.markOfflineDownloadCompleted(trackId) + } + + @Synchronized + fun markFailed(trackId: Long, error: String?) { + _state.value = libraryStore.markOfflineDownloadFailed(trackId, error) + } + + @Synchronized + fun refresh() { + _state.value = libraryStore.offlineDownloadQueueState() + } +} 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 9553d1f..bafb24d 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 @@ -12,6 +12,7 @@ import cy.hexor.furumi.data.remote.model.OfflineManifestTrackResponse import cy.hexor.furumi.domain.model.ArtistCard import cy.hexor.furumi.domain.model.ArtistDetail import cy.hexor.furumi.domain.model.ArtistPage +import cy.hexor.furumi.domain.model.OfflineDownloadQueueState import cy.hexor.furumi.domain.model.PlaylistCard import cy.hexor.furumi.domain.model.PlaylistDetail import cy.hexor.furumi.domain.model.ReleaseCard @@ -510,6 +511,119 @@ class OfflineLibraryStore @Inject constructor( } } + fun enqueueOfflineDownloadTracks(tracks: List): OfflineDownloadQueueState { + if (tracks.isEmpty()) return offlineDownloadQueueState() + val now = System.currentTimeMillis() + dbHelper.writableDatabase.transaction { + tracks.distinctBy { it.id }.forEach { track -> + upsertTrackLocked(this, track) + insertOrUpdate(TABLE_DOWNLOAD_QUEUE, COL_DOWNLOAD_TRACK_ID, track.id, ContentValues().apply { + put(COL_DOWNLOAD_TRACK_ID, track.id) + put(COL_DOWNLOAD_STATUS, STATUS_PENDING) + putNull(COL_DOWNLOAD_ERROR) + put(COL_DOWNLOAD_ENQUEUED_AT, now) + put(COL_DOWNLOAD_UPDATED_AT, now) + }) + } + } + return offlineDownloadQueueState() + } + + fun nextPendingOfflineDownloadTrack(): TrackCard? { + val trackIds = dbHelper.readableDatabase.query( + TABLE_DOWNLOAD_QUEUE, + arrayOf(COL_DOWNLOAD_TRACK_ID), + "$COL_DOWNLOAD_STATUS IN (?, ?)", + arrayOf(STATUS_PENDING, STATUS_RUNNING), + null, + null, + "$COL_DOWNLOAD_ENQUEUED_AT ASC" + ).useCursor { cursor -> + buildList { + while (cursor.moveToNext()) add(cursor.getLong(COL_DOWNLOAD_TRACK_ID)) + } + } + + for (trackId in trackIds) { + getTrack(trackId, preferLocalMedia = false)?.let { return it } + markOfflineDownloadCompleted(trackId) + } + return null + } + + fun markOfflineDownloadRunning(trackId: Long): OfflineDownloadQueueState { + return updateOfflineDownloadStatus(trackId, STATUS_RUNNING, error = null) + } + + fun markOfflineDownloadCompleted(trackId: Long): OfflineDownloadQueueState { + dbHelper.writableDatabase.delete( + TABLE_DOWNLOAD_QUEUE, + "$COL_DOWNLOAD_TRACK_ID = ?", + arrayOf(trackId.toString()) + ) + return offlineDownloadQueueState() + } + + fun markOfflineDownloadFailed(trackId: Long, error: String?): OfflineDownloadQueueState { + return updateOfflineDownloadStatus(trackId, STATUS_FAILED, error) + } + + fun offlineDownloadQueueState(): OfflineDownloadQueueState { + return dbHelper.readableDatabase.query( + TABLE_DOWNLOAD_QUEUE, + arrayOf(COL_DOWNLOAD_TRACK_ID, COL_DOWNLOAD_STATUS, COL_DOWNLOAD_ERROR), + null, + null, + null, + null, + "$COL_DOWNLOAD_UPDATED_AT DESC" + ).useCursor { cursor -> + val activeTrackIds = mutableSetOf() + val failedTrackIds = mutableSetOf() + var lastError: String? = null + while (cursor.moveToNext()) { + val trackId = cursor.getLong(COL_DOWNLOAD_TRACK_ID) + when (cursor.getString(COL_DOWNLOAD_STATUS)) { + STATUS_PENDING, + STATUS_RUNNING -> activeTrackIds.add(trackId) + STATUS_FAILED -> { + failedTrackIds.add(trackId) + if (lastError == null) { + lastError = cursor.getStringOrNull(COL_DOWNLOAD_ERROR) + } + } + } + } + OfflineDownloadQueueState( + activeTrackIds = activeTrackIds, + failedTrackIds = failedTrackIds, + lastError = lastError + ) + } + } + + private fun updateOfflineDownloadStatus( + trackId: Long, + status: String, + error: String? + ): OfflineDownloadQueueState { + dbHelper.writableDatabase.update( + TABLE_DOWNLOAD_QUEUE, + ContentValues().apply { + put(COL_DOWNLOAD_STATUS, status) + if (error == null) { + putNull(COL_DOWNLOAD_ERROR) + } else { + put(COL_DOWNLOAD_ERROR, error) + } + put(COL_DOWNLOAD_UPDATED_AT, System.currentTimeMillis()) + }, + "$COL_DOWNLOAD_TRACK_ID = ?", + arrayOf(trackId.toString()) + ) + return offlineDownloadQueueState() + } + private fun getAllTracks(preferLocalMedia: Boolean): List { return dbHelper.readableDatabase.query( TABLE_TRACKS, @@ -730,6 +844,7 @@ class OfflineLibraryStore @Inject constructor( ) """.trimIndent() ) + createDownloadQueueTable(db) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -737,16 +852,35 @@ class OfflineLibraryStore @Inject constructor( 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)") } + if (oldVersion < 3) { + createDownloadQueueTable(db) + } + } + + private fun createDownloadQueueTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS $TABLE_DOWNLOAD_QUEUE ( + $COL_DOWNLOAD_TRACK_ID INTEGER PRIMARY KEY, + $COL_DOWNLOAD_STATUS TEXT NOT NULL, + $COL_DOWNLOAD_ERROR TEXT, + $COL_DOWNLOAD_ENQUEUED_AT INTEGER NOT NULL, + $COL_DOWNLOAD_UPDATED_AT INTEGER NOT NULL + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS idx_offline_download_status ON $TABLE_DOWNLOAD_QUEUE($COL_DOWNLOAD_STATUS)") } } private companion object { const val DB_NAME = "furumi_offline.db" - const val DB_VERSION = 2 + const val DB_VERSION = 3 const val TABLE_TRACKS = "offline_tracks" const val TABLE_PLAYLISTS = "offline_playlists" const val TABLE_LIKES = "offline_likes" + const val TABLE_DOWNLOAD_QUEUE = "offline_download_queue" const val COL_TRACK_ID = "track_id" const val COL_TITLE = "title" @@ -791,9 +925,18 @@ class OfflineLibraryStore @Inject constructor( const val COL_LIKE_TRACK_ID = "track_id" + const val COL_DOWNLOAD_TRACK_ID = "track_id" + const val COL_DOWNLOAD_STATUS = "status" + const val COL_DOWNLOAD_ERROR = "error" + const val COL_DOWNLOAD_ENQUEUED_AT = "enqueued_at" + const val COL_DOWNLOAD_UPDATED_AT = "updated_at" + const val STATUS_NONE = "none" const val STATUS_READY = "ready" const val STATUS_STALE = "stale" + const val STATUS_PENDING = "pending" + const val STATUS_RUNNING = "running" + const val STATUS_FAILED = "failed" } } 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 a79f2bf..99b614f 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 @@ -52,6 +52,10 @@ class OfflineMediaStore @Inject constructor( } } + suspend fun validateManualDownloadSize(tracks: List) = withContext(Dispatchers.IO) { + ensureManualDownloadsCanFit(tracks.distinctBy { it.id }) + } + private suspend fun cacheTrackMediaBlocking(track: TrackCard, pinAudio: Boolean, requireAudio: Boolean) { libraryStore.upsertTracks(listOf(track)) libraryStore.markTrackAccessed(track.id) 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 182941c..bb5d839 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 @@ -2,6 +2,8 @@ package cy.hexor.furumi.data.repository import cy.hexor.furumi.data.local.AuthSessionStorage import cy.hexor.furumi.data.local.ConnectedDeviceStorage +import cy.hexor.furumi.data.local.OfflineDownloadJobScheduler +import cy.hexor.furumi.data.local.OfflineDownloadQueueStore import cy.hexor.furumi.data.local.OfflineLibraryStore import cy.hexor.furumi.data.local.OfflineMediaStore import cy.hexor.furumi.data.local.OfflineSettingsStorage @@ -34,6 +36,7 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState import cy.hexor.furumi.domain.model.ConnectedJamUser import cy.hexor.furumi.domain.model.ConnectedPlaybackState import cy.hexor.furumi.domain.model.ListeningHistoryPage +import cy.hexor.furumi.domain.model.OfflineDownloadQueueState import cy.hexor.furumi.domain.model.OfflineState import cy.hexor.furumi.domain.model.OfflineSyncSummary import cy.hexor.furumi.domain.model.PlaylistCard @@ -53,11 +56,19 @@ class PlayerRepositoryImpl @Inject constructor( private val offlineSettingsStorage: OfflineSettingsStorage, private val offlineLibraryStore: OfflineLibraryStore, private val offlineMediaStore: OfflineMediaStore, + private val offlineDownloadQueueStore: OfflineDownloadQueueStore, + private val offlineDownloadJobScheduler: OfflineDownloadJobScheduler, private val appClientInfo: AppClientInfo, private val playerEndpoints: PlayerEndpoints, private val errorParser: AuthApiErrorParser ) : PlayerRepository { override val offlineState: StateFlow = offlineSettingsStorage.state + override val offlineDownloadQueueState: StateFlow = offlineDownloadQueueStore.state + + init { + offlineMediaStore.publishStorageStats() + offlineDownloadQueueStore.refresh() + } override fun setOfflineModeEnabled(enabled: Boolean) { offlineSettingsStorage.setOfflineModeEnabled(enabled) @@ -147,8 +158,10 @@ 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) + offlineMediaStore.validateManualDownloadSize(listOf(track)) + offlineDownloadQueueStore.enqueueDownloads(listOf(track)) + offlineDownloadJobScheduler.schedule(estimatedDownloadBytes(listOf(track))) + Result.success(track) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -159,10 +172,11 @@ class PlayerRepositoryImpl @Inject constructor( override suspend fun downloadTracksForOffline(tracks: List): Result> { return try { - offlineMediaStore.downloadTracksForOffline(tracks) - Result.success(tracks.map { track -> - offlineLibraryStore.getTrack(track.id, preferLocalMedia = true) ?: track - }) + val uniqueTracks = tracks.distinctBy { it.id } + offlineMediaStore.validateManualDownloadSize(uniqueTracks) + offlineDownloadQueueStore.enqueueDownloads(uniqueTracks) + offlineDownloadJobScheduler.schedule(estimatedDownloadBytes(uniqueTracks)) + Result.success(uniqueTracks) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -927,6 +941,16 @@ class PlayerRepositoryImpl @Inject constructor( } } + private fun estimatedDownloadBytes(tracks: List): Long { + return tracks.sumOf { track -> + if (track.isAudioCached) { + 0L + } else { + track.audioSizeBytes ?: 0L + } + } + } + private companion object { const val TRACKS_BY_IDS_CHUNK_SIZE = 500 } diff --git a/app/src/main/java/cy/hexor/furumi/domain/model/OfflineMode.kt b/app/src/main/java/cy/hexor/furumi/domain/model/OfflineMode.kt index d49cf7f..9403e49 100644 --- a/app/src/main/java/cy/hexor/furumi/domain/model/OfflineMode.kt +++ b/app/src/main/java/cy/hexor/furumi/domain/model/OfflineMode.kt @@ -28,3 +28,12 @@ data class OfflineSyncSummary( val playlistCount: Int, val syncedAtMs: Long ) + +data class OfflineDownloadQueueState( + val activeTrackIds: Set = emptySet(), + val failedTrackIds: Set = emptySet(), + val lastError: String? = null +) { + val isDownloading: Boolean + get() = activeTrackIds.isNotEmpty() +} 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 78acb3a..da5d3ed 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 @@ -7,6 +7,7 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState import cy.hexor.furumi.domain.model.ConnectedJamUser import cy.hexor.furumi.domain.model.ConnectedPlaybackState import cy.hexor.furumi.domain.model.ListeningHistoryPage +import cy.hexor.furumi.domain.model.OfflineDownloadQueueState import cy.hexor.furumi.domain.model.OfflineState import cy.hexor.furumi.domain.model.OfflineSyncSummary import cy.hexor.furumi.domain.model.PlaylistCard @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow interface PlayerRepository { val offlineState: StateFlow + val offlineDownloadQueueState: StateFlow suspend fun getArtists( page: Int = 1, diff --git a/app/src/main/java/cy/hexor/furumi/offline/OfflineDownloadJobService.kt b/app/src/main/java/cy/hexor/furumi/offline/OfflineDownloadJobService.kt new file mode 100644 index 0000000..4e8004e --- /dev/null +++ b/app/src/main/java/cy/hexor/furumi/offline/OfflineDownloadJobService.kt @@ -0,0 +1,145 @@ +package cy.hexor.furumi.offline + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.job.JobParameters +import android.app.job.JobService +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import cy.hexor.furumi.MainActivity +import cy.hexor.furumi.data.local.OfflineDownloadQueueStore +import cy.hexor.furumi.data.local.OfflineMediaStore +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@AndroidEntryPoint +class OfflineDownloadJobService : JobService() { + @Inject lateinit var downloadQueueStore: OfflineDownloadQueueStore + @Inject lateinit var offlineMediaStore: OfflineMediaStore + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var activeJob: Job? = null + @Volatile private var stoppedBySystem = false + + override fun onStartJob(params: JobParameters): Boolean { + stoppedBySystem = false + publishUserInitiatedJobNotification(params) + activeJob?.cancel() + activeJob = serviceScope.launch { + try { + runDownloadQueue() + if (!stoppedBySystem) { + jobFinished(params, false) + } + } catch (_: CancellationException) { + if (!stoppedBySystem) { + jobFinished(params, true) + } + } + } + return true + } + + override fun onStopJob(params: JobParameters): Boolean { + stoppedBySystem = true + activeJob?.cancel() + return true + } + + override fun onDestroy() { + activeJob?.cancel() + serviceScope.cancel() + super.onDestroy() + } + + private suspend fun runDownloadQueue() = withContext(Dispatchers.IO) { + downloadQueueStore.refresh() + offlineMediaStore.publishStorageStats() + + while (isActive) { + val track = downloadQueueStore.nextPendingDownloadTrack() ?: break + downloadQueueStore.markRunning(track.id) + try { + offlineMediaStore.downloadTrackForOffline(track) + downloadQueueStore.markCompleted(track.id) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + downloadQueueStore.markFailed(track.id, e.message ?: "Unable to download track") + } + offlineMediaStore.publishStorageStats() + } + + offlineMediaStore.publishStorageStats() + } + + private fun publishUserInitiatedJobNotification(params: JobParameters) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || !params.isUserInitiatedJob) { + return + } + + createNotificationChannel() + setNotification( + params, + NOTIFICATION_ID, + buildNotification(), + JOB_END_NOTIFICATION_POLICY_REMOVE + ) + } + + private fun buildNotification() = + NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("Downloading offline music") + .setContentText("Saving tracks for offline playback") + .setContentIntent(contentIntent()) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setProgress(0, 0, true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setOngoing(true) + .build() + + private fun contentIntent(): PendingIntent = + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val channel = NotificationChannel( + CHANNEL_ID, + "Offline downloads", + NotificationManager.IMPORTANCE_LOW + ).apply { + setShowBadge(false) + enableVibration(false) + enableLights(false) + setSound(null, null) + } + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + private companion object { + const val NOTIFICATION_ID = 1002 + const val CHANNEL_ID = "furumi_offline_downloads" + } +} 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 5cf01b7..93986ef 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 @@ -116,6 +116,7 @@ class PlayerViewModel @Inject constructor( private var searchJob: Job? = null private var jamInviteSearchJob: Job? = null private var offlineModeRefreshJob: Job? = null + private var offlineDownloadRefreshJob: Job? = null private val handledConnectedCommandIds = ArrayDeque() private val handledConnectedCommandIdSet = mutableSetOf() private var wasCurrentDeviceActive = false @@ -144,6 +145,7 @@ class PlayerViewModel @Inject constructor( init { loadGlobalArtistsIfNeeded() observeOfflineState() + observeOfflineDownloadQueue() observePlayback() loadLikedTrackIds() startConnectedDevicesPolling() @@ -762,7 +764,9 @@ class PlayerViewModel @Inject constructor( } fun downloadTrackForOffline(track: TrackCard) { - if (track.isAudioCached || _uiState.value.offlineDownloadingTrackIds.contains(track.id)) { + if (_uiState.value.isDownloadedForOffline(track) || + _uiState.value.isDownloadingForOffline(track) + ) { return } if (_uiState.value.offlineState.isOfflineActive) { @@ -770,24 +774,16 @@ class PlayerViewModel @Inject constructor( 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") + .onSuccess { + _messageEvent.tryEmit("Download queued") } .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 - ) } } @@ -800,32 +796,27 @@ class PlayerViewModel @Inject constructor( return } - val tracks = detail.tracks.filter { !it.isAudioCached } + val currentState = _uiState.value + val tracks = detail.tracks.filter { + !currentState.isDownloadedForOffline(it) && !currentState.isDownloadingForOffline(it) + } 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") + .onSuccess { + _uiState.value = _uiState.value.copy( + offlineDownloadingPlaylistIds = _uiState.value.offlineDownloadingPlaylistIds + playlistId + ) + _messageEvent.tryEmit("Playlist download queued") } .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 - ) } } @@ -1135,6 +1126,35 @@ class PlayerViewModel @Inject constructor( } } + private fun observeOfflineDownloadQueue() { + viewModelScope.launch { + var previousActiveTrackIds = playerRepository.offlineDownloadQueueState.value.activeTrackIds + playerRepository.offlineDownloadQueueState.collect { queueState -> + val completedTrackIds = (previousActiveTrackIds - queueState.activeTrackIds) - + queueState.failedTrackIds + val playlistDetail = _uiState.value.playlistDetail + val activePlaylistIds = if (playlistDetail != null && + playlistDetail.tracks.any { it.id in queueState.activeTrackIds } + ) { + setOf(playlistDetail.playlist.id) + } else { + emptySet() + } + + _uiState.value = _uiState.value.copy( + offlineDownloadedTrackIds = _uiState.value.offlineDownloadedTrackIds + completedTrackIds, + offlineDownloadingTrackIds = queueState.activeTrackIds, + offlineDownloadingPlaylistIds = activePlaylistIds, + offlineDownloadError = queueState.lastError + ) + if (completedTrackIds.isNotEmpty()) { + scheduleOfflineDownloadRefresh() + } + previousActiveTrackIds = queueState.activeTrackIds + } + } + } + private fun startConnectedDevicesPolling() { viewModelScope.launch { while (true) { @@ -1649,6 +1669,14 @@ class PlayerViewModel @Inject constructor( } } + private fun scheduleOfflineDownloadRefresh() { + offlineDownloadRefreshJob?.cancel() + offlineDownloadRefreshJob = viewModelScope.launch { + delay(350L) + refreshAfterOfflineLibraryChange() + } + } + private fun refreshAfterOfflineLibraryChange() { clearModeSensitiveCaches() _uiState.value = _uiState.value.copy( @@ -1666,29 +1694,6 @@ 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 @@ -1746,29 +1751,6 @@ 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(