diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 63bc49b67..87ab1bea9 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,11 @@
+
+
+
+
+
@@ -101,6 +106,12 @@
android:authorities="${applicationId}.androidx-startup"
tools:node="remove">
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt b/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt
index 3ad54333e..3c6ddfe51 100644
--- a/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt
@@ -3,6 +3,7 @@ package com.simplecityapps.shuttle.di
import android.content.Context
import com.simplecityapps.localmediaprovider.local.data.room.DatabaseProvider
import com.simplecityapps.localmediaprovider.local.data.room.database.MediaDatabase
+import com.simplecityapps.localmediaprovider.local.data.room.repository.DownloadRepositoryImpl
import com.simplecityapps.localmediaprovider.local.repository.LocalAlbumArtistRepository
import com.simplecityapps.localmediaprovider.local.repository.LocalAlbumRepository
import com.simplecityapps.localmediaprovider.local.repository.LocalGenreRepository
@@ -11,6 +12,7 @@ import com.simplecityapps.localmediaprovider.local.repository.LocalSongRepositor
import com.simplecityapps.mediaprovider.MediaImporter
import com.simplecityapps.mediaprovider.repository.albums.AlbumRepository
import com.simplecityapps.mediaprovider.repository.artists.AlbumArtistRepository
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
import com.simplecityapps.mediaprovider.repository.genres.GenreRepository
import com.simplecityapps.mediaprovider.repository.playlists.PlaylistRepository
import com.simplecityapps.mediaprovider.repository.songs.SongRepository
@@ -76,4 +78,10 @@ class RepositoryModule {
songRepository: SongRepository,
@AppCoroutineScope appCoroutineScope: CoroutineScope
): GenreRepository = LocalGenreRepository(appCoroutineScope, songRepository)
+
+ @Provides
+ @Singleton
+ fun provideDownloadRepository(
+ database: MediaDatabase
+ ): DownloadRepository = DownloadRepositoryImpl(database.downloadDao())
}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadHelper.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadHelper.kt
new file mode 100644
index 000000000..9aa874d51
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadHelper.kt
@@ -0,0 +1,89 @@
+package com.simplecityapps.shuttle.ui.screens.downloads
+
+import android.content.Context
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.DownloadDone
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.simplecityapps.shuttle.model.DownloadState
+import com.simplecityapps.shuttle.model.Song
+
+/**
+ * Helper object for download-related UI functionality
+ */
+object DownloadHelper {
+
+ /**
+ * Get the appropriate icon for a song's download state
+ */
+ fun getDownloadIcon(downloadState: DownloadState): ImageVector {
+ return when (downloadState) {
+ DownloadState.COMPLETED -> Icons.Default.DownloadDone
+ else -> Icons.Default.Download
+ }
+ }
+
+ /**
+ * Get the download action text for a song's download state
+ */
+ fun getDownloadActionText(downloadState: DownloadState): String {
+ return when (downloadState) {
+ DownloadState.NONE -> "Download"
+ DownloadState.QUEUED -> "Queued for download"
+ DownloadState.DOWNLOADING -> "Downloading..."
+ DownloadState.PAUSED -> "Resume download"
+ DownloadState.COMPLETED -> "Downloaded"
+ DownloadState.FAILED -> "Retry download"
+ }
+ }
+
+ /**
+ * Check if a song can be downloaded (is from a remote provider)
+ */
+ fun canDownload(song: Song): Boolean {
+ return song.mediaProvider.remote
+ }
+
+ /**
+ * Format download size for display
+ */
+ fun formatDownloadSize(bytes: Long): String {
+ return when {
+ bytes == 0L -> "Unknown size"
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> String.format("%.1f KB", bytes / 1024.0)
+ bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
+ else -> String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
+ }
+ }
+
+ /**
+ * Get download status message
+ */
+ fun getDownloadStatusMessage(downloadState: DownloadState, progress: Float, downloadedBytes: Long, totalBytes: Long): String {
+ return when (downloadState) {
+ DownloadState.NONE -> "Not downloaded"
+ DownloadState.QUEUED -> "Waiting to download..."
+ DownloadState.DOWNLOADING -> {
+ val percentage = (progress * 100).toInt()
+ if (totalBytes > 0) {
+ "$percentage% (${formatDownloadSize(downloadedBytes)} / ${formatDownloadSize(totalBytes)})"
+ } else {
+ "$percentage%"
+ }
+ }
+ DownloadState.PAUSED -> {
+ val percentage = (progress * 100).toInt()
+ "Paused at $percentage%"
+ }
+ DownloadState.COMPLETED -> {
+ if (totalBytes > 0) {
+ "Downloaded (${formatDownloadSize(totalBytes)})"
+ } else {
+ "Downloaded"
+ }
+ }
+ DownloadState.FAILED -> "Download failed"
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsContract.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsContract.kt
new file mode 100644
index 000000000..b00018ac1
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsContract.kt
@@ -0,0 +1,31 @@
+package com.simplecityapps.shuttle.ui.screens.downloads
+
+import com.simplecityapps.localmediaprovider.local.data.room.entity.DownloadData
+import com.simplecityapps.shuttle.model.Song
+import com.simplecityapps.shuttle.ui.common.mvp.BaseContract
+
+interface DownloadsContract {
+
+ sealed class Event {
+ data class SongClicked(val song: Song) : Event()
+ data class RemoveDownload(val song: Song) : Event()
+ data class PauseDownload(val song: Song) : Event()
+ data class ResumeDownload(val song: Song) : Event()
+ object RemoveAllDownloads : Event()
+ object NavigateBack : Event()
+ }
+
+ interface View : BaseContract.View {
+ fun setData(downloads: List, songs: List)
+ fun showLoadError(error: Error)
+ fun setLoadingState(loading: Boolean)
+ }
+
+ interface Presenter : BaseContract.Presenter {
+ fun loadDownloads()
+ fun removeDownload(song: Song)
+ fun pauseDownload(song: Song)
+ fun resumeDownload(song: Song)
+ fun removeAllDownloads()
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsPresenter.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsPresenter.kt
new file mode 100644
index 000000000..fa4894a52
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsPresenter.kt
@@ -0,0 +1,93 @@
+package com.simplecityapps.shuttle.ui.screens.downloads
+
+import com.simplecityapps.localmediaprovider.local.data.room.entity.DownloadData
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
+import com.simplecityapps.mediaprovider.repository.songs.SongQuery
+import com.simplecityapps.mediaprovider.repository.songs.SongRepository
+import com.simplecityapps.playback.download.DownloadUseCase
+import com.simplecityapps.shuttle.model.Song
+import com.simplecityapps.shuttle.ui.common.mvp.BasePresenter
+import javax.inject.Inject
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+class DownloadsPresenter @Inject constructor(
+ private val downloadRepository: DownloadRepository,
+ private val songRepository: SongRepository,
+ private val downloadUseCase: DownloadUseCase
+) : BasePresenter(),
+ DownloadsContract.Presenter {
+
+ override fun bindView(view: DownloadsContract.View) {
+ super.bindView(view)
+ loadDownloads()
+ }
+
+ override fun loadDownloads() {
+ launch {
+ try {
+ view?.setLoadingState(true)
+
+ combine(
+ downloadRepository.observeAllDownloads(),
+ songRepository.getSongs(SongQuery.All())
+ ) { downloads, songs ->
+ Pair(downloads, songs)
+ }.collect { (downloads, songs) ->
+ view?.setData(downloads, songs ?: emptyList())
+ view?.setLoadingState(false)
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to load downloads")
+ view?.showLoadError(Error.LoadFailed(e))
+ view?.setLoadingState(false)
+ }
+ }
+ }
+
+ override fun removeDownload(song: Song) {
+ launch {
+ try {
+ downloadUseCase.removeDownload(song)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to remove download")
+ }
+ }
+ }
+
+ override fun pauseDownload(song: Song) {
+ launch {
+ try {
+ downloadUseCase.pauseDownload(song)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to pause download")
+ }
+ }
+ }
+
+ override fun resumeDownload(song: Song) {
+ launch {
+ try {
+ downloadUseCase.resumeDownload(song)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to resume download")
+ }
+ }
+ }
+
+ override fun removeAllDownloads() {
+ launch {
+ try {
+ downloadUseCase.removeAllDownloads()
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to remove all downloads")
+ }
+ }
+ }
+
+ sealed class Error {
+ data class LoadFailed(val error: Throwable) : Error()
+ }
+}
diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsScreen.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsScreen.kt
new file mode 100644
index 000000000..919d533e0
--- /dev/null
+++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/downloads/DownloadsScreen.kt
@@ -0,0 +1,227 @@
+package com.simplecityapps.shuttle.ui.screens.downloads
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.simplecityapps.localmediaprovider.local.data.room.entity.DownloadData
+import com.simplecityapps.shuttle.model.DownloadState
+import com.simplecityapps.shuttle.model.Song
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DownloadsScreen(
+ downloads: List,
+ songs: Map,
+ onNavigateBack: () -> Unit,
+ onRemoveDownload: (Song) -> Unit,
+ onPauseDownload: (Song) -> Unit,
+ onResumeDownload: (Song) -> Unit,
+ onRemoveAll: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Downloads") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ if (downloads.isNotEmpty()) {
+ IconButton(onClick = onRemoveAll) {
+ Icon(Icons.Default.Delete, contentDescription = "Remove All")
+ }
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ if (downloads.isEmpty()) {
+ EmptyState(modifier = Modifier.padding(paddingValues))
+ } else {
+ LazyColumn(
+ modifier = modifier.padding(paddingValues),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(
+ items = downloads,
+ key = { it.id }
+ ) { download ->
+ val song = songs[download.songId]
+ if (song != null) {
+ DownloadItem(
+ song = song,
+ download = download,
+ onRemove = { onRemoveDownload(song) },
+ onPause = { onPauseDownload(song) },
+ onResume = { onResumeDownload(song) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyState(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "No downloads",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "Downloaded songs will appear here",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun DownloadItem(
+ song: Song,
+ download: DownloadData,
+ onRemove: () -> Unit,
+ onPause: () -> Unit,
+ onResume: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = song.name ?: "Unknown",
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = song.friendlyArtistName ?: "Unknown Artist",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ when (download.downloadState) {
+ DownloadState.DOWNLOADING, DownloadState.QUEUED -> {
+ IconButton(onClick = onPause) {
+ Icon(Icons.Default.Pause, contentDescription = "Pause")
+ }
+ }
+ DownloadState.PAUSED -> {
+ IconButton(onClick = onResume) {
+ Icon(Icons.Default.PlayArrow, contentDescription = "Resume")
+ }
+ }
+ else -> Unit
+ }
+
+ IconButton(onClick = onRemove) {
+ Icon(Icons.Default.Delete, contentDescription = "Remove")
+ }
+ }
+ }
+
+ // Progress indicator
+ when (download.downloadState) {
+ DownloadState.DOWNLOADING, DownloadState.QUEUED -> {
+ Spacer(modifier = Modifier.height(8.dp))
+ LinearProgressIndicator(
+ progress = { download.downloadProgress },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(
+ text = when (download.downloadState) {
+ DownloadState.QUEUED -> "Queued"
+ DownloadState.DOWNLOADING -> "${(download.downloadProgress * 100).toInt()}%"
+ else -> ""
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ DownloadState.PAUSED -> {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Paused - ${(download.downloadProgress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ DownloadState.COMPLETED -> {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Downloaded • ${formatBytes(download.totalBytes)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ DownloadState.FAILED -> {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Failed: ${download.errorMessage ?: "Unknown error"}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ else -> Unit
+ }
+ }
+ }
+}
+
+private fun formatBytes(bytes: Long): String {
+ return when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+ bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
+ else -> "${bytes / (1024 * 1024 * 1024)} GB"
+ }
+}
diff --git a/android/data/src/main/kotlin/com/simplecityapps/shuttle/model/DownloadState.kt b/android/data/src/main/kotlin/com/simplecityapps/shuttle/model/DownloadState.kt
new file mode 100644
index 000000000..eed061c44
--- /dev/null
+++ b/android/data/src/main/kotlin/com/simplecityapps/shuttle/model/DownloadState.kt
@@ -0,0 +1,30 @@
+package com.simplecityapps.shuttle.model
+
+/**
+ * Represents the download state of a song for offline playback
+ */
+enum class DownloadState {
+ /** Song is not downloaded */
+ NONE,
+
+ /** Song is queued for download */
+ QUEUED,
+
+ /** Song is currently being downloaded */
+ DOWNLOADING,
+
+ /** Song download is paused */
+ PAUSED,
+
+ /** Song has been successfully downloaded and is available offline */
+ COMPLETED,
+
+ /** Song download failed */
+ FAILED;
+
+ val isDownloaded: Boolean
+ get() = this == COMPLETED
+
+ val isInProgress: Boolean
+ get() = this == DOWNLOADING || this == QUEUED || this == PAUSED
+}
diff --git a/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/downloads/DownloadRepository.kt b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/downloads/DownloadRepository.kt
new file mode 100644
index 000000000..e29d88901
--- /dev/null
+++ b/android/mediaprovider/core/src/main/java/com/simplecityapps/mediaprovider/repository/downloads/DownloadRepository.kt
@@ -0,0 +1,157 @@
+package com.simplecityapps.mediaprovider.repository.downloads
+
+import com.simplecityapps.localmediaprovider.local.data.room.entity.DownloadData
+import com.simplecityapps.shuttle.model.DownloadState
+import com.simplecityapps.shuttle.model.Song
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Repository for managing song downloads for offline playback
+ */
+interface DownloadRepository {
+
+ /**
+ * Get download information for a specific song
+ */
+ suspend fun getDownload(song: Song): DownloadData?
+
+ /**
+ * Observe download information for a specific song
+ */
+ fun observeDownload(song: Song): Flow
+
+ /**
+ * Get download information for multiple songs
+ */
+ suspend fun getDownloads(songs: List): List
+
+ /**
+ * Observe download information for multiple songs
+ */
+ fun observeDownloads(songs: List): Flow>
+
+ /**
+ * Get all downloads in a specific state
+ */
+ suspend fun getDownloadsByState(state: DownloadState): List
+
+ /**
+ * Observe all downloads in a specific state
+ */
+ fun observeDownloadsByState(state: DownloadState): Flow>
+
+ /**
+ * Get all downloads
+ */
+ suspend fun getAllDownloads(): List
+
+ /**
+ * Observe all downloads
+ */
+ fun observeAllDownloads(): Flow>
+
+ /**
+ * Get currently active downloads (queued or downloading)
+ */
+ suspend fun getActiveDownloads(): List
+
+ /**
+ * Observe currently active downloads
+ */
+ fun observeActiveDownloads(): Flow>
+
+ /**
+ * Queue a song for download
+ */
+ suspend fun queueDownload(song: Song)
+
+ /**
+ * Queue multiple songs for download
+ */
+ suspend fun queueDownloads(songs: List)
+
+ /**
+ * Update download progress
+ */
+ suspend fun updateDownloadProgress(
+ song: Song,
+ progress: Float,
+ downloadedBytes: Long,
+ totalBytes: Long
+ )
+
+ /**
+ * Mark download as completed
+ */
+ suspend fun markDownloadCompleted(song: Song, localPath: String, totalBytes: Long)
+
+ /**
+ * Mark download as failed
+ */
+ suspend fun markDownloadFailed(song: Song, errorMessage: String)
+
+ /**
+ * Pause a download
+ */
+ suspend fun pauseDownload(song: Song)
+
+ /**
+ * Resume a paused download
+ */
+ suspend fun resumeDownload(song: Song)
+
+ /**
+ * Cancel and remove a download
+ */
+ suspend fun cancelDownload(song: Song)
+
+ /**
+ * Cancel and remove multiple downloads
+ */
+ suspend fun cancelDownloads(songs: List)
+
+ /**
+ * Remove a completed download (delete the file and database entry)
+ */
+ suspend fun removeDownload(song: Song)
+
+ /**
+ * Remove multiple completed downloads
+ */
+ suspend fun removeDownloads(songs: List)
+
+ /**
+ * Remove all downloads in a specific state
+ */
+ suspend fun removeDownloadsByState(state: DownloadState)
+
+ /**
+ * Remove all downloads
+ */
+ suspend fun removeAllDownloads()
+
+ /**
+ * Get total size of all downloaded files
+ */
+ suspend fun getTotalDownloadedSize(): Long
+
+ /**
+ * Get count of downloads by state
+ */
+ suspend fun getDownloadCountByState(state: DownloadState): Int
+
+ /**
+ * Observe count of downloads by state
+ */
+ fun observeDownloadCountByState(state: DownloadState): Flow
+
+ /**
+ * Check if a song is downloaded
+ */
+ suspend fun isDownloaded(song: Song): Boolean
+
+ /**
+ * Get the local file path for a downloaded song, or null if not downloaded
+ */
+ suspend fun getLocalPath(song: Song): String?
+}
diff --git a/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/EmbyAuthenticationManager.kt b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/EmbyAuthenticationManager.kt
index 46b14cadb..a02a4514e 100644
--- a/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/EmbyAuthenticationManager.kt
+++ b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/EmbyAuthenticationManager.kt
@@ -85,4 +85,23 @@ class EmbyAuthenticationManager(
"&AudioCodec=aac" +
"&api_key=${authenticatedCredentials.accessToken}"
}
+
+ /**
+ * Builds a direct download URL for offline playback (no transcoding)
+ */
+ fun buildEmbyDownloadPath(
+ itemId: String,
+ authenticatedCredentials: AuthenticatedCredentials
+ ): String? {
+ if (credentialStore.address == null) {
+ Timber.w("Invalid emby address")
+ return null
+ }
+
+ return "${credentialStore.address}/emby" +
+ "/Audio/$itemId" +
+ "/stream" +
+ "?static=true" +
+ "&api_key=${authenticatedCredentials.accessToken}"
+ }
}
diff --git a/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/EmbyMediaInfoProvider.kt b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/EmbyMediaInfoProvider.kt
index 9bf155859..7690d8bb6 100644
--- a/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/EmbyMediaInfoProvider.kt
+++ b/android/mediaprovider/emby/src/main/java/com/simplecityapps/provider/emby/EmbyMediaInfoProvider.kt
@@ -4,15 +4,18 @@ import android.net.Uri
import androidx.core.net.toUri
import com.simplecityapps.mediaprovider.MediaInfo
import com.simplecityapps.mediaprovider.MediaInfoProvider
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
import com.simplecityapps.provider.emby.http.EmbyTranscodeService
import com.simplecityapps.shuttle.model.Song
+import java.io.File
import javax.inject.Inject
class EmbyMediaInfoProvider
@Inject
constructor(
private val embyAuthenticationManager: EmbyAuthenticationManager,
- private val embyTranscodeService: EmbyTranscodeService
+ private val embyTranscodeService: EmbyTranscodeService,
+ private val downloadRepository: DownloadRepository
) : MediaInfoProvider {
override fun handles(uri: Uri): Boolean = uri.scheme == "emby"
@@ -21,6 +24,17 @@ constructor(
song: Song,
castCompatibilityMode: Boolean
): MediaInfo {
+ // Check if the song is downloaded for offline playback
+ val localPath = downloadRepository.getLocalPath(song)
+ if (localPath != null && File(localPath).exists()) {
+ return MediaInfo(
+ path = File(localPath).toUri(),
+ mimeType = song.mimeType,
+ isRemote = false
+ )
+ }
+
+ // Fall back to streaming
val embyPath =
embyAuthenticationManager.getAuthenticatedCredentials()?.let { authenticatedCredentials ->
embyAuthenticationManager.buildEmbyPath(
diff --git a/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/JellyfinAuthenticationManager.kt b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/JellyfinAuthenticationManager.kt
index 2c3d86841..f0a4cbe7f 100644
--- a/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/JellyfinAuthenticationManager.kt
+++ b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/JellyfinAuthenticationManager.kt
@@ -84,4 +84,23 @@ class JellyfinAuthenticationManager(
"&AudioCodec=aac" +
"&api_key=${authenticatedCredentials.accessToken}"
}
+
+ /**
+ * Builds a direct download URL for offline playback (no transcoding)
+ */
+ fun buildJellyfinDownloadPath(
+ itemId: String,
+ authenticatedCredentials: AuthenticatedCredentials
+ ): String? {
+ if (credentialStore.address == null) {
+ Timber.w("Invalid jellyfin address (${credentialStore.address})")
+ return null
+ }
+
+ return "${credentialStore.address}" +
+ "/Audio/$itemId" +
+ "/stream" +
+ "?static=true" +
+ "&api_key=${authenticatedCredentials.accessToken}"
+ }
}
diff --git a/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/JellyfinMediaInfoProvider.kt b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/JellyfinMediaInfoProvider.kt
index 0d5822955..de4b1944b 100644
--- a/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/JellyfinMediaInfoProvider.kt
+++ b/android/mediaprovider/jellyfin/src/main/java/com/simplecityapps/provider/jellyfin/JellyfinMediaInfoProvider.kt
@@ -4,15 +4,18 @@ import android.net.Uri
import androidx.core.net.toUri
import com.simplecityapps.mediaprovider.MediaInfo
import com.simplecityapps.mediaprovider.MediaInfoProvider
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
import com.simplecityapps.provider.jellyfin.http.JellyfinTranscodeService
import com.simplecityapps.shuttle.model.Song
+import java.io.File
import javax.inject.Inject
class JellyfinMediaInfoProvider
@Inject
constructor(
private val jellyfinAuthenticationManager: JellyfinAuthenticationManager,
- private val jellyfinTranscodeService: JellyfinTranscodeService
+ private val jellyfinTranscodeService: JellyfinTranscodeService,
+ private val downloadRepository: DownloadRepository
) : MediaInfoProvider {
override fun handles(uri: Uri): Boolean = uri.scheme == "jellyfin"
@@ -21,6 +24,17 @@ constructor(
song: Song,
castCompatibilityMode: Boolean
): MediaInfo {
+ // Check if the song is downloaded for offline playback
+ val localPath = downloadRepository.getLocalPath(song)
+ if (localPath != null && File(localPath).exists()) {
+ return MediaInfo(
+ path = File(localPath).toUri(),
+ mimeType = song.mimeType,
+ isRemote = false
+ )
+ }
+
+ // Fall back to streaming
val jellyfinPath =
jellyfinAuthenticationManager.getAuthenticatedCredentials()?.let { authenticatedCredentials ->
jellyfinAuthenticationManager.buildJellyfinPath(
diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/Converters.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/Converters.kt
index 98337b7ba..5c23c1328 100644
--- a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/Converters.kt
+++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/Converters.kt
@@ -1,6 +1,7 @@
package com.simplecityapps.localmediaprovider.local.data.room
import androidx.room.TypeConverter
+import com.simplecityapps.shuttle.model.DownloadState
import com.simplecityapps.shuttle.model.MediaProviderType
import com.simplecityapps.shuttle.sorting.SongSortOrder
import java.util.Date
@@ -33,4 +34,14 @@ class Converters {
} catch (e: IllegalArgumentException) {
SongSortOrder.Default
}
+
+ @TypeConverter
+ fun fromDownloadState(downloadState: DownloadState): String = downloadState.name
+
+ @TypeConverter
+ fun toDownloadState(string: String): DownloadState = try {
+ DownloadState.valueOf(string)
+ } catch (e: IllegalArgumentException) {
+ DownloadState.NONE
+ }
}
diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/DownloadDao.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/DownloadDao.kt
new file mode 100644
index 000000000..e39ceed9c
--- /dev/null
+++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/DownloadDao.kt
@@ -0,0 +1,114 @@
+package com.simplecityapps.localmediaprovider.local.data.room.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import com.simplecityapps.localmediaprovider.local.data.room.entity.DownloadData
+import com.simplecityapps.shuttle.model.DownloadState
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Data Access Object for download operations
+ */
+@Dao
+interface DownloadDao {
+
+ @Query("SELECT * FROM downloads WHERE song_id = :songId LIMIT 1")
+ suspend fun getDownload(songId: Long): DownloadData?
+
+ @Query("SELECT * FROM downloads WHERE song_id = :songId LIMIT 1")
+ fun observeDownload(songId: Long): Flow
+
+ @Query("SELECT * FROM downloads WHERE song_id IN (:songIds)")
+ suspend fun getDownloads(songIds: List): List
+
+ @Query("SELECT * FROM downloads WHERE song_id IN (:songIds)")
+ fun observeDownloads(songIds: List): Flow>
+
+ @Query("SELECT * FROM downloads WHERE download_state = :state")
+ suspend fun getDownloadsByState(state: DownloadState): List
+
+ @Query("SELECT * FROM downloads WHERE download_state = :state")
+ fun observeDownloadsByState(state: DownloadState): Flow>
+
+ @Query("SELECT * FROM downloads")
+ suspend fun getAllDownloads(): List
+
+ @Query("SELECT * FROM downloads")
+ fun observeAllDownloads(): Flow>
+
+ @Query("SELECT * FROM downloads WHERE download_state = :state1 OR download_state = :state2")
+ suspend fun getActiveDownloads(
+ state1: DownloadState = DownloadState.DOWNLOADING,
+ state2: DownloadState = DownloadState.QUEUED
+ ): List
+
+ @Query("SELECT * FROM downloads WHERE download_state = :state1 OR download_state = :state2")
+ fun observeActiveDownloads(
+ state1: DownloadState = DownloadState.DOWNLOADING,
+ state2: DownloadState = DownloadState.QUEUED
+ ): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(download: DownloadData): Long
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(downloads: List)
+
+ @Update
+ suspend fun update(download: DownloadData)
+
+ @Update
+ suspend fun updateAll(downloads: List)
+
+ @Delete
+ suspend fun delete(download: DownloadData)
+
+ @Query("DELETE FROM downloads WHERE song_id = :songId")
+ suspend fun deleteBySongId(songId: Long)
+
+ @Query("DELETE FROM downloads WHERE song_id IN (:songIds)")
+ suspend fun deleteBySongIds(songIds: List)
+
+ @Query("DELETE FROM downloads WHERE download_state = :state")
+ suspend fun deleteByState(state: DownloadState)
+
+ @Query("DELETE FROM downloads")
+ suspend fun deleteAll()
+
+ @Query("UPDATE downloads SET download_state = :newState WHERE song_id = :songId")
+ suspend fun updateDownloadState(songId: Long, newState: DownloadState)
+
+ @Query("UPDATE downloads SET download_state = :newState WHERE song_id IN (:songIds)")
+ suspend fun updateDownloadStates(songIds: List, newState: DownloadState)
+
+ @Query(
+ """
+ UPDATE downloads
+ SET download_progress = :progress,
+ downloaded_bytes = :downloadedBytes,
+ total_bytes = :totalBytes,
+ updated_at = :updatedAt
+ WHERE song_id = :songId
+ """
+ )
+ suspend fun updateDownloadProgress(
+ songId: Long,
+ progress: Float,
+ downloadedBytes: Long,
+ totalBytes: Long,
+ updatedAt: java.util.Date
+ )
+
+ @Query("SELECT SUM(total_bytes) FROM downloads WHERE download_state = :state")
+ suspend fun getTotalDownloadedSize(state: DownloadState = DownloadState.COMPLETED): Long?
+
+ @Query("SELECT COUNT(*) FROM downloads WHERE download_state = :state")
+ suspend fun getDownloadCountByState(state: DownloadState): Int
+
+ @Query("SELECT COUNT(*) FROM downloads WHERE download_state = :state")
+ fun observeDownloadCountByState(state: DownloadState): Flow
+}
diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/database/MediaDatabase.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/database/MediaDatabase.kt
index a0175b35b..af45eb43d 100644
--- a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/database/MediaDatabase.kt
+++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/database/MediaDatabase.kt
@@ -4,9 +4,11 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.simplecityapps.localmediaprovider.local.data.room.Converters
+import com.simplecityapps.localmediaprovider.local.data.room.dao.DownloadDao
import com.simplecityapps.localmediaprovider.local.data.room.dao.PlaylistDataDao
import com.simplecityapps.localmediaprovider.local.data.room.dao.PlaylistSongJoinDao
import com.simplecityapps.localmediaprovider.local.data.room.dao.SongDataDao
+import com.simplecityapps.localmediaprovider.local.data.room.entity.DownloadData
import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistData
import com.simplecityapps.localmediaprovider.local.data.room.entity.PlaylistSongJoin
import com.simplecityapps.localmediaprovider.local.data.room.entity.SongData
@@ -15,9 +17,10 @@ import com.simplecityapps.localmediaprovider.local.data.room.entity.SongData
entities = [
SongData::class,
PlaylistData::class,
- PlaylistSongJoin::class
+ PlaylistSongJoin::class,
+ DownloadData::class
],
- version = 40,
+ version = 41,
exportSchema = true
)
@TypeConverters(Converters::class)
@@ -27,4 +30,6 @@ abstract class MediaDatabase : RoomDatabase() {
abstract fun playlistSongJoinDataDao(): PlaylistSongJoinDao
abstract fun playlistDataDao(): PlaylistDataDao
+
+ abstract fun downloadDao(): DownloadDao
}
diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/DownloadData.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/DownloadData.kt
new file mode 100644
index 000000000..b47820d61
--- /dev/null
+++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/entity/DownloadData.kt
@@ -0,0 +1,54 @@
+package com.simplecityapps.localmediaprovider.local.data.room.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import com.simplecityapps.shuttle.model.DownloadState
+import java.util.Date
+
+/**
+ * Room entity for tracking downloaded songs for offline playback
+ */
+@Entity(
+ tableName = "downloads",
+ indices = [
+ Index("song_id", unique = true),
+ Index("download_state")
+ ]
+)
+data class DownloadData(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "id")
+ val id: Long = 0,
+
+ @ColumnInfo(name = "song_id")
+ val songId: Long,
+
+ @ColumnInfo(name = "download_state")
+ val downloadState: DownloadState,
+
+ @ColumnInfo(name = "local_path")
+ val localPath: String? = null,
+
+ @ColumnInfo(name = "download_progress")
+ val downloadProgress: Float = 0f,
+
+ @ColumnInfo(name = "downloaded_bytes")
+ val downloadedBytes: Long = 0,
+
+ @ColumnInfo(name = "total_bytes")
+ val totalBytes: Long = 0,
+
+ @ColumnInfo(name = "downloaded_date")
+ val downloadedDate: Date? = null,
+
+ @ColumnInfo(name = "error_message")
+ val errorMessage: String? = null,
+
+ @ColumnInfo(name = "created_at")
+ val createdAt: Date = Date(),
+
+ @ColumnInfo(name = "updated_at")
+ val updatedAt: Date = Date()
+)
diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/repository/DownloadRepositoryImpl.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/repository/DownloadRepositoryImpl.kt
new file mode 100644
index 000000000..29c7942da
--- /dev/null
+++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/repository/DownloadRepositoryImpl.kt
@@ -0,0 +1,263 @@
+package com.simplecityapps.localmediaprovider.local.data.room.repository
+
+import com.simplecityapps.localmediaprovider.local.data.room.dao.DownloadDao
+import com.simplecityapps.localmediaprovider.local.data.room.entity.DownloadData
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
+import com.simplecityapps.shuttle.model.DownloadState
+import com.simplecityapps.shuttle.model.Song
+import java.io.File
+import java.util.Date
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Implementation of DownloadRepository using Room database
+ */
+@Singleton
+class DownloadRepositoryImpl @Inject constructor(
+ private val downloadDao: DownloadDao
+) : DownloadRepository {
+
+ override suspend fun getDownload(song: Song): DownloadData? {
+ return downloadDao.getDownload(song.id)
+ }
+
+ override fun observeDownload(song: Song): Flow {
+ return downloadDao.observeDownload(song.id)
+ }
+
+ override suspend fun getDownloads(songs: List): List {
+ return downloadDao.getDownloads(songs.map { it.id })
+ }
+
+ override fun observeDownloads(songs: List): Flow> {
+ return downloadDao.observeDownloads(songs.map { it.id })
+ }
+
+ override suspend fun getDownloadsByState(state: DownloadState): List {
+ return downloadDao.getDownloadsByState(state)
+ }
+
+ override fun observeDownloadsByState(state: DownloadState): Flow> {
+ return downloadDao.observeDownloadsByState(state)
+ }
+
+ override suspend fun getAllDownloads(): List {
+ return downloadDao.getAllDownloads()
+ }
+
+ override fun observeAllDownloads(): Flow> {
+ return downloadDao.observeAllDownloads()
+ }
+
+ override suspend fun getActiveDownloads(): List {
+ return downloadDao.getActiveDownloads()
+ }
+
+ override fun observeActiveDownloads(): Flow> {
+ return downloadDao.observeActiveDownloads()
+ }
+
+ override suspend fun queueDownload(song: Song) {
+ val existing = downloadDao.getDownload(song.id)
+ if (existing == null) {
+ val download = DownloadData(
+ songId = song.id,
+ downloadState = DownloadState.QUEUED,
+ createdAt = Date(),
+ updatedAt = Date()
+ )
+ downloadDao.insert(download)
+ } else if (existing.downloadState == DownloadState.FAILED || existing.downloadState == DownloadState.NONE) {
+ downloadDao.update(existing.copy(downloadState = DownloadState.QUEUED, updatedAt = Date()))
+ }
+ }
+
+ override suspend fun queueDownloads(songs: List) {
+ val songIds = songs.map { it.id }
+ val existing = downloadDao.getDownloads(songIds).associateBy { it.songId }
+
+ val toInsert = mutableListOf()
+ val toUpdate = mutableListOf()
+
+ songs.forEach { song ->
+ val existingDownload = existing[song.id]
+ if (existingDownload == null) {
+ toInsert.add(
+ DownloadData(
+ songId = song.id,
+ downloadState = DownloadState.QUEUED,
+ createdAt = Date(),
+ updatedAt = Date()
+ )
+ )
+ } else if (existingDownload.downloadState == DownloadState.FAILED ||
+ existingDownload.downloadState == DownloadState.NONE) {
+ toUpdate.add(
+ existingDownload.copy(downloadState = DownloadState.QUEUED, updatedAt = Date())
+ )
+ }
+ }
+
+ if (toInsert.isNotEmpty()) {
+ downloadDao.insertAll(toInsert)
+ }
+ if (toUpdate.isNotEmpty()) {
+ downloadDao.updateAll(toUpdate)
+ }
+ }
+
+ override suspend fun updateDownloadProgress(
+ song: Song,
+ progress: Float,
+ downloadedBytes: Long,
+ totalBytes: Long
+ ) {
+ val existing = downloadDao.getDownload(song.id)
+ if (existing != null) {
+ downloadDao.update(
+ existing.copy(
+ downloadState = DownloadState.DOWNLOADING,
+ downloadProgress = progress,
+ downloadedBytes = downloadedBytes,
+ totalBytes = totalBytes,
+ updatedAt = Date()
+ )
+ )
+ }
+ }
+
+ override suspend fun markDownloadCompleted(song: Song, localPath: String, totalBytes: Long) {
+ val existing = downloadDao.getDownload(song.id)
+ if (existing != null) {
+ downloadDao.update(
+ existing.copy(
+ downloadState = DownloadState.COMPLETED,
+ localPath = localPath,
+ downloadProgress = 1f,
+ downloadedBytes = totalBytes,
+ totalBytes = totalBytes,
+ downloadedDate = Date(),
+ updatedAt = Date(),
+ errorMessage = null
+ )
+ )
+ }
+ }
+
+ override suspend fun markDownloadFailed(song: Song, errorMessage: String) {
+ val existing = downloadDao.getDownload(song.id)
+ if (existing != null) {
+ downloadDao.update(
+ existing.copy(
+ downloadState = DownloadState.FAILED,
+ errorMessage = errorMessage,
+ updatedAt = Date()
+ )
+ )
+ }
+ }
+
+ override suspend fun pauseDownload(song: Song) {
+ downloadDao.updateDownloadState(song.id, DownloadState.PAUSED)
+ }
+
+ override suspend fun resumeDownload(song: Song) {
+ val existing = downloadDao.getDownload(song.id)
+ if (existing != null && existing.downloadState == DownloadState.PAUSED) {
+ downloadDao.update(existing.copy(downloadState = DownloadState.QUEUED, updatedAt = Date()))
+ }
+ }
+
+ override suspend fun cancelDownload(song: Song) {
+ downloadDao.deleteBySongId(song.id)
+ }
+
+ override suspend fun cancelDownloads(songs: List) {
+ downloadDao.deleteBySongIds(songs.map { it.id })
+ }
+
+ override suspend fun removeDownload(song: Song) {
+ val download = downloadDao.getDownload(song.id)
+ if (download != null) {
+ // Delete the file if it exists
+ download.localPath?.let { path ->
+ try {
+ File(path).delete()
+ } catch (e: Exception) {
+ // Log but don't fail
+ }
+ }
+ downloadDao.delete(download)
+ }
+ }
+
+ override suspend fun removeDownloads(songs: List) {
+ val downloads = downloadDao.getDownloads(songs.map { it.id })
+ downloads.forEach { download ->
+ download.localPath?.let { path ->
+ try {
+ File(path).delete()
+ } catch (e: Exception) {
+ // Log but don't fail
+ }
+ }
+ }
+ downloadDao.deleteBySongIds(songs.map { it.id })
+ }
+
+ override suspend fun removeDownloadsByState(state: DownloadState) {
+ val downloads = downloadDao.getDownloadsByState(state)
+ downloads.forEach { download ->
+ download.localPath?.let { path ->
+ try {
+ File(path).delete()
+ } catch (e: Exception) {
+ // Log but don't fail
+ }
+ }
+ }
+ downloadDao.deleteByState(state)
+ }
+
+ override suspend fun removeAllDownloads() {
+ val downloads = downloadDao.getAllDownloads()
+ downloads.forEach { download ->
+ download.localPath?.let { path ->
+ try {
+ File(path).delete()
+ } catch (e: Exception) {
+ // Log but don't fail
+ }
+ }
+ }
+ downloadDao.deleteAll()
+ }
+
+ override suspend fun getTotalDownloadedSize(): Long {
+ return downloadDao.getTotalDownloadedSize() ?: 0L
+ }
+
+ override suspend fun getDownloadCountByState(state: DownloadState): Int {
+ return downloadDao.getDownloadCountByState(state)
+ }
+
+ override fun observeDownloadCountByState(state: DownloadState): Flow {
+ return downloadDao.observeDownloadCountByState(state)
+ }
+
+ override suspend fun isDownloaded(song: Song): Boolean {
+ val download = downloadDao.getDownload(song.id)
+ return download?.downloadState == DownloadState.COMPLETED && download.localPath != null
+ }
+
+ override suspend fun getLocalPath(song: Song): String? {
+ val download = downloadDao.getDownload(song.id)
+ return if (download?.downloadState == DownloadState.COMPLETED) {
+ download.localPath
+ } else {
+ null
+ }
+ }
+}
diff --git a/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/PlexAuthenticationManager.kt b/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/PlexAuthenticationManager.kt
index 814a84751..a4f4b0324 100644
--- a/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/PlexAuthenticationManager.kt
+++ b/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/PlexAuthenticationManager.kt
@@ -72,4 +72,24 @@ class PlexAuthenticationManager(
"&X-Plex-Client-Identifier=s2-music-payer" +
"&X-Plex-Device=Android"
}
+
+ /**
+ * Builds a direct download URL for offline playback
+ * For Plex, the download URL is the same as the streaming URL since Plex doesn't transcode by default
+ */
+ fun buildPlexDownloadPath(
+ song: Song,
+ authenticatedCredentials: AuthenticatedCredentials
+ ): String? {
+ if (credentialStore.address == null) {
+ Timber.w("Invalid plex address (${credentialStore.address})")
+ return null
+ }
+
+ return "${credentialStore.address}${song.externalId}" +
+ "?X-Plex-Token=${authenticatedCredentials.accessToken}" +
+ "&X-Plex-Client-Identifier=s2-music-player" +
+ "&X-Plex-Device=Android" +
+ "&download=1"
+ }
}
diff --git a/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/PlexMediaInfoProvider.kt b/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/PlexMediaInfoProvider.kt
index 1596d05ca..59e676dd8 100644
--- a/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/PlexMediaInfoProvider.kt
+++ b/android/mediaprovider/plex/src/main/java/com/simplecityapps/provider/plex/PlexMediaInfoProvider.kt
@@ -4,13 +4,16 @@ import android.net.Uri
import androidx.core.net.toUri
import com.simplecityapps.mediaprovider.MediaInfo
import com.simplecityapps.mediaprovider.MediaInfoProvider
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
import com.simplecityapps.shuttle.model.Song
+import java.io.File
import javax.inject.Inject
class PlexMediaInfoProvider
@Inject
constructor(
- private val plexAuthenticationManager: PlexAuthenticationManager
+ private val plexAuthenticationManager: PlexAuthenticationManager,
+ private val downloadRepository: DownloadRepository
) : MediaInfoProvider {
override fun handles(uri: Uri): Boolean = uri.scheme == "plex"
@@ -19,6 +22,17 @@ constructor(
song: Song,
castCompatibilityMode: Boolean
): MediaInfo {
+ // Check if the song is downloaded for offline playback
+ val localPath = downloadRepository.getLocalPath(song)
+ if (localPath != null && File(localPath).exists()) {
+ return MediaInfo(
+ path = File(localPath).toUri(),
+ mimeType = song.mimeType,
+ isRemote = false
+ )
+ }
+
+ // Fall back to streaming
val plexPath =
plexAuthenticationManager.getAuthenticatedCredentials()?.let { authenticatedCredentials ->
plexAuthenticationManager.buildPlexPath(
diff --git a/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadManager.kt b/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadManager.kt
new file mode 100644
index 000000000..e7c0e58d1
--- /dev/null
+++ b/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadManager.kt
@@ -0,0 +1,327 @@
+package com.simplecityapps.playback.download
+
+import android.content.Context
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
+import com.simplecityapps.mediaprovider.repository.songs.SongRepository
+import com.simplecityapps.provider.emby.EmbyAuthenticationManager
+import com.simplecityapps.provider.jellyfin.JellyfinAuthenticationManager
+import com.simplecityapps.provider.plex.PlexAuthenticationManager
+import com.simplecityapps.shuttle.model.DownloadState
+import com.simplecityapps.shuttle.model.MediaProviderType
+import com.simplecityapps.shuttle.model.Song
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.io.FileOutputStream
+import java.util.Date
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import timber.log.Timber
+
+/**
+ * Manages downloading of songs for offline playback
+ */
+@Singleton
+class DownloadManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val downloadRepository: DownloadRepository,
+ private val songRepository: SongRepository,
+ private val jellyfinAuthManager: JellyfinAuthenticationManager,
+ private val embyAuthManager: EmbyAuthenticationManager,
+ private val plexAuthManager: PlexAuthenticationManager,
+ private val okHttpClient: OkHttpClient
+) {
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+ private val downloadJobs = mutableMapOf()
+ private val mutex = Mutex()
+
+ private val _downloadingCount = MutableStateFlow(0)
+ val downloadingCount: StateFlow = _downloadingCount
+
+ /**
+ * Queue a song for download
+ */
+ suspend fun queueDownload(song: Song) {
+ if (!song.mediaProvider.remote) {
+ Timber.w("Cannot download local song: ${song.name}")
+ return
+ }
+
+ downloadRepository.queueDownload(song)
+ processNextDownload()
+ }
+
+ /**
+ * Queue multiple songs for download
+ */
+ suspend fun queueDownloads(songs: List) {
+ val remoteSongs = songs.filter { it.mediaProvider.remote }
+ if (remoteSongs.isEmpty()) {
+ Timber.w("No remote songs to download")
+ return
+ }
+
+ downloadRepository.queueDownloads(remoteSongs)
+ processNextDownload()
+ }
+
+ /**
+ * Cancel a download
+ */
+ suspend fun cancelDownload(song: Song) {
+ mutex.withLock {
+ downloadJobs[song.id]?.cancel()
+ downloadJobs.remove(song.id)
+ }
+ downloadRepository.cancelDownload(song)
+ updateDownloadingCount()
+ processNextDownload()
+ }
+
+ /**
+ * Cancel multiple downloads
+ */
+ suspend fun cancelDownloads(songs: List) {
+ mutex.withLock {
+ songs.forEach { song ->
+ downloadJobs[song.id]?.cancel()
+ downloadJobs.remove(song.id)
+ }
+ }
+ downloadRepository.cancelDownloads(songs)
+ updateDownloadingCount()
+ processNextDownload()
+ }
+
+ /**
+ * Pause a download
+ */
+ suspend fun pauseDownload(song: Song) {
+ mutex.withLock {
+ downloadJobs[song.id]?.cancel()
+ downloadJobs.remove(song.id)
+ }
+ downloadRepository.pauseDownload(song)
+ updateDownloadingCount()
+ processNextDownload()
+ }
+
+ /**
+ * Resume a paused download
+ */
+ suspend fun resumeDownload(song: Song) {
+ downloadRepository.resumeDownload(song)
+ processNextDownload()
+ }
+
+ /**
+ * Remove a downloaded song (delete file and database entry)
+ */
+ suspend fun removeDownload(song: Song) {
+ downloadRepository.removeDownload(song)
+ }
+
+ /**
+ * Remove multiple downloaded songs
+ */
+ suspend fun removeDownloads(songs: List) {
+ downloadRepository.removeDownloads(songs)
+ }
+
+ /**
+ * Process the next queued download
+ */
+ private suspend fun processNextDownload() {
+ mutex.withLock {
+ // Limit concurrent downloads to 3
+ if (downloadJobs.size >= 3) {
+ return
+ }
+
+ val queuedDownloads = downloadRepository.getDownloadsByState(DownloadState.QUEUED)
+ if (queuedDownloads.isEmpty()) {
+ return
+ }
+
+ val nextDownload = queuedDownloads.firstOrNull() ?: return
+
+ val job = scope.launch {
+ performDownload(nextDownload.songId)
+ }
+
+ downloadJobs[nextDownload.songId] = job
+ updateDownloadingCount()
+
+ job.invokeOnCompletion {
+ scope.launch {
+ mutex.withLock {
+ downloadJobs.remove(nextDownload.songId)
+ updateDownloadingCount()
+ }
+ processNextDownload()
+ }
+ }
+ }
+ }
+
+ /**
+ * Perform the actual download of a song
+ */
+ private suspend fun performDownload(songId: Long) {
+ try {
+ // Get the full song from the repository
+ val songs = songRepository.getSongs(com.simplecityapps.mediaprovider.repository.songs.SongQuery.All()).firstOrNull()
+ val song = songs?.find { it.id == songId }
+
+ if (song == null) {
+ Timber.e("Song not found for download: $songId")
+ return
+ }
+
+ Timber.d("Download started for song: ${song.name}")
+
+ // Get download URL
+ val downloadUrl = getDownloadUrl(song)
+ if (downloadUrl == null) {
+ Timber.e("Failed to get download URL for song: ${song.name}")
+ downloadRepository.markDownloadFailed(song, "Failed to generate download URL")
+ return
+ }
+
+ // Get destination file
+ val destinationFile = getDownloadPath(song)
+
+ // Create parent directories
+ destinationFile.parentFile?.mkdirs()
+
+ // Download the file
+ withContext(Dispatchers.IO) {
+ downloadFile(downloadUrl, destinationFile) { progress, downloadedBytes, totalBytes ->
+ scope.launch {
+ downloadRepository.updateDownloadProgress(
+ song,
+ progress,
+ downloadedBytes,
+ totalBytes
+ )
+ }
+ }
+ }
+
+ // Mark as completed
+ downloadRepository.markDownloadCompleted(
+ song,
+ destinationFile.absolutePath,
+ destinationFile.length()
+ )
+
+ Timber.d("Download completed for song: ${song.name}")
+
+ } catch (e: Exception) {
+ Timber.e(e, "Download failed for song ID: $songId")
+
+ // Try to get the song to mark it as failed
+ val songs = songRepository.getSongs(com.simplecityapps.mediaprovider.repository.songs.SongQuery.All()).firstOrNull()
+ val song = songs?.find { it.id == songId }
+
+ if (song != null) {
+ downloadRepository.markDownloadFailed(song, e.message ?: "Unknown error")
+ }
+ }
+ }
+
+ /**
+ * Get download URL for a song based on provider
+ */
+ private fun getDownloadUrl(song: Song): String? {
+ return when (song.mediaProvider) {
+ MediaProviderType.Jellyfin -> {
+ val credentials = jellyfinAuthManager.getAuthenticatedCredentials() ?: return null
+ val itemId = song.externalId ?: return null
+ jellyfinAuthManager.buildJellyfinDownloadPath(itemId, credentials)
+ }
+ MediaProviderType.Emby -> {
+ val credentials = embyAuthManager.getAuthenticatedCredentials() ?: return null
+ val itemId = song.externalId ?: return null
+ embyAuthManager.buildEmbyDownloadPath(itemId, credentials)
+ }
+ MediaProviderType.Plex -> {
+ val credentials = plexAuthManager.getAuthenticatedCredentials() ?: return null
+ plexAuthManager.buildPlexDownloadPath(song, credentials)
+ }
+ else -> null
+ }
+ }
+
+ /**
+ * Get the local file path for a download
+ */
+ private fun getDownloadPath(song: Song): File {
+ val downloadsDir = File(context.filesDir, "downloads/${song.mediaProvider.name.lowercase()}")
+ downloadsDir.mkdirs()
+
+ val extension = song.mimeType.substringAfter("/").let {
+ when (it) {
+ "mpeg" -> "mp3"
+ "mp4" -> "m4a"
+ else -> it
+ }
+ }
+
+ return File(downloadsDir, "${song.externalId}.$extension")
+ }
+
+ /**
+ * Download a file from URL to local storage
+ */
+ private suspend fun downloadFile(url: String, destination: File, onProgress: (Float, Long, Long) -> Unit) {
+ val request = Request.Builder()
+ .url(url)
+ .build()
+
+ okHttpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw Exception("Download failed: ${response.code}")
+ }
+
+ val body = response.body ?: throw Exception("Empty response body")
+ val contentLength = body.contentLength()
+
+ body.byteStream().use { input ->
+ FileOutputStream(destination).use { output ->
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ var totalBytesRead = 0L
+
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ output.write(buffer, 0, bytesRead)
+ totalBytesRead += bytesRead
+
+ val progress = if (contentLength > 0) {
+ totalBytesRead.toFloat() / contentLength.toFloat()
+ } else {
+ 0f
+ }
+
+ onProgress(progress, totalBytesRead, contentLength)
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun updateDownloadingCount() {
+ _downloadingCount.value = downloadJobs.size
+ }
+}
diff --git a/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadService.kt b/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadService.kt
new file mode 100644
index 000000000..26661fa58
--- /dev/null
+++ b/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadService.kt
@@ -0,0 +1,273 @@
+package com.simplecityapps.playback.download
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.content.getSystemService
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
+import com.simplecityapps.mediaprovider.repository.songs.SongRepository
+import com.simplecityapps.playback.R
+import com.simplecityapps.shuttle.model.DownloadState
+import com.simplecityapps.shuttle.model.Song
+import com.simplecityapps.shuttle.pendingintent.PendingIntentCompat
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+/**
+ * Foreground service that manages background downloads of songs for offline playback
+ */
+@AndroidEntryPoint
+class DownloadService : Service() {
+
+ @Inject
+ lateinit var downloadManager: DownloadManager
+
+ @Inject
+ lateinit var downloadRepository: DownloadRepository
+
+ @Inject
+ lateinit var songRepository: SongRepository
+
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private val notificationManager: NotificationManager? by lazy { getSystemService() }
+ private val connectivityManager: ConnectivityManager? by lazy { getSystemService() }
+
+ private var downloadJob: Job? = null
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ Timber.d("DownloadService created")
+
+ createNotificationChannel()
+ startForeground(NOTIFICATION_ID, buildNotification())
+
+ // Monitor network changes
+ setupNetworkMonitoring()
+
+ // Start monitoring downloads
+ startDownloadMonitoring()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START_DOWNLOAD -> {
+ val songIds = intent.getLongArrayExtra(EXTRA_SONG_IDS)
+ if (songIds != null) {
+ serviceScope.launch {
+ val songs = songIds.mapNotNull { id ->
+ // Get songs from repository
+ // TODO: This needs proper implementation
+ null
+ }
+ if (songs.isNotEmpty()) {
+ downloadManager.queueDownloads(songs)
+ }
+ }
+ }
+ }
+ ACTION_PAUSE_DOWNLOAD -> {
+ val songId = intent.getLongExtra(EXTRA_SONG_ID, -1L)
+ if (songId != -1L) {
+ serviceScope.launch {
+ // TODO: Get song and pause
+ }
+ }
+ }
+ ACTION_CANCEL_DOWNLOAD -> {
+ val songId = intent.getLongExtra(EXTRA_SONG_ID, -1L)
+ if (songId != -1L) {
+ serviceScope.launch {
+ // TODO: Get song and cancel
+ }
+ }
+ }
+ ACTION_CANCEL_ALL -> {
+ serviceScope.launch {
+ val activeDownloads = downloadRepository.getActiveDownloads()
+ // TODO: Cancel all
+ }
+ }
+ }
+
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ downloadJob?.cancel()
+ networkCallback?.let {
+ connectivityManager?.unregisterNetworkCallback(it)
+ }
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+
+ private fun setupNetworkMonitoring() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ super.onAvailable(network)
+ // Network available - resume downloads if needed
+ serviceScope.launch {
+ val pausedDownloads = downloadRepository.getDownloadsByState(DownloadState.PAUSED)
+ // TODO: Resume paused downloads if appropriate
+ }
+ }
+
+ override fun onLost(network: Network) {
+ super.onLost(network)
+ // Network lost - pause downloads if WiFi-only mode
+ // TODO: Check settings and pause if needed
+ }
+ }
+
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+
+ connectivityManager?.registerNetworkCallback(request, networkCallback!!)
+ }
+ }
+
+ private fun startDownloadMonitoring() {
+ downloadJob = serviceScope.launch {
+ downloadManager.downloadingCount.collectLatest { count ->
+ updateNotification(count)
+
+ // Stop service when no active downloads
+ if (count == 0) {
+ serviceScope.launch {
+ val queuedDownloads = downloadRepository.getDownloadsByState(DownloadState.QUEUED)
+ if (queuedDownloads.isEmpty()) {
+ stopSelf()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun updateNotification(activeDownloads: Int) {
+ val notification = buildNotification(activeDownloads)
+ notificationManager?.notify(NOTIFICATION_ID, notification)
+ }
+
+ private fun buildNotification(activeDownloads: Int = 0): Notification {
+ val cancelIntent = Intent(this, DownloadService::class.java).apply {
+ action = ACTION_CANCEL_ALL
+ }
+ val cancelPendingIntent = PendingIntent.getService(
+ this,
+ 0,
+ cancelIntent,
+ PendingIntentCompat.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ val contentText = when {
+ activeDownloads == 0 -> "Preparing downloads..."
+ activeDownloads == 1 -> "Downloading 1 song"
+ else -> "Downloading $activeDownloads songs"
+ }
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("Downloading songs")
+ .setContentText(contentText)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .addAction(
+ NotificationCompat.Action(
+ R.drawable.ic_baseline_close_24,
+ "Cancel All",
+ cancelPendingIntent
+ )
+ )
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Downloads",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Offline download progress"
+ enableLights(false)
+ enableVibration(false)
+ setShowBadge(false)
+ }
+ notificationManager?.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "download_channel"
+ private const val NOTIFICATION_ID = 1001
+
+ const val ACTION_START_DOWNLOAD = "com.simplecityapps.shuttle.START_DOWNLOAD"
+ const val ACTION_PAUSE_DOWNLOAD = "com.simplecityapps.shuttle.PAUSE_DOWNLOAD"
+ const val ACTION_CANCEL_DOWNLOAD = "com.simplecityapps.shuttle.CANCEL_DOWNLOAD"
+ const val ACTION_CANCEL_ALL = "com.simplecityapps.shuttle.CANCEL_ALL"
+
+ const val EXTRA_SONG_ID = "song_id"
+ const val EXTRA_SONG_IDS = "song_ids"
+
+ fun startDownload(context: Context, songs: List) {
+ val intent = Intent(context, DownloadService::class.java).apply {
+ action = ACTION_START_DOWNLOAD
+ putExtra(EXTRA_SONG_IDS, songs.map { it.id }.toLongArray())
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun pauseDownload(context: Context, song: Song) {
+ val intent = Intent(context, DownloadService::class.java).apply {
+ action = ACTION_PAUSE_DOWNLOAD
+ putExtra(EXTRA_SONG_ID, song.id)
+ }
+ context.startService(intent)
+ }
+
+ fun cancelDownload(context: Context, song: Song) {
+ val intent = Intent(context, DownloadService::class.java).apply {
+ action = ACTION_CANCEL_DOWNLOAD
+ putExtra(EXTRA_SONG_ID, song.id)
+ }
+ context.startService(intent)
+ }
+
+ fun cancelAll(context: Context) {
+ val intent = Intent(context, DownloadService::class.java).apply {
+ action = ACTION_CANCEL_ALL
+ }
+ context.startService(intent)
+ }
+ }
+}
diff --git a/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadUseCase.kt b/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadUseCase.kt
new file mode 100644
index 000000000..0405e248f
--- /dev/null
+++ b/android/playback/src/main/java/com/simplecityapps/playback/download/DownloadUseCase.kt
@@ -0,0 +1,150 @@
+package com.simplecityapps.playback.download
+
+import android.content.Context
+import com.simplecityapps.mediaprovider.repository.downloads.DownloadRepository
+import com.simplecityapps.shuttle.model.Album
+import com.simplecityapps.shuttle.model.DownloadState
+import com.simplecityapps.shuttle.model.Playlist
+import com.simplecityapps.shuttle.model.Song
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+/**
+ * Use case for managing song downloads
+ */
+class DownloadUseCase @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val downloadManager: DownloadManager,
+ private val downloadRepository: DownloadRepository
+) {
+
+ /**
+ * Queue a song for download
+ */
+ suspend fun downloadSong(song: Song) {
+ downloadManager.queueDownload(song)
+ DownloadService.startDownload(context, listOf(song))
+ }
+
+ /**
+ * Queue multiple songs for download
+ */
+ suspend fun downloadSongs(songs: List) {
+ downloadManager.queueDownloads(songs)
+ DownloadService.startDownload(context, songs)
+ }
+
+ /**
+ * Download all songs from an album
+ */
+ suspend fun downloadAlbum(album: Album, songs: List) {
+ val albumSongs = songs.filter { song ->
+ song.album == album.name && song.albumArtist == album.albumArtist
+ }
+ downloadSongs(albumSongs)
+ }
+
+ /**
+ * Download all songs from a playlist
+ */
+ suspend fun downloadPlaylist(playlist: Playlist, songs: List) {
+ downloadSongs(songs)
+ }
+
+ /**
+ * Cancel a song download
+ */
+ suspend fun cancelDownload(song: Song) {
+ downloadManager.cancelDownload(song)
+ }
+
+ /**
+ * Cancel multiple downloads
+ */
+ suspend fun cancelDownloads(songs: List) {
+ downloadManager.cancelDownloads(songs)
+ }
+
+ /**
+ * Pause a download
+ */
+ suspend fun pauseDownload(song: Song) {
+ downloadManager.pauseDownload(song)
+ DownloadService.pauseDownload(context, song)
+ }
+
+ /**
+ * Resume a paused download
+ */
+ suspend fun resumeDownload(song: Song) {
+ downloadManager.resumeDownload(song)
+ DownloadService.startDownload(context, listOf(song))
+ }
+
+ /**
+ * Remove a downloaded song (delete file and database entry)
+ */
+ suspend fun removeDownload(song: Song) {
+ downloadManager.removeDownload(song)
+ }
+
+ /**
+ * Remove multiple downloaded songs
+ */
+ suspend fun removeDownloads(songs: List) {
+ downloadManager.removeDownloads(songs)
+ }
+
+ /**
+ * Check if a song is downloaded
+ */
+ suspend fun isDownloaded(song: Song): Boolean {
+ return downloadRepository.isDownloaded(song)
+ }
+
+ /**
+ * Get download state for a song
+ */
+ fun observeDownloadState(song: Song): Flow {
+ return downloadRepository.observeDownload(song).map { download ->
+ download?.downloadState ?: DownloadState.NONE
+ }
+ }
+
+ /**
+ * Get download progress for a song
+ */
+ fun observeDownloadProgress(song: Song): Flow {
+ return downloadRepository.observeDownload(song).map { download ->
+ download?.downloadProgress ?: 0f
+ }
+ }
+
+ /**
+ * Get all downloads
+ */
+ fun observeAllDownloads() = downloadRepository.observeAllDownloads()
+
+ /**
+ * Get downloaded count
+ */
+ fun observeDownloadedCount(): Flow {
+ return downloadRepository.observeDownloadCountByState(DownloadState.COMPLETED)
+ }
+
+ /**
+ * Get total downloaded size
+ */
+ suspend fun getTotalDownloadedSize(): Long {
+ return downloadRepository.getTotalDownloadedSize()
+ }
+
+ /**
+ * Remove all downloads
+ */
+ suspend fun removeAllDownloads() {
+ downloadRepository.removeAllDownloads()
+ }
+}