mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-18 18:17:43 +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"
|
retrofit = "2.9.0"
|
||||||
jackson = "2.10.1"
|
jackson = "2.10.1"
|
||||||
okhttp = "4.9.1"
|
okhttp = "4.10.0"
|
||||||
koin = "3.0.2"
|
koin = "3.0.2"
|
||||||
picasso = "2.71828"
|
picasso = "2.71828"
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="154"
|
line="156"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
@ -59,6 +59,17 @@
|
|||||||
column="10"/>
|
column="10"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="ExportedContentProvider"
|
||||||
|
message="Exported content providers can provide access to potentially sensitive data"
|
||||||
|
errorLine1=" <provider"
|
||||||
|
errorLine2=" ~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/AndroidManifest.xml"
|
||||||
|
line="160"
|
||||||
|
column="10"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="ExportedReceiver"
|
id="ExportedReceiver"
|
||||||
message="Exported receiver does not require permission"
|
message="Exported receiver does not require permission"
|
||||||
@ -213,17 +224,6 @@
|
|||||||
column="1"/>
|
column="1"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
<issue
|
|
||||||
id="UnusedResources"
|
|
||||||
message="The resource `R.drawable.ic_menu_close` appears to be unused"
|
|
||||||
errorLine1="<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
|
<issue
|
||||||
id="UnusedResources"
|
id="UnusedResources"
|
||||||
message="The resource `R.drawable.ic_menu_forward` appears to be unused"
|
message="The resource `R.drawable.ic_menu_forward` appears to be unused"
|
||||||
|
@ -157,6 +157,10 @@
|
|||||||
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"
|
android:authorities="org.moire.ultrasonic.provider.SearchSuggestionProvider"
|
||||||
android:exported="true" />
|
android:exported="true" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name=".provider.AlbumArtContentProvider"
|
||||||
|
android:authorities="org.moire.ultrasonic.provider.AlbumArtContentProvider"
|
||||||
|
android:exported="true" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -2,6 +2,7 @@ package org.moire.ultrasonic.service;
|
|||||||
|
|
||||||
import timber.log.Timber;
|
import timber.log.Timber;
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider;
|
import org.moire.ultrasonic.data.ActiveServerProvider;
|
||||||
|
import org.moire.ultrasonic.domain.Track;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scrobbles played songs to Last.fm.
|
* Scrobbles played songs to Last.fm.
|
||||||
@ -14,12 +15,11 @@ public class Scrobbler
|
|||||||
private String lastSubmission;
|
private String lastSubmission;
|
||||||
private String lastNowPlaying;
|
private String lastNowPlaying;
|
||||||
|
|
||||||
public void scrobble(final DownloadFile song, final boolean submission)
|
public void scrobble(final Track song, final boolean submission)
|
||||||
{
|
{
|
||||||
if (song == null || !ActiveServerProvider.Companion.isScrobblingEnabled()) return;
|
if (song == null || !ActiveServerProvider.Companion.isScrobblingEnabled()) return;
|
||||||
|
|
||||||
final String id = song.getTrack().getId();
|
final String id = song.getId();
|
||||||
if (id == null) return;
|
|
||||||
|
|
||||||
// Avoid duplicate registrations.
|
// Avoid duplicate registrations.
|
||||||
if (submission && id.equals(lastSubmission)) return;
|
if (submission && id.equals(lastSubmission)) return;
|
||||||
|
@ -9,16 +9,13 @@ import android.view.ViewGroup
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import com.drakeet.multitype.ItemViewBinder
|
import com.drakeet.multitype.ItemViewBinder
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.domain.Identifiable
|
import org.moire.ultrasonic.domain.Identifiable
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
|
||||||
import org.moire.ultrasonic.service.Downloader
|
|
||||||
|
|
||||||
class TrackViewBinder(
|
class TrackViewBinder(
|
||||||
val onItemClick: (DownloadFile, Int) -> Unit,
|
val onItemClick: (Track, Int) -> Unit,
|
||||||
val onContextMenuClick: ((MenuItem, DownloadFile) -> Boolean)? = null,
|
val onContextMenuClick: ((MenuItem, Track) -> Boolean)? = null,
|
||||||
val checkable: Boolean,
|
val checkable: Boolean,
|
||||||
val draggable: Boolean,
|
val draggable: Boolean,
|
||||||
context: Context,
|
context: Context,
|
||||||
@ -31,7 +28,6 @@ class TrackViewBinder(
|
|||||||
val layout = R.layout.list_item_track
|
val layout = R.layout.list_item_track
|
||||||
private val contextMenuLayout = R.menu.context_menu_track
|
private val contextMenuLayout = R.menu.context_menu_track
|
||||||
|
|
||||||
private val downloader: Downloader by inject()
|
|
||||||
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
|
private val imageHelper: Utils.ImageHelper = Utils.ImageHelper(context)
|
||||||
|
|
||||||
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): TrackViewHolder {
|
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup): TrackViewHolder {
|
||||||
@ -43,11 +39,8 @@ class TrackViewBinder(
|
|||||||
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
|
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
|
||||||
val diffAdapter = adapter as BaseAdapter<*>
|
val diffAdapter = adapter as BaseAdapter<*>
|
||||||
|
|
||||||
val downloadFile: DownloadFile = when (item) {
|
val track: Track = when (item) {
|
||||||
is Track -> {
|
is Track -> {
|
||||||
downloader.getDownloadFileForSong(item)
|
|
||||||
}
|
|
||||||
is DownloadFile -> {
|
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
@ -61,7 +54,7 @@ class TrackViewBinder(
|
|||||||
holder.observableChecked.removeObservers(lifecycleOwner)
|
holder.observableChecked.removeObservers(lifecycleOwner)
|
||||||
|
|
||||||
holder.setSong(
|
holder.setSong(
|
||||||
file = downloadFile,
|
song = track,
|
||||||
checkable = checkable,
|
checkable = checkable,
|
||||||
draggable = draggable,
|
draggable = draggable,
|
||||||
diffAdapter.isSelected(item.longId)
|
diffAdapter.isSelected(item.longId)
|
||||||
@ -72,11 +65,11 @@ class TrackViewBinder(
|
|||||||
val popup = Utils.createPopupMenu(holder.itemView, contextMenuLayout)
|
val popup = Utils.createPopupMenu(holder.itemView, contextMenuLayout)
|
||||||
|
|
||||||
popup.setOnMenuItemClickListener { menuItem ->
|
popup.setOnMenuItemClickListener { menuItem ->
|
||||||
onContextMenuClick.invoke(menuItem, downloadFile)
|
onContextMenuClick.invoke(menuItem, track)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Minimize or maximize the Text view (if song title is very long)
|
// Minimize or maximize the Text view (if song title is very long)
|
||||||
if (!downloadFile.track.isDirectory) {
|
if (!track.isDirectory) {
|
||||||
holder.maximizeOrMinimize()
|
holder.maximizeOrMinimize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,11 +78,11 @@ class TrackViewBinder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
holder.itemView.setOnClickListener {
|
holder.itemView.setOnClickListener {
|
||||||
if (checkable && !downloadFile.track.isVideo) {
|
if (checkable && !track.isVideo) {
|
||||||
val nowChecked = !holder.check.isChecked
|
val nowChecked = !holder.check.isChecked
|
||||||
holder.isChecked = nowChecked
|
holder.isChecked = nowChecked
|
||||||
} else {
|
} else {
|
||||||
onItemClick(downloadFile, holder.bindingAdapterPosition)
|
onItemClick(track, holder.bindingAdapterPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,20 +112,6 @@ class TrackViewBinder(
|
|||||||
|
|
||||||
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
|
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe download status
|
|
||||||
downloadFile.status.observe(
|
|
||||||
lifecycleOwner
|
|
||||||
) {
|
|
||||||
holder.updateStatus(it)
|
|
||||||
diffAdapter.notifyChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadFile.progress.observe(
|
|
||||||
lifecycleOwner
|
|
||||||
) {
|
|
||||||
holder.updateProgress(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewRecycled(holder: TrackViewHolder) {
|
override fun onViewRecycled(holder: TrackViewHolder) {
|
||||||
|
@ -11,15 +11,17 @@ import android.widget.TextView
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
|
||||||
import org.moire.ultrasonic.service.DownloadStatus
|
import org.moire.ultrasonic.service.DownloadStatus
|
||||||
|
import org.moire.ultrasonic.service.Downloader
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory
|
import org.moire.ultrasonic.service.MusicServiceFactory
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
|
import org.moire.ultrasonic.service.plusAssign
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@ -29,6 +31,8 @@ import timber.log.Timber
|
|||||||
*/
|
*/
|
||||||
class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable, KoinComponent {
|
class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable, KoinComponent {
|
||||||
|
|
||||||
|
private val downloader: Downloader by inject()
|
||||||
|
|
||||||
var check: CheckedTextView = view.findViewById(R.id.song_check)
|
var check: CheckedTextView = view.findViewById(R.id.song_check)
|
||||||
private var rating: LinearLayout = view.findViewById(R.id.song_five_star)
|
private var rating: LinearLayout = view.findViewById(R.id.song_five_star)
|
||||||
private var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1)
|
private var fiveStar1: ImageView = view.findViewById(R.id.song_five_star_1)
|
||||||
@ -46,29 +50,25 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
|||||||
|
|
||||||
var entry: Track? = null
|
var entry: Track? = null
|
||||||
private set
|
private set
|
||||||
var downloadFile: DownloadFile? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var isMaximized = false
|
private var isMaximized = false
|
||||||
private var cachedStatus = DownloadStatus.UNKNOWN
|
private var cachedStatus = DownloadStatus.UNKNOWN
|
||||||
private var statusImage: Drawable? = null
|
private var statusImage: Drawable? = null
|
||||||
private var isPlayingCached = false
|
private var isPlayingCached = false
|
||||||
|
|
||||||
private var rxSubscription: Disposable? = null
|
private var rxBusSubscription: CompositeDisposable? = null
|
||||||
|
|
||||||
var observableChecked = MutableLiveData(false)
|
var observableChecked = MutableLiveData(false)
|
||||||
|
|
||||||
lateinit var imageHelper: Utils.ImageHelper
|
lateinit var imageHelper: Utils.ImageHelper
|
||||||
|
|
||||||
fun setSong(
|
fun setSong(
|
||||||
file: DownloadFile,
|
song: Track,
|
||||||
checkable: Boolean,
|
checkable: Boolean,
|
||||||
draggable: Boolean,
|
draggable: Boolean,
|
||||||
isSelected: Boolean = false
|
isSelected: Boolean = false
|
||||||
) {
|
) {
|
||||||
val useFiveStarRating = Settings.useFiveStarRating
|
val useFiveStarRating = Settings.useFiveStarRating
|
||||||
val song = file.track
|
|
||||||
downloadFile = file
|
|
||||||
entry = song
|
entry = song
|
||||||
|
|
||||||
val entryDescription = Util.readableEntryDescription(song)
|
val entryDescription = Util.readableEntryDescription(song)
|
||||||
@ -94,8 +94,8 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
|||||||
setupStarButtons(song, useFiveStarRating)
|
setupStarButtons(song, useFiveStarRating)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProgress(downloadFile!!.progress.value!!)
|
updateStatus(downloader.getDownloadState(song))
|
||||||
updateStatus(downloadFile!!.status.value!!)
|
updateProgress(0)
|
||||||
|
|
||||||
if (useFiveStarRating) {
|
if (useFiveStarRating) {
|
||||||
setFiveStars(entry?.userRating ?: 0)
|
setFiveStars(entry?.userRating ?: 0)
|
||||||
@ -108,13 +108,22 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
|||||||
progress.isVisible = false
|
progress.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
rxSubscription = RxBus.playerStateObservable.subscribe {
|
// Create new Disposable for the new Subscriptions
|
||||||
setPlayIcon(it.index == bindingAdapterPosition && it.track == downloadFile)
|
rxBusSubscription = CompositeDisposable()
|
||||||
|
rxBusSubscription!! += RxBus.playerStateObservable.subscribe {
|
||||||
|
setPlayIcon(it.index == bindingAdapterPosition && it.track?.id == song.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
rxBusSubscription!! += RxBus.trackDownloadStateObservable.subscribe {
|
||||||
|
if (it.track.id != song.id) return@subscribe
|
||||||
|
updateStatus(it.state)
|
||||||
|
updateProgress(it.progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is called when the Holder is recycled and receives a new Song
|
||||||
fun dispose() {
|
fun dispose() {
|
||||||
rxSubscription?.dispose()
|
rxBusSubscription?.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPlayIcon(isPlaying: Boolean) {
|
private fun setPlayIcon(isPlaying: Boolean) {
|
||||||
@ -198,7 +207,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateStatus(status: DownloadStatus) {
|
private fun updateStatus(status: DownloadStatus) {
|
||||||
if (status == cachedStatus) return
|
if (status == cachedStatus) return
|
||||||
cachedStatus = status
|
cachedStatus = status
|
||||||
|
|
||||||
@ -227,7 +236,7 @@ class TrackViewHolder(val view: View) : RecyclerView.ViewHolder(view), Checkable
|
|||||||
updateImages()
|
updateImages()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProgress(p: Int) {
|
private fun updateProgress(p: Int) {
|
||||||
if (cachedStatus == DownloadStatus.DOWNLOADING) {
|
if (cachedStatus == DownloadStatus.DOWNLOADING) {
|
||||||
progress.text = Util.formatPercentage(p)
|
progress.text = Util.formatPercentage(p)
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package org.moire.ultrasonic.di
|
package org.moire.ultrasonic.di
|
||||||
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
|
||||||
import org.moire.ultrasonic.service.Downloader
|
import org.moire.ultrasonic.service.Downloader
|
||||||
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
import org.moire.ultrasonic.service.ExternalStorageMonitor
|
||||||
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
import org.moire.ultrasonic.service.JukeboxMediaPlayer
|
||||||
@ -13,13 +12,12 @@ import org.moire.ultrasonic.service.PlaybackStateSerializer
|
|||||||
* This Koin module contains the registration of classes related to the media player
|
* This Koin module contains the registration of classes related to the media player
|
||||||
*/
|
*/
|
||||||
val mediaPlayerModule = module {
|
val mediaPlayerModule = module {
|
||||||
single { JukeboxMediaPlayer(get()) }
|
single { JukeboxMediaPlayer() }
|
||||||
single { MediaPlayerLifecycleSupport() }
|
single { MediaPlayerLifecycleSupport() }
|
||||||
single { PlaybackStateSerializer() }
|
single { PlaybackStateSerializer() }
|
||||||
single { ExternalStorageMonitor() }
|
single { ExternalStorageMonitor() }
|
||||||
single { LegacyPlaylistManager() }
|
single { Downloader(get()) }
|
||||||
single { Downloader(get(), get()) }
|
|
||||||
|
|
||||||
// TODO Ideally this can be cleaned up when all circular references are removed.
|
// TODO Ideally this can be cleaned up when all circular references are removed.
|
||||||
single { MediaPlayerController(get(), get(), get(), get(), get()) }
|
single { MediaPlayerController(get(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
@ -17,8 +17,8 @@ import androidx.lifecycle.LiveData
|
|||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.adapters.TrackViewBinder
|
import org.moire.ultrasonic.adapters.TrackViewBinder
|
||||||
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.model.GenericListModel
|
import org.moire.ultrasonic.model.GenericListModel
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
|
||||||
import org.moire.ultrasonic.service.Downloader
|
import org.moire.ultrasonic.service.Downloader
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ import org.moire.ultrasonic.util.Util
|
|||||||
*
|
*
|
||||||
* TODO: Add code to enable manipulation of the download list
|
* TODO: Add code to enable manipulation of the download list
|
||||||
*/
|
*/
|
||||||
class DownloadsFragment : MultiListFragment<DownloadFile>() {
|
class DownloadsFragment : MultiListFragment<Track>() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The ViewModel to use to get the data
|
* The ViewModel to use to get the data
|
||||||
@ -41,7 +41,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
|
|||||||
/**
|
/**
|
||||||
* The central function to pass a query to the model and return a LiveData object
|
* The central function to pass a query to the model and return a LiveData object
|
||||||
*/
|
*/
|
||||||
override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData<List<DownloadFile>> {
|
override fun getLiveData(args: Bundle?, refresh: Boolean): LiveData<List<Track>> {
|
||||||
return listModel.getList()
|
return listModel.getList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,12 +71,12 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
|
|||||||
viewAdapter.submitList(liveDataList.value)
|
viewAdapter.submitList(liveDataList.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onContextMenuItemSelected(menuItem: MenuItem, item: DownloadFile): Boolean {
|
override fun onContextMenuItemSelected(menuItem: MenuItem, item: Track): Boolean {
|
||||||
// TODO: Add code to enable manipulation of the download list
|
// TODO: Add code to enable manipulation of the download list
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: DownloadFile) {
|
override fun onItemClick(item: Track) {
|
||||||
// TODO: Add code to enable manipulation of the download list
|
// TODO: Add code to enable manipulation of the download list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -84,7 +84,7 @@ class DownloadsFragment : MultiListFragment<DownloadFile>() {
|
|||||||
class DownloadListModel(application: Application) : GenericListModel(application) {
|
class DownloadListModel(application: Application) : GenericListModel(application) {
|
||||||
private val downloader by inject<Downloader>()
|
private val downloader by inject<Downloader>()
|
||||||
|
|
||||||
fun getList(): LiveData<List<DownloadFile>> {
|
fun getList(): LiveData<List<Track>> {
|
||||||
return downloader.observableDownloads
|
return downloader.observableDownloads
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -325,7 +325,7 @@ class EditServerFragment : Fragment(), OnBackPressedHandler {
|
|||||||
if (isValid) {
|
if (isValid) {
|
||||||
currentServerSetting!!.name = serverNameEditText!!.editText?.text.toString()
|
currentServerSetting!!.name = serverNameEditText!!.editText?.text.toString()
|
||||||
currentServerSetting!!.url = serverAddressEditText!!.editText?.text.toString()
|
currentServerSetting!!.url = serverAddressEditText!!.editText?.text.toString()
|
||||||
currentServerSetting!!.color = selectedColor
|
currentServerSetting!!.color = selectedColor ?: currentColor
|
||||||
currentServerSetting!!.userName = userNameEditText!!.editText?.text.toString()
|
currentServerSetting!!.userName = userNameEditText!!.editText?.text.toString()
|
||||||
currentServerSetting!!.password = passwordEditText!!.editText?.text.toString()
|
currentServerSetting!!.password = passwordEditText!!.editText?.text.toString()
|
||||||
currentServerSetting!!.allowSelfSignedCertificate = selfSignedSwitch!!.isChecked
|
currentServerSetting!!.allowSelfSignedCertificate = selfSignedSwitch!!.isChecked
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* NowPlayingFragment.kt
|
* NowPlayingFragment.kt
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
*
|
*
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
*/
|
*/
|
||||||
@ -29,6 +29,7 @@ import org.moire.ultrasonic.util.Constants
|
|||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Util.applyTheme
|
import org.moire.ultrasonic.util.Util.applyTheme
|
||||||
import org.moire.ultrasonic.util.Util.getNotificationImageSize
|
import org.moire.ultrasonic.util.Util.getNotificationImageSize
|
||||||
|
import org.moire.ultrasonic.util.toTrack
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,16 +90,15 @@ class NowPlayingFragment : Fragment() {
|
|||||||
playButton!!.setImageResource(R.drawable.media_start_normal)
|
playButton!!.setImageResource(R.drawable.media_start_normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
val file = mediaPlayerController.currentPlayingLegacy
|
val file = mediaPlayerController.currentMediaItem?.toTrack()
|
||||||
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
val song = file.track
|
val title = file.title
|
||||||
val title = song.title
|
val artist = file.artist
|
||||||
val artist = song.artist
|
|
||||||
|
|
||||||
imageLoader.getImageLoader().loadImage(
|
imageLoader.getImageLoader().loadImage(
|
||||||
nowPlayingAlbumArtImage,
|
nowPlayingAlbumArtImage,
|
||||||
song,
|
file,
|
||||||
false,
|
false,
|
||||||
getNotificationImageSize(requireContext())
|
getNotificationImageSize(requireContext())
|
||||||
)
|
)
|
||||||
@ -111,14 +111,14 @@ class NowPlayingFragment : Fragment() {
|
|||||||
|
|
||||||
if (Settings.shouldUseId3Tags) {
|
if (Settings.shouldUseId3Tags) {
|
||||||
bundle.putBoolean(Constants.INTENT_IS_ALBUM, true)
|
bundle.putBoolean(Constants.INTENT_IS_ALBUM, true)
|
||||||
bundle.putString(Constants.INTENT_ID, song.albumId)
|
bundle.putString(Constants.INTENT_ID, file.albumId)
|
||||||
} else {
|
} else {
|
||||||
bundle.putBoolean(Constants.INTENT_IS_ALBUM, false)
|
bundle.putBoolean(Constants.INTENT_IS_ALBUM, false)
|
||||||
bundle.putString(Constants.INTENT_ID, song.parent)
|
bundle.putString(Constants.INTENT_ID, file.parent)
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle.putString(Constants.INTENT_NAME, song.album)
|
bundle.putString(Constants.INTENT_NAME, file.album)
|
||||||
bundle.putString(Constants.INTENT_NAME, song.album)
|
bundle.putString(Constants.INTENT_NAME, file.album)
|
||||||
|
|
||||||
Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
|
Navigation.findNavController(requireActivity(), R.id.nav_host_fragment)
|
||||||
.navigate(R.id.trackCollectionFragment, bundle)
|
.navigate(R.id.trackCollectionFragment, bundle)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* PlayerFragment.kt
|
* PlayerFragment.kt
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
*
|
*
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
*/
|
*/
|
||||||
@ -37,6 +37,7 @@ import android.widget.ViewFlipper
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.media3.common.HeartRating
|
import androidx.media3.common.HeartRating
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Timeline
|
import androidx.media3.common.Timeline
|
||||||
import androidx.media3.session.SessionResult
|
import androidx.media3.session.SessionResult
|
||||||
@ -74,7 +75,6 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
|
|||||||
import org.moire.ultrasonic.domain.Identifiable
|
import org.moire.ultrasonic.domain.Identifiable
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||||
import org.moire.ultrasonic.service.RxBus
|
import org.moire.ultrasonic.service.RxBus
|
||||||
@ -87,6 +87,7 @@ import org.moire.ultrasonic.util.CommunicationError
|
|||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
|
import org.moire.ultrasonic.util.toTrack
|
||||||
import org.moire.ultrasonic.view.AutoRepeatButton
|
import org.moire.ultrasonic.view.AutoRepeatButton
|
||||||
import org.moire.ultrasonic.view.VisualizerView
|
import org.moire.ultrasonic.view.VisualizerView
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@ -120,7 +121,6 @@ class PlayerFragment :
|
|||||||
private val mediaPlayerController: MediaPlayerController by inject()
|
private val mediaPlayerController: MediaPlayerController by inject()
|
||||||
private val shareHandler: ShareHandler by inject()
|
private val shareHandler: ShareHandler by inject()
|
||||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||||
private var currentPlaying: DownloadFile? = null
|
|
||||||
private var currentSong: Track? = null
|
private var currentSong: Track? = null
|
||||||
private lateinit var viewManager: LinearLayoutManager
|
private lateinit var viewManager: LinearLayoutManager
|
||||||
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
private var rxBusSubscription: CompositeDisposable = CompositeDisposable()
|
||||||
@ -466,7 +466,7 @@ class PlayerFragment :
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (mediaPlayerController.currentPlayingLegacy == null) {
|
if (mediaPlayerController.currentMediaItem == null) {
|
||||||
playlistFlipper.displayedChild = 1
|
playlistFlipper.displayedChild = 1
|
||||||
} else {
|
} else {
|
||||||
// Download list and Album art must be updated when resumed
|
// Download list and Album art must be updated when resumed
|
||||||
@ -557,10 +557,10 @@ class PlayerFragment :
|
|||||||
visualizerMenuItem.isVisible = isVisualizerAvailable
|
visualizerMenuItem.isVisible = isVisualizerAvailable
|
||||||
}
|
}
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerController
|
||||||
val downloadFile = mediaPlayerController.currentPlayingLegacy
|
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
||||||
|
|
||||||
if (downloadFile != null) {
|
if (track != null) {
|
||||||
currentSong = downloadFile.track
|
currentSong = track
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useFiveStarRating) starMenuItem.isVisible = false
|
if (useFiveStarRating) starMenuItem.isVisible = false
|
||||||
@ -594,14 +594,11 @@ class PlayerFragment :
|
|||||||
super.onCreateContextMenu(menu, view, menuInfo)
|
super.onCreateContextMenu(menu, view, menuInfo)
|
||||||
if (view === playlistView) {
|
if (view === playlistView) {
|
||||||
val info = menuInfo as AdapterContextMenuInfo?
|
val info = menuInfo as AdapterContextMenuInfo?
|
||||||
val downloadFile = viewAdapter.getCurrentList()[info!!.position] as DownloadFile
|
val track = viewAdapter.getCurrentList()[info!!.position] as Track
|
||||||
val menuInflater = requireActivity().menuInflater
|
val menuInflater = requireActivity().menuInflater
|
||||||
menuInflater.inflate(R.menu.nowplaying_context, menu)
|
menuInflater.inflate(R.menu.nowplaying_context, menu)
|
||||||
val song: Track?
|
|
||||||
|
|
||||||
song = downloadFile.track
|
if (track.parent == null) {
|
||||||
|
|
||||||
if (song.parent == null) {
|
|
||||||
val menuItem = menu.findItem(R.id.menu_show_album)
|
val menuItem = menu.findItem(R.id.menu_show_album)
|
||||||
if (menuItem != null) {
|
if (menuItem != null) {
|
||||||
menuItem.isVisible = false
|
menuItem.isVisible = false
|
||||||
@ -619,16 +616,13 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
// TODO Why is Track null?
|
||||||
return menuItemSelected(item.itemId, null) || super.onOptionsItemSelected(item)
|
return menuItemSelected(item.itemId, null) || super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ComplexMethod", "LongMethod", "ReturnCount")
|
@Suppress("ComplexMethod", "LongMethod", "ReturnCount")
|
||||||
private fun menuItemSelected(menuItemId: Int, song: DownloadFile?): Boolean {
|
private fun menuItemSelected(menuItemId: Int, track: Track?): Boolean {
|
||||||
var track: Track? = null
|
|
||||||
val bundle: Bundle
|
val bundle: Bundle
|
||||||
if (song != null) {
|
|
||||||
track = song.track
|
|
||||||
}
|
|
||||||
|
|
||||||
when (menuItemId) {
|
when (menuItemId) {
|
||||||
R.id.menu_show_artist -> {
|
R.id.menu_show_artist -> {
|
||||||
@ -804,9 +798,9 @@ class PlayerFragment :
|
|||||||
R.id.menu_item_share -> {
|
R.id.menu_item_share -> {
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerController
|
||||||
val tracks: MutableList<Track?> = ArrayList()
|
val tracks: MutableList<Track?> = ArrayList()
|
||||||
val downloadServiceSongs = mediaPlayerController.playList
|
val playlist = mediaPlayerController.playlist
|
||||||
for (downloadFile in downloadServiceSongs) {
|
for (item in playlist) {
|
||||||
val playlistEntry = downloadFile.track
|
val playlistEntry = item.toTrack()
|
||||||
tracks.add(playlistEntry)
|
tracks.add(playlistEntry)
|
||||||
}
|
}
|
||||||
shareHandler.createShare(this, tracks, null, cancellationToken)
|
shareHandler.createShare(this, tracks, null, cancellationToken)
|
||||||
@ -828,7 +822,7 @@ class PlayerFragment :
|
|||||||
private fun update(cancel: CancellationToken? = null) {
|
private fun update(cancel: CancellationToken? = null) {
|
||||||
if (cancel?.isCancellationRequested == true) return
|
if (cancel?.isCancellationRequested == true) return
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerController
|
||||||
if (currentPlaying != mediaPlayerController.currentPlayingLegacy) {
|
if (currentSong?.id != mediaPlayerController.currentMediaItem?.mediaId) {
|
||||||
onCurrentChanged()
|
onCurrentChanged()
|
||||||
}
|
}
|
||||||
onSliderProgressChanged()
|
onSliderProgressChanged()
|
||||||
@ -841,8 +835,8 @@ class PlayerFragment :
|
|||||||
|
|
||||||
ioScope.launch {
|
ioScope.launch {
|
||||||
|
|
||||||
val entries = mediaPlayerController.playList.map {
|
val entries = mediaPlayerController.playlist.map {
|
||||||
it.track
|
it.toTrack()
|
||||||
}
|
}
|
||||||
val musicService = getMusicService()
|
val musicService = getMusicService()
|
||||||
musicService.createPlaylist(null, playlistName, entries)
|
musicService.createPlaylist(null, playlistName, entries)
|
||||||
@ -891,7 +885,7 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create listener
|
// Create listener
|
||||||
val clickHandler: ((DownloadFile, Int) -> Unit) = { _, pos ->
|
val clickHandler: ((Track, Int) -> Unit) = { _, pos ->
|
||||||
mediaPlayerController.seekTo(pos, 0)
|
mediaPlayerController.seekTo(pos, 0)
|
||||||
mediaPlayerController.prepare()
|
mediaPlayerController.prepare()
|
||||||
mediaPlayerController.play()
|
mediaPlayerController.play()
|
||||||
@ -978,10 +972,10 @@ class PlayerFragment :
|
|||||||
|
|
||||||
private fun onPlaylistChanged() {
|
private fun onPlaylistChanged() {
|
||||||
val mediaPlayerController = mediaPlayerController
|
val mediaPlayerController = mediaPlayerController
|
||||||
val list = mediaPlayerController.playList
|
val list = mediaPlayerController.playlist
|
||||||
emptyTextView.setText(R.string.playlist_empty)
|
emptyTextView.setText(R.string.playlist_empty)
|
||||||
|
|
||||||
viewAdapter.submitList(list)
|
viewAdapter.submitList(list.map(MediaItem::toTrack))
|
||||||
|
|
||||||
emptyTextView.isVisible = list.isEmpty()
|
emptyTextView.isVisible = list.isEmpty()
|
||||||
|
|
||||||
@ -989,17 +983,16 @@ class PlayerFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onCurrentChanged() {
|
private fun onCurrentChanged() {
|
||||||
currentPlaying = mediaPlayerController.currentPlayingLegacy
|
currentSong = mediaPlayerController.currentMediaItem?.toTrack()
|
||||||
|
|
||||||
scrollToCurrent()
|
scrollToCurrent()
|
||||||
val totalDuration = mediaPlayerController.playListDuration
|
val totalDuration = mediaPlayerController.playListDuration
|
||||||
val totalSongs = mediaPlayerController.playlistSize.toLong()
|
val totalSongs = mediaPlayerController.playlistSize
|
||||||
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
|
val currentSongIndex = mediaPlayerController.currentMediaItemIndex + 1
|
||||||
val duration = Util.formatTotalDuration(totalDuration)
|
val duration = Util.formatTotalDuration(totalDuration)
|
||||||
val trackFormat =
|
val trackFormat =
|
||||||
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
String.format(Locale.getDefault(), "%d / %d", currentSongIndex, totalSongs)
|
||||||
if (currentPlaying != null) {
|
if (currentSong != null) {
|
||||||
currentSong = currentPlaying!!.track
|
|
||||||
songTitleTextView.text = currentSong!!.title
|
songTitleTextView.text = currentSong!!.title
|
||||||
artistTextView.text = currentSong!!.artist
|
artistTextView.text = currentSong!!.artist
|
||||||
albumTextView.text = currentSong!!.album
|
albumTextView.text = currentSong!!.album
|
||||||
@ -1057,7 +1050,7 @@ class PlayerFragment :
|
|||||||
val isPlaying = mediaPlayerController.isPlaying
|
val isPlaying = mediaPlayerController.isPlaying
|
||||||
|
|
||||||
if (cancellationToken.isCancellationRequested) return
|
if (cancellationToken.isCancellationRequested) return
|
||||||
if (currentPlaying != null) {
|
if (currentSong != null) {
|
||||||
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
|
positionTextView.text = Util.formatTotalDuration(millisPlayed.toLong(), true)
|
||||||
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
|
durationTextView.text = Util.formatTotalDuration(duration.toLong(), true)
|
||||||
progressBar.max =
|
progressBar.max =
|
||||||
|
@ -33,7 +33,6 @@ import org.moire.ultrasonic.domain.SearchResult
|
|||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||||
import org.moire.ultrasonic.model.SearchListModel
|
import org.moire.ultrasonic.model.SearchListModel
|
||||||
import org.moire.ultrasonic.service.DownloadFile
|
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||||
@ -353,13 +352,13 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
this
|
this
|
||||||
)
|
)
|
||||||
|
|
||||||
if (found || item !is DownloadFile) return true
|
if (found || item !is Track) return true
|
||||||
|
|
||||||
val songs = mutableListOf<Track>()
|
val songs = mutableListOf<Track>()
|
||||||
|
|
||||||
when (menuItem.itemId) {
|
when (menuItem.itemId) {
|
||||||
R.id.song_menu_play_now -> {
|
R.id.song_menu_play_now -> {
|
||||||
songs.add(item.track)
|
songs.add(item)
|
||||||
downloadHandler.download(
|
downloadHandler.download(
|
||||||
fragment = this,
|
fragment = this,
|
||||||
append = false,
|
append = false,
|
||||||
@ -371,7 +370,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
R.id.song_menu_play_next -> {
|
R.id.song_menu_play_next -> {
|
||||||
songs.add(item.track)
|
songs.add(item)
|
||||||
downloadHandler.download(
|
downloadHandler.download(
|
||||||
fragment = this,
|
fragment = this,
|
||||||
append = true,
|
append = true,
|
||||||
@ -383,7 +382,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
R.id.song_menu_play_last -> {
|
R.id.song_menu_play_last -> {
|
||||||
songs.add(item.track)
|
songs.add(item)
|
||||||
downloadHandler.download(
|
downloadHandler.download(
|
||||||
fragment = this,
|
fragment = this,
|
||||||
append = true,
|
append = true,
|
||||||
@ -395,7 +394,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
R.id.song_menu_pin -> {
|
R.id.song_menu_pin -> {
|
||||||
songs.add(item.track)
|
songs.add(item)
|
||||||
toast(
|
toast(
|
||||||
context,
|
context,
|
||||||
resources.getQuantityString(
|
resources.getQuantityString(
|
||||||
@ -407,7 +406,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
downloadBackground(true, songs)
|
downloadBackground(true, songs)
|
||||||
}
|
}
|
||||||
R.id.song_menu_download -> {
|
R.id.song_menu_download -> {
|
||||||
songs.add(item.track)
|
songs.add(item)
|
||||||
toast(
|
toast(
|
||||||
context,
|
context,
|
||||||
resources.getQuantityString(
|
resources.getQuantityString(
|
||||||
@ -419,7 +418,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
downloadBackground(false, songs)
|
downloadBackground(false, songs)
|
||||||
}
|
}
|
||||||
R.id.song_menu_unpin -> {
|
R.id.song_menu_unpin -> {
|
||||||
songs.add(item.track)
|
songs.add(item)
|
||||||
toast(
|
toast(
|
||||||
context,
|
context,
|
||||||
resources.getQuantityString(
|
resources.getQuantityString(
|
||||||
@ -431,7 +430,7 @@ class SearchFragment : MultiListFragment<Identifiable>(), KoinComponent {
|
|||||||
mediaPlayerController.unpin(songs)
|
mediaPlayerController.unpin(songs)
|
||||||
}
|
}
|
||||||
R.id.song_menu_share -> {
|
R.id.song_menu_share -> {
|
||||||
songs.add(item.track)
|
songs.add(item)
|
||||||
shareHandler.createShare(this, songs, searchRefresh, cancellationToken!!)
|
shareHandler.createShare(this, songs, searchRefresh, cancellationToken!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -433,7 +433,6 @@ class SettingsFragment :
|
|||||||
|
|
||||||
// Clear download queue.
|
// Clear download queue.
|
||||||
mediaPlayerController.clear()
|
mediaPlayerController.clear()
|
||||||
mediaPlayerController.clearCaches()
|
|
||||||
Storage.reset()
|
Storage.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,6 +38,8 @@ import org.moire.ultrasonic.domain.MusicDirectory
|
|||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle
|
||||||
import org.moire.ultrasonic.model.TrackCollectionModel
|
import org.moire.ultrasonic.model.TrackCollectionModel
|
||||||
|
import org.moire.ultrasonic.service.DownloadStatus
|
||||||
|
import org.moire.ultrasonic.service.Downloader
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
import org.moire.ultrasonic.subsonic.NetworkAndStorageChecker
|
||||||
import org.moire.ultrasonic.subsonic.ShareHandler
|
import org.moire.ultrasonic.subsonic.ShareHandler
|
||||||
@ -79,6 +81,7 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||||||
private var shareButton: MenuItem? = null
|
private var shareButton: MenuItem? = null
|
||||||
|
|
||||||
internal val mediaPlayerController: MediaPlayerController by inject()
|
internal val mediaPlayerController: MediaPlayerController by inject()
|
||||||
|
internal val downloader: Downloader by inject()
|
||||||
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
private val networkAndStorageChecker: NetworkAndStorageChecker by inject()
|
||||||
private val shareHandler: ShareHandler by inject()
|
private val shareHandler: ShareHandler by inject()
|
||||||
internal var cancellationToken: CancellationToken? = null
|
internal var cancellationToken: CancellationToken? = null
|
||||||
@ -125,8 +128,8 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||||||
|
|
||||||
viewAdapter.register(
|
viewAdapter.register(
|
||||||
TrackViewBinder(
|
TrackViewBinder(
|
||||||
onItemClick = { file, _ -> onItemClick(file.track) },
|
onItemClick = { file, _ -> onItemClick(file) },
|
||||||
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id.track) },
|
onContextMenuClick = { menu, id -> onContextMenuItemSelected(menu, id) },
|
||||||
checkable = true,
|
checkable = true,
|
||||||
draggable = false,
|
draggable = false,
|
||||||
context = requireContext(),
|
context = requireContext(),
|
||||||
@ -364,11 +367,11 @@ open class TrackCollectionFragment : MultiListFragment<MusicDirectory.Child>() {
|
|||||||
var pinnedCount = 0
|
var pinnedCount = 0
|
||||||
|
|
||||||
for (song in selection) {
|
for (song in selection) {
|
||||||
val downloadFile = mediaPlayerController.getDownloadFileForSong(song)
|
val state = downloader.getDownloadState(song)
|
||||||
if (downloadFile.isWorkDone) {
|
if (state == DownloadStatus.DONE || state == DownloadStatus.PINNED) {
|
||||||
deleteEnabled = true
|
deleteEnabled = true
|
||||||
}
|
}
|
||||||
if (downloadFile.isSaved) {
|
if (state == DownloadStatus.PINNED) {
|
||||||
pinnedCount++
|
pinnedCount++
|
||||||
unpinEnabled = true
|
unpinEnabled = true
|
||||||
}
|
}
|
||||||
|
@ -16,8 +16,12 @@ import com.google.common.util.concurrent.ListeningExecutorService
|
|||||||
import com.google.common.util.concurrent.MoreExecutors
|
import com.google.common.util.concurrent.MoreExecutors
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
class ArtworkBitmapLoader : BitmapLoader {
|
class ArtworkBitmapLoader : BitmapLoader, KoinComponent {
|
||||||
|
|
||||||
|
private val imageLoader: ImageLoader by inject()
|
||||||
|
|
||||||
private val executorService: ListeningExecutorService by lazy {
|
private val executorService: ListeningExecutorService by lazy {
|
||||||
MoreExecutors.listeningDecorator(
|
MoreExecutors.listeningDecorator(
|
||||||
@ -46,6 +50,8 @@ class ArtworkBitmapLoader : BitmapLoader {
|
|||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
private fun load(uri: Uri): Bitmap {
|
private fun load(uri: Uri): Bitmap {
|
||||||
return BitmapFactory.decodeFile(uri.path)
|
val parts = uri.path?.trim('/')?.split('|')
|
||||||
|
if (parts?.count() != 2) throw IllegalArgumentException("Invalid bitmap Uri")
|
||||||
|
return imageLoader.getImage(parts[0], parts[1], false, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ package org.moire.ultrasonic.imageloader
|
|||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.text.TextUtils
|
import android.graphics.Bitmap
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@ -14,6 +14,8 @@ import java.io.File
|
|||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
import org.moire.ultrasonic.BuildConfig
|
import org.moire.ultrasonic.BuildConfig
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
|
import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient
|
||||||
@ -33,6 +35,8 @@ class ImageLoader(
|
|||||||
apiClient: SubsonicAPIClient,
|
apiClient: SubsonicAPIClient,
|
||||||
private val config: ImageLoaderConfig
|
private val config: ImageLoaderConfig
|
||||||
) {
|
) {
|
||||||
|
private val cacheInProgress: ConcurrentHashMap<String, CountDownLatch> = ConcurrentHashMap()
|
||||||
|
|
||||||
// Shortcut
|
// Shortcut
|
||||||
@Suppress("VariableNaming", "PropertyName")
|
@Suppress("VariableNaming", "PropertyName")
|
||||||
val API = apiClient.api
|
val API = apiClient.api
|
||||||
@ -58,6 +62,14 @@ class ImageLoader(
|
|||||||
.into(request.imageView)
|
.into(request.imageView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getCoverArt(request: ImageRequest.CoverArt): Bitmap {
|
||||||
|
return picasso.load(createLoadCoverArtRequest(request.entityId, request.size.toLong()))
|
||||||
|
.addPlaceholder(request)
|
||||||
|
.addError(request)
|
||||||
|
.stableKey(request.cacheKey)
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadAvatar(request: ImageRequest.Avatar) {
|
private fun loadAvatar(request: ImageRequest.Avatar) {
|
||||||
picasso.load(createLoadAvatarRequest(request.username))
|
picasso.load(createLoadAvatarRequest(request.username))
|
||||||
.addPlaceholder(request)
|
.addPlaceholder(request)
|
||||||
@ -82,6 +94,26 @@ class ImageLoader(
|
|||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the cover of a given entry into a Bitmap
|
||||||
|
*/
|
||||||
|
fun getImage(
|
||||||
|
id: String?,
|
||||||
|
cacheKey: String?,
|
||||||
|
large: Boolean,
|
||||||
|
size: Int,
|
||||||
|
defaultResourceId: Int = R.drawable.unknown_album
|
||||||
|
): Bitmap {
|
||||||
|
val requestedSize = resolveSize(size, large)
|
||||||
|
|
||||||
|
val request = ImageRequest.CoverArt(
|
||||||
|
id!!, cacheKey!!, null, requestedSize,
|
||||||
|
placeHolderDrawableRes = defaultResourceId,
|
||||||
|
errorDrawableRes = defaultResourceId
|
||||||
|
)
|
||||||
|
return getCoverArt(request)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the cover of a given entry into an ImageView
|
* Load the cover of a given entry into an ImageView
|
||||||
*/
|
*/
|
||||||
@ -148,30 +180,30 @@ class ImageLoader(
|
|||||||
/**
|
/**
|
||||||
* Download a cover art file and cache it on disk
|
* Download a cover art file and cache it on disk
|
||||||
*/
|
*/
|
||||||
fun cacheCoverArt(
|
fun cacheCoverArt(track: Track) {
|
||||||
track: Track
|
cacheCoverArt(track.coverArt!!, FileUtil.getAlbumArtFile(track))
|
||||||
) {
|
}
|
||||||
|
|
||||||
// Synchronize on the entry so that we don't download concurrently for
|
fun cacheCoverArt(id: String, file: String) {
|
||||||
// the same song.
|
if (id.isNullOrBlank()) return
|
||||||
synchronized(track) {
|
// Return if have a cache hit
|
||||||
|
if (File(file).exists()) return
|
||||||
|
|
||||||
|
// If another thread has started caching, wait until it finishes
|
||||||
|
val latch = cacheInProgress.putIfAbsent(file, CountDownLatch(1))
|
||||||
|
if (latch != null) {
|
||||||
|
latch.await()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Always download the large size..
|
// Always download the large size..
|
||||||
val size = config.largeSize
|
val size = config.largeSize
|
||||||
|
File(file).createNewFile()
|
||||||
// Check cache to avoid downloading existing files
|
|
||||||
val file = FileUtil.getAlbumArtFile(track)
|
|
||||||
|
|
||||||
// Return if have a cache hit
|
|
||||||
if (file != null && File(file).exists()) return
|
|
||||||
File(file!!).createNewFile()
|
|
||||||
|
|
||||||
// Can't load empty string ids
|
|
||||||
val id = track.coverArt
|
|
||||||
if (TextUtils.isEmpty(id)) return
|
|
||||||
|
|
||||||
// Query the API
|
// Query the API
|
||||||
Timber.d("Loading cover art for: %s", track)
|
Timber.d("Loading cover art for: %s", id)
|
||||||
val response = API.getCoverArt(id!!, size.toLong()).execute().toStreamResponse()
|
val response = API.getCoverArt(id, size.toLong()).execute().toStreamResponse()
|
||||||
response.throwOnFailure()
|
response.throwOnFailure()
|
||||||
|
|
||||||
// Check for failure
|
// Check for failure
|
||||||
@ -192,6 +224,8 @@ class ImageLoader(
|
|||||||
} finally {
|
} finally {
|
||||||
inputStream.safeClose()
|
inputStream.safeClose()
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
cacheInProgress.remove(file)?.countDown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,12 +256,12 @@ class ImageLoader(
|
|||||||
sealed class ImageRequest(
|
sealed class ImageRequest(
|
||||||
val placeHolderDrawableRes: Int? = null,
|
val placeHolderDrawableRes: Int? = null,
|
||||||
val errorDrawableRes: Int? = null,
|
val errorDrawableRes: Int? = null,
|
||||||
val imageView: ImageView
|
val imageView: ImageView?
|
||||||
) {
|
) {
|
||||||
class CoverArt(
|
class CoverArt(
|
||||||
val entityId: String,
|
val entityId: String,
|
||||||
val cacheKey: String,
|
val cacheKey: String,
|
||||||
imageView: ImageView,
|
imageView: ImageView?,
|
||||||
val size: Int,
|
val size: Int,
|
||||||
placeHolderDrawableRes: Int? = null,
|
placeHolderDrawableRes: Int? = null,
|
||||||
errorDrawableRes: Int? = null,
|
errorDrawableRes: Int? = null,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* CustomMediaLibrarySessionCallback.kt
|
* AutoMediaBrowserCallback.kt
|
||||||
* Copyright (C) 2009-2022 Ultrasonic developers
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
*
|
*
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
@ -7,17 +7,14 @@
|
|||||||
|
|
||||||
package org.moire.ultrasonic.playback
|
package org.moire.ultrasonic.playback
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import android.widget.Toast.LENGTH_SHORT
|
import android.widget.Toast.LENGTH_SHORT
|
||||||
import androidx.media3.common.HeartRating
|
import androidx.media3.common.HeartRating
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
|
||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
|
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS
|
||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
|
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
|
||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE
|
|
||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
|
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
|
||||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
|
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
@ -37,8 +34,8 @@ import com.google.common.util.concurrent.ListenableFuture
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.guava.await
|
||||||
import kotlinx.coroutines.guava.future
|
import kotlinx.coroutines.guava.future
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import org.moire.ultrasonic.R
|
import org.moire.ultrasonic.R
|
||||||
@ -54,6 +51,9 @@ import org.moire.ultrasonic.service.MusicServiceFactory
|
|||||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
|
import org.moire.ultrasonic.util.buildMediaItem
|
||||||
|
import org.moire.ultrasonic.util.toMediaItem
|
||||||
|
import org.moire.ultrasonic.util.toTrack
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
private const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID"
|
private const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID"
|
||||||
@ -106,6 +106,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
|
|
||||||
private val serviceJob = Job()
|
private val serviceJob = Job()
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
||||||
|
private val mainScope = CoroutineScope(Dispatchers.Main)
|
||||||
|
|
||||||
private var playlistCache: List<Track>? = null
|
private var playlistCache: List<Track>? = null
|
||||||
private var starredSongsCache: List<Track>? = null
|
private var starredSongsCache: List<Track>? = null
|
||||||
@ -226,7 +227,7 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
* is stored in the track.starred value
|
* is stored in the track.starred value
|
||||||
* See https://github.com/androidx/media/issues/33
|
* See https://github.com/androidx/media/issues/33
|
||||||
*/
|
*/
|
||||||
val track = mediaPlayerController.currentPlayingLegacy?.track
|
val track = mediaPlayerController.currentMediaItem?.toTrack()
|
||||||
if (track != null) {
|
if (track != null) {
|
||||||
customCommandFuture = onSetRating(
|
customCommandFuture = onSetRating(
|
||||||
session,
|
session,
|
||||||
@ -312,18 +313,61 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
* and thereby customarily it is required to rebuild it..
|
* and thereby customarily it is required to rebuild it..
|
||||||
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
|
* See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error
|
||||||
*/
|
*/
|
||||||
|
@Suppress("MagicNumber", "ComplexMethod")
|
||||||
override fun onAddMediaItems(
|
override fun onAddMediaItems(
|
||||||
mediaSession: MediaSession,
|
mediaSession: MediaSession,
|
||||||
controller: MediaSession.ControllerInfo,
|
controller: MediaSession.ControllerInfo,
|
||||||
mediaItems: MutableList<MediaItem>
|
mediaItems: MutableList<MediaItem>
|
||||||
): ListenableFuture<MutableList<MediaItem>> {
|
): ListenableFuture<MutableList<MediaItem>> {
|
||||||
|
|
||||||
val updatedMediaItems = mediaItems.map { mediaItem ->
|
if (!mediaItems.any()) return Futures.immediateFuture(mediaItems)
|
||||||
mediaItem.buildUpon()
|
|
||||||
.setUri(mediaItem.requestMetadata.mediaUri)
|
// Try to find out if the requester understands requestMetadata in the mediaItems
|
||||||
.build()
|
if (mediaItems.firstOrNull()?.requestMetadata?.mediaUri != null) {
|
||||||
|
val updatedMediaItems = mediaItems.map { mediaItem ->
|
||||||
|
mediaItem.buildUpon()
|
||||||
|
.setUri(mediaItem.requestMetadata.mediaUri)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
return Futures.immediateFuture(updatedMediaItems.toMutableList())
|
||||||
|
} else {
|
||||||
|
// Android Auto devices still only use the MediaId to identify the selected Items
|
||||||
|
// They also only select a single item at once
|
||||||
|
val mediaIdParts = mediaItems.first().mediaId.split('|')
|
||||||
|
|
||||||
|
val tracks = when (mediaIdParts.first()) {
|
||||||
|
MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2])
|
||||||
|
MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(
|
||||||
|
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||||
|
)
|
||||||
|
MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2])
|
||||||
|
MEDIA_ALBUM_SONG_ITEM -> playAlbumSong(
|
||||||
|
mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]
|
||||||
|
)
|
||||||
|
MEDIA_SONG_STARRED_ID -> playStarredSongs()
|
||||||
|
MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1])
|
||||||
|
MEDIA_SONG_RANDOM_ID -> playRandomSongs()
|
||||||
|
MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1])
|
||||||
|
MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1])
|
||||||
|
MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2])
|
||||||
|
MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1])
|
||||||
|
MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1])
|
||||||
|
MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode(
|
||||||
|
mediaIdParts[1], mediaIdParts[2]
|
||||||
|
)
|
||||||
|
MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1])
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (tracks != null) {
|
||||||
|
return Futures.immediateFuture(
|
||||||
|
tracks.map { track -> track.toMediaItem() }
|
||||||
|
.toMutableList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to the original list
|
||||||
|
return Futures.immediateFuture(mediaItems)
|
||||||
}
|
}
|
||||||
return Futures.immediateFuture(updatedMediaItems.toMutableList())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ReturnCount", "ComplexMethod")
|
@Suppress("ReturnCount", "ComplexMethod")
|
||||||
@ -398,10 +442,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
searchSongsCache = searchResult.songs
|
searchSongsCache = searchResult.songs
|
||||||
searchResult.songs.map { song ->
|
searchResult.songs.map { song ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
buildMediaItemFromTrack(
|
song.toMediaItem(
|
||||||
song,
|
listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|")
|
||||||
listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|"),
|
|
||||||
isPlayable = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -444,37 +486,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playFromSearchCommand(query: String?) {
|
private fun playSearch(id: String): List<Track>? {
|
||||||
Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query)
|
// If there is no cache, we can't play the selected song.
|
||||||
if (query.isNullOrBlank()) playRandomSongs()
|
if (searchSongsCache != null) {
|
||||||
|
val song = searchSongsCache!!.firstOrNull { x -> x.id == id }
|
||||||
serviceScope.launch {
|
if (song != null) return listOf(song)
|
||||||
val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT)
|
|
||||||
val searchResult = callWithErrorHandling { musicService.search(criteria) }
|
|
||||||
|
|
||||||
// Try to find the best match
|
|
||||||
if (searchResult != null) {
|
|
||||||
val song = searchResult.songs
|
|
||||||
.asSequence()
|
|
||||||
.sortedByDescending { song -> song.starred }
|
|
||||||
.sortedByDescending { song -> song.averageRating }
|
|
||||||
.sortedByDescending { song -> song.userRating }
|
|
||||||
.sortedByDescending { song -> song.closeness }
|
|
||||||
.firstOrNull()
|
|
||||||
|
|
||||||
if (song != null) playSong(song)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun playSearch(id: String) {
|
|
||||||
serviceScope.launch {
|
|
||||||
// If there is no cache, we can't play the selected song.
|
|
||||||
if (searchSongsCache != null) {
|
|
||||||
val song = searchSongsCache!!.firstOrNull { x -> x.id == id }
|
|
||||||
if (song != null) playSong(song)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRootItems(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
private fun getRootItems(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
@ -484,14 +502,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
R.string.music_library_label,
|
R.string.music_library_label,
|
||||||
MEDIA_LIBRARY_ID,
|
MEDIA_LIBRARY_ID,
|
||||||
null
|
null,
|
||||||
|
icon = R.drawable.ic_library
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
R.string.main_artists_title,
|
R.string.main_artists_title,
|
||||||
MEDIA_ARTIST_ID,
|
MEDIA_ARTIST_ID,
|
||||||
null,
|
null,
|
||||||
folderType = FOLDER_TYPE_ARTISTS
|
folderType = FOLDER_TYPE_ARTISTS,
|
||||||
|
icon = R.drawable.ic_artist
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isOffline)
|
if (!isOffline)
|
||||||
@ -499,14 +519,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
R.string.main_albums_title,
|
R.string.main_albums_title,
|
||||||
MEDIA_ALBUM_ID,
|
MEDIA_ALBUM_ID,
|
||||||
null,
|
null,
|
||||||
folderType = FOLDER_TYPE_ALBUMS
|
folderType = FOLDER_TYPE_ALBUMS,
|
||||||
|
icon = R.drawable.ic_menu_browse
|
||||||
)
|
)
|
||||||
|
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
R.string.playlist_label,
|
R.string.playlist_label,
|
||||||
MEDIA_PLAYLIST_ID,
|
MEDIA_PLAYLIST_ID,
|
||||||
null,
|
null,
|
||||||
folderType = FOLDER_TYPE_PLAYLISTS
|
folderType = FOLDER_TYPE_PLAYLISTS,
|
||||||
|
icon = R.drawable.ic_menu_playlists
|
||||||
)
|
)
|
||||||
|
|
||||||
return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null))
|
return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null))
|
||||||
@ -578,18 +600,21 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
// It seems double scoping is required: Media3 requires the Main thread, network operations with musicService forbid the Main thread...
|
||||||
val childMediaId: String
|
return mainScope.future {
|
||||||
var artists = if (!isOffline && useId3Tags) {
|
var childMediaId: String = MEDIA_ARTIST_ITEM
|
||||||
childMediaId = MEDIA_ARTIST_ITEM
|
|
||||||
// TODO this list can be big so we're not refreshing.
|
var artists = serviceScope.future {
|
||||||
// Maybe a refresh menu item can be added
|
if (!isOffline && useId3Tags) {
|
||||||
callWithErrorHandling { musicService.getArtists(false) }
|
// TODO this list can be big so we're not refreshing.
|
||||||
} else {
|
// Maybe a refresh menu item can be added
|
||||||
// This will be handled at getSongsForAlbum, which supports navigation
|
callWithErrorHandling { musicService.getArtists(false) }
|
||||||
childMediaId = MEDIA_ALBUM_ITEM
|
} else {
|
||||||
callWithErrorHandling { musicService.getIndexes(musicFolderId, false) }
|
// This will be handled at getSongsForAlbum, which supports navigation
|
||||||
}
|
childMediaId = MEDIA_ALBUM_ITEM
|
||||||
|
callWithErrorHandling { musicService.getIndexes(musicFolderId, false) }
|
||||||
|
}
|
||||||
|
}.await()
|
||||||
|
|
||||||
if (artists != null) {
|
if (artists != null) {
|
||||||
if (section != null)
|
if (section != null)
|
||||||
@ -633,14 +658,16 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val albums = if (!isOffline && useId3Tags) {
|
val albums = serviceScope.future {
|
||||||
callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
|
if (!isOffline && useId3Tags) {
|
||||||
} else {
|
callWithErrorHandling { musicService.getAlbumsOfArtist(id, name, false) }
|
||||||
callWithErrorHandling {
|
} else {
|
||||||
musicService.getMusicDirectory(id, name, false).getAlbums()
|
callWithErrorHandling {
|
||||||
|
musicService.getMusicDirectory(id, name, false).getAlbums()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}.await()
|
||||||
|
|
||||||
albums?.map { album ->
|
albums?.map { album ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
@ -660,8 +687,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val songs = listSongsInMusicService(id, name)
|
val songs = serviceScope.future { listSongsInMusicService(id, name) }.await()
|
||||||
|
|
||||||
if (songs != null) {
|
if (songs != null) {
|
||||||
if (songs.getChildren(includeDirs = true, includeFiles = false).isEmpty() &&
|
if (songs.getChildren(includeDirs = true, includeFiles = false).isEmpty() &&
|
||||||
@ -680,15 +707,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
buildMediaItemFromTrack(
|
item.toMediaItem(
|
||||||
item,
|
|
||||||
listOf(
|
listOf(
|
||||||
MEDIA_ALBUM_SONG_ITEM,
|
MEDIA_ALBUM_SONG_ITEM,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
item.id
|
item.id
|
||||||
).joinToString("|"),
|
).joinToString("|")
|
||||||
isPlayable = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -703,21 +728,24 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val offset = (page ?: 0) * DISPLAY_LIMIT
|
val offset = (page ?: 0) * DISPLAY_LIMIT
|
||||||
val albums = if (useId3Tags) {
|
|
||||||
callWithErrorHandling {
|
val albums = serviceScope.future {
|
||||||
musicService.getAlbumList2(
|
if (useId3Tags) {
|
||||||
type.typeName, DISPLAY_LIMIT, offset, null
|
callWithErrorHandling {
|
||||||
)
|
musicService.getAlbumList2(
|
||||||
|
type.typeName, DISPLAY_LIMIT, offset, null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callWithErrorHandling {
|
||||||
|
musicService.getAlbumList(
|
||||||
|
type.typeName, DISPLAY_LIMIT, offset, null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}.await()
|
||||||
callWithErrorHandling {
|
|
||||||
musicService.getAlbumList(
|
|
||||||
type.typeName, DISPLAY_LIMIT, offset, null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
albums?.map { album ->
|
albums?.map { album ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
@ -742,8 +770,11 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
private fun getPlaylists(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
private fun getPlaylists(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val playlists = callWithErrorHandling { musicService.getPlaylists(true) }
|
val playlists = serviceScope.future {
|
||||||
|
callWithErrorHandling { musicService.getPlaylists(true) }
|
||||||
|
}.await()
|
||||||
|
|
||||||
playlists?.map { playlist ->
|
playlists?.map { playlist ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
playlist.name,
|
playlist.name,
|
||||||
@ -762,8 +793,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
|
val content = serviceScope.future {
|
||||||
|
callWithErrorHandling { musicService.getPlaylist(id, name) }
|
||||||
|
}.await()
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
if (content.size > 1)
|
if (content.size > 1)
|
||||||
@ -775,15 +808,13 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
playlistCache = content.getTracks()
|
playlistCache = content.getTracks()
|
||||||
playlistCache!!.take(DISPLAY_LIMIT).map { item ->
|
playlistCache!!.take(DISPLAY_LIMIT).map { item ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
buildMediaItemFromTrack(
|
item.toMediaItem(
|
||||||
item,
|
|
||||||
listOf(
|
listOf(
|
||||||
MEDIA_PLAYLIST_SONG_ITEM,
|
MEDIA_PLAYLIST_SONG_ITEM,
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
item.id
|
item.id
|
||||||
).joinToString("|"),
|
).joinToString("|")
|
||||||
isPlayable = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -792,49 +823,52 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playPlaylist(id: String, name: String) {
|
private fun playPlaylist(id: String, name: String): List<Track>? {
|
||||||
serviceScope.launch {
|
if (playlistCache == null) {
|
||||||
if (playlistCache == null) {
|
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
||||||
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
val content =
|
||||||
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
|
serviceScope.future {
|
||||||
playlistCache = content?.getTracks()
|
callWithErrorHandling { musicService.getPlaylist(id, name) }
|
||||||
}
|
}.get()
|
||||||
if (playlistCache != null) playSongs(playlistCache!!)
|
playlistCache = content?.getTracks()
|
||||||
}
|
}
|
||||||
|
if (playlistCache != null) return playlistCache!!
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playPlaylistSong(id: String, name: String, songId: String) {
|
private fun playPlaylistSong(id: String, name: String, songId: String): List<Track>? {
|
||||||
serviceScope.launch {
|
if (playlistCache == null) {
|
||||||
if (playlistCache == null) {
|
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
||||||
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
val content = serviceScope.future {
|
||||||
val content = callWithErrorHandling { musicService.getPlaylist(id, name) }
|
callWithErrorHandling { musicService.getPlaylist(id, name) }
|
||||||
playlistCache = content?.getTracks()
|
}.get()
|
||||||
}
|
playlistCache = content?.getTracks()
|
||||||
val song = playlistCache?.firstOrNull { x -> x.id == songId }
|
|
||||||
if (song != null) playSong(song)
|
|
||||||
}
|
}
|
||||||
|
val song = playlistCache?.firstOrNull { x -> x.id == songId }
|
||||||
|
if (song != null) return listOf(song)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playAlbum(id: String, name: String) {
|
private fun playAlbum(id: String, name: String): List<Track>? {
|
||||||
serviceScope.launch {
|
val songs = listSongsInMusicService(id, name)
|
||||||
val songs = listSongsInMusicService(id, name)
|
if (songs != null) return songs.getTracks()
|
||||||
if (songs != null) playSongs(songs.getTracks())
|
return null
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playAlbumSong(id: String, name: String, songId: String) {
|
private fun playAlbumSong(id: String, name: String, songId: String): List<Track>? {
|
||||||
serviceScope.launch {
|
val songs = listSongsInMusicService(id, name)
|
||||||
val songs = listSongsInMusicService(id, name)
|
val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId }
|
||||||
val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId }
|
if (song != null) return listOf(song)
|
||||||
if (song != null) playSong(song)
|
return null
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPodcasts(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
private fun getPodcasts(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val podcasts = callWithErrorHandling { musicService.getPodcastsChannels(false) }
|
val podcasts = serviceScope.future {
|
||||||
|
callWithErrorHandling { musicService.getPodcastsChannels(false) }
|
||||||
|
}.await()
|
||||||
|
|
||||||
podcasts?.map { podcast ->
|
podcasts?.map { podcast ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
@ -851,8 +885,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
id: String
|
id: String
|
||||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) }
|
val episodes = serviceScope.future {
|
||||||
|
callWithErrorHandling { musicService.getPodcastEpisodes(id) }
|
||||||
|
}.await()
|
||||||
|
|
||||||
if (episodes != null) {
|
if (episodes != null) {
|
||||||
if (episodes.getTracks().count() > 1)
|
if (episodes.getTracks().count() > 1)
|
||||||
@ -860,11 +896,9 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
|
|
||||||
episodes.getTracks().map { episode ->
|
episodes.getTracks().map { episode ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
buildMediaItemFromTrack(
|
episode.toMediaItem(
|
||||||
episode,
|
|
||||||
listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id)
|
listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id)
|
||||||
.joinToString("|"),
|
.joinToString("|")
|
||||||
isPlayable = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -873,40 +907,43 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playPodcast(id: String) {
|
private fun playPodcast(id: String): List<Track>? {
|
||||||
serviceScope.launch {
|
val episodes = serviceScope.future {
|
||||||
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) }
|
callWithErrorHandling { musicService.getPodcastEpisodes(id) }
|
||||||
if (episodes != null) {
|
}.get()
|
||||||
playSongs(episodes.getTracks())
|
if (episodes != null) {
|
||||||
}
|
return episodes.getTracks()
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playPodcastEpisode(id: String, episodeId: String) {
|
private fun playPodcastEpisode(id: String, episodeId: String): List<Track>? {
|
||||||
serviceScope.launch {
|
val episodes = serviceScope.future {
|
||||||
val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) }
|
callWithErrorHandling { musicService.getPodcastEpisodes(id) }
|
||||||
if (episodes != null) {
|
}.get()
|
||||||
val selectedEpisode = episodes
|
if (episodes != null) {
|
||||||
.getTracks()
|
val selectedEpisode = episodes
|
||||||
.firstOrNull { episode -> episode.id == episodeId }
|
.getTracks()
|
||||||
if (selectedEpisode != null) playSong(selectedEpisode)
|
.firstOrNull { episode -> episode.id == episodeId }
|
||||||
}
|
if (selectedEpisode != null) return listOf(selectedEpisode)
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getBookmarks(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
private fun getBookmarks(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val bookmarks = callWithErrorHandling { musicService.getBookmarks() }
|
val bookmarks = serviceScope.future {
|
||||||
|
callWithErrorHandling { musicService.getBookmarks() }
|
||||||
|
}.await()
|
||||||
|
|
||||||
if (bookmarks != null) {
|
if (bookmarks != null) {
|
||||||
val songs = Util.getSongsFromBookmarks(bookmarks)
|
val songs = Util.getSongsFromBookmarks(bookmarks)
|
||||||
|
|
||||||
songs.getTracks().map { song ->
|
songs.getTracks().map { song ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
buildMediaItemFromTrack(
|
song.toMediaItem(
|
||||||
song,
|
listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|")
|
||||||
listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|"),
|
|
||||||
isPlayable = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -915,22 +952,25 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playBookmark(id: String) {
|
private fun playBookmark(id: String): List<Track>? {
|
||||||
serviceScope.launch {
|
val bookmarks = serviceScope.future {
|
||||||
val bookmarks = callWithErrorHandling { musicService.getBookmarks() }
|
callWithErrorHandling { musicService.getBookmarks() }
|
||||||
if (bookmarks != null) {
|
}.get()
|
||||||
val songs = Util.getSongsFromBookmarks(bookmarks)
|
if (bookmarks != null) {
|
||||||
val song = songs.getTracks().firstOrNull { song -> song.id == id }
|
val songs = Util.getSongsFromBookmarks(bookmarks)
|
||||||
if (song != null) playSong(song)
|
val song = songs.getTracks().firstOrNull { song -> song.id == id }
|
||||||
}
|
if (song != null) return listOf(song)
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getShares(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
private fun getShares(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val shares = callWithErrorHandling { musicService.getShares(false) }
|
val shares = serviceScope.future {
|
||||||
|
callWithErrorHandling { musicService.getShares(false) }
|
||||||
|
}.await()
|
||||||
|
|
||||||
shares?.map { share ->
|
shares?.map { share ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
@ -949,8 +989,10 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val shares = callWithErrorHandling { musicService.getShares(false) }
|
val shares = serviceScope.future {
|
||||||
|
callWithErrorHandling { musicService.getShares(false) }
|
||||||
|
}.await()
|
||||||
|
|
||||||
val selectedShare = shares?.firstOrNull { share -> share.id == id }
|
val selectedShare = shares?.firstOrNull { share -> share.id == id }
|
||||||
if (selectedShare != null) {
|
if (selectedShare != null) {
|
||||||
@ -960,10 +1002,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
|
|
||||||
selectedShare.getEntries().map { song ->
|
selectedShare.getEntries().map { song ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
buildMediaItemFromTrack(
|
song.toMediaItem(
|
||||||
song,
|
listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|")
|
||||||
listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|"),
|
|
||||||
isPlayable = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -972,32 +1012,36 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playShare(id: String) {
|
private fun playShare(id: String): List<Track>? {
|
||||||
serviceScope.launch {
|
val shares = serviceScope.future {
|
||||||
val shares = callWithErrorHandling { musicService.getShares(false) }
|
callWithErrorHandling { musicService.getShares(false) }
|
||||||
val selectedShare = shares?.firstOrNull { share -> share.id == id }
|
}.get()
|
||||||
if (selectedShare != null) {
|
val selectedShare = shares?.firstOrNull { share -> share.id == id }
|
||||||
playSongs(selectedShare.getEntries())
|
if (selectedShare != null) {
|
||||||
}
|
return selectedShare.getEntries()
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playShareSong(id: String, songId: String) {
|
private fun playShareSong(id: String, songId: String): List<Track>? {
|
||||||
serviceScope.launch {
|
val shares = serviceScope.future {
|
||||||
val shares = callWithErrorHandling { musicService.getShares(false) }
|
callWithErrorHandling { musicService.getShares(false) }
|
||||||
val selectedShare = shares?.firstOrNull { share -> share.id == id }
|
}.get()
|
||||||
if (selectedShare != null) {
|
val selectedShare = shares?.firstOrNull { share -> share.id == id }
|
||||||
val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId }
|
if (selectedShare != null) {
|
||||||
if (song != null) playSong(song)
|
val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId }
|
||||||
}
|
if (song != null) return listOf(song)
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getStarredSongs(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
private fun getStarredSongs(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val songs = listStarredSongsInMusicService()
|
val songs = serviceScope.future {
|
||||||
|
listStarredSongsInMusicService()
|
||||||
|
}.await()
|
||||||
|
|
||||||
if (songs != null) {
|
if (songs != null) {
|
||||||
if (songs.songs.count() > 1)
|
if (songs.songs.count() > 1)
|
||||||
@ -1008,10 +1052,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
starredSongsCache = items
|
starredSongsCache = items
|
||||||
items.map { song ->
|
items.map { song ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
buildMediaItemFromTrack(
|
song.toMediaItem(
|
||||||
song,
|
listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|")
|
||||||
listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|"),
|
|
||||||
isPlayable = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1020,34 +1062,34 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playStarredSongs() {
|
private fun playStarredSongs(): List<Track>? {
|
||||||
serviceScope.launch {
|
if (starredSongsCache == null) {
|
||||||
if (starredSongsCache == null) {
|
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
||||||
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
val content = listStarredSongsInMusicService()
|
||||||
val content = listStarredSongsInMusicService()
|
starredSongsCache = content?.songs
|
||||||
starredSongsCache = content?.songs
|
|
||||||
}
|
|
||||||
if (starredSongsCache != null) playSongs(starredSongsCache!!)
|
|
||||||
}
|
}
|
||||||
|
if (starredSongsCache != null) return starredSongsCache!!
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playStarredSong(songId: String) {
|
private fun playStarredSong(songId: String): List<Track>? {
|
||||||
serviceScope.launch {
|
if (starredSongsCache == null) {
|
||||||
if (starredSongsCache == null) {
|
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
||||||
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
val content = listStarredSongsInMusicService()
|
||||||
val content = listStarredSongsInMusicService()
|
starredSongsCache = content?.songs
|
||||||
starredSongsCache = content?.songs
|
|
||||||
}
|
|
||||||
val song = starredSongsCache?.firstOrNull { x -> x.id == songId }
|
|
||||||
if (song != null) playSong(song)
|
|
||||||
}
|
}
|
||||||
|
val song = starredSongsCache?.firstOrNull { x -> x.id == songId }
|
||||||
|
if (song != null) return listOf(song)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRandomSongs(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
private fun getRandomSongs(): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||||
val mediaItems: MutableList<MediaItem> = ArrayList()
|
val mediaItems: MutableList<MediaItem> = ArrayList()
|
||||||
|
|
||||||
return serviceScope.future {
|
return mainScope.future {
|
||||||
val songs = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
|
val songs = serviceScope.future {
|
||||||
|
callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
|
||||||
|
}.await()
|
||||||
|
|
||||||
if (songs != null) {
|
if (songs != null) {
|
||||||
if (songs.size > 1)
|
if (songs.size > 1)
|
||||||
@ -1058,10 +1100,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
randomSongsCache = items
|
randomSongsCache = items
|
||||||
items.map { song ->
|
items.map { song ->
|
||||||
mediaItems.add(
|
mediaItems.add(
|
||||||
buildMediaItemFromTrack(
|
song.toMediaItem(
|
||||||
song,
|
listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|")
|
||||||
listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|"),
|
|
||||||
isPlayable = true
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1070,42 +1110,46 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playRandomSongs() {
|
private fun playRandomSongs(): List<Track>? {
|
||||||
serviceScope.launch {
|
if (randomSongsCache == null) {
|
||||||
if (randomSongsCache == null) {
|
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
||||||
// This can only happen if Android Auto cached items, but Ultrasonic has forgot them
|
// In this case we request a new set of random songs
|
||||||
// In this case we request a new set of random songs
|
val content = serviceScope.future {
|
||||||
val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
|
callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) }
|
||||||
randomSongsCache = content?.getTracks()
|
}.get()
|
||||||
}
|
randomSongsCache = content?.getTracks()
|
||||||
if (randomSongsCache != null) playSongs(randomSongsCache!!)
|
|
||||||
}
|
}
|
||||||
|
if (randomSongsCache != null) return randomSongsCache!!
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playRandomSong(songId: String) {
|
private fun playRandomSong(songId: String): List<Track>? {
|
||||||
serviceScope.launch {
|
// If there is no cache, we can't play the selected song.
|
||||||
// If there is no cache, we can't play the selected song.
|
if (randomSongsCache != null) {
|
||||||
if (randomSongsCache != null) {
|
val song = randomSongsCache!!.firstOrNull { x -> x.id == songId }
|
||||||
val song = randomSongsCache!!.firstOrNull { x -> x.id == songId }
|
if (song != null) return listOf(song)
|
||||||
if (song != null) playSong(song)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun listSongsInMusicService(id: String, name: String): MusicDirectory? {
|
private fun listSongsInMusicService(id: String, name: String): MusicDirectory? {
|
||||||
return if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) {
|
return serviceScope.future {
|
||||||
callWithErrorHandling { musicService.getAlbum(id, name, false) }
|
if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) {
|
||||||
} else {
|
callWithErrorHandling { musicService.getAlbum(id, name, false) }
|
||||||
callWithErrorHandling { musicService.getMusicDirectory(id, name, false) }
|
} else {
|
||||||
}
|
callWithErrorHandling { musicService.getMusicDirectory(id, name, false) }
|
||||||
|
}
|
||||||
|
}.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun listStarredSongsInMusicService(): SearchResult? {
|
private fun listStarredSongsInMusicService(): SearchResult? {
|
||||||
return if (Settings.shouldUseId3Tags) {
|
return serviceScope.future {
|
||||||
callWithErrorHandling { musicService.getStarred2() }
|
if (Settings.shouldUseId3Tags) {
|
||||||
} else {
|
callWithErrorHandling { musicService.getStarred2() }
|
||||||
callWithErrorHandling { musicService.getStarred() }
|
} else {
|
||||||
}
|
callWithErrorHandling { musicService.getStarred() }
|
||||||
|
}
|
||||||
|
}.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MutableList<MediaItem>.add(
|
private fun MutableList<MediaItem>.add(
|
||||||
@ -1124,20 +1168,28 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
this.add(mediaItem)
|
this.add(mediaItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
private fun MutableList<MediaItem>.add(
|
private fun MutableList<MediaItem>.add(
|
||||||
resId: Int,
|
resId: Int,
|
||||||
mediaId: String,
|
mediaId: String,
|
||||||
groupNameId: Int?,
|
groupNameId: Int?,
|
||||||
browsable: Boolean = true,
|
browsable: Boolean = true,
|
||||||
folderType: Int = FOLDER_TYPE_MIXED
|
folderType: Int = FOLDER_TYPE_MIXED,
|
||||||
|
icon: Int? = null
|
||||||
) {
|
) {
|
||||||
val applicationContext = UApp.applicationContext()
|
val applicationContext = UApp.applicationContext()
|
||||||
|
|
||||||
val mediaItem = buildMediaItem(
|
val mediaItem = buildMediaItem(
|
||||||
applicationContext.getString(resId),
|
applicationContext.getString(resId),
|
||||||
mediaId,
|
mediaId,
|
||||||
isPlayable = false,
|
isPlayable = !browsable,
|
||||||
folderType = folderType
|
folderType = folderType,
|
||||||
|
group = if (groupNameId != null) {
|
||||||
|
applicationContext.getString(groupNameId)
|
||||||
|
} else null,
|
||||||
|
imageUri = if (icon != null) {
|
||||||
|
Util.getUriToDrawable(applicationContext, icon)
|
||||||
|
} else null
|
||||||
)
|
)
|
||||||
|
|
||||||
this.add(mediaItem)
|
this.add(mediaItem)
|
||||||
@ -1150,7 +1202,8 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
R.string.select_album_play_all,
|
R.string.select_album_play_all,
|
||||||
mediaId,
|
mediaId,
|
||||||
null,
|
null,
|
||||||
false
|
false,
|
||||||
|
icon = R.drawable.media_start_normal
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1160,28 +1213,6 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
return section.toString()
|
return section.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playSongs(songs: List<Track>) {
|
|
||||||
mediaPlayerController.addToPlaylist(
|
|
||||||
songs,
|
|
||||||
cachePermanently = false,
|
|
||||||
autoPlay = true,
|
|
||||||
shuffle = false,
|
|
||||||
insertionMode = MediaPlayerController.InsertionMode.CLEAR
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun playSong(song: Track) {
|
|
||||||
mediaPlayerController.addToPlaylist(
|
|
||||||
listOf(song),
|
|
||||||
cachePermanently = false,
|
|
||||||
autoPlay = false,
|
|
||||||
shuffle = false,
|
|
||||||
insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT
|
|
||||||
)
|
|
||||||
if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next()
|
|
||||||
else mediaPlayerController.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> callWithErrorHandling(function: () -> T): T? {
|
private fun <T> callWithErrorHandling(function: () -> T): T? {
|
||||||
// TODO Implement better error handling
|
// TODO Implement better error handling
|
||||||
return try {
|
return try {
|
||||||
@ -1191,53 +1222,4 @@ class AutoMediaBrowserCallback(var player: Player, val libraryService: MediaLibr
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildMediaItemFromTrack(
|
|
||||||
track: Track,
|
|
||||||
mediaId: String,
|
|
||||||
isPlayable: Boolean
|
|
||||||
): MediaItem {
|
|
||||||
|
|
||||||
return buildMediaItem(
|
|
||||||
title = track.title ?: "",
|
|
||||||
mediaId = mediaId,
|
|
||||||
isPlayable = isPlayable,
|
|
||||||
folderType = FOLDER_TYPE_NONE,
|
|
||||||
album = track.album,
|
|
||||||
artist = track.artist,
|
|
||||||
genre = track.genre,
|
|
||||||
starred = track.starred
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("LongParameterList")
|
|
||||||
private fun buildMediaItem(
|
|
||||||
title: String,
|
|
||||||
mediaId: String,
|
|
||||||
isPlayable: Boolean,
|
|
||||||
@MediaMetadata.FolderType folderType: Int,
|
|
||||||
album: String? = null,
|
|
||||||
artist: String? = null,
|
|
||||||
genre: String? = null,
|
|
||||||
sourceUri: Uri? = null,
|
|
||||||
imageUri: Uri? = null,
|
|
||||||
starred: Boolean = false
|
|
||||||
): MediaItem {
|
|
||||||
val metadata =
|
|
||||||
MediaMetadata.Builder()
|
|
||||||
.setAlbumTitle(album)
|
|
||||||
.setTitle(title)
|
|
||||||
.setArtist(artist)
|
|
||||||
.setGenre(genre)
|
|
||||||
.setUserRating(HeartRating(starred))
|
|
||||||
.setFolderType(folderType)
|
|
||||||
.setIsPlayable(isPlayable)
|
|
||||||
.setArtworkUri(imageUri)
|
|
||||||
.build()
|
|
||||||
return MediaItem.Builder()
|
|
||||||
.setMediaId(mediaId)
|
|
||||||
.setMediaMetadata(metadata)
|
|
||||||
.setUri(sourceUri)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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.R
|
||||||
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
|
import org.moire.ultrasonic.imageloader.ArtworkBitmapLoader
|
||||||
import org.moire.ultrasonic.service.MediaPlayerController
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
|
import org.moire.ultrasonic.util.toTrack
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class MediaNotificationProvider(context: Context) :
|
class MediaNotificationProvider(context: Context) :
|
||||||
@ -47,7 +48,7 @@ class MediaNotificationProvider(context: Context) :
|
|||||||
* is stored in the track.starred value
|
* is stored in the track.starred value
|
||||||
* See https://github.com/androidx/media/issues/33
|
* See https://github.com/androidx/media/issues/33
|
||||||
*/
|
*/
|
||||||
val rating = mediaPlayerController.currentPlayingLegacy?.track?.starred?.let {
|
val rating = mediaPlayerController.currentMediaItem?.toTrack()?.starred?.let {
|
||||||
HeartRating(
|
HeartRating(
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
|
@ -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
|
package org.moire.ultrasonic.service
|
||||||
|
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock as SystemClock
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
@ -16,17 +24,21 @@ import org.koin.core.component.inject
|
|||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.data.MetaDatabase
|
import org.moire.ultrasonic.data.MetaDatabase
|
||||||
import org.moire.ultrasonic.domain.Artist
|
import org.moire.ultrasonic.domain.Artist
|
||||||
|
import org.moire.ultrasonic.domain.Identifiable
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
|
||||||
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
|
||||||
import org.moire.ultrasonic.util.CacheCleaner
|
import org.moire.ultrasonic.util.CacheCleaner
|
||||||
import org.moire.ultrasonic.util.CancellableTask
|
import org.moire.ultrasonic.util.CancellableTask
|
||||||
import org.moire.ultrasonic.util.FileUtil
|
import org.moire.ultrasonic.util.FileUtil
|
||||||
import org.moire.ultrasonic.util.LRUCache
|
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
||||||
|
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
||||||
|
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
import org.moire.ultrasonic.util.Storage
|
import org.moire.ultrasonic.util.Storage
|
||||||
import org.moire.ultrasonic.util.Util
|
import org.moire.ultrasonic.util.Util
|
||||||
import org.moire.ultrasonic.util.Util.safeClose
|
import org.moire.ultrasonic.util.Util.safeClose
|
||||||
|
import org.moire.ultrasonic.util.shouldBePinned
|
||||||
|
import org.moire.ultrasonic.util.toTrack
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,27 +49,25 @@ import timber.log.Timber
|
|||||||
*/
|
*/
|
||||||
class Downloader(
|
class Downloader(
|
||||||
private val storageMonitor: ExternalStorageMonitor,
|
private val storageMonitor: ExternalStorageMonitor,
|
||||||
private val legacyPlaylistManager: LegacyPlaylistManager,
|
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
// Dependencies
|
// Dependencies
|
||||||
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
private val imageLoaderProvider: ImageLoaderProvider by inject()
|
||||||
private val activeServerProvider: ActiveServerProvider by inject()
|
private val activeServerProvider: ActiveServerProvider by inject()
|
||||||
private val mediaController: MediaPlayerController by inject()
|
private val mediaController: MediaPlayerController by inject()
|
||||||
|
private val jukeboxMediaPlayer: JukeboxMediaPlayer by inject()
|
||||||
|
|
||||||
var started: Boolean = false
|
var started: Boolean = false
|
||||||
var shouldStop: Boolean = false
|
var shouldStop: Boolean = false
|
||||||
var isPolling: Boolean = false
|
var isPolling: Boolean = false
|
||||||
|
|
||||||
private val downloadQueue = PriorityQueue<DownloadFile>()
|
private val downloadQueue = PriorityQueue<DownloadableTrack>()
|
||||||
private val activelyDownloading = mutableListOf<DownloadFile>()
|
private val activelyDownloading = mutableMapOf<DownloadableTrack, DownloadTask>()
|
||||||
|
private val failedList = mutableListOf<DownloadableTrack>()
|
||||||
|
|
||||||
// The generic list models expect a LiveData, so even though we are using Rx for many events
|
// The generic list models expect a LiveData, so even though we are using Rx for many events
|
||||||
// surrounding playback the list of Downloads is published as LiveData.
|
// surrounding playback the list of Downloads is published as LiveData.
|
||||||
val observableDownloads = MutableLiveData<List<DownloadFile>>()
|
val observableDownloads = MutableLiveData<List<Track>>()
|
||||||
|
|
||||||
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
|
|
||||||
private val downloadFileCache = LRUCache<Track, DownloadFile>(500)
|
|
||||||
|
|
||||||
private var handler: Handler = Handler(Looper.getMainLooper())
|
private var handler: Handler = Handler(Looper.getMainLooper())
|
||||||
private var wifiLock: WifiManager.WifiLock? = null
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
@ -124,7 +134,10 @@ class Downloader(
|
|||||||
shouldStop = true
|
shouldStop = true
|
||||||
wifiLock?.release()
|
wifiLock?.release()
|
||||||
wifiLock = null
|
wifiLock = null
|
||||||
DownloadService.runningInstance?.notifyDownloaderStopped()
|
handler.postDelayed(
|
||||||
|
Runnable { DownloadService.runningInstance?.notifyDownloaderStopped() },
|
||||||
|
100
|
||||||
|
)
|
||||||
Timber.i("Downloader stopped")
|
Timber.i("Downloader stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,56 +163,48 @@ class Downloader(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
|
if (jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.v("Downloader checkDownloadsInternal checking downloads")
|
Timber.v("Downloader checkDownloadsInternal checking downloads")
|
||||||
|
|
||||||
// Check the active downloads for failures or completions and remove them
|
var listChanged = false
|
||||||
// Store the result in a flag to know if changes have occurred
|
val playlist = mediaController.getNextPlaylistItemsInPlayOrder(Settings.preloadCount)
|
||||||
var listChanged = cleanupActiveDownloads()
|
var priority = 0
|
||||||
|
|
||||||
val playlist = legacyPlaylistManager.playlist
|
for (item in playlist) {
|
||||||
|
val track = item.toTrack()
|
||||||
// Check if need to preload more from playlist
|
|
||||||
val preloadCount = Settings.preloadCount
|
|
||||||
|
|
||||||
// Start preloading at the current playing song
|
|
||||||
var start = mediaController.currentMediaItemIndex
|
|
||||||
|
|
||||||
if (start == -1 || start > playlist.size) start = 0
|
|
||||||
|
|
||||||
val end = (start + preloadCount).coerceAtMost(playlist.size)
|
|
||||||
|
|
||||||
for (i in start until end) {
|
|
||||||
val download = playlist[i]
|
|
||||||
|
|
||||||
// Set correct priority (the lower the number, the higher the priority)
|
|
||||||
download.priority = i
|
|
||||||
|
|
||||||
// Add file to queue if not in one of the queues already.
|
// Add file to queue if not in one of the queues already.
|
||||||
if (!download.isWorkDone &&
|
if (getDownloadState(track) == DownloadStatus.IDLE) {
|
||||||
!activelyDownloading.contains(download) &&
|
|
||||||
!downloadQueue.contains(download) &&
|
|
||||||
download.shouldRetry()
|
|
||||||
) {
|
|
||||||
listChanged = true
|
listChanged = true
|
||||||
downloadQueue.add(download)
|
|
||||||
|
// If a track is already in the manual download queue,
|
||||||
|
// and is now due to be played soon we add it to the queue with high priority instead.
|
||||||
|
val existingItem = downloadQueue.firstOrNull { it.track.id == track.id }
|
||||||
|
if (existingItem != null) {
|
||||||
|
existingItem.priority = priority + 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set correct priority (the lower the number, the higher the priority)
|
||||||
|
downloadQueue.add(DownloadableTrack(track, item.shouldBePinned(), 0, priority++))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill up active List with waiting tasks
|
// Fill up active List with waiting tasks
|
||||||
while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) {
|
while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) {
|
||||||
val task = downloadQueue.remove()
|
val task = downloadQueue.remove()
|
||||||
activelyDownloading.add(task)
|
val downloadTask = DownloadTask(task)
|
||||||
|
activelyDownloading[task] = downloadTask
|
||||||
startDownloadOnService(task)
|
startDownloadOnService(task)
|
||||||
|
|
||||||
listChanged = true
|
listChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop Executor service when done downloading
|
// Stop Executor service when done downloading
|
||||||
if (activelyDownloading.size == 0) {
|
if (activelyDownloading.isEmpty()) {
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,89 +217,36 @@ class Downloader(
|
|||||||
observableDownloads.postValue(downloads)
|
observableDownloads.postValue(downloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownloadOnService(file: DownloadFile) {
|
private fun startDownloadOnService(track: DownloadableTrack) {
|
||||||
if (file.isDownloading) return
|
|
||||||
file.prepare()
|
|
||||||
DownloadService.executeOnStartedDownloadService {
|
DownloadService.executeOnStartedDownloadService {
|
||||||
FileUtil.createDirectoryForParent(file.pinnedFile)
|
FileUtil.createDirectoryForParent(track.pinnedFile)
|
||||||
file.isFailed = false
|
activelyDownloading[track]?.start()
|
||||||
file.downloadTask = DownloadTask(file)
|
Timber.v("startDownloadOnService started downloading file ${track.completeFile}")
|
||||||
file.downloadTask!!.start()
|
|
||||||
Timber.v("startDownloadOnService started downloading file ${file.completeFile}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return true if modifications were made
|
|
||||||
*/
|
|
||||||
private fun cleanupActiveDownloads(): Boolean {
|
|
||||||
val oldSize = activelyDownloading.size
|
|
||||||
|
|
||||||
activelyDownloading.retainAll {
|
|
||||||
when {
|
|
||||||
it.isDownloading -> true
|
|
||||||
it.isFailed && it.shouldRetry() -> {
|
|
||||||
// Add it back to queue
|
|
||||||
downloadQueue.add(it)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
it.cleanup()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (oldSize != activelyDownloading.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:Synchronized
|
|
||||||
val all: List<DownloadFile>
|
|
||||||
get() {
|
|
||||||
val temp: MutableList<DownloadFile> = ArrayList()
|
|
||||||
temp.addAll(activelyDownloading)
|
|
||||||
temp.addAll(downloadQueue)
|
|
||||||
temp.addAll(legacyPlaylistManager.playlist)
|
|
||||||
return temp.distinct().sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Returns a list of all DownloadFiles that are currently downloading or waiting for download,
|
* Returns a list of all DownloadFiles that are currently downloading or waiting for download,
|
||||||
* including undownloaded files from the playlist.
|
*/
|
||||||
*/
|
|
||||||
@get:Synchronized
|
@get:Synchronized
|
||||||
val downloads: List<DownloadFile>
|
val downloads: List<Track>
|
||||||
get() {
|
get() {
|
||||||
val temp: MutableList<DownloadFile> = ArrayList()
|
val temp: MutableList<Track> = ArrayList()
|
||||||
temp.addAll(activelyDownloading)
|
temp.addAll(activelyDownloading.keys.map { x -> x.track })
|
||||||
temp.addAll(downloadQueue)
|
temp.addAll(downloadQueue.map { x -> x.track })
|
||||||
temp.addAll(
|
|
||||||
legacyPlaylistManager.playlist.filter {
|
|
||||||
if (!it.isStatusInitialized) false
|
|
||||||
else when (it.status.value) {
|
|
||||||
DownloadStatus.DOWNLOADING -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return temp.distinct().sorted()
|
return temp.distinct().sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun clearDownloadFileCache() {
|
|
||||||
downloadFileCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun clearBackground() {
|
fun clearBackground() {
|
||||||
// Clear the pending queue
|
// Clear the pending queue
|
||||||
downloadQueue.clear()
|
downloadQueue.clear()
|
||||||
|
|
||||||
// Cancel all active downloads with a low priority
|
// Cancel all active downloads with a low priority
|
||||||
for (download in activelyDownloading) {
|
for (key in activelyDownloading.keys) {
|
||||||
if (download.priority >= 100) {
|
if (key.priority >= 100) {
|
||||||
download.cancelDownload()
|
activelyDownloading[key]?.cancel()
|
||||||
activelyDownloading.remove(download)
|
activelyDownloading.remove(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,125 +257,125 @@ class Downloader(
|
|||||||
fun clearActiveDownloads() {
|
fun clearActiveDownloads() {
|
||||||
// Cancel all active downloads
|
// Cancel all active downloads
|
||||||
for (download in activelyDownloading) {
|
for (download in activelyDownloading) {
|
||||||
download.cancelDownload()
|
download.value.cancel()
|
||||||
}
|
}
|
||||||
activelyDownloading.clear()
|
activelyDownloading.clear()
|
||||||
updateLiveData()
|
updateLiveData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun downloadBackground(songs: List<Track>, save: Boolean) {
|
fun downloadBackground(tracks: List<Track>, save: Boolean) {
|
||||||
|
|
||||||
// By using the counter we ensure that the songs are added in the correct order
|
// By using the counter we ensure that the songs are added in the correct order
|
||||||
for (song in songs) {
|
for (track in tracks) {
|
||||||
val file = song.getDownloadFile()
|
if (downloadQueue.any { t -> t.track.id == track.id }) continue
|
||||||
file.shouldSave = save
|
val file = DownloadableTrack(track, save, 0, backgroundPriorityCounter++)
|
||||||
if (!file.isDownloading) {
|
downloadQueue.add(file)
|
||||||
file.priority = backgroundPriorityCounter++
|
|
||||||
downloadQueue.add(file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.v("downloadBackground Checking Downloads")
|
Timber.v("downloadBackground Checking Downloads")
|
||||||
checkDownloads()
|
checkDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
fun delete(track: Track) {
|
||||||
|
cancelDownload(track)
|
||||||
|
Storage.delete(track.getPartialFile())
|
||||||
|
Storage.delete(track.getCompleteFile())
|
||||||
|
Storage.delete(track.getPinnedFile())
|
||||||
|
postState(track, DownloadStatus.IDLE, 0)
|
||||||
|
Util.scanMedia(track.getPinnedFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelDownload(track: Track) {
|
||||||
|
val key = activelyDownloading.keys.singleOrNull { t -> t.track.id == track.id } ?: return
|
||||||
|
activelyDownloading[key]?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpin(track: Track) {
|
||||||
|
val file = Storage.getFromPath(track.getPinnedFile()) ?: return
|
||||||
|
Storage.rename(file, track.getCompleteFile())
|
||||||
|
postState(track, DownloadStatus.DONE, 100)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("ReturnCount")
|
@Suppress("ReturnCount")
|
||||||
fun getDownloadFileForSong(song: Track): DownloadFile {
|
fun getDownloadState(track: Track): DownloadStatus {
|
||||||
for (downloadFile in legacyPlaylistManager.playlist) {
|
if (Storage.isPathExists(track.getCompleteFile())) return DownloadStatus.DONE
|
||||||
if (downloadFile.track == song) {
|
if (Storage.isPathExists(track.getPinnedFile())) return DownloadStatus.PINNED
|
||||||
return downloadFile
|
|
||||||
}
|
val key = activelyDownloading.keys.firstOrNull { k -> k.track.id == track.id }
|
||||||
|
if (key != null) {
|
||||||
|
if (key.tryCount > 0) return DownloadStatus.RETRYING
|
||||||
|
return DownloadStatus.DOWNLOADING
|
||||||
}
|
}
|
||||||
for (downloadFile in activelyDownloading) {
|
if (failedList.any { t -> t.track.id == track.id }) return DownloadStatus.FAILED
|
||||||
if (downloadFile.track == song) {
|
return DownloadStatus.IDLE
|
||||||
return downloadFile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (downloadFile in downloadQueue) {
|
|
||||||
if (downloadFile.track == song) {
|
|
||||||
return downloadFile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var downloadFile = downloadFileCache[song]
|
|
||||||
if (downloadFile == null) {
|
|
||||||
downloadFile = DownloadFile(song, false)
|
|
||||||
downloadFileCache.put(song, downloadFile)
|
|
||||||
}
|
|
||||||
return downloadFile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHECK_INTERVAL = 5000L
|
const val CHECK_INTERVAL = 5000L
|
||||||
|
const val MAX_RETRIES = 5
|
||||||
|
const val REFRESH_INTERVAL = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun postState(track: Track, state: DownloadStatus, progress: Int) {
|
||||||
* Extension function
|
RxBus.trackDownloadStatePublisher.onNext(
|
||||||
* Gathers the download file for a given song, and modifies shouldSave if provided.
|
RxBus.TrackDownloadState(
|
||||||
*/
|
track,
|
||||||
private fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
|
state,
|
||||||
return getDownloadFileForSong(this).apply {
|
progress
|
||||||
if (save != null) this.shouldSave = save
|
)
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() {
|
private inner class DownloadTask(private val item: DownloadableTrack) :
|
||||||
|
CancellableTask() {
|
||||||
val musicService = MusicServiceFactory.getMusicService()
|
val musicService = MusicServiceFactory.getMusicService()
|
||||||
|
|
||||||
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth")
|
@Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "TooGenericExceptionThrown")
|
||||||
override fun execute() {
|
override fun execute() {
|
||||||
|
|
||||||
downloadFile.downloadPrepared = false
|
|
||||||
var inputStream: InputStream? = null
|
var inputStream: InputStream? = null
|
||||||
var outputStream: OutputStream? = null
|
var outputStream: OutputStream? = null
|
||||||
try {
|
try {
|
||||||
if (Storage.isPathExists(downloadFile.pinnedFile)) {
|
if (Storage.isPathExists(item.pinnedFile)) {
|
||||||
Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile)
|
Timber.i("%s already exists. Skipping.", item.pinnedFile)
|
||||||
downloadFile.status.postValue(DownloadStatus.PINNED)
|
postState(item.track, DownloadStatus.PINNED, 100)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Storage.isPathExists(downloadFile.completeFile)) {
|
if (Storage.isPathExists(item.completeFile)) {
|
||||||
var newStatus: DownloadStatus = DownloadStatus.DONE
|
var newStatus: DownloadStatus = DownloadStatus.DONE
|
||||||
if (downloadFile.shouldSave) {
|
if (item.pinned) {
|
||||||
if (downloadFile.isPlaying) {
|
Storage.rename(
|
||||||
downloadFile.saveWhenDone = true
|
item.completeFile,
|
||||||
} else {
|
item.pinnedFile
|
||||||
Storage.rename(
|
)
|
||||||
downloadFile.completeFile,
|
newStatus = DownloadStatus.PINNED
|
||||||
downloadFile.pinnedFile
|
|
||||||
)
|
|
||||||
newStatus = DownloadStatus.PINNED
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Timber.i(
|
Timber.i(
|
||||||
"%s already exists. Skipping.",
|
"%s already exists. Skipping.",
|
||||||
downloadFile.completeFile
|
item.completeFile
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
|
// Hidden feature: If track is toggled between pinned/saved, refresh the metadata..
|
||||||
try {
|
try {
|
||||||
downloadFile.track.cacheMetadata()
|
item.track.cacheMetadata()
|
||||||
} catch (ignore: Exception) {
|
} catch (ignore: Exception) {
|
||||||
Timber.w(ignore)
|
Timber.w(ignore)
|
||||||
}
|
}
|
||||||
|
postState(item.track, newStatus, 100)
|
||||||
downloadFile.status.postValue(newStatus)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFile.status.postValue(DownloadStatus.DOWNLOADING)
|
postState(item.track, DownloadStatus.DOWNLOADING, 0)
|
||||||
|
|
||||||
// Some devices seem to throw error on partial file which doesn't exist
|
// Some devices seem to throw error on partial file which doesn't exist
|
||||||
val needsDownloading: Boolean
|
val needsDownloading: Boolean
|
||||||
val duration = downloadFile.track.duration
|
val duration = item.track.duration
|
||||||
val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0
|
val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0
|
||||||
|
|
||||||
needsDownloading = (
|
needsDownloading = (
|
||||||
downloadFile.desiredBitRate == 0 ||
|
duration == null ||
|
||||||
duration == null ||
|
|
||||||
duration == 0 ||
|
duration == 0 ||
|
||||||
fileLength == 0L
|
fileLength == 0L
|
||||||
)
|
)
|
||||||
@ -431,9 +383,9 @@ class Downloader(
|
|||||||
if (needsDownloading) {
|
if (needsDownloading) {
|
||||||
// Attempt partial HTTP GET, appending to the file if it exists.
|
// Attempt partial HTTP GET, appending to the file if it exists.
|
||||||
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
val (inStream, isPartial) = musicService.getDownloadInputStream(
|
||||||
downloadFile.track, fileLength,
|
item.track, fileLength,
|
||||||
downloadFile.desiredBitRate,
|
Settings.maxBitRate,
|
||||||
downloadFile.shouldSave
|
item.pinned
|
||||||
)
|
)
|
||||||
|
|
||||||
inputStream = inStream
|
inputStream = inStream
|
||||||
@ -442,31 +394,40 @@ class Downloader(
|
|||||||
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
|
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile)
|
outputStream = Storage.getOrCreateFileFromPath(item.partialFile)
|
||||||
.getFileOutputStream(isPartial)
|
.getFileOutputStream(isPartial)
|
||||||
|
|
||||||
|
var lastPostTime: Long = 0
|
||||||
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
|
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
|
||||||
downloadFile.setProgress(totalBytesCopied)
|
// Manual throttling to avoid overloading Rx
|
||||||
|
if (SystemClock.elapsedRealtime() - lastPostTime > REFRESH_INTERVAL) {
|
||||||
|
lastPostTime = SystemClock.elapsedRealtime()
|
||||||
|
postState(
|
||||||
|
item.track,
|
||||||
|
DownloadStatus.DOWNLOADING,
|
||||||
|
(totalBytesCopied * 100 / (item.track.size ?: 1)).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.i("Downloaded %d bytes to %s", len, downloadFile.partialFile)
|
Timber.i("Downloaded %d bytes to %s", len, item.partialFile)
|
||||||
|
|
||||||
inputStream.close()
|
inputStream.close()
|
||||||
outputStream.flush()
|
outputStream.flush()
|
||||||
outputStream.close()
|
outputStream.close()
|
||||||
|
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
downloadFile.status.postValue(DownloadStatus.CANCELLED)
|
postState(item.track, DownloadStatus.CANCELLED, 0)
|
||||||
throw RuntimeException(
|
throw RuntimeException(
|
||||||
String.format(
|
String.format(
|
||||||
Locale.ROOT, "Download of '%s' was cancelled",
|
Locale.ROOT, "Download of '%s' was cancelled",
|
||||||
downloadFile.track
|
item
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
downloadFile.track.cacheMetadata()
|
item.track.cacheMetadata()
|
||||||
} catch (ignore: Exception) {
|
} catch (ignore: Exception) {
|
||||||
Timber.w(ignore)
|
Timber.w(ignore)
|
||||||
}
|
}
|
||||||
@ -474,40 +435,40 @@ class Downloader(
|
|||||||
downloadAndSaveCoverArt()
|
downloadAndSaveCoverArt()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadFile.isPlaying) {
|
if (item.pinned) {
|
||||||
downloadFile.completeWhenDone = true
|
Storage.rename(
|
||||||
|
item.partialFile,
|
||||||
|
item.pinnedFile
|
||||||
|
)
|
||||||
|
postState(item.track, DownloadStatus.PINNED, 100)
|
||||||
|
Util.scanMedia(item.pinnedFile)
|
||||||
} else {
|
} else {
|
||||||
if (downloadFile.shouldSave) {
|
Storage.rename(
|
||||||
Storage.rename(
|
item.partialFile,
|
||||||
downloadFile.partialFile,
|
item.completeFile
|
||||||
downloadFile.pinnedFile
|
)
|
||||||
)
|
postState(item.track, DownloadStatus.DONE, 100)
|
||||||
downloadFile.status.postValue(DownloadStatus.PINNED)
|
|
||||||
Util.scanMedia(downloadFile.pinnedFile)
|
|
||||||
} else {
|
|
||||||
Storage.rename(
|
|
||||||
downloadFile.partialFile,
|
|
||||||
downloadFile.completeFile
|
|
||||||
)
|
|
||||||
downloadFile.status.postValue(DownloadStatus.DONE)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (all: Exception) {
|
} catch (all: Exception) {
|
||||||
outputStream.safeClose()
|
outputStream.safeClose()
|
||||||
Storage.delete(downloadFile.completeFile)
|
Storage.delete(item.completeFile)
|
||||||
Storage.delete(downloadFile.pinnedFile)
|
Storage.delete(item.pinnedFile)
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
downloadFile.isFailed = true
|
if (item.tryCount < MAX_RETRIES) {
|
||||||
if (downloadFile.retryCount > 1) {
|
postState(item.track, DownloadStatus.RETRYING, 0)
|
||||||
downloadFile.status.postValue(DownloadStatus.RETRYING)
|
item.tryCount++
|
||||||
--downloadFile.retryCount
|
activelyDownloading.remove(item)
|
||||||
} else if (downloadFile.retryCount == 1) {
|
downloadQueue.add(item)
|
||||||
downloadFile.status.postValue(DownloadStatus.FAILED)
|
} else {
|
||||||
--downloadFile.retryCount
|
postState(item.track, DownloadStatus.FAILED, 0)
|
||||||
|
activelyDownloading.remove(item)
|
||||||
|
downloadQueue.remove(item)
|
||||||
|
failedList.add(item)
|
||||||
}
|
}
|
||||||
Timber.w(all, "Failed to download '%s'.", downloadFile.track)
|
Timber.w(all, "Failed to download '%s'.", item)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
activelyDownloading.remove(item)
|
||||||
inputStream.safeClose()
|
inputStream.safeClose()
|
||||||
outputStream.safeClose()
|
outputStream.safeClose()
|
||||||
CacheCleaner().cleanSpace()
|
CacheCleaner().cleanSpace()
|
||||||
@ -517,7 +478,7 @@ class Downloader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track)
|
return String.format(Locale.ROOT, "DownloadTask (%s)", item)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Track.cacheMetadata() {
|
private fun Track.cacheMetadata() {
|
||||||
@ -567,9 +528,9 @@ class Downloader(
|
|||||||
|
|
||||||
private fun downloadAndSaveCoverArt() {
|
private fun downloadAndSaveCoverArt() {
|
||||||
try {
|
try {
|
||||||
if (!TextUtils.isEmpty(downloadFile.track.coverArt)) {
|
if (!TextUtils.isEmpty(item.track.coverArt)) {
|
||||||
// Download the largest size that we can display in the UI
|
// Download the largest size that we can display in the UI
|
||||||
imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track)
|
imageLoaderProvider.getImageLoader().cacheCoverArt(item.track)
|
||||||
}
|
}
|
||||||
} catch (all: Exception) {
|
} catch (all: Exception) {
|
||||||
Timber.e(all, "Failed to get cover art.")
|
Timber.e(all, "Failed to get cover art.")
|
||||||
@ -590,4 +551,26 @@ class Downloader(
|
|||||||
return bytesCopied
|
return bytesCopied
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class DownloadableTrack(
|
||||||
|
val track: Track,
|
||||||
|
val pinned: Boolean,
|
||||||
|
var tryCount: Int,
|
||||||
|
var priority: Int
|
||||||
|
) : Identifiable {
|
||||||
|
val pinnedFile = track.getPinnedFile()
|
||||||
|
val partialFile = track.getPartialFile()
|
||||||
|
val completeFile = track.getCompleteFile()
|
||||||
|
override val id: String
|
||||||
|
get() = track.id
|
||||||
|
|
||||||
|
override fun compareTo(other: Identifiable) = compareTo(other as DownloadableTrack)
|
||||||
|
fun compareTo(other: DownloadableTrack): Int {
|
||||||
|
return priority.compareTo(other.priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DownloadStatus {
|
||||||
|
IDLE, DOWNLOADING, RETRYING, FAILED, CANCELLED, DONE, PINNED, UNKNOWN
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ import timber.log.Timber
|
|||||||
* TODO: Persist RC state?
|
* TODO: Persist RC state?
|
||||||
* TODO: Minimize status updates.
|
* TODO: Minimize status updates.
|
||||||
*/
|
*/
|
||||||
class JukeboxMediaPlayer(private val downloader: Downloader) {
|
class JukeboxMediaPlayer {
|
||||||
private val tasks = TaskQueue()
|
private val tasks = TaskQueue()
|
||||||
private val executorService = Executors.newSingleThreadScheduledExecutor()
|
private val executorService = Executors.newSingleThreadScheduledExecutor()
|
||||||
private var statusUpdateFuture: ScheduledFuture<*>? = null
|
private var statusUpdateFuture: ScheduledFuture<*>? = null
|
||||||
@ -156,8 +156,8 @@ class JukeboxMediaPlayer(private val downloader: Downloader) {
|
|||||||
tasks.remove(Stop::class.java)
|
tasks.remove(Stop::class.java)
|
||||||
tasks.remove(Start::class.java)
|
tasks.remove(Start::class.java)
|
||||||
val ids: MutableList<String> = ArrayList()
|
val ids: MutableList<String> = ArrayList()
|
||||||
for (file in downloader.all) {
|
for (item in mediaPlayerControllerLazy.value.playlist) {
|
||||||
ids.add(file.track.id)
|
ids.add(item.mediaId)
|
||||||
}
|
}
|
||||||
tasks.add(SetPlaylist(ids))
|
tasks.add(SetPlaylist(ids))
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* MediaPlayerController.kt
|
* MediaPlayerController.kt
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
*
|
*
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
*/
|
*/
|
||||||
@ -10,11 +10,11 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.net.toUri
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.HeartRating
|
import androidx.media3.common.HeartRating
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Player.REPEAT_MODE_OFF
|
||||||
import androidx.media3.common.Timeline
|
import androidx.media3.common.Timeline
|
||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import androidx.media3.session.SessionResult
|
import androidx.media3.session.SessionResult
|
||||||
@ -23,7 +23,6 @@ import com.google.common.util.concurrent.FutureCallback
|
|||||||
import com.google.common.util.concurrent.Futures
|
import com.google.common.util.concurrent.Futures
|
||||||
import com.google.common.util.concurrent.MoreExecutors
|
import com.google.common.util.concurrent.MoreExecutors
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -32,16 +31,17 @@ import org.koin.core.component.inject
|
|||||||
import org.moire.ultrasonic.app.UApp
|
import org.moire.ultrasonic.app.UApp
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.Track
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.playback.LegacyPlaylistManager
|
|
||||||
import org.moire.ultrasonic.playback.PlaybackService
|
import org.moire.ultrasonic.playback.PlaybackService
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3
|
||||||
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
|
import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4
|
||||||
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
|
||||||
import org.moire.ultrasonic.util.FileUtil
|
|
||||||
import org.moire.ultrasonic.util.MainThreadExecutor
|
import org.moire.ultrasonic.util.MainThreadExecutor
|
||||||
import org.moire.ultrasonic.util.Settings
|
import org.moire.ultrasonic.util.Settings
|
||||||
|
import org.moire.ultrasonic.util.setPin
|
||||||
|
import org.moire.ultrasonic.util.toMediaItem
|
||||||
|
import org.moire.ultrasonic.util.toTrack
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -54,7 +54,6 @@ class MediaPlayerController(
|
|||||||
private val playbackStateSerializer: PlaybackStateSerializer,
|
private val playbackStateSerializer: PlaybackStateSerializer,
|
||||||
private val externalStorageMonitor: ExternalStorageMonitor,
|
private val externalStorageMonitor: ExternalStorageMonitor,
|
||||||
private val downloader: Downloader,
|
private val downloader: Downloader,
|
||||||
private val legacyPlaylistManager: LegacyPlaylistManager,
|
|
||||||
val context: Context
|
val context: Context
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
@ -85,6 +84,8 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
private lateinit var listeners: Player.Listener
|
private lateinit var listeners: Player.Listener
|
||||||
|
|
||||||
|
private var cachedMediaItem: MediaItem? = null
|
||||||
|
|
||||||
fun onCreate(onCreated: () -> Unit) {
|
fun onCreate(onCreated: () -> Unit) {
|
||||||
if (created) return
|
if (created) return
|
||||||
externalStorageMonitor.onCreate { reset() }
|
externalStorageMonitor.onCreate { reset() }
|
||||||
@ -111,7 +112,7 @@ class MediaPlayerController(
|
|||||||
* We run the event through RxBus in order to throttle them
|
* We run the event through RxBus in order to throttle them
|
||||||
*/
|
*/
|
||||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
legacyPlaylistManager.rebuildPlaylist(controller!!)
|
RxBus.playlistPublisher.onNext(playlist.map(MediaItem::toTrack))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
@ -125,8 +126,8 @@ class MediaPlayerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
onTrackCompleted()
|
clearBookmark()
|
||||||
legacyPlaylistManager.updateCurrentPlaying(mediaItem)
|
cachedMediaItem = mediaItem
|
||||||
publishPlaybackState()
|
publishPlaybackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +181,7 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
|
rxBusSubscription += RxBus.shutdownCommandObservable.subscribe {
|
||||||
playbackStateSerializer.serializeNow(
|
playbackStateSerializer.serializeNow(
|
||||||
playList,
|
playlist.map { it.toTrack() },
|
||||||
currentMediaItemIndex,
|
currentMediaItemIndex,
|
||||||
playerPosition,
|
playerPosition,
|
||||||
isShufflePlayEnabled,
|
isShufflePlayEnabled,
|
||||||
@ -196,7 +197,7 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
private fun playerStateChangedHandler() {
|
private fun playerStateChangedHandler() {
|
||||||
|
|
||||||
val currentPlaying = legacyPlaylistManager.currentPlaying
|
val currentPlaying = controller?.currentMediaItem?.toTrack() ?: return
|
||||||
|
|
||||||
when (playbackState) {
|
when (playbackState) {
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
@ -210,16 +211,14 @@ class MediaPlayerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update widget
|
// Update widget
|
||||||
if (currentPlaying != null) {
|
updateWidget(currentPlaying)
|
||||||
updateWidget(currentPlaying.track)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onTrackCompleted() {
|
private fun clearBookmark() {
|
||||||
// This method is called before we update the currentPlaying,
|
// This method is called just before we update the cachedMediaItem,
|
||||||
// so in fact currentPlaying will refer to the track that has just finished.
|
// so in fact cachedMediaItem will refer to the track that has just finished.
|
||||||
if (legacyPlaylistManager.currentPlaying != null) {
|
if (cachedMediaItem != null) {
|
||||||
val song = legacyPlaylistManager.currentPlaying!!.track
|
val song = cachedMediaItem!!.toTrack()
|
||||||
if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) {
|
if (song.bookmarkPosition > 0 && Settings.shouldClearBookmark) {
|
||||||
val musicService = getMusicService()
|
val musicService = getMusicService()
|
||||||
try {
|
try {
|
||||||
@ -232,7 +231,7 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
private fun publishPlaybackState() {
|
private fun publishPlaybackState() {
|
||||||
val newState = RxBus.StateWithTrack(
|
val newState = RxBus.StateWithTrack(
|
||||||
track = legacyPlaylistManager.currentPlaying,
|
track = currentMediaItem?.let { it.toTrack() },
|
||||||
index = currentMediaItemIndex,
|
index = currentMediaItemIndex,
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
state = playbackState
|
state = playbackState
|
||||||
@ -261,7 +260,6 @@ class MediaPlayerController(
|
|||||||
val context = UApp.applicationContext()
|
val context = UApp.applicationContext()
|
||||||
externalStorageMonitor.onDestroy()
|
externalStorageMonitor.onDestroy()
|
||||||
context.stopService(Intent(context, DownloadService::class.java))
|
context.stopService(Intent(context, DownloadService::class.java))
|
||||||
legacyPlaylistManager.onDestroy()
|
|
||||||
downloader.onDestroy()
|
downloader.onDestroy()
|
||||||
created = false
|
created = false
|
||||||
Timber.i("MediaPlayerController destroyed")
|
Timber.i("MediaPlayerController destroyed")
|
||||||
@ -345,12 +343,17 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun seekTo(position: Int) {
|
fun seekTo(position: Int) {
|
||||||
|
if (controller?.currentTimeline?.isEmpty != false) return
|
||||||
Timber.i("SeekTo: %s", position)
|
Timber.i("SeekTo: %s", position)
|
||||||
controller?.seekTo(position.toLong())
|
controller?.seekTo(position.toLong())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun seekTo(index: Int, position: Int) {
|
fun seekTo(index: Int, position: Int) {
|
||||||
|
// This case would throw an exception in Media3. It can happen when an inconsistent state is saved.
|
||||||
|
if (controller?.currentTimeline?.isEmpty != false ||
|
||||||
|
index >= controller!!.currentTimeline.windowCount
|
||||||
|
) return
|
||||||
Timber.i("SeekTo: %s %s", index, position)
|
Timber.i("SeekTo: %s %s", index, position)
|
||||||
controller?.seekTo(index, position.toLong())
|
controller?.seekTo(index, position.toLong())
|
||||||
}
|
}
|
||||||
@ -390,10 +393,8 @@ class MediaPlayerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val mediaItems: List<MediaItem> = songs.map {
|
val mediaItems: List<MediaItem> = songs.map {
|
||||||
val downloadFile = downloader.getDownloadFileForSong(it)
|
|
||||||
if (cachePermanently) downloadFile.shouldSave = true
|
|
||||||
val result = it.toMediaItem()
|
val result = it.toMediaItem()
|
||||||
legacyPlaylistManager.addToCache(result, downloader.getDownloadFileForSong(it))
|
if (cachePermanently) result.setPin(true)
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -426,9 +427,8 @@ class MediaPlayerController(
|
|||||||
get() = controller?.shuffleModeEnabled == true
|
get() = controller?.shuffleModeEnabled == true
|
||||||
set(enabled) {
|
set(enabled) {
|
||||||
controller?.shuffleModeEnabled = enabled
|
controller?.shuffleModeEnabled = enabled
|
||||||
if (enabled) {
|
// Changing Shuffle may change the playlist, so the next tracks may need to be downloaded
|
||||||
downloader.checkDownloads()
|
downloader.checkDownloads()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@ -467,11 +467,6 @@ class MediaPlayerController(
|
|||||||
jukeboxMediaPlayer.updatePlaylist()
|
jukeboxMediaPlayer.updatePlaylist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun clearCaches() {
|
|
||||||
downloader.clearDownloadFileCache()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun clearIncomplete() {
|
fun clearIncomplete() {
|
||||||
reset()
|
reset()
|
||||||
@ -496,7 +491,7 @@ class MediaPlayerController(
|
|||||||
if (currentMediaItemIndex == -1) return
|
if (currentMediaItemIndex == -1) return
|
||||||
|
|
||||||
playbackStateSerializer.serializeAsync(
|
playbackStateSerializer.serializeAsync(
|
||||||
songs = legacyPlaylistManager.playlist,
|
songs = playlist.map { it.toTrack() },
|
||||||
currentPlayingIndex = currentMediaItemIndex,
|
currentPlayingIndex = currentMediaItemIndex,
|
||||||
currentPlayingPosition = playerPosition,
|
currentPlayingPosition = playerPosition,
|
||||||
isShufflePlayEnabled,
|
isShufflePlayEnabled,
|
||||||
@ -506,17 +501,17 @@ class MediaPlayerController(
|
|||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
// TODO: Make it require not null
|
// TODO: Make it require not null
|
||||||
fun delete(songs: List<Track?>) {
|
fun delete(tracks: List<Track?>) {
|
||||||
for (song in songs.filterNotNull()) {
|
for (track in tracks.filterNotNull()) {
|
||||||
downloader.getDownloadFileForSong(song).delete()
|
downloader.delete(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
// TODO: Make it require not null
|
// TODO: Make it require not null
|
||||||
fun unpin(songs: List<Track?>) {
|
fun unpin(tracks: List<Track?>) {
|
||||||
for (song in songs.filterNotNull()) {
|
for (track in tracks.filterNotNull()) {
|
||||||
downloader.getDownloadFileForSong(song).unpin()
|
downloader.unpin(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -598,8 +593,8 @@ class MediaPlayerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun toggleSongStarred() {
|
fun toggleSongStarred() {
|
||||||
if (legacyPlaylistManager.currentPlaying == null) return
|
if (currentMediaItem == null) return
|
||||||
val song = legacyPlaylistManager.currentPlaying!!.track
|
val song = currentMediaItem!!.toTrack()
|
||||||
|
|
||||||
controller?.setRating(
|
controller?.setRating(
|
||||||
HeartRating(!song.starred)
|
HeartRating(!song.starred)
|
||||||
@ -630,8 +625,8 @@ class MediaPlayerController(
|
|||||||
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
@Suppress("TooGenericExceptionCaught") // The interface throws only generic exceptions
|
||||||
fun setSongRating(rating: Int) {
|
fun setSongRating(rating: Int) {
|
||||||
if (!Settings.useFiveStarRating) return
|
if (!Settings.useFiveStarRating) return
|
||||||
if (legacyPlaylistManager.currentPlaying == null) return
|
if (currentMediaItem == null) return
|
||||||
val song = legacyPlaylistManager.currentPlaying!!.track
|
val song = currentMediaItem!!.toTrack()
|
||||||
song.userRating = rating
|
song.userRating = rating
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
@ -650,29 +645,56 @@ class MediaPlayerController(
|
|||||||
val currentMediaItemIndex: Int
|
val currentMediaItemIndex: Int
|
||||||
get() = controller?.currentMediaItemIndex ?: -1
|
get() = controller?.currentMediaItemIndex ?: -1
|
||||||
|
|
||||||
@Deprecated("Use currentMediaItem")
|
|
||||||
val currentPlayingLegacy: DownloadFile?
|
|
||||||
get() = legacyPlaylistManager.currentPlaying
|
|
||||||
|
|
||||||
val mediaItemCount: Int
|
val mediaItemCount: Int
|
||||||
get() = controller?.mediaItemCount ?: 0
|
get() = controller?.mediaItemCount ?: 0
|
||||||
|
|
||||||
@Deprecated("Use mediaItemCount")
|
|
||||||
val playlistSize: Int
|
val playlistSize: Int
|
||||||
get() = legacyPlaylistManager.playlist.size
|
get() = controller?.currentTimeline?.windowCount ?: 0
|
||||||
|
|
||||||
@Deprecated("Use native APIs")
|
val playlist: List<MediaItem>
|
||||||
val playList: List<DownloadFile>
|
get() {
|
||||||
get() = legacyPlaylistManager.playlist
|
return getPlayList(false)
|
||||||
|
}
|
||||||
|
|
||||||
@Deprecated("Use timeline")
|
val playlistInPlayOrder: List<MediaItem>
|
||||||
val playListDuration: Long
|
get() {
|
||||||
get() = legacyPlaylistManager.playlistDuration
|
return getPlayList(controller?.shuffleModeEnabled ?: false)
|
||||||
|
}
|
||||||
|
|
||||||
fun getDownloadFileForSong(song: Track): DownloadFile {
|
fun getNextPlaylistItemsInPlayOrder(count: Int? = null): List<MediaItem> {
|
||||||
return downloader.getDownloadFileForSong(song)
|
return getPlayList(
|
||||||
|
controller?.shuffleModeEnabled ?: false,
|
||||||
|
controller?.currentMediaItemIndex,
|
||||||
|
count
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getPlayList(
|
||||||
|
shuffle: Boolean,
|
||||||
|
firstIndex: Int? = null,
|
||||||
|
count: Int? = null
|
||||||
|
): List<MediaItem> {
|
||||||
|
if (controller?.currentTimeline == null) return emptyList()
|
||||||
|
if (controller!!.currentTimeline.windowCount < 1) return emptyList()
|
||||||
|
val timeline = controller!!.currentTimeline
|
||||||
|
|
||||||
|
val playlist: MutableList<MediaItem> = mutableListOf()
|
||||||
|
var i = firstIndex ?: timeline.getFirstWindowIndex(false)
|
||||||
|
if (i == C.INDEX_UNSET) return emptyList()
|
||||||
|
|
||||||
|
while (i != C.INDEX_UNSET && (count != playlist.count())) {
|
||||||
|
val window = timeline.getWindow(i, Timeline.Window())
|
||||||
|
playlist.add(window.mediaItem)
|
||||||
|
i = timeline.getNextWindowIndex(i, REPEAT_MODE_OFF, shuffle)
|
||||||
|
}
|
||||||
|
return playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
val playListDuration: Long
|
||||||
|
get() = playlist.fold(0) { i, file ->
|
||||||
|
i + (file.mediaMetadata.extras?.getInt("duration") ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Timber.i("MediaPlayerController instance initiated")
|
Timber.i("MediaPlayerController instance initiated")
|
||||||
}
|
}
|
||||||
@ -681,38 +703,3 @@ class MediaPlayerController(
|
|||||||
CLEAR, APPEND, AFTER_CURRENT
|
CLEAR, APPEND, AFTER_CURRENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* TODO: Merge with the Builder functions in AutoMediaBrowserCallback
|
|
||||||
*/
|
|
||||||
fun Track.toMediaItem(): MediaItem {
|
|
||||||
|
|
||||||
val filePath = FileUtil.getSongFile(this)
|
|
||||||
val bitrate = Settings.maxBitRate
|
|
||||||
val uri = "$id|$bitrate|$filePath"
|
|
||||||
|
|
||||||
val rmd = MediaItem.RequestMetadata.Builder()
|
|
||||||
.setMediaUri(uri.toUri())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val artworkFile = File(FileUtil.getAlbumArtFile(this))
|
|
||||||
|
|
||||||
val metadata = MediaMetadata.Builder()
|
|
||||||
metadata.setTitle(title)
|
|
||||||
.setArtist(artist)
|
|
||||||
.setAlbumTitle(album)
|
|
||||||
.setAlbumArtist(artist)
|
|
||||||
.setUserRating(HeartRating(starred))
|
|
||||||
|
|
||||||
if (artworkFile.exists()) {
|
|
||||||
metadata.setArtworkUri(artworkFile.toUri())
|
|
||||||
}
|
|
||||||
|
|
||||||
val mediaItem = MediaItem.Builder()
|
|
||||||
.setUri(uri)
|
|
||||||
.setMediaId(id)
|
|
||||||
.setRequestMetadata(rmd)
|
|
||||||
.setMediaMetadata(metadata.build())
|
|
||||||
|
|
||||||
return mediaItem.build()
|
|
||||||
}
|
|
||||||
|
@ -15,6 +15,7 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
import org.moire.ultrasonic.domain.Track
|
||||||
import org.moire.ultrasonic.util.Constants
|
import org.moire.ultrasonic.util.Constants
|
||||||
import org.moire.ultrasonic.util.FileUtil
|
import org.moire.ultrasonic.util.FileUtil
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@ -32,7 +33,7 @@ class PlaybackStateSerializer : KoinComponent {
|
|||||||
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
fun serializeAsync(
|
fun serializeAsync(
|
||||||
songs: Iterable<DownloadFile>,
|
songs: Iterable<Track>,
|
||||||
currentPlayingIndex: Int,
|
currentPlayingIndex: Int,
|
||||||
currentPlayingPosition: Int,
|
currentPlayingPosition: Int,
|
||||||
shufflePlay: Boolean,
|
shufflePlay: Boolean,
|
||||||
@ -56,19 +57,14 @@ class PlaybackStateSerializer : KoinComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun serializeNow(
|
fun serializeNow(
|
||||||
referencedList: Iterable<DownloadFile>,
|
tracks: Iterable<Track>,
|
||||||
currentPlayingIndex: Int,
|
currentPlayingIndex: Int,
|
||||||
currentPlayingPosition: Int,
|
currentPlayingPosition: Int,
|
||||||
shufflePlay: Boolean,
|
shufflePlay: Boolean,
|
||||||
repeatMode: Int
|
repeatMode: Int
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val tracks = referencedList.toList().map {
|
|
||||||
it.track
|
|
||||||
}
|
|
||||||
|
|
||||||
val state = PlaybackState(
|
val state = PlaybackState(
|
||||||
tracks,
|
tracks.toList(),
|
||||||
currentPlayingIndex,
|
currentPlayingIndex,
|
||||||
currentPlayingPosition,
|
currentPlayingPosition,
|
||||||
shufflePlay,
|
shufflePlay,
|
||||||
|
@ -7,6 +7,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
|||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import org.moire.ultrasonic.domain.Track
|
||||||
|
|
||||||
class RxBus {
|
class RxBus {
|
||||||
|
|
||||||
@ -41,18 +42,23 @@ class RxBus {
|
|||||||
.autoConnect(0)
|
.autoConnect(0)
|
||||||
.throttleLatest(300, TimeUnit.MILLISECONDS)
|
.throttleLatest(300, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
val playlistPublisher: PublishSubject<List<DownloadFile>> =
|
val playlistPublisher: PublishSubject<List<Track>> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
val playlistObservable: Observable<List<DownloadFile>> =
|
val playlistObservable: Observable<List<Track>> =
|
||||||
playlistPublisher.observeOn(mainThread())
|
playlistPublisher.observeOn(mainThread())
|
||||||
.replay(1)
|
.replay(1)
|
||||||
.autoConnect(0)
|
.autoConnect(0)
|
||||||
val throttledPlaylistObservable: Observable<List<DownloadFile>> =
|
val throttledPlaylistObservable: Observable<List<Track>> =
|
||||||
playlistPublisher.observeOn(mainThread())
|
playlistPublisher.observeOn(mainThread())
|
||||||
.replay(1)
|
.replay(1)
|
||||||
.autoConnect(0)
|
.autoConnect(0)
|
||||||
.throttleLatest(300, TimeUnit.MILLISECONDS)
|
.throttleLatest(300, TimeUnit.MILLISECONDS)
|
||||||
|
|
||||||
|
val trackDownloadStatePublisher: PublishSubject<TrackDownloadState> =
|
||||||
|
PublishSubject.create()
|
||||||
|
val trackDownloadStateObservable: Observable<TrackDownloadState> =
|
||||||
|
trackDownloadStatePublisher.observeOn(mainThread())
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
|
val dismissNowPlayingCommandPublisher: PublishSubject<Unit> =
|
||||||
PublishSubject.create()
|
PublishSubject.create()
|
||||||
@ -76,11 +82,17 @@ class RxBus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class StateWithTrack(
|
data class StateWithTrack(
|
||||||
val track: DownloadFile?,
|
val track: Track?,
|
||||||
val index: Int = -1,
|
val index: Int = -1,
|
||||||
val isPlaying: Boolean = false,
|
val isPlaying: Boolean = false,
|
||||||
val state: Int
|
val state: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class TrackDownloadState(
|
||||||
|
val track: Track,
|
||||||
|
val state: DownloadStatus,
|
||||||
|
val progress: Int
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
operator fun CompositeDisposable.plusAssign(disposable: Disposable) {
|
operator fun CompositeDisposable.plusAssign(disposable: Disposable) {
|
||||||
|
@ -1,3 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* CacheCleaner.kt
|
||||||
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
|
*
|
||||||
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
|
*/
|
||||||
|
|
||||||
package org.moire.ultrasonic.util
|
package org.moire.ultrasonic.util
|
||||||
|
|
||||||
import android.system.Os
|
import android.system.Os
|
||||||
@ -6,12 +13,16 @@ import java.util.HashSet
|
|||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.guava.future
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.java.KoinJavaComponent.inject
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
import org.moire.ultrasonic.data.ActiveServerProvider
|
import org.moire.ultrasonic.data.ActiveServerProvider
|
||||||
import org.moire.ultrasonic.domain.Playlist
|
import org.moire.ultrasonic.domain.Playlist
|
||||||
import org.moire.ultrasonic.service.Downloader
|
import org.moire.ultrasonic.service.MediaPlayerController
|
||||||
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
|
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
|
||||||
|
import org.moire.ultrasonic.util.FileUtil.getCompleteFile
|
||||||
|
import org.moire.ultrasonic.util.FileUtil.getPartialFile
|
||||||
|
import org.moire.ultrasonic.util.FileUtil.getPinnedFile
|
||||||
import org.moire.ultrasonic.util.FileUtil.getPlaylistDirectory
|
import org.moire.ultrasonic.util.FileUtil.getPlaylistDirectory
|
||||||
import org.moire.ultrasonic.util.FileUtil.getPlaylistFile
|
import org.moire.ultrasonic.util.FileUtil.getPlaylistFile
|
||||||
import org.moire.ultrasonic.util.FileUtil.listFiles
|
import org.moire.ultrasonic.util.FileUtil.listFiles
|
||||||
@ -26,6 +37,8 @@ import timber.log.Timber
|
|||||||
*/
|
*/
|
||||||
class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
||||||
|
|
||||||
|
private var mainScope = CoroutineScope(Dispatchers.Main)
|
||||||
|
|
||||||
private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
|
private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
|
||||||
return CoroutineExceptionHandler { _, exception ->
|
return CoroutineExceptionHandler { _, exception ->
|
||||||
Timber.w(exception, "Exception in CacheCleaner.$tag")
|
Timber.w(exception, "Exception in CacheCleaner.$tag")
|
||||||
@ -129,6 +142,24 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun findFilesToNotDelete(): Set<String> {
|
||||||
|
val filesToNotDelete: MutableSet<String> = HashSet(5)
|
||||||
|
val mediaController = inject<MediaPlayerController>(
|
||||||
|
MediaPlayerController::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
val playlist = mainScope.future { mediaController.value.playlist }.get()
|
||||||
|
for (item in playlist) {
|
||||||
|
val track = item.toTrack()
|
||||||
|
filesToNotDelete.add(track.getPartialFile())
|
||||||
|
filesToNotDelete.add(track.getCompleteFile())
|
||||||
|
filesToNotDelete.add(track.getPinnedFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
filesToNotDelete.add(musicDirectory.path)
|
||||||
|
return filesToNotDelete
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val lock = Object()
|
private val lock = Object()
|
||||||
private var cleaning = false
|
private var cleaning = false
|
||||||
@ -247,21 +278,5 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
|
|||||||
a.lastModified.compareTo(b.lastModified)
|
a.lastModified.compareTo(b.lastModified)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findFilesToNotDelete(): Set<String> {
|
|
||||||
val filesToNotDelete: MutableSet<String> = HashSet(5)
|
|
||||||
val downloader = inject<Downloader>(
|
|
||||||
Downloader::class.java
|
|
||||||
)
|
|
||||||
|
|
||||||
for (downloadFile in downloader.value.all) {
|
|
||||||
filesToNotDelete.add(downloadFile.partialFile)
|
|
||||||
filesToNotDelete.add(downloadFile.completeFile)
|
|
||||||
filesToNotDelete.add(downloadFile.pinnedFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
filesToNotDelete.add(musicDirectory.path)
|
|
||||||
return filesToNotDelete
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -80,6 +80,20 @@ object FileUtil {
|
|||||||
return "$dir/$fileName"
|
return "$dir/$fileName"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Track.getPinnedFile(): String {
|
||||||
|
return getSongFile(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Track.getPartialFile(): String {
|
||||||
|
return getParentPath(this.getPinnedFile()) + "/" +
|
||||||
|
getPartialFile(getNameFromPath(this.getPinnedFile()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Track.getCompleteFile(): String {
|
||||||
|
return getParentPath(this.getPinnedFile()) + "/" +
|
||||||
|
getCompleteFile(getNameFromPath(this.getPinnedFile()))
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getPlaylistFile(server: String?, name: String?): File {
|
fun getPlaylistFile(server: String?, name: String?): File {
|
||||||
val playlistDir = getPlaylistDirectory(server)
|
val playlistDir = getPlaylistDirectory(server)
|
||||||
|
@ -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
|
* TimeLimitedCache.kt
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
*
|
*
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
*/
|
*/
|
||||||
@ -15,7 +15,12 @@ class TimeLimitedCache<T>(expiresAfter: Long = 60L, timeUnit: TimeUnit = TimeUni
|
|||||||
private var expires: Long = 0
|
private var expires: Long = 0
|
||||||
|
|
||||||
fun get(): T? {
|
fun get(): T? {
|
||||||
return if (System.currentTimeMillis() < expires) value!!.get() else null
|
return if (System.currentTimeMillis() < expires) {
|
||||||
|
value!!.get()
|
||||||
|
} else {
|
||||||
|
clear()
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* Util.kt
|
* Util.kt
|
||||||
* Copyright (C) 2009-2021 Ultrasonic developers
|
* Copyright (C) 2009-2022 Ultrasonic developers
|
||||||
*
|
*
|
||||||
* Distributed under terms of the GNU GPLv3 license.
|
* Distributed under terms of the GNU GPLv3 license.
|
||||||
*/
|
*/
|
||||||
@ -14,9 +14,6 @@ import android.content.Context
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.drawable.BitmapDrawable
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.media.MediaScannerConnection
|
import android.media.MediaScannerConnection
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
@ -342,7 +339,7 @@ object Util {
|
|||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
fun networkInfo(): NetworkInfo {
|
fun networkInfo(): NetworkInfo {
|
||||||
val manager = getConnectivityManager()
|
val manager = connectivityManager
|
||||||
val info = NetworkInfo()
|
val info = NetworkInfo()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
@ -376,34 +373,6 @@ object Util {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun getDrawableFromAttribute(context: Context, attr: Int): Drawable {
|
|
||||||
val attrs = intArrayOf(attr)
|
|
||||||
val ta = context.obtainStyledAttributes(attrs)
|
|
||||||
val drawableFromTheme: Drawable? = ta.getDrawable(0)
|
|
||||||
ta.recycle()
|
|
||||||
return drawableFromTheme!!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createDrawableFromBitmap(context: Context, bitmap: Bitmap?): Drawable {
|
|
||||||
return BitmapDrawable(context.resources, bitmap)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
|
|
||||||
if (drawable is BitmapDrawable) {
|
|
||||||
return drawable.bitmap
|
|
||||||
}
|
|
||||||
val bitmap = Bitmap.createBitmap(
|
|
||||||
drawable.intrinsicWidth,
|
|
||||||
drawable.intrinsicHeight,
|
|
||||||
Bitmap.Config.ARGB_8888
|
|
||||||
)
|
|
||||||
val canvas = Canvas(bitmap)
|
|
||||||
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
|
||||||
drawable.draw(canvas)
|
|
||||||
return bitmap
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createWifiLock(tag: String?): WifiLock {
|
fun createWifiLock(tag: String?): WifiLock {
|
||||||
val wm =
|
val wm =
|
||||||
appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
@ -423,15 +392,6 @@ object Util {
|
|||||||
return getScaledHeight(bitmap.height.toDouble(), bitmap.width.toDouble(), width)
|
return getScaledHeight(bitmap.height.toDouble(), bitmap.width.toDouble(), width)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scaleBitmap(bitmap: Bitmap?, size: Int): Bitmap? {
|
|
||||||
return if (bitmap == null) null else Bitmap.createScaledBitmap(
|
|
||||||
bitmap,
|
|
||||||
size,
|
|
||||||
getScaledHeight(bitmap, size),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSongsFromSearchResult(searchResult: SearchResult): MusicDirectory {
|
fun getSongsFromSearchResult(searchResult: SearchResult): MusicDirectory {
|
||||||
val musicDirectory = MusicDirectory()
|
val musicDirectory = MusicDirectory()
|
||||||
for (entry in searchResult.songs) {
|
for (entry in searchResult.songs) {
|
||||||
@ -707,10 +667,8 @@ object Util {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getConnectivityManager(): ConnectivityManager {
|
private val connectivityManager: ConnectivityManager
|
||||||
val context = appContext()
|
get() = appContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the given block if this is not null.
|
* Executes the given block if this is not null.
|
||||||
|
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