diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61dcdbc..72082cd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a3bf4bf..6747fb3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -40,6 +41,32 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt index b9201f0..9f3999c 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt @@ -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] diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/download/BitrateOption.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/BitrateOption.kt new file mode 100644 index 0000000..7c699f3 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/BitrateOption.kt @@ -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), +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadRepository.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadRepository.kt new file mode 100644 index 0000000..ba08476 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadRepository.kt @@ -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> = downloadDao.observeAll() + + fun observeState(trackId: String): Flow = 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() + .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 + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadWorker.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadWorker.kt new file mode 100644 index 0000000..8259579 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadWorker.kt @@ -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" +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt new file mode 100644 index 0000000..197e519 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt @@ -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 +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadDao.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadDao.kt new file mode 100644 index 0000000..abcfd5b --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadDao.kt @@ -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> + + @Query("SELECT * FROM downloaded_tracks WHERE trackId = :trackId") + fun observe(trackId: String): Flow + + @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) +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadedTrackEntity.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadedTrackEntity.kt new file mode 100644 index 0000000..77fdf9f --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadedTrackEntity.kt @@ -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, +) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt index 8306581..0568104 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt @@ -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" diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/settings/DownloadPreferences.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/settings/DownloadPreferences.kt new file mode 100644 index 0000000..51a8288 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/settings/DownloadPreferences.kt @@ -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 = context.downloadPreferencesDataStore.data.map { it[wifiOnlyKey] ?: true } + + val defaultBitrate: Flow = 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 } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt new file mode 100644 index 0000000..426d1d5 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt @@ -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() +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/DownloadRepositoryEntryPoint.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/DownloadRepositoryEntryPoint.kt new file mode 100644 index 0000000..21c48cf --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/DownloadRepositoryEntryPoint.kt @@ -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 +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt index 61c4589..7987ca4 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt @@ -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) diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt index 6da8a4a..858a1bd 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt @@ -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() +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/BitratePickerSheet.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/BitratePickerSheet.kt new file mode 100644 index 0000000..008f128 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/BitratePickerSheet.kt @@ -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) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/DownloadStateBadge.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/DownloadStateBadge.kt new file mode 100644 index 0000000..dd56008 --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/DownloadStateBadge.kt @@ -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 = {}) } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt new file mode 100644 index 0000000..c65c28e --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt @@ -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 = {}) + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt new file mode 100644 index 0000000..cd003dc --- /dev/null +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt @@ -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 = emptyList()) + +@UnstableApi +@HiltViewModel +class DownloadsViewModel @Inject constructor( + private val downloadRepository: DownloadRepository, + private val playbackController: PlaybackController, +) : ViewModel() { + + val uiState: StateFlow = 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) } + } +} diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt index ae52047..9df7ef5 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt @@ -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 = { 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, 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 = {}, ) } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt index 8a23fcd..6b813b8 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt @@ -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 = 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().albumId private val _uiState = MutableStateFlow(AlbumDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + val defaultBitrate: StateFlow = downloadPreferences.defaultBitrate + .stateIn(viewModelScope, SharingStarted.Eagerly, BitrateOption.Normal) + init { load() } @@ -53,6 +73,37 @@ class AlbumDetailViewModel @Inject constructor( } } + fun observeDownloadState(trackId: String): Flow = 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) } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt index e79721f..a1506c1 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt @@ -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 { inclusive = true } } }, + onOpenDownloads = { navController.navigate(Route.Downloads) }, ) } + composable { + DownloadsScreen(onBack = { navController.popBackStack() }) + } composable { ArtistDetailScreen( onBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt index 5bbde3a..97c3fbb 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt @@ -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 } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt index 4fa385a..f67b1e3 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt @@ -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 = {}, + ) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsViewModel.kt index 316902e..ede3026 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsViewModel.kt @@ -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 = _uiState.asStateFlow() + + val wifiOnly: StateFlow = downloadPreferences.wifiOnly + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + val defaultBitrate: StateFlow = 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) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fbb6a9..9f82369 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,22 @@ Settings Log out + Downloads + Download over Wi-Fi only + Default download quality + + Downloads + Downloading + Download quality + Download album + Download + Downloading + Downloaded + Download failed, tap to retry + Remove download + + Downloads + No downloads yet Nothing playing Play diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index c2913d3..5ec079a 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -8,4 +8,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index df2fd3f..526dd8e 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -7,9 +7,11 @@ + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9776c0..28c26ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ coil = "3.3.0" media3 = "1.11.1" mockitoCore = "5.23.0" kotlinxCoroutinesTest = "1.11.0" +room = "2.8.5" +workManager = "2.11.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -44,6 +46,8 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hiltAndroid" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hiltAndroid" } androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } @@ -59,6 +63,12 @@ androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3- okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workManager" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }