From e666498f13c52d77eb24944bc086c20a13d3f1b1 Mon Sep 17 00:00:00 2001 From: James Wells <14866211+SaintDubious@users.noreply.github.com> Date: Fri, 28 May 2021 20:30:36 -0400 Subject: [PATCH 01/14] Initial Test of Android Auto --- ultrasonic/src/main/AndroidManifest.xml | 16 +- .../ultrasonic/service/LocalMediaPlayer.kt | 4 + .../ultrasonic/service/MediaPlayerService.kt | 157 ++++++++++++++++-- .../src/main/res/xml/automotive_app_desc.xml | 3 + 4 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 ultrasonic/src/main/res/xml/automotive_app_desc.xml diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 7dcd1c7c..fb24dcca 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ package="org.moire.ultrasonic" android:installLocation="auto"> + + @@ -27,6 +29,14 @@ android:name=".app.UApp" android:label="@string/common.appname" android:usesCleartextTraffic="true"> + + + + + + + android:exported="true"> + + + + diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index 0e4c2503..c3e80f4f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -125,6 +125,10 @@ class LocalMediaPlayer( } fun release() { + // Calling reset() will result in changing this player's state. If we allow + // the onPlayerStateChanged callback, then the state change will cause this + // to resurrect the media session which has just been destroyed. + onPlayerStateChanged = null reset() try { val i = Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index fdc3b4cb..46240783 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -11,18 +11,27 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.app.Service import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Build -import android.os.IBinder +import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.view.KeyEvent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.media.MediaBrowserServiceCompat +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.collections.ArrayList import org.koin.android.ext.android.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.activity.NavigationActivity @@ -40,7 +49,6 @@ import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.NowPlayingEventDistributor import org.moire.ultrasonic.util.ShufflePlayBuffer -import org.moire.ultrasonic.util.SimpleServiceBinder import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -49,8 +57,7 @@ import timber.log.Timber * while the rest of the Ultrasonic App is in the background. */ @Suppress("LargeClass") -class MediaPlayerService : Service() { - private val binder: IBinder = SimpleServiceBinder(this) +class MediaPlayerService : MediaBrowserServiceCompat() { private val scrobbler = Scrobbler() private val jukeboxMediaPlayer by inject() @@ -62,20 +69,25 @@ class MediaPlayerService : Service() { private val mediaPlayerLifecycleSupport by inject() private var mediaSession: MediaSessionCompat? = null - private var mediaSessionToken: MediaSessionCompat.Token? = null private var isInForeground = false private var notificationBuilder: NotificationCompat.Builder? = null + val executorService: ExecutorService = Executors.newFixedThreadPool(4) + + private val MEDIA_BROWSER_ROOT_ID = "_Ultrasonice_mb_root_" + private val MEDIA_BROWSER_ALBUM_LIST_ROOT = "_Ultrasonic_mb_album_list_root_" + private val MEDIA_BROWSER_ALBUM_PREFIX = "_Ultrasonic_mb_album_prefix_" + private val MEDIA_BROWSER_EXTRA_ENTRY_BYTES = "_Ultrasonic_mb_extra_entry_bytes_" + private val repeatMode: RepeatMode get() = Util.getRepeatMode() - override fun onBind(intent: Intent): IBinder { - return binder - } - override fun onCreate() { super.onCreate() + updateMediaSession(null, PlayerState.IDLE) + mediaSession!!.isActive = true + downloader.onCreate() shufflePlayBuffer.onCreate() localMediaPlayer.init() @@ -136,6 +148,99 @@ class MediaPlayerService : Service() { } } + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): MediaBrowserServiceCompat.BrowserRoot { + + // Returns a root ID that clients can use with onLoadChildren() to retrieve + // the content hierarchy. Note that this root isn't actually displayed. + return MediaBrowserServiceCompat.BrowserRoot(MEDIA_BROWSER_ROOT_ID, null) + } + + override fun onLoadChildren( + parentMediaId: String, + result: MediaBrowserServiceCompat.Result> + ) { + + val mediaItems: MutableList = mutableListOf() + + if (MEDIA_BROWSER_ROOT_ID == parentMediaId) { + // Build the MediaItem objects for the top level, + // and put them in the mediaItems list... + + var albumList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() + albumList.setTitle("Browse Albums").setMediaId(MEDIA_BROWSER_ALBUM_LIST_ROOT) + mediaItems.add( + MediaBrowserCompat.MediaItem( + albumList.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (MEDIA_BROWSER_ALBUM_LIST_ROOT == parentMediaId) { + executorService.execute { + val musicService = getMusicService() + + val musicDirectory: MusicDirectory = musicService.getAlbumList2( + "alphabeticalByName", 10, 0, null + ) + + for (item in musicDirectory.getAllChild()) { + var entryBuilder: MediaDescriptionCompat.Builder = + MediaDescriptionCompat.Builder() + entryBuilder + .setTitle(item.title) + .setMediaId(MEDIA_BROWSER_ALBUM_PREFIX + item.id) + mediaItems.add( + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + result.sendResult(mediaItems) + } + result.detach() + return + } else if (parentMediaId.startsWith(MEDIA_BROWSER_ALBUM_PREFIX)) { + executorService.execute { + val musicService = getMusicService() + val id = parentMediaId.substring(MEDIA_BROWSER_ALBUM_PREFIX.length) + + val albumDirectory = musicService.getAlbum( + id, "", false + ) + for (item in albumDirectory.getAllChild()) { + var extras = Bundle() + + var baos = ByteArrayOutputStream() + var oos = ObjectOutputStream(baos) + oos.writeObject(item) + oos.close() + extras.putByteArray(MEDIA_BROWSER_EXTRA_ENTRY_BYTES, baos.toByteArray()) + + var entryBuilder: MediaDescriptionCompat.Builder = + MediaDescriptionCompat.Builder() + entryBuilder.setTitle(item.title).setMediaId(item.id).setExtras(extras) + mediaItems.add( + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + result.sendResult(mediaItems) + } + result.detach() + return + } else { + // Examine the passed parentMediaId to see which submenu we're at, + // and put the children of that menu in the mediaItems list... + } + result.sendResult(mediaItems) + } + @Synchronized fun seekTo(position: Int) { if (jukeboxMediaPlayer.isEnabled) { @@ -631,8 +736,8 @@ class MediaPlayerService : Service() { // Use the Media Style, to enable native Android support for playback notification val style = androidx.media.app.NotificationCompat.MediaStyle() - if (mediaSessionToken != null) { - style.setMediaSession(mediaSessionToken) + if (getSessionToken() != null) { + style.setMediaSession(getSessionToken()) } // Clear old actions @@ -799,7 +904,7 @@ class MediaPlayerService : Service() { Timber.w("Creating media session") mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") - mediaSessionToken = mediaSession!!.sessionToken + setSessionToken(mediaSession!!.sessionToken) updateMediaButtonReceiver() @@ -816,6 +921,32 @@ class MediaPlayerService : Service() { Timber.v("Media Session Callback: onPlay") } + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPlayFromMediaId(mediaId, extras) + + if (extras!!.containsKey(MEDIA_BROWSER_EXTRA_ENTRY_BYTES)) { + + resetPlayback() + + var bytes = extras.getByteArray(MEDIA_BROWSER_EXTRA_ENTRY_BYTES) + var bais = ByteArrayInputStream(bytes) + var ois = ObjectInputStream(bais) + var item: MusicDirectory.Entry = ois.readObject() as MusicDirectory.Entry + + val songs: MutableList = mutableListOf() + songs.add(item) + + downloader.download(songs, false, false, false, true) + + getPendingIntentForMediaAction( + applicationContext, + KeyEvent.KEYCODE_MEDIA_PLAY, + keycode + ).send() + } + Timber.v("Media Session Callback: onPlayFromMediaId") + } + override fun onPause() { super.onPause() getPendingIntentForMediaAction( diff --git a/ultrasonic/src/main/res/xml/automotive_app_desc.xml b/ultrasonic/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 00000000..0f485746 --- /dev/null +++ b/ultrasonic/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,3 @@ + + + From e95b2ce09cbbcd939fb0fa23baaebf97918dc245 Mon Sep 17 00:00:00 2001 From: James Wells <14866211+SaintDubious@users.noreply.github.com> Date: Sun, 6 Jun 2021 18:28:46 -0400 Subject: [PATCH 02/14] refactored a bit, and some cleanup --- .../ultrasonic/service/MediaPlayerService.kt | 109 +-------- .../util/AndroidAutoMediaBrowser.kt | 225 ++++++++++++++++++ 2 files changed, 231 insertions(+), 103 deletions(-) create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 46240783..9893d3ae 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -17,7 +17,6 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat @@ -25,12 +24,6 @@ import android.view.KeyEvent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.media.MediaBrowserServiceCompat -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors import kotlin.collections.ArrayList import org.koin.android.ext.android.inject import org.moire.ultrasonic.R @@ -45,6 +38,7 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService +import org.moire.ultrasonic.util.AndroidAutoMediaBrowser import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.NowPlayingEventDistributor @@ -67,18 +61,12 @@ class MediaPlayerService : MediaBrowserServiceCompat() { private val localMediaPlayer by inject() private val nowPlayingEventDistributor by inject() private val mediaPlayerLifecycleSupport by inject() + private val autoMediaBrowser: AndroidAutoMediaBrowser = AndroidAutoMediaBrowser() private var mediaSession: MediaSessionCompat? = null private var isInForeground = false private var notificationBuilder: NotificationCompat.Builder? = null - val executorService: ExecutorService = Executors.newFixedThreadPool(4) - - private val MEDIA_BROWSER_ROOT_ID = "_Ultrasonice_mb_root_" - private val MEDIA_BROWSER_ALBUM_LIST_ROOT = "_Ultrasonic_mb_album_list_root_" - private val MEDIA_BROWSER_ALBUM_PREFIX = "_Ultrasonic_mb_album_prefix_" - private val MEDIA_BROWSER_EXTRA_ENTRY_BYTES = "_Ultrasonic_mb_extra_entry_bytes_" - private val repeatMode: RepeatMode get() = Util.getRepeatMode() @@ -86,7 +74,6 @@ class MediaPlayerService : MediaBrowserServiceCompat() { super.onCreate() updateMediaSession(null, PlayerState.IDLE) - mediaSession!!.isActive = true downloader.onCreate() shufflePlayBuffer.onCreate() @@ -153,92 +140,14 @@ class MediaPlayerService : MediaBrowserServiceCompat() { clientUid: Int, rootHints: Bundle? ): MediaBrowserServiceCompat.BrowserRoot { - - // Returns a root ID that clients can use with onLoadChildren() to retrieve - // the content hierarchy. Note that this root isn't actually displayed. - return MediaBrowserServiceCompat.BrowserRoot(MEDIA_BROWSER_ROOT_ID, null) + return autoMediaBrowser.getRoot(clientPackageName, clientUid, rootHints) } override fun onLoadChildren( parentMediaId: String, result: MediaBrowserServiceCompat.Result> ) { - - val mediaItems: MutableList = mutableListOf() - - if (MEDIA_BROWSER_ROOT_ID == parentMediaId) { - // Build the MediaItem objects for the top level, - // and put them in the mediaItems list... - - var albumList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() - albumList.setTitle("Browse Albums").setMediaId(MEDIA_BROWSER_ALBUM_LIST_ROOT) - mediaItems.add( - MediaBrowserCompat.MediaItem( - albumList.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) - } else if (MEDIA_BROWSER_ALBUM_LIST_ROOT == parentMediaId) { - executorService.execute { - val musicService = getMusicService() - - val musicDirectory: MusicDirectory = musicService.getAlbumList2( - "alphabeticalByName", 10, 0, null - ) - - for (item in musicDirectory.getAllChild()) { - var entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() - entryBuilder - .setTitle(item.title) - .setMediaId(MEDIA_BROWSER_ALBUM_PREFIX + item.id) - mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) - } - result.sendResult(mediaItems) - } - result.detach() - return - } else if (parentMediaId.startsWith(MEDIA_BROWSER_ALBUM_PREFIX)) { - executorService.execute { - val musicService = getMusicService() - val id = parentMediaId.substring(MEDIA_BROWSER_ALBUM_PREFIX.length) - - val albumDirectory = musicService.getAlbum( - id, "", false - ) - for (item in albumDirectory.getAllChild()) { - var extras = Bundle() - - var baos = ByteArrayOutputStream() - var oos = ObjectOutputStream(baos) - oos.writeObject(item) - oos.close() - extras.putByteArray(MEDIA_BROWSER_EXTRA_ENTRY_BYTES, baos.toByteArray()) - - var entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() - entryBuilder.setTitle(item.title).setMediaId(item.id).setExtras(extras) - mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - ) - } - result.sendResult(mediaItems) - } - result.detach() - return - } else { - // Examine the passed parentMediaId to see which submenu we're at, - // and put the children of that menu in the mediaItems list... - } - result.sendResult(mediaItems) + autoMediaBrowser.loadChildren(parentMediaId, result) } @Synchronized @@ -924,15 +833,9 @@ class MediaPlayerService : MediaBrowserServiceCompat() { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) - if (extras!!.containsKey(MEDIA_BROWSER_EXTRA_ENTRY_BYTES)) { - + val item: MusicDirectory.Entry? = autoMediaBrowser.getMusicDirectoryEntry(extras) + if (item != null) { resetPlayback() - - var bytes = extras.getByteArray(MEDIA_BROWSER_EXTRA_ENTRY_BYTES) - var bais = ByteArrayInputStream(bytes) - var ois = ObjectInputStream(bais) - var item: MusicDirectory.Entry = ois.readObject() as MusicDirectory.Entry - val songs: MutableList = mutableListOf() songs.add(item) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt new file mode 100644 index 00000000..c76bcf35 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt @@ -0,0 +1,225 @@ +package org.moire.ultrasonic.util + +import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import org.moire.ultrasonic.api.subsonic.models.AlbumListType +import org.moire.ultrasonic.domain.Genre +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.service.MusicServiceFactory + +class AndroidAutoMediaBrowser() { + + val executorService: ExecutorService = Executors.newFixedThreadPool(4) + var maximumRootChildLimit: Int = 4 + + private val MEDIA_BROWSER_ROOT_ID = "_Ultrasonice_mb_root_" + + private val MEDIA_BROWSER_GENRE_LIST_ROOT = "_Ultrasonic_mb_genre_list_root_" + private val MEDIA_BROWSER_RECENT_LIST_ROOT = "_Ultrasonic_mb_recent_list_root_" + private val MEDIA_BROWSER_ALBUM_LIST_ROOT = "_Ultrasonic_mb_album_list_root_" + private val MEDIA_BROWSER_ARTIST_LIST_ROOT = "_Ultrasonic_mb_rtist_list_root_" + + private val MEDIA_BROWSER_GENRE_PREFIX = "_Ultrasonic_mb_genre_prefix_" + private val MEDIA_BROWSER_RECENT_PREFIX = "_Ultrasonic_mb_recent_prefix_" + private val MEDIA_BROWSER_ALBUM_PREFIX = "_Ultrasonic_mb_album_prefix_" + private val MEDIA_BROWSER_ARTIST_PREFIX = "_Ultrasonic_mb_artist_prefix_" + + private val MEDIA_BROWSER_EXTRA_ENTRY_BYTES = "_Ultrasonic_mb_extra_entry_bytes_" + + fun getRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): MediaBrowserServiceCompat.BrowserRoot { + if (rootHints != null) { + maximumRootChildLimit = rootHints.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, + 4 + ) + } + // opt into the root tabs (because it's gonna be non-optional + // real soon anyway) + val extras = Bundle() + val TABS_OPT_IN_HINT = "android.media.browse.AUTO_TABS_OPT_IN_HINT" + extras.putBoolean(TABS_OPT_IN_HINT, true) + return MediaBrowserServiceCompat.BrowserRoot(MEDIA_BROWSER_ROOT_ID, extras) + } + + fun loadChildren( + parentMediaId: String, + result: MediaBrowserServiceCompat.Result> + ) { + + val mediaItems: MutableList = mutableListOf() + + if (MEDIA_BROWSER_ROOT_ID == parentMediaId) { + // Build the MediaItem objects for the top level, + // and put them in the mediaItems list... + + var genreList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() + genreList.setTitle("Genre").setMediaId(MEDIA_BROWSER_GENRE_LIST_ROOT) + mediaItems.add( + MediaBrowserCompat.MediaItem( + genreList.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + var recentList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() + recentList.setTitle("Recent").setMediaId(MEDIA_BROWSER_RECENT_LIST_ROOT) + mediaItems.add( + MediaBrowserCompat.MediaItem( + recentList.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + var albumList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() + albumList.setTitle("Albums").setMediaId(MEDIA_BROWSER_ALBUM_LIST_ROOT) + mediaItems.add( + MediaBrowserCompat.MediaItem( + albumList.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + var artistList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() + artistList.setTitle("Artists").setMediaId(MEDIA_BROWSER_ARTIST_LIST_ROOT) + mediaItems.add( + MediaBrowserCompat.MediaItem( + artistList.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (MEDIA_BROWSER_GENRE_LIST_ROOT == parentMediaId) { + fetchGenres(result) + return + } else if (MEDIA_BROWSER_RECENT_LIST_ROOT == parentMediaId) { + fetchAlbumList(AlbumListType.RECENT, MEDIA_BROWSER_RECENT_PREFIX, result) + return + } else if (MEDIA_BROWSER_ALBUM_LIST_ROOT == parentMediaId) { + fetchAlbumList(AlbumListType.SORTED_BY_NAME, MEDIA_BROWSER_ALBUM_PREFIX, result) + return + } else if (MEDIA_BROWSER_ARTIST_LIST_ROOT == parentMediaId) { + fetchAlbumList(AlbumListType.SORTED_BY_ARTIST, MEDIA_BROWSER_ARTIST_PREFIX, result) + return + } else if (parentMediaId.startsWith(MEDIA_BROWSER_ALBUM_PREFIX)) { + executorService.execute { + val musicService = MusicServiceFactory.getMusicService() + val id = parentMediaId.substring(MEDIA_BROWSER_ALBUM_PREFIX.length) + + val albumDirectory = musicService.getAlbum( + id, "", false + ) + for (item in albumDirectory.getAllChild()) { + val extras = Bundle() + + // Note that Bundle supports putSerializable and MusicDirectory.Entry + // implements Serializable, but when I try to use it the app crashes + val byteArrayOutputStream = ByteArrayOutputStream() + val objectOutputStream = ObjectOutputStream(byteArrayOutputStream) + objectOutputStream.writeObject(item) + objectOutputStream.close() + extras.putByteArray( + MEDIA_BROWSER_EXTRA_ENTRY_BYTES, + byteArrayOutputStream.toByteArray() + ) + + val entryBuilder: MediaDescriptionCompat.Builder = + MediaDescriptionCompat.Builder() + entryBuilder.setTitle(item.title).setMediaId(item.id).setExtras(extras) + mediaItems.add( + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + result.sendResult(mediaItems) + } + result.detach() + return + } else { + // Examine the passed parentMediaId to see which submenu we're at, + // and put the children of that menu in the mediaItems list... + } + result.sendResult(mediaItems) + } + + fun getMusicDirectoryEntry(bundle: Bundle?): MusicDirectory.Entry? { + if (bundle == null) { + return null + } + + if (!bundle.containsKey(MEDIA_BROWSER_EXTRA_ENTRY_BYTES)) { + return null + } + val bytes = bundle.getByteArray(MEDIA_BROWSER_EXTRA_ENTRY_BYTES) + val byteArrayInputStream = ByteArrayInputStream(bytes) + val objectInputStream = ObjectInputStream(byteArrayInputStream) + return objectInputStream.readObject() as MusicDirectory.Entry + } + + fun fetchAlbumList( + type: AlbumListType, + idPrefix: String, + result: MediaBrowserServiceCompat.Result> + ) { + executorService.execute { + val mediaItems: MutableList = mutableListOf() + val musicService = MusicServiceFactory.getMusicService() + + val musicDirectory: MusicDirectory = musicService.getAlbumList2( + type.toString(), 500, 0, null + ) + + for (item in musicDirectory.getAllChild()) { + var entryBuilder: MediaDescriptionCompat.Builder = + MediaDescriptionCompat.Builder() + entryBuilder + .setTitle(item.title) + .setMediaId(idPrefix + item.id) + mediaItems.add( + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + result.sendResult(mediaItems) + } + result.detach() + } + + fun fetchGenres(result: MediaBrowserServiceCompat.Result>) { + executorService.execute { + val mediaItems: MutableList = mutableListOf() + val musicService = MusicServiceFactory.getMusicService() + + val genreList: List? = musicService.getGenres(false) + if (genreList != null) { + for (genre in genreList) { + var entryBuilder: MediaDescriptionCompat.Builder = + MediaDescriptionCompat.Builder() + entryBuilder + .setTitle(genre.name) + .setMediaId(MEDIA_BROWSER_GENRE_PREFIX + genre.index) + mediaItems.add( + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + } + result.sendResult(mediaItems) + } + result.detach() + } +} From 793c4a6ca73a344a333e123e7d1550b31a7bd124 Mon Sep 17 00:00:00 2001 From: James Wells <14866211+SaintDubious@users.noreply.github.com> Date: Sat, 19 Jun 2021 00:05:19 -0400 Subject: [PATCH 03/14] working version --- .../ultrasonic/fragment/AlbumListModel.kt | 2 +- .../ultrasonic/fragment/ArtistListModel.kt | 4 +- .../ultrasonic/fragment/GenericListModel.kt | 16 +- .../ultrasonic/service/MediaPlayerService.kt | 23 +- .../util/AndroidAutoMediaBrowser.kt | 272 +++++++++++++----- 5 files changed, 223 insertions(+), 94 deletions(-) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt index 5a6e1ba8..d58e7662 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt @@ -18,7 +18,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) { fun getAlbumList( refresh: Boolean, - swipe: SwipeRefreshLayout, + swipe: SwipeRefreshLayout?, args: Bundle ): LiveData> { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt index d58e97ca..63e9e907 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt @@ -30,12 +30,12 @@ import org.moire.ultrasonic.service.MusicService * Provides ViewModel which contains the list of available Artists */ class ArtistListModel(application: Application) : GenericListModel(application) { - private val artists: MutableLiveData> = MutableLiveData() + val artists: MutableLiveData> = MutableLiveData() /** * Retrieves all available Artists in a LiveData */ - fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData> { + fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout?): LiveData> { backgroundLoadFromServer(refresh, swipe) return artists } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt index 468802a2..96008e20 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt @@ -66,20 +66,24 @@ open class GenericListModel(application: Application) : */ fun backgroundLoadFromServer( refresh: Boolean, - swipe: SwipeRefreshLayout, + swipe: SwipeRefreshLayout?, bundle: Bundle = Bundle() ) { viewModelScope.launch { - swipe.isRefreshing = true + if (swipe != null) { + swipe.isRefreshing = true + } loadFromServer(refresh, swipe, bundle) - swipe.isRefreshing = false + if (swipe != null) { + swipe.isRefreshing = false + } } } /** * Calls the load() function with error handling */ - suspend fun loadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout, bundle: Bundle) = + suspend fun loadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout?, bundle: Bundle) = withContext(Dispatchers.IO) { val musicService = MusicServiceFactory.getMusicService() val isOffline = ActiveServerProvider.isOffline() @@ -88,7 +92,9 @@ open class GenericListModel(application: Application) : try { load(isOffline, useId3Tags, musicService, refresh, bundle) } catch (all: Exception) { - handleException(all, swipe.context) + if (swipe != null) { + handleException(all, swipe.context) + } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 291355f3..1972bfaf 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -61,8 +61,8 @@ class MediaPlayerService : MediaBrowserServiceCompat() { private val localMediaPlayer by inject() private val nowPlayingEventDistributor by inject() private val mediaPlayerLifecycleSupport by inject() - private val autoMediaBrowser: AndroidAutoMediaBrowser = AndroidAutoMediaBrowser() + private var autoMediaBrowser: AndroidAutoMediaBrowser? = null private var mediaSession: MediaSessionCompat? = null private var isInForeground = false private var notificationBuilder: NotificationCompat.Builder? = null @@ -73,6 +73,7 @@ class MediaPlayerService : MediaBrowserServiceCompat() { override fun onCreate() { super.onCreate() + autoMediaBrowser = AndroidAutoMediaBrowser(application) updateMediaSession(null, PlayerState.IDLE) downloader.onCreate() @@ -140,14 +141,14 @@ class MediaPlayerService : MediaBrowserServiceCompat() { clientUid: Int, rootHints: Bundle? ): MediaBrowserServiceCompat.BrowserRoot { - return autoMediaBrowser.getRoot(clientPackageName, clientUid, rootHints) + return autoMediaBrowser!!.getRoot(clientPackageName, clientUid, rootHints) } override fun onLoadChildren( parentMediaId: String, result: MediaBrowserServiceCompat.Result> ) { - autoMediaBrowser.loadChildren(parentMediaId, result) + autoMediaBrowser!!.loadChildren(parentMediaId, result) } @Synchronized @@ -832,12 +833,20 @@ class MediaPlayerService : MediaBrowserServiceCompat() { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) - val item: MusicDirectory.Entry? = autoMediaBrowser.getMusicDirectoryEntry(extras) - if (item != null) { + val result = autoMediaBrowser!!.getBundleData(extras) + if (result != null) { + val mediaId = result.first + val directoryList = result.second + resetPlayback() val songs: MutableList = mutableListOf() - songs.add(item) - + var found = false + for (item in directoryList) { + if (found || item.id == mediaId) { + found = true + songs.add(item) + } + } downloader.download(songs, false, false, false, true) getPendingIntentForMediaAction( diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt index c76bcf35..444219a8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt @@ -1,8 +1,11 @@ package org.moire.ultrasonic.util +import android.app.Application import android.os.Bundle import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants import java.io.ByteArrayInputStream @@ -12,28 +15,124 @@ import java.io.ObjectOutputStream import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import org.moire.ultrasonic.api.subsonic.models.AlbumListType -import org.moire.ultrasonic.domain.Genre +import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.fragment.AlbumListModel +import org.moire.ultrasonic.fragment.ArtistListModel import org.moire.ultrasonic.service.MusicServiceFactory -class AndroidAutoMediaBrowser() { +class AndroidAutoMediaBrowser(application: Application) { + + val albumListModel: AlbumListModel = AlbumListModel(application) + val artistListModel: ArtistListModel = ArtistListModel(application) val executorService: ExecutorService = Executors.newFixedThreadPool(4) var maximumRootChildLimit: Int = 4 private val MEDIA_BROWSER_ROOT_ID = "_Ultrasonice_mb_root_" - private val MEDIA_BROWSER_GENRE_LIST_ROOT = "_Ultrasonic_mb_genre_list_root_" private val MEDIA_BROWSER_RECENT_LIST_ROOT = "_Ultrasonic_mb_recent_list_root_" private val MEDIA_BROWSER_ALBUM_LIST_ROOT = "_Ultrasonic_mb_album_list_root_" private val MEDIA_BROWSER_ARTIST_LIST_ROOT = "_Ultrasonic_mb_rtist_list_root_" - private val MEDIA_BROWSER_GENRE_PREFIX = "_Ultrasonic_mb_genre_prefix_" private val MEDIA_BROWSER_RECENT_PREFIX = "_Ultrasonic_mb_recent_prefix_" private val MEDIA_BROWSER_ALBUM_PREFIX = "_Ultrasonic_mb_album_prefix_" private val MEDIA_BROWSER_ARTIST_PREFIX = "_Ultrasonic_mb_artist_prefix_" - private val MEDIA_BROWSER_EXTRA_ENTRY_BYTES = "_Ultrasonic_mb_extra_entry_bytes_" + private val MEDIA_BROWSER_EXTRA_ALBUM_LIST = "_Ultrasonic_mb_extra_album_list_" + private val MEDIA_BROWSER_EXTRA_MEDIA_ID = "_Ultrasonic_mb_extra_media_id_" + + class AlbumListObserver( + val idPrefix: String, + val result: MediaBrowserServiceCompat.Result>, + data: LiveData> + ) : + Observer> { + + private var liveData: LiveData>? = null + + init { + // Order is very important here. When observerForever is called onChanged + // will immediately be called with any past data updates. We don't care + // about those. So by having it called *before* liveData is set will + // signal to onChanged to ignore the first input + data.observeForever(this) + liveData = data + } + + override fun onChanged(albumList: List?) { + if (liveData == null) { + // See comment in the initializer + return + } + liveData!!.removeObserver(this) + if (albumList == null) { + return + } + val mediaItems: MutableList = mutableListOf() + for (item in albumList) { + val entryBuilder: MediaDescriptionCompat.Builder = + MediaDescriptionCompat.Builder() + entryBuilder + .setTitle(item.title) + .setMediaId(idPrefix + item.id) + mediaItems.add( + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + + result.sendResult(mediaItems) + } + } + + class ArtistListObserver( + val idPrefix: String, + val result: MediaBrowserServiceCompat.Result>, + data: LiveData> + ) : + Observer> { + + private var liveData: LiveData>? = null + + init { + // Order is very important here. When observerForever is called onChanged + // will immediately be called with any past data updates. We don't care + // about those. So by having it called *before* liveData is set will + // signal to onChanged to ignore the first input + data.observeForever(this) + liveData = data + } + + override fun onChanged(artistList: List?) { + if (liveData == null) { + // See comment in the initializer + return + } + liveData!!.removeObserver(this) + if (artistList == null) { + return + } + val mediaItems: MutableList = mutableListOf() + for (item in artistList) { + val entryBuilder: MediaDescriptionCompat.Builder = + MediaDescriptionCompat.Builder() + entryBuilder + .setTitle(item.name) + .setMediaId(idPrefix + item.id) + mediaItems.add( + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + + result.sendResult(mediaItems) + } + } fun getRoot( clientPackageName: String, @@ -65,14 +164,6 @@ class AndroidAutoMediaBrowser() { // Build the MediaItem objects for the top level, // and put them in the mediaItems list... - var genreList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() - genreList.setTitle("Genre").setMediaId(MEDIA_BROWSER_GENRE_LIST_ROOT) - mediaItems.add( - MediaBrowserCompat.MediaItem( - genreList.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) var recentList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() recentList.setTitle("Recent").setMediaId(MEDIA_BROWSER_RECENT_LIST_ROOT) mediaItems.add( @@ -97,9 +188,6 @@ class AndroidAutoMediaBrowser() { MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) ) - } else if (MEDIA_BROWSER_GENRE_LIST_ROOT == parentMediaId) { - fetchGenres(result) - return } else if (MEDIA_BROWSER_RECENT_LIST_ROOT == parentMediaId) { fetchAlbumList(AlbumListType.RECENT, MEDIA_BROWSER_RECENT_PREFIX, result) return @@ -107,43 +195,19 @@ class AndroidAutoMediaBrowser() { fetchAlbumList(AlbumListType.SORTED_BY_NAME, MEDIA_BROWSER_ALBUM_PREFIX, result) return } else if (MEDIA_BROWSER_ARTIST_LIST_ROOT == parentMediaId) { - fetchAlbumList(AlbumListType.SORTED_BY_ARTIST, MEDIA_BROWSER_ARTIST_PREFIX, result) + fetchArtistList(MEDIA_BROWSER_ARTIST_PREFIX, result) + return + } else if (parentMediaId.startsWith(MEDIA_BROWSER_RECENT_PREFIX)) { + fetchTrackList(parentMediaId.substring(MEDIA_BROWSER_RECENT_PREFIX.length), result) return } else if (parentMediaId.startsWith(MEDIA_BROWSER_ALBUM_PREFIX)) { - executorService.execute { - val musicService = MusicServiceFactory.getMusicService() - val id = parentMediaId.substring(MEDIA_BROWSER_ALBUM_PREFIX.length) - - val albumDirectory = musicService.getAlbum( - id, "", false - ) - for (item in albumDirectory.getAllChild()) { - val extras = Bundle() - - // Note that Bundle supports putSerializable and MusicDirectory.Entry - // implements Serializable, but when I try to use it the app crashes - val byteArrayOutputStream = ByteArrayOutputStream() - val objectOutputStream = ObjectOutputStream(byteArrayOutputStream) - objectOutputStream.writeObject(item) - objectOutputStream.close() - extras.putByteArray( - MEDIA_BROWSER_EXTRA_ENTRY_BYTES, - byteArrayOutputStream.toByteArray() - ) - - val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() - entryBuilder.setTitle(item.title).setMediaId(item.id).setExtras(extras) - mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - ) - } - result.sendResult(mediaItems) - } - result.detach() + fetchTrackList(parentMediaId.substring(MEDIA_BROWSER_ALBUM_PREFIX.length), result) + return + } else if (parentMediaId.startsWith(MEDIA_BROWSER_ARTIST_PREFIX)) { + fetchArtistAlbumList( + parentMediaId.substring(MEDIA_BROWSER_ARTIST_PREFIX.length), + result + ) return } else { // Examine the passed parentMediaId to see which submenu we're at, @@ -152,39 +216,67 @@ class AndroidAutoMediaBrowser() { result.sendResult(mediaItems) } - fun getMusicDirectoryEntry(bundle: Bundle?): MusicDirectory.Entry? { + fun getBundleData(bundle: Bundle?): Pair>? { if (bundle == null) { return null } - if (!bundle.containsKey(MEDIA_BROWSER_EXTRA_ENTRY_BYTES)) { + if (!bundle.containsKey(MEDIA_BROWSER_EXTRA_ALBUM_LIST) || + !bundle.containsKey(MEDIA_BROWSER_EXTRA_MEDIA_ID) + ) { return null } - val bytes = bundle.getByteArray(MEDIA_BROWSER_EXTRA_ENTRY_BYTES) + val bytes = bundle.getByteArray(MEDIA_BROWSER_EXTRA_ALBUM_LIST) val byteArrayInputStream = ByteArrayInputStream(bytes) val objectInputStream = ObjectInputStream(byteArrayInputStream) - return objectInputStream.readObject() as MusicDirectory.Entry + return Pair( + bundle.getString(MEDIA_BROWSER_EXTRA_MEDIA_ID), + objectInputStream.readObject() as List + ) } - fun fetchAlbumList( + private fun fetchAlbumList( type: AlbumListType, idPrefix: String, result: MediaBrowserServiceCompat.Result> + ) { + AlbumListObserver( + idPrefix, result, + albumListModel.albumList + ) + + val args: Bundle = Bundle() + args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type.toString()) + albumListModel.getAlbumList(false, null, args) + result.detach() + } + + private fun fetchArtistList( + idPrefix: String, + result: MediaBrowserServiceCompat.Result> + ) { + ArtistListObserver(idPrefix, result, artistListModel.artists) + + artistListModel.getItems(false, null) + result.detach() + } + + private fun fetchArtistAlbumList( + id: String, + result: MediaBrowserServiceCompat.Result> ) { executorService.execute { - val mediaItems: MutableList = mutableListOf() val musicService = MusicServiceFactory.getMusicService() - val musicDirectory: MusicDirectory = musicService.getAlbumList2( - type.toString(), 500, 0, null + val musicDirectory = musicService.getMusicDirectory( + id, "", false ) + val mediaItems: MutableList = mutableListOf() for (item in musicDirectory.getAllChild()) { - var entryBuilder: MediaDescriptionCompat.Builder = + val entryBuilder: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() - entryBuilder - .setTitle(item.title) - .setMediaId(idPrefix + item.id) + entryBuilder.setTitle(item.title).setMediaId(MEDIA_BROWSER_ALBUM_PREFIX + item.id) mediaItems.add( MediaBrowserCompat.MediaItem( entryBuilder.build(), @@ -197,26 +289,48 @@ class AndroidAutoMediaBrowser() { result.detach() } - fun fetchGenres(result: MediaBrowserServiceCompat.Result>) { + private fun fetchTrackList( + id: String, + result: MediaBrowserServiceCompat.Result> + ) { executorService.execute { - val mediaItems: MutableList = mutableListOf() val musicService = MusicServiceFactory.getMusicService() - val genreList: List? = musicService.getGenres(false) - if (genreList != null) { - for (genre in genreList) { - var entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() - entryBuilder - .setTitle(genre.name) - .setMediaId(MEDIA_BROWSER_GENRE_PREFIX + genre.index) - mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + val albumDirectory = musicService.getAlbum( + id, "", false + ) + + // The idea here is that we want to attach the full album list to every song, + // as well as the id of the specific song. This way if someone chooses to play a song + // we can add the song and all subsequent songs in the album + val byteArrayOutputStream = ByteArrayOutputStream() + val objectOutputStream = ObjectOutputStream(byteArrayOutputStream) + objectOutputStream.writeObject(albumDirectory.getAllChild()) + objectOutputStream.close() + val songList = byteArrayOutputStream.toByteArray() + val mediaItems: MutableList = mutableListOf() + + for (item in albumDirectory.getAllChild()) { + val extras = Bundle() + + extras.putByteArray( + MEDIA_BROWSER_EXTRA_ALBUM_LIST, + songList + ) + extras.putString( + MEDIA_BROWSER_EXTRA_MEDIA_ID, + item.id + ) + + val entryBuilder: MediaDescriptionCompat.Builder = + MediaDescriptionCompat.Builder() + entryBuilder.setTitle(item.title).setMediaId(item.id).setExtras(extras) + mediaItems.add( + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE ) - } + ) } result.sendResult(mediaItems) } From db0669098c1294bf57b46f9c6cd51a09fb9fc2e3 Mon Sep 17 00:00:00 2001 From: James Wells <14866211+SaintDubious@users.noreply.github.com> Date: Sun, 4 Jul 2021 16:42:18 -0400 Subject: [PATCH 04/14] Another Attempt at Auto --- ultrasonic/src/main/AndroidManifest.xml | 6 + .../AutoMediaPlayerService.kt} | 212 +++++++++--------- .../ultrasonic/service/MediaPlayerService.kt | 51 ++--- 3 files changed, 130 insertions(+), 139 deletions(-) rename ultrasonic/src/main/kotlin/org/moire/ultrasonic/{util/AndroidAutoMediaBrowser.kt => service/AutoMediaPlayerService.kt} (62%) diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 7aa13db8..f3a0d39c 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -56,6 +56,12 @@ + + + diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt similarity index 62% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt index d951d514..2eb7fca5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/AndroidAutoMediaBrowser.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt @@ -1,6 +1,5 @@ -package org.moire.ultrasonic.util +package org.moire.ultrasonic.service -import android.app.Application import android.os.Bundle import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat @@ -8,24 +7,26 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants +import org.moire.ultrasonic.api.subsonic.models.AlbumListType +import org.moire.ultrasonic.domain.ArtistOrIndex +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.fragment.AlbumListModel +import org.moire.ultrasonic.fragment.ArtistListModel +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.Pair import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.ObjectInputStream import java.io.ObjectOutputStream import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import org.moire.ultrasonic.api.subsonic.models.AlbumListType -import org.moire.ultrasonic.domain.Artist -import org.moire.ultrasonic.domain.ArtistOrIndex -import org.moire.ultrasonic.domain.MusicDirectory -import org.moire.ultrasonic.fragment.AlbumListModel -import org.moire.ultrasonic.fragment.ArtistListModel -import org.moire.ultrasonic.service.MusicServiceFactory -class AndroidAutoMediaBrowser(application: Application) { +class AutoMediaPlayerService: MediaBrowserServiceCompat() { - val albumListModel: AlbumListModel = AlbumListModel(application) - val artistListModel: ArtistListModel = ArtistListModel(application) + val mediaPlayerService : MediaPlayerService = MediaPlayerService() + var albumListModel: AlbumListModel? = null + var artistListModel: ArtistListModel? = null val executorService: ExecutorService = Executors.newFixedThreadPool(4) var maximumRootChildLimit: Int = 4 @@ -44,11 +45,11 @@ class AndroidAutoMediaBrowser(application: Application) { private val MEDIA_BROWSER_EXTRA_MEDIA_ID = "_Ultrasonic_mb_extra_media_id_" class AlbumListObserver( - val idPrefix: String, - val result: MediaBrowserServiceCompat.Result>, - data: LiveData> + val idPrefix: String, + val result: MediaBrowserServiceCompat.Result>, + data: LiveData> ) : - Observer> { + Observer> { private var liveData: LiveData>? = null @@ -73,15 +74,15 @@ class AndroidAutoMediaBrowser(application: Application) { val mediaItems: MutableList = mutableListOf() for (item in albumList) { val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() + MediaDescriptionCompat.Builder() entryBuilder - .setTitle(item.title) - .setMediaId(idPrefix + item.id) + .setTitle(item.title) + .setMediaId(idPrefix + item.id) mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) ) } @@ -90,11 +91,11 @@ class AndroidAutoMediaBrowser(application: Application) { } class ArtistListObserver( - val idPrefix: String, - val result: MediaBrowserServiceCompat.Result>, - data: LiveData> + val idPrefix: String, + val result: MediaBrowserServiceCompat.Result>, + data: LiveData> ) : - Observer> { + Observer> { private var liveData: LiveData>? = null @@ -119,15 +120,15 @@ class AndroidAutoMediaBrowser(application: Application) { val mediaItems: MutableList = mutableListOf() for (item in artistList) { val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() + MediaDescriptionCompat.Builder() entryBuilder - .setTitle(item.name) - .setMediaId(idPrefix + item.id) + .setTitle(item.name) + .setMediaId(idPrefix + item.id) mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) ) } @@ -135,15 +136,21 @@ class AndroidAutoMediaBrowser(application: Application) { } } - fun getRoot( - clientPackageName: String, - clientUid: Int, - rootHints: Bundle? - ): MediaBrowserServiceCompat.BrowserRoot { + override fun onCreate() { + super.onCreate() + + albumListModel = AlbumListModel(application) + artistListModel = ArtistListModel(application) + + mediaPlayerService.onCreate() + mediaPlayerService.updateMediaSession(null, PlayerState.IDLE) + } + + override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { if (rootHints != null) { maximumRootChildLimit = rootHints.getInt( - MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, - 4 + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, + 4 ) } // opt into the root tabs (because it's gonna be non-optional @@ -154,60 +161,56 @@ class AndroidAutoMediaBrowser(application: Application) { return MediaBrowserServiceCompat.BrowserRoot(MEDIA_BROWSER_ROOT_ID, extras) } - fun loadChildren( - parentMediaId: String, - result: MediaBrowserServiceCompat.Result> - ) { - + override fun onLoadChildren(parentId: String, result: Result>) { val mediaItems: MutableList = mutableListOf() - if (MEDIA_BROWSER_ROOT_ID == parentMediaId) { + if (MEDIA_BROWSER_ROOT_ID == parentId) { // Build the MediaItem objects for the top level, // and put them in the mediaItems list... var recentList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() recentList.setTitle("Recent").setMediaId(MEDIA_BROWSER_RECENT_LIST_ROOT) mediaItems.add( - MediaBrowserCompat.MediaItem( - recentList.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + MediaBrowserCompat.MediaItem( + recentList.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) ) var albumList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() albumList.setTitle("Albums").setMediaId(MEDIA_BROWSER_ALBUM_LIST_ROOT) mediaItems.add( - MediaBrowserCompat.MediaItem( - albumList.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + MediaBrowserCompat.MediaItem( + albumList.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) ) var artistList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() artistList.setTitle("Artists").setMediaId(MEDIA_BROWSER_ARTIST_LIST_ROOT) mediaItems.add( - MediaBrowserCompat.MediaItem( - artistList.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + MediaBrowserCompat.MediaItem( + artistList.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) ) - } else if (MEDIA_BROWSER_RECENT_LIST_ROOT == parentMediaId) { + } else if (MEDIA_BROWSER_RECENT_LIST_ROOT == parentId) { fetchAlbumList(AlbumListType.RECENT, MEDIA_BROWSER_RECENT_PREFIX, result) return - } else if (MEDIA_BROWSER_ALBUM_LIST_ROOT == parentMediaId) { + } else if (MEDIA_BROWSER_ALBUM_LIST_ROOT == parentId) { fetchAlbumList(AlbumListType.SORTED_BY_NAME, MEDIA_BROWSER_ALBUM_PREFIX, result) return - } else if (MEDIA_BROWSER_ARTIST_LIST_ROOT == parentMediaId) { + } else if (MEDIA_BROWSER_ARTIST_LIST_ROOT == parentId) { fetchArtistList(MEDIA_BROWSER_ARTIST_PREFIX, result) return - } else if (parentMediaId.startsWith(MEDIA_BROWSER_RECENT_PREFIX)) { - fetchTrackList(parentMediaId.substring(MEDIA_BROWSER_RECENT_PREFIX.length), result) + } else if (parentId.startsWith(MEDIA_BROWSER_RECENT_PREFIX)) { + fetchTrackList(parentId.substring(MEDIA_BROWSER_RECENT_PREFIX.length), result) return - } else if (parentMediaId.startsWith(MEDIA_BROWSER_ALBUM_PREFIX)) { - fetchTrackList(parentMediaId.substring(MEDIA_BROWSER_ALBUM_PREFIX.length), result) + } else if (parentId.startsWith(MEDIA_BROWSER_ALBUM_PREFIX)) { + fetchTrackList(parentId.substring(MEDIA_BROWSER_ALBUM_PREFIX.length), result) return - } else if (parentMediaId.startsWith(MEDIA_BROWSER_ARTIST_PREFIX)) { + } else if (parentId.startsWith(MEDIA_BROWSER_ARTIST_PREFIX)) { fetchArtistAlbumList( - parentMediaId.substring(MEDIA_BROWSER_ARTIST_PREFIX.length), - result + parentId.substring(MEDIA_BROWSER_ARTIST_PREFIX.length), + result ) return } else { @@ -217,13 +220,14 @@ class AndroidAutoMediaBrowser(application: Application) { result.sendResult(mediaItems) } + fun getBundleData(bundle: Bundle?): Pair>? { if (bundle == null) { return null } if (!bundle.containsKey(MEDIA_BROWSER_EXTRA_ALBUM_LIST) || - !bundle.containsKey(MEDIA_BROWSER_EXTRA_MEDIA_ID) + !bundle.containsKey(MEDIA_BROWSER_EXTRA_MEDIA_ID) ) { return null } @@ -231,58 +235,58 @@ class AndroidAutoMediaBrowser(application: Application) { val byteArrayInputStream = ByteArrayInputStream(bytes) val objectInputStream = ObjectInputStream(byteArrayInputStream) return Pair( - bundle.getString(MEDIA_BROWSER_EXTRA_MEDIA_ID), - objectInputStream.readObject() as List + bundle.getString(MEDIA_BROWSER_EXTRA_MEDIA_ID), + objectInputStream.readObject() as List ) } private fun fetchAlbumList( - type: AlbumListType, - idPrefix: String, - result: MediaBrowserServiceCompat.Result> + type: AlbumListType, + idPrefix: String, + result: MediaBrowserServiceCompat.Result> ) { - AlbumListObserver( - idPrefix, result, - albumListModel.albumList + AutoMediaPlayerService.AlbumListObserver( + idPrefix, result, + albumListModel!!.albumList ) val args: Bundle = Bundle() args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type.toString()) - albumListModel.getAlbumList(false, null, args) + albumListModel!!.getAlbumList(false, null, args) result.detach() } private fun fetchArtistList( - idPrefix: String, - result: MediaBrowserServiceCompat.Result> + idPrefix: String, + result: MediaBrowserServiceCompat.Result> ) { - ArtistListObserver(idPrefix, result, artistListModel.artists) + AutoMediaPlayerService.ArtistListObserver(idPrefix, result, artistListModel!!.artists) - artistListModel.getItems(false, null) + artistListModel!!.getItems(false, null) result.detach() } private fun fetchArtistAlbumList( - id: String, - result: MediaBrowserServiceCompat.Result> + id: String, + result: MediaBrowserServiceCompat.Result> ) { executorService.execute { val musicService = MusicServiceFactory.getMusicService() val musicDirectory = musicService.getMusicDirectory( - id, "", false + id, "", false ) val mediaItems: MutableList = mutableListOf() for (item in musicDirectory.getAllChild()) { val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() + MediaDescriptionCompat.Builder() entryBuilder.setTitle(item.title).setMediaId(MEDIA_BROWSER_ALBUM_PREFIX + item.id) mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) ) } result.sendResult(mediaItems) @@ -291,14 +295,14 @@ class AndroidAutoMediaBrowser(application: Application) { } private fun fetchTrackList( - id: String, - result: MediaBrowserServiceCompat.Result> + id: String, + result: MediaBrowserServiceCompat.Result> ) { executorService.execute { val musicService = MusicServiceFactory.getMusicService() val albumDirectory = musicService.getAlbum( - id, "", false + id, "", false ) // The idea here is that we want to attach the full album list to every song, @@ -315,26 +319,26 @@ class AndroidAutoMediaBrowser(application: Application) { val extras = Bundle() extras.putByteArray( - MEDIA_BROWSER_EXTRA_ALBUM_LIST, - songList + MEDIA_BROWSER_EXTRA_ALBUM_LIST, + songList ) extras.putString( - MEDIA_BROWSER_EXTRA_MEDIA_ID, - item.id + MEDIA_BROWSER_EXTRA_MEDIA_ID, + item.id ) val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() + MediaDescriptionCompat.Builder() entryBuilder.setTitle(item.title).setMediaId(item.id).setExtras(extras) mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) + MediaBrowserCompat.MediaItem( + entryBuilder.build(), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) ) } result.sendResult(mediaItems) } result.detach() } -} +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 1972bfaf..101a8f5d 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -7,15 +7,13 @@ package org.moire.ultrasonic.service -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent +import android.app.* import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle +import android.os.IBinder import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat @@ -39,11 +37,7 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.AndroidAutoMediaBrowser -import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.NowPlayingEventDistributor -import org.moire.ultrasonic.util.ShufflePlayBuffer -import org.moire.ultrasonic.util.Util +import org.moire.ultrasonic.util.* import timber.log.Timber /** @@ -51,7 +45,8 @@ import timber.log.Timber * while the rest of the Ultrasonic App is in the background. */ @Suppress("LargeClass") -class MediaPlayerService : MediaBrowserServiceCompat() { +class MediaPlayerService : Service() { + private val binder: IBinder = SimpleServiceBinder(this) private val scrobbler = Scrobbler() private val jukeboxMediaPlayer by inject() @@ -62,20 +57,21 @@ class MediaPlayerService : MediaBrowserServiceCompat() { private val nowPlayingEventDistributor by inject() private val mediaPlayerLifecycleSupport by inject() - private var autoMediaBrowser: AndroidAutoMediaBrowser? = null private var mediaSession: MediaSessionCompat? = null + private var mediaSessionToken: MediaSessionCompat.Token? = null private var isInForeground = false private var notificationBuilder: NotificationCompat.Builder? = null private val repeatMode: RepeatMode get() = Util.getRepeatMode() + override fun onBind(intent: Intent): IBinder { + return binder + } + override fun onCreate() { super.onCreate() - autoMediaBrowser = AndroidAutoMediaBrowser(application) - updateMediaSession(null, PlayerState.IDLE) - downloader.onCreate() shufflePlayBuffer.onCreate() localMediaPlayer.init() @@ -136,21 +132,6 @@ class MediaPlayerService : MediaBrowserServiceCompat() { } } - override fun onGetRoot( - clientPackageName: String, - clientUid: Int, - rootHints: Bundle? - ): MediaBrowserServiceCompat.BrowserRoot { - return autoMediaBrowser!!.getRoot(clientPackageName, clientUid, rootHints) - } - - override fun onLoadChildren( - parentMediaId: String, - result: MediaBrowserServiceCompat.Result> - ) { - autoMediaBrowser!!.loadChildren(parentMediaId, result) - } - @Synchronized fun seekTo(position: Int) { if (jukeboxMediaPlayer.isEnabled) { @@ -483,7 +464,7 @@ class MediaPlayerService : MediaBrowserServiceCompat() { } } - private fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState) { + fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState) { Timber.d("Updating the MediaSession") if (mediaSession == null) initMediaSessions() @@ -645,8 +626,8 @@ class MediaPlayerService : MediaBrowserServiceCompat() { // Use the Media Style, to enable native Android support for playback notification val style = androidx.media.app.NotificationCompat.MediaStyle() - if (getSessionToken() != null) { - style.setMediaSession(getSessionToken()) + if (mediaSessionToken != null) { + style.setMediaSession(mediaSessionToken) } // Clear old actions @@ -813,7 +794,7 @@ class MediaPlayerService : MediaBrowserServiceCompat() { Timber.w("Creating media session") mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") - setSessionToken(mediaSession!!.sessionToken) + mediaSessionToken = mediaSession!!.sessionToken updateMediaButtonReceiver() @@ -829,7 +810,7 @@ class MediaPlayerService : MediaBrowserServiceCompat() { Timber.v("Media Session Callback: onPlay") } - +/* override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) @@ -857,7 +838,7 @@ class MediaPlayerService : MediaBrowserServiceCompat() { } Timber.v("Media Session Callback: onPlayFromMediaId") } - +*/ override fun onPause() { super.onPause() getPendingIntentForMediaAction( From 83c6b76d0a40f7a61f5e9bc6da04e0980d6fe3ea Mon Sep 17 00:00:00 2001 From: Nite Date: Mon, 12 Jul 2021 16:13:34 +0200 Subject: [PATCH 05/14] Updated Android Auto to use MediaPlayerService separately Added some missing features found in the docs --- ultrasonic/src/main/AndroidManifest.xml | 2 +- .../moire/ultrasonic/di/ApplicationModule.kt | 2 + .../service/AutoMediaBrowserService.kt | 186 ++++++++++++++++++ .../ultrasonic/service/LocalMediaPlayer.kt | 2 +- .../ultrasonic/service/MediaPlayerService.kt | 85 ++++---- ...ediaSessionTokenCreatedEventDistributor.kt | 49 +++++ .../MediaSessionTokenCreatedEventListener.kt | 13 ++ 7 files changed, 304 insertions(+), 35 deletions(-) create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index f3a0d39c..f9abed6b 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -60,7 +60,7 @@ diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index e78404bf..47b08cde 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -4,6 +4,7 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider +import org.moire.ultrasonic.util.MediaSessionEventDistributor import org.moire.ultrasonic.util.NowPlayingEventDistributor import org.moire.ultrasonic.util.PermissionUtil import org.moire.ultrasonic.util.ThemeChangedEventDistributor @@ -17,4 +18,5 @@ val applicationModule = module { single { PermissionUtil(androidContext()) } single { NowPlayingEventDistributor() } single { ThemeChangedEventDistributor() } + single { MediaSessionEventDistributor() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt new file mode 100644 index 00000000..6afcb29a --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -0,0 +1,186 @@ +package org.moire.ultrasonic.service + +import android.os.Bundle +import android.os.Handler +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaSessionCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import org.koin.android.ext.android.inject +import org.moire.ultrasonic.util.MediaSessionEventDistributor +import org.moire.ultrasonic.util.MediaSessionEventListener +import timber.log.Timber + + +const val MY_MEDIA_ROOT_ID = "MY_MEDIA_ROOT_ID" +const val MY_MEDIA_ALBUM_ID = "MY_MEDIA_ALBUM_ID" +const val MY_MEDIA_ARTIST_ID = "MY_MEDIA_ARTIST_ID" +const val MY_MEDIA_ALBUM_ITEM = "MY_MEDIA_ALBUM_ITEM" +const val MY_MEDIA_LIBRARY_ID = "MY_MEDIA_LIBRARY_ID" +const val MY_MEDIA_PLAYLIST_ID = "MY_MEDIA_PLAYLIST_ID" + +class AutoMediaBrowserService : MediaBrowserServiceCompat() { + + private lateinit var mediaSessionEventListener: MediaSessionEventListener + private val mediaSessionEventDistributor: MediaSessionEventDistributor by inject() + private val lifecycleSupport: MediaPlayerLifecycleSupport by inject() + + override fun onCreate() { + super.onCreate() + + mediaSessionEventListener = object : MediaSessionEventListener { + override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { + Timber.i("AutoMediaBrowserService onMediaSessionTokenCreated called") + if (sessionToken == null) { + Timber.i("AutoMediaBrowserService onMediaSessionTokenCreated session token was null, set it to %s", token.toString()) + sessionToken = token + } + } + + override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) { + // TODO implement + Timber.i("AutoMediaBrowserService onPlayFromMediaIdRequested called") + } + + override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) { + // TODO implement + Timber.i("AutoMediaBrowserService onPlayFromSearchRequested called") + } + } + + mediaSessionEventDistributor.subscribe(mediaSessionEventListener) + + val handler = Handler() + handler.postDelayed({ + Timber.i("AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService...") + // TODO it seems Android Auto handles autostart, but we must check that + lifecycleSupport.onCreate() + MediaPlayerService.getInstance() + }, 100) + + Timber.i("AutoMediaBrowserService onCreate called") + } + + override fun onDestroy() { + super.onDestroy() + mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) + Timber.i("AutoMediaBrowserService onDestroy called") + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + Timber.i("AutoMediaBrowserService onGetRoot called") + + // TODO: The number of horizontal items available on the Andoid Auto screen. Check and handle. + val maximumRootChildLimit = rootHints!!.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, + 4 + ) + + // TODO: The type of the horizontal items children on the Android Auto screen. Check and handle. + val supportedRootChildFlags = rootHints!!.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + + val extras = Bundle() + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + + return BrowserRoot(MY_MEDIA_ROOT_ID, extras) + } + + override fun onLoadChildren( + parentId: String, + result: Result> + ) { + Timber.i("AutoMediaBrowserService onLoadChildren called") + + if (parentId == MY_MEDIA_ROOT_ID) { + return getRootItems(result) + } else { + return getAlbumLists(result) + } + } + + override fun onSearch( + query: String, + extras: Bundle?, + result: Result> + ) { + super.onSearch(query, extras, result) + } + + private fun getRootItems(result: Result>) { + val mediaItems: MutableList = ArrayList() + + // TODO implement this with proper texts, icons, etc + mediaItems.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Library") + .setMediaId(MY_MEDIA_LIBRARY_ID) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + mediaItems.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Artists") + .setMediaId(MY_MEDIA_ARTIST_ID) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + mediaItems.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Albums") + .setMediaId(MY_MEDIA_ALBUM_ID) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + mediaItems.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Playlists") + .setMediaId(MY_MEDIA_PLAYLIST_ID) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + result.sendResult(mediaItems) + } + + private fun getAlbumLists(result: Result>) { + val mediaItems: MutableList = ArrayList() + + val description = MediaDescriptionCompat.Builder() + .setTitle("Test") + .setMediaId(MY_MEDIA_ALBUM_ITEM + 1) + .build() + + mediaItems.add( + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + + result.sendResult(mediaItems) + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index 1de7bac3..005967b9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -171,7 +171,7 @@ class LocalMediaPlayer( val mainHandler = Handler(context.mainLooper) val myRunnable = Runnable { - onPlayerStateChanged!!(playerState, currentPlaying) + onPlayerStateChanged?.invoke(playerState, currentPlaying) } mainHandler.post(myRunnable) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 101a8f5d..493591fc 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -14,14 +14,13 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.os.IBinder -import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.view.KeyEvent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.media.MediaBrowserServiceCompat import kotlin.collections.ArrayList import org.koin.android.ext.android.inject import org.moire.ultrasonic.R @@ -37,7 +36,12 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService -import org.moire.ultrasonic.util.* +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.MediaSessionEventDistributor +import org.moire.ultrasonic.util.NowPlayingEventDistributor +import org.moire.ultrasonic.util.ShufflePlayBuffer +import org.moire.ultrasonic.util.SimpleServiceBinder +import org.moire.ultrasonic.util.Util import timber.log.Timber /** @@ -56,9 +60,10 @@ class MediaPlayerService : Service() { private val localMediaPlayer by inject() private val nowPlayingEventDistributor by inject() private val mediaPlayerLifecycleSupport by inject() + private val mediaSessionEventDistributor: MediaSessionEventDistributor by inject() private var mediaSession: MediaSessionCompat? = null - private var mediaSessionToken: MediaSessionCompat.Token? = null + var mediaSessionToken: MediaSessionCompat.Token? = null private var isInForeground = false private var notificationBuilder: NotificationCompat.Builder? = null @@ -91,6 +96,12 @@ class MediaPlayerService : Service() { localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } + // TODO maybe MediaSession must be in an independent class after all... + // It seems this must be initialized in the stopped state too, e.g. for Android Auto. + // So it is best to init this early. + initMediaSessions() + updateMediaSession(null, PlayerState.IDLE) + // Create Notification Channel createNotificationChannel() @@ -113,6 +124,8 @@ class MediaPlayerService : Service() { localMediaPlayer.release() downloader.stop() shufflePlayBuffer.onDestroy() + + mediaSessionEventDistributor.ReleaseCachedMediaSessionToken() mediaSession?.release() mediaSession = null } catch (ignored: Throwable) { @@ -467,7 +480,7 @@ class MediaPlayerService : Service() { fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState) { Timber.d("Updating the MediaSession") - if (mediaSession == null) initMediaSessions() + val playbackState = PlaybackStateCompat.Builder() // Set Metadata val metadata = MediaMetadataCompat.Builder() @@ -483,6 +496,9 @@ class MediaPlayerService : Service() { metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album) metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title) metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover) + + playbackState.setActiveQueueItemId(downloader.currentPlayingIndex.toLong()) + } catch (e: Exception) { Timber.e(e, "Error setting the metadata") } @@ -492,13 +508,15 @@ class MediaPlayerService : Service() { mediaSession!!.setMetadata(metadata.build()) // Create playback State - val playbackState = PlaybackStateCompat.Builder() val state: Int val isActive: Boolean var actions: Long = PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or + PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM // Map our playerState to native PlaybackState // TODO: Synchronize these APIs @@ -534,7 +552,8 @@ class MediaPlayerService : Service() { } } - playbackState.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f) + // TODO playerPosition should be updated more frequently (currently this function is called only when the playing track changes) + playbackState.setState(state, playerPosition.toLong(), 1.0f) // Set actions playbackState.setActions(actions) @@ -545,6 +564,14 @@ class MediaPlayerService : Service() { // Set Active state mediaSession!!.isActive = isActive + // TODO Implement Now Playing queue handling properly + mediaSession!!.setQueueTitle("Now Playing") + mediaSession!!.setQueue(downloader.downloadList.mapIndexed { id, file -> + MediaSessionCompat.QueueItem(MediaDescriptionCompat.Builder() + .setTitle(file.song.title) + .build(), id.toLong()) + }) + Timber.d("Setting the MediaSession to active = %s", isActive) } @@ -795,6 +822,7 @@ class MediaPlayerService : Service() { mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") mediaSessionToken = mediaSession!!.sessionToken + mediaSessionEventDistributor.RaiseMediaSessionTokenCreatedEvent(mediaSessionToken!!) updateMediaButtonReceiver() @@ -810,35 +838,21 @@ class MediaPlayerService : Service() { Timber.v("Media Session Callback: onPlay") } -/* + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) - val result = autoMediaBrowser!!.getBundleData(extras) - if (result != null) { - val mediaId = result.first - val directoryList = result.second - - resetPlayback() - val songs: MutableList = mutableListOf() - var found = false - for (item in directoryList) { - if (found || item.id == mediaId) { - found = true - songs.add(item) - } - } - downloader.download(songs, false, false, false, true) - - getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PLAY, - keycode - ).send() - } - Timber.v("Media Session Callback: onPlayFromMediaId") + Timber.d("Media Session Callback: onPlayFromMediaId") + mediaSessionEventDistributor.RaisePlayFromMediaIdRequestedEvent(mediaId, extras) } -*/ + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + super.onPlayFromSearch(query, extras) + + Timber.d("Media Session Callback: onPlayFromSearch") + mediaSessionEventDistributor.RaisePlayFromSearchRequestedEvent(query, extras) + } + override fun onPause() { super.onPause() getPendingIntentForMediaAction( @@ -886,6 +900,11 @@ class MediaPlayerService : Service() { mediaPlayerLifecycleSupport.handleKeyEvent(event) return true } + + override fun onSkipToQueueItem(id: Long) { + super.onSkipToQueueItem(id) + play(id.toInt()) + } } ) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt new file mode 100644 index 00000000..f90470a4 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt @@ -0,0 +1,49 @@ +package org.moire.ultrasonic.util + +import android.os.Bundle +import android.support.v4.media.session.MediaSessionCompat + +/** + * This class distributes MediaSession related events to its subscribers. + * It is a primitive implementation of a pub-sub event bus + */ +class MediaSessionEventDistributor { + var eventListenerList: MutableList = + listOf().toMutableList() + + var cachedToken: MediaSessionCompat.Token? = null + + fun subscribe(listener: MediaSessionEventListener) { + eventListenerList.add(listener) + + synchronized(this) { + if (cachedToken != null) + listener.onMediaSessionTokenCreated(cachedToken!!) + } + } + + fun unsubscribe(listener: MediaSessionEventListener) { + eventListenerList.remove(listener) + } + + fun ReleaseCachedMediaSessionToken() { + synchronized(this) { + cachedToken = null + } + } + + fun RaiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) { + synchronized(this) { + cachedToken = token + eventListenerList.forEach { listener -> listener.onMediaSessionTokenCreated(token) } + } + } + + fun RaisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) { + eventListenerList.forEach { listener -> listener.onPlayFromMediaIdRequested(mediaId, extras) } + } + + fun RaisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) { + eventListenerList.forEach { listener -> listener.onPlayFromSearchRequested(query, extras) } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt new file mode 100644 index 00000000..1b1c922d --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt @@ -0,0 +1,13 @@ +package org.moire.ultrasonic.util + +import android.os.Bundle +import android.support.v4.media.session.MediaSessionCompat + +/** + * Callback interface for MediaSession related event subscribers + */ +interface MediaSessionEventListener { + fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) + fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) + fun onPlayFromSearchRequested(query: String?, extras: Bundle?) +} From 56af9e4bf2b315aa5c2946fd1df055c69eedb45b Mon Sep 17 00:00:00 2001 From: Nite Date: Tue, 13 Jul 2021 19:25:37 +0200 Subject: [PATCH 06/14] Moved MediaSession handling to its own class Fixed queue and position handling on Now Playing screen --- ultrasonic/src/main/AndroidManifest.xml | 2 +- .../ultrasonic/fragment/SettingsFragment.java | 4 +- .../service/DownloadQueueSerializer.java | 108 ------ .../service/MediaPlayerLifecycleSupport.java | 308 ----------------- .../java/org/moire/ultrasonic/util/Util.java | 10 - .../moire/ultrasonic/di/ApplicationModule.kt | 2 + .../moire/ultrasonic/di/MediaPlayerModule.kt | 6 +- .../service/AutoMediaBrowserService.kt | 25 +- .../service/AutoMediaPlayerService.kt | 4 +- .../service/DownloadQueueSerializer.kt | 105 ++++++ .../ultrasonic/service/LocalMediaPlayer.kt | 17 +- .../service/MediaPlayerLifecycleSupport.kt | 280 ++++++++++++++++ .../ultrasonic/service/MediaPlayerService.kt | 259 ++------------ ...tor.kt => MediaSessionEventDistributor.kt} | 17 +- ...stener.kt => MediaSessionEventListener.kt} | 9 +- .../ultrasonic/util/MediaSessionHandler.kt | 315 ++++++++++++++++++ 16 files changed, 777 insertions(+), 694 deletions(-) delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt rename ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/{MediaSessionTokenCreatedEventDistributor.kt => MediaSessionEventDistributor.kt} (70%) rename ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/{MediaSessionTokenCreatedEventListener.kt => MediaSessionEventListener.kt} (73%) create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index f9abed6b..08a2d5aa 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -61,7 +61,7 @@ diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java index 5a6cfdd8..3125f1d0 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/fragment/SettingsFragment.java @@ -33,6 +33,7 @@ import org.moire.ultrasonic.service.Consumer; import org.moire.ultrasonic.service.MediaPlayerController; import org.moire.ultrasonic.util.Constants; import org.moire.ultrasonic.util.FileUtil; +import org.moire.ultrasonic.util.MediaSessionHandler; import org.moire.ultrasonic.util.PermissionUtil; import org.moire.ultrasonic.util.ThemeChangedEventDistributor; import org.moire.ultrasonic.util.TimeSpanPreference; @@ -89,6 +90,7 @@ public class SettingsFragment extends PreferenceFragmentCompat private final Lazy mediaPlayerControllerLazy = inject(MediaPlayerController.class); private final Lazy permissionUtil = inject(PermissionUtil.class); private final Lazy themeChangedEventDistributor = inject(ThemeChangedEventDistributor.class); + private final Lazy mediaSessionHandler = inject(MediaSessionHandler.class); @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -468,7 +470,7 @@ public class SettingsFragment extends PreferenceFragmentCompat private void setMediaButtonsEnabled(boolean enabled) { lockScreenEnabled.setEnabled(enabled); - Util.updateMediaButtonEventReceiver(); + mediaSessionHandler.getValue().updateMediaButtonReceiver(); } private void setBluetoothPreferences(boolean enabled) { diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java deleted file mode 100644 index 53134733..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/DownloadQueueSerializer.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.moire.ultrasonic.service; - -import android.content.Context; -import android.os.AsyncTask; -import timber.log.Timber; - -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.FileUtil; - -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; - -/** - * This class is responsible for the serialization / deserialization - * of the DownloadQueue (playlist) to the filesystem. - * It also serializes the player state e.g. current playing number and play position. - */ -public class DownloadQueueSerializer -{ - public final Lock lock = new ReentrantLock(); - public final AtomicBoolean setup = new AtomicBoolean(false); - private Context context; - - public DownloadQueueSerializer(Context context) - { - this.context = context; - } - - public void serializeDownloadQueue(Iterable songs, int currentPlayingIndex, int currentPlayingPosition) - { - if (!setup.get()) - { - return; - } - - new SerializeTask().execute(songs, currentPlayingIndex, currentPlayingPosition); - } - - public void serializeDownloadQueueNow(Iterable songs, int currentPlayingIndex, int currentPlayingPosition) - { - State state = new State(); - for (DownloadFile downloadFile : songs) - { - state.songs.add(downloadFile.getSong()); - } - state.currentPlayingIndex = currentPlayingIndex; - state.currentPlayingPosition = currentPlayingPosition; - - Timber.i("Serialized currentPlayingIndex: %d, currentPlayingPosition: %d", state.currentPlayingIndex, state.currentPlayingPosition); - FileUtil.serialize(context, state, Constants.FILENAME_DOWNLOADS_SER); - } - - public void deserializeDownloadQueue(Consumer afterDeserialized) - { - new DeserializeTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, afterDeserialized); - } - - public void deserializeDownloadQueueNow(Consumer afterDeserialized) - { - State state = FileUtil.deserialize(context, Constants.FILENAME_DOWNLOADS_SER); - if (state == null) return; - Timber.i("Deserialized currentPlayingIndex: " + state.currentPlayingIndex + ", currentPlayingPosition: " + state.currentPlayingPosition); - afterDeserialized.accept(state); - } - - private class SerializeTask extends AsyncTask - { - @Override - protected Void doInBackground(Object... params) - { - if (lock.tryLock()) - { - try - { - Thread.currentThread().setName("SerializeTask"); - serializeDownloadQueueNow((Iterable)params[0], (int)params[1], (int)params[2]); - } - finally - { - lock.unlock(); - } - } - return null; - } - } - - private class DeserializeTask extends AsyncTask - { - @Override - protected Void doInBackground(Object... params) - { - try - { - Thread.currentThread().setName("DeserializeTask"); - lock.lock(); - deserializeDownloadQueueNow((Consumer)params[0]); - setup.set(true); - } - finally - { - lock.unlock(); - } - - return null; - } - } -} diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java b/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java deleted file mode 100644 index 8713c7e7..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.java +++ /dev/null @@ -1,308 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.service; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.SharedPreferences; -import android.media.AudioManager; -import android.os.Build; -import android.os.Bundle; -import timber.log.Timber; -import android.view.KeyEvent; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.app.UApp; -import org.moire.ultrasonic.domain.PlayerState; -import org.moire.ultrasonic.util.CacheCleaner; -import org.moire.ultrasonic.util.Constants; -import org.moire.ultrasonic.util.Util; - -/** - * This class is responsible for handling received events for the Media Player implementation - * - * @author Sindre Mehus - */ -public class MediaPlayerLifecycleSupport -{ - private boolean created = false; - private final DownloadQueueSerializer downloadQueueSerializer; // From DI - private final MediaPlayerController mediaPlayerController; // From DI - private final Downloader downloader; // From DI - - private BroadcastReceiver headsetEventReceiver; - - public MediaPlayerLifecycleSupport(DownloadQueueSerializer downloadQueueSerializer, - final MediaPlayerController mediaPlayerController, final Downloader downloader) - { - this.downloadQueueSerializer = downloadQueueSerializer; - this.mediaPlayerController = mediaPlayerController; - this.downloader = downloader; - - Timber.i("LifecycleSupport constructed"); - } - - public void onCreate() - { - onCreate(false, null); - } - - private void onCreate(final boolean autoPlay, final Runnable afterCreated) - { - if (created) - { - if (afterCreated != null) afterCreated.run(); - return; - } - - registerHeadsetReceiver(); - - mediaPlayerController.onCreate(); - if (autoPlay) mediaPlayerController.preload(); - - this.downloadQueueSerializer.deserializeDownloadQueue(new Consumer() { - @Override - public void accept(State state) { - mediaPlayerController.restore(state.songs, state.currentPlayingIndex, state.currentPlayingPosition, autoPlay, false); - - // Work-around: Serialize again, as the restore() method creates a serialization without current playing info. - MediaPlayerLifecycleSupport.this.downloadQueueSerializer.serializeDownloadQueue(downloader.downloadList, - downloader.getCurrentPlayingIndex(), mediaPlayerController.getPlayerPosition()); - - if (afterCreated != null) afterCreated.run(); - } - }); - - new CacheCleaner().clean(); - created = true; - Timber.i("LifecycleSupport created"); - } - - public void onDestroy() - { - if (!created) return; - downloadQueueSerializer.serializeDownloadQueueNow(downloader.downloadList, - downloader.getCurrentPlayingIndex(), mediaPlayerController.getPlayerPosition()); - mediaPlayerController.clear(false); - UApp.Companion.applicationContext().unregisterReceiver(headsetEventReceiver); - mediaPlayerController.onDestroy(); - created = false; - Timber.i("LifecycleSupport destroyed"); - } - - public void receiveIntent(Intent intent) - { - if (intent == null) return; - String intentAction = intent.getAction(); - if (intentAction == null || intentAction.isEmpty()) return; - - Timber.i("Received intent: %s", intentAction); - - if (intentAction.equals(Constants.CMD_PROCESS_KEYCODE)) { - if (intent.getExtras() != null) { - KeyEvent event = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT); - if (event != null) { - handleKeyEvent(event); - } - } - } - else - { - handleUltrasonicIntent(intentAction); - } - } - - /** - * The Headset Intent Receiver is responsible for resuming playback when a headset is inserted - * and pausing it when it is removed. - * Unfortunately this Intent can't be registered in the AndroidManifest, so it works only - * while Ultrasonic is running. - */ - private void registerHeadsetReceiver() { - final SharedPreferences sp = Util.getPreferences(); - final Context context = UApp.Companion.applicationContext(); - final String spKey = context - .getString(R.string.settings_playback_resume_play_on_headphones_plug); - - headsetEventReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - final Bundle extras = intent.getExtras(); - - if (extras == null) { - return; - } - - Timber.i("Headset event for: %s", extras.get("name")); - final int state = extras.getInt("state"); - if (state == 0) { - if (!mediaPlayerController.isJukeboxEnabled()) { - mediaPlayerController.pause(); - } - } else if (state == 1) { - if (!mediaPlayerController.isJukeboxEnabled() && - sp.getBoolean(spKey, false) && - mediaPlayerController.getPlayerState() == PlayerState.PAUSED) { - mediaPlayerController.start(); - } - } - } - }; - - - IntentFilter headsetIntentFilter; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) - { - headsetIntentFilter = new IntentFilter(AudioManager.ACTION_HEADSET_PLUG); - } - else - { - headsetIntentFilter = new IntentFilter(Intent.ACTION_HEADSET_PLUG); - } - UApp.Companion.applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter); - } - - public void handleKeyEvent(KeyEvent event) - { - if (event.getAction() != KeyEvent.ACTION_DOWN || event.getRepeatCount() > 0) - { - return; - } - - final int keyCode; - int receivedKeyCode = event.getKeyCode(); - // Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices - if (Util.getSingleButtonPlayPause() && - (receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY || - receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE)) { - Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE"); - keyCode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE; - } - else keyCode = receivedKeyCode; - - boolean autoStart = (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || - keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || - keyCode == KeyEvent.KEYCODE_HEADSETHOOK || - keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS || - keyCode == KeyEvent.KEYCODE_MEDIA_NEXT); - - // We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start - onCreate(autoStart, () -> { - switch (keyCode) - { - case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: - case KeyEvent.KEYCODE_HEADSETHOOK: - mediaPlayerController.togglePlayPause(); - break; - case KeyEvent.KEYCODE_MEDIA_PREVIOUS: - mediaPlayerController.previous(); - break; - case KeyEvent.KEYCODE_MEDIA_NEXT: - mediaPlayerController.next(); - break; - case KeyEvent.KEYCODE_MEDIA_STOP: - mediaPlayerController.stop(); - break; - case KeyEvent.KEYCODE_MEDIA_PLAY: - if (mediaPlayerController.getPlayerState() == PlayerState.IDLE) - { - mediaPlayerController.play(); - } - else if (mediaPlayerController.getPlayerState() != PlayerState.STARTED) - { - mediaPlayerController.start(); - } - break; - case KeyEvent.KEYCODE_MEDIA_PAUSE: - mediaPlayerController.pause(); - break; - case KeyEvent.KEYCODE_1: - mediaPlayerController.setSongRating(1); - break; - case KeyEvent.KEYCODE_2: - mediaPlayerController.setSongRating(2); - break; - case KeyEvent.KEYCODE_3: - mediaPlayerController.setSongRating(3); - break; - case KeyEvent.KEYCODE_4: - mediaPlayerController.setSongRating(4); - break; - case KeyEvent.KEYCODE_5: - mediaPlayerController.setSongRating(5); - break; - case KeyEvent.KEYCODE_STAR: - mediaPlayerController.toggleSongStarred(); - break; - default: - break; - } - }); - } - - /** - * This function processes the intent that could come from other applications. - */ - private void handleUltrasonicIntent(final String intentAction) - { - final boolean isRunning = created; - // If Ultrasonic is not running, do nothing to stop or pause - if (!isRunning && (intentAction.equals(Constants.CMD_PAUSE) || - intentAction.equals(Constants.CMD_STOP))) return; - - boolean autoStart = (intentAction.equals(Constants.CMD_PLAY) || - intentAction.equals(Constants.CMD_RESUME_OR_PLAY) || - intentAction.equals(Constants.CMD_TOGGLEPAUSE) || - intentAction.equals(Constants.CMD_PREVIOUS) || - intentAction.equals(Constants.CMD_NEXT)); - - // We can receive intents when everything is stopped, so we need to start - onCreate(autoStart, () -> { - switch(intentAction) - { - case Constants.CMD_PLAY: - mediaPlayerController.play(); - break; - case Constants.CMD_RESUME_OR_PLAY: - // If Ultrasonic wasn't running, the autoStart is enough to resume, no need to call anything - if (isRunning) mediaPlayerController.resumeOrPlay(); - break; - case Constants.CMD_NEXT: - mediaPlayerController.next(); - break; - case Constants.CMD_PREVIOUS: - mediaPlayerController.previous(); - break; - case Constants.CMD_TOGGLEPAUSE: - mediaPlayerController.togglePlayPause(); - break; - case Constants.CMD_STOP: - // TODO: There is a stop() function, shouldn't we use that? - mediaPlayerController.pause(); - mediaPlayerController.seekTo(0); - break; - case Constants.CMD_PAUSE: - mediaPlayerController.pause(); - break; - } - }); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java index e93d7cc4..7d60601a 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java @@ -692,16 +692,6 @@ public class Util return Bitmap.createScaledBitmap(bitmap, size, getScaledHeight(bitmap, size), true); } - // Trigger an update on the MediaSession. Depending on the preference it will register - // or deregister the MediaButtonReceiver. - public static void updateMediaButtonEventReceiver() - { - MediaPlayerService mediaPlayerService = MediaPlayerService.getRunningInstance(); - if (mediaPlayerService != null) { - mediaPlayerService.updateMediaButtonReceiver(); - } - } - public static MusicDirectory getSongsFromSearchResult(SearchResult searchResult) { MusicDirectory musicDirectory = new MusicDirectory(); diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt index 47b08cde..e44e0774 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/ApplicationModule.kt @@ -5,6 +5,7 @@ import org.koin.dsl.module import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.util.MediaSessionEventDistributor +import org.moire.ultrasonic.util.MediaSessionHandler import org.moire.ultrasonic.util.NowPlayingEventDistributor import org.moire.ultrasonic.util.PermissionUtil import org.moire.ultrasonic.util.ThemeChangedEventDistributor @@ -19,4 +20,5 @@ val applicationModule = module { single { NowPlayingEventDistributor() } single { ThemeChangedEventDistributor() } single { MediaSessionEventDistributor() } + single { MediaSessionHandler() } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index 6f7a751d..2e9810c4 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -17,12 +17,12 @@ import org.moire.ultrasonic.util.ShufflePlayBuffer */ val mediaPlayerModule = module { single { JukeboxMediaPlayer(get()) } - single { MediaPlayerLifecycleSupport(get(), get(), get()) } - single { DownloadQueueSerializer(androidContext()) } + single { MediaPlayerLifecycleSupport() } + single { DownloadQueueSerializer() } single { ExternalStorageMonitor() } single { ShufflePlayBuffer() } single { Downloader(get(), get(), get()) } - single { LocalMediaPlayer(get(), androidContext()) } + single { LocalMediaPlayer() } single { AudioFocusHandler(get()) } // TODO Ideally this can be cleaned up when all circular references are removed. diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index 6afcb29a..1a9f0534 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -10,6 +10,7 @@ import androidx.media.utils.MediaConstants import org.koin.android.ext.android.inject import org.moire.ultrasonic.util.MediaSessionEventDistributor import org.moire.ultrasonic.util.MediaSessionEventListener +import org.moire.ultrasonic.util.MediaSessionHandler import timber.log.Timber @@ -23,49 +24,49 @@ const val MY_MEDIA_PLAYLIST_ID = "MY_MEDIA_PLAYLIST_ID" class AutoMediaBrowserService : MediaBrowserServiceCompat() { private lateinit var mediaSessionEventListener: MediaSessionEventListener - private val mediaSessionEventDistributor: MediaSessionEventDistributor by inject() - private val lifecycleSupport: MediaPlayerLifecycleSupport by inject() + private val mediaSessionEventDistributor by inject() + private val lifecycleSupport by inject() + private val mediaSessionHandler by inject() override fun onCreate() { super.onCreate() mediaSessionEventListener = object : MediaSessionEventListener { override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { - Timber.i("AutoMediaBrowserService onMediaSessionTokenCreated called") if (sessionToken == null) { - Timber.i("AutoMediaBrowserService onMediaSessionTokenCreated session token was null, set it to %s", token.toString()) sessionToken = token } } override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) { // TODO implement - Timber.i("AutoMediaBrowserService onPlayFromMediaIdRequested called") } override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) { // TODO implement - Timber.i("AutoMediaBrowserService onPlayFromSearchRequested called") } } mediaSessionEventDistributor.subscribe(mediaSessionEventListener) + mediaSessionHandler.initialize() val handler = Handler() handler.postDelayed({ - Timber.i("AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService...") - // TODO it seems Android Auto handles autostart, but we must check that + // Ultrasonic may be started from Android Auto. This boots up the necessary components. + Timber.d("AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService...") lifecycleSupport.onCreate() MediaPlayerService.getInstance() }, 100) - Timber.i("AutoMediaBrowserService onCreate called") + Timber.i("AutoMediaBrowserService onCreate finished") } override fun onDestroy() { super.onDestroy() mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) - Timber.i("AutoMediaBrowserService onDestroy called") + mediaSessionHandler.release() + + Timber.i("AutoMediaBrowserService onDestroy finished") } override fun onGetRoot( @@ -73,7 +74,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { clientUid: Int, rootHints: Bundle? ): BrowserRoot? { - Timber.i("AutoMediaBrowserService onGetRoot called") + Timber.d("AutoMediaBrowserService onGetRoot called") // TODO: The number of horizontal items available on the Andoid Auto screen. Check and handle. val maximumRootChildLimit = rootHints!!.getInt( @@ -102,7 +103,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { parentId: String, result: Result> ) { - Timber.i("AutoMediaBrowserService onLoadChildren called") + Timber.d("AutoMediaBrowserService onLoadChildren called") if (parentId == MY_MEDIA_ROOT_ID) { return getRootItems(result) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt index 2eb7fca5..a69cf32a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt @@ -142,8 +142,8 @@ class AutoMediaPlayerService: MediaBrowserServiceCompat() { albumListModel = AlbumListModel(application) artistListModel = ArtistListModel(application) - mediaPlayerService.onCreate() - mediaPlayerService.updateMediaSession(null, PlayerState.IDLE) + //mediaPlayerService.onCreate() + //mediaPlayerService.updateMediaSession(null, PlayerState.IDLE) } override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt new file mode 100644 index 00000000..b88a47a0 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt @@ -0,0 +1,105 @@ +package org.moire.ultrasonic.service + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.FileUtil +import org.moire.ultrasonic.util.MediaSessionHandler +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock + +/** + * This class is responsible for the serialization / deserialization + * of the DownloadQueue (playlist) to the filesystem. + * It also serializes the player state e.g. current playing number and play position. + */ +class DownloadQueueSerializer : KoinComponent { + + private val context by inject() + private val mediaSessionHandler by inject() + + val lock: Lock = ReentrantLock() + val setup = AtomicBoolean(false) + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun serializeDownloadQueue( + songs: Iterable, + currentPlayingIndex: Int, + currentPlayingPosition: Int + ) { + if (!setup.get()) return + + appScope.launch { + if (lock.tryLock()) { + try { + serializeDownloadQueueNow(songs, currentPlayingIndex, currentPlayingPosition) + } finally { + lock.unlock() + } + } + } + } + + fun serializeDownloadQueueNow( + songs: Iterable, + currentPlayingIndex: Int, + currentPlayingPosition: Int + ) { + val state = State() + + for (downloadFile in songs) { + state.songs.add(downloadFile.song) + } + + state.currentPlayingIndex = currentPlayingIndex + state.currentPlayingPosition = currentPlayingPosition + + Timber.i( + "Serialized currentPlayingIndex: %d, currentPlayingPosition: %d", + state.currentPlayingIndex, + state.currentPlayingPosition + ) + + FileUtil.serialize(context, state, Constants.FILENAME_DOWNLOADS_SER) + + // This is called here because the queue is usually serialized after a change + mediaSessionHandler.updateMediaSessionQueue(state.songs) + } + + fun deserializeDownloadQueue(afterDeserialized: Consumer) { + + appScope.launch { + try { + lock.lock() + deserializeDownloadQueueNow(afterDeserialized) + setup.set(true) + } finally { + lock.unlock() + } + } + } + + private fun deserializeDownloadQueueNow(afterDeserialized: Consumer) { + + val state = FileUtil.deserialize( + context, Constants.FILENAME_DOWNLOADS_SER + ) ?: return + + Timber.i( + "Deserialized currentPlayingIndex: %d, currentPlayingPosition: %d ", + state.currentPlayingIndex, + state.currentPlayingPosition + ) + + mediaSessionHandler.updateMediaSessionQueue(state.songs) + afterDeserialized.accept(state) + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index 005967b9..9b5efa05 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -21,6 +21,8 @@ import android.os.PowerManager import android.os.PowerManager.PARTIAL_WAKE_LOCK import android.os.PowerManager.WakeLock import androidx.lifecycle.MutableLiveData +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import java.io.File import java.net.URLEncoder import java.util.Locale @@ -32,6 +34,7 @@ import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline import org.moire.ultrasonic.domain.PlayerState import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.MediaSessionHandler import org.moire.ultrasonic.util.StreamProxy import org.moire.ultrasonic.util.Util import timber.log.Timber @@ -39,10 +42,11 @@ import timber.log.Timber /** * Represents a Media Player which uses the mobile's resources for playback */ -class LocalMediaPlayer( - private val audioFocusHandler: AudioFocusHandler, - private val context: Context -) { +class LocalMediaPlayer: KoinComponent { + + private val audioFocusHandler by inject() + private val context by inject() + private val mediaSessionHandler by inject() @JvmField var onCurrentPlayingChanged: ((DownloadFile?) -> Unit?)? = null @@ -705,8 +709,11 @@ class LocalMediaPlayer( try { if (playerState === PlayerState.STARTED) { cachedPosition = mediaPlayer.currentPosition + mediaSessionHandler.updateMediaSessionPlaybackPosition( + cachedPosition.toLong() + ) } - Util.sleepQuietly(50L) + Util.sleepQuietly(100L) } catch (e: Exception) { Timber.w(e, "Crashed getting current position") isRunning = false diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt new file mode 100644 index 00000000..2917d6f4 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -0,0 +1,280 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package org.moire.ultrasonic.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import android.os.Build +import android.view.KeyEvent +import kotlinx.coroutines.newFixedThreadPoolContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.app.UApp.Companion.applicationContext +import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.util.CacheCleaner +import org.moire.ultrasonic.util.Constants +import org.moire.ultrasonic.util.MediaSessionEventDistributor +import org.moire.ultrasonic.util.MediaSessionEventListener +import org.moire.ultrasonic.util.Util +import timber.log.Timber + +/** + * This class is responsible for handling received events for the Media Player implementation + * + * @author Sindre Mehus + */ +class MediaPlayerLifecycleSupport : KoinComponent { + private val downloadQueueSerializer by inject() + private val mediaPlayerController by inject() + private val downloader by inject() + private val mediaSessionEventDistributor by inject() + + private var created = false + private var headsetEventReceiver: BroadcastReceiver? = null + private lateinit var mediaSessionEventListener: MediaSessionEventListener + + fun onCreate() { + onCreate(false, null) + } + + private fun onCreate(autoPlay: Boolean, afterCreated: Runnable?) { + + if (created) { + afterCreated?.run() + return + } + + mediaSessionEventListener = object : MediaSessionEventListener { + override fun onMediaButtonEvent(keyEvent: KeyEvent?) { + if (keyEvent != null) handleKeyEvent(keyEvent) + } + } + + mediaSessionEventDistributor.subscribe(mediaSessionEventListener) + registerHeadsetReceiver() + mediaPlayerController.onCreate() + if (autoPlay) mediaPlayerController.preload() + + downloadQueueSerializer.deserializeDownloadQueue(object : Consumer() { + override fun accept(state: State?) { + mediaPlayerController.restore( + state!!.songs, + state.currentPlayingIndex, + state.currentPlayingPosition, + autoPlay, + false + ) + + // Work-around: Serialize again, as the restore() method creates a serialization without current playing info. + downloadQueueSerializer.serializeDownloadQueue( + downloader.downloadList, + downloader.currentPlayingIndex, + mediaPlayerController.playerPosition + ) + afterCreated?.run() + } + }) + + CacheCleaner().clean() + created = true + Timber.i("LifecycleSupport created") + } + + fun onDestroy() { + + if (!created) return + + downloadQueueSerializer.serializeDownloadQueueNow( + downloader.downloadList, + downloader.currentPlayingIndex, + mediaPlayerController.playerPosition + ) + + mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) + + mediaPlayerController.clear(false) + applicationContext().unregisterReceiver(headsetEventReceiver) + mediaPlayerController.onDestroy() + + created = false + Timber.i("LifecycleSupport destroyed") + } + + fun receiveIntent(intent: Intent?) { + + if (intent == null) return + + val intentAction = intent.action + if (intentAction == null || intentAction.isEmpty()) return + + Timber.i("Received intent: %s", intentAction) + + if (intentAction == Constants.CMD_PROCESS_KEYCODE) { + if (intent.extras != null) { + val event = intent.extras!![Intent.EXTRA_KEY_EVENT] as KeyEvent? + event?.let { handleKeyEvent(it) } + } + } else { + handleUltrasonicIntent(intentAction) + } + } + + /** + * The Headset Intent Receiver is responsible for resuming playback when a headset is inserted + * and pausing it when it is removed. + * Unfortunately this Intent can't be registered in the AndroidManifest, so it works only + * while Ultrasonic is running. + */ + private fun registerHeadsetReceiver() { + + val sp = Util.getPreferences() + val context = applicationContext() + val spKey = context + .getString(R.string.settings_playback_resume_play_on_headphones_plug) + + headsetEventReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val extras = intent.extras ?: return + + Timber.i("Headset event for: %s", extras["name"]) + + val state = extras.getInt("state") + + if (state == 0) { + if (!mediaPlayerController.isJukeboxEnabled) { + mediaPlayerController.pause() + } + } else if (state == 1) { + if (!mediaPlayerController.isJukeboxEnabled && + sp.getBoolean( + spKey, + false + ) && mediaPlayerController.playerState === PlayerState.PAUSED + ) { + mediaPlayerController.start() + } + } + } + } + + val headsetIntentFilter: IntentFilter = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + IntentFilter(AudioManager.ACTION_HEADSET_PLUG) + } else { + IntentFilter(Intent.ACTION_HEADSET_PLUG) + } + + applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter) + } + + private fun handleKeyEvent(event: KeyEvent) { + + if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return + + val keyCode: Int + val receivedKeyCode = event.keyCode + + // Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices + keyCode = if (Util.getSingleButtonPlayPause() && + (receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY || + receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE) + ) { + Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE") + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE + } else receivedKeyCode + + val autoStart = + keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || + keyCode == KeyEvent.KEYCODE_MEDIA_PLAY || + keyCode == KeyEvent.KEYCODE_HEADSETHOOK || + keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS || + keyCode == KeyEvent.KEYCODE_MEDIA_NEXT + + // We can receive intents (e.g. MediaButton) when everything is stopped, so we need to start + onCreate(autoStart) { + when (keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_HEADSETHOOK -> mediaPlayerController.togglePlayPause() + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> mediaPlayerController.previous() + KeyEvent.KEYCODE_MEDIA_NEXT -> mediaPlayerController.next() + KeyEvent.KEYCODE_MEDIA_STOP -> mediaPlayerController.stop() + + KeyEvent.KEYCODE_MEDIA_PLAY -> + if (mediaPlayerController.playerState === PlayerState.IDLE) { + mediaPlayerController.play() + } else if (mediaPlayerController.playerState !== PlayerState.STARTED) { + mediaPlayerController.start() + } + + KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause() + KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1) + KeyEvent.KEYCODE_2 -> mediaPlayerController.setSongRating(2) + KeyEvent.KEYCODE_3 -> mediaPlayerController.setSongRating(3) + KeyEvent.KEYCODE_4 -> mediaPlayerController.setSongRating(4) + KeyEvent.KEYCODE_5 -> mediaPlayerController.setSongRating(5) + KeyEvent.KEYCODE_STAR -> mediaPlayerController.toggleSongStarred() + else -> { + } + } + } + } + + /** + * This function processes the intent that could come from other applications. + */ + private fun handleUltrasonicIntent(intentAction: String) { + + val isRunning = created + + // If Ultrasonic is not running, do nothing to stop or pause + if (!isRunning && (intentAction == Constants.CMD_PAUSE || + intentAction == Constants.CMD_STOP)) return + + val autoStart = + intentAction == Constants.CMD_PLAY || + intentAction == Constants.CMD_RESUME_OR_PLAY || + intentAction == Constants.CMD_TOGGLEPAUSE || + intentAction == Constants.CMD_PREVIOUS || + intentAction == Constants.CMD_NEXT + + // We can receive intents when everything is stopped, so we need to start + onCreate(autoStart) { + when (intentAction) { + Constants.CMD_PLAY -> mediaPlayerController.play() + Constants.CMD_RESUME_OR_PLAY -> // If Ultrasonic wasn't running, the autoStart is enough to resume, no need to call anything + if (isRunning) mediaPlayerController.resumeOrPlay() + + Constants.CMD_NEXT -> mediaPlayerController.next() + Constants.CMD_PREVIOUS -> mediaPlayerController.previous() + Constants.CMD_TOGGLEPAUSE -> mediaPlayerController.togglePlayPause() + + Constants.CMD_STOP -> { + // TODO: There is a stop() function, shouldn't we use that? + mediaPlayerController.pause() + mediaPlayerController.seekTo(0) + } + Constants.CMD_PAUSE -> mediaPlayerController.pause() + } + } + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 493591fc..04b3ba4e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -8,16 +8,11 @@ package org.moire.ultrasonic.service import android.app.* -import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Build -import android.os.Bundle import android.os.IBinder -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat import android.view.KeyEvent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -34,10 +29,11 @@ import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X1 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X2 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X3 import org.moire.ultrasonic.provider.UltrasonicAppWidgetProvider4X4 -import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.MediaSessionEventDistributor +import org.moire.ultrasonic.util.MediaSessionEventListener +import org.moire.ultrasonic.util.MediaSessionHandler import org.moire.ultrasonic.util.NowPlayingEventDistributor import org.moire.ultrasonic.util.ShufflePlayBuffer import org.moire.ultrasonic.util.SimpleServiceBinder @@ -59,13 +55,14 @@ class MediaPlayerService : Service() { private val downloader by inject() private val localMediaPlayer by inject() private val nowPlayingEventDistributor by inject() - private val mediaPlayerLifecycleSupport by inject() - private val mediaSessionEventDistributor: MediaSessionEventDistributor by inject() + private val mediaSessionEventDistributor by inject() + private val mediaSessionHandler by inject() private var mediaSession: MediaSessionCompat? = null - var mediaSessionToken: MediaSessionCompat.Token? = null + private var mediaSessionToken: MediaSessionCompat.Token? = null private var isInForeground = false private var notificationBuilder: NotificationCompat.Builder? = null + private lateinit var mediaSessionEventListener: MediaSessionEventListener private val repeatMode: RepeatMode get() = Util.getRepeatMode() @@ -96,11 +93,18 @@ class MediaPlayerService : Service() { localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } - // TODO maybe MediaSession must be in an independent class after all... - // It seems this must be initialized in the stopped state too, e.g. for Android Auto. - // So it is best to init this early. - initMediaSessions() - updateMediaSession(null, PlayerState.IDLE) + mediaSessionEventListener = object:MediaSessionEventListener { + override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { + mediaSessionToken = token + } + + override fun onSkipToQueueItemRequested(id: Long) { + play(id.toInt()) + } + } + + mediaSessionEventDistributor.subscribe(mediaSessionEventListener) + mediaSessionHandler.initialize() // Create Notification Channel createNotificationChannel() @@ -121,11 +125,13 @@ class MediaPlayerService : Service() { super.onDestroy() instance = null try { + mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) + mediaSessionHandler.release() + localMediaPlayer.release() downloader.stop() shufflePlayBuffer.onDestroy() - mediaSessionEventDistributor.ReleaseCachedMediaSessionToken() mediaSession?.release() mediaSession = null } catch (ignored: Throwable) { @@ -377,7 +383,7 @@ class MediaPlayerService : Service() { val context = this@MediaPlayerService // Notify MediaSession - updateMediaSession(currentPlaying, playerState) + mediaSessionHandler.updateMediaSession(currentPlaying, downloader.currentPlayingIndex.toLong(), playerState) if (playerState === PlayerState.PAUSED) { downloadQueueSerializer.serializeDownloadQueue( @@ -477,104 +483,6 @@ class MediaPlayerService : Service() { } } - fun updateMediaSession(currentPlaying: DownloadFile?, playerState: PlayerState) { - Timber.d("Updating the MediaSession") - - val playbackState = PlaybackStateCompat.Builder() - - // Set Metadata - val metadata = MediaMetadataCompat.Builder() - if (currentPlaying != null) { - try { - val song = currentPlaying.song - val cover = BitmapUtils.getAlbumArtBitmapFromDisk( - song, Util.getMinDisplayMetric() - ) - metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, -1L) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist) - metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album) - metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title) - metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover) - - playbackState.setActiveQueueItemId(downloader.currentPlayingIndex.toLong()) - - } catch (e: Exception) { - Timber.e(e, "Error setting the metadata") - } - } - - // Save the metadata - mediaSession!!.setMetadata(metadata.build()) - - // Create playback State - val state: Int - val isActive: Boolean - - var actions: Long = PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or - PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or - PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM - - // Map our playerState to native PlaybackState - // TODO: Synchronize these APIs - when (playerState) { - PlayerState.STARTED -> { - state = PlaybackStateCompat.STATE_PLAYING - isActive = true - actions = actions or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_STOP - } - PlayerState.COMPLETED, - PlayerState.STOPPED -> { - isActive = false - state = PlaybackStateCompat.STATE_STOPPED - } - PlayerState.IDLE -> { - isActive = false - state = PlaybackStateCompat.STATE_NONE - actions = 0L - } - PlayerState.PAUSED -> { - isActive = true - state = PlaybackStateCompat.STATE_PAUSED - actions = actions or - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_STOP - } - else -> { - // These are the states PREPARING, PREPARED & DOWNLOADING - isActive = true - state = PlaybackStateCompat.STATE_PAUSED - } - } - - // TODO playerPosition should be updated more frequently (currently this function is called only when the playing track changes) - playbackState.setState(state, playerPosition.toLong(), 1.0f) - - // Set actions - playbackState.setActions(actions) - - // Save the playback state - mediaSession!!.setPlaybackState(playbackState.build()) - - // Set Active state - mediaSession!!.isActive = isActive - - // TODO Implement Now Playing queue handling properly - mediaSession!!.setQueueTitle("Now Playing") - mediaSession!!.setQueue(downloader.downloadList.mapIndexed { id, file -> - MediaSessionCompat.QueueItem(MediaDescriptionCompat.Builder() - .setTitle(file.song.title) - .build(), id.toLong()) - }) - - Timber.d("Setting the MediaSession to active = %s", isActive) - } - private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -814,134 +722,11 @@ class MediaPlayerService : Service() { return PendingIntent.getBroadcast(context, requestCode, intent, flags) } - private fun initMediaSessions() { - @Suppress("MagicNumber") - val keycode = 110 - - Timber.w("Creating media session") - - mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") - mediaSessionToken = mediaSession!!.sessionToken - mediaSessionEventDistributor.RaiseMediaSessionTokenCreatedEvent(mediaSessionToken!!) - - updateMediaButtonReceiver() - - mediaSession!!.setCallback(object : MediaSessionCompat.Callback() { - override fun onPlay() { - super.onPlay() - - getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PLAY, - keycode - ).send() - - Timber.v("Media Session Callback: onPlay") - } - - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - super.onPlayFromMediaId(mediaId, extras) - - Timber.d("Media Session Callback: onPlayFromMediaId") - mediaSessionEventDistributor.RaisePlayFromMediaIdRequestedEvent(mediaId, extras) - } - - override fun onPlayFromSearch(query: String?, extras: Bundle?) { - super.onPlayFromSearch(query, extras) - - Timber.d("Media Session Callback: onPlayFromSearch") - mediaSessionEventDistributor.RaisePlayFromSearchRequestedEvent(query, extras) - } - - override fun onPause() { - super.onPause() - getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PAUSE, - keycode - ).send() - Timber.v("Media Session Callback: onPause") - } - - override fun onStop() { - super.onStop() - getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_STOP, - keycode - ).send() - Timber.v("Media Session Callback: onStop") - } - - override fun onSkipToNext() { - super.onSkipToNext() - getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_NEXT, - keycode - ).send() - Timber.v("Media Session Callback: onSkipToNext") - } - - override fun onSkipToPrevious() { - super.onSkipToPrevious() - getPendingIntentForMediaAction( - applicationContext, - KeyEvent.KEYCODE_MEDIA_PREVIOUS, - keycode - ).send() - Timber.v("Media Session Callback: onSkipToPrevious") - } - - override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - // This probably won't be necessary once we implement more - // of the modern media APIs, like the MediaController etc. - val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent? - mediaPlayerLifecycleSupport.handleKeyEvent(event) - return true - } - - override fun onSkipToQueueItem(id: Long) { - super.onSkipToQueueItem(id) - play(id.toInt()) - } - } - ) - } - - fun updateMediaButtonReceiver() { - if (Util.getMediaButtonsEnabled()) { - registerMediaButtonEventReceiver() - } else { - unregisterMediaButtonEventReceiver() - } - } - - private fun registerMediaButtonEventReceiver() { - val component = ComponentName(packageName, MediaButtonIntentReceiver::class.java.name) - val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON) - mediaButtonIntent.component = component - - val pendingIntent = PendingIntent.getBroadcast( - this, - INTENT_CODE_MEDIA_BUTTON, - mediaButtonIntent, - PendingIntent.FLAG_CANCEL_CURRENT - ) - - mediaSession?.setMediaButtonReceiver(pendingIntent) - } - - private fun unregisterMediaButtonEventReceiver() { - mediaSession?.setMediaButtonReceiver(null) - } - @Suppress("MagicNumber") companion object { private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" private const val NOTIFICATION_CHANNEL_NAME = "Ultrasonic background service" private const val NOTIFICATION_ID = 3033 - private const val INTENT_CODE_MEDIA_BUTTON = 161 private var instance: MediaPlayerService? = null private val instanceLock = Any() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt similarity index 70% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt index f90470a4..7313ef85 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventDistributor.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt @@ -2,6 +2,7 @@ package org.moire.ultrasonic.util import android.os.Bundle import android.support.v4.media.session.MediaSessionCompat +import android.view.KeyEvent /** * This class distributes MediaSession related events to its subscribers. @@ -26,24 +27,32 @@ class MediaSessionEventDistributor { eventListenerList.remove(listener) } - fun ReleaseCachedMediaSessionToken() { + fun releaseCachedMediaSessionToken() { synchronized(this) { cachedToken = null } } - fun RaiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) { + fun raiseMediaSessionTokenCreatedEvent(token: MediaSessionCompat.Token) { synchronized(this) { cachedToken = token eventListenerList.forEach { listener -> listener.onMediaSessionTokenCreated(token) } } } - fun RaisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) { + fun raisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) { eventListenerList.forEach { listener -> listener.onPlayFromMediaIdRequested(mediaId, extras) } } - fun RaisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) { + fun raisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) { eventListenerList.forEach { listener -> listener.onPlayFromSearchRequested(query, extras) } } + + fun raiseSkipToQueueItemRequestedEvent(id: Long) { + eventListenerList.forEach { listener -> listener.onSkipToQueueItemRequested(id) } + } + + fun raiseMediaButtonEvent(keyEvent: KeyEvent?) { + eventListenerList.forEach { listener -> listener.onMediaButtonEvent(keyEvent) } + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt similarity index 73% rename from ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt rename to ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt index 1b1c922d..f67eb16e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionTokenCreatedEventListener.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt @@ -2,12 +2,15 @@ package org.moire.ultrasonic.util import android.os.Bundle import android.support.v4.media.session.MediaSessionCompat +import android.view.KeyEvent /** * Callback interface for MediaSession related event subscribers */ interface MediaSessionEventListener { - fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) - fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) - fun onPlayFromSearchRequested(query: String?, extras: Bundle?) + fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) {} + fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) {} + fun onPlayFromSearchRequested(query: String?, extras: Bundle?) {} + fun onSkipToQueueItemRequested(id: Long) {} + fun onMediaButtonEvent(keyEvent: KeyEvent?) {} } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt new file mode 100644 index 00000000..24f42e88 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt @@ -0,0 +1,315 @@ +package org.moire.ultrasonic.util + +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN +import android.view.KeyEvent +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.imageloader.BitmapUtils +import org.moire.ultrasonic.receiver.MediaButtonIntentReceiver +import org.moire.ultrasonic.service.DownloadFile +import timber.log.Timber + +private const val INTENT_CODE_MEDIA_BUTTON = 161 + +class MediaSessionHandler : KoinComponent { + + private var mediaSession: MediaSessionCompat? = null + private var playbackState: Int? = null + private var playbackActions: Long? = null + private var cachedPlayingIndex: Long? = null + + private val mediaSessionEventDistributor by inject() + private val applicationContext by inject() + + private var referenceCount: Int = 0 + private var cachedPlaylist: Iterable? = null + private var playbackPositionDelayCount: Int = 0 + + fun release() { + + if (referenceCount > 0) referenceCount-- + if (referenceCount > 0) return + + mediaSession?.isActive = false + mediaSessionEventDistributor.releaseCachedMediaSessionToken() + mediaSession?.release() + mediaSession = null + + Timber.i("MediaSessionHandler.initialize Media Session released") + } + + fun initialize() { + + referenceCount++ + if (referenceCount > 1) return + + @Suppress("MagicNumber") + val keycode = 110 + + Timber.d("MediaSessionHandler.initialize Creating Media Session") + + mediaSession = MediaSessionCompat(applicationContext, "UltrasonicService") + val mediaSessionToken = mediaSession!!.sessionToken + mediaSessionEventDistributor.raiseMediaSessionTokenCreatedEvent(mediaSessionToken!!) + + updateMediaButtonReceiver() + + mediaSession!!.setCallback(object : MediaSessionCompat.Callback() { + override fun onPlay() { + super.onPlay() + + getPendingIntentForMediaAction( + applicationContext, + KeyEvent.KEYCODE_MEDIA_PLAY, + keycode + ).send() + + Timber.v("Media Session Callback: onPlay") + } + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPlayFromMediaId(mediaId, extras) + + Timber.d("Media Session Callback: onPlayFromMediaId") + mediaSessionEventDistributor.raisePlayFromMediaIdRequestedEvent(mediaId, extras) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + super.onPlayFromSearch(query, extras) + + Timber.d("Media Session Callback: onPlayFromSearch") + mediaSessionEventDistributor.raisePlayFromSearchRequestedEvent(query, extras) + } + + override fun onPause() { + super.onPause() + getPendingIntentForMediaAction( + applicationContext, + KeyEvent.KEYCODE_MEDIA_PAUSE, + keycode + ).send() + Timber.v("Media Session Callback: onPause") + } + + override fun onStop() { + super.onStop() + getPendingIntentForMediaAction( + applicationContext, + KeyEvent.KEYCODE_MEDIA_STOP, + keycode + ).send() + Timber.v("Media Session Callback: onStop") + } + + override fun onSkipToNext() { + super.onSkipToNext() + getPendingIntentForMediaAction( + applicationContext, + KeyEvent.KEYCODE_MEDIA_NEXT, + keycode + ).send() + Timber.v("Media Session Callback: onSkipToNext") + } + + override fun onSkipToPrevious() { + super.onSkipToPrevious() + getPendingIntentForMediaAction( + applicationContext, + KeyEvent.KEYCODE_MEDIA_PREVIOUS, + keycode + ).send() + Timber.v("Media Session Callback: onSkipToPrevious") + } + + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + // This probably won't be necessary once we implement more + // of the modern media APIs, like the MediaController etc. + val event = mediaButtonEvent.extras!!["android.intent.extra.KEY_EVENT"] as KeyEvent? + mediaSessionEventDistributor.raiseMediaButtonEvent(event) + return true + } + + override fun onSkipToQueueItem(id: Long) { + super.onSkipToQueueItem(id) + mediaSessionEventDistributor.raiseSkipToQueueItemRequestedEvent(id) + } + } + ) + + // It seems to be the best practice to set this to true for the lifetime of the session + mediaSession!!.isActive = true + if (cachedPlaylist != null) updateMediaSessionQueue(cachedPlaylist!!) + Timber.i("MediaSessionHandler.initialize Media Session created") + } + + fun updateMediaSession(currentPlaying: DownloadFile?, currentPlayingIndex: Long?, playerState: PlayerState) { + Timber.d("Updating the MediaSession") + + // Set Metadata + val metadata = MediaMetadataCompat.Builder() + if (currentPlaying != null) { + try { + val song = currentPlaying.song + val cover = BitmapUtils.getAlbumArtBitmapFromDisk( + song, Util.getMinDisplayMetric() + ) + val duration = song.duration?.times(1000) ?: -1 + metadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration.toLong()) + metadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, song.artist) + metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, song.artist) + metadata.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album) + metadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, song.title) + metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, cover) + } catch (e: Exception) { + Timber.e(e, "Error setting the metadata") + } + } + + // Save the metadata + mediaSession!!.setMetadata(metadata.build()) + + playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or + PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM + + // Map our playerState to native PlaybackState + // TODO: Synchronize these APIs + when (playerState) { + PlayerState.STARTED -> { + playbackState = PlaybackStateCompat.STATE_PLAYING + playbackActions = playbackActions!! or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_STOP + } + PlayerState.COMPLETED, + PlayerState.STOPPED -> { + playbackState = PlaybackStateCompat.STATE_STOPPED + } + PlayerState.IDLE -> { + // IDLE state usually just means the playback is stopped + // STATE_NONE means that there is no track to play (playlist is empty) + playbackState = if (currentPlaying == null) + PlaybackStateCompat.STATE_NONE + else + PlaybackStateCompat.STATE_STOPPED + playbackActions = 0L + } + PlayerState.PAUSED -> { + playbackState = PlaybackStateCompat.STATE_PAUSED + playbackActions = playbackActions!! or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_STOP + } + else -> { + // These are the states PREPARING, PREPARED & DOWNLOADING + playbackState = PlaybackStateCompat.STATE_PAUSED + } + } + + val playbackStateBuilder = PlaybackStateCompat.Builder() + playbackStateBuilder.setState(playbackState!!, PLAYBACK_POSITION_UNKNOWN, 1.0f) + + // Set actions + playbackStateBuilder.setActions(playbackActions!!) + + cachedPlayingIndex = currentPlayingIndex + if (currentPlayingIndex != null) + playbackStateBuilder.setActiveQueueItemId(currentPlayingIndex) + + // Save the playback state + mediaSession!!.setPlaybackState(playbackStateBuilder.build()) + } + + fun updateMediaSessionQueue(playlist: Iterable) + { + // This call is cached because Downloader may initialize earlier than the MediaSession + cachedPlaylist = playlist + if (mediaSession == null) return + + // TODO Implement Now Playing queue handling properly + mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing)) + mediaSession!!.setQueue(playlist.mapIndexed { id, song -> + MediaSessionCompat.QueueItem( + MediaDescriptionCompat.Builder() + .setTitle(song.title) + .build(), id.toLong()) + }) + } + + fun updateMediaSessionPlaybackPosition(playbackPosition: Long) { + + if (mediaSession == null) return + + if (playbackState == null || playbackActions == null) return + + // Playback position is updated too frequently in the player. + // This counter makes sure that the MediaSession is updated ~ at every second + playbackPositionDelayCount++ + if (playbackPositionDelayCount < 10) return + + playbackPositionDelayCount = 0 + val playbackStateBuilder = PlaybackStateCompat.Builder() + playbackStateBuilder.setState(playbackState!!, playbackPosition, 1.0f) + playbackStateBuilder.setActions(playbackActions!!) + + if (cachedPlayingIndex != null) + playbackStateBuilder.setActiveQueueItemId(cachedPlayingIndex!!) + + mediaSession!!.setPlaybackState(playbackStateBuilder.build()) + } + + fun updateMediaButtonReceiver() { + if (Util.getMediaButtonsEnabled()) { + registerMediaButtonEventReceiver() + } else { + unregisterMediaButtonEventReceiver() + } + } + + private fun registerMediaButtonEventReceiver() { + val component = ComponentName(applicationContext.packageName, MediaButtonIntentReceiver::class.java.name) + val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON) + mediaButtonIntent.component = component + + val pendingIntent = PendingIntent.getBroadcast( + applicationContext, + INTENT_CODE_MEDIA_BUTTON, + mediaButtonIntent, + PendingIntent.FLAG_CANCEL_CURRENT + ) + + mediaSession?.setMediaButtonReceiver(pendingIntent) + } + + private fun unregisterMediaButtonEventReceiver() { + mediaSession?.setMediaButtonReceiver(null) + } + + // TODO Copied from MediaPlayerService. Move to Utils + private fun getPendingIntentForMediaAction( + context: Context, + keycode: Int, + requestCode: Int + ): PendingIntent { + val intent = Intent(Constants.CMD_PROCESS_KEYCODE) + val flags = PendingIntent.FLAG_UPDATE_CURRENT + intent.setPackage(context.packageName) + intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) + return PendingIntent.getBroadcast(context, requestCode, intent, flags) + } +} \ No newline at end of file From 51dbdfb39ad7d3bff8cf8c317820d22a827b87da Mon Sep 17 00:00:00 2001 From: Nite Date: Wed, 14 Jul 2021 16:09:52 +0200 Subject: [PATCH 07/14] Implemented track detail display in the playlist Fixed playback position disappearing when paused --- .../ultrasonic/util/MediaSessionHandler.kt | 80 +++++++++++++++++-- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt index 24f42e88..7e513609 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt @@ -10,6 +10,7 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN +import android.text.TextUtils import android.view.KeyEvent import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -36,6 +37,7 @@ class MediaSessionHandler : KoinComponent { private var referenceCount: Int = 0 private var cachedPlaylist: Iterable? = null private var playbackPositionDelayCount: Int = 0 + private var cachedPosition: Long = 0 fun release() { @@ -47,7 +49,7 @@ class MediaSessionHandler : KoinComponent { mediaSession?.release() mediaSession = null - Timber.i("MediaSessionHandler.initialize Media Session released") + Timber.i("MediaSessionHandler.release Media Session released") } fun initialize() { @@ -82,14 +84,14 @@ class MediaSessionHandler : KoinComponent { override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) - Timber.d("Media Session Callback: onPlayFromMediaId") + Timber.d("Media Session Callback: onPlayFromMediaId %s", mediaId) mediaSessionEventDistributor.raisePlayFromMediaIdRequestedEvent(mediaId, extras) } override fun onPlayFromSearch(query: String?, extras: Bundle?) { super.onPlayFromSearch(query, extras) - Timber.d("Media Session Callback: onPlayFromSearch") + Timber.d("Media Session Callback: onPlayFromSearch %s", query) mediaSessionEventDistributor.raisePlayFromSearchRequestedEvent(query, extras) } @@ -199,6 +201,7 @@ class MediaSessionHandler : KoinComponent { PlayerState.COMPLETED, PlayerState.STOPPED -> { playbackState = PlaybackStateCompat.STATE_STOPPED + cachedPosition = PLAYBACK_POSITION_UNKNOWN } PlayerState.IDLE -> { // IDLE state usually just means the playback is stopped @@ -208,6 +211,7 @@ class MediaSessionHandler : KoinComponent { else PlaybackStateCompat.STATE_STOPPED playbackActions = 0L + cachedPosition = PLAYBACK_POSITION_UNKNOWN } PlayerState.PAUSED -> { playbackState = PlaybackStateCompat.STATE_PAUSED @@ -222,7 +226,7 @@ class MediaSessionHandler : KoinComponent { } val playbackStateBuilder = PlaybackStateCompat.Builder() - playbackStateBuilder.setState(playbackState!!, PLAYBACK_POSITION_UNKNOWN, 1.0f) + playbackStateBuilder.setState(playbackState!!, cachedPosition, 1.0f) // Set actions playbackStateBuilder.setActions(playbackActions!!) @@ -245,14 +249,14 @@ class MediaSessionHandler : KoinComponent { mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing)) mediaSession!!.setQueue(playlist.mapIndexed { id, song -> MediaSessionCompat.QueueItem( - MediaDescriptionCompat.Builder() - .setTitle(song.title) - .build(), id.toLong()) + getMediaDescriptionForEntry(song), + id.toLong()) }) } fun updateMediaSessionPlaybackPosition(playbackPosition: Long) { + cachedPosition = playbackPosition if (mediaSession == null) return if (playbackState == null || playbackActions == null) return @@ -312,4 +316,66 @@ class MediaSessionHandler : KoinComponent { intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) return PendingIntent.getBroadcast(context, requestCode, intent, flags) } + + private fun getMediaDescriptionForEntry(song: MusicDirectory.Entry): MediaDescriptionCompat { + + val descriptionBuilder = MediaDescriptionCompat.Builder() + val artist = StringBuilder(60) + var bitRate: String? = null + + val duration = song.duration + if (duration != null) { + artist.append(String.format("%s ", Util.formatTotalDuration(duration.toLong()))) + } + + if (song.bitRate != null) + bitRate = String.format( + applicationContext.getString(R.string.song_details_kbps), song.bitRate + ) + + val fileFormat: String? + val suffix = song.suffix + val transcodedSuffix = song.transcodedSuffix + + fileFormat = if ( + TextUtils.isEmpty(transcodedSuffix) || transcodedSuffix == suffix || song.isVideo + ) suffix else String.format("%s > %s", suffix, transcodedSuffix) + + val artistName = song.artist + + if (artistName != null) { + if (Util.shouldDisplayBitrateWithArtist()) { + artist.append(artistName).append(" (").append( + String.format( + applicationContext.getString(R.string.song_details_all), + if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat + ) + ).append(')') + } else { + artist.append(artistName) + } + } + + val trackNumber = song.track ?: 0 + + val title = StringBuilder(60) + if (Util.shouldShowTrackNumber() && trackNumber > 0) + title.append(String.format("%02d - ", trackNumber)) + + title.append(song.title) + + if (song.isVideo && Util.shouldDisplayBitrateWithArtist()) { + title.append(" (").append( + String.format( + applicationContext.getString(R.string.song_details_all), + if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat + ) + ).append(')') + } + + descriptionBuilder.setTitle(title) + descriptionBuilder.setSubtitle(artist) + + return descriptionBuilder.build() + } } \ No newline at end of file From f50d6f13f445d7068892d08d4eeeea871efffbf9 Mon Sep 17 00:00:00 2001 From: Nite Date: Fri, 16 Jul 2021 17:29:21 +0200 Subject: [PATCH 08/14] Started implementing Media Browser Added root menus, playlists and artists --- .../java/org/moire/ultrasonic/util/Util.java | 1353 ----------------- .../ultrasonic/fragment/PlayerFragment.kt | 2 +- .../service/AutoMediaBrowserService.kt | 475 +++++- .../service/MediaPlayerController.kt | 6 +- .../ultrasonic/service/MediaPlayerService.kt | 2 +- .../moire/ultrasonic/subsonic/ShareHandler.kt | 16 +- .../ultrasonic/util/MediaSessionHandler.kt | 67 +- .../kotlin/org/moire/ultrasonic/util/Util.kt | 1320 ++++++++++++++++ .../src/main/res/drawable/ic_artist.xml | 10 + .../src/main/res/drawable/ic_library.xml | 11 + 10 files changed, 1768 insertions(+), 1494 deletions(-) delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt create mode 100644 ultrasonic/src/main/res/drawable/ic_artist.xml create mode 100644 ultrasonic/src/main/res/drawable/ic_library.xml diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java deleted file mode 100644 index 7d60601a..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Util.java +++ /dev/null @@ -1,1353 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.res.Resources; -import android.content.res.TypedArray; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.net.Uri; -import android.net.wifi.WifiManager; -import android.os.Build; -import android.os.Environment; -import android.os.Parcelable; -import android.util.DisplayMetrics; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import android.widget.Toast; - -import androidx.preference.PreferenceManager; - -import org.moire.ultrasonic.R; -import org.moire.ultrasonic.app.UApp; -import org.moire.ultrasonic.data.ActiveServerProvider; -import org.moire.ultrasonic.domain.Bookmark; -import org.moire.ultrasonic.domain.MusicDirectory; -import org.moire.ultrasonic.domain.MusicDirectory.Entry; -import org.moire.ultrasonic.domain.PlayerState; -import org.moire.ultrasonic.domain.RepeatMode; -import org.moire.ultrasonic.domain.SearchResult; -import org.moire.ultrasonic.service.DownloadFile; -import org.moire.ultrasonic.service.MediaPlayerService; - -import java.io.ByteArrayOutputStream; -import java.io.Closeable; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.UnsupportedEncodingException; -import java.security.MessageDigest; -import java.text.DecimalFormat; -import java.util.Locale; -import java.util.concurrent.TimeUnit; -import java.util.regex.Pattern; - -import timber.log.Timber; - -/** - * @author Sindre Mehus - * @version $Id$ - */ -public class Util -{ - private static final DecimalFormat GIGA_BYTE_FORMAT = new DecimalFormat("0.00 GB"); - private static final DecimalFormat MEGA_BYTE_FORMAT = new DecimalFormat("0.00 MB"); - private static final DecimalFormat KILO_BYTE_FORMAT = new DecimalFormat("0 KB"); - private static final Pattern PATTERN = Pattern.compile(":"); - - private static DecimalFormat GIGA_BYTE_LOCALIZED_FORMAT; - private static DecimalFormat MEGA_BYTE_LOCALIZED_FORMAT; - private static DecimalFormat KILO_BYTE_LOCALIZED_FORMAT; - private static DecimalFormat BYTE_LOCALIZED_FORMAT; - - public static final String EVENT_META_CHANGED = "org.moire.ultrasonic.EVENT_META_CHANGED"; - public static final String EVENT_PLAYSTATE_CHANGED = "org.moire.ultrasonic.EVENT_PLAYSTATE_CHANGED"; - - public static final String CM_AVRCP_PLAYSTATE_CHANGED = "com.android.music.playstatechanged"; - public static final String CM_AVRCP_METADATA_CHANGED = "com.android.music.metachanged"; - - // Used by hexEncode() - private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; - private static Toast toast; - - private static Entry currentSong; - - private Util() - { - } - - // Retrieves an instance of the application Context - public static Context appContext() { - return UApp.Companion.applicationContext(); - } - - public static boolean isScreenLitOnDownload() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD, false); - } - - public static RepeatMode getRepeatMode() - { - SharedPreferences preferences = getPreferences(); - return RepeatMode.valueOf(preferences.getString(Constants.PREFERENCES_KEY_REPEAT_MODE, RepeatMode.OFF.name())); - } - - public static void setRepeatMode(RepeatMode repeatMode) - { - SharedPreferences preferences = getPreferences(); - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name()); - editor.apply(); - } - - public static boolean isNotificationEnabled() - { - // After API26 foreground services must be used for music playback, and they must have a notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true; - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION, false); - } - - public static boolean isNotificationAlwaysEnabled() - { - // After API26 foreground services must be used for music playback, and they must have a notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true; - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION, false); - } - - public static boolean isLockScreenEnabled() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS, false); - } - - public static String getTheme() - { - SharedPreferences preferences = getPreferences(); - return preferences.getString(Constants.PREFERENCES_KEY_THEME, Constants.PREFERENCES_KEY_THEME_DARK); - } - - public static void applyTheme(Context context) - { - String theme = Util.getTheme(); - - if (Constants.PREFERENCES_KEY_THEME_DARK.equalsIgnoreCase(theme) || "fullscreen".equalsIgnoreCase(theme)) - { - context.setTheme(R.style.UltrasonicTheme); - } - else if (Constants.PREFERENCES_KEY_THEME_BLACK.equalsIgnoreCase(theme)) - { - context.setTheme(R.style.UltrasonicTheme_Black); - } - else if (Constants.PREFERENCES_KEY_THEME_LIGHT.equalsIgnoreCase(theme) || "fullscreenlight".equalsIgnoreCase(theme)) - { - context.setTheme(R.style.UltrasonicTheme_Light); - } - } - - public static ConnectivityManager getConnectivityManager() { - Context context = appContext(); - return (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - } - - public static int getMaxBitRate() - { - ConnectivityManager manager = getConnectivityManager(); - NetworkInfo networkInfo = manager.getActiveNetworkInfo(); - - if (networkInfo == null) - { - return 0; - } - - boolean wifi = networkInfo.getType() == ConnectivityManager.TYPE_WIFI; - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(wifi ? Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI : Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE, "0")); - } - - public static int getPreloadCount() - { - SharedPreferences preferences = getPreferences(); - int preloadCount = Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_PRELOAD_COUNT, "-1")); - return preloadCount == -1 ? Integer.MAX_VALUE : preloadCount; - } - - public static int getCacheSizeMB() - { - SharedPreferences preferences = getPreferences(); - int cacheSize = Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_CACHE_SIZE, "-1")); - return cacheSize == -1 ? Integer.MAX_VALUE : cacheSize; - } - - public static SharedPreferences getPreferences() { - return PreferenceManager.getDefaultSharedPreferences(appContext()); - } - - /** - * Get the contents of an InputStream as a byte[]. - *

- * This method buffers the input internally, so there is no need to use a - * BufferedInputStream. - * - * @param input the InputStream to read from - * @return the requested byte array - * @throws NullPointerException if the input is null - * @throws java.io.IOException if an I/O error occurs - */ - public static byte[] toByteArray(InputStream input) throws IOException - { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - copy(input, output); - return output.toByteArray(); - } - - public static long copy(InputStream input, OutputStream output) throws IOException - { - byte[] buffer = new byte[1024 * 4]; - long count = 0; - int n; - - while (-1 != (n = input.read(buffer))) - { - output.write(buffer, 0, n); - count += n; - } - - return count; - } - - public static void atomicCopy(File from, File to) throws IOException - { - File tmp = new File(String.format("%s.tmp", to.getPath())); - FileInputStream in = new FileInputStream(from); - FileOutputStream out = new FileOutputStream(tmp); - - try - { - in.getChannel().transferTo(0, from.length(), out.getChannel()); - out.close(); - - if (!tmp.renameTo(to)) - { - throw new IOException(String.format("Failed to rename %s to %s", tmp, to)); - } - - Timber.i("Copied %s to %s", from, to); - } - catch (IOException x) - { - close(out); - delete(to); - throw x; - } - finally - { - close(in); - close(out); - delete(tmp); - } - } - - public static void renameFile(File from, File to) throws IOException - { - if (from.renameTo(to)) - { - Timber.i("Renamed %s to %s", from, to); - } - else - { - atomicCopy(from, to); - } - } - - public static void close(Closeable closeable) - { - try - { - if (closeable != null) - { - closeable.close(); - } - } - catch (Throwable x) - { - // Ignored - } - } - - public static boolean delete(File file) - { - if (file != null && file.exists()) - { - if (!file.delete()) - { - Timber.w("Failed to delete file %s", file); - return false; - } - - Timber.i("Deleted file %s", file); - } - return true; - } - - public static void toast(Context context, int messageId) - { - toast(context, messageId, true); - } - - public static void toast(Context context, int messageId, boolean shortDuration) - { - toast(context, context.getString(messageId), shortDuration); - } - - public static void toast(Context context, CharSequence message) - { - toast(context, message, true); - } - - @SuppressLint("ShowToast") // Invalid warning - public static void toast(Context context, CharSequence message, boolean shortDuration) - { - if (toast == null) - { - toast = Toast.makeText(context, message, shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); - toast.setGravity(Gravity.CENTER, 0, 0); - } - else - { - toast.setText(message); - toast.setDuration(shortDuration ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG); - } - toast.show(); - } - - - /** - * Formats an Int to a percentage string - * For instance: - *

    - *
  • format(99) returns "99 %".
  • - *
- * - * @param percent The percent as a range from 0 - 100 - * @return The formatted string. - */ - public static synchronized String formatPercentage(int percent) - { - return Math.min(Math.max(percent,0),100) + " %"; - } - - - /** - * Converts a byte-count to a formatted string suitable for display to the user. - * For instance: - *
    - *
  • format(918) returns "918 B".
  • - *
  • format(98765) returns "96 KB".
  • - *
  • format(1238476) returns "1.2 MB".
  • - *
- * This method assumes that 1 KB is 1024 bytes. - * To get a localized string, please use formatLocalizedBytes instead. - * - * @param byteCount The number of bytes. - * @return The formatted string. - */ - public static synchronized String formatBytes(long byteCount) - { - - // More than 1 GB? - if (byteCount >= 1024 * 1024 * 1024) - { - return GIGA_BYTE_FORMAT.format((double) byteCount / (1024 * 1024 * 1024)); - } - - // More than 1 MB? - if (byteCount >= 1024 * 1024) - { - return MEGA_BYTE_FORMAT.format((double) byteCount / (1024 * 1024)); - } - - // More than 1 KB? - if (byteCount >= 1024) - { - return KILO_BYTE_FORMAT.format((double) byteCount / 1024); - } - - return byteCount + " B"; - } - - /** - * Converts a byte-count to a formatted string suitable for display to the user. - * For instance: - *
    - *
  • format(918) returns "918 B".
  • - *
  • format(98765) returns "96 KB".
  • - *
  • format(1238476) returns "1.2 MB".
  • - *
- * This method assumes that 1 KB is 1024 bytes. - * This version of the method returns a localized string. - * - * @param byteCount The number of bytes. - * @return The formatted string. - */ - public static synchronized String formatLocalizedBytes(long byteCount, Context context) - { - - // More than 1 GB? - if (byteCount >= 1024 * 1024 * 1024) - { - if (GIGA_BYTE_LOCALIZED_FORMAT == null) - { - GIGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_gigabyte)); - } - - return GIGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024 * 1024)); - } - - // More than 1 MB? - if (byteCount >= 1024 * 1024) - { - if (MEGA_BYTE_LOCALIZED_FORMAT == null) - { - MEGA_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_megabyte)); - } - - return MEGA_BYTE_LOCALIZED_FORMAT.format((double) byteCount / (1024 * 1024)); - } - - // More than 1 KB? - if (byteCount >= 1024) - { - if (KILO_BYTE_LOCALIZED_FORMAT == null) - { - KILO_BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_kilobyte)); - } - - return KILO_BYTE_LOCALIZED_FORMAT.format((double) byteCount / 1024); - } - - if (BYTE_LOCALIZED_FORMAT == null) - { - BYTE_LOCALIZED_FORMAT = new DecimalFormat(context.getResources().getString(R.string.util_bytes_format_byte)); - } - - return BYTE_LOCALIZED_FORMAT.format((double) byteCount); - } - - public static boolean equals(Object object1, Object object2) - { - return object1 == object2 || !(object1 == null || object2 == null) && object1.equals(object2); - } - - /** - * Encodes the given string by using the hexadecimal representation of its UTF-8 bytes. - * - * @param s The string to encode. - * @return The encoded string. - */ - public static String utf8HexEncode(String s) - { - if (s == null) - { - return null; - } - - byte[] utf8; - - try - { - utf8 = s.getBytes(Constants.UTF_8); - } - catch (UnsupportedEncodingException x) - { - throw new RuntimeException(x); - } - - return hexEncode(utf8); - } - - /** - * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. - * The returned array will be double the length of the passed array, as it takes two characters to represent any - * given byte. - * - * @param data Bytes to convert to hexadecimal characters. - * @return A string containing hexadecimal characters. - */ - public static String hexEncode(byte[] data) - { - int length = data.length; - char[] out = new char[length << 1]; - int j = 0; - - // two characters form the hex value. - for (byte aData : data) - { - out[j++] = HEX_DIGITS[(0xF0 & aData) >>> 4]; - out[j++] = HEX_DIGITS[0x0F & aData]; - } - - return new String(out); - } - - /** - * Calculates the MD5 digest and returns the value as a 32 character hex string. - * - * @param s Data to digest. - * @return MD5 digest as a hex string. - */ - public static String md5Hex(String s) - { - if (s == null) - { - return null; - } - - try - { - MessageDigest md5 = MessageDigest.getInstance("MD5"); - return hexEncode(md5.digest(s.getBytes(Constants.UTF_8))); - } - catch (Exception x) - { - throw new RuntimeException(x.getMessage(), x); - } - } - - public static String getGrandparent(final String path) - { - // Find the top level folder, assume it is the album artist - if (path != null) - { - int slashIndex = path.indexOf('/'); - - if (slashIndex > 0) - { - return path.substring(0, slashIndex); - } - } - - return null; - } - - public static boolean isNetworkConnected() - { - ConnectivityManager manager = getConnectivityManager(); - NetworkInfo networkInfo = manager.getActiveNetworkInfo(); - boolean connected = networkInfo != null && networkInfo.isConnected(); - - boolean wifiConnected = connected && networkInfo.getType() == ConnectivityManager.TYPE_WIFI; - boolean wifiRequired = isWifiRequiredForDownload(); - - return connected && (!wifiRequired || wifiConnected); - } - - public static boolean isExternalStoragePresent() - { - return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); - } - - private static boolean isWifiRequiredForDownload() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD, false); - } - - public static boolean shouldDisplayBitrateWithArtist() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_DISPLAY_BITRATE_WITH_ARTIST, true); - } - - public static boolean shouldUseFolderForArtistName() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_USE_FOLDER_FOR_ALBUM_ARTIST, false); - } - - public static boolean shouldShowTrackNumber() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_TRACK_NUMBER, false); - } - - - // The AlertDialog requires an Activity context, app context is not enough - // See https://stackoverflow.com/questions/5436822/ - public static void showDialog(Context context, int icon, int titleId, String message) - { - new AlertDialog.Builder(context) - .setIcon(icon) - .setTitle(titleId) - .setMessage(message) - .setPositiveButton(R.string.common_ok, (dialog, i) -> dialog.dismiss()) - .show(); - } - - - public static void sleepQuietly(long millis) - { - try - { - Thread.sleep(millis); - } - catch (InterruptedException x) - { - Timber.w(x, "Interrupted from sleep."); - } - } - - public static Drawable getDrawableFromAttribute(Context context, int attr) - { - int[] attrs = new int[]{attr}; - TypedArray ta = context.obtainStyledAttributes(attrs); - Drawable drawableFromTheme = null; - - if (ta != null) - { - drawableFromTheme = ta.getDrawable(0); - ta.recycle(); - } - - return drawableFromTheme; - } - - public static Drawable createDrawableFromBitmap(Context context, Bitmap bitmap) - { - return new BitmapDrawable(context.getResources(), bitmap); - } - - public static Bitmap createBitmapFromDrawable(Drawable drawable) { - if (drawable instanceof BitmapDrawable) { - return ((BitmapDrawable)drawable).getBitmap(); - } - - Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); - drawable.draw(canvas); - - return bitmap; - } - - public static WifiManager.WifiLock createWifiLock(String tag) - { - WifiManager wm = (WifiManager) appContext().getApplicationContext().getSystemService(Context.WIFI_SERVICE); - return wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, tag); - } - - public static int getScaledHeight(double height, double width, int newWidth) - { - // Try to keep correct aspect ratio of the original image, do not force a square - double aspectRatio = height / width; - - // Assume the size given refers to the width of the image, so calculate the new height using - // the previously determined aspect ratio - return (int) Math.round(newWidth * aspectRatio); - } - - public static int getScaledHeight(Bitmap bitmap, int width) - { - return getScaledHeight(bitmap.getHeight(), bitmap.getWidth(), width); - } - - public static Bitmap scaleBitmap(Bitmap bitmap, int size) - { - if (bitmap == null) return null; - return Bitmap.createScaledBitmap(bitmap, size, getScaledHeight(bitmap, size), true); - } - - public static MusicDirectory getSongsFromSearchResult(SearchResult searchResult) - { - MusicDirectory musicDirectory = new MusicDirectory(); - - for (Entry entry : searchResult.getSongs()) - { - musicDirectory.addChild(entry); - } - - return musicDirectory; - } - - public static MusicDirectory getSongsFromBookmarks(Iterable bookmarks) { - MusicDirectory musicDirectory = new MusicDirectory(); - - MusicDirectory.Entry song; - for (Bookmark bookmark : bookmarks) { - song = bookmark.getEntry(); - song.setBookmarkPosition(bookmark.getPosition()); - musicDirectory.addChild(song); - } - - return musicDirectory; - } - - /** - *

Broadcasts the given song info as the new song being played.

- */ - public static void broadcastNewTrackInfo(Context context, Entry song) - { - Intent intent = new Intent(EVENT_META_CHANGED); - - if (song != null) - { - intent.putExtra("title", song.getTitle()); - intent.putExtra("artist", song.getArtist()); - intent.putExtra("album", song.getAlbum()); - - File albumArtFile = FileUtil.getAlbumArtFile(song); - intent.putExtra("coverart", albumArtFile.getAbsolutePath()); - } - else - { - intent.putExtra("title", ""); - intent.putExtra("artist", ""); - intent.putExtra("album", ""); - intent.putExtra("coverart", ""); - } - - context.sendBroadcast(intent); - } - - public static void broadcastA2dpMetaDataChange(Context context, int playerPosition, DownloadFile currentPlaying, int listSize, int id) - { - if (!Util.getShouldSendBluetoothNotifications()) - { - return; - } - - Entry song = null; - Intent avrcpIntent = new Intent(CM_AVRCP_METADATA_CHANGED); - - if (currentPlaying != null) song = currentPlaying.getSong(); - - if (song == null) - { - avrcpIntent.putExtra("track", ""); - avrcpIntent.putExtra("track_name", ""); - avrcpIntent.putExtra("artist", ""); - avrcpIntent.putExtra("artist_name", ""); - avrcpIntent.putExtra("album", ""); - avrcpIntent.putExtra("album_name", ""); - avrcpIntent.putExtra("album_artist", ""); - avrcpIntent.putExtra("album_artist_name", ""); - - if (Util.getShouldSendBluetoothAlbumArt()) - { - avrcpIntent.putExtra("coverart", (Parcelable) null); - avrcpIntent.putExtra("cover", (Parcelable) null); - } - - avrcpIntent.putExtra("ListSize", (long) 0); - avrcpIntent.putExtra("id", (long) 0); - avrcpIntent.putExtra("duration", (long) 0); - avrcpIntent.putExtra("position", (long) 0); - } - else - { - if (song != currentSong) - { - currentSong = song; - } - - String title = song.getTitle(); - String artist = song.getArtist(); - String album = song.getAlbum(); - Integer duration = song.getDuration(); - - avrcpIntent.putExtra("track", title); - avrcpIntent.putExtra("track_name", title); - avrcpIntent.putExtra("artist", artist); - avrcpIntent.putExtra("artist_name", artist); - avrcpIntent.putExtra("album", album); - avrcpIntent.putExtra("album_name", album); - avrcpIntent.putExtra("album_artist", artist); - avrcpIntent.putExtra("album_artist_name", artist); - - - if (Util.getShouldSendBluetoothAlbumArt()) - { - File albumArtFile = FileUtil.getAlbumArtFile(song); - avrcpIntent.putExtra("coverart", albumArtFile.getAbsolutePath()); - avrcpIntent.putExtra("cover", albumArtFile.getAbsolutePath()); - } - - avrcpIntent.putExtra("position", (long) playerPosition); - avrcpIntent.putExtra("id", (long) id); - avrcpIntent.putExtra("ListSize", (long) listSize); - - if (duration != null) - { - avrcpIntent.putExtra("duration", (long) duration); - } - } - - context.sendBroadcast(avrcpIntent); - } - - public static void broadcastA2dpPlayStatusChange(Context context, PlayerState state, Entry currentSong, Integer listSize, Integer id, Integer playerPosition) - { - if (!Util.getShouldSendBluetoothNotifications()) - { - return; - } - - if (currentSong != null) - { - Intent avrcpIntent = new Intent(CM_AVRCP_PLAYSTATE_CHANGED); - - if (currentSong == null) - { - return; - } - - // FIXME: This is probably a bug. - if (currentSong != currentSong) - { - Util.currentSong = currentSong; - } - - String title = currentSong.getTitle(); - String artist = currentSong.getArtist(); - String album = currentSong.getAlbum(); - Integer duration = currentSong.getDuration(); - - avrcpIntent.putExtra("track", title); - avrcpIntent.putExtra("track_name", title); - avrcpIntent.putExtra("artist", artist); - avrcpIntent.putExtra("artist_name", artist); - avrcpIntent.putExtra("album", album); - avrcpIntent.putExtra("album_name", album); - avrcpIntent.putExtra("album_artist", artist); - avrcpIntent.putExtra("album_artist_name", artist); - - if (Util.getShouldSendBluetoothAlbumArt()) - { - File albumArtFile = FileUtil.getAlbumArtFile(currentSong); - avrcpIntent.putExtra("coverart", albumArtFile.getAbsolutePath()); - avrcpIntent.putExtra("cover", albumArtFile.getAbsolutePath()); - } - - avrcpIntent.putExtra("position", (long) playerPosition); - avrcpIntent.putExtra("id", (long) id); - avrcpIntent.putExtra("ListSize", (long) listSize); - - if (duration != null) - { - avrcpIntent.putExtra("duration", (long) duration); - } - - switch (state) - { - case STARTED: - avrcpIntent.putExtra("playing", true); - break; - case STOPPED: - case PAUSED: - case COMPLETED: - avrcpIntent.putExtra("playing", false); - break; - default: - return; // No need to broadcast. - } - - context.sendBroadcast(avrcpIntent); - } - } - - /** - *

Broadcasts the given player state as the one being set.

- */ - public static void broadcastPlaybackStatusChange(Context context, PlayerState state) - { - Intent intent = new Intent(EVENT_PLAYSTATE_CHANGED); - - switch (state) - { - case STARTED: - intent.putExtra("state", "play"); - break; - case STOPPED: - intent.putExtra("state", "stop"); - break; - case PAUSED: - intent.putExtra("state", "pause"); - break; - case COMPLETED: - intent.putExtra("state", "complete"); - break; - default: - return; // No need to broadcast. - } - - context.sendBroadcast(intent); - } - - public static int getNotificationImageSize(Context context) - { - DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - int imageSizeLarge = Math.round(Math.min(metrics.widthPixels, metrics.heightPixels)); - - int size; - - if (imageSizeLarge <= 480) - { - size = 64; - } - - else size = imageSizeLarge <= 768 ? 128 : 256; - - return size; - } - - public static int getAlbumImageSize(Context context) - { - DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - int imageSizeLarge = Math.round(Math.min(metrics.widthPixels, metrics.heightPixels)); - - int size; - - if (imageSizeLarge <= 480) - { - size = 128; - } - - else size = imageSizeLarge <= 768 ? 256 : 512; - - return size; - } - - public static int getMinDisplayMetric() - { - DisplayMetrics metrics = appContext().getResources().getDisplayMetrics(); - return Math.min(metrics.widthPixels, metrics.heightPixels); - } - - public static int getMaxDisplayMetric() - { - DisplayMetrics metrics = appContext().getResources().getDisplayMetrics(); - return Math.max(metrics.widthPixels, metrics.heightPixels); - } - - public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) - { - // Raw height and width of image - final int height = options.outHeight; - final int width = options.outWidth; - int inSampleSize = 1; - - if (height > reqHeight || width > reqWidth) - { - - // Calculate ratios of height and width to requested height and - // width - final int heightRatio = Math.round((float) height / (float) reqHeight); - final int widthRatio = Math.round((float) width / (float) reqWidth); - - // Choose the smallest ratio as inSampleSize value, this will - // guarantee - // a final image with both dimensions larger than or equal to the - // requested height and width. - inSampleSize = Math.min(heightRatio, widthRatio); - } - - return inSampleSize; - } - - public static int getDefaultAlbums() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_ALBUMS, "5")); - } - - public static int getMaxAlbums() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_MAX_ALBUMS, "20")); - } - - public static int getDefaultSongs() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SONGS, "10")); - } - - public static int getMaxSongs() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_MAX_SONGS, "25")); - } - - public static int getMaxArtists() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_MAX_ARTISTS, "10")); - } - - public static int getDefaultArtists() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_ARTISTS, "3")); - } - - public static int getBufferLength() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_BUFFER_LENGTH, "5")); - } - - public static int getIncrementTime() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_INCREMENT_TIME, "5")); - } - - public static boolean getMediaButtonsEnabled() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true); - } - - public static boolean getShowNowPlayingPreference() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING, true); - } - - public static boolean getGaplessPlaybackPreference() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_GAPLESS_PLAYBACK, false); - } - - public static boolean getShouldTransitionOnPlaybackPreference() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_DOWNLOAD_TRANSITION, true); - } - - public static boolean getShouldUseId3Tags() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false); - } - - public static boolean getShouldShowArtistPicture() - { - SharedPreferences preferences = getPreferences(); - boolean isOffline = ActiveServerProvider.Companion.isOffline(); - boolean isId3Enabled = preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false); - boolean shouldShowArtistPicture = preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false); - return (!isOffline) && isId3Enabled && shouldShowArtistPicture; - } - - public static int getChatRefreshInterval() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_CHAT_REFRESH_INTERVAL, "5000")); - } - - public static int getDirectoryCacheTime() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_DIRECTORY_CACHE_TIME, "300")); - } - - public static boolean isNullOrWhiteSpace(String string) - { - return string == null || string.isEmpty() || string.trim().isEmpty(); - } - - public static String formatTotalDuration(long totalDuration) - { - return formatTotalDuration(totalDuration, false); - } - - public static boolean getShouldClearPlaylist() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_CLEAR_PLAYLIST, false); - } - - public static boolean getShouldSortByDisc() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_DISC_SORT, false); - } - - public static boolean getShouldClearBookmark() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_CLEAR_BOOKMARK, false); - } - - public static boolean getSingleButtonPlayPause() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SINGLE_BUTTON_PLAY_PAUSE, false); - } - - public static String formatTotalDuration(long totalDuration, boolean inMilliseconds) - { - long millis = totalDuration; - - if (!inMilliseconds) - { - millis = totalDuration * 1000; - } - - long hours = TimeUnit.MILLISECONDS.toHours(millis); - long minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - TimeUnit.HOURS.toMinutes(hours); - long seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(hours * 60 + minutes); - - if (hours >= 10) - { - return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds); - } - else if (hours > 0) - { - return String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds); - } - else if (minutes >= 10) - { - return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds); - } - - else return minutes > 0 ? String.format(Locale.getDefault(), "%d:%02d", minutes, seconds) : String.format(Locale.getDefault(), "0:%02d", seconds); - } - - public static String getVersionName(Context context) - { - String versionName = null; - - PackageManager pm = context.getPackageManager(); - - if (pm != null) - { - String packageName = context.getPackageName(); - - try - { - versionName = pm.getPackageInfo(packageName, 0).versionName; - } - catch (PackageManager.NameNotFoundException ignored) - { - - } - } - - return versionName; - } - - public static int getVersionCode(Context context) - { - int versionCode = 0; - - PackageManager pm = context.getPackageManager(); - - if (pm != null) - { - String packageName = context.getPackageName(); - - try - { - versionCode = pm.getPackageInfo(packageName, 0).versionCode; - } - catch (PackageManager.NameNotFoundException ignored) - { - - } - } - - return versionCode; - } - - @SuppressWarnings("BooleanMethodIsAlwaysInverted") // Inverted for readability - public static boolean getShouldSendBluetoothNotifications() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS, true); - } - - public static boolean getShouldSendBluetoothAlbumArt() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART, true); - } - - public static int getViewRefreshInterval() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_VIEW_REFRESH, "1000")); - } - - public static boolean getShouldAskForShareDetails() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, true); - } - - public static String getDefaultShareDescription() - { - SharedPreferences preferences = getPreferences(); - return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION, ""); - } - - public static String getShareGreeting() - { - SharedPreferences preferences = getPreferences(); - Context context = appContext(); - String defaultVal = String.format(context.getResources().getString(R.string.share_default_greeting), context.getResources().getString(R.string.common_appname)); - return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_GREETING, defaultVal); - } - - public static String getDefaultShareExpiration() - { - SharedPreferences preferences = getPreferences(); - return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, "0"); - } - - public static long getDefaultShareExpirationInMillis(Context context) - { - SharedPreferences preferences = getPreferences(); - String preference = preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, "0"); - - String[] split = PATTERN.split(preference); - - if (split.length == 2) - { - int timeSpanAmount = Integer.parseInt(split[0]); - String timeSpanType = split[1]; - - TimeSpan timeSpan = TimeSpanPicker.calculateTimeSpan(context, timeSpanType, timeSpanAmount); - - return timeSpan.getTotalMilliseconds(); - } - - return 0; - } - - public static void setShouldAskForShareDetails(boolean shouldAskForShareDetails) - { - SharedPreferences preferences = getPreferences(); - SharedPreferences.Editor editor = preferences.edit(); - editor.putBoolean(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, shouldAskForShareDetails); - editor.apply(); - } - - public static void setDefaultShareExpiration(String defaultShareExpiration) - { - SharedPreferences preferences = getPreferences(); - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, defaultShareExpiration); - editor.apply(); - } - - public static void setDefaultShareDescription(String defaultShareDescription) - { - SharedPreferences preferences = getPreferences(); - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION, defaultShareDescription); - editor.apply(); - } - - public static boolean getShouldShowAllSongsByArtist() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST, false); - } - - public static void scanMedia(File file) - { - Uri uri = Uri.fromFile(file); - Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri); - appContext().sendBroadcast(scanFileIntent); - } - - public static int getImageLoaderConcurrency() - { - SharedPreferences preferences = getPreferences(); - return Integer.parseInt(preferences.getString(Constants.PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY, "5")); - } - - public static int getResourceFromAttribute(Context context, int resId) - { - TypedValue typedValue = new TypedValue(); - Resources.Theme theme = context.getTheme(); - theme.resolveAttribute(resId, typedValue, true); - return typedValue.resourceId; - } - - public static boolean isFirstRun() - { - SharedPreferences preferences = getPreferences(); - boolean firstExecuted = preferences.getBoolean(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, false); - if (firstExecuted) return false; - SharedPreferences.Editor editor = preferences.edit(); - editor.putBoolean(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, true); - editor.apply(); - return true; - } - - public static int getResumeOnBluetoothDevice() - { - SharedPreferences preferences = getPreferences(); - return preferences.getInt(Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE, Constants.PREFERENCE_VALUE_DISABLED); - } - - public static int getPauseOnBluetoothDevice() - { - SharedPreferences preferences = getPreferences(); - return preferences.getInt(Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE, Constants.PREFERENCE_VALUE_A2DP); - } - - public static boolean getDebugLogToFile() - { - SharedPreferences preferences = getPreferences(); - return preferences.getBoolean(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE, false); - } - - public static void hideKeyboard(Activity activity) { - InputMethodManager inputManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE); - - View currentFocusedView = activity.getCurrentFocus(); - if (currentFocusedView != null) { - inputManager.hideSoftInputFromWindow(currentFocusedView.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt index af4f7be9..718f34a1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/PlayerFragment.kt @@ -313,7 +313,7 @@ class PlayerFragment : Fragment(), GestureDetector.OnGestureListener, KoinCompon } repeatButton.setOnClickListener { - val repeatMode = mediaPlayerController.repeatMode?.next() + val repeatMode = mediaPlayerController.repeatMode.next() mediaPlayerController.repeatMode = repeatMode onDownloadListChanged() when (repeatMode) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index 1a9f0534..562c26a3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -7,26 +7,65 @@ import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject +import org.moire.ultrasonic.R +import org.moire.ultrasonic.data.ActiveServerProvider +import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.util.MediaSessionEventDistributor import org.moire.ultrasonic.util.MediaSessionEventListener import org.moire.ultrasonic.util.MediaSessionHandler +import org.moire.ultrasonic.util.Util import timber.log.Timber +const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID" +const val MEDIA_ALBUM_ID = "MEDIA_ALBUM_ID" +const val MEDIA_ALBUM_NEWEST_ID = "MEDIA_ALBUM_NEWEST_ID" +const val MEDIA_ALBUM_RECENT_ID = "MEDIA_ALBUM_RECENT_ID" +const val MEDIA_ALBUM_FREQUENT_ID = "MEDIA_ALBUM_FREQUENT_ID" +const val MEDIA_ALBUM_RANDOM_ID = "MEDIA_ALBUM_RANDOM_ID" +const val MEDIA_ALBUM_STARRED_ID = "MEDIA_ALBUM_STARRED_ID" +const val MEDIA_SONG_RANDOM_ID = "MEDIA_SONG_RANDOM_ID" +const val MEDIA_SONG_STARRED_ID = "MEDIA_SONG_STARRED_ID" +const val MEDIA_ARTIST_ID = "MEDIA_ARTIST_ID" +const val MEDIA_LIBRARY_ID = "MEDIA_LIBRARY_ID" +const val MEDIA_PLAYLIST_ID = "MEDIA_PLAYLIST_ID" +const val MEDIA_SHARE_ID = "MEDIA_SHARE_ID" +const val MEDIA_BOOKMARK_ID = "MEDIA_BOOKMARK_ID" +const val MEDIA_PODCAST_ID = "MEDIA_PODCAST_ID" +const val MEDIA_ALBUM_ITEM = "MEDIA_ALBUM_ITEM" +const val MEDIA_PLAYLIST_SONG_ITEM = "MEDIA_PLAYLIST_SONG_ITEM" +const val MEDIA_PLAYLIST_ITEM = "MEDIA_ALBUM_ITEM" +const val MEDIA_ARTIST_ITEM = "MEDIA_ARTIST_ITEM" +const val MEDIA_ARTIST_SECTION = "MEDIA_ARTIST_SECTION" -const val MY_MEDIA_ROOT_ID = "MY_MEDIA_ROOT_ID" -const val MY_MEDIA_ALBUM_ID = "MY_MEDIA_ALBUM_ID" -const val MY_MEDIA_ARTIST_ID = "MY_MEDIA_ARTIST_ID" -const val MY_MEDIA_ALBUM_ITEM = "MY_MEDIA_ALBUM_ITEM" -const val MY_MEDIA_LIBRARY_ID = "MY_MEDIA_LIBRARY_ID" -const val MY_MEDIA_PLAYLIST_ID = "MY_MEDIA_PLAYLIST_ID" +// Currently the display limit for long lists is 100 items +const val displayLimit = 100 +/** + * MediaBrowserService implementation for e.g. Android Auto + */ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private lateinit var mediaSessionEventListener: MediaSessionEventListener private val mediaSessionEventDistributor by inject() private val lifecycleSupport by inject() private val mediaSessionHandler by inject() + private val mediaPlayerController by inject() + private val activeServerProvider: ActiveServerProvider by inject() + private val musicService by lazy { MusicServiceFactory.getMusicService() } + + private val serviceJob = Job() + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + + private var playlistCache: List? = null + + private val isOffline get() = ActiveServerProvider.isOffline() + private val useId3Tags get() = Util.getShouldUseId3Tags() + private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId override fun onCreate() { super.onCreate() @@ -39,7 +78,15 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) { - // TODO implement + Timber.d("AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", mediaId) + + if (mediaId == null) return + val mediaIdParts = mediaId.split('|') + + when (mediaIdParts.first()) { + MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2]) + MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]) + } } override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) { @@ -65,6 +112,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { super.onDestroy() mediaSessionEventDistributor.unsubscribe(mediaSessionEventListener) mediaSessionHandler.release() + serviceJob.cancel() Timber.i("AutoMediaBrowserService onDestroy finished") } @@ -73,20 +121,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? { - Timber.d("AutoMediaBrowserService onGetRoot called") - - // TODO: The number of horizontal items available on the Andoid Auto screen. Check and handle. - val maximumRootChildLimit = rootHints!!.getInt( - MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, - 4 - ) - - // TODO: The type of the horizontal items children on the Android Auto screen. Check and handle. - val supportedRootChildFlags = rootHints!!.getInt( - MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS, - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + ): BrowserRoot { + Timber.d("AutoMediaBrowserService onGetRoot called. clientPackageName: %s; clientUid: %d", clientPackageName, clientUid) val extras = Bundle() extras.putInt( @@ -96,19 +132,37 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) - return BrowserRoot(MY_MEDIA_ROOT_ID, extras) + return BrowserRoot(MEDIA_ROOT_ID, extras) } override fun onLoadChildren( parentId: String, result: Result> ) { - Timber.d("AutoMediaBrowserService onLoadChildren called") + Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) - if (parentId == MY_MEDIA_ROOT_ID) { - return getRootItems(result) - } else { - return getAlbumLists(result) + val parentIdParts = parentId.split('|') + + when (parentIdParts.first()) { + MEDIA_ROOT_ID -> return getRootItems(result) + MEDIA_LIBRARY_ID -> return getLibrary(result) + MEDIA_ARTIST_ID -> return getArtists(result) + MEDIA_ARTIST_SECTION -> return getArtists(result, parentIdParts[1]) + MEDIA_ALBUM_ID -> return getAlbums(result) + MEDIA_PLAYLIST_ID -> return getPlaylists(result) + MEDIA_ALBUM_FREQUENT_ID -> return getFrequentAlbums(result) + MEDIA_ALBUM_NEWEST_ID -> return getNewestAlbums(result) + MEDIA_ALBUM_RECENT_ID -> return getRecentAlbums(result) + MEDIA_ALBUM_RANDOM_ID -> return getRandomAlbums(result) + MEDIA_ALBUM_STARRED_ID -> return getStarredAlbums(result) + MEDIA_SONG_RANDOM_ID -> return getRandomSongs(result) + MEDIA_SONG_STARRED_ID -> return getStarredSongs(result) + MEDIA_SHARE_ID -> return getShares(result) + MEDIA_BOOKMARK_ID -> return getBookmarks(result) + MEDIA_PODCAST_ID -> return getPodcasts(result) + MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2], result) + MEDIA_ARTIST_ITEM -> return getAlbums(result, parentIdParts[1]) + else -> result.sendResult(mutableListOf()) } } @@ -118,70 +172,361 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { result: Result> ) { super.onSearch(query, extras, result) + // TODO implement } private fun getRootItems(result: Result>) { val mediaItems: MutableList = ArrayList() - // TODO implement this with proper texts, icons, etc mediaItems.add( - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Library") - .setMediaId(MY_MEDIA_LIBRARY_ID) - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + R.string.music_library_label, + MEDIA_LIBRARY_ID, + R.drawable.ic_library, + null ) mediaItems.add( - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Artists") - .setMediaId(MY_MEDIA_ARTIST_ID) - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + R.string.main_artists_title, + MEDIA_ARTIST_ID, + R.drawable.ic_artist, + null ) mediaItems.add( - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Albums") - .setMediaId(MY_MEDIA_ALBUM_ID) - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + R.string.main_albums_title, + MEDIA_ALBUM_ID, + R.drawable.ic_menu_browse_dark, + null ) mediaItems.add( - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Playlists") - .setMediaId(MY_MEDIA_PLAYLIST_ID) - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + R.string.playlist_label, + MEDIA_PLAYLIST_ID, + R.drawable.ic_menu_playlists_dark, + null ) result.sendResult(mediaItems) } - private fun getAlbumLists(result: Result>) { + private fun getLibrary(result: Result>) { val mediaItems: MutableList = ArrayList() - val description = MediaDescriptionCompat.Builder() - .setTitle("Test") - .setMediaId(MY_MEDIA_ALBUM_ITEM + 1) - .build() + // Songs + mediaItems.add( + R.string.main_songs_random, + MEDIA_SONG_RANDOM_ID, + null, + R.string.main_songs_title + ) mediaItems.add( - MediaBrowserCompat.MediaItem( - description, - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) + R.string.main_songs_starred, + MEDIA_SONG_STARRED_ID, + null, + R.string.main_songs_title ) + // Albums + mediaItems.add( + R.string.main_albums_newest, + MEDIA_ALBUM_NEWEST_ID, + null, + R.string.main_albums_title + ) + + mediaItems.add( + R.string.main_albums_recent, + MEDIA_ALBUM_RECENT_ID, + null, + R.string.main_albums_title + ) + + mediaItems.add( + R.string.main_albums_frequent, + MEDIA_ALBUM_FREQUENT_ID, + null, + R.string.main_albums_title + ) + + mediaItems.add( + R.string.main_albums_random, + MEDIA_ALBUM_RANDOM_ID, + null, + R.string.main_albums_title + ) + + mediaItems.add( + R.string.main_albums_starred, + MEDIA_ALBUM_STARRED_ID, + null, + R.string.main_albums_title + ) + + // Other + mediaItems.add(R.string.button_bar_shares, MEDIA_SHARE_ID, null, null) + mediaItems.add(R.string.button_bar_bookmarks, MEDIA_BOOKMARK_ID, null, null) + mediaItems.add(R.string.button_bar_podcasts, MEDIA_PODCAST_ID, null, null) + result.sendResult(mediaItems) } + + private fun getArtists(result: Result>, section: String? = null) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + var artists = if (!isOffline && useId3Tags) { + // TODO this list can be big so we're not refreshing. + // Maybe a refresh menu item can be added + musicService.getArtists(false) + } else { + musicService.getIndexes(musicFolderId, false) + } + + if (section != null) + artists = artists.filter { + artist -> getSectionFromName(artist.name ?: "") == section + } + + // If there are too many artists, create alphabetic index of them + if (section == null && artists.count() > displayLimit) { + val index = mutableListOf() + // TODO This sort should use ignoredArticles somehow... + artists = artists.sortedBy { artist -> artist.name } + artists.map { artist -> + val currentSection = getSectionFromName(artist.name ?: "") + if (!index.contains(currentSection)) { + index.add(currentSection) + mediaItems.add( + currentSection, + listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"), + null + ) + } + } + } else { + artists.map { artist -> + mediaItems.add( + artist.name ?: "", + listOf(MEDIA_ARTIST_ITEM, artist.id).joinToString("|"), + null + ) + } + } + result.sendResult(mediaItems) + } + } + + private fun getAlbums( + result: Result>, + artistId: String? = null + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getPlaylists(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val playlists = musicService.getPlaylists(true) + playlists.map { playlist -> + mediaItems.add( + playlist.name, + listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name) + .joinToString("|"), + null + ) + } + result.sendResult(mediaItems) + } + } + + private fun getPlaylist(id: String, name: String, result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val content = musicService.getPlaylist(id, name) + + mediaItems.add( + R.string.select_album_play_all, + listOf(MEDIA_PLAYLIST_ITEM, id, name).joinToString("|"), + R.drawable.ic_stat_play_dark, + null, + false + ) + + // Playlist should be cached as it may contain random elements + playlistCache = content.getAllChild() + playlistCache!!.take(displayLimit).map { item -> + mediaItems.add(MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + item, + listOf(MEDIA_PLAYLIST_SONG_ITEM, id, name, item.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + )) + } + result.sendResult(mediaItems) + } + } + + private fun playPlaylist(id: String, name: String) { + serviceScope.launch { + if (playlistCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = musicService.getPlaylist(id, name) + playlistCache = content.getAllChild() + } + mediaPlayerController.download( + playlistCache, + save = false, + autoPlay = true, + playNext = false, + shuffle = false, + newPlaylist = true + ) + } + } + + private fun playPlaylistSong(id: String, name: String, songId: String) { + serviceScope.launch { + if (playlistCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = musicService.getPlaylist(id, name) + playlistCache = content.getAllChild() + } + val song = playlistCache!!.firstOrNull{x -> x.id == songId} + if (song != null) { + mediaPlayerController.download( + listOf(song), + save = false, + autoPlay = false, + playNext = true, + shuffle = false, + newPlaylist = false + ) + mediaPlayerController.next() + } + } + } + + private fun getPodcasts(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getBookmarks(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getShares(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getStarredSongs(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getRandomSongs(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getStarredAlbums(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getRandomAlbums(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getRecentAlbums(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getNewestAlbums(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun getFrequentAlbums(result: Result>) { + val mediaItems: MutableList = ArrayList() + result.detach() + result.sendResult(mediaItems) + } + + private fun MutableList.add( + title: String, + mediaId: String, + icon: Int?, + ) { + val builder = MediaDescriptionCompat.Builder() + builder.setTitle(title) + builder.setMediaId(mediaId) + + if (icon != null) + builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) + + val mediaItem = MediaBrowserCompat.MediaItem( + builder.build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + + this.add(mediaItem) + } + + private fun MutableList.add( + resId: Int, + mediaId: String, + icon: Int?, + groupNameId: Int?, + browsable: Boolean = true + ) { + val builder = MediaDescriptionCompat.Builder() + builder.setTitle(getString(resId)) + builder.setMediaId(mediaId) + + if (icon != null) + builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) + + if (groupNameId != null) + builder.setExtras(Bundle().apply { putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + getString(groupNameId) + ) }) + + val mediaItem = MediaBrowserCompat.MediaItem( + builder.build(), + if (browsable) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + + this.add(mediaItem) + } + + private fun getSectionFromName(name: String): String { + var section = name.first().uppercaseChar() + if (!section.isLetter()) section = '#' + return section.toString() + } } \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt index b18eb0fa..994f06e8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerController.kt @@ -247,10 +247,10 @@ class MediaPlayerController( } @set:Synchronized - var repeatMode: RepeatMode? - get() = Util.getRepeatMode() + var repeatMode: RepeatMode + get() = Util.repeatMode set(repeatMode) { - Util.setRepeatMode(repeatMode) + Util.repeatMode = repeatMode val mediaPlayerService = runningInstance mediaPlayerService?.setNextPlaying() } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 04b3ba4e..032e6dbd 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -65,7 +65,7 @@ class MediaPlayerService : Service() { private lateinit var mediaSessionEventListener: MediaSessionEventListener private val repeatMode: RepeatMode - get() = Util.getRepeatMode() + get() = Util.repeatMode override fun onBind(intent: Intent): IBinder { return binder diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt index 4ae89551..52d2a403 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/subsonic/ShareHandler.kt @@ -40,13 +40,13 @@ class ShareHandler(val context: Context) { swipe: SwipeRefreshLayout?, cancellationToken: CancellationToken ) { - val askForDetails = Util.getShouldAskForShareDetails() + val askForDetails = Util.shouldAskForShareDetails val shareDetails = ShareDetails() shareDetails.Entries = entries if (askForDetails) { showDialog(fragment, shareDetails, swipe, cancellationToken) } else { - shareDetails.Description = Util.getDefaultShareDescription() + shareDetails.Description = Util.defaultShareDescription shareDetails.Expiration = TimeSpan.getCurrentTime().add( Util.getDefaultShareExpirationInMillis(context) ).totalMilliseconds @@ -133,16 +133,16 @@ class ShareHandler(val context: Context) { } shareDetails.Description = shareDescription!!.text.toString() if (hideDialogCheckBox!!.isChecked) { - Util.setShouldAskForShareDetails(false) + Util.shouldAskForShareDetails = false } if (saveAsDefaultsCheckBox!!.isChecked) { val timeSpanType: String = timeSpanPicker!!.timeSpanType val timeSpanAmount: Int = timeSpanPicker!!.timeSpanAmount - Util.setDefaultShareExpiration( + Util.defaultShareExpiration = if (!noExpirationCheckBox!!.isChecked && timeSpanAmount > 0) String.format("%d:%s", timeSpanAmount, timeSpanType) else "" - ) - Util.setDefaultShareDescription(shareDetails.Description) + + Util.defaultShareDescription = shareDetails.Description } share(fragment, shareDetails, swipe, cancellationToken) } @@ -157,8 +157,8 @@ class ShareHandler(val context: Context) { b -> timeSpanPicker!!.isEnabled = !b } - val defaultDescription = Util.getDefaultShareDescription() - val timeSpan = Util.getDefaultShareExpiration() + val defaultDescription = Util.defaultShareDescription + val timeSpan = Util.defaultShareExpiration val split = pattern.split(timeSpan) if (split.size == 2) { val timeSpanAmount = split[0].toInt() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt index 7e513609..7271f9c8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt @@ -24,6 +24,9 @@ import timber.log.Timber private const val INTENT_CODE_MEDIA_BUTTON = 161 +/** + * Central place to handle the state of the MediaSession + */ class MediaSessionHandler : KoinComponent { private var mediaSession: MediaSessionCompat? = null @@ -249,7 +252,7 @@ class MediaSessionHandler : KoinComponent { mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing)) mediaSession!!.setQueue(playlist.mapIndexed { id, song -> MediaSessionCompat.QueueItem( - getMediaDescriptionForEntry(song), + Util.getMediaDescriptionForEntry(song), id.toLong()) }) } @@ -316,66 +319,4 @@ class MediaSessionHandler : KoinComponent { intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) return PendingIntent.getBroadcast(context, requestCode, intent, flags) } - - private fun getMediaDescriptionForEntry(song: MusicDirectory.Entry): MediaDescriptionCompat { - - val descriptionBuilder = MediaDescriptionCompat.Builder() - val artist = StringBuilder(60) - var bitRate: String? = null - - val duration = song.duration - if (duration != null) { - artist.append(String.format("%s ", Util.formatTotalDuration(duration.toLong()))) - } - - if (song.bitRate != null) - bitRate = String.format( - applicationContext.getString(R.string.song_details_kbps), song.bitRate - ) - - val fileFormat: String? - val suffix = song.suffix - val transcodedSuffix = song.transcodedSuffix - - fileFormat = if ( - TextUtils.isEmpty(transcodedSuffix) || transcodedSuffix == suffix || song.isVideo - ) suffix else String.format("%s > %s", suffix, transcodedSuffix) - - val artistName = song.artist - - if (artistName != null) { - if (Util.shouldDisplayBitrateWithArtist()) { - artist.append(artistName).append(" (").append( - String.format( - applicationContext.getString(R.string.song_details_all), - if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat - ) - ).append(')') - } else { - artist.append(artistName) - } - } - - val trackNumber = song.track ?: 0 - - val title = StringBuilder(60) - if (Util.shouldShowTrackNumber() && trackNumber > 0) - title.append(String.format("%02d - ", trackNumber)) - - title.append(song.title) - - if (song.isVideo && Util.shouldDisplayBitrateWithArtist()) { - title.append(" (").append( - String.format( - applicationContext.getString(R.string.song_details_all), - if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat - ) - ).append(')') - } - - descriptionBuilder.setTitle(title) - descriptionBuilder.setSubtitle(artist) - - return descriptionBuilder.build() - } } \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt new file mode 100644 index 00000000..b6021aee --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -0,0 +1,1320 @@ +/* + This file is part of Subsonic. + + Subsonic is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Subsonic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Subsonic. If not, see . + + Copyright 2009 (C) Sindre Mehus + */ +package org.moire.ultrasonic.util + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.AlertDialog +import android.content.ContentResolver +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.net.ConnectivityManager +import android.net.Uri +import android.net.wifi.WifiManager +import android.net.wifi.WifiManager.WifiLock +import android.os.Build +import android.os.Environment +import android.os.Parcelable +import android.support.v4.media.MediaDescriptionCompat +import android.text.TextUtils +import android.util.TypedValue +import android.view.Gravity +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.annotation.AnyRes +import androidx.preference.PreferenceManager +import org.moire.ultrasonic.R +import org.moire.ultrasonic.app.UApp.Companion.applicationContext +import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline +import org.moire.ultrasonic.domain.Bookmark +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.domain.RepeatMode +import org.moire.ultrasonic.domain.SearchResult +import org.moire.ultrasonic.service.DownloadFile +import timber.log.Timber +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.io.UnsupportedEncodingException +import java.security.MessageDigest +import java.text.DecimalFormat +import java.util.* +import java.util.concurrent.TimeUnit +import java.util.regex.Pattern +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * @author Sindre Mehus + * @version $Id$ + */ +object Util { + + private val GIGA_BYTE_FORMAT = DecimalFormat("0.00 GB") + private val MEGA_BYTE_FORMAT = DecimalFormat("0.00 MB") + private val KILO_BYTE_FORMAT = DecimalFormat("0 KB") + private val PATTERN = Pattern.compile(":") + private var GIGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null + private var MEGA_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null + private var KILO_BYTE_LOCALIZED_FORMAT: DecimalFormat? = null + private var BYTE_LOCALIZED_FORMAT: DecimalFormat? = null + const val EVENT_META_CHANGED = "org.moire.ultrasonic.EVENT_META_CHANGED" + const val EVENT_PLAYSTATE_CHANGED = "org.moire.ultrasonic.EVENT_PLAYSTATE_CHANGED" + const val CM_AVRCP_PLAYSTATE_CHANGED = "com.android.music.playstatechanged" + const val CM_AVRCP_METADATA_CHANGED = "com.android.music.metachanged" + + // Used by hexEncode() + private val HEX_DIGITS = + charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + private var toast: Toast? = null + private var currentSong: MusicDirectory.Entry? = null + + // Retrieves an instance of the application Context + fun appContext(): Context { + return applicationContext() + } + + fun isScreenLitOnDownload(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_SCREEN_LIT_ON_DOWNLOAD, + false + ) + } + + var repeatMode: RepeatMode + get() { + val preferences = getPreferences() + return RepeatMode.valueOf( + preferences.getString( + Constants.PREFERENCES_KEY_REPEAT_MODE, + RepeatMode.OFF.name + )!! + ) + } + set(repeatMode) { + val preferences = getPreferences() + val editor = preferences.edit() + editor.putString(Constants.PREFERENCES_KEY_REPEAT_MODE, repeatMode.name) + editor.apply() + } + + // After API26 foreground services must be used for music playback, + // and they must have a notification + fun isNotificationEnabled(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOTIFICATION, false) + } + + // After API26 foreground services must be used for music playback, + // and they must have a notification + fun isNotificationAlwaysEnabled(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) return true + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_ALWAYS_SHOW_NOTIFICATION, false) + } + + fun isLockScreenEnabled(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_SHOW_LOCK_SCREEN_CONTROLS, + false + ) + } + + @JvmStatic + fun getTheme(): String? { + val preferences = getPreferences() + return preferences.getString( + Constants.PREFERENCES_KEY_THEME, + Constants.PREFERENCES_KEY_THEME_DARK + ) + } + + @JvmStatic + fun applyTheme(context: Context?) { + val theme = getTheme() + if (Constants.PREFERENCES_KEY_THEME_DARK.equals( + theme, + ignoreCase = true + ) || "fullscreen".equals(theme, ignoreCase = true) + ) { + context!!.setTheme(R.style.UltrasonicTheme) + } else if (Constants.PREFERENCES_KEY_THEME_BLACK.equals(theme, ignoreCase = true)) { + context!!.setTheme(R.style.UltrasonicTheme_Black) + } else if (Constants.PREFERENCES_KEY_THEME_LIGHT.equals( + theme, + ignoreCase = true + ) || "fullscreenlight".equals(theme, ignoreCase = true) + ) { + context!!.setTheme(R.style.UltrasonicTheme_Light) + } + } + + private fun getConnectivityManager(): ConnectivityManager { + val context = appContext() + return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + } + + @JvmStatic + fun getMaxBitRate(): Int { + val manager = getConnectivityManager() + val networkInfo = manager.activeNetworkInfo ?: return 0 + val wifi = networkInfo.type == ConnectivityManager.TYPE_WIFI + val preferences = getPreferences() + return preferences.getString( + if (wifi) Constants.PREFERENCES_KEY_MAX_BITRATE_WIFI + else Constants.PREFERENCES_KEY_MAX_BITRATE_MOBILE, + "0" + )!!.toInt() + } + + @JvmStatic + fun getPreloadCount(): Int { + val preferences = getPreferences() + val preloadCount = + preferences.getString(Constants.PREFERENCES_KEY_PRELOAD_COUNT, "-1")!! + .toInt() + return if (preloadCount == -1) Int.MAX_VALUE else preloadCount + } + + @JvmStatic + fun getCacheSizeMB(): Int { + val preferences = getPreferences() + val cacheSize = preferences.getString( + Constants.PREFERENCES_KEY_CACHE_SIZE, + "-1" + )!!.toInt() + return if (cacheSize == -1) Int.MAX_VALUE else cacheSize + } + + @JvmStatic + fun getPreferences(): SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(appContext()) + + /** + * Get the contents of an `InputStream` as a `byte[]`. + * + * + * This method buffers the input internally, so there is no need to use a + * `BufferedInputStream`. + * + * @param input the `InputStream` to read from + * @return the requested byte array + * @throws NullPointerException if the input is null + * @throws java.io.IOException if an I/O error occurs + */ + @Throws(IOException::class) + fun toByteArray(input: InputStream?): ByteArray { + val output = ByteArrayOutputStream() + copy(input!!, output) + return output.toByteArray() + } + + @Throws(IOException::class) + fun copy(input: InputStream, output: OutputStream): Long { + val buffer = ByteArray(1024 * 4) + var count: Long = 0 + var n: Int + while (-1 != input.read(buffer).also { n = it }) { + output.write(buffer, 0, n) + count += n.toLong() + } + return count + } + + @Throws(IOException::class) + fun atomicCopy(from: File, to: File) { + val tmp = File(String.format("%s.tmp", to.path)) + val `in` = FileInputStream(from) + val out = FileOutputStream(tmp) + try { + `in`.channel.transferTo(0, from.length(), out.channel) + out.close() + if (!tmp.renameTo(to)) { + throw IOException(String.format("Failed to rename %s to %s", tmp, to)) + } + Timber.i("Copied %s to %s", from, to) + } catch (x: IOException) { + close(out) + delete(to) + throw x + } finally { + close(`in`) + close(out) + delete(tmp) + } + } + + @JvmStatic + @Throws(IOException::class) + fun renameFile(from: File, to: File) { + if (from.renameTo(to)) { + Timber.i("Renamed %s to %s", from, to) + } else { + atomicCopy(from, to) + } + } + + @JvmStatic + fun close(closeable: Closeable?) { + try { + closeable?.close() + } catch (x: Throwable) { + // Ignored + } + } + + @JvmStatic + fun delete(file: File?): Boolean { + if (file != null && file.exists()) { + if (!file.delete()) { + Timber.w("Failed to delete file %s", file) + return false + } + Timber.i("Deleted file %s", file) + } + return true + } + + @JvmStatic + @JvmOverloads + fun toast(context: Context?, messageId: Int, shortDuration: Boolean = true) { + toast(context, context!!.getString(messageId), shortDuration) + } + + @JvmStatic + fun toast(context: Context?, message: CharSequence?) { + toast(context, message, true) + } + + @JvmStatic + @SuppressLint("ShowToast") // Invalid warning + fun toast(context: Context?, message: CharSequence?, shortDuration: Boolean) { + if (toast == null) { + toast = Toast.makeText( + context, + message, + if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + ) + toast!!.setGravity(Gravity.CENTER, 0, 0) + } else { + toast!!.setText(message) + toast!!.duration = + if (shortDuration) Toast.LENGTH_SHORT else Toast.LENGTH_LONG + } + toast!!.show() + } + + /** + * Formats an Int to a percentage string + * For instance: + * + * * `format(99)` returns *"99 %"*. + * + * + * @param percent The percent as a range from 0 - 100 + * @return The formatted string. + */ + @Synchronized + fun formatPercentage(percent: Int): String { + return min(max(percent, 0), 100).toString() + " %" + } + + /** + * Converts a byte-count to a formatted string suitable for display to the user. + * For instance: + * + * * `format(918)` returns *"918 B"*. + * * `format(98765)` returns *"96 KB"*. + * * `format(1238476)` returns *"1.2 MB"*. + * + * This method assumes that 1 KB is 1024 bytes. + * To get a localized string, please use formatLocalizedBytes instead. + * + * @param byteCount The number of bytes. + * @return The formatted string. + */ + @JvmStatic + @Synchronized + fun formatBytes(byteCount: Long): String { + + // More than 1 GB? + if (byteCount >= 1024 * 1024 * 1024) { + return GIGA_BYTE_FORMAT.format(byteCount.toDouble() / (1024 * 1024 * 1024)) + } + + // More than 1 MB? + if (byteCount >= 1024 * 1024) { + return MEGA_BYTE_FORMAT.format(byteCount.toDouble() / (1024 * 1024)) + } + + // More than 1 KB? + return if (byteCount >= 1024) { + KILO_BYTE_FORMAT.format(byteCount.toDouble() / 1024) + } else "$byteCount B" + } + + /** + * Converts a byte-count to a formatted string suitable for display to the user. + * For instance: + * + * * `format(918)` returns *"918 B"*. + * * `format(98765)` returns *"96 KB"*. + * * `format(1238476)` returns *"1.2 MB"*. + * + * This method assumes that 1 KB is 1024 bytes. + * This version of the method returns a localized string. + * + * @param byteCount The number of bytes. + * @return The formatted string. + */ + @Synchronized + fun formatLocalizedBytes(byteCount: Long, context: Context): String { + + // More than 1 GB? + if (byteCount >= 1024 * 1024 * 1024) { + if (GIGA_BYTE_LOCALIZED_FORMAT == null) { + GIGA_BYTE_LOCALIZED_FORMAT = + DecimalFormat(context.resources.getString(R.string.util_bytes_format_gigabyte)) + } + return GIGA_BYTE_LOCALIZED_FORMAT!! + .format(byteCount.toDouble() / (1024 * 1024 * 1024)) + } + + // More than 1 MB? + if (byteCount >= 1024 * 1024) { + if (MEGA_BYTE_LOCALIZED_FORMAT == null) { + MEGA_BYTE_LOCALIZED_FORMAT = + DecimalFormat(context.resources.getString(R.string.util_bytes_format_megabyte)) + } + return MEGA_BYTE_LOCALIZED_FORMAT!! + .format(byteCount.toDouble() / (1024 * 1024)) + } + + // More than 1 KB? + if (byteCount >= 1024) { + if (KILO_BYTE_LOCALIZED_FORMAT == null) { + KILO_BYTE_LOCALIZED_FORMAT = + DecimalFormat(context.resources.getString(R.string.util_bytes_format_kilobyte)) + } + return KILO_BYTE_LOCALIZED_FORMAT!!.format(byteCount.toDouble() / 1024) + } + if (BYTE_LOCALIZED_FORMAT == null) { + BYTE_LOCALIZED_FORMAT = + DecimalFormat(context.resources.getString(R.string.util_bytes_format_byte)) + } + return BYTE_LOCALIZED_FORMAT!!.format(byteCount.toDouble()) + } + + fun equals(object1: Any?, object2: Any?): Boolean { + return object1 === object2 || !(object1 == null || object2 == null) && object1 == object2 + } + + /** + * Encodes the given string by using the hexadecimal representation of its UTF-8 bytes. + * + * @param s The string to encode. + * @return The encoded string. + */ + fun utf8HexEncode(s: String?): String? { + if (s == null) { + return null + } + val utf8: ByteArray = try { + s.toByteArray(charset(Constants.UTF_8)) + } catch (x: UnsupportedEncodingException) { + throw RuntimeException(x) + } + return hexEncode(utf8) + } + + /** + * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. + * The returned array will be double the length of the passed array, as it takes two characters to represent any + * given byte. + * + * @param data Bytes to convert to hexadecimal characters. + * @return A string containing hexadecimal characters. + */ + fun hexEncode(data: ByteArray): String { + val length = data.size + val out = CharArray(length shl 1) + var j = 0 + + // two characters form the hex value. + for (aData in data) { + out[j++] = HEX_DIGITS[0xF0 and aData.toInt() ushr 4] + out[j++] = HEX_DIGITS[0x0F and aData.toInt()] + } + return String(out) + } + + /** + * Calculates the MD5 digest and returns the value as a 32 character hex string. + * + * @param s Data to digest. + * @return MD5 digest as a hex string. + */ + @JvmStatic + fun md5Hex(s: String?): String? { + return if (s == null) { + null + } else try { + val md5 = MessageDigest.getInstance("MD5") + hexEncode(md5.digest(s.toByteArray(charset(Constants.UTF_8)))) + } catch (x: Exception) { + throw RuntimeException(x.message, x) + } + } + + @JvmStatic + fun getGrandparent(path: String?): String? { + // Find the top level folder, assume it is the album artist + if (path != null) { + val slashIndex = path.indexOf('/') + if (slashIndex > 0) { + return path.substring(0, slashIndex) + } + } + return null + } + + @JvmStatic + fun isNetworkConnected(): Boolean { + val manager = getConnectivityManager() + val networkInfo = manager.activeNetworkInfo + val connected = networkInfo != null && networkInfo.isConnected + val wifiConnected = connected && networkInfo!!.type == ConnectivityManager.TYPE_WIFI + val wifiRequired = isWifiRequiredForDownload() + return connected && (!wifiRequired || wifiConnected) + } + + @JvmStatic + fun isExternalStoragePresent(): Boolean = + Environment.MEDIA_MOUNTED == Environment.getExternalStorageState() + + fun isWifiRequiredForDownload(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_WIFI_REQUIRED_FOR_DOWNLOAD, + false + ) + } + + fun shouldDisplayBitrateWithArtist(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_DISPLAY_BITRATE_WITH_ARTIST, + true + ) + } + + @JvmStatic + fun shouldUseFolderForArtistName(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_USE_FOLDER_FOR_ALBUM_ARTIST, + false + ) + } + + fun shouldShowTrackNumber(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_TRACK_NUMBER, false) + } + + // The AlertDialog requires an Activity context, app context is not enough + // See https://stackoverflow.com/questions/5436822/ + fun showDialog(context: Context?, icon: Int, titleId: Int, message: String?) { + AlertDialog.Builder(context) + .setIcon(icon) + .setTitle(titleId) + .setMessage(message) + .setPositiveButton(R.string.common_ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + .show() + } + + @JvmStatic + fun sleepQuietly(millis: Long) { + try { + Thread.sleep(millis) + } catch (x: InterruptedException) { + Timber.w(x, "Interrupted from sleep.") + } + } + + @JvmStatic + fun getDrawableFromAttribute(context: Context?, attr: Int): Drawable { + val attrs = intArrayOf(attr) + val ta = context!!.obtainStyledAttributes(attrs) + val drawableFromTheme: Drawable? = ta.getDrawable(0) + ta.recycle() + return drawableFromTheme!! + } + + fun createDrawableFromBitmap(context: Context, bitmap: Bitmap?): Drawable { + return BitmapDrawable(context.resources, bitmap) + } + + fun createBitmapFromDrawable(drawable: Drawable): Bitmap { + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } + + fun createWifiLock(tag: String?): WifiLock { + val wm = + appContext().applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + return wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, tag) + } + + fun getScaledHeight(height: Double, width: Double, newWidth: Int): Int { + // Try to keep correct aspect ratio of the original image, do not force a square + val aspectRatio = height / width + + // Assume the size given refers to the width of the image, so calculate the new height using + // the previously determined aspect ratio + return Math.round(newWidth * aspectRatio).toInt() + } + + fun getScaledHeight(bitmap: Bitmap, width: Int): Int { + return getScaledHeight(bitmap.height.toDouble(), bitmap.width.toDouble(), width) + } + + fun scaleBitmap(bitmap: Bitmap?, size: Int): Bitmap? { + return if (bitmap == null) null else Bitmap.createScaledBitmap( + bitmap, + size, + getScaledHeight(bitmap, size), + true + ) + } + + fun getSongsFromSearchResult(searchResult: SearchResult): MusicDirectory { + val musicDirectory = MusicDirectory() + for (entry in searchResult.songs) { + musicDirectory.addChild(entry) + } + return musicDirectory + } + + @JvmStatic + fun getSongsFromBookmarks(bookmarks: Iterable): MusicDirectory { + val musicDirectory = MusicDirectory() + var song: MusicDirectory.Entry + for ((position, _, _, _, _, entry) in bookmarks) { + song = entry + song.bookmarkPosition = position + musicDirectory.addChild(song) + } + return musicDirectory + } + + /** + * + * Broadcasts the given song info as the new song being played. + */ + fun broadcastNewTrackInfo(context: Context, song: MusicDirectory.Entry?) { + val intent = Intent(EVENT_META_CHANGED) + if (song != null) { + intent.putExtra("title", song.title) + intent.putExtra("artist", song.artist) + intent.putExtra("album", song.album) + val albumArtFile = FileUtil.getAlbumArtFile(song) + intent.putExtra("coverart", albumArtFile.absolutePath) + } else { + intent.putExtra("title", "") + intent.putExtra("artist", "") + intent.putExtra("album", "") + intent.putExtra("coverart", "") + } + context.sendBroadcast(intent) + } + + fun broadcastA2dpMetaDataChange( + context: Context, + playerPosition: Int, + currentPlaying: DownloadFile?, + listSize: Int, + id: Int + ) { + if (!shouldSendBluetoothNotifications) { + return + } + var song: MusicDirectory.Entry? = null + val avrcpIntent = Intent(CM_AVRCP_METADATA_CHANGED) + if (currentPlaying != null) song = currentPlaying.song + + if (song == null) { + avrcpIntent.putExtra("track", "") + avrcpIntent.putExtra("track_name", "") + avrcpIntent.putExtra("artist", "") + avrcpIntent.putExtra("artist_name", "") + avrcpIntent.putExtra("album", "") + avrcpIntent.putExtra("album_name", "") + avrcpIntent.putExtra("album_artist", "") + avrcpIntent.putExtra("album_artist_name", "") + + if (getShouldSendBluetoothAlbumArt()) { + avrcpIntent.putExtra("coverart", null as Parcelable?) + avrcpIntent.putExtra("cover", null as Parcelable?) + } + + avrcpIntent.putExtra("ListSize", 0.toLong()) + avrcpIntent.putExtra("id", 0.toLong()) + avrcpIntent.putExtra("duration", 0.toLong()) + avrcpIntent.putExtra("position", 0.toLong()) + } else { + if (song !== currentSong) { + currentSong = song + } + val title = song.title + val artist = song.artist + val album = song.album + val duration = song.duration + + avrcpIntent.putExtra("track", title) + avrcpIntent.putExtra("track_name", title) + avrcpIntent.putExtra("artist", artist) + avrcpIntent.putExtra("artist_name", artist) + avrcpIntent.putExtra("album", album) + avrcpIntent.putExtra("album_name", album) + avrcpIntent.putExtra("album_artist", artist) + avrcpIntent.putExtra("album_artist_name", artist) + + if (getShouldSendBluetoothAlbumArt()) { + val albumArtFile = FileUtil.getAlbumArtFile(song) + avrcpIntent.putExtra("coverart", albumArtFile.absolutePath) + avrcpIntent.putExtra("cover", albumArtFile.absolutePath) + } + + avrcpIntent.putExtra("position", playerPosition.toLong()) + avrcpIntent.putExtra("id", id.toLong()) + avrcpIntent.putExtra("ListSize", listSize.toLong()) + + if (duration != null) { + avrcpIntent.putExtra("duration", duration.toLong()) + } + } + context.sendBroadcast(avrcpIntent) + } + + fun broadcastA2dpPlayStatusChange( + context: Context, + state: PlayerState?, + currentSong: MusicDirectory.Entry?, + listSize: Int, + id: Int, + playerPosition: Int + ) { + if (!shouldSendBluetoothNotifications) { + return + } + if (currentSong != null) { + val avrcpIntent = Intent(CM_AVRCP_PLAYSTATE_CHANGED) + if (currentSong == null) { + return + } + + // FIXME: This is probably a bug. + if (currentSong !== currentSong) { + Util.currentSong = currentSong + } + val title = currentSong.title + val artist = currentSong.artist + val album = currentSong.album + val duration = currentSong.duration + + avrcpIntent.putExtra("track", title) + avrcpIntent.putExtra("track_name", title) + avrcpIntent.putExtra("artist", artist) + avrcpIntent.putExtra("artist_name", artist) + avrcpIntent.putExtra("album", album) + avrcpIntent.putExtra("album_name", album) + avrcpIntent.putExtra("album_artist", artist) + avrcpIntent.putExtra("album_artist_name", artist) + + if (getShouldSendBluetoothAlbumArt()) { + val albumArtFile = FileUtil.getAlbumArtFile(currentSong) + avrcpIntent.putExtra("coverart", albumArtFile.absolutePath) + avrcpIntent.putExtra("cover", albumArtFile.absolutePath) + } + + avrcpIntent.putExtra("position", playerPosition.toLong()) + avrcpIntent.putExtra("id", id.toLong()) + avrcpIntent.putExtra("ListSize", listSize.toLong()) + + if (duration != null) { + avrcpIntent.putExtra("duration", duration.toLong()) + } + + when (state) { + PlayerState.STARTED -> avrcpIntent.putExtra("playing", true) + PlayerState.STOPPED, PlayerState.PAUSED, PlayerState.COMPLETED -> avrcpIntent.putExtra( + "playing", + false + ) + else -> return // No need to broadcast. + } + + context.sendBroadcast(avrcpIntent) + } + } + + /** + * + * Broadcasts the given player state as the one being set. + */ + fun broadcastPlaybackStatusChange(context: Context, state: PlayerState?) { + val intent = Intent(EVENT_PLAYSTATE_CHANGED) + when (state) { + PlayerState.STARTED -> intent.putExtra("state", "play") + PlayerState.STOPPED -> intent.putExtra("state", "stop") + PlayerState.PAUSED -> intent.putExtra("state", "pause") + PlayerState.COMPLETED -> intent.putExtra("state", "complete") + else -> return // No need to broadcast. + } + context.sendBroadcast(intent) + } + + @JvmStatic + fun getNotificationImageSize(context: Context): Int { + val metrics = context.resources.displayMetrics + val imageSizeLarge = + min(metrics.widthPixels, metrics.heightPixels).toFloat().roundToInt() + return when { + imageSizeLarge <= 480 -> { + 64 + } + imageSizeLarge <= 768 -> 128 + else -> 256 + } + } + + fun getAlbumImageSize(context: Context?): Int { + val metrics = context!!.resources.displayMetrics + val imageSizeLarge = + min(metrics.widthPixels, metrics.heightPixels).toFloat().roundToInt() + return when { + imageSizeLarge <= 480 -> { + 128 + } + imageSizeLarge <= 768 -> 256 + else -> 512 + } + } + + fun getMinDisplayMetric(): Int { + val metrics = appContext().resources.displayMetrics + return Math.min(metrics.widthPixels, metrics.heightPixels) + } + + fun getMaxDisplayMetric(): Int { + val metrics = appContext().resources.displayMetrics + return Math.max(metrics.widthPixels, metrics.heightPixels) + } + + fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + // Raw height and width of image + val height = options.outHeight + val width = options.outWidth + var inSampleSize = 1 + if (height > reqHeight || width > reqWidth) { + + // Calculate ratios of height and width to requested height and + // width + val heightRatio = Math.round(height.toFloat() / reqHeight.toFloat()) + val widthRatio = Math.round(width.toFloat() / reqWidth.toFloat()) + + // Choose the smallest ratio as inSampleSize value, this will + // guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = Math.min(heightRatio, widthRatio) + } + return inSampleSize + } + + @JvmStatic + fun getDefaultAlbums(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_ALBUMS, "5")!! + .toInt() + } + + @JvmStatic + fun getMaxAlbums(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_MAX_ALBUMS, "20")!! + .toInt() + } + + @JvmStatic + fun getDefaultSongs(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SONGS, "10")!! + .toInt() + } + + @JvmStatic + fun getMaxSongs(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_MAX_SONGS, "25")!! + .toInt() + } + + @JvmStatic + fun getMaxArtists(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_MAX_ARTISTS, "10")!! + .toInt() + } + + @JvmStatic + fun getDefaultArtists(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_ARTISTS, "3")!! + .toInt() + } + + @JvmStatic + fun getBufferLength(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_BUFFER_LENGTH, "5")!! + .toInt() + } + + @JvmStatic + fun getIncrementTime(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_INCREMENT_TIME, "5")!! + .toInt() + } + + @JvmStatic + fun getMediaButtonsEnabled(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_MEDIA_BUTTONS, true) + } + + @JvmStatic + fun getShowNowPlayingPreference(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_NOW_PLAYING, true) + } + + @JvmStatic + fun getGaplessPlaybackPreference(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_GAPLESS_PLAYBACK, false) + } + + @JvmStatic + fun getShouldTransitionOnPlaybackPreference(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_DOWNLOAD_TRANSITION, true) + } + + @JvmStatic + fun getShouldUseId3Tags(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false) + } + + fun getShouldShowArtistPicture(): Boolean { + val preferences = getPreferences() + val isOffline = isOffline() + val isId3Enabled = preferences.getBoolean(Constants.PREFERENCES_KEY_ID3_TAGS, false) + val shouldShowArtistPicture = + preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ARTIST_PICTURE, false) + return !isOffline && isId3Enabled && shouldShowArtistPicture + } + + @JvmStatic + fun getChatRefreshInterval(): Int { + val preferences = getPreferences() + return preferences.getString( + Constants.PREFERENCES_KEY_CHAT_REFRESH_INTERVAL, + "5000" + )!!.toInt() + } + + fun getDirectoryCacheTime(): Int { + val preferences = getPreferences() + return preferences.getString( + Constants.PREFERENCES_KEY_DIRECTORY_CACHE_TIME, + "300" + )!!.toInt() + } + + @JvmStatic + fun isNullOrWhiteSpace(string: String?): Boolean { + return string == null || string.isEmpty() || string.trim { it <= ' ' }.isEmpty() + } + + fun getShouldClearPlaylist(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_CLEAR_PLAYLIST, false) + } + + fun getShouldSortByDisc(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_DISC_SORT, false) + } + + fun getShouldClearBookmark(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_CLEAR_BOOKMARK, false) + } + + fun getSingleButtonPlayPause(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SINGLE_BUTTON_PLAY_PAUSE, false) + } + + @JvmOverloads + fun formatTotalDuration(totalDuration: Long, inMilliseconds: Boolean = false): String { + var millis = totalDuration + if (!inMilliseconds) { + millis = totalDuration * 1000 + } + val hours = TimeUnit.MILLISECONDS.toHours(millis) + val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - TimeUnit.HOURS.toMinutes(hours) + val seconds = + TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(hours * 60 + minutes) + + return when { + hours >= 10 -> { + String.format( + Locale.getDefault(), + "%02d:%02d:%02d", + hours, + minutes, + seconds + ) + } + hours > 0 -> { + String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds) + } + minutes >= 10 -> { + String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + } + minutes > 0 -> String.format( + Locale.getDefault(), + "%d:%02d", + minutes, + seconds + ) + else -> String.format(Locale.getDefault(), "0:%02d", seconds) + } + } + + @JvmStatic + fun getVersionName(context: Context): String? { + var versionName: String? = null + val pm = context.packageManager + if (pm != null) { + val packageName = context.packageName + try { + versionName = pm.getPackageInfo(packageName, 0).versionName + } catch (ignored: PackageManager.NameNotFoundException) { + } + } + return versionName + } + + fun getVersionCode(context: Context): Int { + var versionCode = 0 + val pm = context.packageManager + if (pm != null) { + val packageName = context.packageName + try { + versionCode = pm.getPackageInfo(packageName, 0).versionCode + } catch (ignored: PackageManager.NameNotFoundException) { + } + } + return versionCode + } + + // Inverted for readability + val shouldSendBluetoothNotifications: Boolean + get() { + val preferences = getPreferences() + return preferences.getBoolean( + Constants.PREFERENCES_KEY_SEND_BLUETOOTH_NOTIFICATIONS, + true + ) + } + + fun getShouldSendBluetoothAlbumArt(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SEND_BLUETOOTH_ALBUM_ART, true) + } + + @JvmStatic + fun getViewRefreshInterval(): Int { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_VIEW_REFRESH, "1000")!! + .toInt() + } + + var shouldAskForShareDetails: Boolean + get() { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, true) + } + set(shouldAskForShareDetails) { + val preferences = getPreferences() + val editor = preferences.edit() + editor.putBoolean( + Constants.PREFERENCES_KEY_ASK_FOR_SHARE_DETAILS, + shouldAskForShareDetails + ) + editor.apply() + } + + var defaultShareDescription: String? + get() { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION, "") + } + set(defaultShareDescription) { + val preferences = getPreferences() + val editor = preferences.edit() + editor.putString( + Constants.PREFERENCES_KEY_DEFAULT_SHARE_DESCRIPTION, + defaultShareDescription + ) + editor.apply() + } + + @JvmStatic + fun getShareGreeting(): String? { + val preferences = getPreferences() + val context = appContext() + val defaultVal = String.format( + context.resources.getString(R.string.share_default_greeting), + context.resources.getString(R.string.common_appname) + ) + return preferences.getString( + Constants.PREFERENCES_KEY_DEFAULT_SHARE_GREETING, + defaultVal + ) + } + + var defaultShareExpiration: String + get() { + val preferences = getPreferences() + return preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, "0")!! + } + set(defaultShareExpiration) { + val preferences = getPreferences() + val editor = preferences.edit() + editor.putString( + Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, + defaultShareExpiration + ) + editor.apply() + } + + fun getDefaultShareExpirationInMillis(context: Context?): Long { + val preferences = getPreferences() + val preference = + preferences.getString(Constants.PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION, "0")!! + val split = PATTERN.split(preference) + if (split.size == 2) { + val timeSpanAmount = split[0].toInt() + val timeSpanType = split[1] + val timeSpan = TimeSpanPicker.calculateTimeSpan(context, timeSpanType, timeSpanAmount) + return timeSpan.totalMilliseconds + } + return 0 + } + + fun getShouldShowAllSongsByArtist(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST, false) + } + + @JvmStatic + fun scanMedia(file: File?) { + val uri = Uri.fromFile(file) + val scanFileIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri) + appContext().sendBroadcast(scanFileIntent) + } + + fun imageLoaderConcurrency(): Int { + val preferences = getPreferences() + return preferences.getString( + Constants.PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY, + "5" + )!!.toInt() + } + + fun getResourceFromAttribute(context: Context, resId: Int): Int { + val typedValue = TypedValue() + val theme = context.theme + theme.resolveAttribute(resId, typedValue, true) + return typedValue.resourceId + } + + fun isFirstRun(): Boolean { + val preferences = getPreferences() + val firstExecuted = + preferences.getBoolean(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, false) + if (firstExecuted) return false + val editor = preferences.edit() + editor.putBoolean(Constants.PREFERENCES_KEY_FIRST_RUN_EXECUTED, true) + editor.apply() + return true + } + + @JvmStatic + fun getResumeOnBluetoothDevice(): Int { + val preferences = getPreferences() + return preferences.getInt( + Constants.PREFERENCES_KEY_RESUME_ON_BLUETOOTH_DEVICE, + Constants.PREFERENCE_VALUE_DISABLED + ) + } + + @JvmStatic + fun getPauseOnBluetoothDevice(): Int { + val preferences = getPreferences() + return preferences.getInt( + Constants.PREFERENCES_KEY_PAUSE_ON_BLUETOOTH_DEVICE, + Constants.PREFERENCE_VALUE_A2DP + ) + } + + fun getDebugLogToFile(): Boolean { + val preferences = getPreferences() + return preferences.getBoolean(Constants.PREFERENCES_KEY_DEBUG_LOG_TO_FILE, false) + } + + fun hideKeyboard(activity: Activity?) { + val inputManager = + activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + val currentFocusedView = activity.currentFocus + if (currentFocusedView != null) { + inputManager.hideSoftInputFromWindow( + currentFocusedView.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) + } + } + + fun getUriToDrawable(context: Context, @AnyRes drawableId: Int): Uri { + return Uri.parse( + ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + context.resources.getResourcePackageName(drawableId) + + '/' + context.resources.getResourceTypeName(drawableId) + + '/' + context.resources.getResourceEntryName(drawableId) + ) + } + + fun getMediaDescriptionForEntry(song: MusicDirectory.Entry, mediaId: String? = null): MediaDescriptionCompat { + + val descriptionBuilder = MediaDescriptionCompat.Builder() + val artist = StringBuilder(60) + var bitRate: String? = null + + val duration = song.duration + if (duration != null) { + artist.append(String.format("%s ", formatTotalDuration(duration.toLong()))) + } + + if (song.bitRate != null) + bitRate = String.format( + appContext().getString(R.string.song_details_kbps), song.bitRate + ) + + val fileFormat: String? + val suffix = song.suffix + val transcodedSuffix = song.transcodedSuffix + + fileFormat = if ( + TextUtils.isEmpty(transcodedSuffix) || transcodedSuffix == suffix || song.isVideo + ) suffix else String.format("%s > %s", suffix, transcodedSuffix) + + val artistName = song.artist + + if (artistName != null) { + if (shouldDisplayBitrateWithArtist()) { + artist.append(artistName).append(" (").append( + String.format( + appContext().getString(R.string.song_details_all), + if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat + ) + ).append(')') + } else { + artist.append(artistName) + } + } + + val trackNumber = song.track ?: 0 + + val title = StringBuilder(60) + if (shouldShowTrackNumber() && trackNumber > 0) + title.append(String.format("%02d - ", trackNumber)) + + title.append(song.title) + + if (song.isVideo && shouldDisplayBitrateWithArtist()) { + title.append(" (").append( + String.format( + appContext().getString(R.string.song_details_all), + if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat + ) + ).append(')') + } + + descriptionBuilder.setTitle(title) + descriptionBuilder.setSubtitle(artist) + descriptionBuilder.setMediaId(mediaId) + + return descriptionBuilder.build() + } +} \ No newline at end of file diff --git a/ultrasonic/src/main/res/drawable/ic_artist.xml b/ultrasonic/src/main/res/drawable/ic_artist.xml new file mode 100644 index 00000000..24b174c7 --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_artist.xml @@ -0,0 +1,10 @@ + + + diff --git a/ultrasonic/src/main/res/drawable/ic_library.xml b/ultrasonic/src/main/res/drawable/ic_library.xml new file mode 100644 index 00000000..ef18b12d --- /dev/null +++ b/ultrasonic/src/main/res/drawable/ic_library.xml @@ -0,0 +1,11 @@ + + + From cf05d3c781433a7a28a7e145f4958c20d3590ba5 Mon Sep 17 00:00:00 2001 From: Nite Date: Sun, 18 Jul 2021 11:33:39 +0200 Subject: [PATCH 09/14] Implemented Media Browsing --- .../service/AutoMediaBrowserService.kt | 747 +++++++++++++++--- .../service/AutoMediaPlayerService.kt | 344 -------- .../ultrasonic/service/MediaPlayerService.kt | 20 +- .../ultrasonic/util/MediaSessionHandler.kt | 26 +- .../kotlin/org/moire/ultrasonic/util/Util.kt | 41 +- 5 files changed, 669 insertions(+), 509 deletions(-) delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index 562c26a3..2cea930a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -13,8 +13,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.moire.ultrasonic.R +import org.moire.ultrasonic.api.subsonic.models.AlbumListType import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.SearchCriteria +import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.util.MediaSessionEventDistributor import org.moire.ultrasonic.util.MediaSessionEventListener import org.moire.ultrasonic.util.MediaSessionHandler @@ -23,6 +26,7 @@ import timber.log.Timber const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID" const val MEDIA_ALBUM_ID = "MEDIA_ALBUM_ID" +const val MEDIA_ALBUM_PAGE_ID = "MEDIA_ALBUM_PAGE_ID" const val MEDIA_ALBUM_NEWEST_ID = "MEDIA_ALBUM_NEWEST_ID" const val MEDIA_ALBUM_RECENT_ID = "MEDIA_ALBUM_RECENT_ID" const val MEDIA_ALBUM_FREQUENT_ID = "MEDIA_ALBUM_FREQUENT_ID" @@ -38,12 +42,22 @@ const val MEDIA_BOOKMARK_ID = "MEDIA_BOOKMARK_ID" const val MEDIA_PODCAST_ID = "MEDIA_PODCAST_ID" const val MEDIA_ALBUM_ITEM = "MEDIA_ALBUM_ITEM" const val MEDIA_PLAYLIST_SONG_ITEM = "MEDIA_PLAYLIST_SONG_ITEM" -const val MEDIA_PLAYLIST_ITEM = "MEDIA_ALBUM_ITEM" +const val MEDIA_PLAYLIST_ITEM = "MEDIA_PLAYLIST_ITEM" const val MEDIA_ARTIST_ITEM = "MEDIA_ARTIST_ITEM" const val MEDIA_ARTIST_SECTION = "MEDIA_ARTIST_SECTION" +const val MEDIA_ALBUM_SONG_ITEM = "MEDIA_ALBUM_SONG_ITEM" +const val MEDIA_SONG_STARRED_ITEM = "MEDIA_SONG_STARRED_ITEM" +const val MEDIA_SONG_RANDOM_ITEM = "MEDIA_SONG_RANDOM_ITEM" +const val MEDIA_SHARE_ITEM = "MEDIA_SHARE_ITEM" +const val MEDIA_SHARE_SONG_ITEM = "MEDIA_SHARE_SONG_ITEM" +const val MEDIA_BOOKMARK_ITEM = "MEDIA_BOOKMARK_ITEM" +const val MEDIA_PODCAST_ITEM = "MEDIA_PODCAST_ITEM" +const val MEDIA_PODCAST_EPISODE_ITEM = "MEDIA_PODCAST_EPISODE_ITEM" +const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM" // Currently the display limit for long lists is 100 items const val displayLimit = 100 +const val searchLimit = 10 /** * MediaBrowserService implementation for e.g. Android Auto @@ -56,12 +70,15 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private val mediaSessionHandler by inject() private val mediaPlayerController by inject() private val activeServerProvider: ActiveServerProvider by inject() - private val musicService by lazy { MusicServiceFactory.getMusicService() } + private val musicService = MusicServiceFactory.getMusicService() private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private var playlistCache: List? = null + private var starredSongsCache: List? = null + private var randomSongsCache: List? = null + private var searchSongsCache: List? = null private val isOffline get() = ActiveServerProvider.isOffline() private val useId3Tags get() = Util.getShouldUseId3Tags() @@ -86,11 +103,42 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { 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]) } } override fun onPlayFromSearchRequested(query: String?, extras: Bundle?) { - // TODO implement + Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query) + if (query.isNullOrBlank()) playRandomSongs() + + serviceScope.launch { + val criteria = SearchCriteria(query!!, 0, 0, displayLimit) + 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) + } + } } } @@ -131,6 +179,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { extras.putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + extras.putBoolean( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) return BrowserRoot(MEDIA_ROOT_ID, extras) } @@ -148,20 +198,24 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { MEDIA_LIBRARY_ID -> return getLibrary(result) MEDIA_ARTIST_ID -> return getArtists(result) MEDIA_ARTIST_SECTION -> return getArtists(result, parentIdParts[1]) - MEDIA_ALBUM_ID -> return getAlbums(result) + MEDIA_ALBUM_ID -> return getAlbums(result, AlbumListType.SORTED_BY_NAME) + MEDIA_ALBUM_PAGE_ID -> return getAlbums(result, AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt()) MEDIA_PLAYLIST_ID -> return getPlaylists(result) - MEDIA_ALBUM_FREQUENT_ID -> return getFrequentAlbums(result) - MEDIA_ALBUM_NEWEST_ID -> return getNewestAlbums(result) - MEDIA_ALBUM_RECENT_ID -> return getRecentAlbums(result) - MEDIA_ALBUM_RANDOM_ID -> return getRandomAlbums(result) - MEDIA_ALBUM_STARRED_ID -> return getStarredAlbums(result) + MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(result, AlbumListType.FREQUENT) + MEDIA_ALBUM_NEWEST_ID -> return getAlbums(result, AlbumListType.NEWEST) + MEDIA_ALBUM_RECENT_ID -> return getAlbums(result, AlbumListType.RECENT) + MEDIA_ALBUM_RANDOM_ID -> return getAlbums(result, AlbumListType.RANDOM) + MEDIA_ALBUM_STARRED_ID -> return getAlbums(result, AlbumListType.STARRED) MEDIA_SONG_RANDOM_ID -> return getRandomSongs(result) MEDIA_SONG_STARRED_ID -> return getStarredSongs(result) MEDIA_SHARE_ID -> return getShares(result) MEDIA_BOOKMARK_ID -> return getBookmarks(result) MEDIA_PODCAST_ID -> return getPodcasts(result) MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2], result) - MEDIA_ARTIST_ITEM -> return getAlbums(result, parentIdParts[1]) + MEDIA_ARTIST_ITEM -> return getAlbumsForArtist(result, parentIdParts[1], parentIdParts[2]) + MEDIA_ALBUM_ITEM -> return getSongsForAlbum(result, parentIdParts[1], parentIdParts[2]) + MEDIA_SHARE_ITEM -> return getSongsForShare(result, parentIdParts[1]) + MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(result, parentIdParts[1]) else -> result.sendResult(mutableListOf()) } } @@ -171,19 +225,73 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { extras: Bundle?, result: Result> ) { - super.onSearch(query, extras, result) - // TODO implement + Timber.d("AutoMediaBrowserService onSearch query: %s", query) + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val criteria = SearchCriteria(query, searchLimit, searchLimit, searchLimit) + val searchResult = callWithErrorHandling { musicService.search(criteria) } + + // TODO Add More... button to categories + if (searchResult != null) { + searchResult.artists.map { artist -> + mediaItems.add( + artist.name ?: "", + listOf(MEDIA_ARTIST_ITEM, artist.id, artist.name).joinToString("|"), + null, + R.string.search_artists + ) + } + + searchResult.albums.map { album -> + mediaItems.add( + album.title ?: "", + listOf(MEDIA_ALBUM_ITEM, album.id, album.name) + .joinToString("|"), + null, + R.string.search_albums + ) + } + + searchSongsCache = searchResult.songs + searchResult.songs.map { song -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|"), + R.string.search_songs + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + } + result.sendResult(mediaItems) + } + } + + private fun playSearch(id : String) { + serviceScope.launch { + // If there is no cache, we can't play the selected song. + if (searchSongsCache != null) { + val song = searchSongsCache!!.firstOrNull { x -> x.id == id } + if (song != null) playSong(song) + } + } } private fun getRootItems(result: Result>) { val mediaItems: MutableList = ArrayList() - mediaItems.add( - R.string.music_library_label, - MEDIA_LIBRARY_ID, - R.drawable.ic_library, - null - ) + if (!isOffline) + mediaItems.add( + R.string.music_library_label, + MEDIA_LIBRARY_ID, + R.drawable.ic_library, + null + ) mediaItems.add( R.string.main_artists_title, @@ -192,12 +300,13 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { null ) - mediaItems.add( - R.string.main_albums_title, - MEDIA_ALBUM_ID, - R.drawable.ic_menu_browse_dark, - null - ) + if (!isOffline) + mediaItems.add( + R.string.main_albums_title, + MEDIA_ALBUM_ID, + R.drawable.ic_menu_browse_dark, + null + ) mediaItems.add( R.string.playlist_label, @@ -276,42 +385,125 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { result.detach() serviceScope.launch { + val childMediaId: String var artists = if (!isOffline && useId3Tags) { + childMediaId = MEDIA_ARTIST_ITEM // TODO this list can be big so we're not refreshing. // Maybe a refresh menu item can be added - musicService.getArtists(false) + callWithErrorHandling { musicService.getArtists(false) } } else { - musicService.getIndexes(musicFolderId, false) + // This will be handled at getSongsForAlbum, which supports navigation + childMediaId = MEDIA_ALBUM_ITEM + callWithErrorHandling { musicService.getIndexes(musicFolderId, false) } } - if (section != null) - artists = artists.filter { - artist -> getSectionFromName(artist.name ?: "") == section - } + if (artists != null) { + if (section != null) + artists = artists.filter { artist -> + getSectionFromName(artist.name ?: "") == section + } - // If there are too many artists, create alphabetic index of them - if (section == null && artists.count() > displayLimit) { - val index = mutableListOf() - // TODO This sort should use ignoredArticles somehow... - artists = artists.sortedBy { artist -> artist.name } - artists.map { artist -> - val currentSection = getSectionFromName(artist.name ?: "") - if (!index.contains(currentSection)) { - index.add(currentSection) + // If there are too many artists, create alphabetic index of them + if (section == null && artists.count() > displayLimit) { + val index = mutableListOf() + // TODO This sort should use ignoredArticles somehow... + artists = artists.sortedBy { artist -> artist.name } + artists.map { artist -> + val currentSection = getSectionFromName(artist.name ?: "") + if (!index.contains(currentSection)) { + index.add(currentSection) + mediaItems.add( + currentSection, + listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"), + null + ) + } + } + } else { + artists.map { artist -> mediaItems.add( - currentSection, - listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"), + artist.name ?: "", + listOf(childMediaId, artist.id, artist.name).joinToString("|"), null ) } } + result.sendResult(mediaItems) + } + } + } + + private fun getAlbumsForArtist( + result: Result>, + id: String, + name: String + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + serviceScope.launch { + val albums = if (!isOffline && useId3Tags) { + callWithErrorHandling { musicService.getArtist(id, name,false) } } else { - artists.map { artist -> - mediaItems.add( - artist.name ?: "", - listOf(MEDIA_ARTIST_ITEM, artist.id).joinToString("|"), - null - ) + callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } + } + + albums?.getAllChild()?.map { album -> + mediaItems.add( + album.title ?: "", + listOf(MEDIA_ALBUM_ITEM, album.id, album.name) + .joinToString("|"), + null + ) + } + result.sendResult(mediaItems) + } + } + + private fun getSongsForAlbum( + result: Result>, + id: String, + name: String + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val songs = listSongsInMusicService(id, name) + + if (songs != null) { + if (songs.getChildren(includeDirs = true, includeFiles = false).count() == 0 && + songs.getChildren(includeDirs = false, includeFiles = true).count() > 0 + ) + mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|")) + + // TODO: Paging is not implemented for songs, is it necessary at all? + val items = songs.getChildren().take(displayLimit) + items.map { item -> + if (item.isDirectory) + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + item, + listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + else + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + item, + listOf( + MEDIA_ALBUM_SONG_ITEM, + id, + name, + item.id + ).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) } } result.sendResult(mediaItems) @@ -320,11 +512,38 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private fun getAlbums( result: Result>, - artistId: String? = null + type: AlbumListType, + page: Int? = null ) { val mediaItems: MutableList = ArrayList() result.detach() - result.sendResult(mediaItems) + serviceScope.launch { + val offset = (page ?: 0) * displayLimit + val albums = if (useId3Tags) { + callWithErrorHandling { musicService.getAlbumList2(type.typeName, displayLimit, offset, null) } + } else { + callWithErrorHandling { musicService.getAlbumList(type.typeName, displayLimit, offset, null) } + } + + albums?.getAllChild()?.map { album -> + mediaItems.add( + album.title ?: "", + listOf(MEDIA_ALBUM_ITEM, album.id, album.name) + .joinToString("|"), + null + ) + } + + if (albums?.getAllChild()?.count() ?: 0 >= displayLimit) + mediaItems.add( + R.string.search_more, + listOf(MEDIA_ALBUM_PAGE_ID, type.typeName, (page ?: 0) + 1).joinToString("|"), + R.drawable.ic_menu_forward_dark, + null + ) + + result.sendResult(mediaItems) + } } private fun getPlaylists(result: Result>) { @@ -332,8 +551,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { result.detach() serviceScope.launch { - val playlists = musicService.getPlaylists(true) - playlists.map { playlist -> + val playlists = callWithErrorHandling { musicService.getPlaylists(true) } + playlists?.map { playlist -> mediaItems.add( playlist.name, listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name) @@ -350,28 +569,34 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { result.detach() serviceScope.launch { - val content = musicService.getPlaylist(id, name) + val content = callWithErrorHandling { musicService.getPlaylist(id, name) } - mediaItems.add( - R.string.select_album_play_all, - listOf(MEDIA_PLAYLIST_ITEM, id, name).joinToString("|"), - R.drawable.ic_stat_play_dark, - null, - false - ) + if (content != null) { + if (content.getAllChild().count() > 1) + mediaItems.addPlayAllItem( + listOf(MEDIA_PLAYLIST_ITEM, id, name).joinToString("|") + ) - // Playlist should be cached as it may contain random elements - playlistCache = content.getAllChild() - playlistCache!!.take(displayLimit).map { item -> - mediaItems.add(MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - item, - listOf(MEDIA_PLAYLIST_SONG_ITEM, id, name, item.id).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - )) + // Playlist should be cached as it may contain random elements + playlistCache = content.getAllChild() + playlistCache!!.take(displayLimit).map { item -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + item, + listOf( + MEDIA_PLAYLIST_SONG_ITEM, + id, + name, + item.id + ).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + result.sendResult(mediaItems) } - result.sendResult(mediaItems) } } @@ -379,17 +604,10 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { serviceScope.launch { if (playlistCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them - val content = musicService.getPlaylist(id, name) - playlistCache = content.getAllChild() + val content = callWithErrorHandling { musicService.getPlaylist(id, name) } + playlistCache = content?.getAllChild() } - mediaPlayerController.download( - playlistCache, - save = false, - autoPlay = true, - playNext = false, - shuffle = false, - newPlaylist = true - ) + if (playlistCache != null) playSongs(playlistCache) } } @@ -397,88 +615,324 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { serviceScope.launch { if (playlistCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them - val content = musicService.getPlaylist(id, name) - playlistCache = content.getAllChild() - } - val song = playlistCache!!.firstOrNull{x -> x.id == songId} - if (song != null) { - mediaPlayerController.download( - listOf(song), - save = false, - autoPlay = false, - playNext = true, - shuffle = false, - newPlaylist = false - ) - mediaPlayerController.next() + val content = callWithErrorHandling { musicService.getPlaylist(id, name) } + playlistCache = content?.getAllChild() } + val song = playlistCache?.firstOrNull{x -> x.id == songId} + if (song != null) playSong(song) + } + } + + private fun playAlbum(id: String, name: String) { + serviceScope.launch { + val songs = listSongsInMusicService(id, name) + if (songs != null) playSongs(songs.getAllChild()) + } + } + + private fun playAlbumSong(id: String, name: String, songId: String) { + serviceScope.launch { + val songs = listSongsInMusicService(id, name) + val song = songs?.getAllChild()?.firstOrNull{x -> x.id == songId} + if (song != null) playSong(song) } } private fun getPodcasts(result: Result>) { val mediaItems: MutableList = ArrayList() result.detach() - result.sendResult(mediaItems) + serviceScope.launch { + val podcasts = callWithErrorHandling { musicService.getPodcastsChannels(false) } + + podcasts?.map { podcast -> + mediaItems.add( + podcast.title ?: "", + listOf(MEDIA_PODCAST_ITEM, podcast.id).joinToString("|"), + null + ) + } + result.sendResult(mediaItems) + } + } + + private fun getPodcastEpisodes( + result: Result>, + id: String + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + serviceScope.launch { + val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } + + if (episodes != null) { + if (episodes.getAllChild().count() > 1) + mediaItems.addPlayAllItem(listOf(MEDIA_PODCAST_ITEM, id).joinToString("|")) + + episodes.getAllChild().map { episode -> + mediaItems.add(MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + episode, + listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) + .joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + )) + } + result.sendResult(mediaItems) + } + } + } + + private fun playPodcast(id: String) { + serviceScope.launch { + val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } + if (episodes != null) { + playSongs(episodes.getAllChild()) + } + } + } + + private fun playPodcastEpisode(id: String, episodeId: String) { + serviceScope.launch { + val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } + if (episodes != null) { + val selectedEpisode = episodes + .getAllChild() + .firstOrNull { episode -> episode.id == episodeId } + if (selectedEpisode != null) playSong(selectedEpisode) + } + } } private fun getBookmarks(result: Result>) { val mediaItems: MutableList = ArrayList() result.detach() - result.sendResult(mediaItems) + serviceScope.launch { + val bookmarks = callWithErrorHandling { musicService.getBookmarks() } + if (bookmarks != null) { + val songs = Util.getSongsFromBookmarks(bookmarks) + + songs.getAllChild().map { song -> + mediaItems.add(MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + )) + } + result.sendResult(mediaItems) + } + } + } + + private fun playBookmark(id: String) { + serviceScope.launch { + val bookmarks = callWithErrorHandling { musicService.getBookmarks() } + if (bookmarks != null) { + val songs = Util.getSongsFromBookmarks(bookmarks) + val song = songs.getAllChild().firstOrNull{song -> song.id == id} + if (song != null) playSong(song) + } + } } private fun getShares(result: Result>) { val mediaItems: MutableList = ArrayList() result.detach() - result.sendResult(mediaItems) + + serviceScope.launch { + val shares = callWithErrorHandling { musicService.getShares(false) } + + shares?.map { share -> + mediaItems.add( + share.name ?: "", + listOf(MEDIA_SHARE_ITEM, share.id) + .joinToString("|"), + null + ) + } + result.sendResult(mediaItems) + } + } + + private fun getSongsForShare( + result: Result>, + id: String + ) { + val mediaItems: MutableList = ArrayList() + result.detach() + + serviceScope.launch { + val shares = callWithErrorHandling { musicService.getShares(false) } + + val selectedShare = shares?.firstOrNull{share -> share.id == id } + if (selectedShare != null) { + + if (selectedShare.getEntries().count() > 1) + mediaItems.addPlayAllItem(listOf(MEDIA_SHARE_ITEM, id).joinToString("|")) + + selectedShare.getEntries().map { song -> + mediaItems.add(MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + )) + } + } + result.sendResult(mediaItems) + } + } + + private fun playShare(id: String) { + serviceScope.launch { + val shares = callWithErrorHandling { musicService.getShares(false) } + val selectedShare = shares?.firstOrNull{share -> share.id == id } + if (selectedShare != null) { + playSongs(selectedShare.getEntries()) + } + } + } + + private fun playShareSong(id: String, songId: String) { + serviceScope.launch { + val shares = callWithErrorHandling { musicService.getShares(false) } + val selectedShare = shares?.firstOrNull{share -> share.id == id } + if (selectedShare != null) { + val song = selectedShare.getEntries().firstOrNull{x -> x.id == songId} + if (song != null) playSong(song) + } + } } private fun getStarredSongs(result: Result>) { val mediaItems: MutableList = ArrayList() result.detach() - result.sendResult(mediaItems) + + serviceScope.launch { + val songs = listStarredSongsInMusicService() + + if (songs != null) { + if (songs.songs.count() > 1) + mediaItems.addPlayAllItem(listOf(MEDIA_SONG_STARRED_ID).joinToString("|")) + + // TODO: Paging is not implemented for songs, is it necessary at all? + val items = songs.songs.take(displayLimit) + starredSongsCache = items + items.map { song -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + } + result.sendResult(mediaItems) + } + } + + private fun playStarredSongs() { + serviceScope.launch { + if (starredSongsCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = listStarredSongsInMusicService() + starredSongsCache = content?.songs + } + if (starredSongsCache != null) playSongs(starredSongsCache) + } + } + + private fun playStarredSong(songId: String) { + serviceScope.launch { + if (starredSongsCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + val content = listStarredSongsInMusicService() + starredSongsCache = content?.songs + } + val song = starredSongsCache?.firstOrNull{x -> x.id == songId} + if (song != null) playSong(song) + } } private fun getRandomSongs(result: Result>) { val mediaItems: MutableList = ArrayList() result.detach() - result.sendResult(mediaItems) + + serviceScope.launch { + val songs = callWithErrorHandling { musicService.getRandomSongs(displayLimit) } + + if (songs != null) { + if (songs.getAllChild().count() > 1) + mediaItems.addPlayAllItem(listOf(MEDIA_SONG_RANDOM_ID).joinToString("|")) + + // TODO: Paging is not implemented for songs, is it necessary at all? + val items = songs.getAllChild() + randomSongsCache = items + items.map { song -> + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) + } + } + result.sendResult(mediaItems) + } } - private fun getStarredAlbums(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() - result.sendResult(mediaItems) + private fun playRandomSongs() { + serviceScope.launch { + if (randomSongsCache == null) { + // This can only happen if Android Auto cached items, but Ultrasonic has forgot them + // In this case we request a new set of random songs + val content = callWithErrorHandling { musicService.getRandomSongs(displayLimit) } + randomSongsCache = content?.getAllChild() + } + if (randomSongsCache != null) playSongs(randomSongsCache) + } } - private fun getRandomAlbums(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() - result.sendResult(mediaItems) + private fun playRandomSong(songId: String) { + serviceScope.launch { + // If there is no cache, we can't play the selected song. + if (randomSongsCache != null) { + val song = randomSongsCache!!.firstOrNull { x -> x.id == songId } + if (song != null) playSong(song) + } + } } - private fun getRecentAlbums(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() - result.sendResult(mediaItems) + private fun listSongsInMusicService(id: String, name: String): MusicDirectory? { + return if (!ActiveServerProvider.isOffline() && Util.getShouldUseId3Tags()) { + callWithErrorHandling { musicService.getAlbum(id, name, false) } + } else { + callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } + } } - private fun getNewestAlbums(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() - result.sendResult(mediaItems) - } - - private fun getFrequentAlbums(result: Result>) { - val mediaItems: MutableList = ArrayList() - result.detach() - result.sendResult(mediaItems) + private fun listStarredSongsInMusicService(): SearchResult? { + return if (Util.getShouldUseId3Tags()) { + callWithErrorHandling { musicService.getStarred2() } + } else { + callWithErrorHandling { musicService.getStarred() } + } } private fun MutableList.add( title: String, mediaId: String, icon: Int?, + groupNameId: Int? = null ) { val builder = MediaDescriptionCompat.Builder() builder.setTitle(title) @@ -487,6 +941,12 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { if (icon != null) builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) + if (groupNameId != null) + builder.setExtras(Bundle().apply { putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + getString(groupNameId) + ) }) + val mediaItem = MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE @@ -524,9 +984,54 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { this.add(mediaItem) } + private fun MutableList.addPlayAllItem( + mediaId: String, + ) { + this.add( + R.string.select_album_play_all, + mediaId, + R.drawable.ic_stat_play_dark, + null, + false + ) + } + private fun getSectionFromName(name: String): String { var section = name.first().uppercaseChar() if (!section.isLetter()) section = '#' return section.toString() } + + private fun playSongs(songs: List?) { + mediaPlayerController.download( + songs, + save = false, + autoPlay = true, + playNext = false, + shuffle = false, + newPlaylist = true + ) + } + + private fun playSong(song: MusicDirectory.Entry) { + mediaPlayerController.download( + listOf(song), + save = false, + autoPlay = false, + playNext = true, + shuffle = false, + newPlaylist = false + ) + mediaPlayerController.next() + } + + private fun callWithErrorHandling(function: () -> T): T? { + // TODO Implement better error handling + return try { + function() + } catch (all: Exception) { + Timber.i(all) + null + } + } } \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt deleted file mode 100644 index a69cf32a..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaPlayerService.kt +++ /dev/null @@ -1,344 +0,0 @@ -package org.moire.ultrasonic.service - -import android.os.Bundle -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaDescriptionCompat -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import androidx.media.MediaBrowserServiceCompat -import androidx.media.utils.MediaConstants -import org.moire.ultrasonic.api.subsonic.models.AlbumListType -import org.moire.ultrasonic.domain.ArtistOrIndex -import org.moire.ultrasonic.domain.MusicDirectory -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.fragment.AlbumListModel -import org.moire.ultrasonic.fragment.ArtistListModel -import org.moire.ultrasonic.util.Constants -import org.moire.ultrasonic.util.Pair -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -class AutoMediaPlayerService: MediaBrowserServiceCompat() { - - val mediaPlayerService : MediaPlayerService = MediaPlayerService() - var albumListModel: AlbumListModel? = null - var artistListModel: ArtistListModel? = null - - val executorService: ExecutorService = Executors.newFixedThreadPool(4) - var maximumRootChildLimit: Int = 4 - - private val MEDIA_BROWSER_ROOT_ID = "_Ultrasonice_mb_root_" - - private val MEDIA_BROWSER_RECENT_LIST_ROOT = "_Ultrasonic_mb_recent_list_root_" - private val MEDIA_BROWSER_ALBUM_LIST_ROOT = "_Ultrasonic_mb_album_list_root_" - private val MEDIA_BROWSER_ARTIST_LIST_ROOT = "_Ultrasonic_mb_rtist_list_root_" - - private val MEDIA_BROWSER_RECENT_PREFIX = "_Ultrasonic_mb_recent_prefix_" - private val MEDIA_BROWSER_ALBUM_PREFIX = "_Ultrasonic_mb_album_prefix_" - private val MEDIA_BROWSER_ARTIST_PREFIX = "_Ultrasonic_mb_artist_prefix_" - - private val MEDIA_BROWSER_EXTRA_ALBUM_LIST = "_Ultrasonic_mb_extra_album_list_" - private val MEDIA_BROWSER_EXTRA_MEDIA_ID = "_Ultrasonic_mb_extra_media_id_" - - class AlbumListObserver( - val idPrefix: String, - val result: MediaBrowserServiceCompat.Result>, - data: LiveData> - ) : - Observer> { - - private var liveData: LiveData>? = null - - init { - // Order is very important here. When observerForever is called onChanged - // will immediately be called with any past data updates. We don't care - // about those. So by having it called *before* liveData is set will - // signal to onChanged to ignore the first input - data.observeForever(this) - liveData = data - } - - override fun onChanged(albumList: List?) { - if (liveData == null) { - // See comment in the initializer - return - } - liveData!!.removeObserver(this) - if (albumList == null) { - return - } - val mediaItems: MutableList = mutableListOf() - for (item in albumList) { - val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() - entryBuilder - .setTitle(item.title) - .setMediaId(idPrefix + item.id) - mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) - } - - result.sendResult(mediaItems) - } - } - - class ArtistListObserver( - val idPrefix: String, - val result: MediaBrowserServiceCompat.Result>, - data: LiveData> - ) : - Observer> { - - private var liveData: LiveData>? = null - - init { - // Order is very important here. When observerForever is called onChanged - // will immediately be called with any past data updates. We don't care - // about those. So by having it called *before* liveData is set will - // signal to onChanged to ignore the first input - data.observeForever(this) - liveData = data - } - - override fun onChanged(artistList: List?) { - if (liveData == null) { - // See comment in the initializer - return - } - liveData!!.removeObserver(this) - if (artistList == null) { - return - } - val mediaItems: MutableList = mutableListOf() - for (item in artistList) { - val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() - entryBuilder - .setTitle(item.name) - .setMediaId(idPrefix + item.id) - mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) - } - - result.sendResult(mediaItems) - } - } - - override fun onCreate() { - super.onCreate() - - albumListModel = AlbumListModel(application) - artistListModel = ArtistListModel(application) - - //mediaPlayerService.onCreate() - //mediaPlayerService.updateMediaSession(null, PlayerState.IDLE) - } - - override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { - if (rootHints != null) { - maximumRootChildLimit = rootHints.getInt( - MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, - 4 - ) - } - // opt into the root tabs (because it's gonna be non-optional - // real soon anyway) - val extras = Bundle() - val TABS_OPT_IN_HINT = "android.media.browse.AUTO_TABS_OPT_IN_HINT" - extras.putBoolean(TABS_OPT_IN_HINT, true) - return MediaBrowserServiceCompat.BrowserRoot(MEDIA_BROWSER_ROOT_ID, extras) - } - - override fun onLoadChildren(parentId: String, result: Result>) { - val mediaItems: MutableList = mutableListOf() - - if (MEDIA_BROWSER_ROOT_ID == parentId) { - // Build the MediaItem objects for the top level, - // and put them in the mediaItems list... - - var recentList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() - recentList.setTitle("Recent").setMediaId(MEDIA_BROWSER_RECENT_LIST_ROOT) - mediaItems.add( - MediaBrowserCompat.MediaItem( - recentList.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) - var albumList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() - albumList.setTitle("Albums").setMediaId(MEDIA_BROWSER_ALBUM_LIST_ROOT) - mediaItems.add( - MediaBrowserCompat.MediaItem( - albumList.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) - var artistList: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() - artistList.setTitle("Artists").setMediaId(MEDIA_BROWSER_ARTIST_LIST_ROOT) - mediaItems.add( - MediaBrowserCompat.MediaItem( - artistList.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) - } else if (MEDIA_BROWSER_RECENT_LIST_ROOT == parentId) { - fetchAlbumList(AlbumListType.RECENT, MEDIA_BROWSER_RECENT_PREFIX, result) - return - } else if (MEDIA_BROWSER_ALBUM_LIST_ROOT == parentId) { - fetchAlbumList(AlbumListType.SORTED_BY_NAME, MEDIA_BROWSER_ALBUM_PREFIX, result) - return - } else if (MEDIA_BROWSER_ARTIST_LIST_ROOT == parentId) { - fetchArtistList(MEDIA_BROWSER_ARTIST_PREFIX, result) - return - } else if (parentId.startsWith(MEDIA_BROWSER_RECENT_PREFIX)) { - fetchTrackList(parentId.substring(MEDIA_BROWSER_RECENT_PREFIX.length), result) - return - } else if (parentId.startsWith(MEDIA_BROWSER_ALBUM_PREFIX)) { - fetchTrackList(parentId.substring(MEDIA_BROWSER_ALBUM_PREFIX.length), result) - return - } else if (parentId.startsWith(MEDIA_BROWSER_ARTIST_PREFIX)) { - fetchArtistAlbumList( - parentId.substring(MEDIA_BROWSER_ARTIST_PREFIX.length), - result - ) - return - } else { - // Examine the passed parentMediaId to see which submenu we're at, - // and put the children of that menu in the mediaItems list... - } - result.sendResult(mediaItems) - } - - - fun getBundleData(bundle: Bundle?): Pair>? { - if (bundle == null) { - return null - } - - if (!bundle.containsKey(MEDIA_BROWSER_EXTRA_ALBUM_LIST) || - !bundle.containsKey(MEDIA_BROWSER_EXTRA_MEDIA_ID) - ) { - return null - } - val bytes = bundle.getByteArray(MEDIA_BROWSER_EXTRA_ALBUM_LIST) - val byteArrayInputStream = ByteArrayInputStream(bytes) - val objectInputStream = ObjectInputStream(byteArrayInputStream) - return Pair( - bundle.getString(MEDIA_BROWSER_EXTRA_MEDIA_ID), - objectInputStream.readObject() as List - ) - } - - private fun fetchAlbumList( - type: AlbumListType, - idPrefix: String, - result: MediaBrowserServiceCompat.Result> - ) { - AutoMediaPlayerService.AlbumListObserver( - idPrefix, result, - albumListModel!!.albumList - ) - - val args: Bundle = Bundle() - args.putString(Constants.INTENT_EXTRA_NAME_ALBUM_LIST_TYPE, type.toString()) - albumListModel!!.getAlbumList(false, null, args) - result.detach() - } - - private fun fetchArtistList( - idPrefix: String, - result: MediaBrowserServiceCompat.Result> - ) { - AutoMediaPlayerService.ArtistListObserver(idPrefix, result, artistListModel!!.artists) - - artistListModel!!.getItems(false, null) - result.detach() - } - - private fun fetchArtistAlbumList( - id: String, - result: MediaBrowserServiceCompat.Result> - ) { - executorService.execute { - val musicService = MusicServiceFactory.getMusicService() - - val musicDirectory = musicService.getMusicDirectory( - id, "", false - ) - val mediaItems: MutableList = mutableListOf() - - for (item in musicDirectory.getAllChild()) { - val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() - entryBuilder.setTitle(item.title).setMediaId(MEDIA_BROWSER_ALBUM_PREFIX + item.id) - mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ) - ) - } - result.sendResult(mediaItems) - } - result.detach() - } - - private fun fetchTrackList( - id: String, - result: MediaBrowserServiceCompat.Result> - ) { - executorService.execute { - val musicService = MusicServiceFactory.getMusicService() - - val albumDirectory = musicService.getAlbum( - id, "", false - ) - - // The idea here is that we want to attach the full album list to every song, - // as well as the id of the specific song. This way if someone chooses to play a song - // we can add the song and all subsequent songs in the album - val byteArrayOutputStream = ByteArrayOutputStream() - val objectOutputStream = ObjectOutputStream(byteArrayOutputStream) - objectOutputStream.writeObject(albumDirectory.getAllChild()) - objectOutputStream.close() - val songList = byteArrayOutputStream.toByteArray() - val mediaItems: MutableList = mutableListOf() - - for (item in albumDirectory.getAllChild()) { - val extras = Bundle() - - extras.putByteArray( - MEDIA_BROWSER_EXTRA_ALBUM_LIST, - songList - ) - extras.putString( - MEDIA_BROWSER_EXTRA_MEDIA_ID, - item.id - ) - - val entryBuilder: MediaDescriptionCompat.Builder = - MediaDescriptionCompat.Builder() - entryBuilder.setTitle(item.title).setMediaId(item.id).setExtras(extras) - mediaItems.add( - MediaBrowserCompat.MediaItem( - entryBuilder.build(), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - ) - ) - } - result.sendResult(mediaItems) - } - result.detach() - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index 032e6dbd..da3a03e7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -535,7 +535,7 @@ class MediaPlayerService : Service() { // Init val context = applicationContext val song = currentPlaying?.song - val stopIntent = getPendingIntentForMediaAction(context, KeyEvent.KEYCODE_MEDIA_STOP, 100) + val stopIntent = Util.getPendingIntentForMediaAction(context, KeyEvent.KEYCODE_MEDIA_STOP, 100) // We should use a single notification builder, otherwise the notification may not be updated if (notificationBuilder == null) { @@ -654,7 +654,7 @@ class MediaPlayerService : Service() { else -> return null } - val pendingIntent = getPendingIntentForMediaAction(context, keycode, requestCode) + val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode) return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() } @@ -665,7 +665,7 @@ class MediaPlayerService : Service() { ): NotificationCompat.Action { val isPlaying = playerState === PlayerState.STARTED val keycode = KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE - val pendingIntent = getPendingIntentForMediaAction(context, keycode, requestCode) + val pendingIntent = Util.getPendingIntentForMediaAction(context, keycode, requestCode) val label: String val icon: Int @@ -698,7 +698,7 @@ class MediaPlayerService : Service() { icon = R.drawable.ic_star_hollow_dark } - val pendingIntent = getPendingIntentForMediaAction(context, keyCode, requestCode) + val pendingIntent = Util.getPendingIntentForMediaAction(context, keyCode, requestCode) return NotificationCompat.Action.Builder(icon, label, pendingIntent).build() } @@ -710,18 +710,6 @@ class MediaPlayerService : Service() { return PendingIntent.getActivity(this, 0, intent, flags) } - private fun getPendingIntentForMediaAction( - context: Context, - keycode: Int, - requestCode: Int - ): PendingIntent { - val intent = Intent(Constants.CMD_PROCESS_KEYCODE) - val flags = PendingIntent.FLAG_UPDATE_CURRENT - intent.setPackage(context.packageName) - intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) - return PendingIntent.getBroadcast(context, requestCode, intent, flags) - } - @Suppress("MagicNumber") companion object { private const val NOTIFICATION_CHANNEL_ID = "org.moire.ultrasonic" diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt index 7271f9c8..0820af47 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt @@ -5,12 +5,10 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Bundle -import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN -import android.text.TextUtils import android.view.KeyEvent import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -75,7 +73,7 @@ class MediaSessionHandler : KoinComponent { override fun onPlay() { super.onPlay() - getPendingIntentForMediaAction( + Util.getPendingIntentForMediaAction( applicationContext, KeyEvent.KEYCODE_MEDIA_PLAY, keycode @@ -100,7 +98,7 @@ class MediaSessionHandler : KoinComponent { override fun onPause() { super.onPause() - getPendingIntentForMediaAction( + Util.getPendingIntentForMediaAction( applicationContext, KeyEvent.KEYCODE_MEDIA_PAUSE, keycode @@ -110,7 +108,7 @@ class MediaSessionHandler : KoinComponent { override fun onStop() { super.onStop() - getPendingIntentForMediaAction( + Util.getPendingIntentForMediaAction( applicationContext, KeyEvent.KEYCODE_MEDIA_STOP, keycode @@ -120,7 +118,7 @@ class MediaSessionHandler : KoinComponent { override fun onSkipToNext() { super.onSkipToNext() - getPendingIntentForMediaAction( + Util.getPendingIntentForMediaAction( applicationContext, KeyEvent.KEYCODE_MEDIA_NEXT, keycode @@ -130,7 +128,7 @@ class MediaSessionHandler : KoinComponent { override fun onSkipToPrevious() { super.onSkipToPrevious() - getPendingIntentForMediaAction( + Util.getPendingIntentForMediaAction( applicationContext, KeyEvent.KEYCODE_MEDIA_PREVIOUS, keycode @@ -248,7 +246,6 @@ class MediaSessionHandler : KoinComponent { cachedPlaylist = playlist if (mediaSession == null) return - // TODO Implement Now Playing queue handling properly mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing)) mediaSession!!.setQueue(playlist.mapIndexed { id, song -> MediaSessionCompat.QueueItem( @@ -306,17 +303,4 @@ class MediaSessionHandler : KoinComponent { private fun unregisterMediaButtonEventReceiver() { mediaSession?.setMediaButtonReceiver(null) } - - // TODO Copied from MediaPlayerService. Move to Utils - private fun getPendingIntentForMediaAction( - context: Context, - keycode: Int, - requestCode: Int - ): PendingIntent { - val intent = Intent(Constants.CMD_PROCESS_KEYCODE) - val flags = PendingIntent.FLAG_UPDATE_CURRENT - intent.setPackage(context.packageName) - intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) - return PendingIntent.getBroadcast(context, requestCode, intent, flags) - } } \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index b6021aee..e0b35e66 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -21,6 +21,7 @@ package org.moire.ultrasonic.util import android.annotation.SuppressLint import android.app.Activity import android.app.AlertDialog +import android.app.PendingIntent import android.content.ContentResolver import android.content.Context import android.content.DialogInterface @@ -37,15 +38,18 @@ import android.net.Uri import android.net.wifi.WifiManager import android.net.wifi.WifiManager.WifiLock import android.os.Build +import android.os.Bundle import android.os.Environment import android.os.Parcelable import android.support.v4.media.MediaDescriptionCompat import android.text.TextUtils import android.util.TypedValue import android.view.Gravity +import android.view.KeyEvent import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.annotation.AnyRes +import androidx.media.utils.MediaConstants import androidx.preference.PreferenceManager import org.moire.ultrasonic.R import org.moire.ultrasonic.app.UApp.Companion.applicationContext @@ -641,12 +645,13 @@ object Util { } @JvmStatic - fun getSongsFromBookmarks(bookmarks: Iterable): MusicDirectory { + fun getSongsFromBookmarks(bookmarks: Iterable): MusicDirectory { val musicDirectory = MusicDirectory() var song: MusicDirectory.Entry - for ((position, _, _, _, _, entry) in bookmarks) { - song = entry - song.bookmarkPosition = position + for (bookmark in bookmarks) { + if (bookmark == null) continue + song = bookmark.entry + song.bookmarkPosition = bookmark.position musicDirectory.addChild(song) } return musicDirectory @@ -1255,7 +1260,11 @@ object Util { ) } - fun getMediaDescriptionForEntry(song: MusicDirectory.Entry, mediaId: String? = null): MediaDescriptionCompat { + fun getMediaDescriptionForEntry( + song: MusicDirectory.Entry, + mediaId: String? = null, + groupNameId: Int? = null + ): MediaDescriptionCompat { val descriptionBuilder = MediaDescriptionCompat.Builder() val artist = StringBuilder(60) @@ -1266,7 +1275,7 @@ object Util { artist.append(String.format("%s ", formatTotalDuration(duration.toLong()))) } - if (song.bitRate != null) + if (song.bitRate != null && song.bitRate!! > 0) bitRate = String.format( appContext().getString(R.string.song_details_kbps), song.bitRate ) @@ -1282,7 +1291,7 @@ object Util { val artistName = song.artist if (artistName != null) { - if (shouldDisplayBitrateWithArtist()) { + if (shouldDisplayBitrateWithArtist() && (!bitRate.isNullOrBlank() || !fileFormat.isNullOrBlank())) { artist.append(artistName).append(" (").append( String.format( appContext().getString(R.string.song_details_all), @@ -1311,10 +1320,28 @@ object Util { ).append(')') } + if (groupNameId != null) + descriptionBuilder.setExtras(Bundle().apply { putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + appContext().getString(groupNameId) + ) }) + descriptionBuilder.setTitle(title) descriptionBuilder.setSubtitle(artist) descriptionBuilder.setMediaId(mediaId) return descriptionBuilder.build() } + + fun getPendingIntentForMediaAction( + context: Context, + keycode: Int, + requestCode: Int + ): PendingIntent { + val intent = Intent(Constants.CMD_PROCESS_KEYCODE) + val flags = PendingIntent.FLAG_UPDATE_CURRENT + intent.setPackage(context.packageName) + intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) + return PendingIntent.getBroadcast(context, requestCode, intent, flags) + } } \ No newline at end of file From 982639d2c72a6e7a51e167dc16f4436c7a8d48a8 Mon Sep 17 00:00:00 2001 From: Nite Date: Sun, 18 Jul 2021 13:17:29 +0200 Subject: [PATCH 10/14] Fixed lint errors Added comments --- ultrasonic/src/main/AndroidManifest.xml | 6 +- .../moire/ultrasonic/di/MediaPlayerModule.kt | 1 - .../service/AutoMediaBrowserService.kt | 269 +++++++++++------- .../service/DownloadQueueSerializer.kt | 15 +- .../ultrasonic/service/LocalMediaPlayer.kt | 7 +- .../service/MediaPlayerLifecycleSupport.kt | 64 ++--- .../ultrasonic/service/MediaPlayerService.kt | 20 +- .../util/MediaSessionEventDistributor.kt | 12 +- .../util/MediaSessionEventListener.kt | 7 + .../ultrasonic/util/MediaSessionHandler.kt | 41 ++- .../kotlin/org/moire/ultrasonic/util/Util.kt | 181 +++++++----- .../src/main/res/drawable/ic_artist.xml | 5 +- .../src/main/res/drawable/ic_library.xml | 6 +- 13 files changed, 385 insertions(+), 249 deletions(-) diff --git a/ultrasonic/src/main/AndroidManifest.xml b/ultrasonic/src/main/AndroidManifest.xml index 08a2d5aa..e80ca5f7 100644 --- a/ultrasonic/src/main/AndroidManifest.xml +++ b/ultrasonic/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ + xmlns:tools="http://schemas.android.com/tools" + package="org.moire.ultrasonic" + android:installLocation="auto"> @@ -60,6 +61,7 @@
diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt index 2e9810c4..f50883d6 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/di/MediaPlayerModule.kt @@ -1,6 +1,5 @@ package org.moire.ultrasonic.di -import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.moire.ultrasonic.service.AudioFocusHandler import org.moire.ultrasonic.service.DownloadQueueSerializer diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index 2cea930a..e834cd42 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -1,3 +1,10 @@ +/* + * AutoMediaBrowserService.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + package org.moire.ultrasonic.service import android.os.Bundle @@ -24,44 +31,45 @@ import org.moire.ultrasonic.util.MediaSessionHandler import org.moire.ultrasonic.util.Util import timber.log.Timber -const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID" -const val MEDIA_ALBUM_ID = "MEDIA_ALBUM_ID" -const val MEDIA_ALBUM_PAGE_ID = "MEDIA_ALBUM_PAGE_ID" -const val MEDIA_ALBUM_NEWEST_ID = "MEDIA_ALBUM_NEWEST_ID" -const val MEDIA_ALBUM_RECENT_ID = "MEDIA_ALBUM_RECENT_ID" -const val MEDIA_ALBUM_FREQUENT_ID = "MEDIA_ALBUM_FREQUENT_ID" -const val MEDIA_ALBUM_RANDOM_ID = "MEDIA_ALBUM_RANDOM_ID" -const val MEDIA_ALBUM_STARRED_ID = "MEDIA_ALBUM_STARRED_ID" -const val MEDIA_SONG_RANDOM_ID = "MEDIA_SONG_RANDOM_ID" -const val MEDIA_SONG_STARRED_ID = "MEDIA_SONG_STARRED_ID" -const val MEDIA_ARTIST_ID = "MEDIA_ARTIST_ID" -const val MEDIA_LIBRARY_ID = "MEDIA_LIBRARY_ID" -const val MEDIA_PLAYLIST_ID = "MEDIA_PLAYLIST_ID" -const val MEDIA_SHARE_ID = "MEDIA_SHARE_ID" -const val MEDIA_BOOKMARK_ID = "MEDIA_BOOKMARK_ID" -const val MEDIA_PODCAST_ID = "MEDIA_PODCAST_ID" -const val MEDIA_ALBUM_ITEM = "MEDIA_ALBUM_ITEM" -const val MEDIA_PLAYLIST_SONG_ITEM = "MEDIA_PLAYLIST_SONG_ITEM" -const val MEDIA_PLAYLIST_ITEM = "MEDIA_PLAYLIST_ITEM" -const val MEDIA_ARTIST_ITEM = "MEDIA_ARTIST_ITEM" -const val MEDIA_ARTIST_SECTION = "MEDIA_ARTIST_SECTION" -const val MEDIA_ALBUM_SONG_ITEM = "MEDIA_ALBUM_SONG_ITEM" -const val MEDIA_SONG_STARRED_ITEM = "MEDIA_SONG_STARRED_ITEM" -const val MEDIA_SONG_RANDOM_ITEM = "MEDIA_SONG_RANDOM_ITEM" -const val MEDIA_SHARE_ITEM = "MEDIA_SHARE_ITEM" -const val MEDIA_SHARE_SONG_ITEM = "MEDIA_SHARE_SONG_ITEM" -const val MEDIA_BOOKMARK_ITEM = "MEDIA_BOOKMARK_ITEM" -const val MEDIA_PODCAST_ITEM = "MEDIA_PODCAST_ITEM" -const val MEDIA_PODCAST_EPISODE_ITEM = "MEDIA_PODCAST_EPISODE_ITEM" -const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM" +private const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID" +private const val MEDIA_ALBUM_ID = "MEDIA_ALBUM_ID" +private const val MEDIA_ALBUM_PAGE_ID = "MEDIA_ALBUM_PAGE_ID" +private const val MEDIA_ALBUM_NEWEST_ID = "MEDIA_ALBUM_NEWEST_ID" +private const val MEDIA_ALBUM_RECENT_ID = "MEDIA_ALBUM_RECENT_ID" +private const val MEDIA_ALBUM_FREQUENT_ID = "MEDIA_ALBUM_FREQUENT_ID" +private const val MEDIA_ALBUM_RANDOM_ID = "MEDIA_ALBUM_RANDOM_ID" +private const val MEDIA_ALBUM_STARRED_ID = "MEDIA_ALBUM_STARRED_ID" +private const val MEDIA_SONG_RANDOM_ID = "MEDIA_SONG_RANDOM_ID" +private const val MEDIA_SONG_STARRED_ID = "MEDIA_SONG_STARRED_ID" +private const val MEDIA_ARTIST_ID = "MEDIA_ARTIST_ID" +private const val MEDIA_LIBRARY_ID = "MEDIA_LIBRARY_ID" +private const val MEDIA_PLAYLIST_ID = "MEDIA_PLAYLIST_ID" +private const val MEDIA_SHARE_ID = "MEDIA_SHARE_ID" +private const val MEDIA_BOOKMARK_ID = "MEDIA_BOOKMARK_ID" +private const val MEDIA_PODCAST_ID = "MEDIA_PODCAST_ID" +private const val MEDIA_ALBUM_ITEM = "MEDIA_ALBUM_ITEM" +private const val MEDIA_PLAYLIST_SONG_ITEM = "MEDIA_PLAYLIST_SONG_ITEM" +private const val MEDIA_PLAYLIST_ITEM = "MEDIA_PLAYLIST_ITEM" +private const val MEDIA_ARTIST_ITEM = "MEDIA_ARTIST_ITEM" +private const val MEDIA_ARTIST_SECTION = "MEDIA_ARTIST_SECTION" +private const val MEDIA_ALBUM_SONG_ITEM = "MEDIA_ALBUM_SONG_ITEM" +private const val MEDIA_SONG_STARRED_ITEM = "MEDIA_SONG_STARRED_ITEM" +private const val MEDIA_SONG_RANDOM_ITEM = "MEDIA_SONG_RANDOM_ITEM" +private const val MEDIA_SHARE_ITEM = "MEDIA_SHARE_ITEM" +private const val MEDIA_SHARE_SONG_ITEM = "MEDIA_SHARE_SONG_ITEM" +private const val MEDIA_BOOKMARK_ITEM = "MEDIA_BOOKMARK_ITEM" +private const val MEDIA_PODCAST_ITEM = "MEDIA_PODCAST_ITEM" +private const val MEDIA_PODCAST_EPISODE_ITEM = "MEDIA_PODCAST_EPISODE_ITEM" +private const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM" // Currently the display limit for long lists is 100 items -const val displayLimit = 100 -const val searchLimit = 10 +private const val DISPLAY_LIMIT = 100 +private const val SEARCH_LIMIT = 10 /** * MediaBrowserService implementation for e.g. Android Auto */ +@Suppress("TooManyFunctions", "LargeClass") class AutoMediaBrowserService : MediaBrowserServiceCompat() { private lateinit var mediaSessionEventListener: MediaSessionEventListener @@ -84,6 +92,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private val useId3Tags get() = Util.getShouldUseId3Tags() private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId + @Suppress("MagicNumber") override fun onCreate() { super.onCreate() @@ -95,16 +104,23 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } override fun onPlayFromMediaIdRequested(mediaId: String?, extras: Bundle?) { - Timber.d("AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", mediaId) + Timber.d( + "AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", + mediaId + ) if (mediaId == null) return val mediaIdParts = mediaId.split('|') when (mediaIdParts.first()) { MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2]) - MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong(mediaIdParts[1], mediaIdParts[2], mediaIdParts[3]) + 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_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() @@ -113,7 +129,9 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { 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_PODCAST_EPISODE_ITEM -> playPodcastEpisode( + mediaIdParts[1], mediaIdParts[2] + ) MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) } } @@ -123,7 +141,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { if (query.isNullOrBlank()) playRandomSongs() serviceScope.launch { - val criteria = SearchCriteria(query!!, 0, 0, displayLimit) + val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT) val searchResult = callWithErrorHandling { musicService.search(criteria) } // Try to find the best match @@ -146,12 +164,17 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { mediaSessionHandler.initialize() val handler = Handler() - handler.postDelayed({ - // Ultrasonic may be started from Android Auto. This boots up the necessary components. - Timber.d("AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService...") - lifecycleSupport.onCreate() - MediaPlayerService.getInstance() - }, 100) + handler.postDelayed( + { + // Ultrasonic may be started from Android Auto. This boots up the necessary components. + Timber.d( + "AutoMediaBrowserService starting lifecycleSupport and MediaPlayerService..." + ) + lifecycleSupport.onCreate() + MediaPlayerService.getInstance() + }, + 100 + ) Timber.i("AutoMediaBrowserService onCreate finished") } @@ -170,21 +193,28 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { clientUid: Int, rootHints: Bundle? ): BrowserRoot { - Timber.d("AutoMediaBrowserService onGetRoot called. clientPackageName: %s; clientUid: %d", clientPackageName, clientUid) + Timber.d( + "AutoMediaBrowserService onGetRoot called. clientPackageName: %s; clientUid: %d", + clientPackageName, clientUid + ) val extras = Bundle() extras.putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM + ) extras.putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM) + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM + ) extras.putBoolean( - MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true + ) return BrowserRoot(MEDIA_ROOT_ID, extras) } + @Suppress("ReturnCount", "ComplexMethod") override fun onLoadChildren( parentId: String, result: Result> @@ -199,7 +229,9 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { MEDIA_ARTIST_ID -> return getArtists(result) MEDIA_ARTIST_SECTION -> return getArtists(result, parentIdParts[1]) MEDIA_ALBUM_ID -> return getAlbums(result, AlbumListType.SORTED_BY_NAME) - MEDIA_ALBUM_PAGE_ID -> return getAlbums(result, AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt()) + MEDIA_ALBUM_PAGE_ID -> return getAlbums( + result, AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() + ) MEDIA_PLAYLIST_ID -> return getPlaylists(result) MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(result, AlbumListType.FREQUENT) MEDIA_ALBUM_NEWEST_ID -> return getAlbums(result, AlbumListType.NEWEST) @@ -212,7 +244,9 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { MEDIA_BOOKMARK_ID -> return getBookmarks(result) MEDIA_PODCAST_ID -> return getPodcasts(result) MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2], result) - MEDIA_ARTIST_ITEM -> return getAlbumsForArtist(result, parentIdParts[1], parentIdParts[2]) + MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( + result, parentIdParts[1], parentIdParts[2] + ) MEDIA_ALBUM_ITEM -> return getSongsForAlbum(result, parentIdParts[1], parentIdParts[2]) MEDIA_SHARE_ITEM -> return getSongsForShare(result, parentIdParts[1]) MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(result, parentIdParts[1]) @@ -230,7 +264,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { result.detach() serviceScope.launch { - val criteria = SearchCriteria(query, searchLimit, searchLimit, searchLimit) + val criteria = SearchCriteria(query, SEARCH_LIMIT, SEARCH_LIMIT, SEARCH_LIMIT) val searchResult = callWithErrorHandling { musicService.search(criteria) } // TODO Add More... button to categories @@ -272,7 +306,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun playSearch(id : String) { + private fun playSearch(id: String) { serviceScope.launch { // If there is no cache, we can't play the selected song. if (searchSongsCache != null) { @@ -380,7 +414,10 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { result.sendResult(mediaItems) } - private fun getArtists(result: Result>, section: String? = null) { + private fun getArtists( + result: Result>, + section: String? = null + ) { val mediaItems: MutableList = ArrayList() result.detach() @@ -404,7 +441,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } // If there are too many artists, create alphabetic index of them - if (section == null && artists.count() > displayLimit) { + if (section == null && artists.count() > DISPLAY_LIMIT) { val index = mutableListOf() // TODO This sort should use ignoredArticles somehow... artists = artists.sortedBy { artist -> artist.name } @@ -442,7 +479,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { result.detach() serviceScope.launch { val albums = if (!isOffline && useId3Tags) { - callWithErrorHandling { musicService.getArtist(id, name,false) } + callWithErrorHandling { musicService.getArtist(id, name, false) } } else { callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } } @@ -477,7 +514,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|")) // TODO: Paging is not implemented for songs, is it necessary at all? - val items = songs.getChildren().take(displayLimit) + val items = songs.getChildren().take(DISPLAY_LIMIT) items.map { item -> if (item.isDirectory) mediaItems.add( @@ -518,11 +555,19 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val mediaItems: MutableList = ArrayList() result.detach() serviceScope.launch { - val offset = (page ?: 0) * displayLimit + val offset = (page ?: 0) * DISPLAY_LIMIT val albums = if (useId3Tags) { - callWithErrorHandling { musicService.getAlbumList2(type.typeName, displayLimit, offset, null) } + callWithErrorHandling { + musicService.getAlbumList2( + type.typeName, DISPLAY_LIMIT, offset, null + ) + } } else { - callWithErrorHandling { musicService.getAlbumList(type.typeName, displayLimit, offset, null) } + callWithErrorHandling { + musicService.getAlbumList( + type.typeName, DISPLAY_LIMIT, offset, null + ) + } } albums?.getAllChild()?.map { album -> @@ -534,7 +579,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { ) } - if (albums?.getAllChild()?.count() ?: 0 >= displayLimit) + if (albums?.getAllChild()?.count() ?: 0 >= DISPLAY_LIMIT) mediaItems.add( R.string.search_more, listOf(MEDIA_ALBUM_PAGE_ID, type.typeName, (page ?: 0) + 1).joinToString("|"), @@ -564,7 +609,11 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { } } - private fun getPlaylist(id: String, name: String, result: Result>) { + private fun getPlaylist( + id: String, + name: String, + result: Result> + ) { val mediaItems: MutableList = ArrayList() result.detach() @@ -579,7 +628,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { // Playlist should be cached as it may contain random elements playlistCache = content.getAllChild() - playlistCache!!.take(displayLimit).map { item -> + playlistCache!!.take(DISPLAY_LIMIT).map { item -> mediaItems.add( MediaBrowserCompat.MediaItem( Util.getMediaDescriptionForEntry( @@ -618,7 +667,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val content = callWithErrorHandling { musicService.getPlaylist(id, name) } playlistCache = content?.getAllChild() } - val song = playlistCache?.firstOrNull{x -> x.id == songId} + val song = playlistCache?.firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } @@ -633,7 +682,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private fun playAlbumSong(id: String, name: String, songId: String) { serviceScope.launch { val songs = listSongsInMusicService(id, name) - val song = songs?.getAllChild()?.firstOrNull{x -> x.id == songId} + val song = songs?.getAllChild()?.firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } @@ -669,14 +718,16 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { mediaItems.addPlayAllItem(listOf(MEDIA_PODCAST_ITEM, id).joinToString("|")) episodes.getAllChild().map { episode -> - mediaItems.add(MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - episode, - listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) - .joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - )) + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + episode, + listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) + .joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) } result.sendResult(mediaItems) } @@ -713,13 +764,15 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val songs = Util.getSongsFromBookmarks(bookmarks) songs.getAllChild().map { song -> - mediaItems.add(MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - song, - listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - )) + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) } result.sendResult(mediaItems) } @@ -731,7 +784,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val bookmarks = callWithErrorHandling { musicService.getBookmarks() } if (bookmarks != null) { val songs = Util.getSongsFromBookmarks(bookmarks) - val song = songs.getAllChild().firstOrNull{song -> song.id == id} + val song = songs.getAllChild().firstOrNull { song -> song.id == id } if (song != null) playSong(song) } } @@ -766,20 +819,22 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { serviceScope.launch { val shares = callWithErrorHandling { musicService.getShares(false) } - val selectedShare = shares?.firstOrNull{share -> share.id == id } + val selectedShare = shares?.firstOrNull { share -> share.id == id } if (selectedShare != null) { if (selectedShare.getEntries().count() > 1) mediaItems.addPlayAllItem(listOf(MEDIA_SHARE_ITEM, id).joinToString("|")) selectedShare.getEntries().map { song -> - mediaItems.add(MediaBrowserCompat.MediaItem( - Util.getMediaDescriptionForEntry( - song, - listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|") - ), - MediaBrowserCompat.MediaItem.FLAG_PLAYABLE - )) + mediaItems.add( + MediaBrowserCompat.MediaItem( + Util.getMediaDescriptionForEntry( + song, + listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|") + ), + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + ) } } result.sendResult(mediaItems) @@ -789,7 +844,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private fun playShare(id: String) { serviceScope.launch { val shares = callWithErrorHandling { musicService.getShares(false) } - val selectedShare = shares?.firstOrNull{share -> share.id == id } + val selectedShare = shares?.firstOrNull { share -> share.id == id } if (selectedShare != null) { playSongs(selectedShare.getEntries()) } @@ -799,9 +854,9 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { private fun playShareSong(id: String, songId: String) { serviceScope.launch { val shares = callWithErrorHandling { musicService.getShares(false) } - val selectedShare = shares?.firstOrNull{share -> share.id == id } + val selectedShare = shares?.firstOrNull { share -> share.id == id } if (selectedShare != null) { - val song = selectedShare.getEntries().firstOrNull{x -> x.id == songId} + val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } @@ -819,7 +874,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { mediaItems.addPlayAllItem(listOf(MEDIA_SONG_STARRED_ID).joinToString("|")) // TODO: Paging is not implemented for songs, is it necessary at all? - val items = songs.songs.take(displayLimit) + val items = songs.songs.take(DISPLAY_LIMIT) starredSongsCache = items items.map { song -> mediaItems.add( @@ -855,7 +910,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { val content = listStarredSongsInMusicService() starredSongsCache = content?.songs } - val song = starredSongsCache?.firstOrNull{x -> x.id == songId} + val song = starredSongsCache?.firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } @@ -865,7 +920,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { result.detach() serviceScope.launch { - val songs = callWithErrorHandling { musicService.getRandomSongs(displayLimit) } + val songs = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } if (songs != null) { if (songs.getAllChild().count() > 1) @@ -895,7 +950,7 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { if (randomSongsCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them // In this case we request a new set of random songs - val content = callWithErrorHandling { musicService.getRandomSongs(displayLimit) } + val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } randomSongsCache = content?.getAllChild() } if (randomSongsCache != null) playSongs(randomSongsCache) @@ -942,10 +997,14 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) if (groupNameId != null) - builder.setExtras(Bundle().apply { putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - getString(groupNameId) - ) }) + builder.setExtras( + Bundle().apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + getString(groupNameId) + ) + } + ) val mediaItem = MediaBrowserCompat.MediaItem( builder.build(), @@ -970,10 +1029,14 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { builder.setIconUri(Util.getUriToDrawable(applicationContext, icon)) if (groupNameId != null) - builder.setExtras(Bundle().apply { putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - getString(groupNameId) - ) }) + builder.setExtras( + Bundle().apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + getString(groupNameId) + ) + } + ) val mediaItem = MediaBrowserCompat.MediaItem( builder.build(), @@ -1034,4 +1097,4 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { null } } -} \ No newline at end of file +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt index b88a47a0..1cb08b32 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadQueueSerializer.kt @@ -1,6 +1,16 @@ +/* + * DownloadQueueSerializer.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + package org.moire.ultrasonic.service import android.content.Context +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -11,9 +21,6 @@ import org.moire.ultrasonic.util.Constants import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.MediaSessionHandler import timber.log.Timber -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.locks.Lock -import java.util.concurrent.locks.ReentrantLock /** * This class is responsible for the serialization / deserialization @@ -102,4 +109,4 @@ class DownloadQueueSerializer : KoinComponent { mediaSessionHandler.updateMediaSessionQueue(state.songs) afterDeserialized.accept(state) } -} \ No newline at end of file +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt index 9b5efa05..b63a0eb9 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/LocalMediaPlayer.kt @@ -21,13 +21,13 @@ import android.os.PowerManager import android.os.PowerManager.PARTIAL_WAKE_LOCK import android.os.PowerManager.WakeLock import androidx.lifecycle.MutableLiveData -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject import java.io.File import java.net.URLEncoder import java.util.Locale import kotlin.math.abs import kotlin.math.max +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.moire.ultrasonic.audiofx.EqualizerController import org.moire.ultrasonic.audiofx.VisualizerController import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline @@ -42,7 +42,8 @@ import timber.log.Timber /** * Represents a Media Player which uses the mobile's resources for playback */ -class LocalMediaPlayer: KoinComponent { +@Suppress("TooManyFunctions") +class LocalMediaPlayer : KoinComponent { private val audioFocusHandler by inject() private val context by inject() diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt index 2917d6f4..3b9a1800 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerLifecycleSupport.kt @@ -1,21 +1,10 @@ /* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus + * MediaPlayerLifecycleSupport.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. */ + package org.moire.ultrasonic.service import android.content.BroadcastReceiver @@ -25,7 +14,6 @@ import android.content.IntentFilter import android.media.AudioManager import android.os.Build import android.view.KeyEvent -import kotlinx.coroutines.newFixedThreadPoolContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R @@ -85,7 +73,8 @@ class MediaPlayerLifecycleSupport : KoinComponent { false ) - // Work-around: Serialize again, as the restore() method creates a serialization without current playing info. + // Work-around: Serialize again, as the restore() method creates a + // serialization without current playing info. downloadQueueSerializer.serializeDownloadQueue( downloader.downloadList, downloader.currentPlayingIndex, @@ -179,14 +168,15 @@ class MediaPlayerLifecycleSupport : KoinComponent { val headsetIntentFilter: IntentFilter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - IntentFilter(AudioManager.ACTION_HEADSET_PLUG) - } else { - IntentFilter(Intent.ACTION_HEADSET_PLUG) - } + IntentFilter(AudioManager.ACTION_HEADSET_PLUG) + } else { + IntentFilter(Intent.ACTION_HEADSET_PLUG) + } applicationContext().registerReceiver(headsetEventReceiver, headsetIntentFilter) } + @Suppress("MagicNumber", "ComplexMethod") private fun handleKeyEvent(event: KeyEvent) { if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount > 0) return @@ -195,9 +185,10 @@ class MediaPlayerLifecycleSupport : KoinComponent { val receivedKeyCode = event.keyCode // Translate PLAY and PAUSE codes to PLAY_PAUSE to improve compatibility with old Bluetooth devices - keyCode = if (Util.getSingleButtonPlayPause() && - (receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY || - receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE) + keyCode = if (Util.getSingleButtonPlayPause() && ( + receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PLAY || + receivedKeyCode == KeyEvent.KEYCODE_MEDIA_PAUSE + ) ) { Timber.i("Single button Play/Pause is set, rewriting keyCode to PLAY_PAUSE") KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE @@ -221,10 +212,10 @@ class MediaPlayerLifecycleSupport : KoinComponent { KeyEvent.KEYCODE_MEDIA_PLAY -> if (mediaPlayerController.playerState === PlayerState.IDLE) { - mediaPlayerController.play() - } else if (mediaPlayerController.playerState !== PlayerState.STARTED) { - mediaPlayerController.start() - } + mediaPlayerController.play() + } else if (mediaPlayerController.playerState !== PlayerState.STARTED) { + mediaPlayerController.start() + } KeyEvent.KEYCODE_MEDIA_PAUSE -> mediaPlayerController.pause() KeyEvent.KEYCODE_1 -> mediaPlayerController.setSongRating(1) @@ -242,13 +233,18 @@ class MediaPlayerLifecycleSupport : KoinComponent { /** * This function processes the intent that could come from other applications. */ + @Suppress("ComplexMethod") private fun handleUltrasonicIntent(intentAction: String) { val isRunning = created // If Ultrasonic is not running, do nothing to stop or pause - if (!isRunning && (intentAction == Constants.CMD_PAUSE || - intentAction == Constants.CMD_STOP)) return + if ( + !isRunning && ( + intentAction == Constants.CMD_PAUSE || + intentAction == Constants.CMD_STOP + ) + ) return val autoStart = intentAction == Constants.CMD_PLAY || @@ -261,7 +257,9 @@ class MediaPlayerLifecycleSupport : KoinComponent { onCreate(autoStart) { when (intentAction) { Constants.CMD_PLAY -> mediaPlayerController.play() - Constants.CMD_RESUME_OR_PLAY -> // If Ultrasonic wasn't running, the autoStart is enough to resume, no need to call anything + Constants.CMD_RESUME_OR_PLAY -> + // If Ultrasonic wasn't running, the autoStart is enough to resume, + // no need to call anything if (isRunning) mediaPlayerController.resumeOrPlay() Constants.CMD_NEXT -> mediaPlayerController.next() @@ -277,4 +275,4 @@ class MediaPlayerLifecycleSupport : KoinComponent { } } } -} \ No newline at end of file +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt index da3a03e7..4e6c14d7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaPlayerService.kt @@ -7,7 +7,11 @@ package org.moire.ultrasonic.service -import android.app.* +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service import android.content.Context import android.content.Intent import android.os.Build @@ -93,7 +97,7 @@ class MediaPlayerService : Service() { localMediaPlayer.onNextSongRequested = Runnable { setNextPlaying() } - mediaSessionEventListener = object:MediaSessionEventListener { + mediaSessionEventListener = object : MediaSessionEventListener { override fun onMediaSessionTokenCreated(token: MediaSessionCompat.Token) { mediaSessionToken = token } @@ -383,7 +387,11 @@ class MediaPlayerService : Service() { val context = this@MediaPlayerService // Notify MediaSession - mediaSessionHandler.updateMediaSession(currentPlaying, downloader.currentPlayingIndex.toLong(), playerState) + mediaSessionHandler.updateMediaSession( + currentPlaying, + downloader.currentPlayingIndex.toLong(), + playerState + ) if (playerState === PlayerState.PAUSED) { downloadQueueSerializer.serializeDownloadQueue( @@ -535,7 +543,11 @@ class MediaPlayerService : Service() { // Init val context = applicationContext val song = currentPlaying?.song - val stopIntent = Util.getPendingIntentForMediaAction(context, KeyEvent.KEYCODE_MEDIA_STOP, 100) + val stopIntent = Util.getPendingIntentForMediaAction( + context, + KeyEvent.KEYCODE_MEDIA_STOP, + 100 + ) // We should use a single notification builder, otherwise the notification may not be updated if (notificationBuilder == null) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt index 7313ef85..a9ecade8 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventDistributor.kt @@ -1,3 +1,10 @@ +/* + * MediaSessionEventDistributor.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + package org.moire.ultrasonic.util import android.os.Bundle @@ -41,7 +48,10 @@ class MediaSessionEventDistributor { } fun raisePlayFromMediaIdRequestedEvent(mediaId: String?, extras: Bundle?) { - eventListenerList.forEach { listener -> listener.onPlayFromMediaIdRequested(mediaId, extras) } + eventListenerList.forEach { + listener -> + listener.onPlayFromMediaIdRequested(mediaId, extras) + } } fun raisePlayFromSearchRequestedEvent(query: String?, extras: Bundle?) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt index f67eb16e..e4075248 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionEventListener.kt @@ -1,3 +1,10 @@ +/* + * MediaSessionEventListener.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + package org.moire.ultrasonic.util import android.os.Bundle diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt index 0820af47..c1191b71 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/MediaSessionHandler.kt @@ -1,3 +1,10 @@ +/* + * MediaSessionHandler.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + package org.moire.ultrasonic.util import android.app.PendingIntent @@ -21,7 +28,7 @@ import org.moire.ultrasonic.service.DownloadFile import timber.log.Timber private const val INTENT_CODE_MEDIA_BUTTON = 161 - +private const val CALL_DIVIDE = 10 /** * Central place to handle the state of the MediaSession */ @@ -157,7 +164,12 @@ class MediaSessionHandler : KoinComponent { Timber.i("MediaSessionHandler.initialize Media Session created") } - fun updateMediaSession(currentPlaying: DownloadFile?, currentPlayingIndex: Long?, playerState: PlayerState) { + @Suppress("TooGenericExceptionCaught", "LongMethod") + fun updateMediaSession( + currentPlaying: DownloadFile?, + currentPlayingIndex: Long?, + playerState: PlayerState + ) { Timber.d("Updating the MediaSession") // Set Metadata @@ -240,18 +252,20 @@ class MediaSessionHandler : KoinComponent { mediaSession!!.setPlaybackState(playbackStateBuilder.build()) } - fun updateMediaSessionQueue(playlist: Iterable) - { + fun updateMediaSessionQueue(playlist: Iterable) { // This call is cached because Downloader may initialize earlier than the MediaSession cachedPlaylist = playlist if (mediaSession == null) return mediaSession!!.setQueueTitle(applicationContext.getString(R.string.button_bar_now_playing)) - mediaSession!!.setQueue(playlist.mapIndexed { id, song -> - MediaSessionCompat.QueueItem( - Util.getMediaDescriptionForEntry(song), - id.toLong()) - }) + mediaSession!!.setQueue( + playlist.mapIndexed { id, song -> + MediaSessionCompat.QueueItem( + Util.getMediaDescriptionForEntry(song), + id.toLong() + ) + } + ) } fun updateMediaSessionPlaybackPosition(playbackPosition: Long) { @@ -264,7 +278,7 @@ class MediaSessionHandler : KoinComponent { // Playback position is updated too frequently in the player. // This counter makes sure that the MediaSession is updated ~ at every second playbackPositionDelayCount++ - if (playbackPositionDelayCount < 10) return + if (playbackPositionDelayCount < CALL_DIVIDE) return playbackPositionDelayCount = 0 val playbackStateBuilder = PlaybackStateCompat.Builder() @@ -286,7 +300,10 @@ class MediaSessionHandler : KoinComponent { } private fun registerMediaButtonEventReceiver() { - val component = ComponentName(applicationContext.packageName, MediaButtonIntentReceiver::class.java.name) + val component = ComponentName( + applicationContext.packageName, + MediaButtonIntentReceiver::class.java.name + ) val mediaButtonIntent = Intent(Intent.ACTION_MEDIA_BUTTON) mediaButtonIntent.component = component @@ -303,4 +320,4 @@ class MediaSessionHandler : KoinComponent { private fun unregisterMediaButtonEventReceiver() { mediaSession?.setMediaButtonReceiver(null) } -} \ No newline at end of file +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index e0b35e66..b0978bb1 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -1,21 +1,10 @@ /* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus + * Util.kt + * Copyright (C) 2009-2021 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. */ + package org.moire.ultrasonic.util import android.annotation.SuppressLint @@ -51,16 +40,6 @@ import android.widget.Toast import androidx.annotation.AnyRes import androidx.media.utils.MediaConstants import androidx.preference.PreferenceManager -import org.moire.ultrasonic.R -import org.moire.ultrasonic.app.UApp.Companion.applicationContext -import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline -import org.moire.ultrasonic.domain.Bookmark -import org.moire.ultrasonic.domain.MusicDirectory -import org.moire.ultrasonic.domain.PlayerState -import org.moire.ultrasonic.domain.RepeatMode -import org.moire.ultrasonic.domain.SearchResult -import org.moire.ultrasonic.service.DownloadFile -import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.Closeable import java.io.File @@ -72,17 +51,32 @@ import java.io.OutputStream import java.io.UnsupportedEncodingException import java.security.MessageDigest import java.text.DecimalFormat -import java.util.* +import java.util.Locale import java.util.concurrent.TimeUnit import java.util.regex.Pattern import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +import org.moire.ultrasonic.R +import org.moire.ultrasonic.app.UApp.Companion.applicationContext +import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline +import org.moire.ultrasonic.domain.Bookmark +import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.PlayerState +import org.moire.ultrasonic.domain.RepeatMode +import org.moire.ultrasonic.domain.SearchResult +import org.moire.ultrasonic.service.DownloadFile +import timber.log.Timber + +private const val LINE_LENGTH = 60 +private const val DEGRADE_PRECISION_AFTER = 10 +private const val MINUTES_IN_HOUR = 60 +private const val KBYTE = 1024 /** - * @author Sindre Mehus - * @version $Id$ + * Contains various utility functions */ +@Suppress("TooManyFunctions", "LargeClass") object Util { private val GIGA_BYTE_FORMAT = DecimalFormat("0.00 GB") @@ -171,17 +165,17 @@ object Util { fun applyTheme(context: Context?) { val theme = getTheme() if (Constants.PREFERENCES_KEY_THEME_DARK.equals( - theme, - ignoreCase = true - ) || "fullscreen".equals(theme, ignoreCase = true) + theme, + ignoreCase = true + ) || "fullscreen".equals(theme, ignoreCase = true) ) { context!!.setTheme(R.style.UltrasonicTheme) } else if (Constants.PREFERENCES_KEY_THEME_BLACK.equals(theme, ignoreCase = true)) { context!!.setTheme(R.style.UltrasonicTheme_Black) } else if (Constants.PREFERENCES_KEY_THEME_LIGHT.equals( - theme, - ignoreCase = true - ) || "fullscreenlight".equals(theme, ignoreCase = true) + theme, + ignoreCase = true + ) || "fullscreenlight".equals(theme, ignoreCase = true) ) { context!!.setTheme(R.style.UltrasonicTheme_Light) } @@ -248,8 +242,9 @@ object Util { } @Throws(IOException::class) + @Suppress("MagicNumber") fun copy(input: InputStream, output: OutputStream): Long { - val buffer = ByteArray(1024 * 4) + val buffer = ByteArray(KBYTE * 4) var count: Long = 0 var n: Int while (-1 != input.read(buffer).also { n = it }) { @@ -261,14 +256,16 @@ object Util { @Throws(IOException::class) fun atomicCopy(from: File, to: File) { - val tmp = File(String.format("%s.tmp", to.path)) - val `in` = FileInputStream(from) + val tmp = File(String.format(Locale.ROOT, "%s.tmp", to.path)) + val input = FileInputStream(from) val out = FileOutputStream(tmp) try { - `in`.channel.transferTo(0, from.length(), out.channel) + input.channel.transferTo(0, from.length(), out.channel) out.close() if (!tmp.renameTo(to)) { - throw IOException(String.format("Failed to rename %s to %s", tmp, to)) + throw IOException( + String.format(Locale.ROOT, "Failed to rename %s to %s", tmp, to) + ) } Timber.i("Copied %s to %s", from, to) } catch (x: IOException) { @@ -276,7 +273,7 @@ object Util { delete(to) throw x } finally { - close(`in`) + close(input) close(out) delete(tmp) } @@ -296,7 +293,7 @@ object Util { fun close(closeable: Closeable?) { try { closeable?.close() - } catch (x: Throwable) { + } catch (_: Throwable) { // Ignored } } @@ -376,18 +373,18 @@ object Util { fun formatBytes(byteCount: Long): String { // More than 1 GB? - if (byteCount >= 1024 * 1024 * 1024) { - return GIGA_BYTE_FORMAT.format(byteCount.toDouble() / (1024 * 1024 * 1024)) + if (byteCount >= KBYTE * KBYTE * KBYTE) { + return GIGA_BYTE_FORMAT.format(byteCount.toDouble() / (KBYTE * KBYTE * KBYTE)) } // More than 1 MB? - if (byteCount >= 1024 * 1024) { - return MEGA_BYTE_FORMAT.format(byteCount.toDouble() / (1024 * 1024)) + if (byteCount >= KBYTE * KBYTE) { + return MEGA_BYTE_FORMAT.format(byteCount.toDouble() / (KBYTE * KBYTE)) } // More than 1 KB? - return if (byteCount >= 1024) { - KILO_BYTE_FORMAT.format(byteCount.toDouble() / 1024) + return if (byteCount >= KBYTE) { + KILO_BYTE_FORMAT.format(byteCount.toDouble() / KBYTE) } else "$byteCount B" } @@ -406,35 +403,36 @@ object Util { * @return The formatted string. */ @Synchronized + @Suppress("ReturnCount") fun formatLocalizedBytes(byteCount: Long, context: Context): String { // More than 1 GB? - if (byteCount >= 1024 * 1024 * 1024) { + if (byteCount >= KBYTE * KBYTE * KBYTE) { if (GIGA_BYTE_LOCALIZED_FORMAT == null) { GIGA_BYTE_LOCALIZED_FORMAT = DecimalFormat(context.resources.getString(R.string.util_bytes_format_gigabyte)) } return GIGA_BYTE_LOCALIZED_FORMAT!! - .format(byteCount.toDouble() / (1024 * 1024 * 1024)) + .format(byteCount.toDouble() / (KBYTE * KBYTE * KBYTE)) } // More than 1 MB? - if (byteCount >= 1024 * 1024) { + if (byteCount >= KBYTE * KBYTE) { if (MEGA_BYTE_LOCALIZED_FORMAT == null) { MEGA_BYTE_LOCALIZED_FORMAT = DecimalFormat(context.resources.getString(R.string.util_bytes_format_megabyte)) } return MEGA_BYTE_LOCALIZED_FORMAT!! - .format(byteCount.toDouble() / (1024 * 1024)) + .format(byteCount.toDouble() / (KBYTE * KBYTE)) } // More than 1 KB? - if (byteCount >= 1024) { + if (byteCount >= KBYTE) { if (KILO_BYTE_LOCALIZED_FORMAT == null) { KILO_BYTE_LOCALIZED_FORMAT = DecimalFormat(context.resources.getString(R.string.util_bytes_format_kilobyte)) } - return KILO_BYTE_LOCALIZED_FORMAT!!.format(byteCount.toDouble() / 1024) + return KILO_BYTE_LOCALIZED_FORMAT!!.format(byteCount.toDouble() / KBYTE) } if (BYTE_LOCALIZED_FORMAT == null) { BYTE_LOCALIZED_FORMAT = @@ -453,6 +451,7 @@ object Util { * @param s The string to encode. * @return The encoded string. */ + @Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught") fun utf8HexEncode(s: String?): String? { if (s == null) { return null @@ -473,6 +472,7 @@ object Util { * @param data Bytes to convert to hexadecimal characters. * @return A string containing hexadecimal characters. */ + @Suppress("MagicNumber") fun hexEncode(data: ByteArray): String { val length = data.size val out = CharArray(length shl 1) @@ -493,6 +493,7 @@ object Util { * @return MD5 digest as a hex string. */ @JvmStatic + @Suppress("TooGenericExceptionThrown", "TooGenericExceptionCaught") fun md5Hex(s: String?): String? { return if (s == null) { null @@ -567,7 +568,11 @@ object Util { .setIcon(icon) .setTitle(titleId) .setMessage(message) - .setPositiveButton(R.string.common_ok) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + .setPositiveButton(R.string.common_ok) { + dialog: DialogInterface, + _: Int -> + dialog.dismiss() + } .show() } @@ -746,6 +751,7 @@ object Util { context.sendBroadcast(avrcpIntent) } + @Suppress("LongParameterList") fun broadcastA2dpPlayStatusChange( context: Context, state: PlayerState?, @@ -763,7 +769,7 @@ object Util { return } - // FIXME: This is probably a bug. + // FIXME This is probably a bug. if (currentSong !== currentSong) { Util.currentSong = currentSong } @@ -797,11 +803,12 @@ object Util { when (state) { PlayerState.STARTED -> avrcpIntent.putExtra("playing", true) - PlayerState.STOPPED, PlayerState.PAUSED, PlayerState.COMPLETED -> avrcpIntent.putExtra( + PlayerState.STOPPED, PlayerState.PAUSED, + PlayerState.COMPLETED -> avrcpIntent.putExtra( "playing", false ) - else -> return // No need to broadcast. + else -> return // No need to broadcast. } context.sendBroadcast(avrcpIntent) @@ -819,12 +826,13 @@ object Util { PlayerState.STOPPED -> intent.putExtra("state", "stop") PlayerState.PAUSED -> intent.putExtra("state", "pause") PlayerState.COMPLETED -> intent.putExtra("state", "complete") - else -> return // No need to broadcast. + else -> return // No need to broadcast. } context.sendBroadcast(intent) } @JvmStatic + @Suppress("MagicNumber") fun getNotificationImageSize(context: Context): Int { val metrics = context.resources.displayMetrics val imageSizeLarge = @@ -838,6 +846,7 @@ object Util { } } + @Suppress("MagicNumber") fun getAlbumImageSize(context: Context?): Int { val metrics = context!!.resources.displayMetrics val imageSizeLarge = @@ -1027,11 +1036,11 @@ object Util { } val hours = TimeUnit.MILLISECONDS.toHours(millis) val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - TimeUnit.HOURS.toMinutes(hours) - val seconds = - TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(hours * 60 + minutes) + val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - + TimeUnit.MINUTES.toSeconds(hours * MINUTES_IN_HOUR + minutes) return when { - hours >= 10 -> { + hours >= DEGRADE_PRECISION_AFTER -> { String.format( Locale.getDefault(), "%02d:%02d:%02d", @@ -1043,7 +1052,7 @@ object Util { hours > 0 -> { String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds) } - minutes >= 10 -> { + minutes >= DEGRADE_PRECISION_AFTER -> { String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) } minutes > 0 -> String.format( @@ -1254,12 +1263,13 @@ object Util { fun getUriToDrawable(context: Context, @AnyRes drawableId: Int): Uri { return Uri.parse( ContentResolver.SCHEME_ANDROID_RESOURCE + - "://" + context.resources.getResourcePackageName(drawableId) - + '/' + context.resources.getResourceTypeName(drawableId) - + '/' + context.resources.getResourceEntryName(drawableId) + "://" + context.resources.getResourcePackageName(drawableId) + + '/' + context.resources.getResourceTypeName(drawableId) + + '/' + context.resources.getResourceEntryName(drawableId) ) } + @Suppress("ComplexMethod") fun getMediaDescriptionForEntry( song: MusicDirectory.Entry, mediaId: String? = null, @@ -1267,12 +1277,14 @@ object Util { ): MediaDescriptionCompat { val descriptionBuilder = MediaDescriptionCompat.Builder() - val artist = StringBuilder(60) + val artist = StringBuilder(LINE_LENGTH) var bitRate: String? = null val duration = song.duration if (duration != null) { - artist.append(String.format("%s ", formatTotalDuration(duration.toLong()))) + artist.append( + String.format(Locale.ROOT, "%s ", formatTotalDuration(duration.toLong())) + ) } if (song.bitRate != null && song.bitRate!! > 0) @@ -1286,16 +1298,21 @@ object Util { fileFormat = if ( TextUtils.isEmpty(transcodedSuffix) || transcodedSuffix == suffix || song.isVideo - ) suffix else String.format("%s > %s", suffix, transcodedSuffix) + ) suffix else String.format(Locale.ROOT, "%s > %s", suffix, transcodedSuffix) val artistName = song.artist if (artistName != null) { - if (shouldDisplayBitrateWithArtist() && (!bitRate.isNullOrBlank() || !fileFormat.isNullOrBlank())) { + if (shouldDisplayBitrateWithArtist() && ( + !bitRate.isNullOrBlank() || !fileFormat.isNullOrBlank() + ) + ) { artist.append(artistName).append(" (").append( String.format( appContext().getString(R.string.song_details_all), - if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat + if (bitRate == null) "" + else String.format(Locale.ROOT, "%s ", bitRate), + fileFormat ) ).append(')') } else { @@ -1305,9 +1322,9 @@ object Util { val trackNumber = song.track ?: 0 - val title = StringBuilder(60) + val title = StringBuilder(LINE_LENGTH) if (shouldShowTrackNumber() && trackNumber > 0) - title.append(String.format("%02d - ", trackNumber)) + title.append(String.format(Locale.ROOT, "%02d - ", trackNumber)) title.append(song.title) @@ -1315,16 +1332,22 @@ object Util { title.append(" (").append( String.format( appContext().getString(R.string.song_details_all), - if (bitRate == null) "" else String.format("%s ", bitRate), fileFormat + if (bitRate == null) "" + else String.format(Locale.ROOT, "%s ", bitRate), + fileFormat ) ).append(')') } if (groupNameId != null) - descriptionBuilder.setExtras(Bundle().apply { putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - appContext().getString(groupNameId) - ) }) + descriptionBuilder.setExtras( + Bundle().apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + appContext().getString(groupNameId) + ) + } + ) descriptionBuilder.setTitle(title) descriptionBuilder.setSubtitle(artist) @@ -1344,4 +1367,4 @@ object Util { intent.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keycode)) return PendingIntent.getBroadcast(context, requestCode, intent, flags) } -} \ No newline at end of file +} diff --git a/ultrasonic/src/main/res/drawable/ic_artist.xml b/ultrasonic/src/main/res/drawable/ic_artist.xml index 24b174c7..c3daf609 100644 --- a/ultrasonic/src/main/res/drawable/ic_artist.xml +++ b/ultrasonic/src/main/res/drawable/ic_artist.xml @@ -2,9 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/ultrasonic/src/main/res/drawable/ic_library.xml b/ultrasonic/src/main/res/drawable/ic_library.xml index ef18b12d..6981f924 100644 --- a/ultrasonic/src/main/res/drawable/ic_library.xml +++ b/ultrasonic/src/main/res/drawable/ic_library.xml @@ -2,10 +2,8 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal" - android:autoMirrored="true"> + android:viewportHeight="24"> From c9f2050c46bbf62692fbbdb72f7d743538d98d4e Mon Sep 17 00:00:00 2001 From: Nite Date: Sun, 18 Jul 2021 13:23:20 +0200 Subject: [PATCH 11/14] Suppress detekt LongMethod --- ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index b0978bb1..ffcca87e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -1269,7 +1269,7 @@ object Util { ) } - @Suppress("ComplexMethod") + @Suppress("ComplexMethod", "LongMethod") fun getMediaDescriptionForEntry( song: MusicDirectory.Entry, mediaId: String? = null, From 961c726da8df84ac41d4d4ea530b6d0d784ce0a2 Mon Sep 17 00:00:00 2001 From: James Wells <14866211+SaintDubious@users.noreply.github.com> Date: Sun, 18 Jul 2021 16:14:43 -0400 Subject: [PATCH 12/14] restore previous code --- .../moire/ultrasonic/fragment/AlbumListModel.kt | 2 +- .../moire/ultrasonic/fragment/ArtistListModel.kt | 4 ++-- .../ultrasonic/fragment/GenericListModel.kt | 16 +++++----------- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt index cec2197f..31ae714e 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/AlbumListModel.kt @@ -19,7 +19,7 @@ class AlbumListModel(application: Application) : GenericListModel(application) { fun getAlbumList( refresh: Boolean, - swipe: SwipeRefreshLayout?, + swipe: SwipeRefreshLayout, args: Bundle ): LiveData> { // Don't reload the data if navigating back to the view that was active before. diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt index 50b97725..c014a059 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/ArtistListModel.kt @@ -30,12 +30,12 @@ import org.moire.ultrasonic.service.MusicService * Provides ViewModel which contains the list of available Artists */ class ArtistListModel(application: Application) : GenericListModel(application) { - val artists: MutableLiveData> = MutableLiveData(listOf()) + private val artists: MutableLiveData> = MutableLiveData(listOf()) /** * Retrieves all available Artists in a LiveData */ - fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout?): LiveData> { + fun getItems(refresh: Boolean, swipe: SwipeRefreshLayout): LiveData> { // Don't reload the data if navigating back to the view that was active before. // This way, we keep the scroll position if (artists.value!!.isEmpty() || refresh) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt index d4c62659..958db756 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/GenericListModel.kt @@ -66,24 +66,20 @@ open class GenericListModel(application: Application) : */ fun backgroundLoadFromServer( refresh: Boolean, - swipe: SwipeRefreshLayout?, + swipe: SwipeRefreshLayout, bundle: Bundle = Bundle() ) { viewModelScope.launch { - if (swipe != null) { - swipe.isRefreshing = true - } + swipe.isRefreshing = true loadFromServer(refresh, swipe, bundle) - if (swipe != null) { - swipe.isRefreshing = false - } + swipe.isRefreshing = false } } /** * Calls the load() function with error handling */ - suspend fun loadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout?, bundle: Bundle) = + suspend fun loadFromServer(refresh: Boolean, swipe: SwipeRefreshLayout, bundle: Bundle) = withContext(Dispatchers.IO) { val musicService = MusicServiceFactory.getMusicService() val isOffline = ActiveServerProvider.isOffline() @@ -92,9 +88,7 @@ open class GenericListModel(application: Application) : try { load(isOffline, useId3Tags, musicService, refresh, bundle) } catch (all: Exception) { - if (swipe != null) { - handleException(all, swipe.context) - } + handleException(all, swipe.context) } } From 9546bdeab54c0e4e9fa15c62a6824f3809f66f11 Mon Sep 17 00:00:00 2001 From: Nite Date: Mon, 19 Jul 2021 15:31:52 +0200 Subject: [PATCH 13/14] Fixed playing single track in empty playlist --- .../org/moire/ultrasonic/service/AutoMediaBrowserService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt index e834cd42..5bdfe45b 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/AutoMediaBrowserService.kt @@ -1085,7 +1085,8 @@ class AutoMediaBrowserService : MediaBrowserServiceCompat() { shuffle = false, newPlaylist = false ) - mediaPlayerController.next() + if (mediaPlayerController.playlistSize > 1) mediaPlayerController.next() + else mediaPlayerController.play() } private fun callWithErrorHandling(function: () -> T): T? { From a051d4d040c236a7aab5d64e7ebef544afeee02d Mon Sep 17 00:00:00 2001 From: tzugen Date: Fri, 20 Aug 2021 20:53:17 +0200 Subject: [PATCH 14/14] Clean up Util.kt after conversion (currentSong was assigned but never actually used) --- .../org/moire/ultrasonic/util/Constants.java | 2 -- .../kotlin/org/moire/ultrasonic/util/Util.kt | 33 ++++--------------- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java index 880564d8..28404977 100644 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java +++ b/ultrasonic/src/main/java/org/moire/ultrasonic/util/Constants.java @@ -125,8 +125,6 @@ public final class Constants public static final String PREFERENCES_KEY_DEFAULT_SHARE_GREETING = "sharingDefaultGreeting"; public static final String PREFERENCES_KEY_DEFAULT_SHARE_EXPIRATION = "sharingDefaultExpiration"; public static final String PREFERENCES_KEY_SHOW_ALL_SONGS_BY_ARTIST = "showAllSongsByArtist"; - public static final String PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY = "imageLoaderConcurrency"; - public static final String PREFERENCES_KEY_FF_IMAGE_LOADER = "ff_new_image_loader"; public static final String PREFERENCES_KEY_USE_FIVE_STAR_RATING = "use_five_star_rating"; public static final String PREFERENCES_KEY_CATEGORY_NOTIFICATIONS = "notificationsCategory"; public static final String PREFERENCES_KEY_FIRST_RUN_EXECUTED = "firstRunExecuted"; diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt index ffcca87e..d59323b3 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Util.kt @@ -96,7 +96,6 @@ object Util { private val HEX_DIGITS = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') private var toast: Toast? = null - private var currentSong: MusicDirectory.Entry? = null // Retrieves an instance of the application Context fun appContext(): Context { @@ -717,9 +716,6 @@ object Util { avrcpIntent.putExtra("duration", 0.toLong()) avrcpIntent.putExtra("position", 0.toLong()) } else { - if (song !== currentSong) { - currentSong = song - } val title = song.title val artist = song.artist val album = song.album @@ -755,7 +751,7 @@ object Util { fun broadcastA2dpPlayStatusChange( context: Context, state: PlayerState?, - currentSong: MusicDirectory.Entry?, + newSong: MusicDirectory.Entry?, listSize: Int, id: Int, playerPosition: Int @@ -763,20 +759,13 @@ object Util { if (!shouldSendBluetoothNotifications) { return } - if (currentSong != null) { + if (newSong != null) { val avrcpIntent = Intent(CM_AVRCP_PLAYSTATE_CHANGED) - if (currentSong == null) { - return - } - // FIXME This is probably a bug. - if (currentSong !== currentSong) { - Util.currentSong = currentSong - } - val title = currentSong.title - val artist = currentSong.artist - val album = currentSong.album - val duration = currentSong.duration + val title = newSong.title + val artist = newSong.artist + val album = newSong.album + val duration = newSong.duration avrcpIntent.putExtra("track", title) avrcpIntent.putExtra("track_name", title) @@ -788,7 +777,7 @@ object Util { avrcpIntent.putExtra("album_artist_name", artist) if (getShouldSendBluetoothAlbumArt()) { - val albumArtFile = FileUtil.getAlbumArtFile(currentSong) + val albumArtFile = FileUtil.getAlbumArtFile(newSong) avrcpIntent.putExtra("coverart", albumArtFile.absolutePath) avrcpIntent.putExtra("cover", albumArtFile.absolutePath) } @@ -1199,14 +1188,6 @@ object Util { appContext().sendBroadcast(scanFileIntent) } - fun imageLoaderConcurrency(): Int { - val preferences = getPreferences() - return preferences.getString( - Constants.PREFERENCES_KEY_IMAGE_LOADER_CONCURRENCY, - "5" - )!!.toInt() - } - fun getResourceFromAttribute(context: Context, resId: Int): Int { val typedValue = TypedValue() val theme = context.theme