Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />

<!-- Permissions for foreground download service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<queries>
<intent>
<action android:name="android.intent.action.OPEN_DOCUMENT_TREE" />
Expand Down Expand Up @@ -101,6 +106,12 @@
android:authorities="${applicationId}.androidx-startup"
tools:node="remove"></provider>

<!-- Download Service -->
<service
android:name="com.simplecityapps.playback.download.DownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />

</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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())
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Presenter, Event> {
fun setData(downloads: List<DownloadData>, songs: List<Song>)
fun showLoadError(error: Error)
fun setLoadingState(loading: Boolean)
}

interface Presenter : BaseContract.Presenter<View> {
fun loadDownloads()
fun removeDownload(song: Song)
fun pauseDownload(song: Song)
fun resumeDownload(song: Song)
fun removeAllDownloads()
}
}
Original file line number Diff line number Diff line change
@@ -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.View>(),
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()
}
}
Loading
Loading