mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-28 22:52:15 +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 com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.service.DownloadStatus
|
import org.moire.ultrasonic.service.DownloadService
|
||||||
import org.moire.ultrasonic.service.Downloader
|
import org.moire.ultrasonic.service.DownloadState
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
import org.moire.ultrasonic.service.plusAssign
|
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 {
|
class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable, KoinComponent {
|
||||||
|
|
||||||
private val downloader: Downloader by inject()
|
|
||||||
|
|
||||||
var entry: Track? = null
|
var entry: Track? = null
|
||||||
private set
|
private set
|
||||||
var check: CheckedTextView = view.findViewById(R.id.song_check)
|
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 isMaximized = false
|
||||||
private var cachedStatus = DownloadStatus.UNKNOWN
|
private var cachedStatus = DownloadState.UNKNOWN
|
||||||
private var isPlayingCached = false
|
private var isPlayingCached = false
|
||||||
|
|
||||||
private var rxBusSubscription: CompositeDisposable? = null
|
private var rxBusSubscription: CompositeDisposable? = null
|
||||||
@ -98,7 +95,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
|||||||
setupStarButtons(song, useFiveStarRating)
|
setupStarButtons(song, useFiveStarRating)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus(downloader.getDownloadState(song), null)
|
updateStatus(DownloadService.getDownloadState(song), null)
|
||||||
|
|
||||||
if (useFiveStarRating) {
|
if (useFiveStarRating) {
|
||||||
setFiveStars(entry?.userRating ?: 0)
|
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
|
progressIndicator.progress = progress ?: 0
|
||||||
|
|
||||||
if (status == cachedStatus) return
|
if (status == cachedStatus) return
|
||||||
cachedStatus = status
|
cachedStatus = status
|
||||||
|
|
||||||
when (status) {
|
when (status) {
|
||||||
DownloadStatus.DONE -> {
|
DownloadState.DONE -> {
|
||||||
showStatusImage(imageHelper.downloadedImage)
|
showStatusImage(imageHelper.downloadedImage)
|
||||||
}
|
}
|
||||||
DownloadStatus.PINNED -> {
|
DownloadState.PINNED -> {
|
||||||
showStatusImage(imageHelper.pinImage)
|
showStatusImage(imageHelper.pinImage)
|
||||||
}
|
}
|
||||||
DownloadStatus.FAILED,
|
DownloadState.FAILED -> {
|
||||||
DownloadStatus.CANCELLED -> {
|
|
||||||
showStatusImage(imageHelper.errorImage)
|
showStatusImage(imageHelper.errorImage)
|
||||||
}
|
}
|
||||||
DownloadStatus.DOWNLOADING -> {
|
DownloadState.DOWNLOADING -> {
|
||||||
showProgress()
|
showProgress()
|
||||||
}
|
}
|
||||||
DownloadStatus.RETRYING,
|
DownloadState.RETRYING,
|
||||||
DownloadStatus.QUEUED -> {
|
DownloadState.QUEUED -> {
|
||||||
showIndefiniteProgress()
|
showIndefiniteProgress()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
// This handles CANCELLED too.
|
||||||
|
// Usually it means no error, just that the track wasn't downloaded
|
||||||
showStatusImage(null)
|
showStatusImage(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package org.moire.ultrasonic.di
|
package org.moire.ultrasonic.di
|
||||||
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.moire.ultrasonic.service.Downloader
|
|
||||||
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
import org.moire.ultrasonic.service.MediaPlayerLifecycleSupport
|
||||||
@ -14,8 +13,7 @@ val mediaPlayerModule = module {
|
|||||||
single { MediaPlayerLifecycleSupport() }
|
single { MediaPlayerLifecycleSupport() }
|
||||||
single { PlaybackStateSerializer() }
|
single { PlaybackStateSerializer() }
|
||||||
single { ExternalStorageMonitor() }
|
single { ExternalStorageMonitor() }
|
||||||
single { Downloader(get()) }
|
|
||||||
|
|
||||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
// 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.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import org.koin.core.component.inject
|
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.adapters.TrackViewBinder
|
import org.moire.ultrasonic.adapters.TrackViewBinder
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.model.GenericListModel
|
import org.moire.ultrasonic.model.GenericListModel
|
||||||
import org.moire.ultrasonic.service.Downloader
|
import org.moire.ultrasonic.service.DownloadService
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,9 +81,7 @@ class DownloadsFragment : MultiListFragment<Track>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DownloadListModel(application: Application) : GenericListModel(application) {
|
class DownloadListModel(application: Application) : GenericListModel(application) {
|
||||||
private val downloader by inject<Downloader>()
|
|
||||||
|
|
||||||
fun getList(): LiveData<List<Track>> {
|
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.ActiveServerProvider.Companion.OFFLINE_DB_ID
|
||||||
import org.moire.ultrasonic.data.ServerSetting
|
import org.moire.ultrasonic.data.ServerSetting
|
||||||
import org.moire.ultrasonic.model.ServerSettingsModel
|
import org.moire.ultrasonic.model.ServerSettingsModel
|
||||||
|
import org.moire.ultrasonic.service.DownloadService
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.util.ErrorDialog
|
import org.moire.ultrasonic.util.ErrorDialog
|
||||||
import org.moire.ultrasonic.util.Util
|
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 we are coming from offline there is no need to clear downloads etc.
|
||||||
if (oldId != OFFLINE_DB_ID) {
|
if (oldId != OFFLINE_DB_ID) {
|
||||||
controller.removeIncompleteTracksFromPlaylist()
|
controller.removeIncompleteTracksFromPlaylist()
|
||||||
controller.clearDownloads()
|
DownloadService.requestStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
ActiveServerProvider.setActiveServerById(id)
|
ActiveServerProvider.setActiveServerById(id)
|
||||||
|
@ -37,8 +37,8 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
|||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||||
import org.moire.ultrasonic.model.TrackCollectionModel
|
import org.moire.ultrasonic.model.TrackCollectionModel
|
||||||
import org.moire.ultrasonic.service.DownloadStatus
|
import org.moire.ultrasonic.service.DownloadService
|
||||||
import org.moire.ultrasonic.service.Downloader
|
import org.moire.ultrasonic.service.DownloadState
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
import org.moire.ultrasonic.service.plusAssign
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
@ -80,7 +80,6 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||||||
private var shareButton: MenuItem? = null
|
private var shareButton: MenuItem? = null
|
||||||
|
|
||||||
internal val mediaPlayerController: MediaPlayerController by inject()
|
internal val mediaPlayerController: MediaPlayerController by inject()
|
||||||
internal val downloader: Downloader by inject()
|
|
||||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||||
private val shareHandler: ShareHandler by inject()
|
private val shareHandler: ShareHandler by inject()
|
||||||
internal var cancellationToken: CancellationToken? = null
|
internal var cancellationToken: CancellationToken? = null
|
||||||
@ -375,30 +374,24 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||||||
var unpinEnabled = false
|
var unpinEnabled = false
|
||||||
var deleteEnabled = false
|
var deleteEnabled = false
|
||||||
var downloadEnabled = false
|
var downloadEnabled = false
|
||||||
var isNotInProgress = true
|
|
||||||
val multipleSelection = viewAdapter.hasMultipleSelection()
|
val multipleSelection = viewAdapter.hasMultipleSelection()
|
||||||
|
|
||||||
var pinnedCount = 0
|
var pinnedCount = 0
|
||||||
|
|
||||||
for (song in selection) {
|
for (song in selection) {
|
||||||
val state = downloader.getDownloadState(song)
|
val state = DownloadService.getDownloadState(song)
|
||||||
when (state) {
|
when (state) {
|
||||||
DownloadStatus.DONE -> {
|
DownloadState.DONE -> {
|
||||||
deleteEnabled = true
|
deleteEnabled = true
|
||||||
}
|
}
|
||||||
DownloadStatus.PINNED -> {
|
DownloadState.PINNED -> {
|
||||||
deleteEnabled = true
|
deleteEnabled = true
|
||||||
pinnedCount++
|
pinnedCount++
|
||||||
unpinEnabled = true
|
unpinEnabled = true
|
||||||
}
|
}
|
||||||
DownloadStatus.IDLE, DownloadStatus.FAILED -> {
|
DownloadState.IDLE, DownloadState.FAILED -> {
|
||||||
downloadEnabled = true
|
downloadEnabled = true
|
||||||
}
|
}
|
||||||
DownloadStatus.DOWNLOADING,
|
|
||||||
DownloadStatus.QUEUED,
|
|
||||||
DownloadStatus.RETRYING -> {
|
|
||||||
isNotInProgress = false
|
|
||||||
}
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -406,11 +399,10 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||||||
playNowButton?.isVisible = enabled
|
playNowButton?.isVisible = enabled
|
||||||
playNextButton?.isVisible = enabled && multipleSelection
|
playNextButton?.isVisible = enabled && multipleSelection
|
||||||
playLastButton?.isVisible = enabled && multipleSelection
|
playLastButton?.isVisible = enabled && multipleSelection
|
||||||
pinButton?.isVisible =
|
pinButton?.isVisible = enabled && !isOffline() && selection.size > pinnedCount
|
||||||
isNotInProgress && enabled && !isOffline() && selection.size > pinnedCount
|
unpinButton?.isVisible = enabled && unpinEnabled
|
||||||
unpinButton?.isVisible = isNotInProgress && enabled && unpinEnabled
|
downloadButton?.isVisible = enabled && downloadEnabled && !isOffline()
|
||||||
downloadButton?.isVisible = isNotInProgress && enabled && downloadEnabled && !isOffline()
|
deleteButton?.isVisible = enabled && deleteEnabled
|
||||||
deleteButton?.isVisible = isNotInProgress && enabled && deleteEnabled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadBackground(save: Boolean) {
|
private fun downloadBackground(save: Boolean) {
|
||||||
|
@ -12,6 +12,8 @@ import android.os.Build
|
|||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.C.USAGE_MEDIA
|
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.DataSource
|
||||||
import androidx.media3.datasource.ResolvingDataSource
|
import androidx.media3.datasource.ResolvingDataSource
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
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.app.UApp
|
||||||
import org.moire.ultrasonic.audiofx.EqualizerController
|
import org.moire.ultrasonic.audiofx.EqualizerController
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
|
import org.moire.ultrasonic.service.DownloadService
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
import org.moire.ultrasonic.service.plusAssign
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
|
import org.moire.ultrasonic.util.Util
|
||||||
|
import org.moire.ultrasonic.util.toTrack
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class PlaybackService : MediaLibraryService(), KoinComponent {
|
class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||||
@ -74,6 +79,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
// Broadcast that the service is being shutdown
|
// Broadcast that the service is being shutdown
|
||||||
RxBus.stopServiceCommandPublisher.onNext(Unit)
|
RxBus.stopServiceCommandPublisher.onNext(Unit)
|
||||||
|
|
||||||
|
player.removeListener(listener)
|
||||||
player.release()
|
player.release()
|
||||||
mediaLibrarySession.release()
|
mediaLibrarySession.release()
|
||||||
rxBusSubscription.dispose()
|
rxBusSubscription.dispose()
|
||||||
@ -153,9 +159,31 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
onDestroy()
|
onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
player.addListener(listener)
|
||||||
isStarted = true
|
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 {
|
private fun getPendingIntentForContent(): PendingIntent {
|
||||||
val intent = Intent(this, NavigationActivity::class.java)
|
val intent = Intent(this, NavigationActivity::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
@ -12,20 +12,40 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.os.Looper
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import java.util.concurrent.Semaphore
|
import androidx.lifecycle.MutableLiveData
|
||||||
import java.util.concurrent.TimeUnit
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import org.koin.android.ext.android.inject
|
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.R
|
||||||
import org.moire.ultrasonic.app.UApp
|
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.SimpleServiceBinder
|
||||||
|
import org.moire.ultrasonic.util.Storage
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
import timber.log.Timber
|
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
|
* 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
|
* 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 binder: IBinder = SimpleServiceBinder(this)
|
||||||
|
|
||||||
private val downloader by inject<Downloader>()
|
|
||||||
|
|
||||||
private var mediaSession: MediaSessionCompat? = null
|
|
||||||
|
|
||||||
private var isInForeground = false
|
private var isInForeground = false
|
||||||
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
|
private var isShuttingDown = false
|
||||||
|
private var retrying = false
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder {
|
override fun onBind(intent: Intent): IBinder {
|
||||||
return binder
|
return binder
|
||||||
@ -54,9 +74,13 @@ class DownloadService : Service() {
|
|||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
updateNotification()
|
updateNotification()
|
||||||
|
|
||||||
instance = this
|
if (wifiLock == null) {
|
||||||
startedSemaphore.release()
|
wifiLock = Util.createWifiLock(toString())
|
||||||
Timber.i("DownloadService initiated")
|
wifiLock?.acquire()
|
||||||
|
}
|
||||||
|
|
||||||
|
startFuture?.set(this)
|
||||||
|
Timber.i("DownloadService created")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||||
@ -66,22 +90,116 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
instance = null
|
startFuture = null
|
||||||
try {
|
|
||||||
downloader.stop()
|
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")
|
Timber.i("DownloadService destroyed")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyDownloaderStopped() {
|
fun addTracks(tracks: List<DownloadableTrack>) {
|
||||||
Timber.i("DownloadService stopped")
|
downloadQueue.addAll(tracks)
|
||||||
isInForeground = false
|
tracks.forEach { postState(it.track, DownloadState.QUEUED) }
|
||||||
stopForeground(true)
|
processNextTracks()
|
||||||
stopSelf()
|
}
|
||||||
|
|
||||||
|
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() {
|
private fun createNotificationChannel() {
|
||||||
@ -143,75 +261,170 @@ class DownloadService : Service() {
|
|||||||
*/
|
*/
|
||||||
@Suppress("SpreadOperator")
|
@Suppress("SpreadOperator")
|
||||||
private fun buildForegroundNotification(): Notification {
|
private fun buildForegroundNotification(): Notification {
|
||||||
|
notificationBuilder.setContentTitle(getString(R.string.notification_downloading_title))
|
||||||
if (downloader.started) {
|
|
||||||
// No song is playing, but Ultrasonic is downloading files
|
|
||||||
notificationBuilder.setContentTitle(
|
|
||||||
getString(R.string.notification_downloading_title)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return notificationBuilder.build()
|
return notificationBuilder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("MagicNumber")
|
@Suppress("MagicNumber")
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic"
|
private var startFuture: SettableFuture<DownloadService>? = null
|
||||||
private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service"
|
|
||||||
private const val NOTIFICATION_ID = 3033
|
|
||||||
|
|
||||||
@Volatile
|
private val downloadQueue = PriorityQueue<DownloadableTrack>()
|
||||||
private var instance: DownloadService? = null
|
private val activelyDownloading = mutableMapOf<DownloadableTrack, DownloadTask>()
|
||||||
private val instanceLock = Any()
|
private val failedList = mutableListOf<DownloadableTrack>()
|
||||||
private val startedSemaphore: Semaphore = Semaphore(0)
|
|
||||||
|
|
||||||
@JvmStatic
|
// The generic list models expect a LiveData, so even though we are using Rx for many events
|
||||||
fun getInstance(): DownloadService? {
|
// surrounding playback the list of Downloads is published as LiveData.
|
||||||
val context = UApp.applicationContext()
|
val observableDownloads = MutableLiveData<List<Track>>()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
private var backgroundPriorityCounter = 100
|
||||||
val runningInstance: DownloadService?
|
|
||||||
get() {
|
|
||||||
synchronized(instanceLock) { return instance }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
fun download(
|
||||||
fun executeOnStartedDownloadService(
|
tracks: List<Track>,
|
||||||
taskToExecute: (DownloadService) -> Unit
|
save: Boolean,
|
||||||
|
isHighPriority: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
// First handle and filter out those tracks that are already completed
|
||||||
val t: Thread = object : Thread() {
|
var filteredTracks: List<Track>
|
||||||
override fun run() {
|
if (save) {
|
||||||
val instance = getInstance()
|
tracks.filter { Storage.isPathExists(it.getCompleteFile()) }.forEach { track ->
|
||||||
if (instance == null) {
|
Storage.getFromPath(track.getCompleteFile())?.let {
|
||||||
Timber.e("ExecuteOnStarted.. failed to get a DownloadService instance!")
|
Storage.rename(it, track.getPinnedFile())
|
||||||
return
|
postState(track, DownloadState.PINNED)
|
||||||
} else {
|
|
||||||
taskToExecute(instance)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@ -18,7 +17,6 @@ import androidx.media3.common.MediaItem
|
|||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
|
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.common.Timeline
|
||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import androidx.media3.session.SessionResult
|
import androidx.media3.session.SessionResult
|
||||||
@ -62,7 +60,6 @@ private const val VOLUME_DELTA = 0.05f
|
|||||||
class MediaPlayerController(
|
class MediaPlayerController(
|
||||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||||
private val downloader: Downloader,
|
|
||||||
val context: Context
|
val context: Context
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
private val activeServerProvider: ActiveServerProvider by inject()
|
private val activeServerProvider: ActiveServerProvider by inject()
|
||||||
@ -205,23 +202,6 @@ class MediaPlayerController(
|
|||||||
Timber.i("MediaPlayerController started")
|
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() {
|
private fun playerStateChangedHandler() {
|
||||||
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
|
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
|
||||||
|
|
||||||
@ -276,6 +256,20 @@ class MediaPlayerController(
|
|||||||
UltrasonicAppWidgetProvider4X4.instance?.notifyChange(context, song, isPlaying, true)
|
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
|
@Synchronized
|
||||||
fun restore(
|
fun restore(
|
||||||
state: PlaybackState,
|
state: PlaybackState,
|
||||||
@ -413,7 +407,7 @@ class MediaPlayerController(
|
|||||||
fun downloadBackground(songs: List<Track?>?, save: Boolean) {
|
fun downloadBackground(songs: List<Track?>?, save: Boolean) {
|
||||||
if (songs == null) return
|
if (songs == null) return
|
||||||
val filteredSongs = songs.filterNotNull()
|
val filteredSongs = songs.filterNotNull()
|
||||||
downloader.downloadBackground(filteredSongs, save)
|
DownloadService.download(filteredSongs, save)
|
||||||
}
|
}
|
||||||
|
|
||||||
@set:Synchronized
|
@set:Synchronized
|
||||||
@ -421,8 +415,6 @@ class MediaPlayerController(
|
|||||||
get() = controller?.shuffleModeEnabled == true
|
get() = controller?.shuffleModeEnabled == true
|
||||||
set(enabled) {
|
set(enabled) {
|
||||||
controller?.shuffleModeEnabled = enabled
|
controller?.shuffleModeEnabled = enabled
|
||||||
// Changing Shuffle may change the playlist, so the next tracks may need to be downloaded
|
|
||||||
downloader.checkDownloads()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@ -459,21 +451,15 @@ class MediaPlayerController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun clearDownloads() {
|
|
||||||
downloader.clearActiveDownloads()
|
|
||||||
downloader.clearBackground()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun removeIncompleteTracksFromPlaylist() {
|
fun removeIncompleteTracksFromPlaylist() {
|
||||||
val list = playlist.toList()
|
val list = playlist.toList()
|
||||||
var removed = 0
|
var removed = 0
|
||||||
for ((index, item) in list.withIndex()) {
|
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
|
// The track is not downloaded, remove it
|
||||||
if (state != DownloadStatus.DONE && state != DownloadStatus.PINNED) {
|
if (state != DownloadState.DONE && state != DownloadState.PINNED) {
|
||||||
removeFromPlaylist(index - removed)
|
removeFromPlaylist(index - removed)
|
||||||
removed++
|
removed++
|
||||||
}
|
}
|
||||||
@ -503,7 +489,7 @@ class MediaPlayerController(
|
|||||||
// TODO: Make it require not null
|
// TODO: Make it require not null
|
||||||
fun delete(tracks: List<Track?>) {
|
fun delete(tracks: List<Track?>) {
|
||||||
for (track in tracks.filterNotNull()) {
|
for (track in tracks.filterNotNull()) {
|
||||||
downloader.delete(track)
|
DownloadService.delete(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -511,7 +497,7 @@ class MediaPlayerController(
|
|||||||
// TODO: Make it require not null
|
// TODO: Make it require not null
|
||||||
fun unpin(tracks: List<Track?>) {
|
fun unpin(tracks: List<Track?>) {
|
||||||
for (track in tracks.filterNotNull()) {
|
for (track in tracks.filterNotNull()) {
|
||||||
downloader.unpin(track)
|
DownloadService.unpin(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -568,7 +554,7 @@ class MediaPlayerController(
|
|||||||
val currentPlaylist = playlist
|
val currentPlaylist = playlist
|
||||||
val currentIndex = controller?.currentMediaItemIndex ?: 0
|
val currentIndex = controller?.currentMediaItemIndex ?: 0
|
||||||
val currentPosition = controller?.currentPosition ?: 0
|
val currentPosition = controller?.currentPosition ?: 0
|
||||||
downloader.clearActiveDownloads()
|
DownloadService.requestStop()
|
||||||
controller?.pause()
|
controller?.pause()
|
||||||
controller?.stop()
|
controller?.stop()
|
||||||
val oldController = controller
|
val oldController = controller
|
||||||
@ -741,7 +727,7 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
val playlist: List<MediaItem>
|
val playlist: List<MediaItem>
|
||||||
get() {
|
get() {
|
||||||
return getPlayList(false)
|
return Util.getPlayListFromTimeline(controller?.currentTimeline, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMediaItemAt(index: Int): MediaItem? {
|
fun getMediaItemAt(index: Int): MediaItem? {
|
||||||
@ -750,38 +736,12 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
val playlistInPlayOrder: List<MediaItem>
|
val playlistInPlayOrder: List<MediaItem>
|
||||||
get() {
|
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
|
val playListDuration: Long
|
||||||
get() = playlist.fold(0) { i, file ->
|
get() = playlist.fold(0) { i, file ->
|
||||||
i + (file.mediaMetadata.extras?.getInt("duration") ?: 0)
|
i + (file.mediaMetadata.extras?.getInt("duration") ?: 0)
|
||||||
|
@ -95,7 +95,7 @@ class RxBus {
|
|||||||
|
|
||||||
data class TrackDownloadState(
|
data class TrackDownloadState(
|
||||||
val id: String,
|
val id: String,
|
||||||
val state: DownloadStatus,
|
val state: DownloadState,
|
||||||
val progress: Int?
|
val progress: Int?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -33,6 +33,10 @@ import android.view.Gravity
|
|||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.annotation.AnyRes
|
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.Closeable
|
||||||
import java.io.UnsupportedEncodingException
|
import java.io.UnsupportedEncodingException
|
||||||
import java.security.MessageDigest
|
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 {
|
fun getPendingIntentToShowPlayer(context: Context): PendingIntent {
|
||||||
val intent = Intent(context, NavigationActivity::class.java)
|
val intent = Intent(context, NavigationActivity::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user