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(