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:
@@ -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,
|
||||
): 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")
|
||||
fun albumsForArtist(
|
||||
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)",
|
||||
)
|
||||
|
||||
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> =
|
||||
arrayOf(
|
||||
MIGRATION_1_2,
|
||||
@@ -268,5 +252,4 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_13_14,
|
||||
MIGRATION_14_15,
|
||||
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.ArtworkSeed
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DownloadStatusConverter
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.Playlist
|
||||
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.ShuffleOrder
|
||||
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.model.UpNextEntry
|
||||
@@ -35,12 +33,11 @@ import com.subtracks.data.model.UpNextEntry
|
||||
PlaybackCursor::class,
|
||||
ShuffleOrder::class,
|
||||
ArtworkSeed::class,
|
||||
SongDownload::class,
|
||||
],
|
||||
version = 17,
|
||||
version = 16,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
|
||||
@ColumnTypeConverters(QueueKindConverter::class)
|
||||
abstract class SubtracksDatabase : RoomDatabase() {
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
|
||||
@@ -49,6 +46,4 @@ abstract class SubtracksDatabase : RoomDatabase() {
|
||||
abstract fun queueDao(): QueueDao
|
||||
|
||||
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
|
||||
private var active: SubsonicMusicSource? = null
|
||||
|
||||
@Volatile
|
||||
private var activeSourceId: Long? = null
|
||||
|
||||
private val _quality = MutableStateFlow(StreamQuality())
|
||||
@@ -96,11 +94,6 @@ class SourceRepository(
|
||||
return uri + streamLengthSuffix(length)
|
||||
}
|
||||
|
||||
fun downloadUri(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
): String? = if (activeSourceId == sourceId) active?.downloadUri(songId)?.toString() else null
|
||||
|
||||
suspend fun addSource(
|
||||
name: String,
|
||||
address: String,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package com.subtracks.di
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import com.subtracks.data.db.createAndroidDatabase
|
||||
import com.subtracks.data.download.SystemDownloadEngine
|
||||
import com.subtracks.data.net.networkMode
|
||||
import com.subtracks.data.prefs.createUserPreferences
|
||||
import com.subtracks.data.repo.ArtworkSeedRepository
|
||||
import com.subtracks.data.repo.ArtworkSeedStore
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
@@ -30,7 +27,6 @@ import com.subtracks.ui.settings.SettingsViewModel
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import java.io.File
|
||||
|
||||
fun appModule(
|
||||
context: Context,
|
||||
@@ -48,24 +44,14 @@ fun appModule(
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get()) }
|
||||
single { QueueRepository(get()) }
|
||||
single { SystemDownloadEngine(context.applicationContext) }
|
||||
single {
|
||||
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) }
|
||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
||||
single { PlaybackController(get(), get(), get(), showMessage = toast) }
|
||||
viewModel { RootViewModel(get()) }
|
||||
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(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 -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
|
||||
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) }
|
||||
viewModel { QueueViewModel(get(), get()) }
|
||||
}
|
||||
|
||||
@@ -9,13 +9,11 @@ import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
|
||||
class Media3PlayerHandle(
|
||||
private val controller: MediaController,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val downloads: DownloadRepository,
|
||||
) : PlayerHandle {
|
||||
override val itemCount: Int get() = controller.mediaItemCount
|
||||
|
||||
@@ -129,13 +127,37 @@ class Media3PlayerHandle(
|
||||
}
|
||||
|
||||
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
|
||||
return queueMediaItem(
|
||||
item = item,
|
||||
local = downloads.localUri(item.id),
|
||||
stream = sourceRepository.streamUri(item.id, item.durationMs, quality),
|
||||
transcode = quality.transcodes,
|
||||
)
|
||||
val builder =
|
||||
MediaItem
|
||||
.Builder()
|
||||
.setMediaId(item.id)
|
||||
.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 =
|
||||
@@ -156,50 +178,11 @@ class Media3PlayerHandle(
|
||||
|
||||
private companion object {
|
||||
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 =
|
||||
when (error.errorCode) {
|
||||
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.QueueEntry
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.QueueSnapshot
|
||||
import com.subtracks.data.repo.QueueWindowItem
|
||||
@@ -107,7 +106,6 @@ class PlaybackController(
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val queueRepository: QueueRepository,
|
||||
private val connection: PlayerConnection,
|
||||
private val downloads: DownloadRepository,
|
||||
private val showMessage: (String) -> Unit = {},
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
|
||||
) {
|
||||
@@ -144,7 +142,7 @@ class PlaybackController(
|
||||
init {
|
||||
scope.launch {
|
||||
sourceRepository.activeSourceId().collect { sourceId ->
|
||||
if (queueSourceId != null && sourceId != queueSourceId) stop()
|
||||
if (queueSourceId != null && sourceId != null && sourceId != queueSourceId) stop()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
@@ -871,7 +869,6 @@ class PlaybackController(
|
||||
) {
|
||||
val player = player ?: return
|
||||
val snapshot = snapshot ?: return
|
||||
downloads.awaitLoaded()
|
||||
val window = queueRepository.window(snapshot, position, QUEUE_WINDOW_RADIUS)
|
||||
val startIndex = window.indexOfFirst { it.position == position }
|
||||
if (startIndex < 0) return
|
||||
@@ -890,27 +887,19 @@ class PlaybackController(
|
||||
|
||||
private suspend fun reloadQuality() =
|
||||
startLock.withLock {
|
||||
val player = player ?: return@withLock
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
if (snapshot.size == 0L) return@withLock
|
||||
val sourceId = queueSourceId
|
||||
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) {
|
||||
scope.launch {
|
||||
startLock.withLock {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
@@ -10,8 +9,6 @@ import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.ExperimentalApi
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
@@ -44,7 +41,9 @@ class PlaybackService : MediaSessionService() {
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(this)
|
||||
.setEnableClippingInMediaPeriod(true)
|
||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
||||
.setDataSourceFactory(
|
||||
KnownLengthDataSourceFactory(OkHttpDataSource.Factory(streamingClient())),
|
||||
),
|
||||
).build()
|
||||
val loader =
|
||||
CoverArtBitmapLoader(
|
||||
@@ -89,6 +88,10 @@ class PlaybackService : MediaSessionService() {
|
||||
response
|
||||
}.build()
|
||||
|
||||
private companion object {
|
||||
const val STREAM_TAG = "SubtracksPlayback"
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
artworkLoader?.shutdown()
|
||||
artworkLoader = null
|
||||
@@ -99,14 +102,4 @@ class PlaybackService : MediaSessionService() {
|
||||
session = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STREAM_TAG = "SubtracksPlayback"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun mediaDataSourceFactory(
|
||||
context: Context,
|
||||
upstream: DataSource.Factory,
|
||||
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream))
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
@@ -18,7 +17,6 @@ interface PlayerConnection {
|
||||
class MediaSessionConnection(
|
||||
private val context: Context,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val downloads: DownloadRepository,
|
||||
) : PlayerConnection {
|
||||
override fun connect(
|
||||
onConnected: (PlayerHandle) -> Unit,
|
||||
@@ -36,7 +34,7 @@ class MediaSessionConnection(
|
||||
future.addListener(
|
||||
{
|
||||
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() },
|
||||
)
|
||||
|
||||
@@ -14,9 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||
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.PlayArrow
|
||||
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.unit.dp
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
import kotlinx.coroutines.launch
|
||||
import com.subtracks.data.model.Album as AlbumModel
|
||||
@@ -64,7 +59,6 @@ sealed interface MenuTarget {
|
||||
data class Song(
|
||||
val song: SongModel,
|
||||
override val coverArt: CoverArtRef? = null,
|
||||
val download: SongDownload? = null,
|
||||
) : MenuTarget {
|
||||
override val title: String get() = song.title
|
||||
|
||||
@@ -121,9 +115,6 @@ class ItemActions(
|
||||
val shufflePlaylist: (PlaylistModel) -> Unit = {},
|
||||
val playNext: ((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 viewAlbum: ((String) -> Unit)? = null,
|
||||
val viewArtist: ((String) -> Unit)? = null,
|
||||
@@ -193,7 +184,6 @@ fun ItemContextMenu(
|
||||
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { play(target.song) } }
|
||||
}
|
||||
queueItems()
|
||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||
StarItem(target.song.starred) { starring -> actions.setStar(StarType.Song, target.song.id, starring) }
|
||||
target.song.albumId?.let { albumId ->
|
||||
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
|
||||
private fun StarItem(
|
||||
current: Long?,
|
||||
|
||||
@@ -25,7 +25,6 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
@@ -56,7 +55,6 @@ fun AlbumDetailRoute(
|
||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
@@ -67,9 +65,6 @@ fun AlbumDetailRoute(
|
||||
shuffleAlbum = { viewModel.shuffle() },
|
||||
playNext = { playbackController.playNext(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,
|
||||
viewArtist = onViewArtist,
|
||||
)
|
||||
@@ -79,7 +74,6 @@ fun AlbumDetailRoute(
|
||||
discs = discs,
|
||||
coverArt = viewModel::coverArt,
|
||||
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
|
||||
downloads = downloads,
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::play,
|
||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||
@@ -102,7 +96,6 @@ fun AlbumDetailScreen(
|
||||
discs: List<Disc> = emptyList(),
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artwork: ArtworkColors?,
|
||||
downloads: Map<String, SongDownload> = emptyMap(),
|
||||
onBack: () -> Unit,
|
||||
onSongClick: (Int) -> Unit,
|
||||
onSongLongClick: (MenuTarget) -> Unit = {},
|
||||
@@ -172,13 +165,10 @@ fun AlbumDetailScreen(
|
||||
song = song,
|
||||
isPlaying = song.id == playingSongId,
|
||||
trackNumber = song.track,
|
||||
download = downloads[song.id],
|
||||
modifier =
|
||||
rowModifier.combinedClickable(
|
||||
onClick = { onSongClick(index) },
|
||||
onLongClick = {
|
||||
onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true), downloads[song.id]))
|
||||
},
|
||||
onLongClick = { onSongLongClick(MenuTarget.Song(song, coverArt(album?.coverArt, true))) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,11 @@ import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Disc
|
||||
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.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -24,7 +21,6 @@ class AlbumDetailViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
private val downloadRepository: DownloadRepository,
|
||||
private val albumId: String,
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
@@ -35,28 +31,11 @@ class AlbumDetailViewModel(
|
||||
|
||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
): 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) {
|
||||
viewModelScope.launch {
|
||||
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.Playlist
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
@@ -44,7 +43,6 @@ fun PlaylistDetailRoute(
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val actions =
|
||||
@@ -53,9 +51,6 @@ fun PlaylistDetailRoute(
|
||||
shufflePlaylist = { viewModel.shuffle() },
|
||||
playNext = { playbackController.playNext(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,
|
||||
viewAlbum = onViewAlbum,
|
||||
viewArtist = onViewArtist,
|
||||
@@ -65,7 +60,6 @@ fun PlaylistDetailRoute(
|
||||
songs = viewModel.songs.collectAsLazyPagingItems(),
|
||||
coverArt = viewModel::coverArt,
|
||||
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
||||
downloads = downloads,
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::play,
|
||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||
@@ -82,7 +76,6 @@ fun PlaylistDetailScreen(
|
||||
songs: LazyPagingItems<SongListItem>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artwork: ArtworkColors?,
|
||||
downloads: Map<String, SongDownload> = emptyMap(),
|
||||
onBack: () -> Unit,
|
||||
onSongClick: (Int) -> Unit,
|
||||
onSongLongClick: (MenuTarget) -> Unit = {},
|
||||
@@ -121,13 +114,10 @@ fun PlaylistDetailScreen(
|
||||
coverArtId = item.coverArt,
|
||||
coverArt = coverArt,
|
||||
isPlaying = item.song.id == playingSongId,
|
||||
download = downloads[item.song.id],
|
||||
modifier =
|
||||
rowModifier.combinedClickable(
|
||||
onClick = { onSongClick(index) },
|
||||
onLongClick = {
|
||||
onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true), downloads[item.song.id]))
|
||||
},
|
||||
onLongClick = { onSongLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,12 @@ import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
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.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
@@ -26,7 +22,6 @@ class PlaylistDetailViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
private val downloadRepository: DownloadRepository,
|
||||
private val playlistId: String,
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
@@ -38,24 +33,6 @@ class PlaylistDetailViewModel(
|
||||
.flatMapLatest { libraryRepository.playlistSongs(it, playlistId) }
|
||||
.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(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
|
||||
@@ -7,9 +7,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.DownloadDone
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
@@ -20,15 +18,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.StarredBadge
|
||||
|
||||
@@ -39,7 +33,6 @@ fun SongRow(
|
||||
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
||||
isPlaying: Boolean = false,
|
||||
trackNumber: Long? = null,
|
||||
download: SongDownload? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -89,7 +82,6 @@ fun SongRow(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
StarredBadge(song.starred != null)
|
||||
DownloadBadge(download)
|
||||
}
|
||||
},
|
||||
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
|
||||
private fun TrackNumber(
|
||||
track: Long,
|
||||
|
||||
@@ -46,7 +46,6 @@ import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -67,7 +66,6 @@ import coil3.request.ImageRequest
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.source.StarType
|
||||
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.HeroGradient
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
const val NOW_PLAYING_COVER_TAG = "now-playing-cover"
|
||||
@@ -98,15 +95,11 @@ fun NowPlayingRoute(
|
||||
modifier: Modifier = Modifier,
|
||||
controller: PlaybackController = koinInject(),
|
||||
libraryRepository: LibraryRepository = koinInject(),
|
||||
downloadRepository: DownloadRepository = koinInject(),
|
||||
) {
|
||||
val state by controller.state.collectAsStateWithLifecycle()
|
||||
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
||||
val sourceId = state.context?.sourceId
|
||||
val songId = state.item?.id
|
||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||
val download = songId?.let { downloads[it] }
|
||||
val scope = rememberCoroutineScope()
|
||||
val song by
|
||||
produceState<Song?>(null, sourceId, songId) {
|
||||
if (sourceId != null && songId != null) {
|
||||
@@ -116,14 +109,6 @@ fun NowPlayingRoute(
|
||||
val context = LocalPlatformContext.current
|
||||
val actions =
|
||||
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,
|
||||
viewAlbum = onViewAlbum,
|
||||
viewArtist = onViewArtist,
|
||||
@@ -157,7 +142,7 @@ fun NowPlayingRoute(
|
||||
onPrevious = controller::previous,
|
||||
onShuffle = controller::toggleShuffle,
|
||||
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) } },
|
||||
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
|
||||
onSeek = controller::seekTo,
|
||||
|
||||
@@ -47,9 +47,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.SongListItem
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.QUEUE_CHUNK
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.QueueWindowItem
|
||||
@@ -269,10 +267,8 @@ fun QueueRoute(
|
||||
viewModel: QueueViewModel = koinViewModel(),
|
||||
controller: PlaybackController = koinInject(),
|
||||
sourceRepository: SourceRepository = koinInject(),
|
||||
downloadRepository: DownloadRepository = koinInject(),
|
||||
) {
|
||||
val playback by controller.state.collectAsStateWithLifecycle()
|
||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||
val queueContext = playback.context
|
||||
// The item's album keeps the header stable while the async name lookup runs, so it does not
|
||||
// flash the fallback when tapping between context and manually queued tracks.
|
||||
@@ -291,7 +287,6 @@ fun QueueRoute(
|
||||
currentSongId = playback.item?.id,
|
||||
shuffle = playback.shuffle,
|
||||
contextTitle = sourceTitle.value,
|
||||
downloads = downloads,
|
||||
onClearUpNext = controller::clearUpNext,
|
||||
coverArt = sourceRepository::coverArt,
|
||||
onBack = onBack,
|
||||
@@ -326,7 +321,6 @@ fun QueueScreen(
|
||||
generation: Int = 0,
|
||||
shuffle: Boolean = false,
|
||||
contextTitle: String? = null,
|
||||
downloads: Map<String, SongDownload> = emptyMap(),
|
||||
onClearUpNext: () -> Unit = {},
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
@@ -443,7 +437,6 @@ fun QueueScreen(
|
||||
isPlaying = row.song.song.id == currentSongId,
|
||||
floating = isDragging,
|
||||
coverArt = coverArt,
|
||||
download = downloads[row.song.song.id],
|
||||
dragHandle =
|
||||
if (!enabled) {
|
||||
null
|
||||
@@ -544,7 +537,6 @@ private fun QueueRowItem(
|
||||
isPlaying: Boolean,
|
||||
floating: Boolean,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
download: SongDownload?,
|
||||
dragHandle: Modifier?,
|
||||
onClick: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
@@ -553,7 +545,6 @@ private fun QueueRowItem(
|
||||
song = row.song.song,
|
||||
coverArtId = row.song.coverArt,
|
||||
coverArt = coverArt,
|
||||
download = download,
|
||||
isPlaying = isPlaying,
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -15,7 +14,6 @@ import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val downloadRepository: DownloadRepository,
|
||||
private val userPreferences: UserPreferences,
|
||||
) : ViewModel() {
|
||||
val sources: StateFlow<List<Source>> =
|
||||
@@ -39,10 +37,7 @@ class SettingsViewModel(
|
||||
}
|
||||
|
||||
fun deleteSource(id: Long) {
|
||||
viewModelScope.launch {
|
||||
downloadRepository.removeSource(id)
|
||||
sourceRepository.deleteSource(id)
|
||||
}
|
||||
viewModelScope.launch { sourceRepository.deleteSource(id) }
|
||||
}
|
||||
|
||||
fun setWifiQuality(quality: StreamQuality) {
|
||||
|
||||
Reference in New Issue
Block a user