mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
fix downloaded playback and tighten the download state machine
Review findings on the first downloads block: - ExoPlayer could not open a downloaded file at all: the service's data source factory was OkHttp only, so a file:// URI went to the HTTP stack and failed with a malformed URL. Wrap it in DefaultDataSource so the file, asset and content schemes are resolved before the stream chain, and cover it with a test that reads a local file through the production factory. - Reconcile no longer restarts a failed download, or a running one whose engine entry vanished: only a queued row is started again, and the rest become failed so the explicit Retry in the UI means something. - song_downloads also references songs with CASCADE now, so a song pruned from the mirror takes its download row with it and the sweep can reclaim the file. - The mutating methods run on the injected dispatcher rather than the caller's, which was the main thread for every call site, and the repository exposes one shared state flow instead of a second map plus five subscriptions. - Playback stops when the active source disappears, including deleting the last source while a downloaded track is playing. - Dropped the unused request mime type, the queued-at column nothing read, the metered constructor parameter nobody varied, and a dead index guard. - The running indicator is asserted positively, the engine's status and reason mapping has a test, and the failure paths use the fake's failure and forget helpers.
This commit is contained in:
+16
-10
@@ -2,7 +2,7 @@
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 18,
|
||||
"identityHash": "5af02d01d5774106a81b2d031b6bd215",
|
||||
"identityHash": "982f1044d9532868990a4e14017ad3d1",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "sources",
|
||||
@@ -1105,7 +1105,7 @@
|
||||
},
|
||||
{
|
||||
"tableName": "song_downloads",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, `queuedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "sourceId",
|
||||
@@ -1148,13 +1148,6 @@
|
||||
"fieldPath": "error",
|
||||
"columnName": "error",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "queuedAt",
|
||||
"columnName": "queuedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
@@ -1195,13 +1188,26 @@
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "songs",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"sourceId",
|
||||
"songId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"sourceId",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5af02d01d5774106a81b2d031b6bd215')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '982f1044d9532868990a4e14017ad3d1')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -249,8 +249,9 @@ val MIGRATION_17_18 =
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `song_downloads` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, " +
|
||||
"`status` TEXT NOT NULL, `engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, " +
|
||||
"`total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, `queuedAt` INTEGER NOT NULL DEFAULT 0, " +
|
||||
"`total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, " +
|
||||
"PRIMARY KEY(`sourceId`, `songId`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE, FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `song_downloads` (`sourceId`)")
|
||||
|
||||
@@ -4,7 +4,6 @@ data class EngineRequest(
|
||||
val uri: String,
|
||||
val path: String,
|
||||
val title: String,
|
||||
val mimeType: String? = null,
|
||||
val allowMetered: Boolean = false,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,15 +9,14 @@ class SystemDownloadEngine(
|
||||
private val context: Context,
|
||||
) : DownloadEngine {
|
||||
override fun enqueue(request: EngineRequest): Long {
|
||||
val builder =
|
||||
val download =
|
||||
DownloadManager
|
||||
.Request(Uri.parse(request.uri))
|
||||
.setTitle(request.title)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
.setAllowedOverMetered(request.allowMetered)
|
||||
request.mimeType?.let(builder::setMimeType)
|
||||
return manager().enqueue(builder)
|
||||
return manager().enqueue(download)
|
||||
}
|
||||
|
||||
override fun download(id: Long): EngineDownload? {
|
||||
@@ -31,15 +30,8 @@ class SystemDownloadEngine(
|
||||
cursor
|
||||
.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
|
||||
.takeIf { status == DownloadManager.STATUS_FAILED }
|
||||
val mapped =
|
||||
when (status) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> EngineStatus.Completed
|
||||
DownloadManager.STATUS_FAILED -> EngineStatus.Failed
|
||||
DownloadManager.STATUS_RUNNING -> EngineStatus.Running
|
||||
else -> EngineStatus.Pending
|
||||
}
|
||||
return EngineDownload(
|
||||
status = mapped,
|
||||
status = engineStatus(status),
|
||||
bytes = bytes.coerceAtLeast(0),
|
||||
total = total.coerceAtLeast(0),
|
||||
error = reason?.let(::failureMessage),
|
||||
@@ -52,15 +44,23 @@ class SystemDownloadEngine(
|
||||
}
|
||||
|
||||
private fun manager(): DownloadManager = context.getSystemService(DownloadManager::class.java)
|
||||
|
||||
private fun failureMessage(reason: Int): String =
|
||||
when (reason) {
|
||||
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Not enough space to download"
|
||||
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Storage is unavailable"
|
||||
DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "Could not write the download"
|
||||
DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "The server refused to send this track"
|
||||
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "The server redirected too many times"
|
||||
DownloadManager.ERROR_CANNOT_RESUME -> "The download could not be resumed"
|
||||
else -> "Download failed"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun engineStatus(status: Int): EngineStatus =
|
||||
when (status) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> EngineStatus.Completed
|
||||
DownloadManager.STATUS_FAILED -> EngineStatus.Failed
|
||||
DownloadManager.STATUS_RUNNING -> EngineStatus.Running
|
||||
else -> EngineStatus.Pending
|
||||
}
|
||||
|
||||
internal fun failureMessage(reason: Int): String =
|
||||
when (reason) {
|
||||
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Not enough space to download"
|
||||
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Storage is unavailable"
|
||||
DownloadManager.ERROR_FILE_ERROR, DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "Could not write the download"
|
||||
DownloadManager.ERROR_HTTP_DATA_ERROR, DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "The server refused to send this track"
|
||||
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "The server redirected too many times"
|
||||
DownloadManager.ERROR_CANNOT_RESUME -> "The download could not be resumed"
|
||||
else -> "Download failed"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ enum class DownloadStatus { Queued, Running, Completed, Failed }
|
||||
childColumns = ["sourceId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = Song::class,
|
||||
parentColumns = ["sourceId", "id"],
|
||||
childColumns = ["sourceId", "songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("sourceId"), Index("engineId")],
|
||||
)
|
||||
@@ -29,7 +35,6 @@ data class SongDownload(
|
||||
@ColumnInfo(defaultValue = "0") val bytes: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val total: Long = 0,
|
||||
val error: String? = null,
|
||||
@ColumnInfo(defaultValue = "0") val queuedAt: Long = 0,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
|
||||
@@ -17,14 +17,18 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class DownloadRepository(
|
||||
@@ -32,9 +36,8 @@ class DownloadRepository(
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val engine: DownloadEngine,
|
||||
private val downloadsDir: File,
|
||||
private val allowMetered: Boolean = false,
|
||||
private val showMessage: (String) -> Unit = {},
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val mutex = Mutex()
|
||||
@@ -44,94 +47,106 @@ class DownloadRepository(
|
||||
@Volatile
|
||||
private var started = false
|
||||
|
||||
private val completedFiles = MutableStateFlow<Map<String, File>>(emptyMap())
|
||||
private val sharedStates: StateFlow<Map<String, SongDownload>> by lazy {
|
||||
downloadStates()
|
||||
.onEach { loaded.complete(Unit) }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (started) return
|
||||
started = true
|
||||
downloadsDir.mkdirs()
|
||||
File(downloadsDir, NO_MEDIA).createNewFile()
|
||||
sharedStates
|
||||
scope.launch {
|
||||
sourceRepository.activeSourceId().distinctUntilChanged().collect { reconcile() }
|
||||
}
|
||||
scope.launch {
|
||||
states().collect { rows ->
|
||||
completedFiles.value =
|
||||
rows
|
||||
.filterValues { it.status == DownloadStatus.Completed }
|
||||
.mapValues { (_, row) -> file(row.sourceId, row.songId) }
|
||||
.filterValues { it.exists() }
|
||||
loaded.complete(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
fun states(): Flow<Map<String, SongDownload>> =
|
||||
sourceRepository
|
||||
.activeSourceId()
|
||||
.flatMapLatest { sourceId -> if (sourceId == null) flowOf(emptyList()) else db.downloadDao().downloads(sourceId) }
|
||||
.map { rows -> rows.associateBy { it.songId } }
|
||||
fun states(): StateFlow<Map<String, SongDownload>> = sharedStates
|
||||
|
||||
suspend fun awaitLoaded() {
|
||||
if (started) loaded.await()
|
||||
if (!started) return
|
||||
states()
|
||||
loaded.await()
|
||||
}
|
||||
|
||||
fun localUri(songId: String): String? = completedFiles.value[songId]?.takeIf { it.exists() }?.let { Uri.fromFile(it).toString() }
|
||||
fun localUri(songId: String): String? =
|
||||
sharedStates.value[songId]
|
||||
?.takeIf { it.status == DownloadStatus.Completed }
|
||||
?.let { file(it.sourceId, it.songId) }
|
||||
?.takeIf { it.exists() }
|
||||
?.let { Uri.fromFile(it).toString() }
|
||||
|
||||
suspend fun download(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
) = mutex.withLock {
|
||||
if (sourceRepository.activeSourceIdOnce() != sourceId) {
|
||||
showMessage("Can't download from a source that isn't active")
|
||||
return@withLock
|
||||
) = withContext(dispatcher) {
|
||||
mutex.withLock {
|
||||
if (sourceRepository.activeSourceIdOnce() != sourceId) {
|
||||
showMessage("Can't download from a source that isn't active")
|
||||
return@withLock
|
||||
}
|
||||
val existing = db.downloadDao().find(sourceId, songId)
|
||||
if (existing != null && existing.status != DownloadStatus.Failed) return@withLock
|
||||
existing?.engineId?.let(engine::cancel)
|
||||
val url = sourceRepository.downloadUri(songId)
|
||||
if (url == null) {
|
||||
showMessage("Can't download: the server address is unavailable")
|
||||
return@withLock
|
||||
}
|
||||
enqueue(sourceId, songId, url)
|
||||
}
|
||||
val existing = db.downloadDao().find(sourceId, songId)
|
||||
if (existing != null && existing.status != DownloadStatus.Failed) return@withLock
|
||||
existing?.engineId?.let(engine::cancel)
|
||||
val url = sourceRepository.downloadUri(songId)
|
||||
if (url == null) {
|
||||
showMessage("Can't download: the server address is unavailable")
|
||||
return@withLock
|
||||
}
|
||||
enqueue(sourceId, songId, url)
|
||||
}
|
||||
|
||||
suspend fun remove(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
) = mutex.withLock {
|
||||
val row = db.downloadDao().find(sourceId, songId) ?: return@withLock
|
||||
row.engineId?.let(engine::cancel)
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().delete(sourceId, songId)
|
||||
) = withContext(dispatcher) {
|
||||
mutex.withLock {
|
||||
val row = db.downloadDao().find(sourceId, songId) ?: return@withLock
|
||||
row.engineId?.let(engine::cancel)
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().delete(sourceId, songId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeSource(sourceId: Long) =
|
||||
mutex.withLock {
|
||||
db
|
||||
.downloadDao()
|
||||
.all()
|
||||
.filter { it.sourceId == sourceId }
|
||||
.forEach { row -> row.engineId?.let(engine::cancel) }
|
||||
db.downloadDao().deleteSource(sourceId)
|
||||
dir(sourceId).deleteRecursively()
|
||||
withContext(dispatcher) {
|
||||
mutex.withLock {
|
||||
db
|
||||
.downloadDao()
|
||||
.all()
|
||||
.filter { it.sourceId == sourceId }
|
||||
.forEach { row -> row.engineId?.let(engine::cancel) }
|
||||
db.downloadDao().deleteSource(sourceId)
|
||||
dir(sourceId).deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reconcile() =
|
||||
mutex.withLock {
|
||||
db.downloadDao().all().forEach { row ->
|
||||
val state = row.engineId?.let(engine::download)
|
||||
if (state == null) recover(row) else apply(row, state)
|
||||
withContext(dispatcher) {
|
||||
mutex.withLock {
|
||||
db.downloadDao().all().forEach { row ->
|
||||
val state = row.engineId?.let(engine::download)
|
||||
if (state == null) recover(row) else apply(row, state)
|
||||
}
|
||||
sweep(db.downloadDao().all())
|
||||
ensurePolling()
|
||||
}
|
||||
sweep(db.downloadDao().all())
|
||||
ensurePolling()
|
||||
}
|
||||
|
||||
private fun downloadStates(): Flow<Map<String, SongDownload>> =
|
||||
sourceRepository
|
||||
.activeSourceId()
|
||||
.flatMapLatest { sourceId -> if (sourceId == null) flowOf(emptyList()) else db.downloadDao().downloads(sourceId) }
|
||||
.map { rows -> rows.associateBy { it.songId } }
|
||||
|
||||
private suspend fun enqueue(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
@@ -142,40 +157,32 @@ class DownloadRepository(
|
||||
val title = db.libraryDao().songOnce(sourceId, songId)?.title ?: songId
|
||||
val engineId =
|
||||
engine.enqueue(
|
||||
EngineRequest(uri = url, path = enginePath(sourceId, songId), title = title, allowMetered = allowMetered),
|
||||
EngineRequest(uri = url, path = enginePath(sourceId, songId), title = title, allowMetered = false),
|
||||
)
|
||||
db.downloadDao().upsert(
|
||||
SongDownload(
|
||||
sourceId = sourceId,
|
||||
songId = songId,
|
||||
status = DownloadStatus.Queued,
|
||||
engineId = engineId,
|
||||
queuedAt = System.currentTimeMillis(),
|
||||
),
|
||||
SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued, engineId = engineId),
|
||||
)
|
||||
ensurePolling()
|
||||
}
|
||||
|
||||
private suspend fun recover(row: SongDownload) {
|
||||
when {
|
||||
row.status == DownloadStatus.Completed && file(row.sourceId, row.songId).exists() -> {
|
||||
Unit
|
||||
when (row.status) {
|
||||
DownloadStatus.Completed -> {
|
||||
if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE)
|
||||
}
|
||||
|
||||
row.status == DownloadStatus.Completed -> {
|
||||
markFailed(row, "The downloaded file is missing")
|
||||
}
|
||||
|
||||
sourceRepository.activeSourceIdOnce() != row.sourceId -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
else -> {
|
||||
DownloadStatus.Queued -> {
|
||||
if (sourceRepository.activeSourceIdOnce() != row.sourceId) return
|
||||
val url = sourceRepository.downloadUri(row.songId)
|
||||
when (url) {
|
||||
null -> markFailed(row, "Can't download: the server address is unavailable")
|
||||
else -> enqueue(row.sourceId, row.songId, url)
|
||||
}
|
||||
if (url == null) markFailed(row, NO_ADDRESS) else enqueue(row.sourceId, row.songId, url)
|
||||
}
|
||||
|
||||
DownloadStatus.Running -> {
|
||||
markFailed(row, STOPPED)
|
||||
}
|
||||
|
||||
DownloadStatus.Failed -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,5 +264,8 @@ class DownloadRepository(
|
||||
const val ENGINE_DIR = "downloads"
|
||||
const val NO_MEDIA = ".nomedia"
|
||||
const val POLL_MS = 1_000L
|
||||
const val MISSING_FILE = "The downloaded file is missing"
|
||||
const val NO_ADDRESS = "Can't download: the server address is unavailable"
|
||||
const val STOPPED = "The download stopped unexpectedly"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class PlaybackController(
|
||||
init {
|
||||
scope.launch {
|
||||
sourceRepository.activeSourceId().collect { sourceId ->
|
||||
if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop()
|
||||
if (queueSourceId != null && sourceId != queueSourceId) stop()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
@@ -965,10 +965,8 @@ class PlaybackController(
|
||||
val snapshot = snapshot ?: return
|
||||
if (snapshot.size == 0L) return
|
||||
if (player.isEnded) return
|
||||
val index = player.currentIndex
|
||||
val position = (windowStart + index).coerceIn(0, snapshot.size - 1)
|
||||
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
|
||||
val positionMs = player.currentPositionMs
|
||||
if (player.currentIndex != index) return
|
||||
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
@@ -9,6 +10,8 @@ import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.ExperimentalApi
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
@@ -41,9 +44,7 @@ class PlaybackService : MediaSessionService() {
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(this)
|
||||
.setEnableClippingInMediaPeriod(true)
|
||||
.setDataSourceFactory(
|
||||
KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
|
||||
),
|
||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
||||
).build()
|
||||
val loader =
|
||||
CoverArtBitmapLoader(
|
||||
@@ -88,10 +89,6 @@ class PlaybackService : MediaSessionService() {
|
||||
response
|
||||
}.build()
|
||||
|
||||
private companion object {
|
||||
const val STREAM_TAG = "SubtracksPlayback"
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
artworkLoader?.shutdown()
|
||||
artworkLoader = null
|
||||
@@ -102,4 +99,14 @@ class PlaybackService : MediaSessionService() {
|
||||
session = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STREAM_TAG = "SubtracksPlayback"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun mediaDataSourceFactory(
|
||||
context: Context,
|
||||
upstream: DataSource.Factory,
|
||||
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream))
|
||||
|
||||
@@ -13,12 +13,10 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -37,8 +35,7 @@ class AlbumDetailViewModel(
|
||||
|
||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> =
|
||||
downloadRepository.states().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
|
||||
@@ -15,12 +15,10 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -41,7 +39,7 @@ class PlaylistDetailViewModel(
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> =
|
||||
downloadRepository.states().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
downloadRepository.states()
|
||||
|
||||
fun download(song: Song) {
|
||||
viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) }
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -117,9 +119,16 @@ private fun DownloadBadge(download: SongDownload?) {
|
||||
DownloadStatus.Queued, DownloadStatus.Running -> {
|
||||
val progress = download.progress
|
||||
if (progress == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" },
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(progress = { progress }, modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" },
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ fun NowPlayingRoute(
|
||||
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
||||
val sourceId = state.context?.sourceId
|
||||
val songId = state.item?.id
|
||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||
val download = songId?.let { downloads[it] }
|
||||
val scope = rememberCoroutineScope()
|
||||
val song by
|
||||
|
||||
@@ -279,7 +279,7 @@ fun QueueRoute(
|
||||
downloadRepository: DownloadRepository = koinInject(),
|
||||
) {
|
||||
val playback by controller.state.collectAsStateWithLifecycle()
|
||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||
val queueContext = playback.context
|
||||
// The item's album keeps the header stable while the async name lookup runs, so it does not
|
||||
// flash the fallback when tapping between context and manually queued tracks.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.subtracks.data.download
|
||||
|
||||
import android.app.DownloadManager
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SystemDownloadEngineTest {
|
||||
@Test
|
||||
fun successfulRunningAndFailedStatusesAreMapped() {
|
||||
assertEquals(EngineStatus.Completed, engineStatus(DownloadManager.STATUS_SUCCESSFUL))
|
||||
assertEquals(EngineStatus.Running, engineStatus(DownloadManager.STATUS_RUNNING))
|
||||
assertEquals(EngineStatus.Failed, engineStatus(DownloadManager.STATUS_FAILED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWaitingDownloadIsPending() {
|
||||
assertEquals(EngineStatus.Pending, engineStatus(DownloadManager.STATUS_PENDING))
|
||||
assertEquals(EngineStatus.Pending, engineStatus(DownloadManager.STATUS_PAUSED))
|
||||
assertEquals(EngineStatus.Pending, engineStatus(Int.MIN_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failureReasonsReadAsSentences() {
|
||||
assertEquals("Not enough space to download", failureMessage(DownloadManager.ERROR_INSUFFICIENT_SPACE))
|
||||
assertEquals("Storage is unavailable", failureMessage(DownloadManager.ERROR_DEVICE_NOT_FOUND))
|
||||
assertEquals("Download failed", failureMessage(DownloadManager.ERROR_UNKNOWN))
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,62 @@ class DownloadRepositoryTest {
|
||||
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedDownloadIsNotRestartedByReconcile() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = null, error = "boom"))
|
||||
|
||||
runBlocking { repository.reconcile() }
|
||||
runBlocking { repository.reconcile() }
|
||||
|
||||
assertTrue(engine.requests.isEmpty())
|
||||
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
|
||||
assertEquals("boom", row(1, "s1")?.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEngineFailureIsMirroredWithItsMessage() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val id = engine.requests.single().first
|
||||
|
||||
engine.fail(id)
|
||||
runBlocking { repository.reconcile() }
|
||||
|
||||
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
|
||||
assertEquals("Download failed", row(1, "s1")?.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRunningDownloadWhoseEngineEntryVanishesIsFailedRatherThanRestarted() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.start()
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val id = engine.requests.single().first
|
||||
engine.running(id, bytes = 40, total = 100)
|
||||
await { row(1, "s1")?.status == DownloadStatus.Running }
|
||||
|
||||
engine.forget(id)
|
||||
runBlocking { repository.reconcile() }
|
||||
|
||||
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
|
||||
assertEquals(1, engine.requests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
|
||||
|
||||
assertNull(row(1, "s1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadingFromASourceThatIsNotActiveIsRefused() =
|
||||
runTest {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaDataSourceTest {
|
||||
@Test
|
||||
fun aDownloadedFileIsReadThroughTheStreamingChain() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val file = File(context.cacheDir, "downloaded-${System.nanoTime()}").apply { writeBytes(byteArrayOf(1, 2, 3, 4)) }
|
||||
val dataSource = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
|
||||
|
||||
val length = dataSource.open(DataSpec(Uri.fromFile(file)))
|
||||
val buffer = ByteArray(4)
|
||||
val read = dataSource.read(buffer, 0, buffer.size)
|
||||
dataSource.close()
|
||||
|
||||
assertEquals(4L, length)
|
||||
assertEquals(4, read)
|
||||
assertEquals(listOf<Byte>(1, 2, 3, 4), buffer.toList())
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,11 @@ class SongRowDownloadTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRunningDownloadShowsNoDownloadedIndicator() {
|
||||
fun aRunningDownloadShowsProgressRatherThanTheDownloadedIndicator() {
|
||||
render(download(DownloadStatus.Running))
|
||||
|
||||
composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist()
|
||||
composeRule.onNodeWithContentDescription("Downloading").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user