Fixed retrieving MediaItems with the AutoMediaBrowser

This commit is contained in:
Nite 2022-07-29 17:59:34 +00:00 committed by birdbird
parent 8d0ff385af
commit 413626ac5c
33 changed files with 1190 additions and 1189 deletions

View File

@ -25,7 +25,7 @@ viewModelKtx = "2.4.1"
retrofit = "2.9.0" retrofit = "2.9.0"
jackson = "2.10.1" jackson = "2.10.1"
okhttp = "4.9.1" okhttp = "4.10.0"
koin = "3.0.2" koin = "3.0.2"
picasso = "2.71828" picasso = "2.71828"

View File

@ -19,7 +19,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location <location
file="src/main/res/values/strings.xml" file="src/main/res/values/strings.xml"
line="154" line="156"
column="5"/> column="5"/>
</issue> </issue>
@ -59,6 +59,17 @@
column="10"/> column="10"/>
</issue> </issue>
<issue
id="ExportedContentProvider"
message="Exported content providers can provide access to potentially sensitive data"
errorLine1=" &lt;provider"
errorLine2=" ~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="160"
column="10"/>
</issue>
<issue <issue
id="ExportedReceiver" id="ExportedReceiver"
message="Exported receiver does not require permission" message="Exported receiver does not require permission"
@ -213,17 +224,6 @@
column="1"/> column="1"/>
</issue> </issue>
<issue
id="UnusedResources"
message="The resource `R.drawable.ic_menu_close` appears to be unused"
errorLine1="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
errorLine2="^">
<location
file="src/main/res/drawable/ic_menu_close.xml"
line="1"
column="1"/>
</issue>
<issue <issue
id="UnusedResources" id="UnusedResources"
message="The resource `R.drawable.ic_menu_forward` appears to be unused" message="The resource `R.drawable.ic_menu_forward` appears to be unused"

View File

@ -157,6 +157,10 @@
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider" android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"
android:exported="true" /> android:exported="true" />
<provider
android:name=".provider.AlbumArtContentProvider"
android:authorities="org.moire.ultrasonic.provider.AlbumArtContentProvider"
android:exported="true" />
</application> </application>
</manifest> </manifest>

View File

@ -2,6 +2,7 @@ package org.moire.ultrasonic.service;
import timber.log.Timber; import timber.log.Timber;
import org.moire.ultrasonic.data.ActiveServerProvider; import org.moire.ultrasonic.data.ActiveServerProvider;
import org.moire.ultrasonic.domain.Track;
/** /**
* Scrobbles played songs to Last.fm. * Scrobbles played songs to Last.fm.
@ -14,12 +15,11 @@ public class Scrobbler
private String lastSubmission; private String lastSubmission;
private String lastNowPlaying; private String lastNowPlaying;
public void scrobble(final DownloadFile song, final boolean submission) public void scrobble(final Track song, final boolean submission)
{ {
if (song == null || !ActiveServerProvider.Companion.isScrobblingEnabled()) return; if (song == null || !ActiveServerProvider.Companion.isScrobblingEnabled()) return;
final String id = song.getTrack().getId(); final String id = song.getId();
if (id == null) return;
// Avoid duplicate registrations. // Avoid duplicate registrations.
if (submission && id.equals(lastSubmission)) return; if (submission && id.equals(lastSubmission)) return;

View File

@ -9,16 +9,13 @@ import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.drakeet.multitype.ItemViewBinder import com.drakeet.multitype.ItemViewBinder
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.domain.Identifiable import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.Downloader
class TrackViewBinder( class TrackViewBinder(
val onItemClick: (DownloadFile, Int) -> Unit, val onItemClick: (Track, Int) -> Unit,
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null, val onContextMenuClick: ((MenuItem, Track) -> Boolean)? = null,
val checkable: Boolean, val checkable: Boolean,
val draggable: Boolean, val draggable: Boolean,
context: Context, context: Context,
@ -31,7 +28,6 @@ class TrackViewBinder(
val layout = R.layout.list_item_track val layout = R.layout.list_item_track
private val contextMenuLayout = R.menu.context_menu_track private val contextMenuLayout = R.menu.context_menu_track
private val downloader: Downloader by inject()
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context) private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): TrackViewHolder { override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): TrackViewHolder {
@ -43,11 +39,8 @@ class TrackViewBinder(
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) { override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
val diffAdapter = adapter as BaseAdapter<*> val diffAdapter = adapter as BaseAdapter<*>
val downloadFile: DownloadFile = when (item) { val track: Track = when (item) {
is Track -> { is Track -> {
downloader.getDownloadFileForSong(item)
}
is DownloadFile -> {
item item
} }
else -> { else -> {
@ -61,7 +54,7 @@ class TrackViewBinder(
holder.observableChecked.removeObservers(lifecycleOwner) holder.observableChecked.removeObservers(lifecycleOwner)
holder.setSong( holder.setSong(
file = downloadFile, song = track,
checkable = checkable, checkable = checkable,
draggable = draggable, draggable = draggable,
diffAdapter.isSelected(item.longId) diffAdapter.isSelected(item.longId)
@ -72,11 +65,11 @@ class TrackViewBinder(
val popup = Utils.createPopupMenu(holder.itemView, contextMenuLayout) val popup = Utils.createPopupMenu(holder.itemView, contextMenuLayout)
popup.setOnMenuItemClickListener { menuItem -> popup.setOnMenuItemClickListener { menuItem ->
onContextMenuClick.invoke(menuItem, downloadFile) onContextMenuClick.invoke(menuItem, track)
} }
} else { } else {
// Minimize or maximize the Text view (if song title is very long) // Minimize or maximize the Text view (if song title is very long)
if (!downloadFile.track.isDirectory) { if (!track.isDirectory) {
holder.maximizeOrMinimize() holder.maximizeOrMinimize()
} }
} }
@ -85,11 +78,11 @@ class TrackViewBinder(
} }
holder.itemView.setOnClickListener { holder.itemView.setOnClickListener {
if (checkable && !downloadFile.track.isVideo) { if (checkable && !track.isVideo) {
val nowChecked = !holder.check.isChecked val nowChecked = !holder.check.isChecked
holder.isChecked = nowChecked holder.isChecked = nowChecked
} else { } else {
onItemClick(downloadFile, holder.bindingAdapterPosition) onItemClick(track, holder.bindingAdapterPosition)
} }
} }
@ -119,20 +112,6 @@ class TrackViewBinder(
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
} }
// Observe download status
downloadFile.status.observe(
lifecycleOwner
) {
holder.updateStatus(it)
diffAdapter.notifyChanged()
}
downloadFile.progress.observe(
lifecycleOwner
) {
holder.updateProgress(it)
}
} }
override fun onViewRecycled(holder: TrackViewHolder) { override fun onViewRecycled(holder: TrackViewHolder) {

View File

@ -11,15 +11,17 @@ import android.widget.TextView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.disposables.Disposable 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.DownloadFile
import org.moire.ultrasonic.service.DownloadStatus import org.moire.ultrasonic.service.DownloadStatus
import org.moire.ultrasonic.service.Downloader
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.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import timber.log.Timber import timber.log.Timber
@ -29,6 +31,8 @@ import timber.log.Timber
*/ */
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 check: CheckedTextView = view.findViewById(R.id.song_check) var check: CheckedTextView = view.findViewById(R.id.song_check)
private var rating: LinearLayout = view.findViewById(R.id.song_five_star) private var rating: LinearLayout = view.findViewById(R.id.song_five_star)
private var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1) private var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1)
@ -46,29 +50,25 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
var entry: Track? = null var entry: Track? = null
private set private set
var downloadFile: DownloadFile? = null
private set
private var isMaximized = false private var isMaximized = false
private var cachedStatus = DownloadStatus.UNKNOWN private var cachedStatus = DownloadStatus.UNKNOWN
private var statusImage: Drawable? = null private var statusImage: Drawable? = null
private var isPlayingCached = false private var isPlayingCached = false
private var rxSubscription: Disposable? = null private var rxBusSubscription: CompositeDisposable? = null
var observableChecked = MutableLiveData(false) var observableChecked = MutableLiveData(false)
lateinit var imageHelper: Utils.ImageHelper lateinit var imageHelper: Utils.ImageHelper
fun setSong( fun setSong(
file: DownloadFile, song: Track,
checkable: Boolean, checkable: Boolean,
draggable: Boolean, draggable: Boolean,
isSelected: Boolean = false isSelected: Boolean = false
) { ) {
val useFiveStarRating = Settings.useFiveStarRating val useFiveStarRating = Settings.useFiveStarRating
val song = file.track
downloadFile = file
entry = song entry = song
val entryDescription = Util.readableEntryDescription(song) val entryDescription = Util.readableEntryDescription(song)
@ -94,8 +94,8 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
setupStarButtons(song, useFiveStarRating) setupStarButtons(song, useFiveStarRating)
} }
updateProgress(downloadFile!!.progress.value!!) updateStatus(downloader.getDownloadState(song))
updateStatus(downloadFile!!.status.value!!) updateProgress(0)
if (useFiveStarRating) { if (useFiveStarRating) {
setFiveStars(entry?.userRating ?: 0) setFiveStars(entry?.userRating ?: 0)
@ -108,13 +108,22 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
progress.isVisible = false progress.isVisible = false
} }
rxSubscription = RxBus.playerStateObservable.subscribe { // Create new Disposable for the new Subscriptions
setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile) rxBusSubscription = CompositeDisposable()
rxBusSubscription!! += RxBus.playerStateObservable.subscribe {
setPlayIcon(it.index == bindingAdapterPosition && it.track?.id == song.id)
}
rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe {
if (it.track.id != song.id) return@subscribe
updateStatus(it.state)
updateProgress(it.progress)
} }
} }
// This is called when the Holder is recycled and receives a new Song
fun dispose() { fun dispose() {
rxSubscription?.dispose() rxBusSubscription?.dispose()
} }
private fun setPlayIcon(isPlaying: Boolean) { private fun setPlayIcon(isPlaying: Boolean) {
@ -198,7 +207,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
} }
} }
fun updateStatus(status: DownloadStatus) { private fun updateStatus(status: DownloadStatus) {
if (status == cachedStatus) return if (status == cachedStatus) return
cachedStatus = status cachedStatus = status
@ -227,7 +236,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
updateImages() updateImages()
} }
fun updateProgress(p: Int) { private fun updateProgress(p: Int) {
if (cachedStatus == DownloadStatus.DOWNLOADING) { if (cachedStatus == DownloadStatus.DOWNLOADING) {
progress.text = Util.formatPercentage(p) progress.text = Util.formatPercentage(p)
} else { } else {

View File

@ -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.playback.LegacyPlaylistManager
import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.Downloader
import org.moire.ultrasonic.service.ExternalStorageMonitor import org.moire.ultrasonic.service.ExternalStorageMonitor
import org.moire.ultrasonic.service.JukeboxMediaPlayer import org.moire.ultrasonic.service.JukeboxMediaPlayer
@ -13,13 +12,12 @@ import org.moire.ultrasonic.service.PlaybackStateSerializer
* This Koin module contains the registration of classes related to the media player * This Koin module contains the registration of classes related to the media player
*/ */
val mediaPlayerModule = module { val mediaPlayerModule = module {
single { JukeboxMediaPlayer(get()) } single { JukeboxMediaPlayer() }
single { MediaPlayerLifecycleSupport() } single { MediaPlayerLifecycleSupport() }
single { PlaybackStateSerializer() } single { PlaybackStateSerializer() }
single { ExternalStorageMonitor() } single { ExternalStorageMonitor() }
single { LegacyPlaylistManager() } single { Downloader(get()) }
single { Downloader(get(), 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(), get()) } single { MediaPlayerController(get(), get(), get(), get()) }
} }

View File

@ -17,8 +17,8 @@ import androidx.lifecycle.LiveData
import org.koin.core.component.inject 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.model.GenericListModel import org.moire.ultrasonic.model.GenericListModel
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.Downloader
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
@ -31,7 +31,7 @@ import org.moire.ultrasonic.util.Util
* *
* TODO: Add code to enable manipulation of the download list * TODO: Add code to enable manipulation of the download list
*/ */
class DownloadsFragment : MultiListFragment<DownloadFile>() { class DownloadsFragment : MultiListFragment<Track>() {
/** /**
* The ViewModel to use to get the data * The ViewModel to use to get the data
@ -41,7 +41,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
/** /**
* The central function to pass a query to the model and return a LiveData object * The central function to pass a query to the model and return a LiveData object
*/ */
override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData<List<DownloadFile>> { override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData<List<Track>> {
return listModel.getList() return listModel.getList()
} }
@ -71,12 +71,12 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
viewAdapter.submitList(liveDataList.value) viewAdapter.submitList(liveDataList.value)
} }
override fun onContextMenuItemSelected(menuItem: MenuItem, item: DownloadFile): Boolean { override fun onContextMenuItemSelected(menuItem: MenuItem, item: Track): Boolean {
// TODO: Add code to enable manipulation of the download list // TODO: Add code to enable manipulation of the download list
return true return true
} }
override fun onItemClick(item: DownloadFile) { override fun onItemClick(item: Track) {
// TODO: Add code to enable manipulation of the download list // TODO: Add code to enable manipulation of the download list
} }
} }
@ -84,7 +84,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
class DownloadListModel(application: Application) : GenericListModel(application) { class DownloadListModel(application: Application) : GenericListModel(application) {
private val downloader by inject<Downloader>() private val downloader by inject<Downloader>()
fun getList(): LiveData<List<DownloadFile>> { fun getList(): LiveData<List<Track>> {
return downloader.observableDownloads return downloader.observableDownloads
} }
} }

View File

@ -325,7 +325,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
if (isValid) { if (isValid) {
currentServerSetting!!.name = serverNameEditText!!.editText?.text.toString() currentServerSetting!!.name = serverNameEditText!!.editText?.text.toString()
currentServerSetting!!.url = serverAddressEditText!!.editText?.text.toString() currentServerSetting!!.url = serverAddressEditText!!.editText?.text.toString()
currentServerSetting!!.color = selectedColor currentServerSetting!!.color = selectedColor ?: currentColor
currentServerSetting!!.userName = userNameEditText!!.editText?.text.toString() currentServerSetting!!.userName = userNameEditText!!.editText?.text.toString()
currentServerSetting!!.password = passwordEditText!!.editText?.text.toString() currentServerSetting!!.password = passwordEditText!!.editText?.text.toString()
currentServerSetting!!.allowSelfSignedCertificate = selfSignedSwitch!!.isChecked currentServerSetting!!.allowSelfSignedCertificate = selfSignedSwitch!!.isChecked

View File

@ -1,6 +1,6 @@
/* /*
* NowPlayingFragment.kt * NowPlayingFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -29,6 +29,7 @@ import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util.applyTheme import org.moire.ultrasonic.util.Util.applyTheme
import org.moire.ultrasonic.util.Util.getNotificationImageSize import org.moire.ultrasonic.util.Util.getNotificationImageSize
import org.moire.ultrasonic.util.toTrack
import timber.log.Timber import timber.log.Timber
/** /**
@ -89,16 +90,15 @@ class NowPlayingFragment : Fragment() {
playButton!!.setImageResource(R.drawable.media_start_normal) playButton!!.setImageResource(R.drawable.media_start_normal)
} }
val file = mediaPlayerController.currentPlayingLegacy val file = mediaPlayerController.currentMediaItem?.toTrack()
if (file != null) { if (file != null) {
val song = file.track val title = file.title
val title = song.title val artist = file.artist
val artist = song.artist
imageLoader.getImageLoader().loadImage( imageLoader.getImageLoader().loadImage(
nowPlayingAlbumArtImage, nowPlayingAlbumArtImage,
song, file,
false, false,
getNotificationImageSize(requireContext()) getNotificationImageSize(requireContext())
) )
@ -111,14 +111,14 @@ class NowPlayingFragment : Fragment() {
if (Settings.shouldUseId3Tags) { if (Settings.shouldUseId3Tags) {
bundle.putBoolean(Constants.INTENT_IS_ALBUM, true) bundle.putBoolean(Constants.INTENT_IS_ALBUM, true)
bundle.putString(Constants.INTENT_ID, song.albumId) bundle.putString(Constants.INTENT_ID, file.albumId)
} else { } else {
bundle.putBoolean(Constants.INTENT_IS_ALBUM, false) bundle.putBoolean(Constants.INTENT_IS_ALBUM, false)
bundle.putString(Constants.INTENT_ID, song.parent) bundle.putString(Constants.INTENT_ID, file.parent)
} }
bundle.putString(Constants.INTENT_NAME, song.album) bundle.putString(Constants.INTENT_NAME, file.album)
bundle.putString(Constants.INTENT_NAME, song.album) bundle.putString(Constants.INTENT_NAME, file.album)
Navigation.findNavController(requireActivity(), R.id.nav_host_fragment) Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
.navigate(R.id.trackCollectionFragment, bundle) .navigate(R.id.trackCollectionFragment, bundle)

View File

@ -1,6 +1,6 @@
/* /*
* PlayerFragment.kt * PlayerFragment.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -37,6 +37,7 @@ import android.widget.ViewFlipper
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.media3.common.HeartRating import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.session.SessionResult import androidx.media3.session.SessionResult
@ -74,7 +75,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Identifiable
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.service.DownloadFile
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
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
@ -87,6 +87,7 @@ import org.moire.ultrasonic.util.CommunicationError
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.Util
import org.moire.ultrasonic.util.toTrack
import org.moire.ultrasonic.view.AutoRepeatButton import org.moire.ultrasonic.view.AutoRepeatButton
import org.moire.ultrasonic.view.VisualizerView import org.moire.ultrasonic.view.VisualizerView
import timber.log.Timber import timber.log.Timber
@ -120,7 +121,6 @@ class PlayerFragment :
private val mediaPlayerController: MediaPlayerController by inject() private val mediaPlayerController: MediaPlayerController by inject()
private val shareHandler: ShareHandler by inject() private val shareHandler: ShareHandler by inject()
private val imageLoaderProvider: ImageLoaderProvider by inject() private val imageLoaderProvider: ImageLoaderProvider by inject()
private var currentPlaying: DownloadFile? = null
private var currentSong: Track? = null private var currentSong: Track? = null
private lateinit var viewManager: LinearLayoutManager private lateinit var viewManager: LinearLayoutManager
private var rxBusSubscription: CompositeDisposable = CompositeDisposable() private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
@ -466,7 +466,7 @@ class PlayerFragment :
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (mediaPlayerController.currentPlayingLegacy == null) { if (mediaPlayerController.currentMediaItem == null) {
playlistFlipper.displayedChild = 1 playlistFlipper.displayedChild = 1
} else { } else {
// Download list and Album art must be updated when resumed // Download list and Album art must be updated when resumed
@ -557,10 +557,10 @@ class PlayerFragment :
visualizerMenuItem.isVisible = isVisualizerAvailable visualizerMenuItem.isVisible = isVisualizerAvailable
} }
val mediaPlayerController = mediaPlayerController val mediaPlayerController = mediaPlayerController
val downloadFile = mediaPlayerController.currentPlayingLegacy val track = mediaPlayerController.currentMediaItem?.toTrack()
if (downloadFile != null) { if (track != null) {
currentSong = downloadFile.track currentSong = track
} }
if (useFiveStarRating) starMenuItem.isVisible = false if (useFiveStarRating) starMenuItem.isVisible = false
@ -594,14 +594,11 @@ class PlayerFragment :
super.onCreateContextMenu(menu, view, menuInfo) super.onCreateContextMenu(menu, view, menuInfo)
if (view === playlistView) { if (view === playlistView) {
val info = menuInfo as AdapterContextMenuInfo? val info = menuInfo as AdapterContextMenuInfo?
val downloadFile = viewAdapter.getCurrentList()[info!!.position] as DownloadFile val track = viewAdapter.getCurrentList()[info!!.position] as Track
val menuInflater = requireActivity().menuInflater val menuInflater = requireActivity().menuInflater
menuInflater.inflate(R.menu.nowplaying_context, menu) menuInflater.inflate(R.menu.nowplaying_context, menu)
val song: Track?
song = downloadFile.track if (track.parent == null) {
if (song.parent == null) {
val menuItem = menu.findItem(R.id.menu_show_album) val menuItem = menu.findItem(R.id.menu_show_album)
if (menuItem != null) { if (menuItem != null) {
menuItem.isVisible = false menuItem.isVisible = false
@ -619,16 +616,13 @@ class PlayerFragment :
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
// TODO Why is Track null?
return menuItemSelected(item.itemId, null) || super.onOptionsItemSelected(item) return menuItemSelected(item.itemId, null) || super.onOptionsItemSelected(item)
} }
@Suppress("ComplexMethod", "LongMethod", "ReturnCount") @Suppress("ComplexMethod", "LongMethod", "ReturnCount")
private fun menuItemSelected(menuItemId: Int, song: DownloadFile?): Boolean { private fun menuItemSelected(menuItemId: Int, track: Track?): Boolean {
var track: Track? = null
val bundle: Bundle val bundle: Bundle
if (song != null) {
track = song.track
}
when (menuItemId) { when (menuItemId) {
R.id.menu_show_artist -> { R.id.menu_show_artist -> {
@ -804,9 +798,9 @@ class PlayerFragment :
R.id.menu_item_share -> { R.id.menu_item_share -> {
val mediaPlayerController = mediaPlayerController val mediaPlayerController = mediaPlayerController
val tracks: MutableList<Track?> = ArrayList() val tracks: MutableList<Track?> = ArrayList()
val downloadServiceSongs = mediaPlayerController.playList val playlist = mediaPlayerController.playlist
for (downloadFile in downloadServiceSongs) { for (item in playlist) {
val playlistEntry = downloadFile.track val playlistEntry = item.toTrack()
tracks.add(playlistEntry) tracks.add(playlistEntry)
} }
shareHandler.createShare(this, tracks, null, cancellationToken) shareHandler.createShare(this, tracks, null, cancellationToken)
@ -828,7 +822,7 @@ class PlayerFragment :
private fun update(cancel: CancellationToken? = null) { private fun update(cancel: CancellationToken? = null) {
if (cancel?.isCancellationRequested == true) return if (cancel?.isCancellationRequested == true) return
val mediaPlayerController = mediaPlayerController val mediaPlayerController = mediaPlayerController
if (currentPlaying != mediaPlayerController.currentPlayingLegacy) { if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
onCurrentChanged() onCurrentChanged()
} }
onSliderProgressChanged() onSliderProgressChanged()
@ -841,8 +835,8 @@ class PlayerFragment :
ioScope.launch { ioScope.launch {
val entries = mediaPlayerController.playList.map { val entries = mediaPlayerController.playlist.map {
it.track it.toTrack()
} }
val musicService = getMusicService() val musicService = getMusicService()
musicService.createPlaylist(null, playlistName, entries) musicService.createPlaylist(null, playlistName, entries)
@ -891,7 +885,7 @@ class PlayerFragment :
} }
// Create listener // Create listener
val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos -> val clickHandler: ((Track, Int) -> Unit) = { _, pos ->
mediaPlayerController.seekTo(pos, 0) mediaPlayerController.seekTo(pos, 0)
mediaPlayerController.prepare() mediaPlayerController.prepare()
mediaPlayerController.play() mediaPlayerController.play()
@ -978,10 +972,10 @@ class PlayerFragment :
private fun onPlaylistChanged() { private fun onPlaylistChanged() {
val mediaPlayerController = mediaPlayerController val mediaPlayerController = mediaPlayerController
val list = mediaPlayerController.playList val list = mediaPlayerController.playlist
emptyTextView.setText(R.string.playlist_empty) emptyTextView.setText(R.string.playlist_empty)
viewAdapter.submitList(list) viewAdapter.submitList(list.map(MediaItem::toTrack))
emptyTextView.isVisible = list.isEmpty() emptyTextView.isVisible = list.isEmpty()
@ -989,17 +983,16 @@ class PlayerFragment :
} }
private fun onCurrentChanged() { private fun onCurrentChanged() {
currentPlaying = mediaPlayerController.currentPlayingLegacy currentSong = mediaPlayerController.currentMediaItem?.toTrack()
scrollToCurrent() scrollToCurrent()
val totalDuration = mediaPlayerController.playListDuration val totalDuration = mediaPlayerController.playListDuration
val totalSongs = mediaPlayerController.playlistSize.toLong() val totalSongs = mediaPlayerController.playlistSize
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1 val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
val duration = Util.formatTotalDuration(totalDuration) val duration = Util.formatTotalDuration(totalDuration)
val trackFormat = val trackFormat =
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs) String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
if (currentPlaying != null) { if (currentSong != null) {
currentSong = currentPlaying!!.track
songTitleTextView.text = currentSong!!.title songTitleTextView.text = currentSong!!.title
artistTextView.text = currentSong!!.artist artistTextView.text = currentSong!!.artist
albumTextView.text = currentSong!!.album albumTextView.text = currentSong!!.album
@ -1057,7 +1050,7 @@ class PlayerFragment :
val isPlaying = mediaPlayerController.isPlaying val isPlaying = mediaPlayerController.isPlaying
if (cancellationToken.isCancellationRequested) return if (cancellationToken.isCancellationRequested) return
if (currentPlaying != null) { if (currentSong != null) {
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true) positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true) durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
progressBar.max = progressBar.max =

View File

@ -33,7 +33,6 @@ import org.moire.ultrasonic.domain.SearchResult
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.SearchListModel import org.moire.ultrasonic.model.SearchListModel
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler import org.moire.ultrasonic.subsonic.ShareHandler
@ -353,13 +352,13 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
this this
) )
if (found || item !is DownloadFile) return true if (found || item !is Track) return true
val songs = mutableListOf<Track>() val songs = mutableListOf<Track>()
when (menuItem.itemId) { when (menuItem.itemId) {
R.id.song_menu_play_now -> { R.id.song_menu_play_now -> {
songs.add(item.track) songs.add(item)
downloadHandler.download( downloadHandler.download(
fragment = this, fragment = this,
append = false, append = false,
@ -371,7 +370,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
) )
} }
R.id.song_menu_play_next -> { R.id.song_menu_play_next -> {
songs.add(item.track) songs.add(item)
downloadHandler.download( downloadHandler.download(
fragment = this, fragment = this,
append = true, append = true,
@ -383,7 +382,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
) )
} }
R.id.song_menu_play_last -> { R.id.song_menu_play_last -> {
songs.add(item.track) songs.add(item)
downloadHandler.download( downloadHandler.download(
fragment = this, fragment = this,
append = true, append = true,
@ -395,7 +394,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
) )
} }
R.id.song_menu_pin -> { R.id.song_menu_pin -> {
songs.add(item.track) songs.add(item)
toast( toast(
context, context,
resources.getQuantityString( resources.getQuantityString(
@ -407,7 +406,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
downloadBackground(true, songs) downloadBackground(true, songs)
} }
R.id.song_menu_download -> { R.id.song_menu_download -> {
songs.add(item.track) songs.add(item)
toast( toast(
context, context,
resources.getQuantityString( resources.getQuantityString(
@ -419,7 +418,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
downloadBackground(false, songs) downloadBackground(false, songs)
} }
R.id.song_menu_unpin -> { R.id.song_menu_unpin -> {
songs.add(item.track) songs.add(item)
toast( toast(
context, context,
resources.getQuantityString( resources.getQuantityString(
@ -431,7 +430,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
mediaPlayerController.unpin(songs) mediaPlayerController.unpin(songs)
} }
R.id.song_menu_share -> { R.id.song_menu_share -> {
songs.add(item.track) songs.add(item)
shareHandler.createShare(this, songs, searchRefresh, cancellationToken!!) shareHandler.createShare(this, songs, searchRefresh, cancellationToken!!)
} }
} }

View File

@ -433,7 +433,6 @@ class SettingsFragment :
// Clear download queue. // Clear download queue.
mediaPlayerController.clear() mediaPlayerController.clear()
mediaPlayerController.clearCaches()
Storage.reset() Storage.reset()
} }

View File

@ -38,6 +38,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.Downloader
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
import org.moire.ultrasonic.subsonic.ShareHandler import org.moire.ultrasonic.subsonic.ShareHandler
@ -79,6 +81,7 @@ 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
@ -125,8 +128,8 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
viewAdapter.register( viewAdapter.register(
TrackViewBinder( TrackViewBinder(
onItemClick = { file, _ -> onItemClick(file.track) }, onItemClick = { file, _ -> onItemClick(file) },
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) }, onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id) },
checkable = true, checkable = true,
draggable = false, draggable = false,
context = requireContext(), context = requireContext(),
@ -364,11 +367,11 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
var pinnedCount = 0 var pinnedCount = 0
for (song in selection) { for (song in selection) {
val downloadFile = mediaPlayerController.getDownloadFileForSong(song) val state = downloader.getDownloadState(song)
if (downloadFile.isWorkDone) { if (state == DownloadStatus.DONE || state == DownloadStatus.PINNED) {
deleteEnabled = true deleteEnabled = true
} }
if (downloadFile.isSaved) { if (state == DownloadStatus.PINNED) {
pinnedCount++ pinnedCount++
unpinEnabled = true unpinEnabled = true
} }

View File

@ -16,8 +16,12 @@ import com.google.common.util.concurrent.ListeningExecutorService
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import java.io.IOException import java.io.IOException
import java.util.concurrent.Executors import java.util.concurrent.Executors
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class ArtworkBitmapLoader : BitmapLoader { class ArtworkBitmapLoader : BitmapLoader, KoinComponent {
private val imageLoader: ImageLoader by inject()
private val executorService: ListeningExecutorService by lazy { private val executorService: ListeningExecutorService by lazy {
MoreExecutors.listeningDecorator( MoreExecutors.listeningDecorator(
@ -46,6 +50,8 @@ class ArtworkBitmapLoader : BitmapLoader {
@Throws(IOException::class) @Throws(IOException::class)
private fun load(uri: Uri): Bitmap { private fun load(uri: Uri): Bitmap {
return BitmapFactory.decodeFile(uri.path) val parts = uri.path?.trim('/')?.split('|')
if (parts?.count() != 2) throw IllegalArgumentException("Invalid bitmap Uri")
return imageLoader.getImage(parts[0], parts[1], false, 0)
} }
} }

View File

@ -3,7 +3,7 @@ package org.moire.ultrasonic.imageloader
import android.app.ActivityManager import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.text.TextUtils import android.graphics.Bitmap
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -14,6 +14,8 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import org.moire.ultrasonic.BuildConfig import org.moire.ultrasonic.BuildConfig
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
@ -33,6 +35,8 @@ class ImageLoader(
apiClient: SubsonicAPIClient, apiClient: SubsonicAPIClient,
private val config: ImageLoaderConfig private val config: ImageLoaderConfig
) { ) {
private val cacheInProgress: ConcurrentHashMap<String, CountDownLatch> = ConcurrentHashMap()
// Shortcut // Shortcut
@Suppress("VariableNaming", "PropertyName") @Suppress("VariableNaming", "PropertyName")
val API = apiClient.api val API = apiClient.api
@ -58,6 +62,14 @@ class ImageLoader(
.into(request.imageView) .into(request.imageView)
} }
private fun getCoverArt(request: ImageRequest.CoverArt): Bitmap {
return picasso.load(createLoadCoverArtRequest(request.entityId, request.size.toLong()))
.addPlaceholder(request)
.addError(request)
.stableKey(request.cacheKey)
.get()
}
private fun loadAvatar(request: ImageRequest.Avatar) { private fun loadAvatar(request: ImageRequest.Avatar) {
picasso.load(createLoadAvatarRequest(request.username)) picasso.load(createLoadAvatarRequest(request.username))
.addPlaceholder(request) .addPlaceholder(request)
@ -82,6 +94,26 @@ class ImageLoader(
return this return this
} }
/**
* Load the cover of a given entry into a Bitmap
*/
fun getImage(
id: String?,
cacheKey: String?,
large: Boolean,
size: Int,
defaultResourceId: Int = R.drawable.unknown_album
): Bitmap {
val requestedSize = resolveSize(size, large)
val request = ImageRequest.CoverArt(
id!!, cacheKey!!, null, requestedSize,
placeHolderDrawableRes = defaultResourceId,
errorDrawableRes = defaultResourceId
)
return getCoverArt(request)
}
/** /**
* Load the cover of a given entry into an ImageView * Load the cover of a given entry into an ImageView
*/ */
@ -148,30 +180,30 @@ class ImageLoader(
/** /**
* Download a cover art file and cache it on disk * Download a cover art file and cache it on disk
*/ */
fun cacheCoverArt( fun cacheCoverArt(track: Track) {
track: Track cacheCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
) { }
// Synchronize on the entry so that we don't download concurrently for fun cacheCoverArt(id: String, file: String) {
// the same song. if (id.isNullOrBlank()) return
synchronized(track) { // Return if have a cache hit
if (File(file).exists()) return
// If another thread has started caching, wait until it finishes
val latch = cacheInProgress.putIfAbsent(file, CountDownLatch(1))
if (latch != null) {
latch.await()
return
}
try {
// Always download the large size.. // Always download the large size..
val size = config.largeSize val size = config.largeSize
File(file).createNewFile()
// Check cache to avoid downloading existing files
val file = FileUtil.getAlbumArtFile(track)
// Return if have a cache hit
if (file != null && File(file).exists()) return
File(file!!).createNewFile()
// Can't load empty string ids
val id = track.coverArt
if (TextUtils.isEmpty(id)) return
// Query the API // Query the API
Timber.d("Loading cover art for: %s", track) Timber.d("Loading cover art for: %s", id)
val response = API.getCoverArt(id!!, size.toLong()).execute().toStreamResponse() val response = API.getCoverArt(id, size.toLong()).execute().toStreamResponse()
response.throwOnFailure() response.throwOnFailure()
// Check for failure // Check for failure
@ -192,6 +224,8 @@ class ImageLoader(
} finally { } finally {
inputStream.safeClose() inputStream.safeClose()
} }
} finally {
cacheInProgress.remove(file)?.countDown()
} }
} }
@ -222,12 +256,12 @@ class ImageLoader(
sealed class ImageRequest( sealed class ImageRequest(
val placeHolderDrawableRes: Int? = null, val placeHolderDrawableRes: Int? = null,
val errorDrawableRes: Int? = null, val errorDrawableRes: Int? = null,
val imageView: ImageView val imageView: ImageView?
) { ) {
class CoverArt( class CoverArt(
val entityId: String, val entityId: String,
val cacheKey: String, val cacheKey: String,
imageView: ImageView, imageView: ImageView?,
val size: Int, val size: Int,
placeHolderDrawableRes: Int? = null, placeHolderDrawableRes: Int? = null,
errorDrawableRes: Int? = null, errorDrawableRes: Int? = null,

View File

@ -1,5 +1,5 @@
/* /*
* CustomMediaLibrarySessionCallback.kt * AutoMediaBrowserCallback.kt
* Copyright (C) 2009-2022 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
@ -7,17 +7,14 @@
package org.moire.ultrasonic.playback package org.moire.ultrasonic.playback
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT import android.widget.Toast.LENGTH_SHORT
import androidx.media3.common.HeartRating import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
import androidx.media3.common.Player import androidx.media3.common.Player
@ -37,8 +34,8 @@ import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.guava.future import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
@ -54,6 +51,9 @@ import org.moire.ultrasonic.service.MusicServiceFactory
import org.moire.ultrasonic.util.MainThreadExecutor import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.buildMediaItem
import org.moire.ultrasonic.util.toMediaItem
import org.moire.ultrasonic.util.toTrack
import timber.log.Timber import timber.log.Timber
private const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID" private const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID"
@ -106,6 +106,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
private val serviceJob = Job() private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val mainScope = CoroutineScope(Dispatchers.Main)
private var playlistCache: List<Track>? = null private var playlistCache: List<Track>? = null
private var starredSongsCache: List<Track>? = null private var starredSongsCache: List<Track>? = null
@ -226,7 +227,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
* is stored in the track.starred value * is stored in the track.starred value
* See https://github.com/androidx/media/issues/33 * See https://github.com/androidx/media/issues/33
*/ */
val track = mediaPlayerController.currentPlayingLegacy?.track val track = mediaPlayerController.currentMediaItem?.toTrack()
if (track != null) { if (track != null) {
customCommandFuture = onSetRating( customCommandFuture = onSetRating(
session, session,
@ -312,18 +313,61 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
* and thereby customarily it is required to rebuild it.. * and thereby customarily it is required to rebuild it..
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error * See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
*/ */
@Suppress("MagicNumber", "ComplexMethod")
override fun onAddMediaItems( override fun onAddMediaItems(
mediaSession: MediaSession, mediaSession: MediaSession,
controller: MediaSession.ControllerInfo, controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem> mediaItems: MutableList<MediaItem>
): ListenableFuture<MutableList<MediaItem>> { ): ListenableFuture<MutableList<MediaItem>> {
val updatedMediaItems = mediaItems.map { mediaItem -> if (!mediaItems.any()) return Futures.immediateFuture(mediaItems)
mediaItem.buildUpon()
.setUri(mediaItem.requestMetadata.mediaUri) // Try to find out if the requester understands requestMetadata in the mediaItems
.build() if (mediaItems.firstOrNull()?.requestMetadata?.mediaUri != null) {
val updatedMediaItems = mediaItems.map { mediaItem ->
mediaItem.buildUpon()
.setUri(mediaItem.requestMetadata.mediaUri)
.build()
}
return Futures.immediateFuture(updatedMediaItems.toMutableList())
} else {
// Android Auto devices still only use the MediaId to identify the selected Items
// They also only select a single item at once
val mediaIdParts = mediaItems.first().mediaId.split('|')
val tracks = when (mediaIdParts.first()) {
MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2])
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
)
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
)
MEDIA_SONG_STARRED_ID -> playStarredSongs()
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
MEDIA_SONG_RANDOM_ID -> playRandomSongs()
MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1])
MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1])
MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2])
MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1])
MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1])
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
mediaIdParts[1], mediaIdParts[2]
)
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
else -> null
}
if (tracks != null) {
return Futures.immediateFuture(
tracks.map { track -> track.toMediaItem() }
.toMutableList()
)
}
// Fallback to the original list
return Futures.immediateFuture(mediaItems)
} }
return Futures.immediateFuture(updatedMediaItems.toMutableList())
} }
@Suppress("ReturnCount", "ComplexMethod") @Suppress("ReturnCount", "ComplexMethod")
@ -398,10 +442,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
searchSongsCache = searchResult.songs searchSongsCache = searchResult.songs
searchResult.songs.map { song -> searchResult.songs.map { song ->
mediaItems.add( mediaItems.add(
buildMediaItemFromTrack( song.toMediaItem(
song, listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|")
listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|"),
isPlayable = true
) )
) )
} }
@ -444,37 +486,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
} }
} }
private fun playFromSearchCommand(query: String?) { private fun playSearch(id: String): List<Track>? {
Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query) // If there is no cache, we can't play the selected song.
if (query.isNullOrBlank()) playRandomSongs() if (searchSongsCache != null) {
val song = searchSongsCache!!.firstOrNull { x -> x.id == id }
serviceScope.launch { if (song != null) return listOf(song)
val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT)
val searchResult = callWithErrorHandling { musicService.search(criteria) }
// Try to find the best match
if (searchResult != null) {
val song = searchResult.songs
.asSequence()
.sortedByDescending { song -> song.starred }
.sortedByDescending { song -> song.averageRating }
.sortedByDescending { song -> song.userRating }
.sortedByDescending { song -> song.closeness }
.firstOrNull()
if (song != null) playSong(song)
}
}
}
private fun playSearch(id: String) {
serviceScope.launch {
// If there is no cache, we can't play the selected song.
if (searchSongsCache != null) {
val song = searchSongsCache!!.firstOrNull { x -> x.id == id }
if (song != null) playSong(song)
}
} }
return null
} }
private fun getRootItems(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { private fun getRootItems(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
@ -484,14 +502,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
mediaItems.add( mediaItems.add(
R.string.music_library_label, R.string.music_library_label,
MEDIA_LIBRARY_ID, MEDIA_LIBRARY_ID,
null null,
icon = R.drawable.ic_library
) )
mediaItems.add( mediaItems.add(
R.string.main_artists_title, R.string.main_artists_title,
MEDIA_ARTIST_ID, MEDIA_ARTIST_ID,
null, null,
folderType = FOLDER_TYPE_ARTISTS folderType = FOLDER_TYPE_ARTISTS,
icon = R.drawable.ic_artist
) )
if (!isOffline) if (!isOffline)
@ -499,14 +519,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
R.string.main_albums_title, R.string.main_albums_title,
MEDIA_ALBUM_ID, MEDIA_ALBUM_ID,
null, null,
folderType = FOLDER_TYPE_ALBUMS folderType = FOLDER_TYPE_ALBUMS,
icon = R.drawable.ic_menu_browse
) )
mediaItems.add( mediaItems.add(
R.string.playlist_label, R.string.playlist_label,
MEDIA_PLAYLIST_ID, MEDIA_PLAYLIST_ID,
null, null,
folderType = FOLDER_TYPE_PLAYLISTS folderType = FOLDER_TYPE_PLAYLISTS,
icon = R.drawable.ic_menu_playlists
) )
return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null)) return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null))
@ -578,18 +600,21 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { // It seems double scoping is required: Media3 requires the Main thread, network operations with musicService forbid the Main thread...
val childMediaId: String return mainScope.future {
var artists = if (!isOffline && useId3Tags) { var childMediaId: String = MEDIA_ARTIST_ITEM
childMediaId = MEDIA_ARTIST_ITEM
// TODO this list can be big so we're not refreshing. var artists = serviceScope.future {
// Maybe a refresh menu item can be added if (!isOffline && useId3Tags) {
callWithErrorHandling { musicService.getArtists(false) } // TODO this list can be big so we're not refreshing.
} else { // Maybe a refresh menu item can be added
// This will be handled at getSongsForAlbum, which supports navigation callWithErrorHandling { musicService.getArtists(false) }
childMediaId = MEDIA_ALBUM_ITEM } else {
callWithErrorHandling { musicService.getIndexes(musicFolderId, false) } // This will be handled at getSongsForAlbum, which supports navigation
} childMediaId = MEDIA_ALBUM_ITEM
callWithErrorHandling { musicService.getIndexes(musicFolderId, false) }
}
}.await()
if (artists != null) { if (artists != null) {
if (section != null) if (section != null)
@ -633,14 +658,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val albums = if (!isOffline && useId3Tags) { val albums = serviceScope.future {
callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) } if (!isOffline && useId3Tags) {
} else { callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
callWithErrorHandling { } else {
musicService.getMusicDirectory(id, name, false).getAlbums() callWithErrorHandling {
musicService.getMusicDirectory(id, name, false).getAlbums()
}
} }
} }.await()
albums?.map { album -> albums?.map { album ->
mediaItems.add( mediaItems.add(
@ -660,8 +687,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val songs = listSongsInMusicService(id, name) val songs = serviceScope.future { listSongsInMusicService(id, name) }.await()
if (songs != null) { if (songs != null) {
if (songs.getChildren(includeDirs = true, includeFiles = false).isEmpty() && if (songs.getChildren(includeDirs = true, includeFiles = false).isEmpty() &&
@ -680,15 +707,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
) )
else else
mediaItems.add( mediaItems.add(
buildMediaItemFromTrack( item.toMediaItem(
item,
listOf( listOf(
MEDIA_ALBUM_SONG_ITEM, MEDIA_ALBUM_SONG_ITEM,
id, id,
name, name,
item.id item.id
).joinToString("|"), ).joinToString("|")
isPlayable = true
) )
) )
} }
@ -703,21 +728,24 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val offset = (page ?: 0) * DISPLAY_LIMIT val offset = (page ?: 0) * DISPLAY_LIMIT
val albums = if (useId3Tags) {
callWithErrorHandling { val albums = serviceScope.future {
musicService.getAlbumList2( if (useId3Tags) {
type.typeName, DISPLAY_LIMIT, offset, null callWithErrorHandling {
) musicService.getAlbumList2(
type.typeName, DISPLAY_LIMIT, offset, null
)
}
} else {
callWithErrorHandling {
musicService.getAlbumList(
type.typeName, DISPLAY_LIMIT, offset, null
)
}
} }
} else { }.await()
callWithErrorHandling {
musicService.getAlbumList(
type.typeName, DISPLAY_LIMIT, offset, null
)
}
}
albums?.map { album -> albums?.map { album ->
mediaItems.add( mediaItems.add(
@ -742,8 +770,11 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
private fun getPlaylists(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { private fun getPlaylists(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val playlists = callWithErrorHandling { musicService.getPlaylists(true) } val playlists = serviceScope.future {
callWithErrorHandling { musicService.getPlaylists(true) }
}.await()
playlists?.map { playlist -> playlists?.map { playlist ->
mediaItems.add( mediaItems.add(
playlist.name, playlist.name,
@ -762,8 +793,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val content = callWithErrorHandling { musicService.getPlaylist(id, name) } val content = serviceScope.future {
callWithErrorHandling { musicService.getPlaylist(id, name) }
}.await()
if (content != null) { if (content != null) {
if (content.size > 1) if (content.size > 1)
@ -775,15 +808,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
playlistCache = content.getTracks() playlistCache = content.getTracks()
playlistCache!!.take(DISPLAY_LIMIT).map { item -> playlistCache!!.take(DISPLAY_LIMIT).map { item ->
mediaItems.add( mediaItems.add(
buildMediaItemFromTrack( item.toMediaItem(
item,
listOf( listOf(
MEDIA_PLAYLIST_SONG_ITEM, MEDIA_PLAYLIST_SONG_ITEM,
id, id,
name, name,
item.id item.id
).joinToString("|"), ).joinToString("|")
isPlayable = true
) )
) )
} }
@ -792,49 +823,52 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
} }
} }
private fun playPlaylist(id: String, name: String) { private fun playPlaylist(id: String, name: String): List<Track>? {
serviceScope.launch { if (playlistCache == null) {
if (playlistCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them val content =
val content = callWithErrorHandling { musicService.getPlaylist(id, name) } serviceScope.future {
playlistCache = content?.getTracks() callWithErrorHandling { musicService.getPlaylist(id, name) }
} }.get()
if (playlistCache != null) playSongs(playlistCache!!) playlistCache = content?.getTracks()
} }
if (playlistCache != null) return playlistCache!!
return null
} }
private fun playPlaylistSong(id: String, name: String, songId: String) { private fun playPlaylistSong(id: String, name: String, songId: String): List<Track>? {
serviceScope.launch { if (playlistCache == null) {
if (playlistCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them val content = serviceScope.future {
val content = callWithErrorHandling { musicService.getPlaylist(id, name) } callWithErrorHandling { musicService.getPlaylist(id, name) }
playlistCache = content?.getTracks() }.get()
} playlistCache = content?.getTracks()
val song = playlistCache?.firstOrNull { x -> x.id == songId }
if (song != null) playSong(song)
} }
val song = playlistCache?.firstOrNull { x -> x.id == songId }
if (song != null) return listOf(song)
return null
} }
private fun playAlbum(id: String, name: String) { private fun playAlbum(id: String, name: String): List<Track>? {
serviceScope.launch { val songs = listSongsInMusicService(id, name)
val songs = listSongsInMusicService(id, name) if (songs != null) return songs.getTracks()
if (songs != null) playSongs(songs.getTracks()) return null
}
} }
private fun playAlbumSong(id: String, name: String, songId: String) { private fun playAlbumSong(id: String, name: String, songId: String): List<Track>? {
serviceScope.launch { val songs = listSongsInMusicService(id, name)
val songs = listSongsInMusicService(id, name) val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId }
val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId } if (song != null) return listOf(song)
if (song != null) playSong(song) return null
}
} }
private fun getPodcasts(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { private fun getPodcasts(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val podcasts = callWithErrorHandling { musicService.getPodcastsChannels(false) } val podcasts = serviceScope.future {
callWithErrorHandling { musicService.getPodcastsChannels(false) }
}.await()
podcasts?.map { podcast -> podcasts?.map { podcast ->
mediaItems.add( mediaItems.add(
@ -851,8 +885,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
id: String id: String
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } val episodes = serviceScope.future {
callWithErrorHandling { musicService.getPodcastEpisodes(id) }
}.await()
if (episodes != null) { if (episodes != null) {
if (episodes.getTracks().count() > 1) if (episodes.getTracks().count() > 1)
@ -860,11 +896,9 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
episodes.getTracks().map { episode -> episodes.getTracks().map { episode ->
mediaItems.add( mediaItems.add(
buildMediaItemFromTrack( episode.toMediaItem(
episode,
listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id)
.joinToString("|"), .joinToString("|")
isPlayable = true
) )
) )
} }
@ -873,40 +907,43 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
} }
} }
private fun playPodcast(id: String) { private fun playPodcast(id: String): List<Track>? {
serviceScope.launch { val episodes = serviceScope.future {
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } callWithErrorHandling { musicService.getPodcastEpisodes(id) }
if (episodes != null) { }.get()
playSongs(episodes.getTracks()) if (episodes != null) {
} return episodes.getTracks()
} }
return null
} }
private fun playPodcastEpisode(id: String, episodeId: String) { private fun playPodcastEpisode(id: String, episodeId: String): List<Track>? {
serviceScope.launch { val episodes = serviceScope.future {
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } callWithErrorHandling { musicService.getPodcastEpisodes(id) }
if (episodes != null) { }.get()
val selectedEpisode = episodes if (episodes != null) {
.getTracks() val selectedEpisode = episodes
.firstOrNull { episode -> episode.id == episodeId } .getTracks()
if (selectedEpisode != null) playSong(selectedEpisode) .firstOrNull { episode -> episode.id == episodeId }
} if (selectedEpisode != null) return listOf(selectedEpisode)
} }
return null
} }
private fun getBookmarks(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { private fun getBookmarks(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val bookmarks = callWithErrorHandling { musicService.getBookmarks() } val bookmarks = serviceScope.future {
callWithErrorHandling { musicService.getBookmarks() }
}.await()
if (bookmarks != null) { if (bookmarks != null) {
val songs = Util.getSongsFromBookmarks(bookmarks) val songs = Util.getSongsFromBookmarks(bookmarks)
songs.getTracks().map { song -> songs.getTracks().map { song ->
mediaItems.add( mediaItems.add(
buildMediaItemFromTrack( song.toMediaItem(
song, listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|")
listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|"),
isPlayable = true
) )
) )
} }
@ -915,22 +952,25 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
} }
} }
private fun playBookmark(id: String) { private fun playBookmark(id: String): List<Track>? {
serviceScope.launch { val bookmarks = serviceScope.future {
val bookmarks = callWithErrorHandling { musicService.getBookmarks() } callWithErrorHandling { musicService.getBookmarks() }
if (bookmarks != null) { }.get()
val songs = Util.getSongsFromBookmarks(bookmarks) if (bookmarks != null) {
val song = songs.getTracks().firstOrNull { song -> song.id == id } val songs = Util.getSongsFromBookmarks(bookmarks)
if (song != null) playSong(song) val song = songs.getTracks().firstOrNull { song -> song.id == id }
} if (song != null) return listOf(song)
} }
return null
} }
private fun getShares(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { private fun getShares(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val shares = callWithErrorHandling { musicService.getShares(false) } val shares = serviceScope.future {
callWithErrorHandling { musicService.getShares(false) }
}.await()
shares?.map { share -> shares?.map { share ->
mediaItems.add( mediaItems.add(
@ -949,8 +989,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { ): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val shares = callWithErrorHandling { musicService.getShares(false) } val shares = serviceScope.future {
callWithErrorHandling { musicService.getShares(false) }
}.await()
val selectedShare = shares?.firstOrNull { share -> share.id == id } val selectedShare = shares?.firstOrNull { share -> share.id == id }
if (selectedShare != null) { if (selectedShare != null) {
@ -960,10 +1002,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
selectedShare.getEntries().map { song -> selectedShare.getEntries().map { song ->
mediaItems.add( mediaItems.add(
buildMediaItemFromTrack( song.toMediaItem(
song, listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|")
listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|"),
isPlayable = true
) )
) )
} }
@ -972,32 +1012,36 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
} }
} }
private fun playShare(id: String) { private fun playShare(id: String): List<Track>? {
serviceScope.launch { val shares = serviceScope.future {
val shares = callWithErrorHandling { musicService.getShares(false) } callWithErrorHandling { musicService.getShares(false) }
val selectedShare = shares?.firstOrNull { share -> share.id == id } }.get()
if (selectedShare != null) { val selectedShare = shares?.firstOrNull { share -> share.id == id }
playSongs(selectedShare.getEntries()) if (selectedShare != null) {
} return selectedShare.getEntries()
} }
return null
} }
private fun playShareSong(id: String, songId: String) { private fun playShareSong(id: String, songId: String): List<Track>? {
serviceScope.launch { val shares = serviceScope.future {
val shares = callWithErrorHandling { musicService.getShares(false) } callWithErrorHandling { musicService.getShares(false) }
val selectedShare = shares?.firstOrNull { share -> share.id == id } }.get()
if (selectedShare != null) { val selectedShare = shares?.firstOrNull { share -> share.id == id }
val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId } if (selectedShare != null) {
if (song != null) playSong(song) val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId }
} if (song != null) return listOf(song)
} }
return null
} }
private fun getStarredSongs(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { private fun getStarredSongs(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val songs = listStarredSongsInMusicService() val songs = serviceScope.future {
listStarredSongsInMusicService()
}.await()
if (songs != null) { if (songs != null) {
if (songs.songs.count() > 1) if (songs.songs.count() > 1)
@ -1008,10 +1052,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
starredSongsCache = items starredSongsCache = items
items.map { song -> items.map { song ->
mediaItems.add( mediaItems.add(
buildMediaItemFromTrack( song.toMediaItem(
song, listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|")
listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|"),
isPlayable = true
) )
) )
} }
@ -1020,34 +1062,34 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
} }
} }
private fun playStarredSongs() { private fun playStarredSongs(): List<Track>? {
serviceScope.launch { if (starredSongsCache == null) {
if (starredSongsCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them val content = listStarredSongsInMusicService()
val content = listStarredSongsInMusicService() starredSongsCache = content?.songs
starredSongsCache = content?.songs
}
if (starredSongsCache != null) playSongs(starredSongsCache!!)
} }
if (starredSongsCache != null) return starredSongsCache!!
return null
} }
private fun playStarredSong(songId: String) { private fun playStarredSong(songId: String): List<Track>? {
serviceScope.launch { if (starredSongsCache == null) {
if (starredSongsCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them val content = listStarredSongsInMusicService()
val content = listStarredSongsInMusicService() starredSongsCache = content?.songs
starredSongsCache = content?.songs
}
val song = starredSongsCache?.firstOrNull { x -> x.id == songId }
if (song != null) playSong(song)
} }
val song = starredSongsCache?.firstOrNull { x -> x.id == songId }
if (song != null) return listOf(song)
return null
} }
private fun getRandomSongs(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> { private fun getRandomSongs(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = ArrayList() val mediaItems: MutableList<MediaItem> = ArrayList()
return serviceScope.future { return mainScope.future {
val songs = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } val songs = serviceScope.future {
callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
}.await()
if (songs != null) { if (songs != null) {
if (songs.size > 1) if (songs.size > 1)
@ -1058,10 +1100,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
randomSongsCache = items randomSongsCache = items
items.map { song -> items.map { song ->
mediaItems.add( mediaItems.add(
buildMediaItemFromTrack( song.toMediaItem(
song, listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|")
listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|"),
isPlayable = true
) )
) )
} }
@ -1070,42 +1110,46 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
} }
} }
private fun playRandomSongs() { private fun playRandomSongs(): List<Track>? {
serviceScope.launch { if (randomSongsCache == null) {
if (randomSongsCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them // In this case we request a new set of random songs
// In this case we request a new set of random songs val content = serviceScope.future {
val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
randomSongsCache = content?.getTracks() }.get()
} randomSongsCache = content?.getTracks()
if (randomSongsCache != null) playSongs(randomSongsCache!!)
} }
if (randomSongsCache != null) return randomSongsCache!!
return null
} }
private fun playRandomSong(songId: String) { private fun playRandomSong(songId: String): List<Track>? {
serviceScope.launch { // If there is no cache, we can't play the selected song.
// If there is no cache, we can't play the selected song. if (randomSongsCache != null) {
if (randomSongsCache != null) { val song = randomSongsCache!!.firstOrNull { x -> x.id == songId }
val song = randomSongsCache!!.firstOrNull { x -> x.id == songId } if (song != null) return listOf(song)
if (song != null) playSong(song)
}
} }
return null
} }
private fun listSongsInMusicService(id: String, name: String): MusicDirectory? { private fun listSongsInMusicService(id: String, name: String): MusicDirectory? {
return if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) { return serviceScope.future {
callWithErrorHandling { musicService.getAlbum(id, name, false) } if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) {
} else { callWithErrorHandling { musicService.getAlbum(id, name, false) }
callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } } else {
} callWithErrorHandling { musicService.getMusicDirectory(id, name, false) }
}
}.get()
} }
private fun listStarredSongsInMusicService(): SearchResult? { private fun listStarredSongsInMusicService(): SearchResult? {
return if (Settings.shouldUseId3Tags) { return serviceScope.future {
callWithErrorHandling { musicService.getStarred2() } if (Settings.shouldUseId3Tags) {
} else { callWithErrorHandling { musicService.getStarred2() }
callWithErrorHandling { musicService.getStarred() } } else {
} callWithErrorHandling { musicService.getStarred() }
}
}.get()
} }
private fun MutableList<MediaItem>.add( private fun MutableList<MediaItem>.add(
@ -1124,20 +1168,28 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
this.add(mediaItem) this.add(mediaItem)
} }
@Suppress("LongParameterList")
private fun MutableList<MediaItem>.add( private fun MutableList<MediaItem>.add(
resId: Int, resId: Int,
mediaId: String, mediaId: String,
groupNameId: Int?, groupNameId: Int?,
browsable: Boolean = true, browsable: Boolean = true,
folderType: Int = FOLDER_TYPE_MIXED folderType: Int = FOLDER_TYPE_MIXED,
icon: Int? = null
) { ) {
val applicationContext = UApp.applicationContext() val applicationContext = UApp.applicationContext()
val mediaItem = buildMediaItem( val mediaItem = buildMediaItem(
applicationContext.getString(resId), applicationContext.getString(resId),
mediaId, mediaId,
isPlayable = false, isPlayable = !browsable,
folderType = folderType folderType = folderType,
group = if (groupNameId != null) {
applicationContext.getString(groupNameId)
} else null,
imageUri = if (icon != null) {
Util.getUriToDrawable(applicationContext, icon)
} else null
) )
this.add(mediaItem) this.add(mediaItem)
@ -1150,7 +1202,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
R.string.select_album_play_all, R.string.select_album_play_all,
mediaId, mediaId,
null, null,
false false,
icon = R.drawable.media_start_normal
) )
} }
@ -1160,28 +1213,6 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
return section.toString() return section.toString()
} }
private fun playSongs(songs: List<Track>) {
mediaPlayerController.addToPlaylist(
songs,
cachePermanently = false,
autoPlay = true,
shuffle = false,
insertionMode = MediaPlayerController.InsertionMode.CLEAR
)
}
private fun playSong(song: Track) {
mediaPlayerController.addToPlaylist(
listOf(song),
cachePermanently = false,
autoPlay = false,
shuffle = false,
insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT
)
if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next()
else mediaPlayerController.play()
}
private fun <T> callWithErrorHandling(function: () -> T): T? { private fun <T> callWithErrorHandling(function: () -> T): T? {
// TODO Implement better error handling // TODO Implement better error handling
return try { return try {
@ -1191,53 +1222,4 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
null null
} }
} }
private fun buildMediaItemFromTrack(
track: Track,
mediaId: String,
isPlayable: Boolean
): MediaItem {
return buildMediaItem(
title = track.title ?: "",
mediaId = mediaId,
isPlayable = isPlayable,
folderType = FOLDER_TYPE_NONE,
album = track.album,
artist = track.artist,
genre = track.genre,
starred = track.starred
)
}
@Suppress("LongParameterList")
private fun buildMediaItem(
title: String,
mediaId: String,
isPlayable: Boolean,
@MediaMetadata.FolderType folderType: Int,
album: String? = null,
artist: String? = null,
genre: String? = null,
sourceUri: Uri? = null,
imageUri: Uri? = null,
starred: Boolean = false
): MediaItem {
val metadata =
MediaMetadata.Builder()
.setAlbumTitle(album)
.setTitle(title)
.setArtist(artist)
.setGenre(genre)
.setUserRating(HeartRating(starred))
.setFolderType(folderType)
.setIsPlayable(isPlayable)
.setArtworkUri(imageUri)
.build()
return MediaItem.Builder()
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setUri(sourceUri)
.build()
}
} }

View File

@ -1,110 +0,0 @@
/*
* LegacyPlaylist.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.playback
import androidx.media3.common.MediaItem
import androidx.media3.session.MediaController
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.service.DownloadFile
import org.moire.ultrasonic.service.Downloader
import org.moire.ultrasonic.service.JukeboxMediaPlayer
import org.moire.ultrasonic.service.RxBus
import org.moire.ultrasonic.util.LRUCache
import timber.log.Timber
/**
* This class keeps a legacy playlist maintained which
* reflects the internal timeline of the Media3.Player
*/
class LegacyPlaylistManager : KoinComponent {
private val _playlist = mutableListOf<DownloadFile>()
@JvmField
var currentPlaying: DownloadFile? = null
// TODO This limits the maximum size of the playlist.
// This will be fixed when this class is refactored and removed
private val mediaItemCache = LRUCache<String, DownloadFile>(2000)
val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
val downloader: Downloader by inject()
private var playlistUpdateRevision: Long = 0
private set(value) {
field = value
RxBus.playlistPublisher.onNext(_playlist)
}
fun rebuildPlaylist(controller: MediaController) {
_playlist.clear()
val n = controller.mediaItemCount
for (i in 0 until n) {
val item = controller.getMediaItemAt(i)
val file = mediaItemCache[item.requestMetadata.toString()]
if (file != null)
_playlist.add(file)
}
playlistUpdateRevision++
}
fun addToCache(item: MediaItem, file: DownloadFile) {
mediaItemCache.put(item.requestMetadata.toString(), file)
}
fun updateCurrentPlaying(item: MediaItem?) {
currentPlaying = mediaItemCache[item?.requestMetadata.toString()]
}
@Synchronized
fun clearPlaylist() {
_playlist.clear()
playlistUpdateRevision++
}
fun onDestroy() {
clearPlaylist()
Timber.i("PlaylistManager destroyed")
}
// Public facing playlist (immutable)
val playlist: List<DownloadFile>
get() = _playlist
@get:Synchronized
val playlistDuration: Long
get() {
var totalDuration: Long = 0
for (downloadFile in _playlist) {
val song = downloadFile.track
if (!song.isDirectory) {
if (song.artist != null) {
if (song.duration != null) {
totalDuration += song.duration!!.toLong()
}
}
}
}
return totalDuration
}
/**
* Extension function
* Gathers the download file for a given song, and modifies shouldSave if provided.
*/
fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
return downloader.getDownloadFileForSong(this).apply {
if (save != null) this.shouldSave = save
}
}
}

View File

@ -22,6 +22,7 @@ import org.koin.core.component.inject
import org.moire.ultrasonic.R import org.moire.ultrasonic.R
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.toTrack
@UnstableApi @UnstableApi
class MediaNotificationProvider(context: Context) : class MediaNotificationProvider(context: Context) :
@ -47,7 +48,7 @@ class MediaNotificationProvider(context: Context) :
* is stored in the track.starred value * is stored in the track.starred value
* See https://github.com/androidx/media/issues/33 * See https://github.com/androidx/media/issues/33
*/ */
val rating = mediaPlayerController.currentPlayingLegacy?.track?.starred?.let { val rating = mediaPlayerController.currentMediaItem?.toTrack()?.starred?.let {
HeartRating( HeartRating(
it it
) )

View File

@ -0,0 +1,88 @@
/*
* AlbumArtContentProvider.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.provider
import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.File
import java.util.Locale
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.imageloader.ImageLoader
import org.moire.ultrasonic.util.FileUtil
import timber.log.Timber
class AlbumArtContentProvider : ContentProvider(), KoinComponent {
private val imageLoader: ImageLoader by inject()
companion object {
fun mapArtworkToContentProviderUri(track: Track?): Uri? {
if (track?.coverArt.isNullOrBlank()) return null
return Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority("org.moire.ultrasonic.provider.AlbumArtContentProvider")
// currently only large files are cached
.path(
String.format(
Locale.ROOT,
"%s|%s", track!!.coverArt, FileUtil.getAlbumArtKey(track, true)
)
)
.build()
}
}
override fun onCreate(): Boolean {
Timber.i("AlbumArtContentProvider.onCreate called")
return true
}
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
val parts = uri.path?.trim('/')?.split('|')
if (parts?.count() != 2 || parts[0].isEmpty() || parts[1].isEmpty()) return null
val albumArtFile = FileUtil.getAlbumArtFile(parts[1])
Timber.d("AlbumArtContentProvider openFile id: %s; file: %s", parts[0], albumArtFile)
imageLoader.cacheCoverArt(parts[0], albumArtFile)
val file = File(albumArtFile)
if (!file.exists()) return null
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor? = null
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<String>?
) = 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?) = 0
override fun getType(uri: Uri): String? = null
override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String> {
return arrayOf("image/jpeg", "image/png", "image/gif")
}
}

View File

@ -1,231 +0,0 @@
/*
* DownloadFile.kt
* Copyright (C) 2009-2021 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import androidx.lifecycle.MutableLiveData
import androidx.media3.common.MediaItem
import java.io.IOException
import java.util.Locale
import org.koin.core.component.KoinComponent
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track
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 timber.log.Timber
/**
* This class represents a single Song or Video that can be downloaded.
*
* Terminology:
* PinnedFile: A "pinned" song. Will stay in cache permanently
* CompleteFile: A "downloaded" song. Will be quicker to be deleted if the cache is full
*
*/
class DownloadFile(
val track: Track,
save: Boolean
) : KoinComponent, Identifiable {
val partialFile: String
lateinit var completeFile: String
val pinnedFile: String = FileUtil.getSongFile(track)
var shouldSave = save
internal var downloadTask: CancellableTask? = null
var isFailed = false
internal var retryCount = MAX_RETRIES
val desiredBitRate: Int = Settings.maxBitRate
var priority = 100
var downloadPrepared = false
@Volatile
internal var saveWhenDone = false
@Volatile
var completeWhenDone = false
val progress: MutableLiveData<Int> = MutableLiveData(0)
// We must be able to query if the status is initialized.
// The status is lazy because DownloadFiles are usually created in bulk, and
// checking their status possibly means a slow SAF operation.
val isStatusInitialized: Boolean
get() = lazyInitialStatus.isInitialized()
private val lazyInitialStatus: Lazy<DownloadStatus> = lazy {
when {
Storage.isPathExists(pinnedFile) -> {
DownloadStatus.PINNED
}
Storage.isPathExists(completeFile) -> {
DownloadStatus.DONE
}
else -> {
DownloadStatus.IDLE
}
}
}
val status: MutableLiveData<DownloadStatus> by lazy {
MutableLiveData(lazyInitialStatus.value)
}
init {
partialFile = FileUtil.getParentPath(pinnedFile) + "/" +
FileUtil.getPartialFile(FileUtil.getNameFromPath(pinnedFile))
completeFile = FileUtil.getParentPath(pinnedFile) + "/" +
FileUtil.getCompleteFile(FileUtil.getNameFromPath(pinnedFile))
}
/**
* Returns the effective bit rate.
*/
fun getBitRate(): Int {
return if (track.bitRate == null) desiredBitRate else track.bitRate!!
}
@Synchronized
fun prepare() {
// It is necessary to signal that the download will begin shortly on another thread
// so it won't get cleaned up accidentally
downloadPrepared = true
}
@Synchronized
fun cancelDownload() {
downloadTask?.cancel()
}
val completeOrSaveFile: String
get() = if (Storage.isPathExists(pinnedFile)) {
pinnedFile
} else {
completeFile
}
val isSaved: Boolean
get() = Storage.isPathExists(pinnedFile)
@get:Synchronized
val isCompleteFileAvailable: Boolean
get() = Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)
@get:Synchronized
val isWorkDone: Boolean
get() = Storage.isPathExists(completeFile) && !shouldSave ||
Storage.isPathExists(pinnedFile) || saveWhenDone || completeWhenDone
@get:Synchronized
val isDownloading: Boolean
get() = downloadPrepared || (downloadTask != null && downloadTask!!.isRunning)
@get:Synchronized
val isDownloadCancelled: Boolean
get() = downloadTask != null && downloadTask!!.isCancelled
fun shouldRetry(): Boolean {
return (retryCount > 0)
}
fun delete() {
cancelDownload()
Storage.delete(partialFile)
Storage.delete(completeFile)
Storage.delete(pinnedFile)
status.postValue(DownloadStatus.IDLE)
Util.scanMedia(pinnedFile)
}
fun unpin() {
Timber.e("CLEANING")
val file = Storage.getFromPath(pinnedFile) ?: return
Storage.rename(file, completeFile)
status.postValue(DownloadStatus.DONE)
}
fun cleanup(): Boolean {
Timber.e("CLEANING")
var ok = true
if (Storage.isPathExists(completeFile) || Storage.isPathExists(pinnedFile)) {
ok = Storage.delete(partialFile)
}
if (Storage.isPathExists(pinnedFile)) {
ok = ok and Storage.delete(completeFile)
}
return ok
}
/**
* Create a MediaItem instance representing the data inside this DownloadFile
*/
val mediaItem: MediaItem by lazy {
track.toMediaItem()
}
var isPlaying: Boolean = false
get() = field
set(isPlaying) {
if (!isPlaying) doPendingRename()
field = isPlaying
}
// Do a pending rename after the song has stopped playing
private fun doPendingRename() {
try {
Timber.e("CLEANING")
if (saveWhenDone) {
Storage.rename(completeFile, pinnedFile)
saveWhenDone = false
} else if (completeWhenDone) {
if (shouldSave) {
Storage.rename(partialFile, pinnedFile)
Util.scanMedia(pinnedFile)
} else {
Storage.rename(partialFile, completeFile)
}
completeWhenDone = false
}
} catch (e: IOException) {
Timber.w(e, "Failed to rename file %s to %s", completeFile, pinnedFile)
}
}
override fun toString(): String {
return String.format(Locale.ROOT, "DownloadFile (%s)", track)
}
internal fun setProgress(totalBytesCopied: Long) {
if (track.size != null) {
progress.postValue((totalBytesCopied * 100 / track.size!!).toInt())
}
}
override fun compareTo(other: Identifiable) = compareTo(other as DownloadFile)
fun compareTo(other: DownloadFile): Int {
return priority.compareTo(other.priority)
}
override val id: String
get() = track.id
companion object {
const val MAX_RETRIES = 5
}
}
enum class DownloadStatus {
IDLE, DOWNLOADING, RETRYING, FAILED, CANCELLED, DONE, PINNED, UNKNOWN
}

View File

@ -1,8 +1,16 @@
/*
* Downloader.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service package org.moire.ultrasonic.service
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock as SystemClock
import android.text.TextUtils import android.text.TextUtils
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -16,17 +24,21 @@ import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.data.MetaDatabase import org.moire.ultrasonic.data.MetaDatabase
import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Identifiable
import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.playback.LegacyPlaylistManager
import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.LRUCache 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.Settings
import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.safeClose import org.moire.ultrasonic.util.Util.safeClose
import org.moire.ultrasonic.util.shouldBePinned
import org.moire.ultrasonic.util.toTrack
import timber.log.Timber import timber.log.Timber
/** /**
@ -37,27 +49,25 @@ import timber.log.Timber
*/ */
class Downloader( class Downloader(
private val storageMonitor: ExternalStorageMonitor, private val storageMonitor: ExternalStorageMonitor,
private val legacyPlaylistManager: LegacyPlaylistManager,
) : KoinComponent { ) : KoinComponent {
// Dependencies // Dependencies
private val imageLoaderProvider: ImageLoaderProvider by inject() private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject() private val activeServerProvider: ActiveServerProvider by inject()
private val mediaController: MediaPlayerController by inject() private val mediaController: MediaPlayerController by inject()
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
var started: Boolean = false var started: Boolean = false
var shouldStop: Boolean = false var shouldStop: Boolean = false
var isPolling: Boolean = false var isPolling: Boolean = false
private val downloadQueue = PriorityQueue<DownloadFile>() private val downloadQueue = PriorityQueue<DownloadableTrack>()
private val activelyDownloading = mutableListOf<DownloadFile>() 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 // 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. // surrounding playback the list of Downloads is published as LiveData.
val observableDownloads = MutableLiveData<List<DownloadFile>>() val observableDownloads = MutableLiveData<List<Track>>()
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
private val downloadFileCache = LRUCache<Track, DownloadFile>(500)
private var handler: Handler = Handler(Looper.getMainLooper()) private var handler: Handler = Handler(Looper.getMainLooper())
private var wifiLock: WifiManager.WifiLock? = null private var wifiLock: WifiManager.WifiLock? = null
@ -124,7 +134,10 @@ class Downloader(
shouldStop = true shouldStop = true
wifiLock?.release() wifiLock?.release()
wifiLock = null wifiLock = null
DownloadService.runningInstance?.notifyDownloaderStopped() handler.postDelayed(
Runnable { DownloadService.runningInstance?.notifyDownloaderStopped() },
100
)
Timber.i("Downloader stopped") Timber.i("Downloader stopped")
} }
@ -150,56 +163,48 @@ class Downloader(
return return
} }
if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) { if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
return return
} }
Timber.v("Downloader checkDownloadsInternal checking downloads") Timber.v("Downloader checkDownloadsInternal checking downloads")
// Check the active downloads for failures or completions and remove them var listChanged = false
// Store the result in a flag to know if changes have occurred val playlist = mediaController.getNextPlaylistItemsInPlayOrder(Settings.preloadCount)
var listChanged = cleanupActiveDownloads() var priority = 0
val playlist = legacyPlaylistManager.playlist for (item in playlist) {
val track = item.toTrack()
// Check if need to preload more from playlist
val preloadCount = Settings.preloadCount
// Start preloading at the current playing song
var start = mediaController.currentMediaItemIndex
if (start == -1 || start > playlist.size) start = 0
val end = (start + preloadCount).coerceAtMost(playlist.size)
for (i in start until end) {
val download = playlist[i]
// Set correct priority (the lower the number, the higher the priority)
download.priority = i
// Add file to queue if not in one of the queues already. // Add file to queue if not in one of the queues already.
if (!download.isWorkDone && if (getDownloadState(track) == DownloadStatus.IDLE) {
!activelyDownloading.contains(download) &&
!downloadQueue.contains(download) &&
download.shouldRetry()
) {
listChanged = true listChanged = true
downloadQueue.add(download)
// 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
return
}
// Set correct priority (the lower the number, the higher the priority)
downloadQueue.add(DownloadableTrack(track, item.shouldBePinned(), 0, priority++))
} }
} }
// Fill up active List with waiting tasks // Fill up active List with waiting tasks
while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) { while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) {
val task = downloadQueue.remove() val task = downloadQueue.remove()
activelyDownloading.add(task) val downloadTask = DownloadTask(task)
activelyDownloading[task] = downloadTask
startDownloadOnService(task) startDownloadOnService(task)
listChanged = true listChanged = true
} }
// Stop Executor service when done downloading // Stop Executor service when done downloading
if (activelyDownloading.size == 0) { if (activelyDownloading.isEmpty()) {
stop() stop()
} }
@ -212,89 +217,36 @@ class Downloader(
observableDownloads.postValue(downloads) observableDownloads.postValue(downloads)
} }
private fun startDownloadOnService(file: DownloadFile) { private fun startDownloadOnService(track: DownloadableTrack) {
if (file.isDownloading) return
file.prepare()
DownloadService.executeOnStartedDownloadService { DownloadService.executeOnStartedDownloadService {
FileUtil.createDirectoryForParent(file.pinnedFile) FileUtil.createDirectoryForParent(track.pinnedFile)
file.isFailed = false activelyDownloading[track]?.start()
file.downloadTask = DownloadTask(file) Timber.v("startDownloadOnService started downloading file ${track.completeFile}")
file.downloadTask!!.start()
Timber.v("startDownloadOnService started downloading file ${file.completeFile}")
} }
} }
/**
* Return true if modifications were made
*/
private fun cleanupActiveDownloads(): Boolean {
val oldSize = activelyDownloading.size
activelyDownloading.retainAll {
when {
it.isDownloading -> true
it.isFailed && it.shouldRetry() -> {
// Add it back to queue
downloadQueue.add(it)
false
}
else -> {
it.cleanup()
false
}
}
}
return (oldSize != activelyDownloading.size)
}
@get:Synchronized
val all: List<DownloadFile>
get() {
val temp: MutableList<DownloadFile> = ArrayList()
temp.addAll(activelyDownloading)
temp.addAll(downloadQueue)
temp.addAll(legacyPlaylistManager.playlist)
return temp.distinct().sorted()
}
/* /*
* Returns a list of all DownloadFiles that are currently downloading or waiting for download, * Returns a list of all DownloadFiles that are currently downloading or waiting for download,
* including undownloaded files from the playlist. */
*/
@get:Synchronized @get:Synchronized
val downloads: List<DownloadFile> val downloads: List<Track>
get() { get() {
val temp: MutableList<DownloadFile> = ArrayList() val temp: MutableList<Track> = ArrayList()
temp.addAll(activelyDownloading) temp.addAll(activelyDownloading.keys.map { x -> x.track })
temp.addAll(downloadQueue) temp.addAll(downloadQueue.map { x -> x.track })
temp.addAll(
legacyPlaylistManager.playlist.filter {
if (!it.isStatusInitialized) false
else when (it.status.value) {
DownloadStatus.DOWNLOADING -> true
else -> false
}
}
)
return temp.distinct().sorted() return temp.distinct().sorted()
} }
@Synchronized
fun clearDownloadFileCache() {
downloadFileCache.clear()
}
@Synchronized @Synchronized
fun clearBackground() { fun clearBackground() {
// Clear the pending queue // Clear the pending queue
downloadQueue.clear() downloadQueue.clear()
// Cancel all active downloads with a low priority // Cancel all active downloads with a low priority
for (download in activelyDownloading) { for (key in activelyDownloading.keys) {
if (download.priority >= 100) { if (key.priority >= 100) {
download.cancelDownload() activelyDownloading[key]?.cancel()
activelyDownloading.remove(download) activelyDownloading.remove(key)
} }
} }
@ -305,125 +257,125 @@ class Downloader(
fun clearActiveDownloads() { fun clearActiveDownloads() {
// Cancel all active downloads // Cancel all active downloads
for (download in activelyDownloading) { for (download in activelyDownloading) {
download.cancelDownload() download.value.cancel()
} }
activelyDownloading.clear() activelyDownloading.clear()
updateLiveData() updateLiveData()
} }
@Synchronized @Synchronized
fun downloadBackground(songs: List<Track>, save: Boolean) { fun downloadBackground(tracks: List<Track>, save: Boolean) {
// By using the counter we ensure that the songs are added in the correct order // By using the counter we ensure that the songs are added in the correct order
for (song in songs) { for (track in tracks) {
val file = song.getDownloadFile() if (downloadQueue.any { t -> t.track.id == track.id }) continue
file.shouldSave = save val file = DownloadableTrack(track, save, 0, backgroundPriorityCounter++)
if (!file.isDownloading) { downloadQueue.add(file)
file.priority = backgroundPriorityCounter++
downloadQueue.add(file)
}
} }
Timber.v("downloadBackground Checking Downloads") Timber.v("downloadBackground Checking Downloads")
checkDownloads() checkDownloads()
} }
@Synchronized fun delete(track: Track) {
cancelDownload(track)
Storage.delete(track.getPartialFile())
Storage.delete(track.getCompleteFile())
Storage.delete(track.getPinnedFile())
postState(track, DownloadStatus.IDLE, 0)
Util.scanMedia(track.getPinnedFile())
}
fun cancelDownload(track: Track) {
val key = activelyDownloading.keys.singleOrNull { t -> t.track.id == track.id } ?: return
activelyDownloading[key]?.cancel()
}
fun unpin(track: Track) {
val file = Storage.getFromPath(track.getPinnedFile()) ?: return
Storage.rename(file, track.getCompleteFile())
postState(track, DownloadStatus.DONE, 100)
}
@Suppress("ReturnCount") @Suppress("ReturnCount")
fun getDownloadFileForSong(song: Track): DownloadFile { fun getDownloadState(track: Track): DownloadStatus {
for (downloadFile in legacyPlaylistManager.playlist) { if (Storage.isPathExists(track.getCompleteFile())) return DownloadStatus.DONE
if (downloadFile.track == song) { if (Storage.isPathExists(track.getPinnedFile())) return DownloadStatus.PINNED
return downloadFile
} val key = activelyDownloading.keys.firstOrNull { k -> k.track.id == track.id }
if (key != null) {
if (key.tryCount > 0) return DownloadStatus.RETRYING
return DownloadStatus.DOWNLOADING
} }
for (downloadFile in activelyDownloading) { if (failedList.any { t -> t.track.id == track.id }) return DownloadStatus.FAILED
if (downloadFile.track == song) { return DownloadStatus.IDLE
return downloadFile
}
}
for (downloadFile in downloadQueue) {
if (downloadFile.track == song) {
return downloadFile
}
}
var downloadFile = downloadFileCache[song]
if (downloadFile == null) {
downloadFile = DownloadFile(song, false)
downloadFileCache.put(song, downloadFile)
}
return downloadFile
} }
companion object { companion object {
const val CHECK_INTERVAL = 5000L const val CHECK_INTERVAL = 5000L
const val MAX_RETRIES = 5
const val REFRESH_INTERVAL = 100
} }
/** private fun postState(track: Track, state: DownloadStatus, progress: Int) {
* Extension function RxBus.trackDownloadStatePublisher.onNext(
* Gathers the download file for a given song, and modifies shouldSave if provided. RxBus.TrackDownloadState(
*/ track,
private fun Track.getDownloadFile(save: Boolean? = null): DownloadFile { state,
return getDownloadFileForSong(this).apply { progress
if (save != null) this.shouldSave = save )
} )
} }
private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() { private inner class DownloadTask(private val item: DownloadableTrack) :
CancellableTask() {
val musicService = MusicServiceFactory.getMusicService() val musicService = MusicServiceFactory.getMusicService()
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth") @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "TooGenericExceptionThrown")
override fun execute() { override fun execute() {
downloadFile.downloadPrepared = false
var inputStream: InputStream? = null var inputStream: InputStream? = null
var outputStream: OutputStream? = null var outputStream: OutputStream? = null
try { try {
if (Storage.isPathExists(downloadFile.pinnedFile)) { if (Storage.isPathExists(item.pinnedFile)) {
Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile) Timber.i("%s already exists. Skipping.", item.pinnedFile)
downloadFile.status.postValue(DownloadStatus.PINNED) postState(item.track, DownloadStatus.PINNED, 100)
return return
} }
if (Storage.isPathExists(downloadFile.completeFile)) { if (Storage.isPathExists(item.completeFile)) {
var newStatus: DownloadStatus = DownloadStatus.DONE var newStatus: DownloadStatus = DownloadStatus.DONE
if (downloadFile.shouldSave) { if (item.pinned) {
if (downloadFile.isPlaying) { Storage.rename(
downloadFile.saveWhenDone = true item.completeFile,
} else { item.pinnedFile
Storage.rename( )
downloadFile.completeFile, newStatus = DownloadStatus.PINNED
downloadFile.pinnedFile
)
newStatus = DownloadStatus.PINNED
}
} else { } else {
Timber.i( Timber.i(
"%s already exists. Skipping.", "%s already exists. Skipping.",
downloadFile.completeFile item.completeFile
) )
} }
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata.. // Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
try { try {
downloadFile.track.cacheMetadata() item.track.cacheMetadata()
} catch (ignore: Exception) { } catch (ignore: Exception) {
Timber.w(ignore) Timber.w(ignore)
} }
postState(item.track, newStatus, 100)
downloadFile.status.postValue(newStatus)
return return
} }
downloadFile.status.postValue(DownloadStatus.DOWNLOADING) postState(item.track, DownloadStatus.DOWNLOADING, 0)
// Some devices seem to throw error on partial file which doesn't exist // Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean val needsDownloading: Boolean
val duration = downloadFile.track.duration val duration = item.track.duration
val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0 val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0
needsDownloading = ( needsDownloading = (
downloadFile.desiredBitRate == 0 || duration == null ||
duration == null ||
duration == 0 || duration == 0 ||
fileLength == 0L fileLength == 0L
) )
@ -431,9 +383,9 @@ class Downloader(
if (needsDownloading) { if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists. // Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, isPartial) = musicService.getDownloadInputStream( val (inStream, isPartial) = musicService.getDownloadInputStream(
downloadFile.track, fileLength, item.track, fileLength,
downloadFile.desiredBitRate, Settings.maxBitRate,
downloadFile.shouldSave item.pinned
) )
inputStream = inStream inputStream = inStream
@ -442,31 +394,40 @@ class Downloader(
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
} }
outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile) outputStream = Storage.getOrCreateFileFromPath(item.partialFile)
.getFileOutputStream(isPartial) .getFileOutputStream(isPartial)
var lastPostTime: Long = 0
val len = inputStream.copyTo(outputStream) { totalBytesCopied -> val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
downloadFile.setProgress(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, downloadFile.partialFile) Timber.i("Downloaded %d bytes to %s", len, item.partialFile)
inputStream.close() inputStream.close()
outputStream.flush() outputStream.flush()
outputStream.close() outputStream.close()
if (isCancelled) { if (isCancelled) {
downloadFile.status.postValue(DownloadStatus.CANCELLED) postState(item.track, DownloadStatus.CANCELLED, 0)
throw RuntimeException( throw RuntimeException(
String.format( String.format(
Locale.ROOT, "Download of '%s' was cancelled", Locale.ROOT, "Download of '%s' was cancelled",
downloadFile.track item
) )
) )
} }
try { try {
downloadFile.track.cacheMetadata() item.track.cacheMetadata()
} catch (ignore: Exception) { } catch (ignore: Exception) {
Timber.w(ignore) Timber.w(ignore)
} }
@ -474,40 +435,40 @@ class Downloader(
downloadAndSaveCoverArt() downloadAndSaveCoverArt()
} }
if (downloadFile.isPlaying) { if (item.pinned) {
downloadFile.completeWhenDone = true Storage.rename(
item.partialFile,
item.pinnedFile
)
postState(item.track, DownloadStatus.PINNED, 100)
Util.scanMedia(item.pinnedFile)
} else { } else {
if (downloadFile.shouldSave) { Storage.rename(
Storage.rename( item.partialFile,
downloadFile.partialFile, item.completeFile
downloadFile.pinnedFile )
) postState(item.track, DownloadStatus.DONE, 100)
downloadFile.status.postValue(DownloadStatus.PINNED)
Util.scanMedia(downloadFile.pinnedFile)
} else {
Storage.rename(
downloadFile.partialFile,
downloadFile.completeFile
)
downloadFile.status.postValue(DownloadStatus.DONE)
}
} }
} catch (all: Exception) { } catch (all: Exception) {
outputStream.safeClose() outputStream.safeClose()
Storage.delete(downloadFile.completeFile) Storage.delete(item.completeFile)
Storage.delete(downloadFile.pinnedFile) Storage.delete(item.pinnedFile)
if (!isCancelled) { if (!isCancelled) {
downloadFile.isFailed = true if (item.tryCount < MAX_RETRIES) {
if (downloadFile.retryCount > 1) { postState(item.track, DownloadStatus.RETRYING, 0)
downloadFile.status.postValue(DownloadStatus.RETRYING) item.tryCount++
--downloadFile.retryCount activelyDownloading.remove(item)
} else if (downloadFile.retryCount == 1) { downloadQueue.add(item)
downloadFile.status.postValue(DownloadStatus.FAILED) } else {
--downloadFile.retryCount postState(item.track, DownloadStatus.FAILED, 0)
activelyDownloading.remove(item)
downloadQueue.remove(item)
failedList.add(item)
} }
Timber.w(all, "Failed to download '%s'.", downloadFile.track) Timber.w(all, "Failed to download '%s'.", item)
} }
} finally { } finally {
activelyDownloading.remove(item)
inputStream.safeClose() inputStream.safeClose()
outputStream.safeClose() outputStream.safeClose()
CacheCleaner().cleanSpace() CacheCleaner().cleanSpace()
@ -517,7 +478,7 @@ class Downloader(
} }
override fun toString(): String { override fun toString(): String {
return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track) return String.format(Locale.ROOT, "DownloadTask (%s)", item)
} }
private fun Track.cacheMetadata() { private fun Track.cacheMetadata() {
@ -567,9 +528,9 @@ class Downloader(
private fun downloadAndSaveCoverArt() { private fun downloadAndSaveCoverArt() {
try { try {
if (!TextUtils.isEmpty(downloadFile.track.coverArt)) { if (!TextUtils.isEmpty(item.track.coverArt)) {
// Download the largest size that we can display in the UI // Download the largest size that we can display in the UI
imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track) imageLoaderProvider.getImageLoader().cacheCoverArt(item.track)
} }
} catch (all: Exception) { } catch (all: Exception) {
Timber.e(all, "Failed to get cover art.") Timber.e(all, "Failed to get cover art.")
@ -590,4 +551,26 @@ class Downloader(
return bytesCopied 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, DOWNLOADING, RETRYING, FAILED, CANCELLED, DONE, PINNED, UNKNOWN
} }

View File

@ -42,7 +42,7 @@ import timber.log.Timber
* TODO: Persist RC state? * TODO: Persist RC state?
* TODO: Minimize status updates. * TODO: Minimize status updates.
*/ */
class JukeboxMediaPlayer(private val downloader: Downloader) { class JukeboxMediaPlayer {
private val tasks = TaskQueue() private val tasks = TaskQueue()
private val executorService = Executors.newSingleThreadScheduledExecutor() private val executorService = Executors.newSingleThreadScheduledExecutor()
private var statusUpdateFuture: ScheduledFuture<*>? = null private var statusUpdateFuture: ScheduledFuture<*>? = null
@ -156,8 +156,8 @@ class JukeboxMediaPlayer(private val downloader: Downloader) {
tasks.remove(Stop::class.java) tasks.remove(Stop::class.java)
tasks.remove(Start::class.java) tasks.remove(Start::class.java)
val ids: MutableList<String> = ArrayList() val ids: MutableList<String> = ArrayList()
for (file in downloader.all) { for (item in mediaPlayerControllerLazy.value.playlist) {
ids.add(file.track.id) ids.add(item.mediaId)
} }
tasks.add(SetPlaylist(ids)) tasks.add(SetPlaylist(ids))
} }

View File

@ -1,6 +1,6 @@
/* /*
* MediaPlayerController.kt * MediaPlayerController.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -10,11 +10,11 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.widget.Toast import android.widget.Toast
import androidx.core.net.toUri import androidx.media3.common.C
import androidx.media3.common.HeartRating import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
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
@ -23,7 +23,6 @@ import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.io.File
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -32,16 +31,17 @@ import org.koin.core.component.inject
import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.app.UApp
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.playback.LegacyPlaylistManager
import org.moire.ultrasonic.playback.PlaybackService import org.moire.ultrasonic.playback.PlaybackService
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.MainThreadExecutor import org.moire.ultrasonic.util.MainThreadExecutor
import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.setPin
import org.moire.ultrasonic.util.toMediaItem
import org.moire.ultrasonic.util.toTrack
import timber.log.Timber import timber.log.Timber
/** /**
@ -54,7 +54,6 @@ class MediaPlayerController(
private val playbackStateSerializer: PlaybackStateSerializer, private val playbackStateSerializer: PlaybackStateSerializer,
private val externalStorageMonitor: ExternalStorageMonitor, private val externalStorageMonitor: ExternalStorageMonitor,
private val downloader: Downloader, private val downloader: Downloader,
private val legacyPlaylistManager: LegacyPlaylistManager,
val context: Context val context: Context
) : KoinComponent { ) : KoinComponent {
@ -85,6 +84,8 @@ class MediaPlayerController(
private lateinit var listeners: Player.Listener private lateinit var listeners: Player.Listener
private var cachedMediaItem: MediaItem? = null
fun onCreate(onCreated: () -> Unit) { fun onCreate(onCreated: () -> Unit) {
if (created) return if (created) return
externalStorageMonitor.onCreate { reset() } externalStorageMonitor.onCreate { reset() }
@ -111,7 +112,7 @@ class MediaPlayerController(
* We run the event through RxBus in order to throttle them * We run the event through RxBus in order to throttle them
*/ */
override fun onTimelineChanged(timeline: Timeline, reason: Int) { override fun onTimelineChanged(timeline: Timeline, reason: Int) {
legacyPlaylistManager.rebuildPlaylist(controller!!) RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack))
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
@ -125,8 +126,8 @@ class MediaPlayerController(
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
onTrackCompleted() clearBookmark()
legacyPlaylistManager.updateCurrentPlaying(mediaItem) cachedMediaItem = mediaItem
publishPlaybackState() publishPlaybackState()
} }
@ -180,7 +181,7 @@ class MediaPlayerController(
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe { rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
playbackStateSerializer.serializeNow( playbackStateSerializer.serializeNow(
playList, playlist.map { it.toTrack() },
currentMediaItemIndex, currentMediaItemIndex,
playerPosition, playerPosition,
isShufflePlayEnabled, isShufflePlayEnabled,
@ -196,7 +197,7 @@ class MediaPlayerController(
private fun playerStateChangedHandler() { private fun playerStateChangedHandler() {
val currentPlaying = legacyPlaylistManager.currentPlaying val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
when (playbackState) { when (playbackState) {
Player.STATE_READY -> { Player.STATE_READY -> {
@ -210,16 +211,14 @@ class MediaPlayerController(
} }
// Update widget // Update widget
if (currentPlaying != null) { updateWidget(currentPlaying)
updateWidget(currentPlaying.track)
}
} }
private fun onTrackCompleted() { private fun clearBookmark() {
// This method is called before we update the currentPlaying, // This method is called just before we update the cachedMediaItem,
// so in fact currentPlaying will refer to the track that has just finished. // so in fact cachedMediaItem will refer to the track that has just finished.
if (legacyPlaylistManager.currentPlaying != null) { if (cachedMediaItem != null) {
val song = legacyPlaylistManager.currentPlaying!!.track val song = cachedMediaItem!!.toTrack()
if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) { if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) {
val musicService = getMusicService() val musicService = getMusicService()
try { try {
@ -232,7 +231,7 @@ class MediaPlayerController(
private fun publishPlaybackState() { private fun publishPlaybackState() {
val newState = RxBus.StateWithTrack( val newState = RxBus.StateWithTrack(
track = legacyPlaylistManager.currentPlaying, track = currentMediaItem?.let { it.toTrack() },
index = currentMediaItemIndex, index = currentMediaItemIndex,
isPlaying = isPlaying, isPlaying = isPlaying,
state = playbackState state = playbackState
@ -261,7 +260,6 @@ class MediaPlayerController(
val context = UApp.applicationContext() val context = UApp.applicationContext()
externalStorageMonitor.onDestroy() externalStorageMonitor.onDestroy()
context.stopService(Intent(context, DownloadService::class.java)) context.stopService(Intent(context, DownloadService::class.java))
legacyPlaylistManager.onDestroy()
downloader.onDestroy() downloader.onDestroy()
created = false created = false
Timber.i("MediaPlayerController destroyed") Timber.i("MediaPlayerController destroyed")
@ -345,12 +343,17 @@ class MediaPlayerController(
@Synchronized @Synchronized
fun seekTo(position: Int) { fun seekTo(position: Int) {
if (controller?.currentTimeline?.isEmpty != false) return
Timber.i("SeekTo: %s", position) Timber.i("SeekTo: %s", position)
controller?.seekTo(position.toLong()) controller?.seekTo(position.toLong())
} }
@Synchronized @Synchronized
fun seekTo(index: Int, position: Int) { fun seekTo(index: Int, position: Int) {
// This case would throw an exception in Media3. It can happen when an inconsistent state is saved.
if (controller?.currentTimeline?.isEmpty != false ||
index >= controller!!.currentTimeline.windowCount
) return
Timber.i("SeekTo: %s %s", index, position) Timber.i("SeekTo: %s %s", index, position)
controller?.seekTo(index, position.toLong()) controller?.seekTo(index, position.toLong())
} }
@ -390,10 +393,8 @@ class MediaPlayerController(
} }
val mediaItems: List<MediaItem> = songs.map { val mediaItems: List<MediaItem> = songs.map {
val downloadFile = downloader.getDownloadFileForSong(it)
if (cachePermanently) downloadFile.shouldSave = true
val result = it.toMediaItem() val result = it.toMediaItem()
legacyPlaylistManager.addToCache(result, downloader.getDownloadFileForSong(it)) if (cachePermanently) result.setPin(true)
result result
} }
@ -426,9 +427,8 @@ class MediaPlayerController(
get() = controller?.shuffleModeEnabled == true get() = controller?.shuffleModeEnabled == true
set(enabled) { set(enabled) {
controller?.shuffleModeEnabled = enabled controller?.shuffleModeEnabled = enabled
if (enabled) { // Changing Shuffle may change the playlist, so the next tracks may need to be downloaded
downloader.checkDownloads() downloader.checkDownloads()
}
} }
@Synchronized @Synchronized
@ -467,11 +467,6 @@ class MediaPlayerController(
jukeboxMediaPlayer.updatePlaylist() jukeboxMediaPlayer.updatePlaylist()
} }
@Synchronized
fun clearCaches() {
downloader.clearDownloadFileCache()
}
@Synchronized @Synchronized
fun clearIncomplete() { fun clearIncomplete() {
reset() reset()
@ -496,7 +491,7 @@ class MediaPlayerController(
if (currentMediaItemIndex == -1) return if (currentMediaItemIndex == -1) return
playbackStateSerializer.serializeAsync( playbackStateSerializer.serializeAsync(
songs = legacyPlaylistManager.playlist, songs = playlist.map { it.toTrack() },
currentPlayingIndex = currentMediaItemIndex, currentPlayingIndex = currentMediaItemIndex,
currentPlayingPosition = playerPosition, currentPlayingPosition = playerPosition,
isShufflePlayEnabled, isShufflePlayEnabled,
@ -506,17 +501,17 @@ class MediaPlayerController(
@Synchronized @Synchronized
// TODO: Make it require not null // TODO: Make it require not null
fun delete(songs: List<Track?>) { fun delete(tracks: List<Track?>) {
for (song in songs.filterNotNull()) { for (track in tracks.filterNotNull()) {
downloader.getDownloadFileForSong(song).delete() downloader.delete(track)
} }
} }
@Synchronized @Synchronized
// TODO: Make it require not null // TODO: Make it require not null
fun unpin(songs: List<Track?>) { fun unpin(tracks: List<Track?>) {
for (song in songs.filterNotNull()) { for (track in tracks.filterNotNull()) {
downloader.getDownloadFileForSong(song).unpin() downloader.unpin(track)
} }
} }
@ -598,8 +593,8 @@ class MediaPlayerController(
} }
fun toggleSongStarred() { fun toggleSongStarred() {
if (legacyPlaylistManager.currentPlaying == null) return if (currentMediaItem == null) return
val song = legacyPlaylistManager.currentPlaying!!.track val song = currentMediaItem!!.toTrack()
controller?.setRating( controller?.setRating(
HeartRating(!song.starred) HeartRating(!song.starred)
@ -630,8 +625,8 @@ class MediaPlayerController(
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
fun setSongRating(rating: Int) { fun setSongRating(rating: Int) {
if (!Settings.useFiveStarRating) return if (!Settings.useFiveStarRating) return
if (legacyPlaylistManager.currentPlaying == null) return if (currentMediaItem == null) return
val song = legacyPlaylistManager.currentPlaying!!.track val song = currentMediaItem!!.toTrack()
song.userRating = rating song.userRating = rating
Thread { Thread {
try { try {
@ -650,29 +645,56 @@ class MediaPlayerController(
val currentMediaItemIndex: Int val currentMediaItemIndex: Int
get() = controller?.currentMediaItemIndex ?: -1 get() = controller?.currentMediaItemIndex ?: -1
@Deprecated("Use currentMediaItem")
val currentPlayingLegacy: DownloadFile?
get() = legacyPlaylistManager.currentPlaying
val mediaItemCount: Int val mediaItemCount: Int
get() = controller?.mediaItemCount ?: 0 get() = controller?.mediaItemCount ?: 0
@Deprecated("Use mediaItemCount")
val playlistSize: Int val playlistSize: Int
get() = legacyPlaylistManager.playlist.size get() = controller?.currentTimeline?.windowCount ?: 0
@Deprecated("Use native APIs") val playlist: List<MediaItem>
val playList: List<DownloadFile> get() {
get() = legacyPlaylistManager.playlist return getPlayList(false)
}
@Deprecated("Use timeline") val playlistInPlayOrder: List<MediaItem>
val playListDuration: Long get() {
get() = legacyPlaylistManager.playlistDuration return getPlayList(controller?.shuffleModeEnabled ?: false)
}
fun getDownloadFileForSong(song: Track): DownloadFile { fun getNextPlaylistItemsInPlayOrder(count: Int? = null): List<MediaItem> {
return downloader.getDownloadFileForSong(song) 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)
}
init { init {
Timber.i("MediaPlayerController instance initiated") Timber.i("MediaPlayerController instance initiated")
} }
@ -681,38 +703,3 @@ class MediaPlayerController(
CLEAR, APPEND, AFTER_CURRENT CLEAR, APPEND, AFTER_CURRENT
} }
} }
/*
* TODO: Merge with the Builder functions in AutoMediaBrowserCallback
*/
fun Track.toMediaItem(): MediaItem {
val filePath = FileUtil.getSongFile(this)
val bitrate = Settings.maxBitRate
val uri = "$id|$bitrate|$filePath"
val rmd = MediaItem.RequestMetadata.Builder()
.setMediaUri(uri.toUri())
.build()
val artworkFile = File(FileUtil.getAlbumArtFile(this))
val metadata = MediaMetadata.Builder()
metadata.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.setAlbumArtist(artist)
.setUserRating(HeartRating(starred))
if (artworkFile.exists()) {
metadata.setArtworkUri(artworkFile.toUri())
}
val mediaItem = MediaItem.Builder()
.setUri(uri)
.setMediaId(id)
.setRequestMetadata(rmd)
.setMediaMetadata(metadata.build())
return mediaItem.build()
}

View File

@ -15,6 +15,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.Constants
import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.FileUtil
import timber.log.Timber import timber.log.Timber
@ -32,7 +33,7 @@ class PlaybackStateSerializer : KoinComponent {
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun serializeAsync( fun serializeAsync(
songs: Iterable<DownloadFile>, songs: Iterable<Track>,
currentPlayingIndex: Int, currentPlayingIndex: Int,
currentPlayingPosition: Int, currentPlayingPosition: Int,
shufflePlay: Boolean, shufflePlay: Boolean,
@ -56,19 +57,14 @@ class PlaybackStateSerializer : KoinComponent {
} }
fun serializeNow( fun serializeNow(
referencedList: Iterable<DownloadFile>, tracks: Iterable<Track>,
currentPlayingIndex: Int, currentPlayingIndex: Int,
currentPlayingPosition: Int, currentPlayingPosition: Int,
shufflePlay: Boolean, shufflePlay: Boolean,
repeatMode: Int repeatMode: Int
) { ) {
val tracks = referencedList.toList().map {
it.track
}
val state = PlaybackState( val state = PlaybackState(
tracks, tracks.toList(),
currentPlayingIndex, currentPlayingIndex,
currentPlayingPosition, currentPlayingPosition,
shufflePlay, shufflePlay,

View File

@ -7,6 +7,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.PublishSubject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import org.moire.ultrasonic.domain.Track
class RxBus { class RxBus {
@ -41,18 +42,23 @@ class RxBus {
.autoConnect(0) .autoConnect(0)
.throttleLatest(300, TimeUnit.MILLISECONDS) .throttleLatest(300, TimeUnit.MILLISECONDS)
val playlistPublisher: PublishSubject<List<DownloadFile>> = val playlistPublisher: PublishSubject<List<Track>> =
PublishSubject.create() PublishSubject.create()
val playlistObservable: Observable<List<DownloadFile>> = val playlistObservable: Observable<List<Track>> =
playlistPublisher.observeOn(mainThread()) playlistPublisher.observeOn(mainThread())
.replay(1) .replay(1)
.autoConnect(0) .autoConnect(0)
val throttledPlaylistObservable: Observable<List<DownloadFile>> = val throttledPlaylistObservable: Observable<List<Track>> =
playlistPublisher.observeOn(mainThread()) playlistPublisher.observeOn(mainThread())
.replay(1) .replay(1)
.autoConnect(0) .autoConnect(0)
.throttleLatest(300, TimeUnit.MILLISECONDS) .throttleLatest(300, TimeUnit.MILLISECONDS)
val trackDownloadStatePublisher: PublishSubject<TrackDownloadState> =
PublishSubject.create()
val trackDownloadStateObservable: Observable<TrackDownloadState> =
trackDownloadStatePublisher.observeOn(mainThread())
// Commands // Commands
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> = val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
PublishSubject.create() PublishSubject.create()
@ -76,11 +82,17 @@ class RxBus {
} }
data class StateWithTrack( data class StateWithTrack(
val track: DownloadFile?, val track: Track?,
val index: Int = -1, val index: Int = -1,
val isPlaying: Boolean = false, val isPlaying: Boolean = false,
val state: Int val state: Int
) )
data class TrackDownloadState(
val track: Track,
val state: DownloadStatus,
val progress: Int
)
} }
operator fun CompositeDisposable.plusAssign(disposable: Disposable) { operator fun CompositeDisposable.plusAssign(disposable: Disposable) {

View File

@ -1,3 +1,10 @@
/*
* CacheCleaner.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util package org.moire.ultrasonic.util
import android.system.Os import android.system.Os
@ -6,12 +13,16 @@ import java.util.HashSet
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Playlist import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.MediaPlayerController
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
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.FileUtil.getPlaylistDirectory import org.moire.ultrasonic.util.FileUtil.getPlaylistDirectory
import org.moire.ultrasonic.util.FileUtil.getPlaylistFile import org.moire.ultrasonic.util.FileUtil.getPlaylistFile
import org.moire.ultrasonic.util.FileUtil.listFiles import org.moire.ultrasonic.util.FileUtil.listFiles
@ -26,6 +37,8 @@ import timber.log.Timber
*/ */
class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private var mainScope = CoroutineScope(Dispatchers.Main)
private fun exceptionHandler(tag: String): CoroutineExceptionHandler { private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
return CoroutineExceptionHandler { _, exception -> return CoroutineExceptionHandler { _, exception ->
Timber.w(exception, "Exception in CacheCleaner.$tag") Timber.w(exception, "Exception in CacheCleaner.$tag")
@ -129,6 +142,24 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
} }
} }
private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val mediaController = inject<MediaPlayerController>(
MediaPlayerController::class.java
)
val playlist = mainScope.future { mediaController.value.playlist }.get()
for (item in playlist) {
val track = item.toTrack()
filesToNotDelete.add(track.getPartialFile())
filesToNotDelete.add(track.getCompleteFile())
filesToNotDelete.add(track.getPinnedFile())
}
filesToNotDelete.add(musicDirectory.path)
return filesToNotDelete
}
companion object { companion object {
private val lock = Object() private val lock = Object()
private var cleaning = false private var cleaning = false
@ -247,21 +278,5 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
a.lastModified.compareTo(b.lastModified) a.lastModified.compareTo(b.lastModified)
} }
} }
private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val downloader = inject<Downloader>(
Downloader::class.java
)
for (downloadFile in downloader.value.all) {
filesToNotDelete.add(downloadFile.partialFile)
filesToNotDelete.add(downloadFile.completeFile)
filesToNotDelete.add(downloadFile.pinnedFile)
}
filesToNotDelete.add(musicDirectory.path)
return filesToNotDelete
}
} }
} }

View File

@ -80,6 +80,20 @@ object FileUtil {
return "$dir/$fileName" return "$dir/$fileName"
} }
fun Track.getPinnedFile(): String {
return getSongFile(this)
}
fun Track.getPartialFile(): String {
return getParentPath(this.getPinnedFile()) + "/" +
getPartialFile(getNameFromPath(this.getPinnedFile()))
}
fun Track.getCompleteFile(): String {
return getParentPath(this.getPinnedFile()) + "/" +
getCompleteFile(getNameFromPath(this.getPinnedFile()))
}
@JvmStatic @JvmStatic
fun getPlaylistFile(server: String?, name: String?): File { fun getPlaylistFile(server: String?, name: String?): File {
val playlistDir = getPlaylistDirectory(server) val playlistDir = getPlaylistDirectory(server)

View File

@ -0,0 +1,259 @@
/*
* MediaItemConverter.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
import android.net.Uri
import android.os.Bundle
import androidx.core.net.toUri
import androidx.media.utils.MediaConstants
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.StarRating
import java.text.DateFormat
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.provider.AlbumArtContentProvider
object MediaItemConverter {
private const val CACHE_SIZE = 250
private const val CACHE_EXPIRY_MINUTES = 10L
val mediaItemCache: LRUCache<String, TimeLimitedCache<MediaItem>> = LRUCache(CACHE_SIZE)
val trackCache: LRUCache<String, TimeLimitedCache<Track>> = LRUCache(CACHE_SIZE)
/**
* Adds a MediaItem to the cache with default expiry time
*/
fun addToCache(key: String, item: MediaItem) {
val cache: TimeLimitedCache<MediaItem> = TimeLimitedCache(CACHE_EXPIRY_MINUTES)
cache.set(item)
mediaItemCache.put(key, cache)
}
/**
* Add a Track object to the cache with default expiry time
*/
fun addToCache(key: String, item: Track) {
val cache: TimeLimitedCache<Track> = TimeLimitedCache(CACHE_EXPIRY_MINUTES)
cache.set(item)
trackCache.put(key, cache)
}
}
/**
* Extension function to convert a Track to an MediaItem, using the cache if possible
*/
@Suppress("LongMethod")
fun Track.toMediaItem(
mediaId: String = id,
): MediaItem {
// Check Cache
val cachedItem = MediaItemConverter.mediaItemCache[mediaId]?.get()
if (cachedItem != null) return cachedItem
// No cache hit, generate it
val filePath = FileUtil.getSongFile(this)
val bitrate = Settings.maxBitRate
val uri = "$id|$bitrate|$filePath"
val artworkUri = AlbumArtContentProvider.mapArtworkToContentProviderUri(this)
val mediaItem = buildMediaItem(
title = title ?: "",
mediaId = mediaId,
isPlayable = !isDirectory,
folderType = if (isDirectory) MediaMetadata.FOLDER_TYPE_TITLES
else MediaMetadata.FOLDER_TYPE_NONE,
album = album,
artist = artist,
genre = genre,
sourceUri = uri.toUri(),
imageUri = artworkUri,
starred = starred,
group = null
)
val metadataBuilder = mediaItem.mediaMetadata.buildUpon()
.setTrackNumber(track)
.setReleaseYear(year)
.setTotalTrackCount(songCount?.toInt())
.setDiscNumber(discNumber)
mediaItem.mediaMetadata.extras?.putInt("serverId", serverId)
mediaItem.mediaMetadata.extras?.putString("parent", parent)
mediaItem.mediaMetadata.extras?.putString("albumId", albumId)
mediaItem.mediaMetadata.extras?.putString("artistId", artistId)
mediaItem.mediaMetadata.extras?.putString("contentType", contentType)
mediaItem.mediaMetadata.extras?.putString("suffix", suffix)
mediaItem.mediaMetadata.extras?.putString("transcodedContentType", transcodedContentType)
mediaItem.mediaMetadata.extras?.putString("transcodedSuffix", transcodedSuffix)
mediaItem.mediaMetadata.extras?.putString("coverArt", coverArt)
if (size != null) mediaItem.mediaMetadata.extras?.putLong("size", size!!)
if (duration != null) mediaItem.mediaMetadata.extras?.putInt("duration", duration!!)
if (bitRate != null) mediaItem.mediaMetadata.extras?.putInt("bitRate", bitRate!!)
mediaItem.mediaMetadata.extras?.putString("path", path)
mediaItem.mediaMetadata.extras?.putBoolean("isVideo", isVideo)
mediaItem.mediaMetadata.extras?.putBoolean("starred", starred)
mediaItem.mediaMetadata.extras?.putString("type", type)
if (created != null) mediaItem.mediaMetadata.extras?.putString(
"created", DateFormat.getDateInstance().format(created!!)
)
mediaItem.mediaMetadata.extras?.putInt("closeness", closeness)
mediaItem.mediaMetadata.extras?.putInt("bookmarkPosition", bookmarkPosition)
mediaItem.mediaMetadata.extras?.putString("name", name)
if (userRating != null) {
mediaItem.mediaMetadata.extras?.putInt("userRating", userRating!!)
metadataBuilder.setUserRating(StarRating(5, userRating!!.toFloat()))
}
if (averageRating != null) {
mediaItem.mediaMetadata.extras?.putFloat("averageRating", averageRating!!)
metadataBuilder.setOverallRating(StarRating(5, averageRating!!))
}
val item = mediaItem.buildUpon().setMediaMetadata(metadataBuilder.build()).build()
// Add MediaItem and Track to the cache
MediaItemConverter.addToCache(mediaId, item)
MediaItemConverter.addToCache(mediaId, this)
return item
}
/**
* Extension function to convert a MediaItem to a Track, using the cache if possible
*/
@Suppress("ComplexMethod")
fun MediaItem.toTrack(): Track {
// Check Cache
val cachedTrack = MediaItemConverter.trackCache[mediaId]?.get()
if (cachedTrack != null) return cachedTrack
// No cache hit, generate it
val created = mediaMetadata.extras?.getString("created")
val createdDate = if (created != null) DateFormat.getDateInstance().parse(created) else null
val track = Track(
mediaId,
mediaMetadata.extras?.getInt("serverId") ?: -1,
mediaMetadata.extras?.getString("parent"),
!(mediaMetadata.isPlayable ?: true),
mediaMetadata.title as String?,
mediaMetadata.albumTitle as String?,
mediaMetadata.extras?.getString("albumId"),
mediaMetadata.artist as String?,
mediaMetadata.extras?.getString("artistId"),
mediaMetadata.trackNumber,
mediaMetadata.releaseYear,
mediaMetadata.genre as String?,
mediaMetadata.extras?.getString("contentType"),
mediaMetadata.extras?.getString("suffix"),
mediaMetadata.extras?.getString("transcodedContentType"),
mediaMetadata.extras?.getString("transcodedSuffix"),
mediaMetadata.extras?.getString("coverArt"),
if (mediaMetadata.extras?.containsKey("size") == true)
mediaMetadata.extras?.getLong("size") else null,
mediaMetadata.totalTrackCount?.toLong(),
if (mediaMetadata.extras?.containsKey("duration") == true)
mediaMetadata.extras?.getInt("duration") else null,
if (mediaMetadata.extras?.containsKey("bitRate") == true)
mediaMetadata.extras?.getInt("bitRate") else null,
mediaMetadata.extras?.getString("path"),
mediaMetadata.extras?.getBoolean("isVideo") ?: false,
mediaMetadata.extras?.getBoolean("starred", false) ?: false,
mediaMetadata.discNumber,
mediaMetadata.extras?.getString("type"),
createdDate,
mediaMetadata.extras?.getInt("closeness", 0) ?: 0,
mediaMetadata.extras?.getInt("bookmarkPosition", 0) ?: 0,
mediaMetadata.extras?.getInt("userRating", 0) ?: 0,
mediaMetadata.extras?.getFloat("averageRating", 0F) ?: 0F,
mediaMetadata.extras?.getString("name"),
)
if (mediaMetadata.userRating is HeartRating) {
track.starred = (mediaMetadata.userRating as HeartRating).isHeart
}
// Add MediaItem and Track to the cache
MediaItemConverter.addToCache(mediaId, track)
MediaItemConverter.addToCache(mediaId, this)
return track
}
fun MediaItem.setPin(pin: Boolean) {
this.mediaMetadata.extras?.putBoolean("pin", pin)
}
fun MediaItem.shouldBePinned(): Boolean {
return this.mediaMetadata.extras?.getBoolean("pin") ?: false
}
/**
* Build a new MediaItem from a list of attributes.
* Especially useful to create folder entries in the Auto interface.
*/
@Suppress("LongParameterList")
fun buildMediaItem(
title: String,
mediaId: String,
isPlayable: Boolean,
@MediaMetadata.FolderType folderType: Int,
album: String? = null,
artist: String? = null,
genre: String? = null,
sourceUri: Uri? = null,
imageUri: Uri? = null,
starred: Boolean = false,
group: String? = null
): MediaItem {
val metadataBuilder = MediaMetadata.Builder()
.setAlbumTitle(album)
.setTitle(title)
.setSubtitle(artist) // Android Auto only displays this field with Title
.setArtist(artist)
.setAlbumArtist(artist)
.setGenre(genre)
.setUserRating(HeartRating(starred))
.setFolderType(folderType)
.setIsPlayable(isPlayable)
if (imageUri != null) {
metadataBuilder.setArtworkUri(imageUri)
}
if (group != null) {
metadataBuilder.setExtras(
Bundle().apply {
putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
group
)
}
)
} else metadataBuilder.setExtras(Bundle())
val metadata = metadataBuilder.build()
val mediaItemBuilder = MediaItem.Builder()
.setMediaId(mediaId)
.setMediaMetadata(metadata)
.setUri(sourceUri)
if (sourceUri != null) {
mediaItemBuilder.setRequestMetadata(
MediaItem.RequestMetadata.Builder()
.setMediaUri(sourceUri)
.build()
)
}
return mediaItemBuilder.build()
}

View File

@ -1,6 +1,6 @@
/* /*
* TimeLimitedCache.kt * TimeLimitedCache.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -15,7 +15,12 @@ class TimeLimitedCache<T>(expiresAfter: Long = 60L, timeUnit: TimeUnit = TimeUni
private var expires: Long = 0 private var expires: Long = 0
fun get(): T? { fun get(): T? {
return if (System.currentTimeMillis() < expires) value!!.get() else null return if (System.currentTimeMillis() < expires) {
value!!.get()
} else {
clear()
null
}
} }
@JvmOverloads @JvmOverloads

View File

@ -1,6 +1,6 @@
/* /*
* Util.kt * Util.kt
* Copyright (C) 2009-2021 Ultrasonic developers * Copyright (C) 2009-2022 Ultrasonic developers
* *
* Distributed under terms of the GNU GPLv3 license. * Distributed under terms of the GNU GPLv3 license.
*/ */
@ -14,9 +14,6 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
@ -342,7 +339,7 @@ object Util {
*/ */
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
fun networkInfo(): NetworkInfo { fun networkInfo(): NetworkInfo {
val manager = getConnectivityManager() val manager = connectivityManager
val info = NetworkInfo() val info = NetworkInfo()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@ -376,34 +373,6 @@ object Util {
} }
} }
@JvmStatic
fun getDrawableFromAttribute(context: Context, attr: Int): Drawable {
val attrs = intArrayOf(attr)
val ta = context.obtainStyledAttributes(attrs)
val drawableFromTheme: Drawable? = ta.getDrawable(0)
ta.recycle()
return drawableFromTheme!!
}
fun createDrawableFromBitmap(context: Context, bitmap: Bitmap?): Drawable {
return BitmapDrawable(context.resources, bitmap)
}
fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
fun createWifiLock(tag: String?): WifiLock { fun createWifiLock(tag: String?): WifiLock {
val wm = val wm =
appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
@ -423,15 +392,6 @@ object Util {
return getScaledHeight(bitmap.height.toDouble(), bitmap.width.toDouble(), width) return getScaledHeight(bitmap.height.toDouble(), bitmap.width.toDouble(), width)
} }
fun scaleBitmap(bitmap: Bitmap?, size: Int): Bitmap? {
return if (bitmap == null) null else Bitmap.createScaledBitmap(
bitmap,
size,
getScaledHeight(bitmap, size),
true
)
}
fun getSongsFromSearchResult(searchResult: SearchResult): MusicDirectory { fun getSongsFromSearchResult(searchResult: SearchResult): MusicDirectory {
val musicDirectory = MusicDirectory() val musicDirectory = MusicDirectory()
for (entry in searchResult.songs) { for (entry in searchResult.songs) {
@ -707,10 +667,8 @@ object Util {
) )
} }
fun getConnectivityManager(): ConnectivityManager { private val connectivityManager: ConnectivityManager
val context = appContext() get() = appContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
/** /**
* Executes the given block if this is not null. * Executes the given block if this is not null.

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFF"
android:pathData="M9,13.75c-2.34,0 -7,1.17 -7,3.5L2,19h14v-1.75c0,-2.33 -4.66,-3.5 -7,-3.5zM4.34,17c0.84,-0.58 2.87,-1.25 4.66,-1.25s3.82,0.67 4.66,1.25L4.34,17zM9,12c1.93,0 3.5,-1.57 3.5,-3.5S10.93,5 9,5 5.5,6.57 5.5,8.5 7.07,12 9,12zM9,7c0.83,0 1.5,0.67 1.5,1.5S9.83,10 9,10s-1.5,-0.67 -1.5,-1.5S8.17,7 9,7zM16.04,13.81c1.16,0.84 1.96,1.96 1.96,3.44L18,19h4v-1.75c0,-2.02 -3.5,-3.17 -5.96,-3.44zM15,12c1.93,0 3.5,-1.57 3.5,-3.5S16.93,5 15,5c-0.54,0 -1.04,0.13 -1.5,0.35 0.63,0.89 1,1.98 1,3.15s-0.37,2.26 -1,3.15c0.46,0.22 0.96,0.35 1.5,0.35z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFF"
android:pathData="M22,6h-5v8.18C16.69,14.07 16.35,14 16,14c-1.66,0 -3,1.34 -3,3s1.34,3 3,3s3,-1.34 3,-3V8h3V6zM15,6H3v2h12V6zM15,10H3v2h12V10zM11,14H3v2h8V14z"/>
</vector>