Added reliable media downloader
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".FurumiApplication"
|
android:name=".FurumiApplication"
|
||||||
@@ -30,6 +31,10 @@
|
|||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
<service
|
||||||
|
android:name=".offline.OfflineDownloadJobService"
|
||||||
|
android:exported="false"
|
||||||
|
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||||
|
|
||||||
<receiver android:name="androidx.media3.session.MediaButtonReceiver"
|
<receiver android:name="androidx.media3.session.MediaButtonReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OfflineDownloadQueueState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun enqueueDownloads(tracks: List<TrackCard>): 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.ArtistCard
|
||||||
import cy.hexor.furumi.domain.model.ArtistDetail
|
import cy.hexor.furumi.domain.model.ArtistDetail
|
||||||
import cy.hexor.furumi.domain.model.ArtistPage
|
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.PlaylistCard
|
||||||
import cy.hexor.furumi.domain.model.PlaylistDetail
|
import cy.hexor.furumi.domain.model.PlaylistDetail
|
||||||
import cy.hexor.furumi.domain.model.ReleaseCard
|
import cy.hexor.furumi.domain.model.ReleaseCard
|
||||||
@@ -510,6 +511,119 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun enqueueOfflineDownloadTracks(tracks: List<TrackCard>): 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<Long>()
|
||||||
|
val failedTrackIds = mutableSetOf<Long>()
|
||||||
|
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<TrackCard> {
|
private fun getAllTracks(preferLocalMedia: Boolean): List<TrackCard> {
|
||||||
return dbHelper.readableDatabase.query(
|
return dbHelper.readableDatabase.query(
|
||||||
TABLE_TRACKS,
|
TABLE_TRACKS,
|
||||||
@@ -730,6 +844,7 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
)
|
)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
)
|
)
|
||||||
|
createDownloadQueueTable(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
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("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)")
|
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 {
|
private companion object {
|
||||||
const val DB_NAME = "furumi_offline.db"
|
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_TRACKS = "offline_tracks"
|
||||||
const val TABLE_PLAYLISTS = "offline_playlists"
|
const val TABLE_PLAYLISTS = "offline_playlists"
|
||||||
const val TABLE_LIKES = "offline_likes"
|
const val TABLE_LIKES = "offline_likes"
|
||||||
|
const val TABLE_DOWNLOAD_QUEUE = "offline_download_queue"
|
||||||
|
|
||||||
const val COL_TRACK_ID = "track_id"
|
const val COL_TRACK_ID = "track_id"
|
||||||
const val COL_TITLE = "title"
|
const val COL_TITLE = "title"
|
||||||
@@ -791,9 +925,18 @@ class OfflineLibraryStore @Inject constructor(
|
|||||||
|
|
||||||
const val COL_LIKE_TRACK_ID = "track_id"
|
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_NONE = "none"
|
||||||
const val STATUS_READY = "ready"
|
const val STATUS_READY = "ready"
|
||||||
const val STATUS_STALE = "stale"
|
const val STATUS_STALE = "stale"
|
||||||
|
const val STATUS_PENDING = "pending"
|
||||||
|
const val STATUS_RUNNING = "running"
|
||||||
|
const val STATUS_FAILED = "failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class OfflineMediaStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun validateManualDownloadSize(tracks: List<TrackCard>) = withContext(Dispatchers.IO) {
|
||||||
|
ensureManualDownloadsCanFit(tracks.distinctBy { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun cacheTrackMediaBlocking(track: TrackCard, pinAudio: Boolean, requireAudio: Boolean) {
|
private suspend fun cacheTrackMediaBlocking(track: TrackCard, pinAudio: Boolean, requireAudio: Boolean) {
|
||||||
libraryStore.upsertTracks(listOf(track))
|
libraryStore.upsertTracks(listOf(track))
|
||||||
libraryStore.markTrackAccessed(track.id)
|
libraryStore.markTrackAccessed(track.id)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package cy.hexor.furumi.data.repository
|
|||||||
|
|
||||||
import cy.hexor.furumi.data.local.AuthSessionStorage
|
import cy.hexor.furumi.data.local.AuthSessionStorage
|
||||||
import cy.hexor.furumi.data.local.ConnectedDeviceStorage
|
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.OfflineLibraryStore
|
||||||
import cy.hexor.furumi.data.local.OfflineMediaStore
|
import cy.hexor.furumi.data.local.OfflineMediaStore
|
||||||
import cy.hexor.furumi.data.local.OfflineSettingsStorage
|
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.ConnectedJamUser
|
||||||
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
||||||
import cy.hexor.furumi.domain.model.ListeningHistoryPage
|
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.OfflineState
|
||||||
import cy.hexor.furumi.domain.model.OfflineSyncSummary
|
import cy.hexor.furumi.domain.model.OfflineSyncSummary
|
||||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||||
@@ -53,11 +56,19 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
private val offlineSettingsStorage: OfflineSettingsStorage,
|
private val offlineSettingsStorage: OfflineSettingsStorage,
|
||||||
private val offlineLibraryStore: OfflineLibraryStore,
|
private val offlineLibraryStore: OfflineLibraryStore,
|
||||||
private val offlineMediaStore: OfflineMediaStore,
|
private val offlineMediaStore: OfflineMediaStore,
|
||||||
|
private val offlineDownloadQueueStore: OfflineDownloadQueueStore,
|
||||||
|
private val offlineDownloadJobScheduler: OfflineDownloadJobScheduler,
|
||||||
private val appClientInfo: AppClientInfo,
|
private val appClientInfo: AppClientInfo,
|
||||||
private val playerEndpoints: PlayerEndpoints,
|
private val playerEndpoints: PlayerEndpoints,
|
||||||
private val errorParser: AuthApiErrorParser
|
private val errorParser: AuthApiErrorParser
|
||||||
) : PlayerRepository {
|
) : PlayerRepository {
|
||||||
override val offlineState: StateFlow<OfflineState> = offlineSettingsStorage.state
|
override val offlineState: StateFlow<OfflineState> = offlineSettingsStorage.state
|
||||||
|
override val offlineDownloadQueueState: StateFlow<OfflineDownloadQueueState> = offlineDownloadQueueStore.state
|
||||||
|
|
||||||
|
init {
|
||||||
|
offlineMediaStore.publishStorageStats()
|
||||||
|
offlineDownloadQueueStore.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
override fun setOfflineModeEnabled(enabled: Boolean) {
|
override fun setOfflineModeEnabled(enabled: Boolean) {
|
||||||
offlineSettingsStorage.setOfflineModeEnabled(enabled)
|
offlineSettingsStorage.setOfflineModeEnabled(enabled)
|
||||||
@@ -147,8 +158,10 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun downloadTrackForOffline(track: TrackCard): Result<TrackCard> {
|
override suspend fun downloadTrackForOffline(track: TrackCard): Result<TrackCard> {
|
||||||
return try {
|
return try {
|
||||||
offlineMediaStore.downloadTrackForOffline(track)
|
offlineMediaStore.validateManualDownloadSize(listOf(track))
|
||||||
Result.success(offlineLibraryStore.getTrack(track.id, preferLocalMedia = true) ?: track)
|
offlineDownloadQueueStore.enqueueDownloads(listOf(track))
|
||||||
|
offlineDownloadJobScheduler.schedule(estimatedDownloadBytes(listOf(track)))
|
||||||
|
Result.success(track)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -159,10 +172,11 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun downloadTracksForOffline(tracks: List<TrackCard>): Result<List<TrackCard>> {
|
override suspend fun downloadTracksForOffline(tracks: List<TrackCard>): Result<List<TrackCard>> {
|
||||||
return try {
|
return try {
|
||||||
offlineMediaStore.downloadTracksForOffline(tracks)
|
val uniqueTracks = tracks.distinctBy { it.id }
|
||||||
Result.success(tracks.map { track ->
|
offlineMediaStore.validateManualDownloadSize(uniqueTracks)
|
||||||
offlineLibraryStore.getTrack(track.id, preferLocalMedia = true) ?: track
|
offlineDownloadQueueStore.enqueueDownloads(uniqueTracks)
|
||||||
})
|
offlineDownloadJobScheduler.schedule(estimatedDownloadBytes(uniqueTracks))
|
||||||
|
Result.success(uniqueTracks)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -927,6 +941,16 @@ class PlayerRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun estimatedDownloadBytes(tracks: List<TrackCard>): Long {
|
||||||
|
return tracks.sumOf { track ->
|
||||||
|
if (track.isAudioCached) {
|
||||||
|
0L
|
||||||
|
} else {
|
||||||
|
track.audioSizeBytes ?: 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TRACKS_BY_IDS_CHUNK_SIZE = 500
|
const val TRACKS_BY_IDS_CHUNK_SIZE = 500
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,3 +28,12 @@ data class OfflineSyncSummary(
|
|||||||
val playlistCount: Int,
|
val playlistCount: Int,
|
||||||
val syncedAtMs: Long
|
val syncedAtMs: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class OfflineDownloadQueueState(
|
||||||
|
val activeTrackIds: Set<Long> = emptySet(),
|
||||||
|
val failedTrackIds: Set<Long> = emptySet(),
|
||||||
|
val lastError: String? = null
|
||||||
|
) {
|
||||||
|
val isDownloading: Boolean
|
||||||
|
get() = activeTrackIds.isNotEmpty()
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import cy.hexor.furumi.domain.model.ConnectedDevicesState
|
|||||||
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
import cy.hexor.furumi.domain.model.ConnectedJamUser
|
||||||
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
import cy.hexor.furumi.domain.model.ConnectedPlaybackState
|
||||||
import cy.hexor.furumi.domain.model.ListeningHistoryPage
|
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.OfflineState
|
||||||
import cy.hexor.furumi.domain.model.OfflineSyncSummary
|
import cy.hexor.furumi.domain.model.OfflineSyncSummary
|
||||||
import cy.hexor.furumi.domain.model.PlaylistCard
|
import cy.hexor.furumi.domain.model.PlaylistCard
|
||||||
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
|
|
||||||
interface PlayerRepository {
|
interface PlayerRepository {
|
||||||
val offlineState: StateFlow<OfflineState>
|
val offlineState: StateFlow<OfflineState>
|
||||||
|
val offlineDownloadQueueState: StateFlow<OfflineDownloadQueueState>
|
||||||
|
|
||||||
suspend fun getArtists(
|
suspend fun getArtists(
|
||||||
page: Int = 1,
|
page: Int = 1,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
private var jamInviteSearchJob: Job? = null
|
private var jamInviteSearchJob: Job? = null
|
||||||
private var offlineModeRefreshJob: Job? = null
|
private var offlineModeRefreshJob: Job? = null
|
||||||
|
private var offlineDownloadRefreshJob: Job? = null
|
||||||
private val handledConnectedCommandIds = ArrayDeque<String>()
|
private val handledConnectedCommandIds = ArrayDeque<String>()
|
||||||
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
||||||
private var wasCurrentDeviceActive = false
|
private var wasCurrentDeviceActive = false
|
||||||
@@ -144,6 +145,7 @@ class PlayerViewModel @Inject constructor(
|
|||||||
init {
|
init {
|
||||||
loadGlobalArtistsIfNeeded()
|
loadGlobalArtistsIfNeeded()
|
||||||
observeOfflineState()
|
observeOfflineState()
|
||||||
|
observeOfflineDownloadQueue()
|
||||||
observePlayback()
|
observePlayback()
|
||||||
loadLikedTrackIds()
|
loadLikedTrackIds()
|
||||||
startConnectedDevicesPolling()
|
startConnectedDevicesPolling()
|
||||||
@@ -762,7 +764,9 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun downloadTrackForOffline(track: TrackCard) {
|
fun downloadTrackForOffline(track: TrackCard) {
|
||||||
if (track.isAudioCached || _uiState.value.offlineDownloadingTrackIds.contains(track.id)) {
|
if (_uiState.value.isDownloadedForOffline(track) ||
|
||||||
|
_uiState.value.isDownloadingForOffline(track)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (_uiState.value.offlineState.isOfflineActive) {
|
if (_uiState.value.offlineState.isOfflineActive) {
|
||||||
@@ -770,24 +774,16 @@ class PlayerViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
offlineDownloadingTrackIds = _uiState.value.offlineDownloadingTrackIds + track.id,
|
|
||||||
offlineDownloadError = null
|
|
||||||
)
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playerRepository.downloadTrackForOffline(track)
|
playerRepository.downloadTrackForOffline(track)
|
||||||
.onSuccess { downloadedTrack ->
|
.onSuccess {
|
||||||
applyDownloadedOfflineTracks(listOf(downloadedTrack))
|
_messageEvent.tryEmit("Download queued")
|
||||||
_messageEvent.tryEmit("Downloaded for offline")
|
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
val message = error.message ?: "Unable to download track"
|
val message = error.message ?: "Unable to download track"
|
||||||
_uiState.value = _uiState.value.copy(offlineDownloadError = message)
|
_uiState.value = _uiState.value.copy(offlineDownloadError = message)
|
||||||
_messageEvent.tryEmit(message)
|
_messageEvent.tryEmit(message)
|
||||||
}
|
}
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
offlineDownloadingTrackIds = _uiState.value.offlineDownloadingTrackIds - track.id
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,32 +796,27 @@ class PlayerViewModel @Inject constructor(
|
|||||||
return
|
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()) {
|
if (tracks.isEmpty()) {
|
||||||
_messageEvent.tryEmit("Playlist is already downloaded")
|
_messageEvent.tryEmit("Playlist is already downloaded")
|
||||||
return
|
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 {
|
viewModelScope.launch {
|
||||||
playerRepository.downloadTracksForOffline(tracks)
|
playerRepository.downloadTracksForOffline(tracks)
|
||||||
.onSuccess { downloadedTracks ->
|
.onSuccess {
|
||||||
applyDownloadedOfflineTracks(downloadedTracks)
|
_uiState.value = _uiState.value.copy(
|
||||||
_messageEvent.tryEmit("Playlist downloaded for offline")
|
offlineDownloadingPlaylistIds = _uiState.value.offlineDownloadingPlaylistIds + playlistId
|
||||||
|
)
|
||||||
|
_messageEvent.tryEmit("Playlist download queued")
|
||||||
}
|
}
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
val message = error.message ?: "Unable to download playlist"
|
val message = error.message ?: "Unable to download playlist"
|
||||||
_uiState.value = _uiState.value.copy(offlineDownloadError = message)
|
_uiState.value = _uiState.value.copy(offlineDownloadError = message)
|
||||||
_messageEvent.tryEmit(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() {
|
private fun startConnectedDevicesPolling() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -1649,6 +1669,14 @@ class PlayerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleOfflineDownloadRefresh() {
|
||||||
|
offlineDownloadRefreshJob?.cancel()
|
||||||
|
offlineDownloadRefreshJob = viewModelScope.launch {
|
||||||
|
delay(350L)
|
||||||
|
refreshAfterOfflineLibraryChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshAfterOfflineLibraryChange() {
|
private fun refreshAfterOfflineLibraryChange() {
|
||||||
clearModeSensitiveCaches()
|
clearModeSensitiveCaches()
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
@@ -1666,29 +1694,6 @@ class PlayerViewModel @Inject constructor(
|
|||||||
_uiState.value.selectedRelease?.id?.let(::loadReleaseDetail)
|
_uiState.value.selectedRelease?.id?.let(::loadReleaseDetail)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyDownloadedOfflineTracks(downloadedTracks: List<TrackCard>) {
|
|
||||||
if (downloadedTracks.isEmpty()) return
|
|
||||||
val replacements = downloadedTracks.associateBy { it.id }
|
|
||||||
artistDetailCache.keys.toList().forEach { key ->
|
|
||||||
artistDetailCache[key] = artistDetailCache.getValue(key).withReplacedTracks(replacements)
|
|
||||||
}
|
|
||||||
releaseDetailCache.keys.toList().forEach { key ->
|
|
||||||
releaseDetailCache[key] = releaseDetailCache.getValue(key).withReplacedTracks(replacements)
|
|
||||||
}
|
|
||||||
playlistDetailCache.keys.toList().forEach { key ->
|
|
||||||
playlistDetailCache[key] = playlistDetailCache.getValue(key).withReplacedTracks(replacements)
|
|
||||||
}
|
|
||||||
|
|
||||||
val state = _uiState.value
|
|
||||||
_uiState.value = state.copy(
|
|
||||||
offlineDownloadedTrackIds = state.offlineDownloadedTrackIds + replacements.keys,
|
|
||||||
searchResults = state.searchResults?.withReplacedTracks(replacements),
|
|
||||||
artistDetail = state.artistDetail?.withReplacedTracks(replacements),
|
|
||||||
releaseDetail = state.releaseDetail?.withReplacedTracks(replacements),
|
|
||||||
playlistDetail = state.playlistDetail?.withReplacedTracks(replacements)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val ARTIST_PAGE_SIZE = 60
|
const val ARTIST_PAGE_SIZE = 60
|
||||||
const val SEARCH_DEBOUNCE_MS = 300L
|
const val SEARCH_DEBOUNCE_MS = 300L
|
||||||
@@ -1746,29 +1751,6 @@ private fun PlaylistDetail.previewCoverUrls(): List<String> {
|
|||||||
}.take(PLAYLIST_PREVIEW_IMAGE_LIMIT)
|
}.take(PLAYLIST_PREVIEW_IMAGE_LIMIT)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ArtistDetail.withReplacedTracks(replacements: Map<Long, TrackCard>): ArtistDetail {
|
|
||||||
return copy(
|
|
||||||
topTracks = topTracks.withReplacedTracks(replacements),
|
|
||||||
featuredTracks = featuredTracks.withReplacedTracks(replacements)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ReleaseDetail.withReplacedTracks(replacements: Map<Long, TrackCard>): ReleaseDetail {
|
|
||||||
return copy(tracks = tracks.withReplacedTracks(replacements))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PlaylistDetail.withReplacedTracks(replacements: Map<Long, TrackCard>): PlaylistDetail {
|
|
||||||
return copy(tracks = tracks.withReplacedTracks(replacements))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SearchResults.withReplacedTracks(replacements: Map<Long, TrackCard>): SearchResults {
|
|
||||||
return copy(tracks = tracks.withReplacedTracks(replacements))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<TrackCard>.withReplacedTracks(replacements: Map<Long, TrackCard>): List<TrackCard> {
|
|
||||||
return map { track -> replacements[track.id] ?: track }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults {
|
private fun SearchResults.withArtistCards(artistCards: Map<Long, ArtistCard>): SearchResults {
|
||||||
if (artistCards.isEmpty()) return this
|
if (artistCards.isEmpty()) return this
|
||||||
return copy(
|
return copy(
|
||||||
|
|||||||
Reference in New Issue
Block a user