Merge branch 'refactor_downloader' into 'develop'

Refactor DownloadService

Closes #815 and #790

See merge request ultrasonic/ultrasonic!814
This commit is contained in:
Nite 2022-09-20 20:07:00 +00:00
commit 97ebed3d7a
14 changed files with 715 additions and 774 deletions

View File

@ -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)
}
}

View File

@ -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()) }
}

View File

@ -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<Track>() {
}
class DownloadListModel(application: Application) : GenericListModel(application) {
private val downloader by inject<Downloader>()
fun getList(): LiveData<List<Track>> {
return downloader.observableDownloads
return DownloadService.observableDownloads
}
}

View File

@ -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)

View File

@ -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<MusicDirectory.Child>() {
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<MusicDirectory.Child>() {
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<MusicDirectory.Child>() {
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) {

View File

@ -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)

View File

@ -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<Downloader>()
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<DownloadableTrack>) {
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<Track> = 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<DownloadService>? = null
@Volatile
private var instance: DownloadService? = null
private val instanceLock = Any()
private val startedSemaphore: Semaphore = Semaphore(0)
private val downloadQueue = PriorityQueue<DownloadableTrack>()
private val activelyDownloading = mutableMapOf<DownloadableTrack, DownloadTask>()
private val failedList = mutableListOf<DownloadableTrack>()
@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<List<Track>>()
@JvmStatic
val runningInstance: DownloadService?
get() {
synchronized(instanceLock) { return instance }
}
private var backgroundPriorityCounter = 100
@JvmStatic
fun executeOnStartedDownloadService(
taskToExecute: (DownloadService) -> Unit
fun download(
tracks: List<Track>,
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<Track>
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<DownloadableTrack>) {
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<DownloadService> {
val future = SettableFuture.create<DownloadService>()
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()
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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<Artist> = 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
}
}

View File

@ -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)
}
}

View File

@ -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<DownloadableTrack>()
private val activelyDownloading = mutableMapOf<DownloadableTrack, DownloadTask>()
private val failedList = mutableListOf<DownloadableTrack>()
// 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<List<Track>>()
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<Track>
get() {
val temp: MutableList<Track> = 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<Track>, 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<Artist> = 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
}

View File

@ -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<Track?>?, 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<Track?>) {
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<Track?>) {
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<MediaItem>
get() {
return getPlayList(false)
return Util.getPlayListFromTimeline(controller?.currentTimeline, false)
}
fun getMediaItemAt(index: Int): MediaItem? {
@ -750,38 +736,12 @@ class MediaPlayerController(
val playlistInPlayOrder: List<MediaItem>
get() {
return getPlayList(controller?.shuffleModeEnabled ?: false)
return Util.getPlayListFromTimeline(
controller?.currentTimeline,
controller?.shuffleModeEnabled ?: false
)
}
fun getNextPlaylistItemsInPlayOrder(count: Int? = null): List<MediaItem> {
return getPlayList(
controller?.shuffleModeEnabled ?: false,
controller?.currentMediaItemIndex,
count
)
}
private fun getPlayList(
shuffle: Boolean,
firstIndex: Int? = null,
count: Int? = null
): List<MediaItem> {
if (controller?.currentTimeline == null) return emptyList()
if (controller!!.currentTimeline.windowCount < 1) return emptyList()
val timeline = controller!!.currentTimeline
val playlist: MutableList<MediaItem> = 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)

View File

@ -95,7 +95,7 @@ class RxBus {
data class TrackDownloadState(
val id: String,
val state: DownloadStatus,
val state: DownloadState,
val progress: Int?
)

View File

@ -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<MediaItem> {
if (timeline == null) return emptyList()
if (timeline.windowCount < 1) return emptyList()
val playlist: MutableList<MediaItem> = 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)