mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-15 08:50:35 +03:00
Fixed retrieving MediaItems with the AutoMediaBrowser
This commit is contained in:
parent
8d0ff385af
commit
413626ac5c
@ -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"
|
||||
|
||||
|
@ -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=" <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="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
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"
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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()) }
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 =
|
||||
|
@ -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!!)
|
||||
}
|
||||
}
|
||||
|
@ -433,7 +433,6 @@ class SettingsFragment :
|
||||
|
||||
// Clear download queue.
|
||||
mediaPlayerController.clear()
|
||||
mediaPlayerController.clearCaches()
|
||||
Storage.reset()
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
@ -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
|
||||
|
@ -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.
|
||||
|
9
ultrasonic/src/main/res/drawable/ic_artist.xml
Normal file
9
ultrasonic/src/main/res/drawable/ic_artist.xml
Normal 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>
|
9
ultrasonic/src/main/res/drawable/ic_library.xml
Normal file
9
ultrasonic/src/main/res/drawable/ic_library.xml
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user