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:
christopher
2026-09-17 14:28:42 -04:00
co-authored by Claude Sonnet 5
parent ad10f891b6
commit a89e81983e
29 changed files with 1155 additions and 19 deletions
+10
View File
@@ -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)
+27
View File
@@ -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)
}
@@ -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) }
}
}
+16
View File
@@ -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>
+3
View File
@@ -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>