mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-27 14:12:14 +03:00
Merge branch 'refactor_downloader' into 'develop'
Refactor DownloadService Closes #815 and #790 See merge request ultrasonic/ultrasonic!814
This commit is contained in:
commit
97ebed3d7a
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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()) }
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
|
@ -95,7 +95,7 @@ class RxBus {
|
||||
|
||||
data class TrackDownloadState(
|
||||
val id: String,
|
||||
val state: DownloadStatus,
|
||||
val state: DownloadState,
|
||||
val progress: Int?
|
||||
)
|
||||
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user