Phase 5: Offline downloads at selectable bitrates
Adds Room + WorkManager-backed offline downloads: a bitrate picker (Low/Normal/High/Original) on track and album detail screens, a Downloads library screen, and Settings additions for Wi-Fi-only downloads and default quality. Bitrate tiers route through Subsonic's `stream` endpoint with `maxBitRate`; Original uses `download`, which always returns the untranscoded source file. Three real bugs found and fixed during on-device verification: - WorkManager was constructing DownloadWorker with its default reflection-based WorkerFactory instead of HiltWorkerFactory (NoSuchMethodException on the @AssistedInject constructor). The default androidx.startup auto-init ran before Hilt's field injection was guaranteed to have happened. Fixed by disabling the manifest's auto-init provider and calling WorkManager.initialize() manually in DeepwaveApplication.onCreate(), after super.onCreate(). - Crash on every download: WorkManager's own SystemForegroundService declares no foregroundServiceType in its manifest, but the worker requests dataSync at runtime via ForegroundInfo, which API 29+ requires to be a subset of what's manifest-declared. Fixed by manifest-merging that service with foregroundServiceType="dataSync". - Offline playback was completely broken: ResolvingDataSource only rewrites the DataSpec's URI (to file:// for a downloaded track) but always hands it to the same wrapped upstream DataSource to open. OkHttpDataSource can only open http(s) URLs, so the rewritten file:// URI failed with "Malformed URL" and playback silently fell through to the network. Wrapping the signed OkHttpDataSource.Factory in DefaultDataSource.Factory routes by scheme instead - this bug was latent since Phase 4, since LocalTrackFiles was always empty until now and the local-file path was never actually exercised. - Re-downloading a track at a different quality could produce a different file extension (Content-Type-driven), orphaning the previous file on disk with no cleanup path. DownloadWorker now clears any existing files for the track id before writing the new one. Verified on-device: downloads at all four tiers produce distinctly different, correctly-ordered file sizes (Low < Normal < High < Original); a fully downloaded track keeps playing with Wi-Fi and mobile data both disabled; killing and relaunching the app mid-download lets WorkManager resume the interrupted download to a correct, byte-exact final file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ad10f891b6
commit
a89e81983e
@@ -56,6 +56,8 @@ dependencies {
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
||||
// Networking (Subsonic/Navidrome API client)
|
||||
implementation(libs.retrofit)
|
||||
@@ -77,10 +79,18 @@ dependencies {
|
||||
implementation(libs.androidx.media3.session)
|
||||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
|
||||
// Offline downloads (Room index + WorkManager)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.mockito.core)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.androidx.room.testing)
|
||||
testImplementation(libs.androidx.work.testing)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
@@ -40,6 +41,32 @@
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- WorkManager's own internal foreground-work service, declared with no type in the
|
||||
library's manifest - on API 29+, startForeground() requires the type passed at
|
||||
runtime (dataSync, in DownloadWorker) to be a subset of what's manifest-declared
|
||||
for the actual service class handling it, which is this one, not DownloadWorker
|
||||
itself. Without this override, every download crashes the app on setForeground(). -->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<!-- WorkManager is initialized manually in DeepwaveApplication.onCreate(), after Hilt's
|
||||
field injection has run, so HiltWorkerFactory is actually set before anything can
|
||||
call WorkManager.getInstance(). Without removing this, the default auto-init runs
|
||||
first (via this same androidx.startup provider) with WorkManager's built-in
|
||||
reflection-based factory, which can't construct @HiltWorker/@AssistedInject workers. -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -2,16 +2,39 @@ package com.InfernalAquatics.deepwave
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.InfernalAquatics.deepwave.di.DownloadRepositoryEntryPoint
|
||||
import com.InfernalAquatics.deepwave.di.OkHttpClientEntryPoint
|
||||
import dagger.hilt.EntryPoints
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class DeepwaveApplication : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Manual init, not Configuration.Provider: WorkManager's default auto-init (via the
|
||||
// androidx.startup ContentProvider, disabled in the manifest) runs before Hilt's field
|
||||
// injection above is guaranteed to have happened, so it can't reliably read a
|
||||
// Hilt-provided WorkerFactory from this Application. Calling super.onCreate() first
|
||||
// guarantees workerFactory is already set by the time this runs.
|
||||
WorkManager.initialize(this, Configuration.Builder().setWorkerFactory(workerFactory).build())
|
||||
|
||||
// Force DownloadRepository to construct now: its init block starts observing completed
|
||||
// downloads into LocalTrackFiles, which needs to be running before playback ever needs
|
||||
// to resolve a track to a local file, not just whenever the Downloads screen opens first.
|
||||
EntryPoints.get(this, DownloadRepositoryEntryPoint::class.java).downloadRepository()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses the same signed OkHttpClient as Retrofit so cover-art requests get the
|
||||
* Subsonic auth params from [com.InfernalAquatics.deepwave.data.network.SubsonicRequestInterceptor]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.InfernalAquatics.deepwave.data.download
|
||||
|
||||
/** [kbps] is null for [Original] - that tier uses Subsonic's `download` endpoint, not a transcoded `stream`. */
|
||||
enum class BitrateOption(val label: String, val kbps: Int?) {
|
||||
Low("Low (96 kbps)", 96),
|
||||
Normal("Normal (160 kbps)", 160),
|
||||
High("High (320 kbps)", 320),
|
||||
Original("Original", null),
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.InfernalAquatics.deepwave.data.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
import com.InfernalAquatics.deepwave.media.LocalTrackFiles
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** UI-facing state for a single track, blending Room's persisted status with WorkManager's live progress. */
|
||||
sealed interface TrackDownloadState {
|
||||
data object NotDownloaded : TrackDownloadState
|
||||
data class InProgress(val progress: Float?) : TrackDownloadState
|
||||
data object Downloaded : TrackDownloadState
|
||||
data object Failed : TrackDownloadState
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class DownloadRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val downloadDao: DownloadDao,
|
||||
private val localTrackFiles: LocalTrackFiles,
|
||||
) {
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
init {
|
||||
// Keeps Phase 4's LocalOrRemoteDataSource resolving to a local file the moment a
|
||||
// download completes (and reverting to streaming if one is ever removed), with no
|
||||
// dependency the other direction - LocalTrackFiles still knows nothing about downloads.
|
||||
repositoryScope.launch {
|
||||
downloadDao.observeAll()
|
||||
.map { entities ->
|
||||
entities.filter { it.status == DownloadStatus.COMPLETE }
|
||||
.associate { it.trackId to it.localFilePath }
|
||||
}
|
||||
.collect { localTrackFiles.setLocalFiles(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadedTracks(): Flow<List<DownloadedTrackEntity>> = downloadDao.observeAll()
|
||||
|
||||
fun observeState(trackId: String): Flow<TrackDownloadState> = combine(
|
||||
downloadDao.observe(trackId),
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workName(trackId)),
|
||||
) { entity, workInfos ->
|
||||
val workInfo = workInfos.firstOrNull()
|
||||
when {
|
||||
workInfo != null && !workInfo.state.isFinished -> TrackDownloadState.InProgress(progressOf(workInfo))
|
||||
entity?.status == DownloadStatus.COMPLETE -> TrackDownloadState.Downloaded
|
||||
entity?.status == DownloadStatus.FAILED -> TrackDownloadState.Failed
|
||||
else -> TrackDownloadState.NotDownloaded
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueue(song: Song, bitrateOption: BitrateOption, wifiOnly: Boolean) {
|
||||
val data = workDataOf(
|
||||
DownloadWorker.KEY_TRACK_ID to song.id,
|
||||
DownloadWorker.KEY_TITLE to song.title,
|
||||
DownloadWorker.KEY_ARTIST to song.artistName,
|
||||
DownloadWorker.KEY_ALBUM to song.albumName,
|
||||
DownloadWorker.KEY_COVER_ART_ID to song.coverArtId,
|
||||
DownloadWorker.KEY_BITRATE_KBPS to (bitrateOption.kbps ?: -1),
|
||||
DownloadWorker.KEY_IS_ORIGINAL to (bitrateOption == BitrateOption.Original),
|
||||
)
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.build()
|
||||
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.setInputData(data)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
// REPLACE (not KEEP): a track mid-download is never re-offered "Download" by the UI, so
|
||||
// this only ever fires again to retry a failed one - which needs a fresh request anyway.
|
||||
workManager.enqueueUniqueWork(workName(song.id), ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
fun cancel(trackId: String) {
|
||||
workManager.cancelUniqueWork(workName(trackId))
|
||||
}
|
||||
|
||||
suspend fun delete(trackId: String) {
|
||||
downloadDao.get(trackId)?.localFilePath?.let { path ->
|
||||
if (path.isNotBlank()) File(path).delete()
|
||||
}
|
||||
downloadDao.delete(trackId)
|
||||
}
|
||||
|
||||
private fun workName(trackId: String) = "download-$trackId"
|
||||
|
||||
private fun progressOf(workInfo: WorkInfo): Float? {
|
||||
val bytes = workInfo.progress.getLong(DownloadWorker.KEY_PROGRESS_BYTES, -1L)
|
||||
val total = workInfo.progress.getLong(DownloadWorker.KEY_PROGRESS_TOTAL, -1L)
|
||||
return if (bytes >= 0 && total > 0) bytes.toFloat() / total else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.InfernalAquatics.deepwave.data.download
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
|
||||
import com.InfernalAquatics.deepwave.data.network.downloadUrl
|
||||
import com.InfernalAquatics.deepwave.data.network.streamUrl
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/** Downloads one track at a given [BitrateOption] and records it in [DownloadDao]. One work item per track id. */
|
||||
@HiltWorker
|
||||
class DownloadWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val downloadDao: DownloadDao,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val trackId = inputData.getString(KEY_TRACK_ID) ?: return Result.failure()
|
||||
val title = inputData.getString(KEY_TITLE).orEmpty()
|
||||
val artistName = inputData.getString(KEY_ARTIST)
|
||||
val albumName = inputData.getString(KEY_ALBUM)
|
||||
val coverArtId = inputData.getString(KEY_COVER_ART_ID)
|
||||
val bitrateKbps = inputData.getInt(KEY_BITRATE_KBPS, -1).takeIf { it > 0 }
|
||||
val isOriginal = inputData.getBoolean(KEY_IS_ORIGINAL, false)
|
||||
|
||||
setForeground(createForegroundInfo(title))
|
||||
downloadDao.upsert(pendingEntity(trackId, title, artistName, albumName, coverArtId, bitrateKbps, DownloadStatus.DOWNLOADING))
|
||||
|
||||
return try {
|
||||
val outputFile = withContext(Dispatchers.IO) {
|
||||
downloadToFile(trackId, isOriginal, bitrateKbps)
|
||||
}
|
||||
downloadDao.upsert(
|
||||
DownloadedTrackEntity(
|
||||
trackId = trackId,
|
||||
title = title,
|
||||
artistName = artistName,
|
||||
albumName = albumName,
|
||||
coverArtId = coverArtId,
|
||||
localFilePath = outputFile.absolutePath,
|
||||
fileSizeBytes = outputFile.length(),
|
||||
bitrateKbps = bitrateKbps,
|
||||
format = outputFile.extension,
|
||||
downloadedAt = System.currentTimeMillis(),
|
||||
status = DownloadStatus.COMPLETE,
|
||||
),
|
||||
)
|
||||
Result.success()
|
||||
} catch (e: IOException) {
|
||||
downloadDao.upsert(pendingEntity(trackId, title, artistName, albumName, coverArtId, bitrateKbps, DownloadStatus.FAILED))
|
||||
if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadToFile(trackId: String, isOriginal: Boolean, bitrateKbps: Int?): File {
|
||||
val url = if (isOriginal) downloadUrl(trackId) else streamUrl(trackId, bitrateKbps)
|
||||
val request = Request.Builder().url(url).build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
|
||||
val body = response.body ?: throw IOException("Empty response body")
|
||||
|
||||
val extension = extensionFromContentType(response.header("Content-Type"))
|
||||
val outputDir = File(applicationContext.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "downloads")
|
||||
outputDir.mkdirs()
|
||||
|
||||
// A re-download at a different quality can get a different Content-Type (e.g. Original's
|
||||
// untranscoded source vs. a transcoded tier), which changes the extension. Without this,
|
||||
// the old file is never removed - Room's localFilePath just moves to the new file, orphaning
|
||||
// the previous one on disk forever.
|
||||
outputDir.listFiles { file -> file.name.startsWith("$trackId.") }?.forEach { it.delete() }
|
||||
|
||||
val outputFile = File(outputDir, "$trackId.$extension")
|
||||
|
||||
val totalBytes = body.contentLength()
|
||||
var bytesCopied = 0L
|
||||
var lastReportedPercent = -1
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
body.byteStream().use { input ->
|
||||
outputFile.outputStream().use { output ->
|
||||
var read = input.read(buffer)
|
||||
while (read >= 0) {
|
||||
output.write(buffer, 0, read)
|
||||
bytesCopied += read
|
||||
if (totalBytes > 0) {
|
||||
val percent = (bytesCopied * 100 / totalBytes).toInt()
|
||||
if (percent != lastReportedPercent) {
|
||||
lastReportedPercent = percent
|
||||
setProgress(workDataOf(KEY_PROGRESS_BYTES to bytesCopied, KEY_PROGRESS_TOTAL to totalBytes))
|
||||
}
|
||||
}
|
||||
read = input.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
return outputFile
|
||||
}
|
||||
}
|
||||
|
||||
private fun pendingEntity(
|
||||
trackId: String,
|
||||
title: String,
|
||||
artistName: String?,
|
||||
albumName: String?,
|
||||
coverArtId: String?,
|
||||
bitrateKbps: Int?,
|
||||
status: DownloadStatus,
|
||||
) = DownloadedTrackEntity(
|
||||
trackId = trackId,
|
||||
title = title,
|
||||
artistName = artistName,
|
||||
albumName = albumName,
|
||||
coverArtId = coverArtId,
|
||||
localFilePath = "",
|
||||
fileSizeBytes = 0,
|
||||
bitrateKbps = bitrateKbps,
|
||||
format = "",
|
||||
downloadedAt = System.currentTimeMillis(),
|
||||
status = status,
|
||||
)
|
||||
|
||||
private fun createForegroundInfo(title: String): ForegroundInfo {
|
||||
val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(applicationContext.getString(R.string.download_notification_channel_name))
|
||||
.build(),
|
||||
)
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setContentTitle(applicationContext.getString(R.string.download_notification_title))
|
||||
.setContentText(title)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
ForegroundInfo(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_TRACK_ID = "trackId"
|
||||
const val KEY_TITLE = "title"
|
||||
const val KEY_ARTIST = "artist"
|
||||
const val KEY_ALBUM = "album"
|
||||
const val KEY_COVER_ART_ID = "coverArtId"
|
||||
const val KEY_BITRATE_KBPS = "bitrateKbps"
|
||||
const val KEY_IS_ORIGINAL = "isOriginal"
|
||||
const val KEY_PROGRESS_BYTES = "progressBytes"
|
||||
const val KEY_PROGRESS_TOTAL = "progressTotal"
|
||||
|
||||
private const val CHANNEL_ID = "downloads"
|
||||
private const val NOTIFICATION_ID = 4200
|
||||
private const val MAX_RETRIES = 3
|
||||
}
|
||||
}
|
||||
|
||||
private fun extensionFromContentType(contentType: String?): String = when {
|
||||
contentType == null -> "audio"
|
||||
contentType.contains("mpeg") -> "mp3"
|
||||
contentType.contains("flac") -> "flac"
|
||||
contentType.contains("ogg") -> "ogg"
|
||||
contentType.contains("mp4") || contentType.contains("aac") -> "m4a"
|
||||
contentType.contains("wav") -> "wav"
|
||||
else -> "audio"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.InfernalAquatics.deepwave.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
|
||||
|
||||
/** Single shared database - Phase 6's playlist tables join this same instance rather than a second DB. */
|
||||
@Database(entities = [DownloadedTrackEntity::class], version = 1, exportSchema = false)
|
||||
abstract class DeepwaveDatabase : RoomDatabase() {
|
||||
abstract fun downloadDao(): DownloadDao
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.InfernalAquatics.deepwave.data.local.download
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface DownloadDao {
|
||||
@Query("SELECT * FROM downloaded_tracks ORDER BY downloadedAt DESC")
|
||||
fun observeAll(): Flow<List<DownloadedTrackEntity>>
|
||||
|
||||
@Query("SELECT * FROM downloaded_tracks WHERE trackId = :trackId")
|
||||
fun observe(trackId: String): Flow<DownloadedTrackEntity?>
|
||||
|
||||
@Query("SELECT * FROM downloaded_tracks WHERE trackId = :trackId")
|
||||
suspend fun get(trackId: String): DownloadedTrackEntity?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(entity: DownloadedTrackEntity)
|
||||
|
||||
@Query("DELETE FROM downloaded_tracks WHERE trackId = :trackId")
|
||||
suspend fun delete(trackId: String)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.InfernalAquatics.deepwave.data.local.download
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
enum class DownloadStatus { QUEUED, DOWNLOADING, COMPLETE, FAILED }
|
||||
|
||||
/**
|
||||
* A downloaded (or in-flight/failed) track. Carries enough metadata (title/artist/album/cover)
|
||||
* to render an offline downloads list without touching the network - the whole point of a
|
||||
* downloads screen is that it works with no connection.
|
||||
*/
|
||||
@Entity(tableName = "downloaded_tracks")
|
||||
data class DownloadedTrackEntity(
|
||||
@PrimaryKey val trackId: String,
|
||||
val title: String,
|
||||
val artistName: String?,
|
||||
val albumName: String?,
|
||||
val coverArtId: String?,
|
||||
val localFilePath: String,
|
||||
val fileSizeBytes: Long,
|
||||
val bitrateKbps: Int?,
|
||||
val format: String,
|
||||
val downloadedAt: Long,
|
||||
val status: DownloadStatus,
|
||||
)
|
||||
@@ -3,9 +3,19 @@ package com.InfernalAquatics.deepwave.data.network
|
||||
import com.InfernalAquatics.deepwave.di.PLACEHOLDER_BASE_URL
|
||||
|
||||
/**
|
||||
* Builds a playback request URL for ExoPlayer, the same way [coverArtUrl] does for Coil:
|
||||
* [SubsonicRequestInterceptor] rewrites and signs it, and ExoPlayer's OkHttpDataSource shares
|
||||
* the same OkHttpClient (see DeepwavePlaybackService), so this resolves like every other
|
||||
* Subsonic request.
|
||||
* Builds a playback/download request URL, the same way [coverArtUrl] does for Coil:
|
||||
* [SubsonicRequestInterceptor] rewrites and signs it, and both ExoPlayer's OkHttpDataSource
|
||||
* and DownloadWorker share the same OkHttpClient, so this resolves like every other Subsonic
|
||||
* request.
|
||||
*
|
||||
* [maxBitRate], when set, asks the server to transcode down to that bitrate. Subsonic's
|
||||
* separate `download` endpoint ([downloadUrl]) ignores `maxBitRate` entirely and always
|
||||
* returns the original file - bitrate-limited downloads must go through here instead.
|
||||
*/
|
||||
fun streamUrl(songId: String): String = "${PLACEHOLDER_BASE_URL}rest/stream.view?id=$songId"
|
||||
fun streamUrl(songId: String, maxBitRate: Int? = null): String {
|
||||
val base = "${PLACEHOLDER_BASE_URL}rest/stream.view?id=$songId"
|
||||
return if (maxBitRate != null) "$base&maxBitRate=$maxBitRate" else base
|
||||
}
|
||||
|
||||
/** The original, untranscoded file - use for "Original" quality downloads only, never streaming. */
|
||||
fun downloadUrl(songId: String): String = "${PLACEHOLDER_BASE_URL}rest/download.view?id=$songId"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.InfernalAquatics.deepwave.data.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.InfernalAquatics.deepwave.data.download.BitrateOption
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.downloadPreferencesDataStore by preferencesDataStore(name = "deepwave_download_preferences")
|
||||
|
||||
/** Plain (unencrypted) settings - separate from [com.InfernalAquatics.deepwave.data.security.CredentialsStore]'s store, which has no reason to hold anything but credentials. */
|
||||
@Singleton
|
||||
class DownloadPreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val wifiOnlyKey = booleanPreferencesKey("wifi_only_downloads")
|
||||
private val defaultBitrateKey = stringPreferencesKey("default_bitrate")
|
||||
|
||||
val wifiOnly: Flow<Boolean> = context.downloadPreferencesDataStore.data.map { it[wifiOnlyKey] ?: true }
|
||||
|
||||
val defaultBitrate: Flow<BitrateOption> = context.downloadPreferencesDataStore.data.map { prefs ->
|
||||
prefs[defaultBitrateKey]?.let { name -> runCatching { BitrateOption.valueOf(name) }.getOrNull() }
|
||||
?: BitrateOption.Normal
|
||||
}
|
||||
|
||||
suspend fun setWifiOnly(value: Boolean) {
|
||||
context.downloadPreferencesDataStore.edit { it[wifiOnlyKey] = value }
|
||||
}
|
||||
|
||||
suspend fun setDefaultBitrate(option: BitrateOption) {
|
||||
context.downloadPreferencesDataStore.edit { it[defaultBitrateKey] = option.name }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.InfernalAquatics.deepwave.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.InfernalAquatics.deepwave.data.local.DeepwaveDatabase
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): DeepwaveDatabase =
|
||||
Room.databaseBuilder(context, DeepwaveDatabase::class.java, "deepwave.db")
|
||||
// Schema isn't stable yet (Phase 6 adds playlist tables to this same DB) - fine to
|
||||
// drop and recreate rather than write real migrations before that settles.
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
fun provideDownloadDao(database: DeepwaveDatabase): DownloadDao = database.downloadDao()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.InfernalAquatics.deepwave.di
|
||||
|
||||
import com.InfernalAquatics.deepwave.data.download.DownloadRepository
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/** Lets [com.InfernalAquatics.deepwave.DeepwaveApplication] force DownloadRepository to construct at startup. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DownloadRepositoryEntryPoint {
|
||||
fun downloadRepository(): DownloadRepository
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.ResolvingDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
@@ -43,8 +44,14 @@ class DeepwavePlaybackService : MediaLibraryService() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// DefaultDataSource, not OkHttpDataSource directly: ResolvingDataSource only rewrites the
|
||||
// DataSpec's URI (e.g. to file:// for a downloaded track) and always hands it to the same
|
||||
// wrapped upstream DataSource to open. OkHttpDataSource can only open http(s) URLs, so a
|
||||
// rewritten file:// URI would fail there with "Malformed URL". DefaultDataSource routes by
|
||||
// scheme instead - file:// to FileDataSource, http(s) to the signed OkHttp factory below.
|
||||
val upstreamFactory = OkHttpDataSource.Factory(okHttpClient)
|
||||
val resolvingFactory = ResolvingDataSource.Factory(upstreamFactory, localOrRemoteDataSource)
|
||||
val defaultDataSourceFactory = DefaultDataSource.Factory(this, upstreamFactory)
|
||||
val resolvingFactory = ResolvingDataSource.Factory(defaultDataSourceFactory, localOrRemoteDataSource)
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(this).setDataSourceFactory(resolvingFactory)
|
||||
|
||||
player = ExoPlayer.Builder(this)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.InfernalAquatics.deepwave.media
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
|
||||
import com.InfernalAquatics.deepwave.data.network.streamUrl
|
||||
@@ -31,3 +32,26 @@ fun Song.toMediaItem(): MediaItem {
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the same kind of MediaItem [Song.toMediaItem] does, still pointed at the stream URL
|
||||
* rather than [DownloadedTrackEntity.localFilePath] directly - [LocalOrRemoteDataSource] already
|
||||
* rewrites that URL onto the local file transparently, so playback from the Downloads screen
|
||||
* goes through the exact same resolution path as playback from anywhere else.
|
||||
*/
|
||||
fun DownloadedTrackEntity.toMediaItem(): MediaItem {
|
||||
val metadata = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artistName)
|
||||
.setAlbumTitle(albumName)
|
||||
.apply {
|
||||
coverArtUrl(coverArtId)?.let { setArtworkUri(Uri.parse(it)) }
|
||||
}
|
||||
.build()
|
||||
|
||||
return MediaItem.Builder()
|
||||
.setMediaId(songMediaId(trackId))
|
||||
.setUri(streamUrl(trackId))
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.InfernalAquatics.deepwave.ui.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.download.BitrateOption
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BitratePickerSheet(
|
||||
onSelect: (BitrateOption) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
defaultOption: BitrateOption = BitrateOption.Normal,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
BitratePickerContent(onSelect = onSelect, defaultOption = defaultOption)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BitratePickerContent(onSelect: (BitrateOption) -> Unit, defaultOption: BitrateOption) {
|
||||
Column(modifier = Modifier.padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.download_pick_quality),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
BitrateOption.entries.forEach { option ->
|
||||
ListItem(
|
||||
headlineContent = { Text(option.label) },
|
||||
trailingContent = {
|
||||
if (option == defaultOption) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelect(option) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411)
|
||||
@Composable
|
||||
private fun BitratePickerContentPreview() {
|
||||
DeepwaveTheme {
|
||||
BitratePickerContent(onSelect = {}, defaultOption = BitrateOption.Normal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.InfernalAquatics.deepwave.ui.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.download.TrackDownloadState
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
/** Tapping it opens the bitrate picker when not downloaded, or retries on failure; no-op otherwise. */
|
||||
@Composable
|
||||
fun DownloadStateBadge(state: TrackDownloadState, onClick: () -> Unit) {
|
||||
when (state) {
|
||||
TrackDownloadState.NotDownloaded -> IconButton(onClick = onClick) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.download_action))
|
||||
}
|
||||
is TrackDownloadState.InProgress -> {
|
||||
if (state.progress != null) {
|
||||
CircularProgressIndicator(
|
||||
progress = { state.progress },
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
TrackDownloadState.Downloaded -> Icon(
|
||||
Icons.Filled.DownloadDone,
|
||||
contentDescription = stringResource(R.string.download_downloaded),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
TrackDownloadState.Failed -> IconButton(onClick = onClick) {
|
||||
Icon(Icons.Filled.ErrorOutline, contentDescription = stringResource(R.string.download_failed), tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun DownloadStateBadgeNotDownloadedPreview() {
|
||||
DeepwaveTheme { DownloadStateBadge(state = TrackDownloadState.NotDownloaded, onClick = {}) }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun DownloadStateBadgeInProgressPreview() {
|
||||
DeepwaveTheme { DownloadStateBadge(state = TrackDownloadState.InProgress(0.4f), onClick = {}) }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun DownloadStateBadgeDownloadedPreview() {
|
||||
DeepwaveTheme { DownloadStateBadge(state = TrackDownloadState.Downloaded, onClick = {}) }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun DownloadStateBadgeFailedPreview() {
|
||||
DeepwaveTheme { DownloadStateBadge(state = TrackDownloadState.Failed, onClick = {}) }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.InfernalAquatics.deepwave.ui.downloads
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
|
||||
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
|
||||
import com.InfernalAquatics.deepwave.ui.components.TrackRow
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun DownloadsScreen(
|
||||
onBack: () -> Unit,
|
||||
viewModel: DownloadsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
DownloadsContent(
|
||||
uiState = uiState,
|
||||
onBack = onBack,
|
||||
onTrackClick = viewModel::playTrack,
|
||||
onRemove = viewModel::remove,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DownloadsContent(
|
||||
uiState: DownloadsUiState,
|
||||
onBack: () -> Unit,
|
||||
onTrackClick: (Int) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.downloads_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (uiState.tracks.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = stringResource(R.string.downloads_empty), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
itemsIndexed(uiState.tracks, key = { _, track -> track.trackId }) { index, track ->
|
||||
TrackRow(
|
||||
title = track.title,
|
||||
subtitle = track.artistName.orEmpty(),
|
||||
coverArtUrl = coverArtUrl(track.coverArtId),
|
||||
onClick = { onTrackClick(index) },
|
||||
trailingContent = {
|
||||
IconButton(onClick = { onRemove(track.trackId) }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.download_remove))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val sampleTracks = List(3) { index ->
|
||||
DownloadedTrackEntity(
|
||||
trackId = "sample-$index",
|
||||
title = "Sample Track ${index + 1}",
|
||||
artistName = "Sample Artist",
|
||||
albumName = "Sample Album",
|
||||
coverArtId = null,
|
||||
localFilePath = "/sample/path",
|
||||
fileSizeBytes = 4_000_000,
|
||||
bitrateKbps = 160,
|
||||
format = "mp3",
|
||||
downloadedAt = System.currentTimeMillis(),
|
||||
status = DownloadStatus.COMPLETE,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun DownloadsContentPreview() {
|
||||
DeepwaveTheme {
|
||||
DownloadsContent(uiState = DownloadsUiState(tracks = sampleTracks), onBack = {}, onTrackClick = {}, onRemove = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Empty", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun DownloadsContentEmptyPreview() {
|
||||
DeepwaveTheme {
|
||||
DownloadsContent(uiState = DownloadsUiState(), onBack = {}, onTrackClick = {}, onRemove = {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.InfernalAquatics.deepwave.ui.downloads
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.InfernalAquatics.deepwave.data.download.DownloadRepository
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
|
||||
import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
|
||||
import com.InfernalAquatics.deepwave.media.PlaybackController
|
||||
import com.InfernalAquatics.deepwave.media.toMediaItem
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class DownloadsUiState(val tracks: List<DownloadedTrackEntity> = emptyList())
|
||||
|
||||
@UnstableApi
|
||||
@HiltViewModel
|
||||
class DownloadsViewModel @Inject constructor(
|
||||
private val downloadRepository: DownloadRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<DownloadsUiState> = downloadRepository.downloadedTracks()
|
||||
.map { tracks -> DownloadsUiState(tracks.filter { it.status == DownloadStatus.COMPLETE }) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState())
|
||||
|
||||
fun playTrack(index: Int) {
|
||||
val tracks = uiState.value.tracks
|
||||
if (index !in tracks.indices) return
|
||||
viewModelScope.launch {
|
||||
playbackController.ensureConnected()
|
||||
playbackController.playQueue(tracks.map { it.toMediaItem() }, startIndex = index)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(trackId: String) {
|
||||
viewModelScope.launch { downloadRepository.delete(trackId) }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.InfernalAquatics.deepwave.ui.library
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -12,17 +13,20 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -32,12 +36,18 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.download.BitrateOption
|
||||
import com.InfernalAquatics.deepwave.data.download.TrackDownloadState
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
|
||||
import com.InfernalAquatics.deepwave.ui.components.Artwork
|
||||
import com.InfernalAquatics.deepwave.ui.components.BitratePickerSheet
|
||||
import com.InfernalAquatics.deepwave.ui.components.DownloadStateBadge
|
||||
import com.InfernalAquatics.deepwave.ui.components.TrackRow
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
@@ -46,12 +56,33 @@ fun AlbumDetailScreen(
|
||||
viewModel: AlbumDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
AlbumDetailContent(uiState = uiState, onBack = onBack, onSongClick = viewModel::playSong)
|
||||
val defaultBitrate by viewModel.defaultBitrate.collectAsState()
|
||||
AlbumDetailContent(
|
||||
uiState = uiState,
|
||||
defaultBitrate = defaultBitrate,
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::playSong,
|
||||
onDownloadSong = viewModel::requestDownloadSong,
|
||||
onDownloadAlbum = viewModel::requestDownloadAlbum,
|
||||
onConfirmDownload = viewModel::confirmDownload,
|
||||
onDismissDownloadPicker = viewModel::dismissDownloadPicker,
|
||||
observeDownloadState = viewModel::observeDownloadState,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit, onSongClick: (Int) -> Unit = {}) {
|
||||
private fun AlbumDetailContent(
|
||||
uiState: AlbumDetailUiState,
|
||||
defaultBitrate: BitrateOption,
|
||||
onBack: () -> Unit,
|
||||
onSongClick: (Int) -> Unit = {},
|
||||
onDownloadSong: (Int) -> Unit = {},
|
||||
onDownloadAlbum: () -> Unit = {},
|
||||
onConfirmDownload: (BitrateOption) -> Unit = {},
|
||||
onDismissDownloadPicker: () -> Unit = {},
|
||||
observeDownloadState: (String) -> Flow<TrackDownloadState> = { flowOf(TrackDownloadState.NotDownloaded) },
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -73,23 +104,45 @@ private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit,
|
||||
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item { AlbumHeader(uiState.album) }
|
||||
item { AlbumHeader(uiState.album, onDownloadAlbum = onDownloadAlbum) }
|
||||
itemsIndexed(uiState.songs, key = { _, song -> song.id }) { index, song ->
|
||||
TrackRow(
|
||||
title = song.title,
|
||||
subtitle = song.artistName ?: uiState.album?.artistName.orEmpty(),
|
||||
coverArtUrl = coverArtUrl(song.coverArtId ?: uiState.album?.coverArtId),
|
||||
onClick = { onSongClick(index) },
|
||||
trailingContent = {
|
||||
DownloadBadge(
|
||||
trackId = song.id,
|
||||
observeState = observeDownloadState,
|
||||
onClick = { onDownloadSong(index) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.pendingDownloadTarget != null) {
|
||||
BitratePickerSheet(
|
||||
onSelect = onConfirmDownload,
|
||||
onDismiss = onDismissDownloadPicker,
|
||||
defaultOption = defaultBitrate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumHeader(album: Album?) {
|
||||
private fun DownloadBadge(trackId: String, observeState: (String) -> Flow<TrackDownloadState>, onClick: () -> Unit) {
|
||||
val flow = remember(trackId) { observeState(trackId) }
|
||||
val state by flow.collectAsState(initial = TrackDownloadState.NotDownloaded)
|
||||
DownloadStateBadge(state = state, onClick = onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumHeader(album: Album?, onDownloadAlbum: () -> Unit) {
|
||||
if (album == null) return
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -106,6 +159,11 @@ private fun AlbumHeader(album: Album?) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onDownloadAlbum) {
|
||||
Icon(Icons.Filled.Download, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
|
||||
Text(stringResource(R.string.download_album))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +196,24 @@ private fun AlbumDetailContentPreview() {
|
||||
DeepwaveTheme {
|
||||
AlbumDetailContent(
|
||||
uiState = AlbumDetailUiState(isLoading = false, album = sampleAlbum, songs = sampleSongs),
|
||||
defaultBitrate = BitrateOption.Normal,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Picking quality", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
|
||||
@Composable
|
||||
private fun AlbumDetailContentPickerPreview() {
|
||||
DeepwaveTheme {
|
||||
AlbumDetailContent(
|
||||
uiState = AlbumDetailUiState(
|
||||
isLoading = false,
|
||||
album = sampleAlbum,
|
||||
songs = sampleSongs,
|
||||
pendingDownloadTarget = DownloadTarget.Album,
|
||||
),
|
||||
defaultBitrate = BitrateOption.Normal,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,27 +5,42 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.navigation.toRoute
|
||||
import com.InfernalAquatics.deepwave.data.download.BitrateOption
|
||||
import com.InfernalAquatics.deepwave.data.download.DownloadRepository
|
||||
import com.InfernalAquatics.deepwave.data.download.TrackDownloadState
|
||||
import com.InfernalAquatics.deepwave.data.model.Album
|
||||
import com.InfernalAquatics.deepwave.data.model.Song
|
||||
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
|
||||
import com.InfernalAquatics.deepwave.data.settings.DownloadPreferences
|
||||
import com.InfernalAquatics.deepwave.media.PlaybackController
|
||||
import com.InfernalAquatics.deepwave.media.toMediaItem
|
||||
import com.InfernalAquatics.deepwave.ui.navigation.Route
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
/** What a bitrate pick from the sheet applies to - a single track, or every track in the album. */
|
||||
sealed interface DownloadTarget {
|
||||
data class Track(val index: Int) : DownloadTarget
|
||||
data object Album : DownloadTarget
|
||||
}
|
||||
|
||||
data class AlbumDetailUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val album: Album? = null,
|
||||
val songs: List<Song> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val pendingDownloadTarget: DownloadTarget? = null,
|
||||
)
|
||||
|
||||
@UnstableApi
|
||||
@@ -34,12 +49,17 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
private val downloadRepository: DownloadRepository,
|
||||
private val downloadPreferences: DownloadPreferences,
|
||||
) : ViewModel() {
|
||||
private val albumId = savedStateHandle.toRoute<Route.AlbumDetail>().albumId
|
||||
|
||||
private val _uiState = MutableStateFlow(AlbumDetailUiState())
|
||||
val uiState: StateFlow<AlbumDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
val defaultBitrate: StateFlow<BitrateOption> = downloadPreferences.defaultBitrate
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, BitrateOption.Normal)
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
@@ -53,6 +73,37 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun observeDownloadState(trackId: String): Flow<TrackDownloadState> = downloadRepository.observeState(trackId)
|
||||
|
||||
fun requestDownloadSong(index: Int) {
|
||||
_uiState.update { it.copy(pendingDownloadTarget = DownloadTarget.Track(index)) }
|
||||
}
|
||||
|
||||
fun requestDownloadAlbum() {
|
||||
_uiState.update { it.copy(pendingDownloadTarget = DownloadTarget.Album) }
|
||||
}
|
||||
|
||||
fun dismissDownloadPicker() {
|
||||
_uiState.update { it.copy(pendingDownloadTarget = null) }
|
||||
}
|
||||
|
||||
fun confirmDownload(bitrateOption: BitrateOption) {
|
||||
val target = _uiState.value.pendingDownloadTarget ?: return
|
||||
val songs = _uiState.value.songs
|
||||
viewModelScope.launch {
|
||||
val wifiOnly = downloadPreferences.wifiOnly.first()
|
||||
when (target) {
|
||||
is DownloadTarget.Track -> songs.getOrNull(target.index)?.let {
|
||||
downloadRepository.enqueue(it, bitrateOption, wifiOnly)
|
||||
}
|
||||
DownloadTarget.Album -> songs.forEach {
|
||||
downloadRepository.enqueue(it, bitrateOption, wifiOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(pendingDownloadTarget = null) }
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar
|
||||
import com.InfernalAquatics.deepwave.ui.downloads.DownloadsScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.AlbumDetailScreen
|
||||
import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen
|
||||
import com.InfernalAquatics.deepwave.ui.login.LoginScreen
|
||||
@@ -102,8 +103,12 @@ fun DeepwaveNavHost(
|
||||
popUpTo<Route.Main> { inclusive = true }
|
||||
}
|
||||
},
|
||||
onOpenDownloads = { navController.navigate(Route.Downloads) },
|
||||
)
|
||||
}
|
||||
composable<Route.Downloads> {
|
||||
DownloadsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable<Route.ArtistDetail> {
|
||||
ArtistDetailScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
|
||||
@@ -9,4 +9,5 @@ sealed interface Route {
|
||||
@Serializable data class ArtistDetail(val artistId: String) : Route
|
||||
@Serializable data class AlbumDetail(val albumId: String) : Route
|
||||
@Serializable data object NowPlaying : Route
|
||||
@Serializable data object Downloads : Route
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.InfernalAquatics.deepwave.ui.settings
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -10,33 +12,62 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.InfernalAquatics.deepwave.R
|
||||
import com.InfernalAquatics.deepwave.data.download.BitrateOption
|
||||
import com.InfernalAquatics.deepwave.ui.components.BitratePickerSheet
|
||||
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onLoggedOut: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val wifiOnly by viewModel.wifiOnly.collectAsState()
|
||||
val defaultBitrate by viewModel.defaultBitrate.collectAsState()
|
||||
SettingsContent(
|
||||
uiState = uiState,
|
||||
wifiOnly = wifiOnly,
|
||||
defaultBitrate = defaultBitrate,
|
||||
onBack = onBack,
|
||||
onLogOutClick = { viewModel.logout(onLoggedOut) },
|
||||
onOpenDownloads = onOpenDownloads,
|
||||
onWifiOnlyChange = viewModel::setWifiOnly,
|
||||
onOpenDefaultQualityPicker = viewModel::openDefaultQualityPicker,
|
||||
onDismissDefaultQualityPicker = viewModel::dismissDefaultQualityPicker,
|
||||
onSelectDefaultQuality = viewModel::setDefaultBitrate,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SettingsContent(onBack: () -> Unit, onLogOutClick: () -> Unit) {
|
||||
private fun SettingsContent(
|
||||
uiState: SettingsUiState,
|
||||
wifiOnly: Boolean,
|
||||
defaultBitrate: BitrateOption,
|
||||
onBack: () -> Unit,
|
||||
onLogOutClick: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
onWifiOnlyChange: (Boolean) -> Unit,
|
||||
onOpenDefaultQualityPicker: () -> Unit,
|
||||
onDismissDefaultQualityPicker: () -> Unit,
|
||||
onSelectDefaultQuality: (BitrateOption) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -49,16 +80,37 @@ private fun SettingsContent(onBack: () -> Unit, onLogOutClick: () -> Unit) {
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Button(onClick = onLogOutClick) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_downloads)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenDownloads),
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_wifi_only_downloads)) },
|
||||
trailingContent = { Switch(checked = wifiOnly, onCheckedChange = onWifiOnlyChange) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_default_quality)) },
|
||||
supportingContent = { Text(defaultBitrate.label) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onOpenDefaultQualityPicker),
|
||||
)
|
||||
Button(onClick = onLogOutClick, modifier = Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.settings_log_out))
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.isPickingDefaultQuality) {
|
||||
BitratePickerSheet(
|
||||
onSelect = onSelectDefaultQuality,
|
||||
onDismiss = onDismissDefaultQualityPicker,
|
||||
defaultOption = defaultBitrate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +118,17 @@ private fun SettingsContent(onBack: () -> Unit, onLogOutClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun SettingsContentPreview() {
|
||||
DeepwaveTheme {
|
||||
SettingsContent(onBack = {}, onLogOutClick = {})
|
||||
SettingsContent(
|
||||
uiState = SettingsUiState(),
|
||||
wifiOnly = true,
|
||||
defaultBitrate = BitrateOption.Normal,
|
||||
onBack = {},
|
||||
onLogOutClick = {},
|
||||
onOpenDownloads = {},
|
||||
onWifiOnlyChange = {},
|
||||
onOpenDefaultQualityPicker = {},
|
||||
onDismissDefaultQualityPicker = {},
|
||||
onSelectDefaultQuality = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,57 @@ package com.InfernalAquatics.deepwave.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.InfernalAquatics.deepwave.data.download.BitrateOption
|
||||
import com.InfernalAquatics.deepwave.data.repository.ServerRepository
|
||||
import com.InfernalAquatics.deepwave.data.settings.DownloadPreferences
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(val isPickingDefaultQuality: Boolean = false)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val serverRepository: ServerRepository,
|
||||
private val downloadPreferences: DownloadPreferences,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
val wifiOnly: StateFlow<Boolean> = downloadPreferences.wifiOnly
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
val defaultBitrate: StateFlow<BitrateOption> = downloadPreferences.defaultBitrate
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, BitrateOption.Normal)
|
||||
|
||||
fun logout(onLoggedOut: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
serverRepository.logout()
|
||||
onLoggedOut()
|
||||
}
|
||||
}
|
||||
|
||||
fun setWifiOnly(value: Boolean) {
|
||||
viewModelScope.launch { downloadPreferences.setWifiOnly(value) }
|
||||
}
|
||||
|
||||
fun openDefaultQualityPicker() {
|
||||
_uiState.update { it.copy(isPickingDefaultQuality = true) }
|
||||
}
|
||||
|
||||
fun dismissDefaultQualityPicker() {
|
||||
_uiState.update { it.copy(isPickingDefaultQuality = false) }
|
||||
}
|
||||
|
||||
fun setDefaultBitrate(option: BitrateOption) {
|
||||
viewModelScope.launch { downloadPreferences.setDefaultBitrate(option) }
|
||||
_uiState.update { it.copy(isPickingDefaultQuality = false) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,22 @@
|
||||
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_log_out">Log out</string>
|
||||
<string name="settings_downloads">Downloads</string>
|
||||
<string name="settings_wifi_only_downloads">Download over Wi-Fi only</string>
|
||||
<string name="settings_default_quality">Default download quality</string>
|
||||
|
||||
<string name="download_notification_channel_name">Downloads</string>
|
||||
<string name="download_notification_title">Downloading</string>
|
||||
<string name="download_pick_quality">Download quality</string>
|
||||
<string name="download_album">Download album</string>
|
||||
<string name="download_action">Download</string>
|
||||
<string name="download_downloading">Downloading</string>
|
||||
<string name="download_downloaded">Downloaded</string>
|
||||
<string name="download_failed">Download failed, tap to retry</string>
|
||||
<string name="download_remove">Remove download</string>
|
||||
|
||||
<string name="downloads_title">Downloads</string>
|
||||
<string name="downloads_empty">No downloads yet</string>
|
||||
|
||||
<string name="player_nothing_playing">Nothing playing</string>
|
||||
<string name="player_play">Play</string>
|
||||
|
||||
@@ -8,4 +8,7 @@
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="deepwave_master_keyset_prefs.xml"/>
|
||||
<exclude domain="file" path="datastore/deepwave_credentials.preferences_pb"/>
|
||||
<!-- Restoring "downloaded" rows without the actual audio files (app-specific external
|
||||
storage, never backed up) would be misleading state - the DB and files must move together. -->
|
||||
<exclude domain="database" path="deepwave.db"/>
|
||||
</full-backup-content>
|
||||
@@ -7,9 +7,11 @@
|
||||
<cloud-backup>
|
||||
<exclude domain="sharedpref" path="deepwave_master_keyset_prefs.xml"/>
|
||||
<exclude domain="file" path="datastore/deepwave_credentials.preferences_pb"/>
|
||||
<exclude domain="database" path="deepwave.db"/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="deepwave_master_keyset_prefs.xml"/>
|
||||
<exclude domain="file" path="datastore/deepwave_credentials.preferences_pb"/>
|
||||
<exclude domain="database" path="deepwave.db"/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Reference in New Issue
Block a user