Fixed retrieving MediaItems with the AutoMediaBrowser

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<DownloadFile>() {
class DownloadsFragment : MultiListFragment<Track>() {
/**
* The ViewModel to use to get the data
@ -41,7 +41,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
/**
* The central function to pass a query to the model and return a LiveData object
*/
override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData<List<DownloadFile>> {
override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData<List<Track>> {
return listModel.getList()
}
@ -71,12 +71,12 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
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<DownloadFile>() {
class DownloadListModel(application: Application) : GenericListModel(application) {
private val downloader by inject<Downloader>()
fun getList(): LiveData<List<DownloadFile>> {
fun getList(): LiveData<List<Track>> {
return downloader.observableDownloads
}
}

View File

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

View File

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

View File

@ -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<Track?> = 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 =

View File

@ -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<Identifiable>(), KoinComponent {
this
)
if (found || item !is DownloadFile) return true
if (found || item !is Track) return true
val songs = mutableListOf<Track>()
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<Identifiable>(), 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<Identifiable>(), 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<Identifiable>(), KoinComponent {
)
}
R.id.song_menu_pin -> {
songs.add(item.track)
songs.add(item)
toast(
context,
resources.getQuantityString(
@ -407,7 +406,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), 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<Identifiable>(), 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<Identifiable>(), KoinComponent {
mediaPlayerController.unpin(songs)
}
R.id.song_menu_share -> {
songs.add(item.track)
songs.add(item)
shareHandler.createShare(this, songs, searchRefresh, cancellationToken!!)
}
}

View File

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

View File

@ -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<MusicDirectory.Child>() {
private var shareButton: MenuItem? = null
internal val mediaPlayerController: MediaPlayerController by inject()
internal val downloader: Downloader by inject()
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
private val shareHandler: ShareHandler by inject()
internal var cancellationToken: CancellationToken? = null
@ -125,8 +128,8 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
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<MusicDirectory.Child>() {
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
}

View File

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

View File

@ -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<String, CountDownLatch> = 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,

View File

@ -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<Track>? = null
private var starredSongsCache: List<Track>? = null
@ -226,7 +227,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
* is stored in the track.starred value
* 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<MediaItem>
): ListenableFuture<MutableList<MediaItem>> {
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<Track>? {
// 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<LibraryResult<ImmutableList<MediaItem>>> {
@ -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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<Track>? {
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<Track>? {
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<Track>? {
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<Track>? {
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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<Track>? {
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<Track>? {
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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<Track>? {
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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<Track>? {
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<Track>? {
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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<Track>? {
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<Track>? {
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<LibraryResult<ImmutableList<MediaItem>>> {
val mediaItems: MutableList<MediaItem> = 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<Track>? {
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<Track>? {
// 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<MediaItem>.add(
@ -1124,20 +1168,28 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
this.add(mediaItem)
}
@Suppress("LongParameterList")
private fun MutableList<MediaItem>.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<Track>) {
mediaPlayerController.addToPlaylist(
songs,
cachePermanently = false,
autoPlay = true,
shuffle = false,
insertionMode = MediaPlayerController.InsertionMode.CLEAR
)
}
private fun playSong(song: Track) {
mediaPlayerController.addToPlaylist(
listOf(song),
cachePermanently = false,
autoPlay = false,
shuffle = false,
insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT
)
if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next()
else mediaPlayerController.play()
}
private fun <T> callWithErrorHandling(function: () -> T): T? {
// 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()
}
}

View File

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

View File

@ -22,6 +22,7 @@ import org.koin.core.component.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.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
)

View File

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

View File

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

View File

@ -1,8 +1,16 @@
/*
* Downloader.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
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<DownloadFile>()
private val activelyDownloading = mutableListOf<DownloadFile>()
private val downloadQueue = PriorityQueue<DownloadableTrack>()
private val activelyDownloading = mutableMapOf<DownloadableTrack, DownloadTask>()
private val failedList = mutableListOf<DownloadableTrack>()
// The generic list models expect a LiveData, so even though we are using Rx for many events
// surrounding playback the list of Downloads is published as LiveData.
val observableDownloads = MutableLiveData<List<DownloadFile>>()
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
private val downloadFileCache = LRUCache<Track, DownloadFile>(500)
val observableDownloads = MutableLiveData<List<Track>>()
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<DownloadFile>
get() {
val temp: MutableList<DownloadFile> = ArrayList()
temp.addAll(activelyDownloading)
temp.addAll(downloadQueue)
temp.addAll(legacyPlaylistManager.playlist)
return temp.distinct().sorted()
}
/*
* Returns a list of all DownloadFiles that are currently downloading or waiting for download,
* including undownloaded files from the playlist.
*/
*/
@get:Synchronized
val downloads: List<DownloadFile>
val downloads: List<Track>
get() {
val temp: MutableList<DownloadFile> = 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<Track> = 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<Track>, save: Boolean) {
fun downloadBackground(tracks: List<Track>, save: Boolean) {
// By using the counter we ensure that the songs are added in the correct order
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
}

View File

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

View File

@ -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<MediaItem> = 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<Track?>) {
for (song in songs.filterNotNull()) {
downloader.getDownloadFileForSong(song).delete()
fun delete(tracks: List<Track?>) {
for (track in tracks.filterNotNull()) {
downloader.delete(track)
}
}
@Synchronized
// TODO: Make it require not null
fun unpin(songs: List<Track?>) {
for (song in songs.filterNotNull()) {
downloader.getDownloadFileForSong(song).unpin()
fun unpin(tracks: List<Track?>) {
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<DownloadFile>
get() = legacyPlaylistManager.playlist
val playlist: List<MediaItem>
get() {
return getPlayList(false)
}
@Deprecated("Use timeline")
val playListDuration: Long
get() = legacyPlaylistManager.playlistDuration
val playlistInPlayOrder: List<MediaItem>
get() {
return getPlayList(controller?.shuffleModeEnabled ?: false)
}
fun getDownloadFileForSong(song: Track): DownloadFile {
return downloader.getDownloadFileForSong(song)
fun getNextPlaylistItemsInPlayOrder(count: Int? = null): List<MediaItem> {
return getPlayList(
controller?.shuffleModeEnabled ?: false,
controller?.currentMediaItemIndex,
count
)
}
private fun getPlayList(
shuffle: Boolean,
firstIndex: Int? = null,
count: Int? = null
): List<MediaItem> {
if (controller?.currentTimeline == null) return emptyList()
if (controller!!.currentTimeline.windowCount < 1) return emptyList()
val timeline = controller!!.currentTimeline
val playlist: MutableList<MediaItem> = mutableListOf()
var i = firstIndex ?: timeline.getFirstWindowIndex(false)
if (i == C.INDEX_UNSET) return emptyList()
while (i != C.INDEX_UNSET && (count != playlist.count())) {
val window = timeline.getWindow(i, Timeline.Window())
playlist.add(window.mediaItem)
i = timeline.getNextWindowIndex(i, REPEAT_MODE_OFF, shuffle)
}
return playlist
}
val playListDuration: Long
get() = playlist.fold(0) { i, file ->
i + (file.mediaMetadata.extras?.getInt("duration") ?: 0)
}
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()
}

View File

@ -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<DownloadFile>,
songs: Iterable<Track>,
currentPlayingIndex: Int,
currentPlayingPosition: Int,
shufflePlay: Boolean,
@ -56,19 +57,14 @@ class PlaybackStateSerializer : KoinComponent {
}
fun serializeNow(
referencedList: Iterable<DownloadFile>,
tracks: Iterable<Track>,
currentPlayingIndex: Int,
currentPlayingPosition: Int,
shufflePlay: Boolean,
repeatMode: Int
) {
val tracks = referencedList.toList().map {
it.track
}
val state = PlaybackState(
tracks,
tracks.toList(),
currentPlayingIndex,
currentPlayingPosition,
shufflePlay,

View File

@ -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<List<DownloadFile>> =
val playlistPublisher: PublishSubject<List<Track>> =
PublishSubject.create()
val playlistObservable: Observable<List<DownloadFile>> =
val playlistObservable: Observable<List<Track>> =
playlistPublisher.observeOn(mainThread())
.replay(1)
.autoConnect(0)
val throttledPlaylistObservable: Observable<List<DownloadFile>> =
val throttledPlaylistObservable: Observable<List<Track>> =
playlistPublisher.observeOn(mainThread())
.replay(1)
.autoConnect(0)
.throttleLatest(300, TimeUnit.MILLISECONDS)
val trackDownloadStatePublisher: PublishSubject<TrackDownloadState> =
PublishSubject.create()
val trackDownloadStateObservable: Observable<TrackDownloadState> =
trackDownloadStatePublisher.observeOn(mainThread())
// Commands
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
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) {

View File

@ -1,3 +1,10 @@
/*
* CacheCleaner.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.util
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<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val mediaController = inject<MediaPlayerController>(
MediaPlayerController::class.java
)
val playlist = mainScope.future { mediaController.value.playlist }.get()
for (item in playlist) {
val track = item.toTrack()
filesToNotDelete.add(track.getPartialFile())
filesToNotDelete.add(track.getCompleteFile())
filesToNotDelete.add(track.getPinnedFile())
}
filesToNotDelete.add(musicDirectory.path)
return filesToNotDelete
}
companion object {
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<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val downloader = inject<Downloader>(
Downloader::class.java
)
for (downloadFile in downloader.value.all) {
filesToNotDelete.add(downloadFile.partialFile)
filesToNotDelete.add(downloadFile.completeFile)
filesToNotDelete.add(downloadFile.pinnedFile)
}
filesToNotDelete.add(musicDirectory.path)
return filesToNotDelete
}
}
}

View File

@ -80,6 +80,20 @@ object FileUtil {
return "$dir/$fileName"
}
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)

View File

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

View File

@ -1,6 +1,6 @@
/*
* TimeLimitedCache.kt
* 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<T>(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

View File

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

View File

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

View File

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