Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f6b92fe0 | ||
|
|
68d8da0f85 |
@@ -7,6 +7,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
|
||||
|
||||
<application
|
||||
android:name=".FurumiApplication"
|
||||
@@ -30,6 +31,10 @@
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".offline.OfflineDownloadJobService"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
|
||||
<receiver android:name="androidx.media3.session.MediaButtonReceiver"
|
||||
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.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
|
||||
@@ -385,6 +386,18 @@ class OfflineLibraryStore @Inject constructor(
|
||||
putNull(COL_LOCAL_AUDIO_PATH)
|
||||
put(COL_AUDIO_STATUS, STATUS_NONE)
|
||||
put(COL_CACHED_AUDIO_BYTES, 0L)
|
||||
put(COL_AUDIO_PINNED, 0)
|
||||
},
|
||||
"$COL_TRACK_ID = ?",
|
||||
arrayOf(trackId.toString())
|
||||
)
|
||||
}
|
||||
|
||||
fun markAudioPinned(trackId: Long, pinned: Boolean) {
|
||||
dbHelper.writableDatabase.update(
|
||||
TABLE_TRACKS,
|
||||
ContentValues().apply {
|
||||
put(COL_AUDIO_PINNED, pinned.toInt())
|
||||
},
|
||||
"$COL_TRACK_ID = ?",
|
||||
arrayOf(trackId.toString())
|
||||
@@ -411,6 +424,7 @@ class OfflineLibraryStore @Inject constructor(
|
||||
putNull(COL_LOCAL_AUDIO_PATH)
|
||||
put(COL_AUDIO_STATUS, STATUS_NONE)
|
||||
put(COL_CACHED_AUDIO_BYTES, 0L)
|
||||
put(COL_AUDIO_PINNED, 0)
|
||||
putNull(COL_LOCAL_COVER_PATH)
|
||||
put(COL_COVER_STATUS, STATUS_NONE)
|
||||
put(COL_CACHED_COVER_BYTES, 0L)
|
||||
@@ -430,7 +444,7 @@ class OfflineLibraryStore @Inject constructor(
|
||||
COL_LAST_ACCESSED_AT,
|
||||
COL_PLAY_COUNT
|
||||
),
|
||||
"$COL_AUDIO_STATUS = ? AND $COL_LOCAL_AUDIO_PATH IS NOT NULL",
|
||||
"$COL_AUDIO_STATUS = ? AND $COL_LOCAL_AUDIO_PATH IS NOT NULL AND $COL_AUDIO_PINNED = 0",
|
||||
arrayOf(STATUS_READY),
|
||||
null,
|
||||
null,
|
||||
@@ -497,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> {
|
||||
return dbHelper.readableDatabase.query(
|
||||
TABLE_TRACKS,
|
||||
@@ -550,6 +677,7 @@ class OfflineLibraryStore @Inject constructor(
|
||||
put(COL_CACHED_COVER_BYTES, 0L)
|
||||
put(COL_LAST_ACCESSED_AT, 0L)
|
||||
put(COL_PLAY_COUNT, 0)
|
||||
put(COL_AUDIO_PINNED, 0)
|
||||
}
|
||||
}
|
||||
db.insertOrUpdate(TABLE_TRACKS, COL_TRACK_ID, track.id, values)
|
||||
@@ -684,13 +812,15 @@ class OfflineLibraryStore @Inject constructor(
|
||||
$COL_CACHED_AUDIO_BYTES INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_CACHED_COVER_BYTES INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_LAST_ACCESSED_AT INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_PLAY_COUNT INTEGER NOT NULL DEFAULT 0
|
||||
$COL_PLAY_COUNT INTEGER NOT NULL DEFAULT 0,
|
||||
$COL_AUDIO_PINNED INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
db.execSQL("CREATE INDEX idx_offline_tracks_title ON $TABLE_TRACKS($COL_TITLE_SORT)")
|
||||
db.execSQL("CREATE INDEX idx_offline_tracks_release ON $TABLE_TRACKS($COL_RELEASE_ID)")
|
||||
db.execSQL("CREATE INDEX idx_offline_tracks_audio_status ON $TABLE_TRACKS($COL_AUDIO_STATUS)")
|
||||
db.execSQL("CREATE INDEX idx_offline_tracks_audio_pinned ON $TABLE_TRACKS($COL_AUDIO_PINNED)")
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE $TABLE_PLAYLISTS (
|
||||
@@ -714,23 +844,43 @@ class OfflineLibraryStore @Inject constructor(
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
createDownloadQueueTable(db)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_LIKES")
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_PLAYLISTS")
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_TRACKS")
|
||||
onCreate(db)
|
||||
if (oldVersion < 2) {
|
||||
db.execSQL("ALTER TABLE $TABLE_TRACKS ADD COLUMN $COL_AUDIO_PINNED INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_offline_tracks_audio_pinned ON $TABLE_TRACKS($COL_AUDIO_PINNED)")
|
||||
}
|
||||
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 = 1
|
||||
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"
|
||||
@@ -760,6 +910,7 @@ class OfflineLibraryStore @Inject constructor(
|
||||
const val COL_CACHED_COVER_BYTES = "cached_cover_bytes"
|
||||
const val COL_LAST_ACCESSED_AT = "last_accessed_at"
|
||||
const val COL_PLAY_COUNT = "play_count"
|
||||
const val COL_AUDIO_PINNED = "audio_pinned"
|
||||
|
||||
const val COL_PLAYLIST_ID = "playlist_id"
|
||||
const val COL_PLAYLIST_TITLE = "title"
|
||||
@@ -774,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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,44 @@ class OfflineMediaStore @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun cacheTrackMedia(track: TrackCard) = withContext(Dispatchers.IO) {
|
||||
cacheTrackMediaBlocking(track = track, pinAudio = false, requireAudio = false)
|
||||
}
|
||||
|
||||
suspend fun downloadTrackForOffline(track: TrackCard) = withContext(Dispatchers.IO) {
|
||||
ensureManualDownloadsCanFit(listOf(track))
|
||||
cacheTrackMediaBlocking(track = track, pinAudio = true, requireAudio = true)
|
||||
}
|
||||
|
||||
suspend fun downloadTracksForOffline(tracks: List<TrackCard>) = withContext(Dispatchers.IO) {
|
||||
val uniqueTracks = tracks.distinctBy { it.id }
|
||||
ensureManualDownloadsCanFit(uniqueTracks)
|
||||
uniqueTracks.forEach { track ->
|
||||
cacheTrackMediaBlocking(track = track, pinAudio = true, requireAudio = true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun validateManualDownloadSize(tracks: List<TrackCard>) = 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)
|
||||
pruneStaleMedia()
|
||||
|
||||
val storedTrack = libraryStore.getTrack(track.id, preferLocalMedia = false) ?: track
|
||||
val audioPath = storedTrack.localAudioPath
|
||||
if (audioPath.isNullOrBlank() || !File(audioPath).exists()) {
|
||||
val hadCachedAudio = !audioPath.isNullOrBlank() && File(audioPath).exists()
|
||||
val hasAudio = if (!hadCachedAudio) {
|
||||
downloadAudio(storedTrack)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
if (requireAudio && !hasAudio) {
|
||||
throw IOException("Track has no downloadable audio source")
|
||||
}
|
||||
if (pinAudio && hasAudio) {
|
||||
libraryStore.markAudioPinned(storedTrack.id, pinned = true)
|
||||
}
|
||||
|
||||
val coverPath = storedTrack.localCoverPath
|
||||
@@ -54,6 +84,9 @@ class OfflineMediaStore @Inject constructor(
|
||||
}
|
||||
|
||||
enforceStorageLimit()
|
||||
if (pinAudio) {
|
||||
enforceManualStorageLimit(storedTrack.id, canRemoveAudio = !hadCachedAudio)
|
||||
}
|
||||
publishStorageStats()
|
||||
}
|
||||
|
||||
@@ -79,9 +112,9 @@ class OfflineMediaStore @Inject constructor(
|
||||
settingsStorage.setStorageStats(stats.usedBytes, stats.cachedTrackCount)
|
||||
}
|
||||
|
||||
private fun downloadAudio(track: TrackCard) {
|
||||
private fun downloadAudio(track: TrackCard): Boolean {
|
||||
val sourceUrl = track.remoteStreamUrl.ifBlank { track.streamUrl }
|
||||
if (sourceUrl.isBlank()) return
|
||||
if (sourceUrl.isBlank()) return false
|
||||
|
||||
val extension = extensionForMime(track.audioMimeType)
|
||||
?: extensionFromUrl(sourceUrl)
|
||||
@@ -89,6 +122,7 @@ class OfflineMediaStore @Inject constructor(
|
||||
val target = File(audioDir, "${track.id}-${track.audioHash ?: track.audioUpdatedAt ?: "audio"}.$extension")
|
||||
val bytes = downloadToFile(sourceUrl, target)
|
||||
libraryStore.markAudioCached(track.id, target.absolutePath, bytes)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun downloadCover(track: TrackCard) {
|
||||
@@ -142,6 +176,39 @@ class OfflineMediaStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureManualDownloadsCanFit(tracks: List<TrackCard>) {
|
||||
val limitBytes = settingsStorage.state.value.settings.storageLimitBytes
|
||||
val stats = libraryStore.storageStats()
|
||||
val evictableBytes = libraryStore.cachedAudioEntriesForEviction().sumOf { it.sizeBytes }
|
||||
val protectedUsedBytes = (stats.usedBytes - evictableBytes).coerceAtLeast(0L)
|
||||
val estimatedNeededBytes = tracks.sumOf { track ->
|
||||
val storedTrack = libraryStore.getTrack(track.id, preferLocalMedia = false)
|
||||
if (storedTrack?.isAudioCached == true) {
|
||||
0L
|
||||
} else {
|
||||
track.audioSizeBytes ?: storedTrack?.audioSizeBytes ?: 0L
|
||||
}
|
||||
}
|
||||
|
||||
if (estimatedNeededBytes > 0 && protectedUsedBytes + estimatedNeededBytes > limitBytes) {
|
||||
throw IOException("Not enough offline storage limit for this download")
|
||||
}
|
||||
}
|
||||
|
||||
private fun enforceManualStorageLimit(trackId: Long, canRemoveAudio: Boolean) {
|
||||
val limitBytes = settingsStorage.state.value.settings.storageLimitBytes
|
||||
enforceStorageLimit()
|
||||
if (libraryStore.storageStats().usedBytes <= limitBytes) return
|
||||
|
||||
if (canRemoveAudio) {
|
||||
libraryStore.getTrack(trackId, preferLocalMedia = false)
|
||||
?.localAudioPath
|
||||
?.let { path -> File(path).delete() }
|
||||
libraryStore.markAudioRemoved(trackId)
|
||||
}
|
||||
throw IOException("Not enough offline storage limit for this download")
|
||||
}
|
||||
|
||||
private fun extensionForMime(mimeType: String?): String? {
|
||||
return when (mimeType?.lowercase()) {
|
||||
"audio/mpeg", "audio/mp3" -> "mp3"
|
||||
|
||||
@@ -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<OfflineState> = offlineSettingsStorage.state
|
||||
override val offlineDownloadQueueState: StateFlow<OfflineDownloadQueueState> = offlineDownloadQueueStore.state
|
||||
|
||||
init {
|
||||
offlineMediaStore.publishStorageStats()
|
||||
offlineDownloadQueueStore.refresh()
|
||||
}
|
||||
|
||||
override fun setOfflineModeEnabled(enabled: Boolean) {
|
||||
offlineSettingsStorage.setOfflineModeEnabled(enabled)
|
||||
@@ -145,6 +156,35 @@ class PlayerRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadTrackForOffline(track: TrackCard): Result<TrackCard> {
|
||||
return try {
|
||||
offlineMediaStore.validateManualDownloadSize(listOf(track))
|
||||
offlineDownloadQueueStore.enqueueDownloads(listOf(track))
|
||||
offlineDownloadJobScheduler.schedule(estimatedDownloadBytes(listOf(track)))
|
||||
Result.success(track)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
offlineSettingsStorage.setLastError(e.message ?: "Unable to download track")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadTracksForOffline(tracks: List<TrackCard>): Result<List<TrackCard>> {
|
||||
return try {
|
||||
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) {
|
||||
offlineSettingsStorage.setLastError(e.message ?: "Unable to download tracks")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getArtists(page: Int, limit: Int, mine: Boolean): Result<ArtistPage> {
|
||||
if (isOfflineModeEnabled()) {
|
||||
return Result.success(offlineLibraryStore.getArtists(page, limit))
|
||||
@@ -901,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 {
|
||||
const val TRACKS_BY_IDS_CHUNK_SIZE = 500
|
||||
}
|
||||
|
||||
@@ -28,3 +28,12 @@ data class OfflineSyncSummary(
|
||||
val playlistCount: Int,
|
||||
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.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<OfflineState>
|
||||
val offlineDownloadQueueState: StateFlow<OfflineDownloadQueueState>
|
||||
|
||||
suspend fun getArtists(
|
||||
page: Int = 1,
|
||||
@@ -96,5 +98,9 @@ interface PlayerRepository {
|
||||
|
||||
suspend fun syncOfflineLibrary(): Result<OfflineSyncSummary>
|
||||
|
||||
suspend fun downloadTrackForOffline(track: TrackCard): Result<TrackCard>
|
||||
|
||||
suspend fun downloadTracksForOffline(tracks: List<TrackCard>): Result<List<TrackCard>>
|
||||
|
||||
suspend fun clearOfflineCache(): Result<Unit>
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,8 @@ internal fun ArtistDetailContent(
|
||||
onPlayNext: (TrackCard) -> Unit,
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
onDownloadForOffline: (TrackCard) -> Unit
|
||||
) {
|
||||
val artist = uiState.artistDetail?.artist ?: uiState.selectedArtist ?: return
|
||||
val artistImage = mediaImageFor(uiState.mediaImages, artist.imageUrl)
|
||||
@@ -275,6 +276,7 @@ internal fun ArtistDetailContent(
|
||||
val detail = uiState.artistDetail
|
||||
if (detail.topTracks.isNotEmpty()) {
|
||||
PopularTracksSection(
|
||||
uiState = uiState,
|
||||
tracks = detail.topTracks,
|
||||
mediaImages = uiState.mediaImages,
|
||||
likedTrackIds = uiState.likedTrackIds,
|
||||
@@ -284,7 +286,8 @@ internal fun ArtistDetailContent(
|
||||
onPlayNext = onPlayNext,
|
||||
onPlayLast = onPlayLast,
|
||||
onShare = onShare,
|
||||
onAddToPlaylist = onAddToPlaylist
|
||||
onAddToPlaylist = onAddToPlaylist,
|
||||
onDownloadForOffline = onDownloadForOffline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
}
|
||||
@@ -405,6 +408,7 @@ internal fun ArtistDetailError(
|
||||
|
||||
@Composable
|
||||
internal fun PopularTracksSection(
|
||||
uiState: PlayerUiState,
|
||||
tracks: List<TrackCard>,
|
||||
mediaImages: Map<String, Bitmap>,
|
||||
likedTrackIds: Set<Long>,
|
||||
@@ -414,7 +418,8 @@ internal fun PopularTracksSection(
|
||||
onPlayNext: (TrackCard) -> Unit,
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
onDownloadForOffline: (TrackCard) -> Unit
|
||||
) {
|
||||
SectionTitle("Popular")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -425,13 +430,17 @@ internal fun PopularTracksSection(
|
||||
track = track,
|
||||
bitmap = mediaImageFor(mediaImages, track.coverUrl),
|
||||
isLiked = likedTrackIds.contains(track.id),
|
||||
isDownloadedForOffline = uiState.isDownloadedForOffline(track),
|
||||
isDownloadingForOffline = uiState.isDownloadingForOffline(track),
|
||||
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||
onMediaImageNeeded = onMediaImageNeeded,
|
||||
onTrackClick = onTrackClick,
|
||||
onToggleLike = { onToggleLike(track.id) },
|
||||
onPlayNext = { onPlayNext(track) },
|
||||
onPlayLast = { onPlayLast(track) },
|
||||
onShare = { onShare(track) },
|
||||
onAddToPlaylist = { onAddToPlaylist(track) }
|
||||
onAddToPlaylist = { onAddToPlaylist(track) },
|
||||
onDownloadForOffline = { onDownloadForOffline(track) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,8 @@ internal fun SearchContent(
|
||||
onPlayNext: (TrackCard) -> Unit,
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
onDownloadForOffline: (TrackCard) -> Unit
|
||||
) {
|
||||
val query = uiState.searchQuery
|
||||
val results = uiState.searchResults
|
||||
@@ -148,6 +149,7 @@ internal fun SearchContent(
|
||||
|
||||
if (tracks.isNotEmpty()) {
|
||||
SearchTracksSection(
|
||||
uiState = uiState,
|
||||
tracks = tracks,
|
||||
mediaImages = uiState.mediaImages,
|
||||
likedTrackIds = uiState.likedTrackIds,
|
||||
@@ -157,7 +159,8 @@ internal fun SearchContent(
|
||||
onPlayNext = onPlayNext,
|
||||
onPlayLast = onPlayLast,
|
||||
onShare = onShare,
|
||||
onAddToPlaylist = onAddToPlaylist
|
||||
onAddToPlaylist = onAddToPlaylist,
|
||||
onDownloadForOffline = onDownloadForOffline
|
||||
)
|
||||
}
|
||||
|
||||
@@ -237,6 +240,7 @@ private fun SearchArtistCard(
|
||||
|
||||
@Composable
|
||||
private fun SearchTracksSection(
|
||||
uiState: PlayerUiState,
|
||||
tracks: List<TrackCard>,
|
||||
mediaImages: Map<String, Bitmap>,
|
||||
likedTrackIds: Set<Long>,
|
||||
@@ -246,7 +250,8 @@ private fun SearchTracksSection(
|
||||
onPlayNext: (TrackCard) -> Unit,
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
onDownloadForOffline: (TrackCard) -> Unit
|
||||
) {
|
||||
SectionTitle("Tracks")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -257,13 +262,17 @@ private fun SearchTracksSection(
|
||||
track = track,
|
||||
bitmap = mediaImageFor(mediaImages, track.coverUrl),
|
||||
isLiked = likedTrackIds.contains(track.id),
|
||||
isDownloadedForOffline = uiState.isDownloadedForOffline(track),
|
||||
isDownloadingForOffline = uiState.isDownloadingForOffline(track),
|
||||
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||
onMediaImageNeeded = onMediaImageNeeded,
|
||||
onTrackClick = onTrackClick,
|
||||
onToggleLike = { onToggleLike(track.id) },
|
||||
onPlayNext = { onPlayNext(track) },
|
||||
onPlayLast = { onPlayLast(track) },
|
||||
onShare = { onShare(track) },
|
||||
onAddToPlaylist = { onAddToPlaylist(track) }
|
||||
onAddToPlaylist = { onAddToPlaylist(track) },
|
||||
onDownloadForOffline = { onDownloadForOffline(track) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -632,10 +641,14 @@ internal fun PlaylistDetailContent(
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
onDownloadForOffline: (TrackCard) -> Unit,
|
||||
onDownloadPlaylistClick: () -> Unit,
|
||||
onDeletePlaylistClick: (PlaylistCard) -> Unit
|
||||
) {
|
||||
val playlist = uiState.playlistDetail?.playlist ?: uiState.selectedPlaylist ?: return
|
||||
val tracks = uiState.playlistDetail?.tracks.orEmpty()
|
||||
val isPlaylistDownloading = uiState.offlineDownloadingPlaylistIds.contains(playlist.id)
|
||||
val isPlaylistDownloaded = tracks.isNotEmpty() && tracks.all(uiState::isDownloadedForOffline)
|
||||
val heroTrack = tracks.firstOrNull { !it.coverUrl.isNullOrBlank() }
|
||||
val heroImage = mediaImageFor(uiState.mediaImages, heroTrack?.coverUrl)
|
||||
|
||||
@@ -725,6 +738,41 @@ internal fun PlaylistDetailContent(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = onDownloadPlaylistClick,
|
||||
enabled = tracks.isNotEmpty() &&
|
||||
!isPlaylistDownloaded &&
|
||||
!isPlaylistDownloading &&
|
||||
uiState.canStartOfflineDownload(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp),
|
||||
shape = CircleShape,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, FurumiNeonPink.copy(alpha = 0.72f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = FurumiNeonPink,
|
||||
disabledContentColor = FurumiTextMuted
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
isPlaylistDownloaded -> "Downloaded"
|
||||
isPlaylistDownloading -> "Downloading..."
|
||||
else -> "Download offline"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
uiState.offlineDownloadError?.let { error ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = FurumiHotOrange
|
||||
)
|
||||
}
|
||||
|
||||
if (playlist.canDeleteFromDetail()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
@@ -767,13 +815,17 @@ internal fun PlaylistDetailContent(
|
||||
track = track,
|
||||
bitmap = mediaImageFor(uiState.mediaImages, track.coverUrl),
|
||||
isLiked = uiState.likedTrackIds.contains(track.id),
|
||||
isDownloadedForOffline = uiState.isDownloadedForOffline(track),
|
||||
isDownloadingForOffline = uiState.isDownloadingForOffline(track),
|
||||
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||
onMediaImageNeeded = onMediaImageNeeded,
|
||||
onTrackClick = onTrackClick,
|
||||
onToggleLike = { onToggleLike(track.id) },
|
||||
onPlayNext = { onPlayNext(track) },
|
||||
onPlayLast = { onPlayLast(track) },
|
||||
onShare = { onShare(track) },
|
||||
onAddToPlaylist = { onAddToPlaylist(track) }
|
||||
onAddToPlaylist = { onAddToPlaylist(track) },
|
||||
onDownloadForOffline = { onDownloadForOffline(track) }
|
||||
)
|
||||
if (index < tracks.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -88,7 +88,11 @@ internal fun TrackContextMenu(
|
||||
onPlayNext: () -> Unit,
|
||||
onPlayLast: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onAddToPlaylist: () -> Unit
|
||||
onAddToPlaylist: () -> Unit,
|
||||
isDownloadedForOffline: Boolean,
|
||||
isDownloadingForOffline: Boolean,
|
||||
canDownloadForOffline: Boolean,
|
||||
onDownloadForOffline: () -> Unit
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
@@ -114,6 +118,19 @@ internal fun TrackContextMenu(
|
||||
text = { Text("Add to playlist") },
|
||||
onClick = onAddToPlaylist
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
when {
|
||||
isDownloadedForOffline -> "Downloaded"
|
||||
isDownloadingForOffline -> "Downloading..."
|
||||
else -> "Download offline"
|
||||
}
|
||||
)
|
||||
},
|
||||
enabled = canDownloadForOffline && !isDownloadedForOffline && !isDownloadingForOffline,
|
||||
onClick = onDownloadForOffline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ internal fun FullPlayerOverlay(
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
offlineDownloadedTrackIds: Set<Long>,
|
||||
offlineDownloadingTrackIds: Set<Long>,
|
||||
canDownloadForOffline: Boolean,
|
||||
onDownloadForOffline: (TrackCard) -> Unit,
|
||||
onArtistClick: (cy.hexor.furumi.domain.model.ArtistCard) -> Unit,
|
||||
connectedDevicesState: ConnectedDevicesState?,
|
||||
connectedDevicesError: String?,
|
||||
@@ -277,6 +281,13 @@ internal fun FullPlayerOverlay(
|
||||
onAddToPlaylist = {
|
||||
currentTrackMenuExpanded = false
|
||||
onAddToPlaylist(track)
|
||||
},
|
||||
isDownloadedForOffline = track.isAudioCached || offlineDownloadedTrackIds.contains(track.id),
|
||||
isDownloadingForOffline = offlineDownloadingTrackIds.contains(track.id),
|
||||
canDownloadForOffline = canDownloadForOffline,
|
||||
onDownloadForOffline = {
|
||||
currentTrackMenuExpanded = false
|
||||
onDownloadForOffline(track)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -446,7 +457,11 @@ internal fun FullPlayerOverlay(
|
||||
onPlayNext = onPlayNext,
|
||||
onPlayLast = onPlayLast,
|
||||
onShare = onShare,
|
||||
onAddToPlaylist = onAddToPlaylist
|
||||
onAddToPlaylist = onAddToPlaylist,
|
||||
offlineDownloadedTrackIds = offlineDownloadedTrackIds,
|
||||
offlineDownloadingTrackIds = offlineDownloadingTrackIds,
|
||||
canDownloadForOffline = canDownloadForOffline,
|
||||
onDownloadForOffline = onDownloadForOffline
|
||||
)
|
||||
}
|
||||
|
||||
@@ -483,7 +498,11 @@ private fun FullPlayerQueue(
|
||||
onPlayNext: (TrackCard) -> Unit,
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
offlineDownloadedTrackIds: Set<Long>,
|
||||
offlineDownloadingTrackIds: Set<Long>,
|
||||
canDownloadForOffline: Boolean,
|
||||
onDownloadForOffline: (TrackCard) -> Unit
|
||||
) {
|
||||
if (queue.isEmpty()) {
|
||||
Text(
|
||||
@@ -543,13 +562,17 @@ private fun FullPlayerQueue(
|
||||
isLiked = likedTrackIds.contains(queueTrack.id),
|
||||
isPlayed = isPlayed,
|
||||
isCurrent = isCurrent,
|
||||
isDownloadedForOffline = queueTrack.isAudioCached || offlineDownloadedTrackIds.contains(queueTrack.id),
|
||||
isDownloadingForOffline = offlineDownloadingTrackIds.contains(queueTrack.id),
|
||||
canDownloadForOffline = canDownloadForOffline,
|
||||
onMediaImageNeeded = onMediaImageNeeded,
|
||||
onClick = { onQueueTrackClick(queueTrack, index) },
|
||||
onToggleLike = { onToggleLike(queueTrack.id) },
|
||||
onPlayNext = { onPlayNext(queueTrack) },
|
||||
onPlayLast = { onPlayLast(queueTrack) },
|
||||
onShare = { onShare(queueTrack) },
|
||||
onAddToPlaylist = { onAddToPlaylist(queueTrack) }
|
||||
onAddToPlaylist = { onAddToPlaylist(queueTrack) },
|
||||
onDownloadForOffline = { onDownloadForOffline(queueTrack) }
|
||||
)
|
||||
if (visibleIndex < visibleQueue.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -88,7 +88,8 @@ internal fun ReleaseDetailContent(
|
||||
onPlayNext: (TrackCard) -> Unit,
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
onDownloadForOffline: (TrackCard) -> Unit
|
||||
) {
|
||||
val release = uiState.releaseDetail?.release ?: uiState.selectedRelease ?: return
|
||||
val releaseImage = mediaImageFor(uiState.mediaImages, release.coverUrl)
|
||||
@@ -141,6 +142,7 @@ internal fun ReleaseDetailContent(
|
||||
onRetry = onRetry
|
||||
)
|
||||
uiState.releaseDetail != null -> ReleaseTracksSection(
|
||||
uiState = uiState,
|
||||
tracks = uiState.releaseDetail.tracks,
|
||||
likedTrackIds = uiState.likedTrackIds,
|
||||
onTrackClick = onTrackClick,
|
||||
@@ -148,7 +150,8 @@ internal fun ReleaseDetailContent(
|
||||
onPlayNext = onPlayNext,
|
||||
onPlayLast = onPlayLast,
|
||||
onShare = onShare,
|
||||
onAddToPlaylist = onAddToPlaylist
|
||||
onAddToPlaylist = onAddToPlaylist,
|
||||
onDownloadForOffline = onDownloadForOffline
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,6 +273,7 @@ internal fun ReleaseDetailError(
|
||||
|
||||
@Composable
|
||||
internal fun ReleaseTracksSection(
|
||||
uiState: PlayerUiState,
|
||||
tracks: List<TrackCard>,
|
||||
likedTrackIds: Set<Long>,
|
||||
onTrackClick: (TrackCard) -> Unit,
|
||||
@@ -277,7 +281,8 @@ internal fun ReleaseTracksSection(
|
||||
onPlayNext: (TrackCard) -> Unit,
|
||||
onPlayLast: (TrackCard) -> Unit,
|
||||
onShare: (TrackCard) -> Unit,
|
||||
onAddToPlaylist: (TrackCard) -> Unit
|
||||
onAddToPlaylist: (TrackCard) -> Unit,
|
||||
onDownloadForOffline: (TrackCard) -> Unit
|
||||
) {
|
||||
SectionTitle("Tracks")
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -316,12 +321,16 @@ internal fun ReleaseTracksSection(
|
||||
fallbackIndex = index + 1,
|
||||
track = track,
|
||||
isLiked = likedTrackIds.contains(track.id),
|
||||
isDownloadedForOffline = uiState.isDownloadedForOffline(track),
|
||||
isDownloadingForOffline = uiState.isDownloadingForOffline(track),
|
||||
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||
onTrackClick = onTrackClick,
|
||||
onToggleLike = { onToggleLike(track.id) },
|
||||
onPlayNext = { onPlayNext(track) },
|
||||
onPlayLast = { onPlayLast(track) },
|
||||
onShare = { onShare(track) },
|
||||
onAddToPlaylist = { onAddToPlaylist(track) }
|
||||
onAddToPlaylist = { onAddToPlaylist(track) },
|
||||
onDownloadForOffline = { onDownloadForOffline(track) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,13 +84,17 @@ internal fun ArtistDetailTrackRow(
|
||||
track: TrackCard,
|
||||
bitmap: Bitmap?,
|
||||
isLiked: Boolean,
|
||||
isDownloadedForOffline: Boolean,
|
||||
isDownloadingForOffline: Boolean,
|
||||
canDownloadForOffline: Boolean,
|
||||
onMediaImageNeeded: (String?) -> Unit,
|
||||
onTrackClick: (TrackCard) -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onPlayNext: () -> Unit,
|
||||
onPlayLast: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onAddToPlaylist: () -> Unit
|
||||
onAddToPlaylist: () -> Unit,
|
||||
onDownloadForOffline: () -> Unit
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val isUnavailable = track.isUnavailableForPlayback()
|
||||
@@ -174,7 +178,11 @@ internal fun ArtistDetailTrackRow(
|
||||
onPlayNext = { menuExpanded = false; onPlayNext() },
|
||||
onPlayLast = { menuExpanded = false; onPlayLast() },
|
||||
onShare = { menuExpanded = false; onShare() },
|
||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
|
||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() },
|
||||
isDownloadedForOffline = isDownloadedForOffline,
|
||||
isDownloadingForOffline = isDownloadingForOffline,
|
||||
canDownloadForOffline = canDownloadForOffline,
|
||||
onDownloadForOffline = { menuExpanded = false; onDownloadForOffline() }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
@@ -191,12 +199,16 @@ internal fun ReleaseTrackRow(
|
||||
fallbackIndex: Int,
|
||||
track: TrackCard,
|
||||
isLiked: Boolean,
|
||||
isDownloadedForOffline: Boolean,
|
||||
isDownloadingForOffline: Boolean,
|
||||
canDownloadForOffline: Boolean,
|
||||
onTrackClick: (TrackCard) -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onPlayNext: () -> Unit,
|
||||
onPlayLast: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onAddToPlaylist: () -> Unit
|
||||
onAddToPlaylist: () -> Unit,
|
||||
onDownloadForOffline: () -> Unit
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val isUnavailable = track.isUnavailableForPlayback()
|
||||
@@ -264,7 +276,11 @@ internal fun ReleaseTrackRow(
|
||||
onPlayNext = { menuExpanded = false; onPlayNext() },
|
||||
onPlayLast = { menuExpanded = false; onPlayLast() },
|
||||
onShare = { menuExpanded = false; onShare() },
|
||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
|
||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() },
|
||||
isDownloadedForOffline = isDownloadedForOffline,
|
||||
isDownloadingForOffline = isDownloadingForOffline,
|
||||
canDownloadForOffline = canDownloadForOffline,
|
||||
onDownloadForOffline = { menuExpanded = false; onDownloadForOffline() }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
@@ -282,13 +298,17 @@ internal fun PlaylistTrackRow(
|
||||
track: TrackCard,
|
||||
bitmap: Bitmap?,
|
||||
isLiked: Boolean,
|
||||
isDownloadedForOffline: Boolean,
|
||||
isDownloadingForOffline: Boolean,
|
||||
canDownloadForOffline: Boolean,
|
||||
onMediaImageNeeded: (String?) -> Unit,
|
||||
onTrackClick: (TrackCard) -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onPlayNext: () -> Unit,
|
||||
onPlayLast: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onAddToPlaylist: () -> Unit
|
||||
onAddToPlaylist: () -> Unit,
|
||||
onDownloadForOffline: () -> Unit
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val isUnavailable = track.isUnavailableForPlayback()
|
||||
@@ -370,7 +390,11 @@ internal fun PlaylistTrackRow(
|
||||
onPlayNext = { menuExpanded = false; onPlayNext() },
|
||||
onPlayLast = { menuExpanded = false; onPlayLast() },
|
||||
onShare = { menuExpanded = false; onShare() },
|
||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
|
||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() },
|
||||
isDownloadedForOffline = isDownloadedForOffline,
|
||||
isDownloadingForOffline = isDownloadingForOffline,
|
||||
canDownloadForOffline = canDownloadForOffline,
|
||||
onDownloadForOffline = { menuExpanded = false; onDownloadForOffline() }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
@@ -426,13 +450,17 @@ internal fun QueueTrackRow(
|
||||
isLiked: Boolean,
|
||||
isPlayed: Boolean = false,
|
||||
isCurrent: Boolean = false,
|
||||
isDownloadedForOffline: Boolean,
|
||||
isDownloadingForOffline: Boolean,
|
||||
canDownloadForOffline: Boolean,
|
||||
onMediaImageNeeded: (String?) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onPlayNext: () -> Unit,
|
||||
onPlayLast: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onAddToPlaylist: () -> Unit
|
||||
onAddToPlaylist: () -> Unit,
|
||||
onDownloadForOffline: () -> Unit
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -519,7 +547,11 @@ internal fun QueueTrackRow(
|
||||
onPlayNext = { menuExpanded = false; onPlayNext() },
|
||||
onPlayLast = { menuExpanded = false; onPlayLast() },
|
||||
onShare = { menuExpanded = false; onShare() },
|
||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() }
|
||||
onAddToPlaylist = { menuExpanded = false; onAddToPlaylist() },
|
||||
isDownloadedForOffline = isDownloadedForOffline,
|
||||
isDownloadingForOffline = isDownloadingForOffline,
|
||||
canDownloadForOffline = canDownloadForOffline,
|
||||
onDownloadForOffline = { menuExpanded = false; onDownloadForOffline() }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
@@ -535,6 +567,18 @@ private fun TrackCard.isUnavailableForPlayback(): Boolean {
|
||||
return streamUrl.isBlank()
|
||||
}
|
||||
|
||||
internal fun PlayerUiState.isDownloadedForOffline(track: TrackCard): Boolean {
|
||||
return track.isAudioCached || offlineDownloadedTrackIds.contains(track.id)
|
||||
}
|
||||
|
||||
internal fun PlayerUiState.isDownloadingForOffline(track: TrackCard): Boolean {
|
||||
return offlineDownloadingTrackIds.contains(track.id)
|
||||
}
|
||||
|
||||
internal fun PlayerUiState.canStartOfflineDownload(): Boolean {
|
||||
return !offlineState.isOfflineActive
|
||||
}
|
||||
|
||||
private fun Modifier.unavailableTrackArtworkOverlay(isUnavailable: Boolean): Modifier {
|
||||
if (!isUnavailable) return this
|
||||
return drawWithContent {
|
||||
|
||||
@@ -2,6 +2,7 @@ package cy.hexor.furumi.ui.player
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.Canvas
|
||||
@@ -151,6 +152,12 @@ fun PlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.messageEvent.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -190,6 +197,8 @@ fun PlayerScreen(
|
||||
onPlayLast = viewModel::addToQueueEnd,
|
||||
onShare = viewModel::shareTrack,
|
||||
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||
onDownloadForOffline = viewModel::downloadTrackForOffline,
|
||||
onDownloadPlaylistClick = viewModel::downloadPlaylistForOffline,
|
||||
onDeletePlaylistClick = { playlist ->
|
||||
viewModel.clearPlaylistDeleteError()
|
||||
deletePlaylistCandidate = playlist
|
||||
@@ -220,7 +229,8 @@ fun PlayerScreen(
|
||||
onPlayNext = viewModel::addToPlayNext,
|
||||
onPlayLast = viewModel::addToQueueEnd,
|
||||
onShare = viewModel::shareTrack,
|
||||
onAddToPlaylist = { track -> addToPlaylistTrack = track }
|
||||
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||
onDownloadForOffline = viewModel::downloadTrackForOffline
|
||||
)
|
||||
}
|
||||
} else if (uiState.selectedArtist != null) {
|
||||
@@ -248,7 +258,8 @@ fun PlayerScreen(
|
||||
onPlayNext = viewModel::addToPlayNext,
|
||||
onPlayLast = viewModel::addToQueueEnd,
|
||||
onShare = viewModel::shareTrack,
|
||||
onAddToPlaylist = { track -> addToPlaylistTrack = track }
|
||||
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||
onDownloadForOffline = viewModel::downloadTrackForOffline
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -280,7 +291,8 @@ fun PlayerScreen(
|
||||
onPlayNext = viewModel::addToPlayNext,
|
||||
onPlayLast = viewModel::addToQueueEnd,
|
||||
onShare = viewModel::shareTrack,
|
||||
onAddToPlaylist = { track -> addToPlaylistTrack = track }
|
||||
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||
onDownloadForOffline = viewModel::downloadTrackForOffline
|
||||
)
|
||||
}
|
||||
PlayerTab.Library -> Box(
|
||||
@@ -388,6 +400,10 @@ fun PlayerScreen(
|
||||
onPlayLast = viewModel::addToQueueEnd,
|
||||
onShare = viewModel::shareTrack,
|
||||
onAddToPlaylist = { track -> addToPlaylistTrack = track },
|
||||
offlineDownloadedTrackIds = uiState.offlineDownloadedTrackIds,
|
||||
offlineDownloadingTrackIds = uiState.offlineDownloadingTrackIds,
|
||||
canDownloadForOffline = uiState.canStartOfflineDownload(),
|
||||
onDownloadForOffline = viewModel::downloadTrackForOffline,
|
||||
onArtistClick = { artist ->
|
||||
isFullPlayerOpen = false
|
||||
viewModel.openArtist(artist)
|
||||
|
||||
@@ -85,6 +85,10 @@ data class PlayerUiState(
|
||||
val playlistCreateError: String? = null,
|
||||
val isDeletingPlaylist: Boolean = false,
|
||||
val playlistDeleteError: String? = null,
|
||||
val offlineDownloadedTrackIds: Set<Long> = emptySet(),
|
||||
val offlineDownloadingTrackIds: Set<Long> = emptySet(),
|
||||
val offlineDownloadingPlaylistIds: Set<Long> = emptySet(),
|
||||
val offlineDownloadError: String? = null,
|
||||
val selectedPlaylist: PlaylistCard? = null,
|
||||
val playlistDetail: PlaylistDetail? = null,
|
||||
val isPlaylistDetailLoading: Boolean = false,
|
||||
@@ -112,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<String>()
|
||||
private val handledConnectedCommandIdSet = mutableSetOf<String>()
|
||||
private var wasCurrentDeviceActive = false
|
||||
@@ -120,6 +125,8 @@ class PlayerViewModel @Inject constructor(
|
||||
private var shouldClaimActiveDeviceOnReconnect = false
|
||||
private val _shareEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val shareEvent: SharedFlow<String> = _shareEvent.asSharedFlow()
|
||||
private val _messageEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val messageEvent: SharedFlow<String> = _messageEvent.asSharedFlow()
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
authRepository.getCurrentSession()?.let { session ->
|
||||
@@ -138,6 +145,7 @@ class PlayerViewModel @Inject constructor(
|
||||
init {
|
||||
loadGlobalArtistsIfNeeded()
|
||||
observeOfflineState()
|
||||
observeOfflineDownloadQueue()
|
||||
observePlayback()
|
||||
loadLikedTrackIds()
|
||||
startConnectedDevicesPolling()
|
||||
@@ -755,6 +763,63 @@ class PlayerViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadTrackForOffline(track: TrackCard) {
|
||||
if (_uiState.value.isDownloadedForOffline(track) ||
|
||||
_uiState.value.isDownloadingForOffline(track)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (_uiState.value.offlineState.isOfflineActive) {
|
||||
_messageEvent.tryEmit("Downloads need an online connection")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
playerRepository.downloadTrackForOffline(track)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadPlaylistForOffline() {
|
||||
val detail = _uiState.value.playlistDetail ?: return
|
||||
val playlistId = detail.playlist.id
|
||||
if (_uiState.value.offlineDownloadingPlaylistIds.contains(playlistId)) return
|
||||
if (_uiState.value.offlineState.isOfflineActive) {
|
||||
_messageEvent.tryEmit("Downloads need an online connection")
|
||||
return
|
||||
}
|
||||
|
||||
val currentState = _uiState.value
|
||||
val tracks = detail.tracks.filter {
|
||||
!currentState.isDownloadedForOffline(it) && !currentState.isDownloadingForOffline(it)
|
||||
}
|
||||
if (tracks.isEmpty()) {
|
||||
_messageEvent.tryEmit("Playlist is already downloaded")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
playerRepository.downloadTracksForOffline(tracks)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPlaylists() {
|
||||
if (_uiState.value.isPlaylistsLoading) return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
@@ -1061,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) {
|
||||
@@ -1575,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(
|
||||
|
||||
Reference in New Issue
Block a user