mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
Revert the downloads block
This commit is contained in:
-1230
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
|||||||
package com.subtracks.data.db
|
|
||||||
|
|
||||||
import androidx.room3.Dao
|
|
||||||
import androidx.room3.Query
|
|
||||||
import androidx.room3.Upsert
|
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface DownloadDao {
|
|
||||||
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
|
||||||
fun downloads(sourceId: Long): Flow<List<SongDownload>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM song_downloads")
|
|
||||||
suspend fun all(): List<SongDownload>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId AND songId = :songId")
|
|
||||||
suspend fun find(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
): SongDownload?
|
|
||||||
|
|
||||||
@Upsert
|
|
||||||
suspend fun upsert(download: SongDownload)
|
|
||||||
|
|
||||||
@Query("DELETE FROM song_downloads WHERE sourceId = :sourceId AND songId = :songId")
|
|
||||||
suspend fun delete(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Query("DELETE FROM song_downloads WHERE sourceId = :sourceId")
|
|
||||||
suspend fun deleteSource(sourceId: Long)
|
|
||||||
}
|
|
||||||
@@ -369,12 +369,6 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Flow<Song?>
|
): Flow<Song?>
|
||||||
|
|
||||||
@Query("SELECT * FROM songs WHERE sourceId = :sourceId AND id = :songId")
|
|
||||||
suspend fun songOnce(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
): Song?
|
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE, id")
|
||||||
fun albumsForArtist(
|
fun albumsForArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -235,22 +235,6 @@ private val PLAYLISTS_V14_INDICES =
|
|||||||
"CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `\${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)",
|
"CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `\${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)",
|
||||||
)
|
)
|
||||||
|
|
||||||
val MIGRATION_16_17 =
|
|
||||||
object : Migration(16, 17) {
|
|
||||||
override suspend fun migrate(connection: SQLiteConnection) {
|
|
||||||
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, " +
|
|
||||||
"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`)")
|
|
||||||
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `song_downloads` (`engineId`)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val MIGRATIONS: Array<Migration> =
|
val MIGRATIONS: Array<Migration> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MIGRATION_1_2,
|
MIGRATION_1_2,
|
||||||
@@ -268,5 +252,4 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_13_14,
|
MIGRATION_13_14,
|
||||||
MIGRATION_14_15,
|
MIGRATION_14_15,
|
||||||
MIGRATION_15_16,
|
MIGRATION_15_16,
|
||||||
MIGRATION_16_17,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.subtracks.data.model.Album
|
|||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DownloadStatusConverter
|
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
@@ -15,7 +14,6 @@ import com.subtracks.data.model.QueueEntry
|
|||||||
import com.subtracks.data.model.QueueKindConverter
|
import com.subtracks.data.model.QueueKindConverter
|
||||||
import com.subtracks.data.model.ShuffleOrder
|
import com.subtracks.data.model.ShuffleOrder
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
import com.subtracks.data.model.UpNextEntry
|
import com.subtracks.data.model.UpNextEntry
|
||||||
@@ -35,12 +33,11 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
PlaybackCursor::class,
|
PlaybackCursor::class,
|
||||||
ShuffleOrder::class,
|
ShuffleOrder::class,
|
||||||
ArtworkSeed::class,
|
ArtworkSeed::class,
|
||||||
SongDownload::class,
|
|
||||||
],
|
],
|
||||||
version = 17,
|
version = 16,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
|
@ColumnTypeConverters(QueueKindConverter::class)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|
||||||
@@ -49,6 +46,4 @@ abstract class SubtracksDatabase : RoomDatabase() {
|
|||||||
abstract fun queueDao(): QueueDao
|
abstract fun queueDao(): QueueDao
|
||||||
|
|
||||||
abstract fun artworkSeedDao(): ArtworkSeedDao
|
abstract fun artworkSeedDao(): ArtworkSeedDao
|
||||||
|
|
||||||
abstract fun downloadDao(): DownloadDao
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.subtracks.data.download
|
|
||||||
|
|
||||||
data class EngineRequest(
|
|
||||||
val uri: String,
|
|
||||||
val path: String,
|
|
||||||
val title: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class EngineStatus { Pending, Running, Completed, Failed }
|
|
||||||
|
|
||||||
data class EngineDownload(
|
|
||||||
val status: EngineStatus,
|
|
||||||
val bytes: Long = 0,
|
|
||||||
val total: Long = 0,
|
|
||||||
val error: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface DownloadEngine {
|
|
||||||
fun enqueue(request: EngineRequest): Long
|
|
||||||
|
|
||||||
fun download(id: Long): EngineDownload?
|
|
||||||
|
|
||||||
fun cancel(id: Long)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package com.subtracks.data.download
|
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Environment
|
|
||||||
|
|
||||||
class SystemDownloadEngine(
|
|
||||||
private val context: Context,
|
|
||||||
) : DownloadEngine {
|
|
||||||
override fun enqueue(request: EngineRequest): Long {
|
|
||||||
val download =
|
|
||||||
DownloadManager
|
|
||||||
.Request(Uri.parse(request.uri))
|
|
||||||
.setTitle(request.title)
|
|
||||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
|
||||||
// Downloads wait for an unmetered network until the preference for them exists.
|
|
||||||
.setAllowedOverMetered(false)
|
|
||||||
return manager().enqueue(download)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun download(id: Long): EngineDownload? {
|
|
||||||
val query = DownloadManager.Query().setFilterById(id)
|
|
||||||
manager().query(query).use { cursor ->
|
|
||||||
if (!cursor.moveToFirst()) return null
|
|
||||||
val status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
|
||||||
val bytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
|
|
||||||
val total = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
|
||||||
val reason =
|
|
||||||
cursor
|
|
||||||
.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
|
|
||||||
.takeIf { status == DownloadManager.STATUS_FAILED }
|
|
||||||
return EngineDownload(
|
|
||||||
status = engineStatus(status),
|
|
||||||
bytes = bytes.coerceAtLeast(0),
|
|
||||||
total = total.coerceAtLeast(0),
|
|
||||||
error = reason?.let(::failureMessage),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun cancel(id: Long) {
|
|
||||||
manager().remove(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun manager(): DownloadManager = context.getSystemService(DownloadManager::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package com.subtracks.data.model
|
|
||||||
|
|
||||||
import androidx.room3.ColumnInfo
|
|
||||||
import androidx.room3.ColumnTypeConverter
|
|
||||||
import androidx.room3.Entity
|
|
||||||
import androidx.room3.ForeignKey
|
|
||||||
import androidx.room3.Index
|
|
||||||
|
|
||||||
enum class DownloadStatus { Queued, Running, Completed, Failed }
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "song_downloads",
|
|
||||||
primaryKeys = ["sourceId", "songId"],
|
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = Source::class,
|
|
||||||
parentColumns = ["id"],
|
|
||||||
childColumns = ["sourceId"],
|
|
||||||
onDelete = ForeignKey.CASCADE,
|
|
||||||
),
|
|
||||||
ForeignKey(
|
|
||||||
entity = Song::class,
|
|
||||||
parentColumns = ["sourceId", "id"],
|
|
||||||
childColumns = ["sourceId", "songId"],
|
|
||||||
onDelete = ForeignKey.CASCADE,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
indices = [Index("sourceId"), Index("engineId")],
|
|
||||||
)
|
|
||||||
data class SongDownload(
|
|
||||||
val sourceId: Long,
|
|
||||||
val songId: String,
|
|
||||||
val status: DownloadStatus,
|
|
||||||
val engineId: Long? = null,
|
|
||||||
@ColumnInfo(defaultValue = "0") val bytes: Long = 0,
|
|
||||||
@ColumnInfo(defaultValue = "0") val total: Long = 0,
|
|
||||||
val error: String? = null,
|
|
||||||
) {
|
|
||||||
val progress: Float?
|
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
|
||||||
}
|
|
||||||
|
|
||||||
class DownloadStatusConverter {
|
|
||||||
@ColumnTypeConverter
|
|
||||||
fun fromDownloadStatus(status: DownloadStatus): String = status.name
|
|
||||||
|
|
||||||
@ColumnTypeConverter
|
|
||||||
fun toDownloadStatus(value: String): DownloadStatus = DownloadStatus.entries.firstOrNull { it.name == value } ?: DownloadStatus.Failed
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
package com.subtracks.data.repo
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
|
||||||
import com.subtracks.data.download.DownloadEngine
|
|
||||||
import com.subtracks.data.download.EngineDownload
|
|
||||||
import com.subtracks.data.download.EngineRequest
|
|
||||||
import com.subtracks.data.download.EngineStatus
|
|
||||||
import com.subtracks.data.model.DownloadStatus
|
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
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.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class DownloadRepository(
|
|
||||||
private val db: SubtracksDatabase,
|
|
||||||
private val sourceRepository: SourceRepository,
|
|
||||||
private val engine: DownloadEngine,
|
|
||||||
private val downloadsDir: File,
|
|
||||||
private val showMessage: (String) -> Unit = {},
|
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
|
||||||
) {
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private val loaded = CompletableDeferred<Unit>()
|
|
||||||
private var pollJob: Job? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var started = false
|
|
||||||
|
|
||||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (started) return
|
|
||||||
started = true
|
|
||||||
downloadsDir.mkdirs()
|
|
||||||
File(downloadsDir, NO_MEDIA).createNewFile()
|
|
||||||
scope.launch {
|
|
||||||
downloadStates().collect { rows ->
|
|
||||||
statesFlow.value = rows
|
|
||||||
loaded.complete(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
sourceRepository.activeSourceId().distinctUntilChanged().collect { reconcile() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun close() {
|
|
||||||
scope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
|
|
||||||
|
|
||||||
suspend fun awaitLoaded() {
|
|
||||||
if (started) loaded.await()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun localUri(songId: String): String? =
|
|
||||||
statesFlow.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,
|
|
||||||
) = 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(sourceId, songId)
|
|
||||||
if (url == null) {
|
|
||||||
showMessage(NO_ADDRESS)
|
|
||||||
return@withLock
|
|
||||||
}
|
|
||||||
enqueue(sourceId, songId, url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun remove(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
) = 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) =
|
|
||||||
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() =
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
url: String,
|
|
||||||
) {
|
|
||||||
dir(sourceId).mkdirs()
|
|
||||||
file(sourceId, songId).delete()
|
|
||||||
val title = db.libraryDao().songOnce(sourceId, songId)?.title ?: songId
|
|
||||||
val engineId =
|
|
||||||
engine.enqueue(
|
|
||||||
EngineRequest(uri = url, path = enginePath(sourceId, songId), title = title),
|
|
||||||
)
|
|
||||||
db.downloadDao().upsert(
|
|
||||||
SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued, engineId = engineId),
|
|
||||||
)
|
|
||||||
ensurePolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun recover(row: SongDownload) {
|
|
||||||
when (row.status) {
|
|
||||||
DownloadStatus.Completed -> {
|
|
||||||
if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE)
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadStatus.Queued -> {
|
|
||||||
if (sourceRepository.activeSourceIdOnce() != row.sourceId) return
|
|
||||||
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return
|
|
||||||
enqueue(row.sourceId, row.songId, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadStatus.Running -> {
|
|
||||||
markFailed(row, STOPPED)
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadStatus.Failed -> {
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun apply(
|
|
||||||
row: SongDownload,
|
|
||||||
state: EngineDownload,
|
|
||||||
) {
|
|
||||||
val status =
|
|
||||||
when (state.status) {
|
|
||||||
EngineStatus.Pending -> DownloadStatus.Queued
|
|
||||||
EngineStatus.Running -> DownloadStatus.Running
|
|
||||||
EngineStatus.Completed -> if (file(row.sourceId, row.songId).exists()) DownloadStatus.Completed else DownloadStatus.Failed
|
|
||||||
EngineStatus.Failed -> DownloadStatus.Failed
|
|
||||||
}
|
|
||||||
if (status == DownloadStatus.Failed) file(row.sourceId, row.songId).delete()
|
|
||||||
db.downloadDao().upsert(
|
|
||||||
row.copy(
|
|
||||||
status = status,
|
|
||||||
bytes = state.bytes,
|
|
||||||
total = state.total,
|
|
||||||
error = state.error ?: if (status == DownloadStatus.Failed) "Download failed" else null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun markFailed(
|
|
||||||
row: SongDownload,
|
|
||||||
message: String,
|
|
||||||
) {
|
|
||||||
row.engineId?.let(engine::cancel)
|
|
||||||
file(row.sourceId, row.songId).delete()
|
|
||||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = message))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensurePolling() {
|
|
||||||
if (pollJob?.isActive == true) return
|
|
||||||
pollJob =
|
|
||||||
scope.launch {
|
|
||||||
while (true) {
|
|
||||||
delay(POLL_MS)
|
|
||||||
mutex.withLock {
|
|
||||||
val sourceId = sourceRepository.activeSourceIdOnce()
|
|
||||||
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
|
|
||||||
if (active.isEmpty()) return@launch
|
|
||||||
active.forEach { row ->
|
|
||||||
val state = row.engineId?.let(engine::download)
|
|
||||||
if (state == null) recover(row) else apply(row, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sweep(rows: List<SongDownload>) {
|
|
||||||
val known = rows.map { it.sourceId to it.songId }.toHashSet()
|
|
||||||
// Artwork (a later block) lives in subdirectories and is swept separately.
|
|
||||||
downloadsDir.listFiles().orEmpty().filter { it.isDirectory }.forEach { sourceDir ->
|
|
||||||
val sourceId = sourceDir.name.toLongOrNull() ?: return@forEach
|
|
||||||
sourceDir.listFiles().orEmpty().filter { it.isFile }.forEach { file ->
|
|
||||||
if ((sourceId to file.name) !in known) file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dir(sourceId: Long): File = downloadsDir.resolve(sourceId.toString())
|
|
||||||
|
|
||||||
private fun file(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
): File = dir(sourceId).resolve(songId)
|
|
||||||
|
|
||||||
private fun enginePath(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
): String = "$ENGINE_DIR/$sourceId/$songId"
|
|
||||||
|
|
||||||
private val DownloadStatus.isActive: Boolean
|
|
||||||
get() = this == DownloadStatus.Queued || this == DownloadStatus.Running
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,8 +38,6 @@ class SourceRepository(
|
|||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var active: SubsonicMusicSource? = null
|
private var active: SubsonicMusicSource? = null
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var activeSourceId: Long? = null
|
private var activeSourceId: Long? = null
|
||||||
|
|
||||||
private val _quality = MutableStateFlow(StreamQuality())
|
private val _quality = MutableStateFlow(StreamQuality())
|
||||||
@@ -96,11 +94,6 @@ class SourceRepository(
|
|||||||
return uri + streamLengthSuffix(length)
|
return uri + streamLengthSuffix(length)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadUri(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
): String? = if (activeSourceId == sourceId) active?.downloadUri(songId)?.toString() else null
|
|
||||||
|
|
||||||
suspend fun addSource(
|
suspend fun addSource(
|
||||||
name: String,
|
name: String,
|
||||||
address: String,
|
address: String,
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
package com.subtracks.di
|
package com.subtracks.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Environment
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.subtracks.data.db.createAndroidDatabase
|
import com.subtracks.data.db.createAndroidDatabase
|
||||||
import com.subtracks.data.download.SystemDownloadEngine
|
|
||||||
import com.subtracks.data.net.networkMode
|
import com.subtracks.data.net.networkMode
|
||||||
import com.subtracks.data.prefs.createUserPreferences
|
import com.subtracks.data.prefs.createUserPreferences
|
||||||
import com.subtracks.data.repo.ArtworkSeedRepository
|
import com.subtracks.data.repo.ArtworkSeedRepository
|
||||||
import com.subtracks.data.repo.ArtworkSeedStore
|
import com.subtracks.data.repo.ArtworkSeedStore
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
@@ -30,7 +27,6 @@ import com.subtracks.ui.settings.SettingsViewModel
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
fun appModule(
|
fun appModule(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -48,24 +44,14 @@ fun appModule(
|
|||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get()) }
|
single { SyncManager(get(), get(), get()) }
|
||||||
single { QueueRepository(get()) }
|
single { QueueRepository(get()) }
|
||||||
single { SystemDownloadEngine(context.applicationContext) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
||||||
single {
|
single { PlaybackController(get(), get(), get(), showMessage = toast) }
|
||||||
DownloadRepository(
|
|
||||||
db = get(),
|
|
||||||
sourceRepository = get(),
|
|
||||||
engine = get(),
|
|
||||||
downloadsDir = File(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC) ?: context.filesDir, "downloads"),
|
|
||||||
showMessage = toast,
|
|
||||||
).also { it.start() }
|
|
||||||
}
|
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
|
||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
|
|
||||||
viewModel { RootViewModel(get()) }
|
viewModel { RootViewModel(get()) }
|
||||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
|
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
|
||||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
viewModel { SettingsViewModel(get(), get()) }
|
||||||
viewModel { AddSourceViewModel(get(), get()) }
|
viewModel { AddSourceViewModel(get(), get()) }
|
||||||
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
|
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
|
||||||
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), params.get()) }
|
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), params.get()) }
|
||||||
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
|
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
|
||||||
viewModel { QueueViewModel(get(), get()) }
|
viewModel { QueueViewModel(get(), get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ import androidx.media3.common.MediaMetadata
|
|||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
|
||||||
class Media3PlayerHandle(
|
class Media3PlayerHandle(
|
||||||
private val controller: MediaController,
|
private val controller: MediaController,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val downloads: DownloadRepository,
|
|
||||||
) : PlayerHandle {
|
) : PlayerHandle {
|
||||||
override val itemCount: Int get() = controller.mediaItemCount
|
override val itemCount: Int get() = controller.mediaItemCount
|
||||||
|
|
||||||
@@ -129,13 +127,37 @@ class Media3PlayerHandle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun toMediaItem(item: QueueItem): MediaItem {
|
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||||
|
val metadata =
|
||||||
|
MediaMetadata
|
||||||
|
.Builder()
|
||||||
|
.setTitle(item.title)
|
||||||
|
.setArtist(item.artist)
|
||||||
|
.setAlbumTitle(item.album)
|
||||||
|
.setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri))
|
||||||
|
.setDurationMs(item.durationMs)
|
||||||
|
.setIsBrowsable(false)
|
||||||
|
.setIsPlayable(true)
|
||||||
|
.setExtras(
|
||||||
|
Bundle().apply {
|
||||||
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||||
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||||
|
},
|
||||||
|
).build()
|
||||||
val quality = sourceRepository.quality.value
|
val quality = sourceRepository.quality.value
|
||||||
return queueMediaItem(
|
val builder =
|
||||||
item = item,
|
MediaItem
|
||||||
local = downloads.localUri(item.id),
|
.Builder()
|
||||||
stream = sourceRepository.streamUri(item.id, item.durationMs, quality),
|
.setMediaId(item.id)
|
||||||
transcode = quality.transcodes,
|
.setUri(sourceRepository.streamUri(item.id, item.durationMs, quality))
|
||||||
)
|
.setMediaMetadata(metadata)
|
||||||
|
if (quality.transcodes) {
|
||||||
|
item.durationMs?.takeIf { it > 0 }?.let { duration ->
|
||||||
|
builder.setClippingConfiguration(
|
||||||
|
ClippingConfiguration.Builder().setEndPositionMs(duration).build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
||||||
@@ -156,50 +178,11 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "SubtracksPlayback"
|
const val TAG = "SubtracksPlayback"
|
||||||
|
const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||||
|
const val EXTRA_DURATION_MS = "durationMs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
|
||||||
private const val EXTRA_DURATION_MS = "durationMs"
|
|
||||||
|
|
||||||
internal fun queueMediaItem(
|
|
||||||
item: QueueItem,
|
|
||||||
local: String?,
|
|
||||||
stream: String?,
|
|
||||||
transcode: Boolean,
|
|
||||||
): MediaItem {
|
|
||||||
val metadata =
|
|
||||||
MediaMetadata
|
|
||||||
.Builder()
|
|
||||||
.setTitle(item.title)
|
|
||||||
.setArtist(item.artist)
|
|
||||||
.setAlbumTitle(item.album)
|
|
||||||
.setArtworkUri(item.coverArtId?.takeIf { it.isNotEmpty() }?.let(CoverArtArtwork::uri))
|
|
||||||
.setDurationMs(item.durationMs)
|
|
||||||
.setIsBrowsable(false)
|
|
||||||
.setIsPlayable(true)
|
|
||||||
.setExtras(
|
|
||||||
Bundle().apply {
|
|
||||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
|
||||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
|
||||||
},
|
|
||||||
).build()
|
|
||||||
val builder =
|
|
||||||
MediaItem
|
|
||||||
.Builder()
|
|
||||||
.setMediaId(item.id)
|
|
||||||
.setUri(local ?: stream)
|
|
||||||
.setMediaMetadata(metadata)
|
|
||||||
if (local == null && transcode) {
|
|
||||||
item.durationMs?.takeIf { it > 0 }?.let { duration ->
|
|
||||||
builder.setClippingConfiguration(
|
|
||||||
ClippingConfiguration.Builder().setEndPositionMs(duration).build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return builder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun playbackErrorMessage(error: PlaybackException): String =
|
internal fun playbackErrorMessage(error: PlaybackException): String =
|
||||||
when (error.errorCode) {
|
when (error.errorCode) {
|
||||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.subtracks.playback
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.QueueSnapshot
|
import com.subtracks.data.repo.QueueSnapshot
|
||||||
import com.subtracks.data.repo.QueueWindowItem
|
import com.subtracks.data.repo.QueueWindowItem
|
||||||
@@ -107,7 +106,6 @@ class PlaybackController(
|
|||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val connection: PlayerConnection,
|
private val connection: PlayerConnection,
|
||||||
private val downloads: DownloadRepository,
|
|
||||||
private val showMessage: (String) -> Unit = {},
|
private val showMessage: (String) -> Unit = {},
|
||||||
dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
|
dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
|
||||||
) {
|
) {
|
||||||
@@ -144,7 +142,7 @@ class PlaybackController(
|
|||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sourceRepository.activeSourceId().collect { sourceId ->
|
sourceRepository.activeSourceId().collect { sourceId ->
|
||||||
if (queueSourceId != null && sourceId != queueSourceId) stop()
|
if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -871,7 +869,6 @@ class PlaybackController(
|
|||||||
) {
|
) {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
val snapshot = snapshot ?: return
|
val snapshot = snapshot ?: return
|
||||||
downloads.awaitLoaded()
|
|
||||||
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
||||||
val startIndex = window.indexOfFirst { it.position == position }
|
val startIndex = window.indexOfFirst { it.position == position }
|
||||||
if (startIndex < 0) return
|
if (startIndex < 0) return
|
||||||
@@ -890,27 +887,19 @@ class PlaybackController(
|
|||||||
|
|
||||||
private suspend fun reloadQuality() =
|
private suspend fun reloadQuality() =
|
||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
|
val player = player ?: return@withLock
|
||||||
|
val snapshot = snapshot ?: return@withLock
|
||||||
|
if (snapshot.size == 0L) return@withLock
|
||||||
val sourceId = queueSourceId
|
val sourceId = queueSourceId
|
||||||
if (sourceId == null || sourceRepository.activeSourceIdOnce() != sourceId) return@withLock
|
if (sourceId == null || sourceRepository.activeSourceIdOnce() != sourceId) return@withLock
|
||||||
reloadWindowLocked()
|
if (player.isEnded) return@withLock
|
||||||
|
val index = player.currentIndex
|
||||||
|
val position = (windowStart + index).coerceIn(0, snapshot.size - 1)
|
||||||
|
val positionMs = player.currentPositionMs
|
||||||
|
if (player.currentIndex != index) return@withLock
|
||||||
|
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshMediaItems() {
|
|
||||||
scope.launch {
|
|
||||||
startLock.withLock { reloadWindowLocked() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun reloadWindowLocked() {
|
|
||||||
val player = player ?: return
|
|
||||||
val snapshot = snapshot ?: return
|
|
||||||
if (snapshot.size == 0L) return
|
|
||||||
if (player.isEnded) return
|
|
||||||
val position = (windowStart + player.currentIndex).coerceIn(0, snapshot.size - 1)
|
|
||||||
val positionMs = player.currentPositionMs
|
|
||||||
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun jumpTo(target: Long) {
|
private fun jumpTo(target: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -10,8 +9,6 @@ import androidx.media3.common.AudioAttributes
|
|||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.util.ExperimentalApi
|
import androidx.media3.common.util.ExperimentalApi
|
||||||
import androidx.media3.common.util.UnstableApi
|
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.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
@@ -44,7 +41,9 @@ class PlaybackService : MediaSessionService() {
|
|||||||
.setMediaSourceFactory(
|
.setMediaSourceFactory(
|
||||||
DefaultMediaSourceFactory(this)
|
DefaultMediaSourceFactory(this)
|
||||||
.setEnableClippingInMediaPeriod(true)
|
.setEnableClippingInMediaPeriod(true)
|
||||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
.setDataSourceFactory(
|
||||||
|
KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
|
||||||
|
),
|
||||||
).build()
|
).build()
|
||||||
val loader =
|
val loader =
|
||||||
CoverArtBitmapLoader(
|
CoverArtBitmapLoader(
|
||||||
@@ -89,6 +88,10 @@ class PlaybackService : MediaSessionService() {
|
|||||||
response
|
response
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STREAM_TAG = "SubtracksPlayback"
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
artworkLoader?.shutdown()
|
artworkLoader?.shutdown()
|
||||||
artworkLoader = null
|
artworkLoader = null
|
||||||
@@ -99,14 +102,4 @@ class PlaybackService : MediaSessionService() {
|
|||||||
session = null
|
session = null
|
||||||
super.onDestroy()
|
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))
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
@@ -18,7 +17,6 @@ interface PlayerConnection {
|
|||||||
class MediaSessionConnection(
|
class MediaSessionConnection(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val downloads: DownloadRepository,
|
|
||||||
) : PlayerConnection {
|
) : PlayerConnection {
|
||||||
override fun connect(
|
override fun connect(
|
||||||
onConnected: (PlayerHandle) -> Unit,
|
onConnected: (PlayerHandle) -> Unit,
|
||||||
@@ -36,7 +34,7 @@ class MediaSessionConnection(
|
|||||||
future.addListener(
|
future.addListener(
|
||||||
{
|
{
|
||||||
val controller = runCatching { future.get() }.getOrNull()
|
val controller = runCatching { future.get() }.getOrNull()
|
||||||
if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository, downloads))
|
if (controller == null) onDisconnected() else onConnected(Media3PlayerHandle(controller, sourceRepository))
|
||||||
},
|
},
|
||||||
Executor { it.run() },
|
Executor { it.run() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||||
import androidx.compose.material.icons.rounded.Album
|
import androidx.compose.material.icons.rounded.Album
|
||||||
import androidx.compose.material.icons.rounded.Cancel
|
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Download
|
|
||||||
import androidx.compose.material.icons.rounded.Person
|
import androidx.compose.material.icons.rounded.Person
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
import androidx.compose.material.icons.rounded.Shuffle
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
@@ -46,9 +43,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadStatus
|
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.subtracks.data.model.Album as AlbumModel
|
import com.subtracks.data.model.Album as AlbumModel
|
||||||
@@ -64,7 +59,6 @@ sealed interface MenuTarget {
|
|||||||
data class Song(
|
data class Song(
|
||||||
val song: SongModel,
|
val song: SongModel,
|
||||||
override val coverArt: CoverArtRef? = null,
|
override val coverArt: CoverArtRef? = null,
|
||||||
val download: SongDownload? = null,
|
|
||||||
) : MenuTarget {
|
) : MenuTarget {
|
||||||
override val title: String get() = song.title
|
override val title: String get() = song.title
|
||||||
|
|
||||||
@@ -121,9 +115,6 @@ class ItemActions(
|
|||||||
val shufflePlaylist: (PlaylistModel) -> Unit = {},
|
val shufflePlaylist: (PlaylistModel) -> Unit = {},
|
||||||
val playNext: ((QueueRef) -> Unit)? = null,
|
val playNext: ((QueueRef) -> Unit)? = null,
|
||||||
val addToQueue: ((QueueRef) -> Unit)? = null,
|
val addToQueue: ((QueueRef) -> Unit)? = null,
|
||||||
val download: ((SongModel) -> Unit)? = null,
|
|
||||||
val cancelDownload: ((SongModel) -> Unit)? = null,
|
|
||||||
val deleteDownload: ((SongModel) -> Unit)? = null,
|
|
||||||
val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) },
|
val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) },
|
||||||
val viewAlbum: ((String) -> Unit)? = null,
|
val viewAlbum: ((String) -> Unit)? = null,
|
||||||
val viewArtist: ((String) -> Unit)? = null,
|
val viewArtist: ((String) -> Unit)? = null,
|
||||||
@@ -193,7 +184,6 @@ fun ItemContextMenu(
|
|||||||
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } }
|
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } }
|
||||||
}
|
}
|
||||||
queueItems()
|
queueItems()
|
||||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
|
||||||
StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) }
|
StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) }
|
||||||
target.song.albumId?.let { albumId ->
|
target.song.albumId?.let { albumId ->
|
||||||
actions.viewAlbum?.let { view ->
|
actions.viewAlbum?.let { view ->
|
||||||
@@ -271,40 +261,6 @@ private fun MenuHeader(target: MenuTarget) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DownloadItem(
|
|
||||||
download: SongDownload?,
|
|
||||||
song: SongModel,
|
|
||||||
actions: ItemActions,
|
|
||||||
dismiss: (() -> Unit) -> Unit,
|
|
||||||
) {
|
|
||||||
when (download?.status) {
|
|
||||||
DownloadStatus.Completed -> {
|
|
||||||
actions.deleteDownload?.let { delete ->
|
|
||||||
MenuItem(Icons.Rounded.Delete, "Delete download") { dismiss { delete(song) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadStatus.Queued, DownloadStatus.Running -> {
|
|
||||||
actions.cancelDownload?.let { cancel ->
|
|
||||||
MenuItem(Icons.Rounded.Cancel, "Cancel download") { dismiss { cancel(song) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadStatus.Failed -> {
|
|
||||||
actions.download?.let { start ->
|
|
||||||
MenuItem(Icons.Rounded.Download, "Retry download") { dismiss { start(song) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
null -> {
|
|
||||||
actions.download?.let { start ->
|
|
||||||
MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(song) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StarItem(
|
private fun StarItem(
|
||||||
current: Long?,
|
current: Long?,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import com.subtracks.data.model.CoverArtRef
|
|||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
@@ -56,7 +55,6 @@ fun AlbumDetailRoute(
|
|||||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
@@ -67,9 +65,6 @@ fun AlbumDetailRoute(
|
|||||||
shuffleAlbum = { viewModel.shuffle() },
|
shuffleAlbum = { viewModel.shuffle() },
|
||||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||||
download = viewModel::download,
|
|
||||||
cancelDownload = viewModel::cancelDownload,
|
|
||||||
deleteDownload = viewModel::deleteDownload,
|
|
||||||
setStar = setStar,
|
setStar = setStar,
|
||||||
viewArtist = onViewArtist,
|
viewArtist = onViewArtist,
|
||||||
)
|
)
|
||||||
@@ -79,7 +74,6 @@ fun AlbumDetailRoute(
|
|||||||
discs = discs,
|
discs = discs,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
|
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
|
||||||
downloads = downloads,
|
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
@@ -102,7 +96,6 @@ fun AlbumDetailScreen(
|
|||||||
discs: List<Disc> = emptyList(),
|
discs: List<Disc> = emptyList(),
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
downloads: Map<String, SongDownload> = emptyMap(),
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSongClick: (Int) -> Unit,
|
onSongClick: (Int) -> Unit,
|
||||||
onSongLongClick: (MenuTarget) -> Unit = {},
|
onSongLongClick: (MenuTarget) -> Unit = {},
|
||||||
@@ -172,13 +165,10 @@ fun AlbumDetailScreen(
|
|||||||
song = song,
|
song = song,
|
||||||
isPlaying = song.id == playingSongId,
|
isPlaying = song.id == playingSongId,
|
||||||
trackNumber = song.track,
|
trackNumber = song.track,
|
||||||
download = downloads[song.id],
|
|
||||||
modifier =
|
modifier =
|
||||||
rowModifier.combinedClickable(
|
rowModifier.combinedClickable(
|
||||||
onClick = { onSongClick(index) },
|
onClick = { onSongClick(index) },
|
||||||
onLongClick = {
|
onLongClick = { onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true))) },
|
||||||
onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true), downloads[song.id]))
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,11 @@ import com.subtracks.data.model.Album
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -24,7 +21,6 @@ class AlbumDetailViewModel(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
private val downloadRepository: DownloadRepository,
|
|
||||||
private val albumId: String,
|
private val albumId: String,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
@@ -35,28 +31,11 @@ class AlbumDetailViewModel(
|
|||||||
|
|
||||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||||
|
|
||||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
|
||||||
|
|
||||||
fun coverArt(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
fun download(song: Song) {
|
|
||||||
viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelDownload(song: Song) {
|
|
||||||
viewModelScope.launch { downloadRepository.remove(song.sourceId, song.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteDownload(song: Song) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
downloadRepository.remove(song.sourceId, song.id)
|
|
||||||
playbackController.refreshMediaItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun play(startIndex: Int) {
|
fun play(startIndex: Int) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackController.playAlbum(sourceId.first(), albumId, startIndex.toLong())
|
playbackController.playAlbum(sourceId.first(), albumId, startIndex.toLong())
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import androidx.paging.compose.itemKey
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
@@ -44,7 +43,6 @@ fun PlaylistDetailRoute(
|
|||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val actions =
|
val actions =
|
||||||
@@ -53,9 +51,6 @@ fun PlaylistDetailRoute(
|
|||||||
shufflePlaylist = { viewModel.shuffle() },
|
shufflePlaylist = { viewModel.shuffle() },
|
||||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||||
download = viewModel::download,
|
|
||||||
cancelDownload = viewModel::cancelDownload,
|
|
||||||
deleteDownload = viewModel::deleteDownload,
|
|
||||||
setStar = setStar,
|
setStar = setStar,
|
||||||
viewAlbum = onViewAlbum,
|
viewAlbum = onViewAlbum,
|
||||||
viewArtist = onViewArtist,
|
viewArtist = onViewArtist,
|
||||||
@@ -65,7 +60,6 @@ fun PlaylistDetailRoute(
|
|||||||
songs = viewModel.songs.collectAsLazyPagingItems(),
|
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
||||||
downloads = downloads,
|
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
@@ -82,7 +76,6 @@ fun PlaylistDetailScreen(
|
|||||||
songs: LazyPagingItems<SongListItem>,
|
songs: LazyPagingItems<SongListItem>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
downloads: Map<String, SongDownload> = emptyMap(),
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSongClick: (Int) -> Unit,
|
onSongClick: (Int) -> Unit,
|
||||||
onSongLongClick: (MenuTarget) -> Unit = {},
|
onSongLongClick: (MenuTarget) -> Unit = {},
|
||||||
@@ -121,13 +114,10 @@ fun PlaylistDetailScreen(
|
|||||||
coverArtId = item.coverArt,
|
coverArtId = item.coverArt,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
isPlaying = item.song.id == playingSongId,
|
isPlaying = item.song.id == playingSongId,
|
||||||
download = downloads[item.song.id],
|
|
||||||
modifier =
|
modifier =
|
||||||
rowModifier.combinedClickable(
|
rowModifier.combinedClickable(
|
||||||
onClick = { onSongClick(index) },
|
onClick = { onSongClick(index) },
|
||||||
onLongClick = {
|
onLongClick = { onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
|
||||||
onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true), downloads[item.song.id]))
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import androidx.paging.PagingData
|
|||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.Song
|
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
@@ -26,7 +22,6 @@ class PlaylistDetailViewModel(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
private val downloadRepository: DownloadRepository,
|
|
||||||
private val playlistId: String,
|
private val playlistId: String,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
@@ -38,24 +33,6 @@ class PlaylistDetailViewModel(
|
|||||||
.flatMapLatest { libraryRepository.playlistSongs(it, playlistId) }
|
.flatMapLatest { libraryRepository.playlistSongs(it, playlistId) }
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
val downloads: StateFlow<Map<String, SongDownload>> =
|
|
||||||
downloadRepository.states()
|
|
||||||
|
|
||||||
fun download(song: Song) {
|
|
||||||
viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelDownload(song: Song) {
|
|
||||||
viewModelScope.launch { downloadRepository.remove(song.sourceId, song.id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteDownload(song: Song) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
downloadRepository.remove(song.sourceId, song.id)
|
|
||||||
playbackController.refreshMediaItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun coverArt(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.DownloadDone
|
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
@@ -20,15 +18,11 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadStatus
|
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.StarredBadge
|
import com.subtracks.ui.components.StarredBadge
|
||||||
|
|
||||||
@@ -39,7 +33,6 @@ fun SongRow(
|
|||||||
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
||||||
isPlaying: Boolean = false,
|
isPlaying: Boolean = false,
|
||||||
trackNumber: Long? = null,
|
trackNumber: Long? = null,
|
||||||
download: SongDownload? = null,
|
|
||||||
trailingContent: (@Composable () -> Unit)? = null,
|
trailingContent: (@Composable () -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -89,7 +82,6 @@ fun SongRow(
|
|||||||
modifier = Modifier.weight(1f, fill = false),
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
)
|
)
|
||||||
StarredBadge(song.starred != null)
|
StarredBadge(song.starred != null)
|
||||||
DownloadBadge(download)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
@@ -104,40 +96,6 @@ fun SongRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DownloadBadge(download: SongDownload?) {
|
|
||||||
when (download?.status) {
|
|
||||||
DownloadStatus.Completed -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.DownloadDone,
|
|
||||||
contentDescription = "Downloaded",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadStatus.Queued, DownloadStatus.Running -> {
|
|
||||||
val progress = download.progress
|
|
||||||
if (progress == null) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" },
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = { progress },
|
|
||||||
modifier = Modifier.size(14.dp).semantics { contentDescription = "Downloading" },
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrackNumber(
|
private fun TrackNumber(
|
||||||
track: Long,
|
track: Long,
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import androidx.compose.runtime.mutableFloatStateOf
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -67,7 +66,6 @@ import coil3.request.ImageRequest
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
@@ -82,7 +80,6 @@ import com.subtracks.ui.theme.ArtworkSeedCache
|
|||||||
import com.subtracks.ui.theme.ArtworkTheme
|
import com.subtracks.ui.theme.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
const val NOW_PLAYING_COVER_TAG = "now-playing-cover"
|
const val NOW_PLAYING_COVER_TAG = "now-playing-cover"
|
||||||
@@ -98,15 +95,11 @@ fun NowPlayingRoute(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
controller: PlaybackController = koinInject(),
|
controller: PlaybackController = koinInject(),
|
||||||
libraryRepository: LibraryRepository = koinInject(),
|
libraryRepository: LibraryRepository = koinInject(),
|
||||||
downloadRepository: DownloadRepository = koinInject(),
|
|
||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsStateWithLifecycle()
|
val state by controller.state.collectAsStateWithLifecycle()
|
||||||
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
||||||
val sourceId = state.context?.sourceId
|
val sourceId = state.context?.sourceId
|
||||||
val songId = state.item?.id
|
val songId = state.item?.id
|
||||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
|
||||||
val download = songId?.let { downloads[it] }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val song by
|
val song by
|
||||||
produceState<Song?>(null, sourceId, songId) {
|
produceState<Song?>(null, sourceId, songId) {
|
||||||
if (sourceId != null && songId != null) {
|
if (sourceId != null && songId != null) {
|
||||||
@@ -116,14 +109,6 @@ fun NowPlayingRoute(
|
|||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
val actions =
|
val actions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
download = { track -> scope.launch { downloadRepository.download(track.sourceId, track.id) } },
|
|
||||||
cancelDownload = { track -> scope.launch { downloadRepository.remove(track.sourceId, track.id) } },
|
|
||||||
deleteDownload = { track ->
|
|
||||||
scope.launch {
|
|
||||||
downloadRepository.remove(track.sourceId, track.id)
|
|
||||||
controller.refreshMediaItems()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setStar = setStar,
|
setStar = setStar,
|
||||||
viewAlbum = onViewAlbum,
|
viewAlbum = onViewAlbum,
|
||||||
viewArtist = onViewArtist,
|
viewArtist = onViewArtist,
|
||||||
@@ -157,7 +142,7 @@ fun NowPlayingRoute(
|
|||||||
onPrevious = controller::previous,
|
onPrevious = controller::previous,
|
||||||
onShuffle = controller::toggleShuffle,
|
onShuffle = controller::toggleShuffle,
|
||||||
onRepeat = controller::cycleRepeat,
|
onRepeat = controller::cycleRepeat,
|
||||||
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail), actions) } },
|
||||||
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
||||||
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
|
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
|
||||||
onSeek = controller::seekTo,
|
onSeek = controller::seekTo,
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.QUEUE_CHUNK
|
import com.subtracks.data.repo.QUEUE_CHUNK
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.QueueWindowItem
|
import com.subtracks.data.repo.QueueWindowItem
|
||||||
@@ -269,10 +267,8 @@ fun QueueRoute(
|
|||||||
viewModel: QueueViewModel = koinViewModel(),
|
viewModel: QueueViewModel = koinViewModel(),
|
||||||
controller: PlaybackController = koinInject(),
|
controller: PlaybackController = koinInject(),
|
||||||
sourceRepository: SourceRepository = koinInject(),
|
sourceRepository: SourceRepository = koinInject(),
|
||||||
downloadRepository: DownloadRepository = koinInject(),
|
|
||||||
) {
|
) {
|
||||||
val playback by controller.state.collectAsStateWithLifecycle()
|
val playback by controller.state.collectAsStateWithLifecycle()
|
||||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
|
||||||
val queueContext = playback.context
|
val queueContext = playback.context
|
||||||
// The item's album keeps the header stable while the async name lookup runs, so it does not
|
// 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.
|
// flash the fallback when tapping between context and manually queued tracks.
|
||||||
@@ -291,7 +287,6 @@ fun QueueRoute(
|
|||||||
currentSongId = playback.item?.id,
|
currentSongId = playback.item?.id,
|
||||||
shuffle = playback.shuffle,
|
shuffle = playback.shuffle,
|
||||||
contextTitle = sourceTitle.value,
|
contextTitle = sourceTitle.value,
|
||||||
downloads = downloads,
|
|
||||||
onClearUpNext = controller::clearUpNext,
|
onClearUpNext = controller::clearUpNext,
|
||||||
coverArt = sourceRepository::coverArt,
|
coverArt = sourceRepository::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -326,7 +321,6 @@ fun QueueScreen(
|
|||||||
generation: Int = 0,
|
generation: Int = 0,
|
||||||
shuffle: Boolean = false,
|
shuffle: Boolean = false,
|
||||||
contextTitle: String? = null,
|
contextTitle: String? = null,
|
||||||
downloads: Map<String, SongDownload> = emptyMap(),
|
|
||||||
onClearUpNext: () -> Unit = {},
|
onClearUpNext: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
@@ -443,7 +437,6 @@ fun QueueScreen(
|
|||||||
isPlaying = row.song.song.id == currentSongId,
|
isPlaying = row.song.song.id == currentSongId,
|
||||||
floating = isDragging,
|
floating = isDragging,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
download = downloads[row.song.song.id],
|
|
||||||
dragHandle =
|
dragHandle =
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
null
|
null
|
||||||
@@ -544,7 +537,6 @@ private fun QueueRowItem(
|
|||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
floating: Boolean,
|
floating: Boolean,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
download: SongDownload?,
|
|
||||||
dragHandle: Modifier?,
|
dragHandle: Modifier?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
@@ -553,7 +545,6 @@ private fun QueueRowItem(
|
|||||||
song = row.song.song,
|
song = row.song.song,
|
||||||
coverArtId = row.song.coverArt,
|
coverArtId = row.song.coverArt,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
download = download,
|
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import com.subtracks.data.model.Source
|
|||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -15,7 +14,6 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val downloadRepository: DownloadRepository,
|
|
||||||
private val userPreferences: UserPreferences,
|
private val userPreferences: UserPreferences,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val sources: StateFlow<List<Source>> =
|
val sources: StateFlow<List<Source>> =
|
||||||
@@ -39,10 +37,7 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSource(id: Long) {
|
fun deleteSource(id: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { sourceRepository.deleteSource(id) }
|
||||||
downloadRepository.removeSource(id)
|
|
||||||
sourceRepository.deleteSource(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWifiQuality(quality: StreamQuality) {
|
fun setWifiQuality(quality: StreamQuality) {
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package com.subtracks.data.download
|
|
||||||
|
|
||||||
class FakeDownloadEngine : DownloadEngine {
|
|
||||||
private val downloads = mutableMapOf<Long, EngineDownload>()
|
|
||||||
private var nextId = 1L
|
|
||||||
|
|
||||||
val requests = mutableListOf<Pair<Long, EngineRequest>>()
|
|
||||||
val cancelled = mutableListOf<Long>()
|
|
||||||
|
|
||||||
override fun enqueue(request: EngineRequest): Long {
|
|
||||||
val id = nextId++
|
|
||||||
requests += id to request
|
|
||||||
downloads[id] = EngineDownload(EngineStatus.Pending)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun download(id: Long): EngineDownload? = downloads[id]
|
|
||||||
|
|
||||||
override fun cancel(id: Long) {
|
|
||||||
cancelled += id
|
|
||||||
}
|
|
||||||
|
|
||||||
fun running(
|
|
||||||
id: Long,
|
|
||||||
bytes: Long,
|
|
||||||
total: Long,
|
|
||||||
) {
|
|
||||||
downloads[id] = EngineDownload(EngineStatus.Running, bytes, total)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun complete(
|
|
||||||
id: Long,
|
|
||||||
total: Long,
|
|
||||||
) {
|
|
||||||
downloads[id] = EngineDownload(EngineStatus.Completed, total, total)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fail(id: Long) {
|
|
||||||
downloads[id] = EngineDownload(EngineStatus.Failed, error = "Download failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun forget(id: Long) {
|
|
||||||
downloads.remove(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.subtracks.data.download
|
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
package com.subtracks.data.repo
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
|
||||||
import com.subtracks.data.model.DownloadStatus
|
|
||||||
import com.subtracks.data.model.Song
|
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.data.model.Source
|
|
||||||
import com.subtracks.data.model.SubsonicSource
|
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class DownloadRepositoryTest {
|
|
||||||
private lateinit var db: SubtracksDatabase
|
|
||||||
private lateinit var sources: SourceRepository
|
|
||||||
private lateinit var engine: FakeDownloadEngine
|
|
||||||
private lateinit var repository: DownloadRepository
|
|
||||||
private lateinit var dir: File
|
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
|
||||||
engine = FakeDownloadEngine()
|
|
||||||
dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
|
|
||||||
repository = DownloadRepository(db, sources, engine, dir, showMessage = { messages += it })
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
repository.close()
|
|
||||||
sources.close()
|
|
||||||
db.close()
|
|
||||||
dir.deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun startingADownloadAsksTheEngineForTheOriginalAndRecordsIt() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
|
|
||||||
runBlocking { repository.download(1, "s1") }
|
|
||||||
|
|
||||||
val request = engine.requests.single().second
|
|
||||||
assertTrue("expected the download endpoint, was ${request.uri}", request.uri.contains("/rest/download"))
|
|
||||||
assertEquals("downloads/1/s1", request.path)
|
|
||||||
assertEquals("Song 1", request.title)
|
|
||||||
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() =
|
|
||||||
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 }
|
|
||||||
assertEquals(40L, row(1, "s1")?.bytes)
|
|
||||||
|
|
||||||
engine.complete(id, total = 100)
|
|
||||||
writeFile(1, "s1", "audio")
|
|
||||||
runBlocking { repository.reconcile() }
|
|
||||||
|
|
||||||
await { repository.localUri("s1") != null }
|
|
||||||
assertEquals(DownloadStatus.Completed, row(1, "s1")?.status)
|
|
||||||
assertEquals(Uri.fromFile(file(1, "s1")).toString(), repository.localUri("s1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aPartialFileIsNotTreatedAsPlayableWhileTheDownloadRuns() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
repository.start()
|
|
||||||
runBlocking { repository.download(1, "s1") }
|
|
||||||
val id = engine.requests.single().first
|
|
||||||
engine.running(id, bytes = 40, total = 100)
|
|
||||||
writeFile(1, "s1", "half")
|
|
||||||
await { row(1, "s1")?.status == DownloadStatus.Running }
|
|
||||||
|
|
||||||
assertNull(repository.localUri("s1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aCompletedRowWhoseFileIsGoneIsFailedRatherThanLeftCompleted() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = null))
|
|
||||||
|
|
||||||
runBlocking { repository.reconcile() }
|
|
||||||
|
|
||||||
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aCompletedEngineStateWithoutAFileIsFailed() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
runBlocking { repository.download(1, "s1") }
|
|
||||||
val id = engine.requests.single().first
|
|
||||||
engine.complete(id, total = 100)
|
|
||||||
|
|
||||||
runBlocking { repository.reconcile() }
|
|
||||||
|
|
||||||
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aQueuedRowWhoseEngineEntryIsGoneIsStartedAgain() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Queued, engineId = 7))
|
|
||||||
|
|
||||||
runBlocking { repository.reconcile() }
|
|
||||||
|
|
||||||
val request = engine.requests.single()
|
|
||||||
assertNotEquals(7L, request.first)
|
|
||||||
assertEquals("downloads/1/s1", request.second.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun removingADownloadCancelsItAndDeletesTheFile() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
writeFile(1, "s1", "audio")
|
|
||||||
runBlocking { repository.download(1, "s1") }
|
|
||||||
val id = engine.requests.single().first
|
|
||||||
|
|
||||||
runBlocking { repository.remove(1, "s1") }
|
|
||||||
|
|
||||||
assertTrue(engine.cancelled.contains(id))
|
|
||||||
assertFalse(file(1, "s1").exists())
|
|
||||||
assertNull(row(1, "s1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aFailedDownloadCanBeStartedAgain() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Failed, engineId = 3, error = "boom"))
|
|
||||||
|
|
||||||
runBlocking { repository.download(1, "s1") }
|
|
||||||
|
|
||||||
assertEquals(1, engine.requests.size)
|
|
||||||
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 anEngineFailureIsMirroredWithItsMessageAndLeavesNoPartialFile() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
runBlocking { repository.download(1, "s1") }
|
|
||||||
val id = engine.requests.single().first
|
|
||||||
writeFile(1, "s1", "half")
|
|
||||||
|
|
||||||
engine.fail(id)
|
|
||||||
runBlocking { repository.reconcile() }
|
|
||||||
|
|
||||||
assertEquals(DownloadStatus.Failed, row(1, "s1")?.status)
|
|
||||||
assertEquals("Download failed", row(1, "s1")?.error)
|
|
||||||
assertFalse(file(1, "s1").exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 {
|
|
||||||
seedLibrary()
|
|
||||||
db.sourcesDao().upsertSource(Source(2, "other", "http://other/", isActive = false, createdAt = 0))
|
|
||||||
db.sourcesDao().upsertSubsonicSource(SubsonicSource(2, "user", "pass"))
|
|
||||||
|
|
||||||
runBlocking { repository.download(2, "s1") }
|
|
||||||
|
|
||||||
assertTrue(engine.requests.isEmpty())
|
|
||||||
assertEquals(listOf("Can't download from a source that isn't active"), messages.toList())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun removingASourceCancelsItsDownloadsAndDeletesItsFiles() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
writeFile(1, "s1", "audio")
|
|
||||||
runBlocking { repository.download(1, "s1") }
|
|
||||||
val id = engine.requests.single().first
|
|
||||||
|
|
||||||
runBlocking { repository.removeSource(1) }
|
|
||||||
|
|
||||||
assertTrue(engine.cancelled.contains(id))
|
|
||||||
assertFalse(file(1, "s1").exists())
|
|
||||||
assertTrue(allRows().isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun reconcileDeletesFilesThatBelongToNoDownload() =
|
|
||||||
runTest {
|
|
||||||
seedLibrary()
|
|
||||||
writeFile(1, "orphan", "audio")
|
|
||||||
|
|
||||||
runBlocking { repository.reconcile() }
|
|
||||||
|
|
||||||
assertFalse(file(1, "orphan").exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun seedLibrary() {
|
|
||||||
runBlocking {
|
|
||||||
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0))
|
|
||||||
db.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass"))
|
|
||||||
db.libraryDao().upsertSongs(
|
|
||||||
listOf(
|
|
||||||
Song(
|
|
||||||
sourceId = 1,
|
|
||||||
id = "s1",
|
|
||||||
albumId = "al1",
|
|
||||||
artistId = "ar1",
|
|
||||||
title = "Song 1",
|
|
||||||
album = "Album",
|
|
||||||
artist = "Artist",
|
|
||||||
duration = 100,
|
|
||||||
track = 1,
|
|
||||||
disc = 1,
|
|
||||||
starred = null,
|
|
||||||
genre = null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await { sources.downloadUri(1, "s1") != null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun file(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
): File = File(File(dir, sourceId.toString()), songId)
|
|
||||||
|
|
||||||
private fun row(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) }
|
|
||||||
|
|
||||||
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
|
|
||||||
|
|
||||||
private fun writeFile(
|
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
contents: String,
|
|
||||||
) {
|
|
||||||
val file = file(sourceId, songId)
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
file.writeText(contents)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun await(predicate: () -> Boolean) {
|
|
||||||
val deadline = System.nanoTime() + 5_000_000_000L
|
|
||||||
while (!predicate() && System.nanoTime() < deadline) Thread.sleep(10)
|
|
||||||
assertTrue("Timed out waiting for the expected download state", predicate())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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 com.subtracks.data.source.streamLengthSuffix
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.mockwebserver.MockResponse
|
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class MediaDataSourceTest {
|
|
||||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
|
|
||||||
private fun dataSource() = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aDownloadedFileIsReadThroughTheStreamingChain() {
|
|
||||||
val file = File(context.cacheDir, "downloaded-${System.nanoTime()}").apply { writeBytes(byteArrayOf(1, 2, 3, 4)) }
|
|
||||||
val dataSource = dataSource()
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aStreamWithoutAContentLengthGetsItsDeclaredLength() {
|
|
||||||
val server = MockWebServer()
|
|
||||||
server.enqueue(MockResponse().setChunkedBody("0123456789", 1))
|
|
||||||
server.start()
|
|
||||||
val url = "http://${server.hostName}:${server.port}/rest/stream" + streamLengthSuffix(1_000_000)
|
|
||||||
val dataSource = dataSource()
|
|
||||||
|
|
||||||
val length = dataSource.open(DataSpec(Uri.parse(url)))
|
|
||||||
dataSource.close()
|
|
||||||
server.shutdown()
|
|
||||||
|
|
||||||
assertEquals(1_000_000L, length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package com.subtracks.playback
|
|
||||||
|
|
||||||
import androidx.media3.common.C
|
|
||||||
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 MediaItemTest {
|
|
||||||
private val song =
|
|
||||||
QueueItem(
|
|
||||||
id = "s1",
|
|
||||||
title = "Song",
|
|
||||||
artist = "Artist",
|
|
||||||
album = "Album",
|
|
||||||
coverArtId = "al1",
|
|
||||||
durationMs = 100_000L,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aDownloadedFileIsPreferredOverTheStream() {
|
|
||||||
val item = queueMediaItem(song, local = "file:///downloads/1/s1", stream = "http://server/stream?s=s1", transcode = false)
|
|
||||||
|
|
||||||
assertEquals("file:///downloads/1/s1", item.localConfiguration?.uri.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theStreamIsUsedWhenThereIsNoDownload() {
|
|
||||||
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
|
|
||||||
|
|
||||||
assertEquals("http://server/stream?s=s1", item.localConfiguration?.uri.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aDownloadedFileIsNotClippedEvenForATranscodingQuality() {
|
|
||||||
val item = queueMediaItem(song, local = "file:///downloads/1/s1", stream = "http://server/stream?s=s1", transcode = true)
|
|
||||||
|
|
||||||
assertEquals(C.TIME_END_OF_SOURCE, item.clippingConfiguration?.endPositionMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aTranscodedStreamIsClippedToItsDeclaredDuration() {
|
|
||||||
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = true)
|
|
||||||
|
|
||||||
assertEquals(100_000L, item.clippingConfiguration?.endPositionMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun theItemKeepsItsIdTitleAndCoverArt() {
|
|
||||||
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
|
|
||||||
|
|
||||||
assertEquals("s1", item.mediaId)
|
|
||||||
assertEquals("Song", item.mediaMetadata.title.toString())
|
|
||||||
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
@@ -17,7 +16,6 @@ import com.subtracks.data.net.NetworkMode
|
|||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -35,7 +33,6 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@@ -48,7 +45,6 @@ class PlaybackControllerTest {
|
|||||||
private lateinit var networkMode: MutableStateFlow<NetworkMode>
|
private lateinit var networkMode: MutableStateFlow<NetworkMode>
|
||||||
private lateinit var sources: SourceRepository
|
private lateinit var sources: SourceRepository
|
||||||
private lateinit var queues: QueueRepository
|
private lateinit var queues: QueueRepository
|
||||||
private lateinit var downloads: DownloadRepository
|
|
||||||
private lateinit var handle: FakePlayerHandle
|
private lateinit var handle: FakePlayerHandle
|
||||||
private lateinit var controller: PlaybackController
|
private lateinit var controller: PlaybackController
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
@@ -65,24 +61,15 @@ class PlaybackControllerTest {
|
|||||||
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
||||||
sources = SourceRepository(db, OkHttpClient(), prefs, networkMode = networkMode)
|
sources = SourceRepository(db, OkHttpClient(), prefs, networkMode = networkMode)
|
||||||
queues = QueueRepository(db)
|
queues = QueueRepository(db)
|
||||||
downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads"))
|
|
||||||
handle = FakePlayerHandle()
|
handle = FakePlayerHandle()
|
||||||
controller =
|
controller =
|
||||||
PlaybackController(
|
PlaybackController(sources, queues, FakePlayerConnection(handle), showMessage = { messages += it }, dispatcher = dispatcher)
|
||||||
sources,
|
|
||||||
queues,
|
|
||||||
FakePlayerConnection(handle),
|
|
||||||
downloads,
|
|
||||||
showMessage = { messages += it },
|
|
||||||
dispatcher = dispatcher,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
controller.close()
|
controller.close()
|
||||||
sources.close()
|
sources.close()
|
||||||
downloads.close()
|
|
||||||
db.close()
|
db.close()
|
||||||
dispatcher.close()
|
dispatcher.close()
|
||||||
}
|
}
|
||||||
@@ -563,7 +550,7 @@ class PlaybackControllerTest {
|
|||||||
runBlocking { queues.setPosition(12_000L) }
|
runBlocking { queues.setPosition(12_000L) }
|
||||||
|
|
||||||
val restoredHandle = FakePlayerHandle()
|
val restoredHandle = FakePlayerHandle()
|
||||||
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher)
|
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher)
|
||||||
restored.connect()
|
restored.connect()
|
||||||
await {
|
await {
|
||||||
restored.state.value.item
|
restored.state.value.item
|
||||||
@@ -671,7 +658,7 @@ class PlaybackControllerTest {
|
|||||||
block: Runnable,
|
block: Runnable,
|
||||||
) = block.run()
|
) = block.run()
|
||||||
}
|
}
|
||||||
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
|
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
|
||||||
|
|
||||||
eager.playAlbum(1, "al1", 0)
|
eager.playAlbum(1, "al1", 0)
|
||||||
await {
|
await {
|
||||||
@@ -718,7 +705,7 @@ class PlaybackControllerTest {
|
|||||||
block: Runnable,
|
block: Runnable,
|
||||||
) = block.run()
|
) = block.run()
|
||||||
}
|
}
|
||||||
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
|
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
|
||||||
|
|
||||||
eager.playAlbum(1, "al1", 0)
|
eager.playAlbum(1, "al1", 0)
|
||||||
await {
|
await {
|
||||||
@@ -761,7 +748,7 @@ class PlaybackControllerTest {
|
|||||||
block: Runnable,
|
block: Runnable,
|
||||||
) = block.run()
|
) = block.run()
|
||||||
}
|
}
|
||||||
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
|
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
|
||||||
|
|
||||||
eager.playAlbum(1, "al1", 0)
|
eager.playAlbum(1, "al1", 0)
|
||||||
await {
|
await {
|
||||||
@@ -804,7 +791,7 @@ class PlaybackControllerTest {
|
|||||||
block: Runnable,
|
block: Runnable,
|
||||||
) = block.run()
|
) = block.run()
|
||||||
}
|
}
|
||||||
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), downloads, dispatcher = inline)
|
val eager = PlaybackController(sources, queues, FakePlayerConnection(eagerHandle), dispatcher = inline)
|
||||||
|
|
||||||
eager.playAlbum(1, "al1", 0)
|
eager.playAlbum(1, "al1", 0)
|
||||||
await {
|
await {
|
||||||
@@ -1843,7 +1830,7 @@ class PlaybackControllerTest {
|
|||||||
runBlocking { queues.setPosition(12_000L) }
|
runBlocking { queues.setPosition(12_000L) }
|
||||||
|
|
||||||
val restoredHandle = FakePlayerHandle()
|
val restoredHandle = FakePlayerHandle()
|
||||||
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), downloads, dispatcher = dispatcher)
|
val restored = PlaybackController(sources, queues, FakePlayerConnection(restoredHandle), dispatcher = dispatcher)
|
||||||
restored.connect()
|
restored.connect()
|
||||||
await {
|
await {
|
||||||
restored.state.value.item
|
restored.state.value.item
|
||||||
@@ -1879,21 +1866,6 @@ class PlaybackControllerTest {
|
|||||||
assertEquals(0L, runBlocking { queues.cursorPositionMs() })
|
assertEquals(0L, runBlocking { queues.cursorPositionMs() })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun refreshingMediaItemsRebuildsTheWindowAtTheSamePosition() {
|
|
||||||
seedAlbum(3, sourceId = 1)
|
|
||||||
controller.playAlbum(1, "al1", 0)
|
|
||||||
await { handle.operations.contains("play") }
|
|
||||||
handle.positionMs = 42_000L
|
|
||||||
val windows = handle.operations.count { it.startsWith("setWindow") }
|
|
||||||
|
|
||||||
controller.refreshMediaItems()
|
|
||||||
|
|
||||||
await { handle.operations.count { it.startsWith("setWindow") } > windows }
|
|
||||||
assertEquals("s1", handle.currentItem?.id)
|
|
||||||
assertEquals(42_000L, handle.positionMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun positionTickerAdvancesPositionWithoutEmittingPlaybackState() =
|
fun positionTickerAdvancesPositionWithoutEmittingPlaybackState() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
package com.subtracks.ui.library
|
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.subtracks.data.model.DownloadStatus
|
|
||||||
import com.subtracks.data.model.Song
|
|
||||||
import com.subtracks.data.model.SongDownload
|
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.annotation.GraphicsMode
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
||||||
class SongRowDownloadTest {
|
|
||||||
@get:Rule
|
|
||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
|
|
||||||
private val song =
|
|
||||||
Song(
|
|
||||||
sourceId = 1,
|
|
||||||
id = "s1",
|
|
||||||
albumId = "al1",
|
|
||||||
artistId = "ar1",
|
|
||||||
title = "Song",
|
|
||||||
album = "Album",
|
|
||||||
artist = "Artist",
|
|
||||||
duration = 100,
|
|
||||||
track = 1,
|
|
||||||
disc = 1,
|
|
||||||
starred = null,
|
|
||||||
genre = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aCompletedDownloadShowsTheDownloadedIndicator() {
|
|
||||||
render(download(DownloadStatus.Completed))
|
|
||||||
|
|
||||||
composeRule.onNodeWithContentDescription("Downloaded").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aRunningDownloadShowsProgressRatherThanTheDownloadedIndicator() {
|
|
||||||
render(download(DownloadStatus.Running))
|
|
||||||
|
|
||||||
composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist()
|
|
||||||
composeRule.onNodeWithContentDescription("Downloading").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun aSongWithoutADownloadShowsNoIndicator() {
|
|
||||||
render(null)
|
|
||||||
|
|
||||||
composeRule.onNodeWithText("Song").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithContentDescription("Downloaded").assertDoesNotExist()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun download(status: DownloadStatus) =
|
|
||||||
SongDownload(sourceId = 1, songId = "s1", status = status, engineId = 7, bytes = 40, total = 100)
|
|
||||||
|
|
||||||
private fun render(download: SongDownload?) {
|
|
||||||
composeRule.setContent {
|
|
||||||
SubtracksTheme {
|
|
||||||
SongRow(song = song, download = download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,11 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongListItem
|
import com.subtracks.data.model.SongListItem
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.FakePlayerConnection
|
import com.subtracks.playback.FakePlayerConnection
|
||||||
@@ -31,7 +29,6 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -55,8 +52,7 @@ class QueueViewModelTest {
|
|||||||
.build()
|
.build()
|
||||||
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
val sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
||||||
queues = QueueRepository(db)
|
queues = QueueRepository(db)
|
||||||
val downloads = DownloadRepository(db, sources, FakeDownloadEngine(), File(context.cacheDir, "downloads"))
|
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), dispatcher = dispatcher)
|
||||||
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, dispatcher = dispatcher)
|
|
||||||
viewModel = QueueViewModel(queues, controller)
|
viewModel = QueueViewModel(queues, controller)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user