diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 8605bd9c..140848e8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -13,12 +13,11 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.progressindicator.CircularProgressIndicator import io.reactivex.rxjava3.disposables.CompositeDisposable import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.DownloadStatus -import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.service.DownloadService +import org.moire.ultrasonic.service.DownloadState import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign @@ -34,8 +33,6 @@ const val INDICATOR_THICKNESS_DEFINITE = 10 */ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable, KoinComponent { - private val downloader: Downloader by inject() - var entry: Track? = null private set var check: CheckedTextView = view.findViewById(R.id.song_check) @@ -61,7 +58,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable } private var isMaximized = false - private var cachedStatus = DownloadStatus.UNKNOWN + private var cachedStatus = DownloadState.UNKNOWN private var isPlayingCached = false private var rxBusSubscription: CompositeDisposable? = null @@ -98,7 +95,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable setupStarButtons(song, useFiveStarRating) } - updateStatus(downloader.getDownloadState(song), null) + updateStatus(DownloadService.getDownloadState(song), null) if (useFiveStarRating) { setFiveStars(entry?.userRating ?: 0) @@ -209,31 +206,32 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable } } - private fun updateStatus(status: DownloadStatus, progress: Int?) { + private fun updateStatus(status: DownloadState, progress: Int?) { progressIndicator.progress = progress ?: 0 if (status == cachedStatus) return cachedStatus = status when (status) { - DownloadStatus.DONE -> { + DownloadState.DONE -> { showStatusImage(imageHelper.downloadedImage) } - DownloadStatus.PINNED -> { + DownloadState.PINNED -> { showStatusImage(imageHelper.pinImage) } - DownloadStatus.FAILED, - DownloadStatus.CANCELLED -> { + DownloadState.FAILED -> { showStatusImage(imageHelper.errorImage) } - DownloadStatus.DOWNLOADING -> { + DownloadState.DOWNLOADING -> { showProgress() } - DownloadStatus.RETRYING, - DownloadStatus.QUEUED -> { + DownloadState.RETRYING, + DownloadState.QUEUED -> { showIndefiniteProgress() } else -> { + // This handles CANCELLED too. + // Usually it means no error, just that the track wasn't downloaded showStatusImage(null) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index cf9d5d42..6ec150c6 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -1,7 +1,6 @@ package org.moire.ultrasonic.di import org.koin.dsl.module -import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.ExternalStorageMonitor import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport @@ -14,8 +13,7 @@ val mediaPlayerModule = module { single { MediaPlayerLifecycleSupport() } single { PlaybackStateSerializer() } single { ExternalStorageMonitor() } - single { Downloader(get()) } // TODO Ideally this can be cleaned up when all circular references are removed. - single { MediaPlayerController(get(), get(), get(), get()) } + single { MediaPlayerController(get(), get(), get()) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt index 8a93da28..e8b453d0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt @@ -14,12 +14,11 @@ import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData -import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.TrackViewBinder import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.model.GenericListModel -import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.service.DownloadService import org.moire.ultrasonic.util.Util /** @@ -82,9 +81,7 @@ class DownloadsFragment : MultiListFragment() { } class DownloadListModel(application: Application) : GenericListModel(application) { - private val downloader by inject() - fun getList(): LiveData> { - return downloader.observableDownloads + return DownloadService.observableDownloads } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt index 370b470e..e364ec0f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ServerSelectorFragment.kt @@ -17,6 +17,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider.Companion.OFFLINE_DB_ID import org.moire.ultrasonic.data.ServerSetting import org.moire.ultrasonic.model.ServerSettingsModel +import org.moire.ultrasonic.service.DownloadService import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.util.ErrorDialog import org.moire.ultrasonic.util.Util @@ -101,7 +102,7 @@ class ServerSelectorFragment : Fragment() { // If we are coming from offline there is no need to clear downloads etc. if (oldId != OFFLINE_DB_ID) { controller.removeIncompleteTracksFromPlaylist() - controller.clearDownloads() + DownloadService.requestStop() } ActiveServerProvider.setActiveServerById(id) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 5b57a6ad..a2188078 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -37,8 +37,8 @@ import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.model.TrackCollectionModel -import org.moire.ultrasonic.service.DownloadStatus -import org.moire.ultrasonic.service.Downloader +import org.moire.ultrasonic.service.DownloadService +import org.moire.ultrasonic.service.DownloadState import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign @@ -80,7 +80,6 @@ open class TrackCollectionFragment : MultiListFragment() { private var shareButton: MenuItem? = null internal val mediaPlayerController: MediaPlayerController by inject() - internal val downloader: Downloader by inject() private val networkAndStorageChecker: NetworkAndStorageChecker by inject() private val shareHandler: ShareHandler by inject() internal var cancellationToken: CancellationToken? = null @@ -375,30 +374,24 @@ open class TrackCollectionFragment : MultiListFragment() { var unpinEnabled = false var deleteEnabled = false var downloadEnabled = false - var isNotInProgress = true val multipleSelection = viewAdapter.hasMultipleSelection() var pinnedCount = 0 for (song in selection) { - val state = downloader.getDownloadState(song) + val state = DownloadService.getDownloadState(song) when (state) { - DownloadStatus.DONE -> { + DownloadState.DONE -> { deleteEnabled = true } - DownloadStatus.PINNED -> { + DownloadState.PINNED -> { deleteEnabled = true pinnedCount++ unpinEnabled = true } - DownloadStatus.IDLE, DownloadStatus.FAILED -> { + DownloadState.IDLE, DownloadState.FAILED -> { downloadEnabled = true } - DownloadStatus.DOWNLOADING, - DownloadStatus.QUEUED, - DownloadStatus.RETRYING -> { - isNotInProgress = false - } else -> {} } } @@ -406,11 +399,10 @@ open class TrackCollectionFragment : MultiListFragment() { playNowButton?.isVisible = enabled playNextButton?.isVisible = enabled && multipleSelection playLastButton?.isVisible = enabled && multipleSelection - pinButton?.isVisible = - isNotInProgress && enabled && !isOffline() && selection.size > pinnedCount - unpinButton?.isVisible = isNotInProgress && enabled && unpinEnabled - downloadButton?.isVisible = isNotInProgress && enabled && downloadEnabled && !isOffline() - deleteButton?.isVisible = isNotInProgress && enabled && deleteEnabled + pinButton?.isVisible = enabled && !isOffline() && selection.size > pinnedCount + unpinButton?.isVisible = enabled && unpinEnabled + downloadButton?.isVisible = enabled && downloadEnabled && !isOffline() + deleteButton?.isVisible = enabled && deleteEnabled } private fun downloadBackground(save: Boolean) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt index dbf4a862..8c2eb083 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/PlaybackService.kt @@ -12,6 +12,8 @@ import android.os.Build import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.C.USAGE_MEDIA +import androidx.media3.common.MediaItem +import androidx.media3.common.Player import androidx.media3.datasource.DataSource import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource @@ -27,11 +29,14 @@ import org.moire.ultrasonic.activity.NavigationActivity import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.service.DownloadService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.toTrack import timber.log.Timber class PlaybackService : MediaLibraryService(), KoinComponent { @@ -74,6 +79,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent { // Broadcast that the service is being shutdown RxBus.stopServiceCommandPublisher.onNext(Unit) + player.removeListener(listener) player.release() mediaLibrarySession.release() rxBusSubscription.dispose() @@ -153,9 +159,31 @@ class PlaybackService : MediaLibraryService(), KoinComponent { onDestroy() } + player.addListener(listener) isStarted = true } + private val listener: Player.Listener = object : Player.Listener { + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + cacheNextSongs() + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + cacheNextSongs() + } + } + + private fun cacheNextSongs() { + Timber.d("PlaybackService caching the next songs") + val nextSongs = Util.getPlayListFromTimeline( + player.currentTimeline, + player.shuffleModeEnabled, + player.currentMediaItemIndex, + Settings.preloadCount + ).map { it.toTrack() } + DownloadService.download(nextSongs, save = false, isHighPriority = true) + } + private fun getPendingIntentForContent(): PendingIntent { val intent = Intent(this, NavigationActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt index 81e7f865..eb75a7f5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -12,20 +12,40 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Intent +import android.net.wifi.WifiManager import android.os.Build +import android.os.Handler import android.os.IBinder -import android.support.v4.media.session.MediaSessionCompat +import android.os.Looper import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import java.util.concurrent.Semaphore -import java.util.concurrent.TimeUnit -import org.koin.android.ext.android.inject +import androidx.lifecycle.MutableLiveData +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import com.google.common.util.concurrent.SettableFuture +import java.util.PriorityQueue +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.service.DownloadState.Companion.isFinalState +import org.moire.ultrasonic.util.FileUtil +import org.moire.ultrasonic.util.FileUtil.getCompleteFile +import org.moire.ultrasonic.util.FileUtil.getPartialFile +import org.moire.ultrasonic.util.FileUtil.getPinnedFile +import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.SimpleServiceBinder +import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Util import timber.log.Timber +private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" +private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" +private const val NOTIFICATION_ID = 3033 + +private const val CHECK_INTERVAL = 5000L + /** * Android Foreground service which is used to download tracks even when the app is not visible * @@ -34,14 +54,14 @@ import timber.log.Timber * * TODO: Migrate this to use the Media3 DownloadHelper */ -class DownloadService : Service() { +class DownloadService : Service(), KoinComponent { + private val storageMonitor: ExternalStorageMonitor by inject() private val binder: IBinder = SimpleServiceBinder(this) - private val downloader by inject() - - private var mediaSession: MediaSessionCompat? = null - private var isInForeground = false + private var wifiLock: WifiManager.WifiLock? = null + private var isShuttingDown = false + private var retrying = false override fun onBind(intent: Intent): IBinder { return binder @@ -54,9 +74,13 @@ class DownloadService : Service() { createNotificationChannel() updateNotification() - instance = this - startedSemaphore.release() - Timber.i("DownloadService initiated") + if (wifiLock == null) { + wifiLock = Util.createWifiLock(toString()) + wifiLock?.acquire() + } + + startFuture?.set(this) + Timber.i("DownloadService created") } override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { @@ -66,22 +90,116 @@ class DownloadService : Service() { override fun onDestroy() { super.onDestroy() - instance = null - try { - downloader.stop() + startFuture = null + + isShuttingDown = true + isInForeground = false + stopForeground(true) + + wifiLock?.release() + wifiLock = null + + clearDownloads() + observableDownloads.value = listOf() - mediaSession?.release() - mediaSession = null - } catch (ignored: Throwable) { - } Timber.i("DownloadService destroyed") } - fun notifyDownloaderStopped() { - Timber.i("DownloadService stopped") - isInForeground = false - stopForeground(true) - stopSelf() + fun addTracks(tracks: List) { + downloadQueue.addAll(tracks) + tracks.forEach { postState(it.track, DownloadState.QUEUED) } + processNextTracks() + } + + private fun processNextTracks() { + retrying = false + if ( + !Util.isNetworkConnected() || + !Util.isExternalStoragePresent() || + !storageMonitor.isExternalStorageAvailable + ) { + retryProcessNextTracks() + return + } + + Timber.v("DownloadService processNextTracks checking downloads") + var listChanged = false + + // Fill up active List with waiting tasks + while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) { + val task = downloadQueue.remove() + val downloadTask = DownloadTask(task) { downloadableTrack, downloadState, progress -> + downloadStateChangedCallback(downloadableTrack, downloadState, progress) + } + activelyDownloading[task] = downloadTask + FileUtil.createDirectoryForParent(task.pinnedFile) + activelyDownloading[task]?.start() + + listChanged = true + } + + // Stop Executor service when done downloading + if (activelyDownloading.isEmpty()) { + stopSelf() + } + + if (listChanged) { + updateLiveData() + } + } + + private fun retryProcessNextTracks() { + if (isShuttingDown || retrying) return + retrying = true + Handler(Looper.getMainLooper()).postDelayed( + { if (retrying) processNextTracks() }, + CHECK_INTERVAL + ) + } + + private fun downloadStateChangedCallback( + item: DownloadableTrack, + downloadState: DownloadState, + progress: Int? + ) { + postState(item.track, downloadState, progress) + + if (downloadState.isFinalState()) { + activelyDownloading.remove(item) + processNextTracks() + } + + when (downloadState) { + DownloadState.FAILED -> { + downloadQueue.remove(item) + failedList.add(item) + } + DownloadState.RETRYING -> { + item.tryCount++ + downloadQueue.add(item) + } + else -> {} + } + } + + private fun updateLiveData() { + val temp: MutableList = ArrayList() + temp.addAll(activelyDownloading.keys.map { x -> x.track }) + temp.addAll(downloadQueue.map { x -> x.track }) + observableDownloads.postValue(temp.distinct().sorted()) + } + + private fun clearDownloads() { + // Clear the pending queue + while (!downloadQueue.isEmpty()) { + postState(downloadQueue.remove().track, DownloadState.IDLE) + } + // Cancel all active downloads + for (download in activelyDownloading) { + download.value.cancel() + } + activelyDownloading.clear() + updateLiveData() } private fun createNotificationChannel() { @@ -143,75 +261,170 @@ class DownloadService : Service() { */ @Suppress("SpreadOperator") private fun buildForegroundNotification(): Notification { - - if (downloader.started) { - // No song is playing, but Ultrasonic is downloading files - notificationBuilder.setContentTitle( - getString(R.string.notification_downloading_title) - ) - } - + notificationBuilder.setContentTitle(getString(R.string.notification_downloading_title)) return notificationBuilder.build() } @Suppress("MagicNumber") companion object { - private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" - private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" - private const val NOTIFICATION_ID = 3033 + private var startFuture: SettableFuture? = null - @Volatile - private var instance: DownloadService? = null - private val instanceLock = Any() - private val startedSemaphore: Semaphore = Semaphore(0) + private val downloadQueue = PriorityQueue() + private val activelyDownloading = mutableMapOf() + private val failedList = mutableListOf() - @JvmStatic - fun getInstance(): DownloadService? { - val context = UApp.applicationContext() - if (instance != null) return instance - synchronized(instanceLock) { - if (instance != null) return instance - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService( - Intent(context, DownloadService::class.java) - ) - } else { - context.startService(Intent(context, DownloadService::class.java)) - } - Timber.i("DownloadService starting...") - if (startedSemaphore.tryAcquire(10, TimeUnit.SECONDS)) { - Timber.i("DownloadService started") - return instance - } - Timber.w("DownloadService failed to start!") - return null - } - } + // The generic list models expect a LiveData, so even though we are using Rx for many events + // surrounding playback the list of Downloads is published as LiveData. + val observableDownloads = MutableLiveData>() - @JvmStatic - val runningInstance: DownloadService? - get() { - synchronized(instanceLock) { return instance } - } + private var backgroundPriorityCounter = 100 - @JvmStatic - fun executeOnStartedDownloadService( - taskToExecute: (DownloadService) -> Unit + fun download( + tracks: List, + save: Boolean, + isHighPriority: Boolean = false ) { - - val t: Thread = object : Thread() { - override fun run() { - val instance = getInstance() - if (instance == null) { - Timber.e("ExecuteOnStarted.. failed to get a DownloadService instance!") - return - } else { - taskToExecute(instance) + // First handle and filter out those tracks that are already completed + var filteredTracks: List + if (save) { + tracks.filter { Storage.isPathExists(it.getCompleteFile()) }.forEach { track -> + Storage.getFromPath(track.getCompleteFile())?.let { + Storage.rename(it, track.getPinnedFile()) + postState(track, DownloadState.PINNED) } } + filteredTracks = tracks.filter { !Storage.isPathExists(it.getPinnedFile()) } + } else { + tracks.filter { Storage.isPathExists(it.getPinnedFile()) }.forEach { track -> + Storage.getFromPath(track.getPinnedFile())?.let { + Storage.rename(it, track.getCompleteFile()) + postState(track, DownloadState.DONE) + } + } + filteredTracks = tracks.filter { !Storage.isPathExists(it.getCompleteFile()) } + } + + // Update Pinned flag of items in progress + downloadQueue.filter { item -> tracks.any { it.id == item.id } } + .forEach { it.pinned = save } + activelyDownloading.filter { item -> tracks.any { it.id == item.key.id } } + .forEach { it.key.pinned = save } + failedList.filter { item -> tracks.any { it.id == item.id } } + .forEach { it.pinned = save } + + filteredTracks = filteredTracks.filter { + !downloadQueue.any { t -> + t.track.id == it.id + } && !activelyDownloading.any { t -> + t.key.track.id == it.id + } + } + + // The remainder tracks should be added to the download queue + // By using the counter we ensure that the songs are added in the correct order + var priority = 0 + val tracksToDownload = + filteredTracks.map { + DownloadableTrack( + it, + save, + 0, + if (isHighPriority) priority++ else backgroundPriorityCounter++ + ) + } + + if (tracksToDownload.isNotEmpty()) addTracks(tracksToDownload) + } + + fun requestStop() { + val context = UApp.applicationContext() + val intent = Intent(context, DownloadService::class.java) + context.stopService(intent) + failedList.clear() + } + + fun delete(track: Track) { + + downloadQueue.singleOrNull { it.id == track.id }?.let { downloadQueue.remove(it) } + failedList.singleOrNull { it.id == track.id }?.let { downloadQueue.remove(it) } + cancelDownload(track) + + Storage.delete(track.getPartialFile()) + Storage.delete(track.getCompleteFile()) + Storage.delete(track.getPinnedFile()) + postState(track, DownloadState.IDLE) + Util.scanMedia(track.getPinnedFile()) + } + + fun unpin(track: Track) { + // Update Pinned flag of items in progress + downloadQueue.singleOrNull { it.id == track.id }?.pinned = false + activelyDownloading.keys.singleOrNull { it.id == track.id }?.pinned = false + failedList.singleOrNull { it.id == track.id }?.pinned = false + + val pinnedFile = track.getPinnedFile() + if (!Storage.isPathExists(pinnedFile)) return + val file = Storage.getFromPath(track.getPinnedFile()) ?: return + Storage.rename(file, track.getCompleteFile()) + postState(track, DownloadState.DONE) + } + + @Suppress("ReturnCount") + fun getDownloadState(track: Track): DownloadState { + if (Storage.isPathExists(track.getCompleteFile())) return DownloadState.DONE + if (Storage.isPathExists(track.getPinnedFile())) return DownloadState.PINNED + if (activelyDownloading.any { it.key.id == track.id }) return DownloadState.QUEUED + if (downloadQueue.any { it.id == track.id }) return DownloadState.QUEUED + + val key = activelyDownloading.keys.firstOrNull { it.track.id == track.id } + if (key != null) { + if (key.tryCount > 0) return DownloadState.RETRYING + return DownloadState.DOWNLOADING + } + if (failedList.any { it.track.id == track.id }) return DownloadState.FAILED + return DownloadState.IDLE + } + + private fun addTracks(tracks: List) { + val serviceFuture = startFuture ?: requestStart() + serviceFuture.addListener({ + val service = serviceFuture.get() + service.addTracks(tracks) + Timber.i("Added tracks to DownloadService") + }, MoreExecutors.directExecutor()) + } + + private fun cancelDownload(track: Track) { + val key = activelyDownloading.keys.singleOrNull { it.track.id == track.id } ?: return + activelyDownloading[key]?.cancel() + } + + private fun postState(track: Track, state: DownloadState, progress: Int? = null) { + RxBus.trackDownloadStatePublisher.onNext( + RxBus.TrackDownloadState( + track.id, + state, + progress + ) + ) + } + + private fun requestStart(): ListenableFuture { + val future = SettableFuture.create() + startFuture = future + startService() + return future + } + + private fun startService() { + val context = UApp.applicationContext() + val intent = Intent(context, DownloadService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) } - t.start() } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadState.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadState.kt new file mode 100644 index 00000000..976184a1 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadState.kt @@ -0,0 +1,25 @@ +/* + * DownloadStatus.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +enum class DownloadState { + IDLE, QUEUED, DOWNLOADING, RETRYING, FAILED, CANCELLED, DONE, PINNED, UNKNOWN; + + companion object { + fun DownloadState.isFinalState(): Boolean { + return when (this) { + RETRYING, + FAILED, + CANCELLED, + DONE, + PINNED -> true + else -> false + } + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt new file mode 100644 index 00000000..45d7a9cd --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt @@ -0,0 +1,259 @@ +/* + * DownloadTask.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import android.os.SystemClock +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.Locale +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.data.MetaDatabase +import org.moire.ultrasonic.domain.Album +import org.moire.ultrasonic.domain.Artist +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.subsonic.ImageLoaderProvider +import org.moire.ultrasonic.util.CacheCleaner +import org.moire.ultrasonic.util.CancellableTask +import org.moire.ultrasonic.util.FileUtil +import org.moire.ultrasonic.util.Settings +import org.moire.ultrasonic.util.Storage +import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.Util.safeClose +import timber.log.Timber + +private const val MAX_RETRIES = 5 +private const val REFRESH_INTERVAL = 50 + +class DownloadTask( + private val item: DownloadableTrack, + private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit +) : + CancellableTask(), KoinComponent { + val musicService = MusicServiceFactory.getMusicService() + + private val imageLoaderProvider: ImageLoaderProvider by inject() + private val activeServerProvider: ActiveServerProvider by inject() + + @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "TooGenericExceptionThrown") + override fun execute() { + + var inputStream: InputStream? = null + var outputStream: OutputStream? = null + try { + if (Storage.isPathExists(item.pinnedFile)) { + Timber.i("%s already exists. Skipping.", item.pinnedFile) + stateChangedCallback(item, DownloadState.PINNED, null) + return + } + + if (Storage.isPathExists(item.completeFile)) { + var newStatus: DownloadState = DownloadState.DONE + if (item.pinned) { + Storage.rename( + item.completeFile, + item.pinnedFile + ) + newStatus = DownloadState.PINNED + } else { + Timber.i( + "%s already exists. Skipping.", + item.completeFile + ) + } + + // Hidden feature: If track is toggled between pinned/saved, refresh the metadata.. + try { + item.track.cacheMetadataAndArtwork() + } catch (ignore: Exception) { + Timber.w(ignore) + } + stateChangedCallback(item, newStatus, null) + return + } + + stateChangedCallback(item, DownloadState.DOWNLOADING, null) + + // Some devices seem to throw error on partial file which doesn't exist + val needsDownloading: Boolean + val duration = item.track.duration + val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0 + + needsDownloading = (duration == null || duration == 0 || fileLength == 0L) + + if (needsDownloading) { + // Attempt partial HTTP GET, appending to the file if it exists. + val (inStream, isPartial) = musicService.getDownloadInputStream( + item.track, fileLength, + Settings.maxBitRate, + item.pinned + ) + + inputStream = inStream + + if (isPartial) { + Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) + } + + outputStream = Storage.getOrCreateFileFromPath(item.partialFile) + .getFileOutputStream(isPartial) + + var lastPostTime: Long = 0 + val len = inputStream.copyTo(outputStream) { totalBytesCopied -> + // Manual throttling to avoid overloading Rx + if (SystemClock.elapsedRealtime() - lastPostTime > REFRESH_INTERVAL) { + lastPostTime = SystemClock.elapsedRealtime() + stateChangedCallback( + item, + DownloadState.DOWNLOADING, + (totalBytesCopied * 100 / (item.track.size ?: 1)).toInt() + ) + } + } + + Timber.i("Downloaded %d bytes to %s", len, item.partialFile) + + inputStream.close() + outputStream.flush() + outputStream.close() + + if (isCancelled) { + stateChangedCallback(item, DownloadState.CANCELLED, null) + throw RuntimeException( + String.format( + Locale.ROOT, "Download of '%s' was cancelled", + item + ) + ) + } + + try { + item.track.cacheMetadataAndArtwork() + } catch (ignore: Exception) { + Timber.w(ignore) + } + } + + if (item.pinned) { + Storage.rename( + item.partialFile, + item.pinnedFile + ) + stateChangedCallback(item, DownloadState.PINNED, null) + Util.scanMedia(item.pinnedFile) + } else { + Storage.rename( + item.partialFile, + item.completeFile + ) + stateChangedCallback(item, DownloadState.DONE, null) + } + } catch (all: Exception) { + outputStream.safeClose() + Storage.delete(item.completeFile) + Storage.delete(item.pinnedFile) + if (!isCancelled) { + if (item.tryCount < MAX_RETRIES) { + stateChangedCallback(item, DownloadState.RETRYING, null) + } else { + stateChangedCallback(item, DownloadState.FAILED, null) + } + Timber.w(all, "Failed to download '%s'.", item) + } + } finally { + inputStream.safeClose() + outputStream.safeClose() + CacheCleaner().cleanSpace() + } + } + + override fun toString(): String { + return String.format(Locale.ROOT, "DownloadTask (%s)", item) + } + + private fun Track.cacheMetadataAndArtwork() { + val onlineDB = activeServerProvider.getActiveMetaDatabase() + val offlineDB = activeServerProvider.offlineMetaDatabase + + var artistId: String? = if (artistId.isNullOrEmpty()) null else artistId + val albumId: String? = if (albumId.isNullOrEmpty()) null else albumId + + var album: Album? = null + + // Sometime in compilation albums, the individual tracks won't have an Artist id + // In this case, try to get the ArtistId of the album... + if (artistId == null && albumId != null) { + album = musicService.getAlbum(albumId, null, false) + artistId = album?.artistId + } + + // Cache the artist + if (artistId != null) + cacheArtist(onlineDB, offlineDB, artistId) + + // Now cache the album + if (albumId != null) { + if (album == null) { + // This is a cached call + val albums = musicService.getAlbumsOfArtist(artistId!!, null, false) + album = albums.find { it.id == albumId } + } + + if (album != null) { + // Often the album entity returned from the server won't have the path set. + if (album.path.isNullOrEmpty()) album.path = FileUtil.getParentPath(path) + + offlineDB.albumDao().insert(album) + + // If the album is a Compilation, also cache the Album artist + if (album.artistId != null && album.artistId != artistId) + cacheArtist(onlineDB, offlineDB, album.artistId!!) + } + } + + // Now cache the track data + offlineDB.trackDao().insert(this) + + // Download the largest size that we can display in the UI + imageLoaderProvider.getImageLoader().cacheCoverArt(this) + } + + private fun cacheArtist(onlineDB: MetaDatabase, offlineDB: MetaDatabase, artistId: String) { + var artist: Artist? = onlineDB.artistDao().get(artistId) + + // If we are downloading a new album, and the user has not visited the Artists list + // recently, then the artist won't be in the database. + if (artist == null) { + val artists: List = musicService.getArtists(true) + artist = artists.find { + it.id == artistId + } + } + + // If we have found an artist, cache it. + if (artist != null) { + offlineDB.artistDao().insert(artist) + } + } + + @Throws(IOException::class) + fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = read(buffer) + while (!isCancelled && bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + onCopy(bytesCopied) + bytes = read(buffer) + } + return bytesCopied + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadableTrack.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadableTrack.kt new file mode 100644 index 00000000..e7ff4099 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadableTrack.kt @@ -0,0 +1,32 @@ +/* + * DownloadableTrack.kt + * Copyright (C) 2009-2022 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.service + +import org.moire.ultrasonic.domain.Identifiable +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.util.FileUtil.getCompleteFile +import org.moire.ultrasonic.util.FileUtil.getPartialFile +import org.moire.ultrasonic.util.FileUtil.getPinnedFile + +class DownloadableTrack( + val track: Track, + var pinned: Boolean, + var tryCount: Int, + var priority: Int +) : Identifiable { + val pinnedFile = track.getPinnedFile() + val partialFile = track.getPartialFile() + val completeFile = track.getCompleteFile() + override val id: String + get() = track.id + + override fun compareTo(other: Identifiable) = compareTo(other as DownloadableTrack) + fun compareTo(other: DownloadableTrack): Int { + return priority.compareTo(other.priority) + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt deleted file mode 100644 index 5ba849be..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ /dev/null @@ -1,587 +0,0 @@ -/* - * Downloader.kt - * Copyright (C) 2009-2022 Ultrasonic developers - * - * Distributed under terms of the GNU GPLv3 license. - */ - -package org.moire.ultrasonic.service - -import android.net.wifi.WifiManager -import android.os.Handler -import android.os.Looper -import android.os.SystemClock as SystemClock -import androidx.lifecycle.MutableLiveData -import io.reactivex.rxjava3.disposables.CompositeDisposable -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.util.Locale -import java.util.PriorityQueue -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import org.moire.ultrasonic.data.ActiveServerProvider -import org.moire.ultrasonic.data.MetaDatabase -import org.moire.ultrasonic.domain.Album -import org.moire.ultrasonic.domain.Artist -import org.moire.ultrasonic.domain.Identifiable -import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.subsonic.ImageLoaderProvider -import org.moire.ultrasonic.util.CacheCleaner -import org.moire.ultrasonic.util.CancellableTask -import org.moire.ultrasonic.util.FileUtil -import org.moire.ultrasonic.util.FileUtil.getCompleteFile -import org.moire.ultrasonic.util.FileUtil.getPartialFile -import org.moire.ultrasonic.util.FileUtil.getPinnedFile -import org.moire.ultrasonic.util.Settings -import org.moire.ultrasonic.util.Storage -import org.moire.ultrasonic.util.Util -import org.moire.ultrasonic.util.Util.safeClose -import org.moire.ultrasonic.util.shouldBePinned -import org.moire.ultrasonic.util.toTrack -import timber.log.Timber - -/** - * This class is responsible for maintaining the playlist and downloading - * its items from the network to the filesystem. - * - * TODO: Move entirely to subclass the Media3.DownloadService - */ -class Downloader( - private val storageMonitor: ExternalStorageMonitor, -) : KoinComponent { - - // Dependencies - private val imageLoaderProvider: ImageLoaderProvider by inject() - private val activeServerProvider: ActiveServerProvider by inject() - private val mediaController: MediaPlayerController by inject() - - var started: Boolean = false - var shouldStop: Boolean = false - var isPolling: Boolean = false - - private val downloadQueue = PriorityQueue() - private val activelyDownloading = mutableMapOf() - private val failedList = mutableListOf() - - // The generic list models expect a LiveData, so even though we are using Rx for many events - // surrounding playback the list of Downloads is published as LiveData. - val observableDownloads = MutableLiveData>() - - private var handler: Handler = Handler(Looper.getMainLooper()) - private var wifiLock: WifiManager.WifiLock? = null - - private var backgroundPriorityCounter = 100 - - private val rxBusSubscription: CompositeDisposable = CompositeDisposable() - - init { - Timber.i("Init called") - // Check downloads if the playlist changed - rxBusSubscription += RxBus.playlistObservable.subscribe { - Timber.v("Playlist has changed, checking Downloads...") - checkDownloads() - } - } - - private var downloadChecker = object : Runnable { - override fun run() { - try { - Timber.w("Checking Downloads") - checkDownloadsInternal() - } catch (all: Exception) { - Timber.e(all, "checkDownloads() failed.") - } finally { - if (!isPolling) { - isPolling = true - if (!shouldStop) { - Handler(Looper.getMainLooper()).postDelayed(this, CHECK_INTERVAL) - } else { - shouldStop = false - isPolling = false - } - } - } - } - } - - fun onDestroy() { - stop() - rxBusSubscription.dispose() - clearBackground() - observableDownloads.value = listOf() - Timber.i("Downloader destroyed") - } - - @Synchronized - fun start() { - if (started) return - started = true - - // Start our loop - handler.postDelayed(downloadChecker, 100) - - if (wifiLock == null) { - wifiLock = Util.createWifiLock(toString()) - wifiLock?.acquire() - } - } - - fun stop() { - if (!started) return - started = false - shouldStop = true - wifiLock?.release() - wifiLock = null - handler.postDelayed( - Runnable { DownloadService.runningInstance?.notifyDownloaderStopped() }, - 100 - ) - Timber.i("Downloader stopped") - } - - fun checkDownloads() { - if (!started) { - start() - } else { - try { - handler.postDelayed(downloadChecker, 100) - } catch (all: Exception) { - Timber.w( - all, - "checkDownloads() can't run, maybe the Downloader is shutting down..." - ) - } - } - } - - @Suppress("ComplexMethod", "ComplexCondition") - @Synchronized - private fun checkDownloadsInternal() { - if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) { - return - } - - if (JukeboxMediaPlayer.running.get() || !Util.isNetworkConnected()) { - return - } - - Timber.v("Downloader checkDownloadsInternal checking downloads") - - var listChanged = false - val playlist = mediaController.getNextPlaylistItemsInPlayOrder(Settings.preloadCount) - var priority = 0 - - for (item in playlist) { - val track = item.toTrack() - - // Add file to queue if not in one of the queues already. - if (getDownloadState(track) == DownloadStatus.IDLE) { - listChanged = true - - // If a track is already in the manual download queue, - // and is now due to be played soon we add it to the queue with high priority instead. - val existingItem = downloadQueue.firstOrNull { it.track.id == track.id } - if (existingItem != null) { - existingItem.priority = priority + 1 - continue - } - - // Set correct priority (the lower the number, the higher the priority) - downloadQueue.add(DownloadableTrack(track, item.shouldBePinned(), 0, priority++)) - postState(track, DownloadStatus.QUEUED) - } - } - - // Fill up active List with waiting tasks - while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) { - val task = downloadQueue.remove() - val downloadTask = DownloadTask(task) - activelyDownloading[task] = downloadTask - startDownloadOnService(task) - - listChanged = true - } - - // Stop Executor service when done downloading - if (activelyDownloading.isEmpty()) { - stop() - } - - if (listChanged) { - updateLiveData() - } - } - - private fun updateLiveData() { - observableDownloads.postValue(downloads) - } - - private fun startDownloadOnService(track: DownloadableTrack) { - DownloadService.executeOnStartedDownloadService { - FileUtil.createDirectoryForParent(track.pinnedFile) - activelyDownloading[track]?.start() - Timber.v("startDownloadOnService started downloading file ${track.completeFile}") - } - } - - /* - * Returns a list of all DownloadFiles that are currently downloading or waiting for download, - */ - @get:Synchronized - val downloads: List - get() { - val temp: MutableList = ArrayList() - temp.addAll(activelyDownloading.keys.map { x -> x.track }) - temp.addAll(downloadQueue.map { x -> x.track }) - return temp.distinct().sorted() - } - - @Synchronized - fun clearBackground() { - // Clear the pending queue - while (!downloadQueue.isEmpty()) { - postState(downloadQueue.remove().track, DownloadStatus.IDLE) - } - - // Cancel all active downloads with a low priority - for (key in activelyDownloading.keys) { - if (key.priority >= 100) { - activelyDownloading[key]?.cancel() - activelyDownloading.remove(key) - } - } - - backgroundPriorityCounter = 100 - } - - @Synchronized - fun clearActiveDownloads() { - // Cancel all active downloads - for (download in activelyDownloading) { - download.value.cancel() - } - activelyDownloading.clear() - updateLiveData() - } - - @Synchronized - fun downloadBackground(tracks: List, save: Boolean) { - // By using the counter we ensure that the songs are added in the correct order - for (track in tracks) { - if (downloadQueue.any { t -> t.track.id == track.id } || - activelyDownloading.any { t -> t.key.track.id == track.id } - ) continue - val file = DownloadableTrack(track, save, 0, backgroundPriorityCounter++) - downloadQueue.add(file) - postState(track, DownloadStatus.QUEUED) - } - - Timber.v("downloadBackground Checking Downloads") - checkDownloads() - } - - fun delete(track: Track) { - cancelDownload(track) - Storage.delete(track.getPartialFile()) - Storage.delete(track.getCompleteFile()) - Storage.delete(track.getPinnedFile()) - postState(track, DownloadStatus.IDLE) - Util.scanMedia(track.getPinnedFile()) - } - - private fun cancelDownload(track: Track) { - val key = activelyDownloading.keys.singleOrNull { it.track.id == track.id } ?: return - activelyDownloading[key]?.cancel() - } - - fun unpin(track: Track) { - val pinnedFile = track.getPinnedFile() - if (!Storage.isPathExists(pinnedFile)) return - val file = Storage.getFromPath(track.getPinnedFile()) ?: return - Storage.rename(file, track.getCompleteFile()) - postState(track, DownloadStatus.DONE) - } - - @Suppress("ReturnCount") - fun getDownloadState(track: Track): DownloadStatus { - if (Storage.isPathExists(track.getCompleteFile())) return DownloadStatus.DONE - if (Storage.isPathExists(track.getPinnedFile())) return DownloadStatus.PINNED - if (downloads.any { it.id == track.id }) return DownloadStatus.QUEUED - - val key = activelyDownloading.keys.firstOrNull { it.track.id == track.id } - if (key != null) { - if (key.tryCount > 0) return DownloadStatus.RETRYING - return DownloadStatus.DOWNLOADING - } - if (failedList.any { it.track.id == track.id }) return DownloadStatus.FAILED - return DownloadStatus.IDLE - } - - companion object { - const val CHECK_INTERVAL = 5000L - const val MAX_RETRIES = 5 - const val REFRESH_INTERVAL = 50 - } - - private fun postState(track: Track, state: DownloadStatus, progress: Int? = null) { - RxBus.trackDownloadStatePublisher.onNext( - RxBus.TrackDownloadState( - track.id, - state, - progress - ) - ) - } - - private inner class DownloadTask(private val item: DownloadableTrack) : - CancellableTask() { - val musicService = MusicServiceFactory.getMusicService() - - @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "TooGenericExceptionThrown") - override fun execute() { - - var inputStream: InputStream? = null - var outputStream: OutputStream? = null - try { - if (Storage.isPathExists(item.pinnedFile)) { - Timber.i("%s already exists. Skipping.", item.pinnedFile) - postState(item.track, DownloadStatus.PINNED) - return - } - - if (Storage.isPathExists(item.completeFile)) { - var newStatus: DownloadStatus = DownloadStatus.DONE - if (item.pinned) { - Storage.rename( - item.completeFile, - item.pinnedFile - ) - newStatus = DownloadStatus.PINNED - } else { - Timber.i( - "%s already exists. Skipping.", - item.completeFile - ) - } - - // Hidden feature: If track is toggled between pinned/saved, refresh the metadata.. - try { - item.track.cacheMetadataAndArtwork() - } catch (ignore: Exception) { - Timber.w(ignore) - } - postState(item.track, newStatus) - return - } - - postState(item.track, DownloadStatus.DOWNLOADING) - - // Some devices seem to throw error on partial file which doesn't exist - val needsDownloading: Boolean - val duration = item.track.duration - val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0 - - needsDownloading = (duration == null || duration == 0 || fileLength == 0L) - - if (needsDownloading) { - // Attempt partial HTTP GET, appending to the file if it exists. - val (inStream, isPartial) = musicService.getDownloadInputStream( - item.track, fileLength, - Settings.maxBitRate, - item.pinned - ) - - inputStream = inStream - - if (isPartial) { - Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) - } - - outputStream = Storage.getOrCreateFileFromPath(item.partialFile) - .getFileOutputStream(isPartial) - - var lastPostTime: Long = 0 - val len = inputStream.copyTo(outputStream) { totalBytesCopied -> - // Manual throttling to avoid overloading Rx - if (SystemClock.elapsedRealtime() - lastPostTime > REFRESH_INTERVAL) { - lastPostTime = SystemClock.elapsedRealtime() - postState( - item.track, - DownloadStatus.DOWNLOADING, - (totalBytesCopied * 100 / (item.track.size ?: 1)).toInt() - ) - } - } - - Timber.i("Downloaded %d bytes to %s", len, item.partialFile) - - inputStream.close() - outputStream.flush() - outputStream.close() - - if (isCancelled) { - postState(item.track, DownloadStatus.CANCELLED) - throw RuntimeException( - String.format( - Locale.ROOT, "Download of '%s' was cancelled", - item - ) - ) - } - - try { - item.track.cacheMetadataAndArtwork() - } catch (ignore: Exception) { - Timber.w(ignore) - } - } - - if (item.pinned) { - Storage.rename( - item.partialFile, - item.pinnedFile - ) - postState(item.track, DownloadStatus.PINNED) - Util.scanMedia(item.pinnedFile) - } else { - Storage.rename( - item.partialFile, - item.completeFile - ) - postState(item.track, DownloadStatus.DONE) - } - } catch (all: Exception) { - outputStream.safeClose() - Storage.delete(item.completeFile) - Storage.delete(item.pinnedFile) - if (!isCancelled) { - if (item.tryCount < MAX_RETRIES) { - postState(item.track, DownloadStatus.RETRYING) - item.tryCount++ - activelyDownloading.remove(item) - downloadQueue.add(item) - } else { - postState(item.track, DownloadStatus.FAILED) - activelyDownloading.remove(item) - downloadQueue.remove(item) - failedList.add(item) - } - Timber.w(all, "Failed to download '%s'.", item) - } - } finally { - activelyDownloading.remove(item) - inputStream.safeClose() - outputStream.safeClose() - CacheCleaner().cleanSpace() - Timber.v("DownloadTask checking downloads") - checkDownloads() - } - } - - override fun toString(): String { - return String.format(Locale.ROOT, "DownloadTask (%s)", item) - } - - private fun Track.cacheMetadataAndArtwork() { - val onlineDB = activeServerProvider.getActiveMetaDatabase() - val offlineDB = activeServerProvider.offlineMetaDatabase - - var artistId: String? = if (artistId.isNullOrEmpty()) null else artistId - val albumId: String? = if (albumId.isNullOrEmpty()) null else albumId - - var album: Album? = null - - // Sometime in compilation albums, the individual tracks won't have an Artist id - // In this case, try to get the ArtistId of the album... - if (artistId == null && albumId != null) { - album = musicService.getAlbum(albumId, null, false) - artistId = album?.artistId - } - - // Cache the artist - if (artistId != null) - cacheArtist(onlineDB, offlineDB, artistId) - - // Now cache the album - if (albumId != null) { - if (album == null) { - // This is a cached call - val albums = musicService.getAlbumsOfArtist(artistId!!, null, false) - album = albums.find { it.id == albumId } - } - - if (album != null) { - // Often the album entity returned from the server won't have the path set. - if (album.path.isNullOrEmpty()) album.path = FileUtil.getParentPath(path) - - offlineDB.albumDao().insert(album) - - // If the album is a Compilation, also cache the Album artist - if (album.artistId != null && album.artistId != artistId) - cacheArtist(onlineDB, offlineDB, album.artistId!!) - } - } - - // Now cache the track data - offlineDB.trackDao().insert(this) - - // Download the largest size that we can display in the UI - imageLoaderProvider.getImageLoader().cacheCoverArt(this) - } - - private fun cacheArtist(onlineDB: MetaDatabase, offlineDB: MetaDatabase, artistId: String) { - var artist: Artist? = onlineDB.artistDao().get(artistId) - - // If we are downloading a new album, and the user has not visited the Artists list - // recently, then the artist won't be in the database. - if (artist == null) { - val artists: List = musicService.getArtists(true) - artist = artists.find { - it.id == artistId - } - } - - // If we have found an artist, cache it. - if (artist != null) { - offlineDB.artistDao().insert(artist) - } - } - - @Throws(IOException::class) - fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long { - var bytesCopied: Long = 0 - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = read(buffer) - while (!isCancelled && bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - onCopy(bytesCopied) - bytes = read(buffer) - } - return bytesCopied - } - } - - private class DownloadableTrack( - val track: Track, - val pinned: Boolean, - var tryCount: Int, - var priority: Int - ) : Identifiable { - val pinnedFile = track.getPinnedFile() - val partialFile = track.getPartialFile() - val completeFile = track.getCompleteFile() - override val id: String - get() = track.id - - override fun compareTo(other: Identifiable) = compareTo(other as DownloadableTrack) - fun compareTo(other: DownloadableTrack): Int { - return priority.compareTo(other.priority) - } - } -} - -enum class DownloadStatus { - IDLE, QUEUED, DOWNLOADING, RETRYING, FAILED, CANCELLED, DONE, PINNED, UNKNOWN -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index 801e9643..80a7b6f2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -8,7 +8,6 @@ package org.moire.ultrasonic.service import android.content.ComponentName import android.content.Context -import android.content.Intent import android.os.Handler import android.os.Looper import android.widget.Toast @@ -18,7 +17,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -import androidx.media3.common.Player.REPEAT_MODE_OFF import androidx.media3.common.Timeline import androidx.media3.session.MediaController import androidx.media3.session.SessionResult @@ -62,7 +60,6 @@ private const val VOLUME_DELTA = 0.05f class MediaPlayerController( private val playbackStateSerializer: PlaybackStateSerializer, private val externalStorageMonitor: ExternalStorageMonitor, - private val downloader: Downloader, val context: Context ) : KoinComponent { private val activeServerProvider: ActiveServerProvider by inject() @@ -205,23 +202,6 @@ class MediaPlayerController( Timber.i("MediaPlayerController started") } - fun onDestroy() { - if (!created) return - - // First stop listening to events - rxBusSubscription.dispose() - controller?.removeListener(listeners) - releaseController() - - // Shutdown the rest - val context = UApp.applicationContext() - externalStorageMonitor.onDestroy() - context.stopService(Intent(context, DownloadService::class.java)) - downloader.onDestroy() - created = false - Timber.i("MediaPlayerController destroyed") - } - private fun playerStateChangedHandler() { val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return @@ -276,6 +256,20 @@ class MediaPlayerController( UltrasonicAppWidgetProvider4X4.instance?.notifyChange(context, song, isPlaying, true) } + fun onDestroy() { + if (!created) return + + // First stop listening to events + rxBusSubscription.dispose() + releaseController() + + // Shutdown the rest + externalStorageMonitor.onDestroy() + DownloadService.requestStop() + created = false + Timber.i("MediaPlayerController destroyed") + } + @Synchronized fun restore( state: PlaybackState, @@ -413,7 +407,7 @@ class MediaPlayerController( fun downloadBackground(songs: List?, save: Boolean) { if (songs == null) return val filteredSongs = songs.filterNotNull() - downloader.downloadBackground(filteredSongs, save) + DownloadService.download(filteredSongs, save) } @set:Synchronized @@ -421,8 +415,6 @@ class MediaPlayerController( get() = controller?.shuffleModeEnabled == true set(enabled) { controller?.shuffleModeEnabled = enabled - // Changing Shuffle may change the playlist, so the next tracks may need to be downloaded - downloader.checkDownloads() } @Synchronized @@ -459,21 +451,15 @@ class MediaPlayerController( } } - @Synchronized - fun clearDownloads() { - downloader.clearActiveDownloads() - downloader.clearBackground() - } - @Synchronized fun removeIncompleteTracksFromPlaylist() { val list = playlist.toList() var removed = 0 for ((index, item) in list.withIndex()) { - val state = downloader.getDownloadState(item.toTrack()) + val state = DownloadService.getDownloadState(item.toTrack()) // The track is not downloaded, remove it - if (state != DownloadStatus.DONE && state != DownloadStatus.PINNED) { + if (state != DownloadState.DONE && state != DownloadState.PINNED) { removeFromPlaylist(index - removed) removed++ } @@ -503,7 +489,7 @@ class MediaPlayerController( // TODO: Make it require not null fun delete(tracks: List) { for (track in tracks.filterNotNull()) { - downloader.delete(track) + DownloadService.delete(track) } } @@ -511,7 +497,7 @@ class MediaPlayerController( // TODO: Make it require not null fun unpin(tracks: List) { for (track in tracks.filterNotNull()) { - downloader.unpin(track) + DownloadService.unpin(track) } } @@ -568,7 +554,7 @@ class MediaPlayerController( val currentPlaylist = playlist val currentIndex = controller?.currentMediaItemIndex ?: 0 val currentPosition = controller?.currentPosition ?: 0 - downloader.clearActiveDownloads() + DownloadService.requestStop() controller?.pause() controller?.stop() val oldController = controller @@ -741,7 +727,7 @@ class MediaPlayerController( val playlist: List get() { - return getPlayList(false) + return Util.getPlayListFromTimeline(controller?.currentTimeline, false) } fun getMediaItemAt(index: Int): MediaItem? { @@ -750,38 +736,12 @@ class MediaPlayerController( val playlistInPlayOrder: List get() { - return getPlayList(controller?.shuffleModeEnabled ?: false) + return Util.getPlayListFromTimeline( + controller?.currentTimeline, + controller?.shuffleModeEnabled ?: false + ) } - fun getNextPlaylistItemsInPlayOrder(count: Int? = null): List { - return getPlayList( - controller?.shuffleModeEnabled ?: false, - controller?.currentMediaItemIndex, - count - ) - } - - private fun getPlayList( - shuffle: Boolean, - firstIndex: Int? = null, - count: Int? = null - ): List { - if (controller?.currentTimeline == null) return emptyList() - if (controller!!.currentTimeline.windowCount < 1) return emptyList() - val timeline = controller!!.currentTimeline - - val playlist: MutableList = mutableListOf() - var i = firstIndex ?: timeline.getFirstWindowIndex(false) - if (i == C.INDEX_UNSET) return emptyList() - - while (i != C.INDEX_UNSET && (count != playlist.count())) { - val window = timeline.getWindow(i, Timeline.Window()) - playlist.add(window.mediaItem) - i = timeline.getNextWindowIndex(i, REPEAT_MODE_OFF, shuffle) - } - return playlist - } - val playListDuration: Long get() = playlist.fold(0) { i, file -> i + (file.mediaMetadata.extras?.getInt("duration") ?: 0) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt index e97b1049..cc30ed5e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -95,7 +95,7 @@ class RxBus { data class TrackDownloadState( val id: String, - val state: DownloadStatus, + val state: DownloadState, val progress: Int? ) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index b2256699..47c3853e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -33,6 +33,10 @@ import android.view.Gravity import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.annotation.AnyRes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline import java.io.Closeable import java.io.UnsupportedEncodingException import java.security.MessageDigest @@ -656,6 +660,27 @@ object Util { ) } + fun getPlayListFromTimeline( + timeline: Timeline?, + shuffle: Boolean, + firstIndex: Int? = null, + count: Int? = null + ): List { + if (timeline == null) return emptyList() + if (timeline.windowCount < 1) return emptyList() + + val playlist: MutableList = mutableListOf() + var i = firstIndex ?: timeline.getFirstWindowIndex(false) + if (i == C.INDEX_UNSET) return emptyList() + + while (i != C.INDEX_UNSET && (count != playlist.count())) { + val window = timeline.getWindow(i, Timeline.Window()) + playlist.add(window.mediaItem) + i = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffle) + } + return playlist + } + fun getPendingIntentToShowPlayer(context: Context): PendingIntent { val intent = Intent(context, NavigationActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)