diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5da218cd..ec516055 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ viewModelKtx = "2.4.1" retrofit = "2.9.0" jackson = "2.10.1" -okhttp = "4.9.1" +okhttp = "4.10.0" koin = "3.0.2" picasso = "2.71828" diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml index 032bf5a6..cb0bd135 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -19,7 +19,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -59,6 +59,17 @@ column="10"/> + + + + - - - - + diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/Scrobbler.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/Scrobbler.java index 59a428e7..27ea8172 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/Scrobbler.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/service/Scrobbler.java @@ -2,6 +2,7 @@ package org.moire.ultrasonic.service; import timber.log.Timber; import org.moire.ultrasonic.data.ActiveServerProvider; +import org.moire.ultrasonic.domain.Track; /** * Scrobbles played songs to Last.fm. @@ -14,12 +15,11 @@ public class Scrobbler private String lastSubmission; 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; - final String id = song.getTrack().getId(); - if (id == null) return; + final String id = song.getId(); // Avoid duplicate registrations. if (submission && id.equals(lastSubmission)) return; diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt index 4efe0338..f6b96d10 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewBinder.kt @@ -9,16 +9,13 @@ import android.view.ViewGroup import androidx.lifecycle.LifecycleOwner import com.drakeet.multitype.ItemViewBinder import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.DownloadFile -import org.moire.ultrasonic.service.Downloader class TrackViewBinder( - val onItemClick: (DownloadFile, Int) -> Unit, - val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null, + val onItemClick: (Track, Int) -> Unit, + val onContextMenuClick: ((MenuItem, Track) -> Boolean)? = null, val checkable: Boolean, val draggable: Boolean, context: Context, @@ -31,7 +28,6 @@ class TrackViewBinder( val layout = R.layout.list_item_track private val contextMenuLayout = R.menu.context_menu_track - private val downloader: Downloader by inject() private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context) override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): TrackViewHolder { @@ -43,11 +39,8 @@ class TrackViewBinder( override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) { val diffAdapter = adapter as BaseAdapter<*> - val downloadFile: DownloadFile = when (item) { + val track: Track = when (item) { is Track -> { - downloader.getDownloadFileForSong(item) - } - is DownloadFile -> { item } else -> { @@ -61,7 +54,7 @@ class TrackViewBinder( holder.observableChecked.removeObservers(lifecycleOwner) holder.setSong( - file = downloadFile, + song = track, checkable = checkable, draggable = draggable, diffAdapter.isSelected(item.longId) @@ -72,11 +65,11 @@ class TrackViewBinder( val popup = Utils.createPopupMenu(holder.itemView, contextMenuLayout) popup.setOnMenuItemClickListener { menuItem -> - onContextMenuClick.invoke(menuItem, downloadFile) + onContextMenuClick.invoke(menuItem, track) } } else { // Minimize or maximize the Text view (if song title is very long) - if (!downloadFile.track.isDirectory) { + if (!track.isDirectory) { holder.maximizeOrMinimize() } } @@ -85,11 +78,11 @@ class TrackViewBinder( } holder.itemView.setOnClickListener { - if (checkable && !downloadFile.track.isVideo) { + if (checkable && !track.isVideo) { val nowChecked = !holder.check.isChecked holder.isChecked = nowChecked } else { - onItemClick(downloadFile, holder.bindingAdapterPosition) + onItemClick(track, holder.bindingAdapterPosition) } } @@ -119,20 +112,6 @@ class TrackViewBinder( 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) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt index 95f47112..75aa02a7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/adapters/TrackViewHolder.kt @@ -11,15 +11,17 @@ import android.widget.TextView import androidx.core.view.isVisible import androidx.lifecycle.MutableLiveData 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.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.DownloadStatus +import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.service.RxBus +import org.moire.ultrasonic.service.plusAssign import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -29,6 +31,8 @@ import timber.log.Timber */ 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) private var rating: LinearLayout = view.findViewById(R.id.song_five_star) 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 private set - var downloadFile: DownloadFile? = null - private set private var isMaximized = false private var cachedStatus = DownloadStatus.UNKNOWN private var statusImage: Drawable? = null private var isPlayingCached = false - private var rxSubscription: Disposable? = null + private var rxBusSubscription: CompositeDisposable? = null var observableChecked = MutableLiveData(false) lateinit var imageHelper: Utils.ImageHelper fun setSong( - file: DownloadFile, + song: Track, checkable: Boolean, draggable: Boolean, isSelected: Boolean = false ) { val useFiveStarRating = Settings.useFiveStarRating - val song = file.track - downloadFile = file entry = song val entryDescription = Util.readableEntryDescription(song) @@ -94,8 +94,8 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable setupStarButtons(song, useFiveStarRating) } - updateProgress(downloadFile!!.progress.value!!) - updateStatus(downloadFile!!.status.value!!) + updateStatus(downloader.getDownloadState(song)) + updateProgress(0) if (useFiveStarRating) { setFiveStars(entry?.userRating ?: 0) @@ -108,13 +108,22 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable progress.isVisible = false } - rxSubscription = RxBus.playerStateObservable.subscribe { - setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile) + // Create new Disposable for the new Subscriptions + 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() { - rxSubscription?.dispose() + rxBusSubscription?.dispose() } 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 cachedStatus = status @@ -227,7 +236,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable updateImages() } - fun updateProgress(p: Int) { + private fun updateProgress(p: Int) { if (cachedStatus == DownloadStatus.DOWNLOADING) { progress.text = Util.formatPercentage(p) } else { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index 658893ea..70f6968b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -1,7 +1,6 @@ package org.moire.ultrasonic.di import org.koin.dsl.module -import org.moire.ultrasonic.playback.LegacyPlaylistManager import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.ExternalStorageMonitor 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 */ val mediaPlayerModule = module { - single { JukeboxMediaPlayer(get()) } + single { JukeboxMediaPlayer() } single { MediaPlayerLifecycleSupport() } single { PlaybackStateSerializer() } single { ExternalStorageMonitor() } - single { LegacyPlaylistManager() } - single { Downloader(get(), get()) } + single { Downloader(get()) } // 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()) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt index 913154b4..3b6b302b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/DownloadsFragment.kt @@ -17,8 +17,8 @@ import androidx.lifecycle.LiveData import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.adapters.TrackViewBinder +import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.model.GenericListModel -import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.Downloader 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 */ -class DownloadsFragment : MultiListFragment() { +class DownloadsFragment : MultiListFragment() { /** * The ViewModel to use to get the data @@ -41,7 +41,7 @@ class DownloadsFragment : MultiListFragment() { /** * The central function to pass a query to the model and return a LiveData object */ - override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData> { + override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData> { return listModel.getList() } @@ -71,12 +71,12 @@ class DownloadsFragment : MultiListFragment() { 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 return true } - override fun onItemClick(item: DownloadFile) { + override fun onItemClick(item: Track) { // TODO: Add code to enable manipulation of the download list } } @@ -84,7 +84,7 @@ class DownloadsFragment : MultiListFragment() { class DownloadListModel(application: Application) : GenericListModel(application) { private val downloader by inject() - fun getList(): LiveData> { + fun getList(): LiveData> { return downloader.observableDownloads } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt index 6b778c5b..a527b6d8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/EditServerFragment.kt @@ -325,7 +325,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler { if (isValid) { currentServerSetting!!.name = serverNameEditText!!.editText?.text.toString() currentServerSetting!!.url = serverAddressEditText!!.editText?.text.toString() - currentServerSetting!!.color = selectedColor + currentServerSetting!!.color = selectedColor ?: currentColor currentServerSetting!!.userName = userNameEditText!!.editText?.text.toString() currentServerSetting!!.password = passwordEditText!!.editText?.text.toString() currentServerSetting!!.allowSelfSignedCertificate = selfSignedSwitch!!.isChecked diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt index 7c045041..9f3254b5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/NowPlayingFragment.kt @@ -1,6 +1,6 @@ /* * NowPlayingFragment.kt - * Copyright (C) 2009-2021 Ultrasonic developers + * Copyright (C) 2009-2022 Ultrasonic developers * * 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.Util.applyTheme import org.moire.ultrasonic.util.Util.getNotificationImageSize +import org.moire.ultrasonic.util.toTrack import timber.log.Timber /** @@ -89,16 +90,15 @@ class NowPlayingFragment : Fragment() { playButton!!.setImageResource(R.drawable.media_start_normal) } - val file = mediaPlayerController.currentPlayingLegacy + val file = mediaPlayerController.currentMediaItem?.toTrack() if (file != null) { - val song = file.track - val title = song.title - val artist = song.artist + val title = file.title + val artist = file.artist imageLoader.getImageLoader().loadImage( nowPlayingAlbumArtImage, - song, + file, false, getNotificationImageSize(requireContext()) ) @@ -111,14 +111,14 @@ class NowPlayingFragment : Fragment() { if (Settings.shouldUseId3Tags) { bundle.putBoolean(Constants.INTENT_IS_ALBUM, true) - bundle.putString(Constants.INTENT_ID, song.albumId) + bundle.putString(Constants.INTENT_ID, file.albumId) } else { 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, song.album) + bundle.putString(Constants.INTENT_NAME, file.album) + bundle.putString(Constants.INTENT_NAME, file.album) Navigation.findNavController(requireActivity(), R.id.nav_host_fragment) .navigate(R.id.trackCollectionFragment, bundle) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index 072eda66..af62e9bb 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -1,6 +1,6 @@ /* * PlayerFragment.kt - * Copyright (C) 2009-2021 Ultrasonic developers + * Copyright (C) 2009-2022 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ @@ -37,6 +37,7 @@ import android.widget.ViewFlipper import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.media3.common.HeartRating +import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline 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.Track 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.MusicServiceFactory.getMusicService 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.Settings import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.toTrack import org.moire.ultrasonic.view.AutoRepeatButton import org.moire.ultrasonic.view.VisualizerView import timber.log.Timber @@ -120,7 +121,6 @@ class PlayerFragment : private val mediaPlayerController: MediaPlayerController by inject() private val shareHandler: ShareHandler by inject() private val imageLoaderProvider: ImageLoaderProvider by inject() - private var currentPlaying: DownloadFile? = null private var currentSong: Track? = null private lateinit var viewManager: LinearLayoutManager private var rxBusSubscription: CompositeDisposable = CompositeDisposable() @@ -466,7 +466,7 @@ class PlayerFragment : override fun onResume() { super.onResume() - if (mediaPlayerController.currentPlayingLegacy == null) { + if (mediaPlayerController.currentMediaItem == null) { playlistFlipper.displayedChild = 1 } else { // Download list and Album art must be updated when resumed @@ -557,10 +557,10 @@ class PlayerFragment : visualizerMenuItem.isVisible = isVisualizerAvailable } val mediaPlayerController = mediaPlayerController - val downloadFile = mediaPlayerController.currentPlayingLegacy + val track = mediaPlayerController.currentMediaItem?.toTrack() - if (downloadFile != null) { - currentSong = downloadFile.track + if (track != null) { + currentSong = track } if (useFiveStarRating) starMenuItem.isVisible = false @@ -594,14 +594,11 @@ class PlayerFragment : super.onCreateContextMenu(menu, view, menuInfo) if (view === playlistView) { 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 menuInflater.inflate(R.menu.nowplaying_context, menu) - val song: Track? - song = downloadFile.track - - if (song.parent == null) { + if (track.parent == null) { val menuItem = menu.findItem(R.id.menu_show_album) if (menuItem != null) { menuItem.isVisible = false @@ -619,16 +616,13 @@ class PlayerFragment : } override fun onOptionsItemSelected(item: MenuItem): Boolean { + // TODO Why is Track null? return menuItemSelected(item.itemId, null) || super.onOptionsItemSelected(item) } @Suppress("ComplexMethod", "LongMethod", "ReturnCount") - private fun menuItemSelected(menuItemId: Int, song: DownloadFile?): Boolean { - var track: Track? = null + private fun menuItemSelected(menuItemId: Int, track: Track?): Boolean { val bundle: Bundle - if (song != null) { - track = song.track - } when (menuItemId) { R.id.menu_show_artist -> { @@ -804,9 +798,9 @@ class PlayerFragment : R.id.menu_item_share -> { val mediaPlayerController = mediaPlayerController val tracks: MutableList = ArrayList() - val downloadServiceSongs = mediaPlayerController.playList - for (downloadFile in downloadServiceSongs) { - val playlistEntry = downloadFile.track + val playlist = mediaPlayerController.playlist + for (item in playlist) { + val playlistEntry = item.toTrack() tracks.add(playlistEntry) } shareHandler.createShare(this, tracks, null, cancellationToken) @@ -828,7 +822,7 @@ class PlayerFragment : private fun update(cancel: CancellationToken? = null) { if (cancel?.isCancellationRequested == true) return val mediaPlayerController = mediaPlayerController - if (currentPlaying != mediaPlayerController.currentPlayingLegacy) { + if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) { onCurrentChanged() } onSliderProgressChanged() @@ -841,8 +835,8 @@ class PlayerFragment : ioScope.launch { - val entries = mediaPlayerController.playList.map { - it.track + val entries = mediaPlayerController.playlist.map { + it.toTrack() } val musicService = getMusicService() musicService.createPlaylist(null, playlistName, entries) @@ -891,7 +885,7 @@ class PlayerFragment : } // Create listener - val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos -> + val clickHandler: ((Track, Int) -> Unit) = { _, pos -> mediaPlayerController.seekTo(pos, 0) mediaPlayerController.prepare() mediaPlayerController.play() @@ -978,10 +972,10 @@ class PlayerFragment : private fun onPlaylistChanged() { val mediaPlayerController = mediaPlayerController - val list = mediaPlayerController.playList + val list = mediaPlayerController.playlist emptyTextView.setText(R.string.playlist_empty) - viewAdapter.submitList(list) + viewAdapter.submitList(list.map(MediaItem::toTrack)) emptyTextView.isVisible = list.isEmpty() @@ -989,17 +983,16 @@ class PlayerFragment : } private fun onCurrentChanged() { - currentPlaying = mediaPlayerController.currentPlayingLegacy + currentSong = mediaPlayerController.currentMediaItem?.toTrack() scrollToCurrent() val totalDuration = mediaPlayerController.playListDuration - val totalSongs = mediaPlayerController.playlistSize.toLong() + val totalSongs = mediaPlayerController.playlistSize val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1 val duration = Util.formatTotalDuration(totalDuration) val trackFormat = String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs) - if (currentPlaying != null) { - currentSong = currentPlaying!!.track + if (currentSong != null) { songTitleTextView.text = currentSong!!.title artistTextView.text = currentSong!!.artist albumTextView.text = currentSong!!.album @@ -1057,7 +1050,7 @@ class PlayerFragment : val isPlaying = mediaPlayerController.isPlaying if (cancellationToken.isCancellationRequested) return - if (currentPlaying != null) { + if (currentSong != null) { positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true) durationTextView.text = Util.formatTotalDuration(duration.toLong(), true) progressBar.max = diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt index 61495272..2f6d8196 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SearchFragment.kt @@ -33,7 +33,6 @@ import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.model.SearchListModel -import org.moire.ultrasonic.service.DownloadFile import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.ShareHandler @@ -353,13 +352,13 @@ class SearchFragment : MultiListFragment(), KoinComponent { this ) - if (found || item !is DownloadFile) return true + if (found || item !is Track) return true val songs = mutableListOf() when (menuItem.itemId) { R.id.song_menu_play_now -> { - songs.add(item.track) + songs.add(item) downloadHandler.download( fragment = this, append = false, @@ -371,7 +370,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { ) } R.id.song_menu_play_next -> { - songs.add(item.track) + songs.add(item) downloadHandler.download( fragment = this, append = true, @@ -383,7 +382,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { ) } R.id.song_menu_play_last -> { - songs.add(item.track) + songs.add(item) downloadHandler.download( fragment = this, append = true, @@ -395,7 +394,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { ) } R.id.song_menu_pin -> { - songs.add(item.track) + songs.add(item) toast( context, resources.getQuantityString( @@ -407,7 +406,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { downloadBackground(true, songs) } R.id.song_menu_download -> { - songs.add(item.track) + songs.add(item) toast( context, resources.getQuantityString( @@ -419,7 +418,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { downloadBackground(false, songs) } R.id.song_menu_unpin -> { - songs.add(item.track) + songs.add(item) toast( context, resources.getQuantityString( @@ -431,7 +430,7 @@ class SearchFragment : MultiListFragment(), KoinComponent { mediaPlayerController.unpin(songs) } R.id.song_menu_share -> { - songs.add(item.track) + songs.add(item) shareHandler.createShare(this, songs, searchRefresh, cancellationToken!!) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt index 7f14bb5b..68718696 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/SettingsFragment.kt @@ -433,7 +433,6 @@ class SettingsFragment : // Clear download queue. mediaPlayerController.clear() - mediaPlayerController.clearCaches() Storage.reset() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 211c122d..ddf9cc04 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -38,6 +38,8 @@ import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.model.TrackCollectionModel +import org.moire.ultrasonic.service.DownloadStatus +import org.moire.ultrasonic.service.Downloader import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker import org.moire.ultrasonic.subsonic.ShareHandler @@ -79,6 +81,7 @@ open class TrackCollectionFragment : MultiListFragment() { private var shareButton: MenuItem? = null internal val mediaPlayerController: MediaPlayerController by inject() + internal val downloader: Downloader by inject() private val networkAndStorageChecker: NetworkAndStorageChecker by inject() private val shareHandler: ShareHandler by inject() internal var cancellationToken: CancellationToken? = null @@ -125,8 +128,8 @@ open class TrackCollectionFragment : MultiListFragment() { viewAdapter.register( TrackViewBinder( - onItemClick = { file, _ -> onItemClick(file.track) }, - onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) }, + onItemClick = { file, _ -> onItemClick(file) }, + onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id) }, checkable = true, draggable = false, context = requireContext(), @@ -364,11 +367,11 @@ open class TrackCollectionFragment : MultiListFragment() { var pinnedCount = 0 for (song in selection) { - val downloadFile = mediaPlayerController.getDownloadFileForSong(song) - if (downloadFile.isWorkDone) { + val state = downloader.getDownloadState(song) + if (state == DownloadStatus.DONE || state == DownloadStatus.PINNED) { deleteEnabled = true } - if (downloadFile.isSaved) { + if (state == DownloadStatus.PINNED) { pinnedCount++ unpinEnabled = true } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ArtworkBitmapLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ArtworkBitmapLoader.kt index 896445f8..120b70bf 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ArtworkBitmapLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ArtworkBitmapLoader.kt @@ -16,8 +16,12 @@ import com.google.common.util.concurrent.ListeningExecutorService import com.google.common.util.concurrent.MoreExecutors import java.io.IOException 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 { MoreExecutors.listeningDecorator( @@ -46,6 +50,8 @@ class ArtworkBitmapLoader : BitmapLoader { @Throws(IOException::class) 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) } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt index cea51877..cb5a7694 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt @@ -3,7 +3,7 @@ package org.moire.ultrasonic.imageloader import android.app.ActivityManager import android.content.Context import android.content.pm.ApplicationInfo -import android.text.TextUtils +import android.graphics.Bitmap import android.view.View import android.widget.ImageView import androidx.core.content.ContextCompat @@ -14,6 +14,8 @@ import java.io.File import java.io.FileOutputStream import java.io.InputStream import java.io.OutputStream +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch import org.moire.ultrasonic.BuildConfig import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient @@ -33,6 +35,8 @@ class ImageLoader( apiClient: SubsonicAPIClient, private val config: ImageLoaderConfig ) { + private val cacheInProgress: ConcurrentHashMap = ConcurrentHashMap() + // Shortcut @Suppress("VariableNaming", "PropertyName") val API = apiClient.api @@ -58,6 +62,14 @@ class ImageLoader( .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) { picasso.load(createLoadAvatarRequest(request.username)) .addPlaceholder(request) @@ -82,6 +94,26 @@ class ImageLoader( 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 */ @@ -148,30 +180,30 @@ class ImageLoader( /** * Download a cover art file and cache it on disk */ - fun cacheCoverArt( - track: Track - ) { + fun cacheCoverArt(track: Track) { + cacheCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track)) + } - // Synchronize on the entry so that we don't download concurrently for - // the same song. - synchronized(track) { + fun cacheCoverArt(id: String, file: String) { + if (id.isNullOrBlank()) return + // 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.. val size = config.largeSize - - // 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 + File(file).createNewFile() // Query the API - Timber.d("Loading cover art for: %s", track) - val response = API.getCoverArt(id!!, size.toLong()).execute().toStreamResponse() + Timber.d("Loading cover art for: %s", id) + val response = API.getCoverArt(id, size.toLong()).execute().toStreamResponse() response.throwOnFailure() // Check for failure @@ -192,6 +224,8 @@ class ImageLoader( } finally { inputStream.safeClose() } + } finally { + cacheInProgress.remove(file)?.countDown() } } @@ -222,12 +256,12 @@ class ImageLoader( sealed class ImageRequest( val placeHolderDrawableRes: Int? = null, val errorDrawableRes: Int? = null, - val imageView: ImageView + val imageView: ImageView? ) { class CoverArt( val entityId: String, val cacheKey: String, - imageView: ImageView, + imageView: ImageView?, val size: Int, placeHolderDrawableRes: Int? = null, errorDrawableRes: Int? = null, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt index 8fb80cc1..1040da95 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/AutoMediaBrowserCallback.kt @@ -1,5 +1,5 @@ /* - * CustomMediaLibrarySessionCallback.kt + * AutoMediaBrowserCallback.kt * Copyright (C) 2009-2022 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. @@ -7,17 +7,14 @@ package org.moire.ultrasonic.playback -import android.net.Uri import android.os.Bundle import android.widget.Toast import android.widget.Toast.LENGTH_SHORT import androidx.media3.common.HeartRating 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_ARTISTS 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_TITLES import androidx.media3.common.Player @@ -37,8 +34,8 @@ import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.future -import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject 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.Settings 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 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 serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + private val mainScope = CoroutineScope(Dispatchers.Main) private var playlistCache: List? = null private var starredSongsCache: List? = null @@ -226,7 +227,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr * is stored in the track.starred value * See https://github.com/androidx/media/issues/33 */ - val track = mediaPlayerController.currentPlayingLegacy?.track + val track = mediaPlayerController.currentMediaItem?.toTrack() if (track != null) { customCommandFuture = onSetRating( session, @@ -312,18 +313,61 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr * 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 */ + @Suppress("MagicNumber", "ComplexMethod") override fun onAddMediaItems( mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList ): ListenableFuture> { - val updatedMediaItems = mediaItems.map { mediaItem -> - mediaItem.buildUpon() - .setUri(mediaItem.requestMetadata.mediaUri) - .build() + if (!mediaItems.any()) return Futures.immediateFuture(mediaItems) + + // Try to find out if the requester understands requestMetadata in the mediaItems + 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") @@ -398,10 +442,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr searchSongsCache = searchResult.songs searchResult.songs.map { song -> mediaItems.add( - buildMediaItemFromTrack( - song, - listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|"), - isPlayable = true + song.toMediaItem( + listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|") ) ) } @@ -444,37 +486,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr } } - private fun playFromSearchCommand(query: String?) { - Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query) - if (query.isNullOrBlank()) playRandomSongs() - - serviceScope.launch { - 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) - } + private fun playSearch(id: String): List? { + // 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) return listOf(song) } + return null } private fun getRootItems(): ListenableFuture>> { @@ -484,14 +502,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr mediaItems.add( R.string.music_library_label, MEDIA_LIBRARY_ID, - null + null, + icon = R.drawable.ic_library ) mediaItems.add( R.string.main_artists_title, MEDIA_ARTIST_ID, null, - folderType = FOLDER_TYPE_ARTISTS + folderType = FOLDER_TYPE_ARTISTS, + icon = R.drawable.ic_artist ) if (!isOffline) @@ -499,14 +519,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr R.string.main_albums_title, MEDIA_ALBUM_ID, null, - folderType = FOLDER_TYPE_ALBUMS + folderType = FOLDER_TYPE_ALBUMS, + icon = R.drawable.ic_menu_browse ) mediaItems.add( R.string.playlist_label, MEDIA_PLAYLIST_ID, null, - folderType = FOLDER_TYPE_PLAYLISTS + folderType = FOLDER_TYPE_PLAYLISTS, + icon = R.drawable.ic_menu_playlists ) return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null)) @@ -578,18 +600,21 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val childMediaId: String - var artists = if (!isOffline && useId3Tags) { - childMediaId = MEDIA_ARTIST_ITEM - // TODO this list can be big so we're not refreshing. - // Maybe a refresh menu item can be added - callWithErrorHandling { musicService.getArtists(false) } - } else { - // This will be handled at getSongsForAlbum, which supports navigation - childMediaId = MEDIA_ALBUM_ITEM - callWithErrorHandling { musicService.getIndexes(musicFolderId, false) } - } + // It seems double scoping is required: Media3 requires the Main thread, network operations with musicService forbid the Main thread... + return mainScope.future { + var childMediaId: String = MEDIA_ARTIST_ITEM + + var artists = serviceScope.future { + if (!isOffline && useId3Tags) { + // TODO this list can be big so we're not refreshing. + // Maybe a refresh menu item can be added + callWithErrorHandling { musicService.getArtists(false) } + } else { + // This will be handled at getSongsForAlbum, which supports navigation + childMediaId = MEDIA_ALBUM_ITEM + callWithErrorHandling { musicService.getIndexes(musicFolderId, false) } + } + }.await() if (artists != null) { if (section != null) @@ -633,14 +658,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val albums = if (!isOffline && useId3Tags) { - callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) } - } else { - callWithErrorHandling { - musicService.getMusicDirectory(id, name, false).getAlbums() + return mainScope.future { + val albums = serviceScope.future { + if (!isOffline && useId3Tags) { + callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) } + } else { + callWithErrorHandling { + musicService.getMusicDirectory(id, name, false).getAlbums() + } } - } + }.await() albums?.map { album -> mediaItems.add( @@ -660,8 +687,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val songs = listSongsInMusicService(id, name) + return mainScope.future { + val songs = serviceScope.future { listSongsInMusicService(id, name) }.await() if (songs != null) { if (songs.getChildren(includeDirs = true, includeFiles = false).isEmpty() && @@ -680,15 +707,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr ) else mediaItems.add( - buildMediaItemFromTrack( - item, + item.toMediaItem( listOf( MEDIA_ALBUM_SONG_ITEM, id, name, item.id - ).joinToString("|"), - isPlayable = true + ).joinToString("|") ) ) } @@ -703,21 +728,24 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { + return mainScope.future { val offset = (page ?: 0) * DISPLAY_LIMIT - val albums = if (useId3Tags) { - callWithErrorHandling { - musicService.getAlbumList2( - type.typeName, DISPLAY_LIMIT, offset, null - ) + + val albums = serviceScope.future { + if (useId3Tags) { + callWithErrorHandling { + musicService.getAlbumList2( + type.typeName, DISPLAY_LIMIT, offset, null + ) + } + } else { + callWithErrorHandling { + musicService.getAlbumList( + type.typeName, DISPLAY_LIMIT, offset, null + ) + } } - } else { - callWithErrorHandling { - musicService.getAlbumList( - type.typeName, DISPLAY_LIMIT, offset, null - ) - } - } + }.await() albums?.map { album -> mediaItems.add( @@ -742,8 +770,11 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr private fun getPlaylists(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val playlists = callWithErrorHandling { musicService.getPlaylists(true) } + return mainScope.future { + val playlists = serviceScope.future { + callWithErrorHandling { musicService.getPlaylists(true) } + }.await() + playlists?.map { playlist -> mediaItems.add( playlist.name, @@ -762,8 +793,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val content = callWithErrorHandling { musicService.getPlaylist(id, name) } + return mainScope.future { + val content = serviceScope.future { + callWithErrorHandling { musicService.getPlaylist(id, name) } + }.await() if (content != null) { if (content.size > 1) @@ -775,15 +808,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr playlistCache = content.getTracks() playlistCache!!.take(DISPLAY_LIMIT).map { item -> mediaItems.add( - buildMediaItemFromTrack( - item, + item.toMediaItem( listOf( MEDIA_PLAYLIST_SONG_ITEM, id, name, item.id - ).joinToString("|"), - isPlayable = true + ).joinToString("|") ) ) } @@ -792,49 +823,52 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr } } - private fun playPlaylist(id: String, name: String) { - serviceScope.launch { - if (playlistCache == null) { - // This can only happen if Android Auto cached items, but Ultrasonic has forgot them - val content = callWithErrorHandling { musicService.getPlaylist(id, name) } - playlistCache = content?.getTracks() - } - if (playlistCache != null) playSongs(playlistCache!!) + private fun playPlaylist(id: String, name: String): List? { + if (playlistCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = + serviceScope.future { + callWithErrorHandling { musicService.getPlaylist(id, name) } + }.get() + playlistCache = content?.getTracks() } + if (playlistCache != null) return playlistCache!! + return null } - private fun playPlaylistSong(id: String, name: String, songId: String) { - serviceScope.launch { - if (playlistCache == null) { - // This can only happen if Android Auto cached items, but Ultrasonic has forgot them - val content = callWithErrorHandling { musicService.getPlaylist(id, name) } - playlistCache = content?.getTracks() - } - val song = playlistCache?.firstOrNull { x -> x.id == songId } - if (song != null) playSong(song) + private fun playPlaylistSong(id: String, name: String, songId: String): List? { + if (playlistCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = serviceScope.future { + callWithErrorHandling { musicService.getPlaylist(id, name) } + }.get() + playlistCache = content?.getTracks() } + val song = playlistCache?.firstOrNull { x -> x.id == songId } + if (song != null) return listOf(song) + return null } - private fun playAlbum(id: String, name: String) { - serviceScope.launch { - val songs = listSongsInMusicService(id, name) - if (songs != null) playSongs(songs.getTracks()) - } + private fun playAlbum(id: String, name: String): List? { + val songs = listSongsInMusicService(id, name) + if (songs != null) return songs.getTracks() + return null } - private fun playAlbumSong(id: String, name: String, songId: String) { - serviceScope.launch { - val songs = listSongsInMusicService(id, name) - val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId } - if (song != null) playSong(song) - } + private fun playAlbumSong(id: String, name: String, songId: String): List? { + val songs = listSongsInMusicService(id, name) + val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId } + if (song != null) return listOf(song) + return null } private fun getPodcasts(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val podcasts = callWithErrorHandling { musicService.getPodcastsChannels(false) } + return mainScope.future { + val podcasts = serviceScope.future { + callWithErrorHandling { musicService.getPodcastsChannels(false) } + }.await() podcasts?.map { podcast -> mediaItems.add( @@ -851,8 +885,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr id: String ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } + return mainScope.future { + val episodes = serviceScope.future { + callWithErrorHandling { musicService.getPodcastEpisodes(id) } + }.await() if (episodes != null) { if (episodes.getTracks().count() > 1) @@ -860,11 +896,9 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr episodes.getTracks().map { episode -> mediaItems.add( - buildMediaItemFromTrack( - episode, + episode.toMediaItem( listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) - .joinToString("|"), - isPlayable = true + .joinToString("|") ) ) } @@ -873,40 +907,43 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr } } - private fun playPodcast(id: String) { - serviceScope.launch { - val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } - if (episodes != null) { - playSongs(episodes.getTracks()) - } + private fun playPodcast(id: String): List? { + val episodes = serviceScope.future { + callWithErrorHandling { musicService.getPodcastEpisodes(id) } + }.get() + if (episodes != null) { + return episodes.getTracks() } + return null } - private fun playPodcastEpisode(id: String, episodeId: String) { - serviceScope.launch { - val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } - if (episodes != null) { - val selectedEpisode = episodes - .getTracks() - .firstOrNull { episode -> episode.id == episodeId } - if (selectedEpisode != null) playSong(selectedEpisode) - } + private fun playPodcastEpisode(id: String, episodeId: String): List? { + val episodes = serviceScope.future { + callWithErrorHandling { musicService.getPodcastEpisodes(id) } + }.get() + if (episodes != null) { + val selectedEpisode = episodes + .getTracks() + .firstOrNull { episode -> episode.id == episodeId } + if (selectedEpisode != null) return listOf(selectedEpisode) } + return null } private fun getBookmarks(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val bookmarks = callWithErrorHandling { musicService.getBookmarks() } + return mainScope.future { + val bookmarks = serviceScope.future { + callWithErrorHandling { musicService.getBookmarks() } + }.await() + if (bookmarks != null) { val songs = Util.getSongsFromBookmarks(bookmarks) songs.getTracks().map { song -> mediaItems.add( - buildMediaItemFromTrack( - song, - listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|"), - isPlayable = true + song.toMediaItem( + listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|") ) ) } @@ -915,22 +952,25 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr } } - private fun playBookmark(id: String) { - serviceScope.launch { - val bookmarks = callWithErrorHandling { musicService.getBookmarks() } - if (bookmarks != null) { - val songs = Util.getSongsFromBookmarks(bookmarks) - val song = songs.getTracks().firstOrNull { song -> song.id == id } - if (song != null) playSong(song) - } + private fun playBookmark(id: String): List? { + val bookmarks = serviceScope.future { + callWithErrorHandling { musicService.getBookmarks() } + }.get() + if (bookmarks != null) { + val songs = Util.getSongsFromBookmarks(bookmarks) + val song = songs.getTracks().firstOrNull { song -> song.id == id } + if (song != null) return listOf(song) } + return null } private fun getShares(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val shares = callWithErrorHandling { musicService.getShares(false) } + return mainScope.future { + val shares = serviceScope.future { + callWithErrorHandling { musicService.getShares(false) } + }.await() shares?.map { share -> mediaItems.add( @@ -949,8 +989,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val shares = callWithErrorHandling { musicService.getShares(false) } + return mainScope.future { + val shares = serviceScope.future { + callWithErrorHandling { musicService.getShares(false) } + }.await() val selectedShare = shares?.firstOrNull { share -> share.id == id } if (selectedShare != null) { @@ -960,10 +1002,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr selectedShare.getEntries().map { song -> mediaItems.add( - buildMediaItemFromTrack( - song, - listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|"), - isPlayable = true + song.toMediaItem( + listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|") ) ) } @@ -972,32 +1012,36 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr } } - private fun playShare(id: String) { - serviceScope.launch { - val shares = callWithErrorHandling { musicService.getShares(false) } - val selectedShare = shares?.firstOrNull { share -> share.id == id } - if (selectedShare != null) { - playSongs(selectedShare.getEntries()) - } + private fun playShare(id: String): List? { + val shares = serviceScope.future { + callWithErrorHandling { musicService.getShares(false) } + }.get() + val selectedShare = shares?.firstOrNull { share -> share.id == id } + if (selectedShare != null) { + return selectedShare.getEntries() } + return null } - private fun playShareSong(id: String, songId: String) { - serviceScope.launch { - val shares = callWithErrorHandling { musicService.getShares(false) } - val selectedShare = shares?.firstOrNull { share -> share.id == id } - if (selectedShare != null) { - val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId } - if (song != null) playSong(song) - } + private fun playShareSong(id: String, songId: String): List? { + val shares = serviceScope.future { + callWithErrorHandling { musicService.getShares(false) } + }.get() + val selectedShare = shares?.firstOrNull { share -> share.id == id } + if (selectedShare != null) { + val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId } + if (song != null) return listOf(song) } + return null } private fun getStarredSongs(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val songs = listStarredSongsInMusicService() + return mainScope.future { + val songs = serviceScope.future { + listStarredSongsInMusicService() + }.await() if (songs != null) { if (songs.songs.count() > 1) @@ -1008,10 +1052,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr starredSongsCache = items items.map { song -> mediaItems.add( - buildMediaItemFromTrack( - song, - listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|"), - isPlayable = true + song.toMediaItem( + listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|") ) ) } @@ -1020,34 +1062,34 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr } } - private fun playStarredSongs() { - serviceScope.launch { - if (starredSongsCache == null) { - // This can only happen if Android Auto cached items, but Ultrasonic has forgot them - val content = listStarredSongsInMusicService() - starredSongsCache = content?.songs - } - if (starredSongsCache != null) playSongs(starredSongsCache!!) + private fun playStarredSongs(): List? { + if (starredSongsCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = listStarredSongsInMusicService() + starredSongsCache = content?.songs } + if (starredSongsCache != null) return starredSongsCache!! + return null } - private fun playStarredSong(songId: String) { - serviceScope.launch { - if (starredSongsCache == null) { - // This can only happen if Android Auto cached items, but Ultrasonic has forgot them - val content = listStarredSongsInMusicService() - starredSongsCache = content?.songs - } - val song = starredSongsCache?.firstOrNull { x -> x.id == songId } - if (song != null) playSong(song) + private fun playStarredSong(songId: String): List? { + if (starredSongsCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = listStarredSongsInMusicService() + starredSongsCache = content?.songs } + val song = starredSongsCache?.firstOrNull { x -> x.id == songId } + if (song != null) return listOf(song) + return null } private fun getRandomSongs(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() - return serviceScope.future { - val songs = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } + return mainScope.future { + val songs = serviceScope.future { + callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } + }.await() if (songs != null) { if (songs.size > 1) @@ -1058,10 +1100,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr randomSongsCache = items items.map { song -> mediaItems.add( - buildMediaItemFromTrack( - song, - listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|"), - isPlayable = true + song.toMediaItem( + listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|") ) ) } @@ -1070,42 +1110,46 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr } } - private fun playRandomSongs() { - serviceScope.launch { - if (randomSongsCache == null) { - // 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 - val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } - randomSongsCache = content?.getTracks() - } - if (randomSongsCache != null) playSongs(randomSongsCache!!) + private fun playRandomSongs(): List? { + if (randomSongsCache == null) { + // 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 + val content = serviceScope.future { + callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } + }.get() + randomSongsCache = content?.getTracks() } + if (randomSongsCache != null) return randomSongsCache!! + return null } - private fun playRandomSong(songId: String) { - serviceScope.launch { - // If there is no cache, we can't play the selected song. - if (randomSongsCache != null) { - val song = randomSongsCache!!.firstOrNull { x -> x.id == songId } - if (song != null) playSong(song) - } + private fun playRandomSong(songId: String): List? { + // If there is no cache, we can't play the selected song. + if (randomSongsCache != null) { + val song = randomSongsCache!!.firstOrNull { x -> x.id == songId } + if (song != null) return listOf(song) } + return null } private fun listSongsInMusicService(id: String, name: String): MusicDirectory? { - return if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) { - callWithErrorHandling { musicService.getAlbum(id, name, false) } - } else { - callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } - } + return serviceScope.future { + if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) { + callWithErrorHandling { musicService.getAlbum(id, name, false) } + } else { + callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } + } + }.get() } private fun listStarredSongsInMusicService(): SearchResult? { - return if (Settings.shouldUseId3Tags) { - callWithErrorHandling { musicService.getStarred2() } - } else { - callWithErrorHandling { musicService.getStarred() } - } + return serviceScope.future { + if (Settings.shouldUseId3Tags) { + callWithErrorHandling { musicService.getStarred2() } + } else { + callWithErrorHandling { musicService.getStarred() } + } + }.get() } private fun MutableList.add( @@ -1124,20 +1168,28 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr this.add(mediaItem) } + @Suppress("LongParameterList") private fun MutableList.add( resId: Int, mediaId: String, groupNameId: Int?, browsable: Boolean = true, - folderType: Int = FOLDER_TYPE_MIXED + folderType: Int = FOLDER_TYPE_MIXED, + icon: Int? = null ) { val applicationContext = UApp.applicationContext() val mediaItem = buildMediaItem( applicationContext.getString(resId), mediaId, - isPlayable = false, - folderType = folderType + isPlayable = !browsable, + folderType = folderType, + group = if (groupNameId != null) { + applicationContext.getString(groupNameId) + } else null, + imageUri = if (icon != null) { + Util.getUriToDrawable(applicationContext, icon) + } else null ) this.add(mediaItem) @@ -1150,7 +1202,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr R.string.select_album_play_all, mediaId, null, - false + false, + icon = R.drawable.media_start_normal ) } @@ -1160,28 +1213,6 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr return section.toString() } - private fun playSongs(songs: List) { - 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 callWithErrorHandling(function: () -> T): T? { // TODO Implement better error handling return try { @@ -1191,53 +1222,4 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr 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() - } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt deleted file mode 100644 index 77013e45..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/LegacyPlaylistManager.kt +++ /dev/null @@ -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() - - @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(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 - 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 - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt index be7873a9..502ca0fa 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/playback/MediaNotificationProvider.kt @@ -22,6 +22,7 @@ import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader import org.moire.ultrasonic.service.MediaPlayerController +import org.moire.ultrasonic.util.toTrack @UnstableApi class MediaNotificationProvider(context: Context) : @@ -47,7 +48,7 @@ class MediaNotificationProvider(context: Context) : * is stored in the track.starred value * See https://github.com/androidx/media/issues/33 */ - val rating = mediaPlayerController.currentPlayingLegacy?.track?.starred?.let { + val rating = mediaPlayerController.currentMediaItem?.toTrack()?.starred?.let { HeartRating( it ) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt new file mode 100644 index 00000000..82aab61d --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/provider/AlbumArtContentProvider.kt @@ -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?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = null + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ) = 0 + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 + + override fun getType(uri: Uri): String? = null + + override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array { + return arrayOf("image/jpeg", "image/png", "image/gif") + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt deleted file mode 100644 index e52d6a90..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadFile.kt +++ /dev/null @@ -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 = 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 = lazy { - when { - Storage.isPathExists(pinnedFile) -> { - DownloadStatus.PINNED - } - Storage.isPathExists(completeFile) -> { - DownloadStatus.DONE - } - else -> { - DownloadStatus.IDLE - } - } - } - - val status: MutableLiveData 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 -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt index f8bc608f..75ae935f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt @@ -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 import android.net.wifi.WifiManager import android.os.Handler import android.os.Looper +import android.os.SystemClock as SystemClock import android.text.TextUtils import androidx.lifecycle.MutableLiveData 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.MetaDatabase import org.moire.ultrasonic.domain.Artist +import org.moire.ultrasonic.domain.Identifiable import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.playback.LegacyPlaylistManager import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.FileUtil -import org.moire.ultrasonic.util.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.Storage import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util.safeClose +import org.moire.ultrasonic.util.shouldBePinned +import org.moire.ultrasonic.util.toTrack import timber.log.Timber /** @@ -37,27 +49,25 @@ import timber.log.Timber */ class Downloader( private val storageMonitor: ExternalStorageMonitor, - private val legacyPlaylistManager: LegacyPlaylistManager, ) : KoinComponent { // Dependencies private val imageLoaderProvider: ImageLoaderProvider by inject() private val activeServerProvider: ActiveServerProvider by inject() private val mediaController: MediaPlayerController by inject() + private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject() var started: Boolean = false var shouldStop: Boolean = false var isPolling: Boolean = false - private val downloadQueue = PriorityQueue() - private val activelyDownloading = mutableListOf() + private val downloadQueue = PriorityQueue() + private val activelyDownloading = mutableMapOf() + private val failedList = mutableListOf() // The generic list models expect a LiveData, so even though we are using Rx for many events // surrounding playback the list of Downloads is published as LiveData. - val observableDownloads = MutableLiveData>() - - // This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries - private val downloadFileCache = LRUCache(500) + val observableDownloads = MutableLiveData>() private var handler: Handler = Handler(Looper.getMainLooper()) private var wifiLock: WifiManager.WifiLock? = null @@ -124,7 +134,10 @@ class Downloader( shouldStop = true wifiLock?.release() wifiLock = null - DownloadService.runningInstance?.notifyDownloaderStopped() + handler.postDelayed( + Runnable { DownloadService.runningInstance?.notifyDownloaderStopped() }, + 100 + ) Timber.i("Downloader stopped") } @@ -150,56 +163,48 @@ class Downloader( return } - if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) { + if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) { return } Timber.v("Downloader checkDownloadsInternal checking downloads") - // Check the active downloads for failures or completions and remove them - // Store the result in a flag to know if changes have occurred - var listChanged = cleanupActiveDownloads() + var listChanged = false + val playlist = mediaController.getNextPlaylistItemsInPlayOrder(Settings.preloadCount) + var priority = 0 - val playlist = legacyPlaylistManager.playlist - - // 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 + for (item in playlist) { + val track = item.toTrack() // Add file to queue if not in one of the queues already. - if (!download.isWorkDone && - !activelyDownloading.contains(download) && - !downloadQueue.contains(download) && - download.shouldRetry() - ) { + if (getDownloadState(track) == DownloadStatus.IDLE) { 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 while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) { val task = downloadQueue.remove() - activelyDownloading.add(task) + val downloadTask = DownloadTask(task) + activelyDownloading[task] = downloadTask startDownloadOnService(task) listChanged = true } // Stop Executor service when done downloading - if (activelyDownloading.size == 0) { + if (activelyDownloading.isEmpty()) { stop() } @@ -212,89 +217,36 @@ class Downloader( observableDownloads.postValue(downloads) } - private fun startDownloadOnService(file: DownloadFile) { - if (file.isDownloading) return - file.prepare() + private fun startDownloadOnService(track: DownloadableTrack) { DownloadService.executeOnStartedDownloadService { - FileUtil.createDirectoryForParent(file.pinnedFile) - file.isFailed = false - file.downloadTask = DownloadTask(file) - file.downloadTask!!.start() - Timber.v("startDownloadOnService started downloading file ${file.completeFile}") + FileUtil.createDirectoryForParent(track.pinnedFile) + activelyDownloading[track]?.start() + Timber.v("startDownloadOnService started downloading file ${track.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 - get() { - val temp: MutableList = 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, - * including undownloaded files from the playlist. - */ + */ @get:Synchronized - val downloads: List + val downloads: List get() { - val temp: MutableList = ArrayList() - temp.addAll(activelyDownloading) - temp.addAll(downloadQueue) - temp.addAll( - legacyPlaylistManager.playlist.filter { - if (!it.isStatusInitialized) false - else when (it.status.value) { - DownloadStatus.DOWNLOADING -> true - else -> false - } - } - ) + val temp: MutableList = ArrayList() + temp.addAll(activelyDownloading.keys.map { x -> x.track }) + temp.addAll(downloadQueue.map { x -> x.track }) return temp.distinct().sorted() } - @Synchronized - fun clearDownloadFileCache() { - downloadFileCache.clear() - } - @Synchronized fun clearBackground() { // Clear the pending queue downloadQueue.clear() // Cancel all active downloads with a low priority - for (download in activelyDownloading) { - if (download.priority >= 100) { - download.cancelDownload() - activelyDownloading.remove(download) + for (key in activelyDownloading.keys) { + if (key.priority >= 100) { + activelyDownloading[key]?.cancel() + activelyDownloading.remove(key) } } @@ -305,125 +257,125 @@ class Downloader( fun clearActiveDownloads() { // Cancel all active downloads for (download in activelyDownloading) { - download.cancelDownload() + download.value.cancel() } activelyDownloading.clear() updateLiveData() } @Synchronized - fun downloadBackground(songs: List, save: Boolean) { - + fun downloadBackground(tracks: List, save: Boolean) { // By using the counter we ensure that the songs are added in the correct order - for (song in songs) { - val file = song.getDownloadFile() - file.shouldSave = save - if (!file.isDownloading) { - file.priority = backgroundPriorityCounter++ - downloadQueue.add(file) - } + for (track in tracks) { + if (downloadQueue.any { t -> t.track.id == track.id }) continue + val file = DownloadableTrack(track, save, 0, backgroundPriorityCounter++) + downloadQueue.add(file) } Timber.v("downloadBackground Checking Downloads") 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") - fun getDownloadFileForSong(song: Track): DownloadFile { - for (downloadFile in legacyPlaylistManager.playlist) { - if (downloadFile.track == song) { - return downloadFile - } + fun getDownloadState(track: Track): DownloadStatus { + if (Storage.isPathExists(track.getCompleteFile())) return DownloadStatus.DONE + if (Storage.isPathExists(track.getPinnedFile())) return DownloadStatus.PINNED + + 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 (downloadFile.track == song) { - 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 + if (failedList.any { t -> t.track.id == track.id }) return DownloadStatus.FAILED + return DownloadStatus.IDLE } companion object { const val CHECK_INTERVAL = 5000L + const val MAX_RETRIES = 5 + const val REFRESH_INTERVAL = 100 } - /** - * Extension function - * Gathers the download file for a given song, and modifies shouldSave if provided. - */ - private fun Track.getDownloadFile(save: Boolean? = null): DownloadFile { - return getDownloadFileForSong(this).apply { - if (save != null) this.shouldSave = save - } + private fun postState(track: Track, state: DownloadStatus, progress: Int) { + RxBus.trackDownloadStatePublisher.onNext( + RxBus.TrackDownloadState( + track, + state, + progress + ) + ) } - private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() { + private inner class DownloadTask(private val item: DownloadableTrack) : + CancellableTask() { val musicService = MusicServiceFactory.getMusicService() - @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth") + @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "TooGenericExceptionThrown") override fun execute() { - downloadFile.downloadPrepared = false var inputStream: InputStream? = null var outputStream: OutputStream? = null try { - if (Storage.isPathExists(downloadFile.pinnedFile)) { - Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile) - downloadFile.status.postValue(DownloadStatus.PINNED) + if (Storage.isPathExists(item.pinnedFile)) { + Timber.i("%s already exists. Skipping.", item.pinnedFile) + postState(item.track, DownloadStatus.PINNED, 100) return } - if (Storage.isPathExists(downloadFile.completeFile)) { + if (Storage.isPathExists(item.completeFile)) { var newStatus: DownloadStatus = DownloadStatus.DONE - if (downloadFile.shouldSave) { - if (downloadFile.isPlaying) { - downloadFile.saveWhenDone = true - } else { - Storage.rename( - downloadFile.completeFile, - downloadFile.pinnedFile - ) - newStatus = DownloadStatus.PINNED - } + if (item.pinned) { + Storage.rename( + item.completeFile, + item.pinnedFile + ) + newStatus = DownloadStatus.PINNED } else { Timber.i( "%s already exists. Skipping.", - downloadFile.completeFile + item.completeFile ) } // Hidden feature: If track is toggled between pinned/saved, refresh the metadata.. try { - downloadFile.track.cacheMetadata() + item.track.cacheMetadata() } catch (ignore: Exception) { Timber.w(ignore) } - - downloadFile.status.postValue(newStatus) + postState(item.track, newStatus, 100) 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 val needsDownloading: Boolean - val duration = downloadFile.track.duration - val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0 + val duration = item.track.duration + val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0 needsDownloading = ( - downloadFile.desiredBitRate == 0 || - duration == null || + duration == null || duration == 0 || fileLength == 0L ) @@ -431,9 +383,9 @@ class Downloader( if (needsDownloading) { // Attempt partial HTTP GET, appending to the file if it exists. val (inStream, isPartial) = musicService.getDownloadInputStream( - downloadFile.track, fileLength, - downloadFile.desiredBitRate, - downloadFile.shouldSave + item.track, fileLength, + Settings.maxBitRate, + item.pinned ) inputStream = inStream @@ -442,31 +394,40 @@ class Downloader( Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) } - outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile) + outputStream = Storage.getOrCreateFileFromPath(item.partialFile) .getFileOutputStream(isPartial) + var lastPostTime: Long = 0 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() outputStream.flush() outputStream.close() if (isCancelled) { - downloadFile.status.postValue(DownloadStatus.CANCELLED) + postState(item.track, DownloadStatus.CANCELLED, 0) throw RuntimeException( String.format( Locale.ROOT, "Download of '%s' was cancelled", - downloadFile.track + item ) ) } try { - downloadFile.track.cacheMetadata() + item.track.cacheMetadata() } catch (ignore: Exception) { Timber.w(ignore) } @@ -474,40 +435,40 @@ class Downloader( downloadAndSaveCoverArt() } - if (downloadFile.isPlaying) { - downloadFile.completeWhenDone = true + if (item.pinned) { + Storage.rename( + item.partialFile, + item.pinnedFile + ) + postState(item.track, DownloadStatus.PINNED, 100) + Util.scanMedia(item.pinnedFile) } else { - if (downloadFile.shouldSave) { - Storage.rename( - downloadFile.partialFile, - downloadFile.pinnedFile - ) - downloadFile.status.postValue(DownloadStatus.PINNED) - Util.scanMedia(downloadFile.pinnedFile) - } else { - Storage.rename( - downloadFile.partialFile, - downloadFile.completeFile - ) - downloadFile.status.postValue(DownloadStatus.DONE) - } + Storage.rename( + item.partialFile, + item.completeFile + ) + postState(item.track, DownloadStatus.DONE, 100) } } catch (all: Exception) { outputStream.safeClose() - Storage.delete(downloadFile.completeFile) - Storage.delete(downloadFile.pinnedFile) + Storage.delete(item.completeFile) + Storage.delete(item.pinnedFile) if (!isCancelled) { - downloadFile.isFailed = true - if (downloadFile.retryCount > 1) { - downloadFile.status.postValue(DownloadStatus.RETRYING) - --downloadFile.retryCount - } else if (downloadFile.retryCount == 1) { - downloadFile.status.postValue(DownloadStatus.FAILED) - --downloadFile.retryCount + if (item.tryCount < MAX_RETRIES) { + postState(item.track, DownloadStatus.RETRYING, 0) + item.tryCount++ + activelyDownloading.remove(item) + downloadQueue.add(item) + } else { + 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 { + activelyDownloading.remove(item) inputStream.safeClose() outputStream.safeClose() CacheCleaner().cleanSpace() @@ -517,7 +478,7 @@ class Downloader( } 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() { @@ -567,9 +528,9 @@ class Downloader( private fun downloadAndSaveCoverArt() { try { - if (!TextUtils.isEmpty(downloadFile.track.coverArt)) { + if (!TextUtils.isEmpty(item.track.coverArt)) { // Download the largest size that we can display in the UI - imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track) + imageLoaderProvider.getImageLoader().cacheCoverArt(item.track) } } catch (all: Exception) { Timber.e(all, "Failed to get cover art.") @@ -590,4 +551,26 @@ class Downloader( 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 } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt index 75c926a1..0479a9eb 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt @@ -42,7 +42,7 @@ import timber.log.Timber * TODO: Persist RC state? * TODO: Minimize status updates. */ -class JukeboxMediaPlayer(private val downloader: Downloader) { +class JukeboxMediaPlayer { private val tasks = TaskQueue() private val executorService = Executors.newSingleThreadScheduledExecutor() private var statusUpdateFuture: ScheduledFuture<*>? = null @@ -156,8 +156,8 @@ class JukeboxMediaPlayer(private val downloader: Downloader) { tasks.remove(Stop::class.java) tasks.remove(Start::class.java) val ids: MutableList = ArrayList() - for (file in downloader.all) { - ids.add(file.track.id) + for (item in mediaPlayerControllerLazy.value.playlist) { + ids.add(item.mediaId) } tasks.add(SetPlaylist(ids)) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index 6cdae7d7..869ba44e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -1,6 +1,6 @@ /* * MediaPlayerController.kt - * Copyright (C) 2009-2021 Ultrasonic developers + * Copyright (C) 2009-2022 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ @@ -10,11 +10,11 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.widget.Toast -import androidx.core.net.toUri +import androidx.media3.common.C import androidx.media3.common.HeartRating import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_OFF import androidx.media3.common.Timeline import androidx.media3.session.MediaController 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.MoreExecutors import io.reactivex.rxjava3.disposables.CompositeDisposable -import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -32,16 +31,17 @@ import org.koin.core.component.inject import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.Track -import org.moire.ultrasonic.playback.LegacyPlaylistManager import org.moire.ultrasonic.playback.PlaybackService import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.MainThreadExecutor 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 /** @@ -54,7 +54,6 @@ class MediaPlayerController( private val playbackStateSerializer: PlaybackStateSerializer, private val externalStorageMonitor: ExternalStorageMonitor, private val downloader: Downloader, - private val legacyPlaylistManager: LegacyPlaylistManager, val context: Context ) : KoinComponent { @@ -85,6 +84,8 @@ class MediaPlayerController( private lateinit var listeners: Player.Listener + private var cachedMediaItem: MediaItem? = null + fun onCreate(onCreated: () -> Unit) { if (created) return externalStorageMonitor.onCreate { reset() } @@ -111,7 +112,7 @@ class MediaPlayerController( * We run the event through RxBus in order to throttle them */ override fun onTimelineChanged(timeline: Timeline, reason: Int) { - legacyPlaylistManager.rebuildPlaylist(controller!!) + RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack)) } override fun onPlaybackStateChanged(playbackState: Int) { @@ -125,8 +126,8 @@ class MediaPlayerController( } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - onTrackCompleted() - legacyPlaylistManager.updateCurrentPlaying(mediaItem) + clearBookmark() + cachedMediaItem = mediaItem publishPlaybackState() } @@ -180,7 +181,7 @@ class MediaPlayerController( rxBusSubscription += RxBus.shutdownCommandObservable.subscribe { playbackStateSerializer.serializeNow( - playList, + playlist.map { it.toTrack() }, currentMediaItemIndex, playerPosition, isShufflePlayEnabled, @@ -196,7 +197,7 @@ class MediaPlayerController( private fun playerStateChangedHandler() { - val currentPlaying = legacyPlaylistManager.currentPlaying + val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return when (playbackState) { Player.STATE_READY -> { @@ -210,16 +211,14 @@ class MediaPlayerController( } // Update widget - if (currentPlaying != null) { - updateWidget(currentPlaying.track) - } + updateWidget(currentPlaying) } - private fun onTrackCompleted() { - // This method is called before we update the currentPlaying, - // so in fact currentPlaying will refer to the track that has just finished. - if (legacyPlaylistManager.currentPlaying != null) { - val song = legacyPlaylistManager.currentPlaying!!.track + private fun clearBookmark() { + // This method is called just before we update the cachedMediaItem, + // so in fact cachedMediaItem will refer to the track that has just finished. + if (cachedMediaItem != null) { + val song = cachedMediaItem!!.toTrack() if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) { val musicService = getMusicService() try { @@ -232,7 +231,7 @@ class MediaPlayerController( private fun publishPlaybackState() { val newState = RxBus.StateWithTrack( - track = legacyPlaylistManager.currentPlaying, + track = currentMediaItem?.let { it.toTrack() }, index = currentMediaItemIndex, isPlaying = isPlaying, state = playbackState @@ -261,7 +260,6 @@ class MediaPlayerController( val context = UApp.applicationContext() externalStorageMonitor.onDestroy() context.stopService(Intent(context, DownloadService::class.java)) - legacyPlaylistManager.onDestroy() downloader.onDestroy() created = false Timber.i("MediaPlayerController destroyed") @@ -345,12 +343,17 @@ class MediaPlayerController( @Synchronized fun seekTo(position: Int) { + if (controller?.currentTimeline?.isEmpty != false) return Timber.i("SeekTo: %s", position) controller?.seekTo(position.toLong()) } @Synchronized 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) controller?.seekTo(index, position.toLong()) } @@ -390,10 +393,8 @@ class MediaPlayerController( } val mediaItems: List = songs.map { - val downloadFile = downloader.getDownloadFileForSong(it) - if (cachePermanently) downloadFile.shouldSave = true val result = it.toMediaItem() - legacyPlaylistManager.addToCache(result, downloader.getDownloadFileForSong(it)) + if (cachePermanently) result.setPin(true) result } @@ -426,9 +427,8 @@ class MediaPlayerController( get() = controller?.shuffleModeEnabled == true set(enabled) { controller?.shuffleModeEnabled = enabled - if (enabled) { - downloader.checkDownloads() - } + // Changing Shuffle may change the playlist, so the next tracks may need to be downloaded + downloader.checkDownloads() } @Synchronized @@ -467,11 +467,6 @@ class MediaPlayerController( jukeboxMediaPlayer.updatePlaylist() } - @Synchronized - fun clearCaches() { - downloader.clearDownloadFileCache() - } - @Synchronized fun clearIncomplete() { reset() @@ -496,7 +491,7 @@ class MediaPlayerController( if (currentMediaItemIndex == -1) return playbackStateSerializer.serializeAsync( - songs = legacyPlaylistManager.playlist, + songs = playlist.map { it.toTrack() }, currentPlayingIndex = currentMediaItemIndex, currentPlayingPosition = playerPosition, isShufflePlayEnabled, @@ -506,17 +501,17 @@ class MediaPlayerController( @Synchronized // TODO: Make it require not null - fun delete(songs: List) { - for (song in songs.filterNotNull()) { - downloader.getDownloadFileForSong(song).delete() + fun delete(tracks: List) { + for (track in tracks.filterNotNull()) { + downloader.delete(track) } } @Synchronized // TODO: Make it require not null - fun unpin(songs: List) { - for (song in songs.filterNotNull()) { - downloader.getDownloadFileForSong(song).unpin() + fun unpin(tracks: List) { + for (track in tracks.filterNotNull()) { + downloader.unpin(track) } } @@ -598,8 +593,8 @@ class MediaPlayerController( } fun toggleSongStarred() { - if (legacyPlaylistManager.currentPlaying == null) return - val song = legacyPlaylistManager.currentPlaying!!.track + if (currentMediaItem == null) return + val song = currentMediaItem!!.toTrack() controller?.setRating( HeartRating(!song.starred) @@ -630,8 +625,8 @@ class MediaPlayerController( @Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions fun setSongRating(rating: Int) { if (!Settings.useFiveStarRating) return - if (legacyPlaylistManager.currentPlaying == null) return - val song = legacyPlaylistManager.currentPlaying!!.track + if (currentMediaItem == null) return + val song = currentMediaItem!!.toTrack() song.userRating = rating Thread { try { @@ -650,29 +645,56 @@ class MediaPlayerController( val currentMediaItemIndex: Int get() = controller?.currentMediaItemIndex ?: -1 - @Deprecated("Use currentMediaItem") - val currentPlayingLegacy: DownloadFile? - get() = legacyPlaylistManager.currentPlaying - val mediaItemCount: Int get() = controller?.mediaItemCount ?: 0 - @Deprecated("Use mediaItemCount") val playlistSize: Int - get() = legacyPlaylistManager.playlist.size + get() = controller?.currentTimeline?.windowCount ?: 0 - @Deprecated("Use native APIs") - val playList: List - get() = legacyPlaylistManager.playlist + val playlist: List + get() { + return getPlayList(false) + } - @Deprecated("Use timeline") - val playListDuration: Long - get() = legacyPlaylistManager.playlistDuration + val playlistInPlayOrder: List + get() { + return getPlayList(controller?.shuffleModeEnabled ?: false) + } - fun getDownloadFileForSong(song: Track): DownloadFile { - return downloader.getDownloadFileForSong(song) + fun getNextPlaylistItemsInPlayOrder(count: Int? = null): List { + return getPlayList( + controller?.shuffleModeEnabled ?: false, + controller?.currentMediaItemIndex, + count + ) } + private fun getPlayList( + shuffle: Boolean, + firstIndex: Int? = null, + count: Int? = null + ): List { + if (controller?.currentTimeline == null) return emptyList() + if (controller!!.currentTimeline.windowCount < 1) return emptyList() + val timeline = controller!!.currentTimeline + + val playlist: MutableList = mutableListOf() + var i = firstIndex ?: timeline.getFirstWindowIndex(false) + if (i == C.INDEX_UNSET) return emptyList() + + while (i != C.INDEX_UNSET && (count != playlist.count())) { + val window = timeline.getWindow(i, Timeline.Window()) + playlist.add(window.mediaItem) + i = timeline.getNextWindowIndex(i, REPEAT_MODE_OFF, shuffle) + } + return playlist + } + + val playListDuration: Long + get() = playlist.fold(0) { i, file -> + i + (file.mediaMetadata.extras?.getInt("duration") ?: 0) + } + init { Timber.i("MediaPlayerController instance initiated") } @@ -681,38 +703,3 @@ class MediaPlayerController( 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() -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt index e7f56ec8..bad9dc2b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackStateSerializer.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil import timber.log.Timber @@ -32,7 +33,7 @@ class PlaybackStateSerializer : KoinComponent { private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) fun serializeAsync( - songs: Iterable, + songs: Iterable, currentPlayingIndex: Int, currentPlayingPosition: Int, shufflePlay: Boolean, @@ -56,19 +57,14 @@ class PlaybackStateSerializer : KoinComponent { } fun serializeNow( - referencedList: Iterable, + tracks: Iterable, currentPlayingIndex: Int, currentPlayingPosition: Int, shufflePlay: Boolean, repeatMode: Int ) { - - val tracks = referencedList.toList().map { - it.track - } - val state = PlaybackState( - tracks, + tracks.toList(), currentPlayingIndex, currentPlayingPosition, shufflePlay, diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt index aa5d7bda..a932eb8e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/RxBus.kt @@ -7,6 +7,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.subjects.PublishSubject import java.util.concurrent.TimeUnit +import org.moire.ultrasonic.domain.Track class RxBus { @@ -41,18 +42,23 @@ class RxBus { .autoConnect(0) .throttleLatest(300, TimeUnit.MILLISECONDS) - val playlistPublisher: PublishSubject> = + val playlistPublisher: PublishSubject> = PublishSubject.create() - val playlistObservable: Observable> = + val playlistObservable: Observable> = playlistPublisher.observeOn(mainThread()) .replay(1) .autoConnect(0) - val throttledPlaylistObservable: Observable> = + val throttledPlaylistObservable: Observable> = playlistPublisher.observeOn(mainThread()) .replay(1) .autoConnect(0) .throttleLatest(300, TimeUnit.MILLISECONDS) + val trackDownloadStatePublisher: PublishSubject = + PublishSubject.create() + val trackDownloadStateObservable: Observable = + trackDownloadStatePublisher.observeOn(mainThread()) + // Commands val dismissNowPlayingCommandPublisher: PublishSubject = PublishSubject.create() @@ -76,11 +82,17 @@ class RxBus { } data class StateWithTrack( - val track: DownloadFile?, + val track: Track?, val index: Int = -1, val isPlaying: Boolean = false, val state: Int ) + + data class TrackDownloadState( + val track: Track, + val state: DownloadStatus, + val progress: Int + ) } operator fun CompositeDisposable.plusAssign(disposable: Disposable) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index e585ea66..dc9f4309 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -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 import android.system.Os @@ -6,12 +13,16 @@ import java.util.HashSet import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.inject import org.moire.ultrasonic.data.ActiveServerProvider 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.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.getPlaylistFile import org.moire.ultrasonic.util.FileUtil.listFiles @@ -26,6 +37,8 @@ import timber.log.Timber */ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { + private var mainScope = CoroutineScope(Dispatchers.Main) + private fun exceptionHandler(tag: String): CoroutineExceptionHandler { return CoroutineExceptionHandler { _, exception -> Timber.w(exception, "Exception in CacheCleaner.$tag") @@ -129,6 +142,24 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { } } + private fun findFilesToNotDelete(): Set { + val filesToNotDelete: MutableSet = HashSet(5) + val mediaController = inject( + 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 { private val lock = Object() private var cleaning = false @@ -247,21 +278,5 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) { a.lastModified.compareTo(b.lastModified) } } - - private fun findFilesToNotDelete(): Set { - val filesToNotDelete: MutableSet = HashSet(5) - val downloader = inject( - 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 - } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt index 31e686fc..11fef99a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -80,6 +80,20 @@ object FileUtil { 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 fun getPlaylistFile(server: String?, name: String?): File { val playlistDir = getPlaylistDirectory(server) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaItemConverter.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaItemConverter.kt new file mode 100644 index 00000000..58045c67 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaItemConverter.kt @@ -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> = LRUCache(CACHE_SIZE) + val trackCache: LRUCache> = LRUCache(CACHE_SIZE) + + /** + * Adds a MediaItem to the cache with default expiry time + */ + fun addToCache(key: String, item: MediaItem) { + val cache: TimeLimitedCache = 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 = 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() +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeLimitedCache.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeLimitedCache.kt index 09afdb41..e9288ba3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeLimitedCache.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/TimeLimitedCache.kt @@ -1,6 +1,6 @@ /* * TimeLimitedCache.kt - * Copyright (C) 2009-2021 Ultrasonic developers + * Copyright (C) 2009-2022 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ @@ -15,7 +15,12 @@ class TimeLimitedCache(expiresAfter: Long = 60L, timeUnit: TimeUnit = TimeUni private var expires: Long = 0 fun get(): T? { - return if (System.currentTimeMillis() < expires) value!!.get() else null + return if (System.currentTimeMillis() < expires) { + value!!.get() + } else { + clear() + null + } } @JvmOverloads diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index 641b4591..81194ff8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -1,6 +1,6 @@ /* * Util.kt - * Copyright (C) 2009-2021 Ultrasonic developers + * Copyright (C) 2009-2022 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ @@ -14,9 +14,6 @@ import android.content.Context import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable import android.media.MediaScannerConnection import android.net.ConnectivityManager import android.net.Network @@ -342,7 +339,7 @@ object Util { */ @Suppress("DEPRECATION") fun networkInfo(): NetworkInfo { - val manager = getConnectivityManager() + val manager = connectivityManager val info = NetworkInfo() 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 { val wm = appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager @@ -423,15 +392,6 @@ object Util { 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 { val musicDirectory = MusicDirectory() for (entry in searchResult.songs) { @@ -707,10 +667,8 @@ object Util { ) } - fun getConnectivityManager(): ConnectivityManager { - val context = appContext() - return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - } + private val connectivityManager: ConnectivityManager + get() = appContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager /** * Executes the given block if this is not null. diff --git a/ultrasonic/src/main/res/drawable/ic_artist.xml b/ultrasonic/src/main/res/drawable/ic_artist.xml new file mode 100644 index 00000000..c3daf609 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_artist.xml @@ -0,0 +1,9 @@ + + + diff --git a/ultrasonic/src/main/res/drawable/ic_library.xml b/ultrasonic/src/main/res/drawable/ic_library.xml new file mode 100644 index 00000000..6981f924 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_library.xml @@ -0,0 +1,9 @@ + + +