From c1e5adbc4406e8988d21179ddeb5c11a79576448 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 23 Aug 2024 13:55:49 -0600 Subject: [PATCH 01/66] media: unwind tightly bound action handling --- .../service/MediaSessionServiceFragment.kt | 12 ++ .../playback/service/PlaybackActionHandler.kt | 117 +----------------- .../service/SystemPlaybackReceiver.kt | 112 +++++++++++++++++ .../oxycblt/auxio/widgets/WidgetComponent.kt | 4 +- 4 files changed, 127 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index ad5102477f..a4be02ad21 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback.service import android.app.Notification import android.content.Context import android.os.Bundle +import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem import androidx.media3.session.CommandButton import androidx.media3.session.DefaultActionFactory @@ -49,10 +50,12 @@ import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.service.MediaItemBrowser +import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent +import org.oxycblt.auxio.widgets.WidgetComponent class MediaSessionServiceFragment @Inject @@ -60,6 +63,8 @@ constructor( @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, private val actionHandler: PlaybackActionHandler, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent, private val mediaItemBrowser: MediaItemBrowser, exoHolderFactory: ExoPlaybackStateHolder.Factory ) : @@ -86,6 +91,7 @@ constructor( .also { it.setSmallIcon(R.drawable.ic_auxio_24) } private var foregroundListener: ForegroundListener? = null + lateinit var systemReceiver: SystemPlaybackReceiver lateinit var mediaSession: MediaLibrarySession private set @@ -99,6 +105,10 @@ constructor( playbackManager.addListener(this) exoHolder.attach() actionHandler.attach(this) + systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) + ContextCompat.registerReceiver( + context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) + widgetComponent.attach() mediaItemBrowser.attach(this) return mediaSession } @@ -142,6 +152,8 @@ constructor( fun release() { waitJob.cancel() mediaItemBrowser.release() + context.unregisterReceiver(systemReceiver) + widgetComponent.release() actionHandler.release() exoHolder.release() playbackManager.removeListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 6d0c1fbeb1..3dd0acf684 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -18,13 +18,8 @@ package org.oxycblt.auxio.playback.service -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.media.AudioManager import android.os.Bundle -import androidx.core.content.ContextCompat import androidx.media3.common.Player import androidx.media3.session.CommandButton import androidx.media3.session.SessionCommand @@ -39,41 +34,31 @@ import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.widgets.WidgetComponent -import org.oxycblt.auxio.widgets.WidgetProvider class PlaybackActionHandler @Inject constructor( @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings, - private val widgetComponent: WidgetComponent + private val playbackSettings: PlaybackSettings ) : PlaybackStateManager.Listener, PlaybackSettings.Listener { interface Callback { fun onCustomLayoutChanged(layout: List) } - private val systemReceiver = - SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) private var callback: Callback? = null fun attach(callback: Callback) { this.callback = callback playbackManager.addListener(this) playbackSettings.registerListener(this) - ContextCompat.registerReceiver( - context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) } fun release() { callback = null playbackManager.removeListener(this) playbackSettings.unregisterListener(this) - context.unregisterReceiver(systemReceiver) - widgetComponent.release() } fun withCommands(commands: SessionCommands) = @@ -178,103 +163,3 @@ object PlaybackActions { const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" } - -/** - * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an - * active [IntentFilter] to be registered. - */ -class SystemPlaybackReceiver( - private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings, - private val widgetComponent: WidgetComponent -) : BroadcastReceiver() { - private var initialHeadsetPlugEventHandled = false - - val intentFilter = - IntentFilter().apply { - addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) - addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) - addAction(PlaybackActions.ACTION_SKIP_PREV) - addAction(PlaybackActions.ACTION_PLAY_PAUSE) - addAction(PlaybackActions.ACTION_SKIP_NEXT) - addAction(WidgetProvider.ACTION_WIDGET_UPDATE) - } - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - // --- SYSTEM EVENTS --- - - // Android has three different ways of handling audio plug events for some reason: - // 1. ACTION_HEADSET_PLUG, which only works with wired headsets - // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires - // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less - // a non-starter since both require me to display a permission prompt - // 3. Some internal framework thing that also handles bluetooth headsets - // Just use ACTION_HEADSET_PLUG. - AudioManager.ACTION_HEADSET_PLUG -> { - logD("Received headset plug event") - when (intent.getIntExtra("state", -1)) { - 0 -> pauseFromHeadsetPlug() - 1 -> playFromHeadsetPlug() - } - - initialHeadsetPlugEventHandled = true - } - AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { - logD("Received Headset noise event") - pauseFromHeadsetPlug() - } - - // --- AUXIO EVENTS --- - PlaybackActions.ACTION_PLAY_PAUSE -> { - logD("Received play event") - playbackManager.playing(!playbackManager.progression.isPlaying) - } - PlaybackActions.ACTION_INC_REPEAT_MODE -> { - logD("Received repeat mode event") - playbackManager.repeatMode(playbackManager.repeatMode.increment()) - } - PlaybackActions.ACTION_INVERT_SHUFFLE -> { - logD("Received shuffle event") - playbackManager.shuffled(!playbackManager.isShuffled) - } - PlaybackActions.ACTION_SKIP_PREV -> { - logD("Received skip previous event") - playbackManager.prev() - } - PlaybackActions.ACTION_SKIP_NEXT -> { - logD("Received skip next event") - playbackManager.next() - } - PlaybackActions.ACTION_EXIT -> { - logD("Received exit event") - playbackManager.endSession() - } - WidgetProvider.ACTION_WIDGET_UPDATE -> { - logD("Received widget update event") - widgetComponent.update() - } - } - } - - private fun playFromHeadsetPlug() { - // ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached, - // which would result in unexpected playback. Work around it by dropping the first - // call to this function, which should come from that Intent. - if (playbackSettings.headsetAutoplay && - playbackManager.currentSong != null && - initialHeadsetPlugEventHandled) { - logD("Device connected, resuming") - playbackManager.playing(true) - } - } - - private fun pauseFromHeadsetPlug() { - if (playbackManager.currentSong != null) { - logD("Device disconnected, pausing") - playbackManager.playing(false) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt new file mode 100644 index 0000000000..31c931fdbe --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -0,0 +1,112 @@ +package org.oxycblt.auxio.playback.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.widgets.WidgetComponent +import org.oxycblt.auxio.widgets.WidgetProvider + +/** + * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an + * active [IntentFilter] to be registered. + */ +class SystemPlaybackReceiver( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent +) : BroadcastReceiver() { + private var initialHeadsetPlugEventHandled = false + + val intentFilter = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + // --- SYSTEM EVENTS --- + + // Android has three different ways of handling audio plug events for some reason: + // 1. ACTION_HEADSET_PLUG, which only works with wired headsets + // 2. ACTION_ACL_CONNECTED, which allows headset autoplay but also requires + // granting the BLUETOOTH/BLUETOOTH_CONNECT permissions, which is more or less + // a non-starter since both require me to display a permission prompt + // 3. Some internal framework thing that also handles bluetooth headsets + // Just use ACTION_HEADSET_PLUG. + AudioManager.ACTION_HEADSET_PLUG -> { + logD("Received headset plug event") + when (intent.getIntExtra("state", -1)) { + 0 -> pauseFromHeadsetPlug() + 1 -> playFromHeadsetPlug() + } + + initialHeadsetPlugEventHandled = true + } + AudioManager.ACTION_AUDIO_BECOMING_NOISY -> { + logD("Received Headset noise event") + pauseFromHeadsetPlug() + } + + // --- AUXIO EVENTS --- + PlaybackActions.ACTION_PLAY_PAUSE -> { + logD("Received play event") + playbackManager.playing(!playbackManager.progression.isPlaying) + } + PlaybackActions.ACTION_INC_REPEAT_MODE -> { + logD("Received repeat mode event") + playbackManager.repeatMode(playbackManager.repeatMode.increment()) + } + PlaybackActions.ACTION_INVERT_SHUFFLE -> { + logD("Received shuffle event") + playbackManager.shuffled(!playbackManager.isShuffled) + } + PlaybackActions.ACTION_SKIP_PREV -> { + logD("Received skip previous event") + playbackManager.prev() + } + PlaybackActions.ACTION_SKIP_NEXT -> { + logD("Received skip next event") + playbackManager.next() + } + PlaybackActions.ACTION_EXIT -> { + logD("Received exit event") + playbackManager.endSession() + } + WidgetProvider.ACTION_WIDGET_UPDATE -> { + logD("Received widget update event") + widgetComponent.update() + } + } + } + + private fun playFromHeadsetPlug() { + // ACTION_HEADSET_PLUG will fire when this BroadcastReceiver is initially attached, + // which would result in unexpected playback. Work around it by dropping the first + // call to this function, which should come from that Intent. + if (playbackSettings.headsetAutoplay && + playbackManager.currentSong != null && + initialHeadsetPlugEventHandled) { + logD("Device connected, resuming") + playbackManager.playing(true) + } + } + + private fun pauseFromHeadsetPlug() { + if (playbackManager.currentSong != null) { + logD("Device disconnected, pausing") + playbackManager.playing(false) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 1e95eb6f4f..bb8caf6931 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -57,7 +57,7 @@ constructor( ) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { private val widgetProvider = WidgetProvider() - init { + fun attach() { playbackManager.addListener(this) uiSettings.registerListener(this) imageSettings.registerListener(this) @@ -90,7 +90,7 @@ constructor( } else if (uiSettings.roundMode) { // < Android 12, but the user still enabled round mode. logD("Using default corner radius") - context.getDimenPixels(R.dimen.size_corners_medium) + context.getDimenPixels(R.dimen.spacing_medium) } else { // User did not enable round mode. logD("Using no corner radius") From e43f55bc78ee699c3cab7f36f7c6497b14a8e345 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 26 Aug 2024 10:45:58 -0600 Subject: [PATCH 02/66] service: drop media3 session entirely --- app/build.gradle | 1 - .../java/org/oxycblt/auxio/AuxioService.kt | 123 ++- .../java/org/oxycblt/auxio/MainActivity.kt | 2 +- .../music/service/IndexerNotifications.kt | 56 +- .../auxio/music/service/MediaItemBrowser.kt | 710 +++++++++--------- ...iceFragment.kt => MusicServiceFragment.kt} | 5 +- .../playback/service/MediaSessionHolder.kt | 609 +++++++++++++++ .../service/MediaSessionServiceFragment.kt | 297 -------- .../playback/service/PlaybackActionHandler.kt | 135 ---- .../service/PlaybackServiceFragment.kt | 212 ++++++ .../service/SystemPlaybackReceiver.kt | 20 +- 11 files changed, 1308 insertions(+), 862 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/service/{IndexerServiceFragment.kt => MusicServiceFragment.kt} (97%) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt diff --git a/app/build.gradle b/app/build.gradle index 98ded55d65..2312bcb385 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -126,7 +126,6 @@ dependencies { // --- THIRD PARTY --- // Exoplayer (Vendored) - implementation project(":media-lib-session") implementation project(":media-lib-exoplayer") implementation project(":media-lib-decoder-ffmpeg") coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4" diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index f0352d523e..9c62fdcd2b 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -19,34 +19,35 @@ package org.oxycblt.auxio import android.annotation.SuppressLint +import android.content.Context import android.content.Intent +import android.os.Bundle import android.os.IBinder +import android.support.v4.media.MediaBrowserCompat +import androidx.annotation.StringRes +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaSession +import androidx.media.MediaBrowserServiceCompat import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import org.oxycblt.auxio.music.service.IndexerServiceFragment -import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment +import org.oxycblt.auxio.music.service.MusicServiceFragment +import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint -class AuxioService : MediaLibraryService(), ForegroundListener { - @Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment +class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { + @Inject lateinit var mediaSessionFragment: PlaybackServiceFragment - @Inject lateinit var indexingFragment: IndexerServiceFragment + @Inject lateinit var indexingFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() - mediaSessionFragment.attach(this, this) + setSessionToken(mediaSessionFragment.attach(this)) indexingFragment.attach(this) } - override fun onBind(intent: Intent?): IBinder? { - onHandleForeground(intent) - return super.onBind(intent) - } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // TODO: Start command occurring from a foreign service basically implies a detached // service, we might need more handling here. @@ -54,6 +55,11 @@ class AuxioService : MediaLibraryService(), ForegroundListener { return super.onStartCommand(intent, flags, startId) } + override fun onBind(intent: Intent): IBinder? { + onHandleForeground(intent) + return super.onBind(intent) + } + private fun onHandleForeground(intent: Intent?) { val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1 indexingFragment.start() @@ -71,20 +77,54 @@ class AuxioService : MediaLibraryService(), ForegroundListener { mediaSessionFragment.release() } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession = - mediaSessionFragment.mediaSession + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + TODO("Not yet implemented") + } + + override fun onLoadChildren( + parentId: String, + result: Result> + ) = throw NotImplementedError() + + override fun onLoadChildren( + parentId: String, + result: Result>, + options: Bundle + ) { + super.onLoadChildren(parentId, result, options) + } + + override fun onLoadItem(itemId: String, result: Result) { + super.onLoadItem(itemId, result) + } - override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { - updateForeground(ForegroundListener.Change.MEDIA_SESSION) + override fun onSearch( + query: String, + extras: Bundle?, + result: Result> + ) { + super.onSearch(query, extras, result) + } + + @SuppressLint("RestrictedApi") + override fun onSubscribe(id: String?, option: Bundle?) { + super.onSubscribe(id, option) + } + + @SuppressLint("RestrictedApi") + override fun onUnsubscribe(id: String?) { + super.onUnsubscribe(id) } override fun updateForeground(change: ForegroundListener.Change) { - if (mediaSessionFragment.hasNotification()) { + val mediaNotification = mediaSessionFragment.notification + if (mediaNotification != null) { if (change == ForegroundListener.Change.MEDIA_SESSION) { - mediaSessionFragment.createNotification { - startForeground(it.notificationId, it.notification) - isForeground = true - } + startForeground(mediaNotification.code, mediaNotification.build()) } // Nothing changed, but don't show anything music related since we can always // index during playback. @@ -118,3 +158,42 @@ interface ForegroundListener { INDEXER } } + +/** + * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that + * signal a Service's ongoing foreground state. + * + * @author Alexander Capehart (OxygenCobalt) + */ +abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) : + NotificationCompat.Builder(context, info.id) { + private val notificationManager = NotificationManagerCompat.from(context) + + init { + // Set up the notification channel. Foreground notifications are non-substantial, and + // thus make no sense to have lights, vibration, or lead to a notification badge. + val channel = + NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(context.getString(info.nameRes)) + .setLightsEnabled(false) + .setVibrationEnabled(false) + .setShowBadge(false) + .build() + notificationManager.createNotificationChannel(channel) + } + + /** + * The code used to identify this notification. + * + * @see NotificationManagerCompat.notify + */ + abstract val code: Int + + /** + * Reduced representation of a [NotificationChannelCompat]. + * + * @param id The ID of the channel. + * @param nameRes A string resource ID corresponding to the human-readable name of this channel. + */ + data class ChannelInfo(val id: String, @StringRes val nameRes: Int) +} diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index ab5474c9c3..530f3f14f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -79,7 +79,7 @@ class MainActivity : AppCompatActivity() { } } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) startIntentAction(intent) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt index d857ab32be..0e895196d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/IndexerNotifications.kt @@ -20,11 +20,9 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.os.SystemClock -import androidx.annotation.StringRes -import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingProgress @@ -32,52 +30,13 @@ import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent /** - * Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that - * signal a Service's ongoing foreground state. - * - * @author Alexander Capehart (OxygenCobalt) - */ -abstract class IndexerNotification(context: Context, info: ChannelInfo) : - NotificationCompat.Builder(context, info.id) { - private val notificationManager = NotificationManagerCompat.from(context) - - init { - // Set up the notification channel. Foreground notifications are non-substantial, and - // thus make no sense to have lights, vibration, or lead to a notification badge. - val channel = - NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(context.getString(info.nameRes)) - .setLightsEnabled(false) - .setVibrationEnabled(false) - .setShowBadge(false) - .build() - notificationManager.createNotificationChannel(channel) - } - - /** - * The code used to identify this notification. - * - * @see NotificationManagerCompat.notify - */ - abstract val code: Int - - /** - * Reduced representation of a [NotificationChannelCompat]. - * - * @param id The ID of the channel. - * @param nameRes A string resource ID corresponding to the human-readable name of this channel. - */ - data class ChannelInfo(val id: String, @StringRes val nameRes: Int) -} - -/** - * A dynamic [IndexerNotification] that shows the current music loading state. + * A dynamic [ForegroundServiceNotification] that shows the current music loading state. * * @param context [Context] required to create the notification. * @author Alexander Capehart (OxygenCobalt) */ class IndexingNotification(private val context: Context) : - IndexerNotification(context, indexerChannel) { + ForegroundServiceNotification(context, indexerChannel) { private var lastUpdateTime = -1L init { @@ -133,12 +92,13 @@ class IndexingNotification(private val context: Context) : } /** - * A static [IndexerNotification] that signals to the user that the app is currently monitoring the - * music library for changes. + * A static [ForegroundServiceNotification] that signals to the user that the app is currently + * monitoring the music library for changes. * * @author Alexander Capehart (OxygenCobalt) */ -class ObservingNotification(context: Context) : IndexerNotification(context, indexerChannel) { +class ObservingNotification(context: Context) : + ForegroundServiceNotification(context, indexerChannel) { init { setSmallIcon(R.drawable.ic_indexer_24) setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -156,5 +116,5 @@ class ObservingNotification(context: Context) : IndexerNotification(context, ind /** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */ private val indexerChannel = - IndexerNotification.ChannelInfo( + ForegroundServiceNotification.ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 93841a63f9..5d578119aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -17,358 +17,358 @@ */ package org.oxycblt.auxio.music.service - -import android.content.Context -import android.os.Bundle -import androidx.annotation.StringRes -import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaItem -import androidx.media3.session.MediaSession.ControllerInfo -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import kotlin.math.min -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.ListSettings -import org.oxycblt.auxio.list.sort.Sort -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.user.UserLibrary -import org.oxycblt.auxio.search.SearchEngine - -class MediaItemBrowser -@Inject -constructor( - @ApplicationContext private val context: Context, - private val musicRepository: MusicRepository, - private val listSettings: ListSettings, - private val searchEngine: SearchEngine -) : MusicRepository.UpdateListener { - private val browserJob = Job() - private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) - private val searchSubscribers = mutableMapOf() - private val searchResults = mutableMapOf>() - private var invalidator: Invalidator? = null - - interface Invalidator { - fun invalidate(ids: Map) - - fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) - } - - fun attach(invalidator: Invalidator) { - this.invalidator = invalidator - musicRepository.addUpdateListener(this) - } - - fun release() { - browserJob.cancel() - invalidator = null - musicRepository.removeUpdateListener(this) - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary - var invalidateSearch = false - val invalidate = mutableMapOf() - if (changes.deviceLibrary && deviceLibrary != null) { - MediaSessionUID.Category.DEVICE_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) - } - - deviceLibrary.albums.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size - } - - deviceLibrary.artists.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size - } - - deviceLibrary.genres.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size + it.artists.size - } - - invalidateSearch = true - } - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - MediaSessionUID.Category.USER_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) - } - userLibrary.playlists.forEach { - val id = MediaSessionUID.Single(it.uid).toString() - invalidate[id] = it.songs.size - } - invalidateSearch = true - } - - if (invalidate.isNotEmpty()) { - invalidator?.invalidate(invalidate) - } - - if (invalidateSearch) { - for (entry in searchResults.entries) { - searchResults[entry.key]?.cancel() - } - searchResults.clear() - - for (entry in searchSubscribers.entries) { - if (searchResults[entry.value] != null) { - continue - } - searchResults[entry.value] = searchTo(entry.value) - } - } - } - - val root: MediaItem - get() = MediaSessionUID.Category.ROOT.toMediaItem(context) - - fun getItem(mediaId: String): MediaItem? { - val music = - when (val uid = MediaSessionUID.fromString(mediaId)) { - is MediaSessionUID.Category -> return uid.toMediaItem(context) - is MediaSessionUID.Single -> - musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.Joined -> - musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - null -> null - } - ?: return null - - return when (music) { - is Album -> music.toMediaItem(context) - is Artist -> music.toMediaItem(context) - is Genre -> music.toMediaItem(context) - is Playlist -> music.toMediaItem(context) - is Song -> music.toMediaItem(context, null) - } - } - - fun getChildren(parentId: String, page: Int, pageSize: Int): List? { - val deviceLibrary = musicRepository.deviceLibrary - val userLibrary = musicRepository.userLibrary - if (deviceLibrary == null || userLibrary == null) { - return listOf() - } - - val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null - return items.paginate(page, pageSize) - } - - private fun getMediaItemList( - id: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary - ): List? { - return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { - is MediaSessionUID.Category -> { - when (mediaSessionUID) { - MediaSessionUID.Category.ROOT -> - MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } - MediaSessionUID.Category.SONGS -> - listSettings.songSort.songs(deviceLibrary.songs).map { - it.toMediaItem(context, null) - } - MediaSessionUID.Category.ALBUMS -> - listSettings.albumSort.albums(deviceLibrary.albums).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.ARTISTS -> - listSettings.artistSort.artists(deviceLibrary.artists).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.GENRES -> - listSettings.genreSort.genres(deviceLibrary.genres).map { - it.toMediaItem(context) - } - MediaSessionUID.Category.PLAYLISTS -> - userLibrary.playlists.map { it.toMediaItem(context) } - } - } - is MediaSessionUID.Single -> { - getChildMediaItems(mediaSessionUID.uid) - } - is MediaSessionUID.Joined -> { - getChildMediaItems(mediaSessionUID.childUid) - } - null -> { - return null - } - } - } - - private fun getChildMediaItems(uid: Music.UID): List? { - return when (val item = musicRepository.find(uid)) { - is Album -> { - val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Artist -> { - val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) - val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Genre -> { - val artists = GENRE_ARTISTS_SORT.artists(item.artists) - val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + - songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } - } - is Playlist -> { - item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } - } - is Song, - null -> return null - } - } - - private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { - val oldExtras = mediaMetadata.extras ?: Bundle() - val newExtras = - Bundle(oldExtras).apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.getString(res)) - } - return buildUpon() - .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) - .build() - } - - private fun getCategorySize( - category: MediaSessionUID.Category, - musicRepository: MusicRepository - ): Int { - val deviceLibrary = musicRepository.deviceLibrary ?: return 0 - val userLibrary = musicRepository.userLibrary ?: return 0 - return when (category) { - MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size - MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size - MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size - MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size - MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size - MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size - } - } - - suspend fun prepareSearch(query: String, controller: ControllerInfo) { - searchSubscribers[controller] = query - val existing = searchResults[query] - if (existing == null) { - val new = searchTo(query) - searchResults[query] = new - new.await() - } else { - val items = existing.await() - invalidator?.invalidate(controller, query, items.count()) - } - } - - suspend fun getSearchResult( - query: String, - page: Int, - pageSize: Int, - ): List? { - val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } - return deferred.await().concat().paginate(page, pageSize) - } - - private fun SearchEngine.Items.concat(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context) }) - } - return music - } - - private fun SearchEngine.Items.count(): Int { - var count = 0 - if (songs != null) { - count += songs.size - } - if (albums != null) { - count += albums.size - } - if (artists != null) { - count += artists.size - } - if (genres != null) { - count += genres.size - } - if (playlists != null) { - count += playlists.size - } - return count - } - - private fun searchTo(query: String) = - searchScope.async { - if (query.isEmpty()) { - return@async SearchEngine.Items() - } - val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() - val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists) - val results = searchEngine.search(items, query) - for (entry in searchSubscribers.entries) { - if (entry.value == query) { - invalidator?.invalidate(entry.key, query, results.count()) - } - } - results - } - - private fun List.paginate(page: Int, pageSize: Int): List? { - if (page == Int.MAX_VALUE) { - // I think if someone requests this page it more or less implies that I should - // return all of the pages. - return this - } - val start = page * pageSize - val end = min((page + 1) * pageSize, size) // Tolerate partial page queries - if (pageSize == 0 || start !in indices) { - // These pages are probably invalid. Hopefully this won't backfire. - return null - } - return subList(start, end).toMutableList() - } - - private companion object { - // TODO: Rely on detail item gen logic? - val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - } -} +// +// import android.content.Context +// import android.os.Bundle +// import androidx.annotation.StringRes +// import androidx.media.utils.MediaConstants +// import androidx.media3.common.MediaItem +// import androidx.media3.session.MediaSession.ControllerInfo +// import dagger.hilt.android.qualifiers.ApplicationContext +// import javax.inject.Inject +// import kotlin.math.min +// import kotlinx.coroutines.CoroutineScope +// import kotlinx.coroutines.Deferred +// import kotlinx.coroutines.Dispatchers +// import kotlinx.coroutines.Job +// import kotlinx.coroutines.async +// import org.oxycblt.auxio.R +// import org.oxycblt.auxio.list.ListSettings +// import org.oxycblt.auxio.list.sort.Sort +// import org.oxycblt.auxio.music.Album +// import org.oxycblt.auxio.music.Artist +// import org.oxycblt.auxio.music.Genre +// import org.oxycblt.auxio.music.Music +// import org.oxycblt.auxio.music.MusicRepository +// import org.oxycblt.auxio.music.Playlist +// import org.oxycblt.auxio.music.Song +// import org.oxycblt.auxio.music.device.DeviceLibrary +// import org.oxycblt.auxio.music.user.UserLibrary +// import org.oxycblt.auxio.search.SearchEngine +// +// class MediaItemBrowser +// @Inject +// constructor( +// @ApplicationContext private val context: Context, +// private val musicRepository: MusicRepository, +// private val listSettings: ListSettings, +// private val searchEngine: SearchEngine +// ) : MusicRepository.UpdateListener { +// private val browserJob = Job() +// private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) +// private val searchSubscribers = mutableMapOf() +// private val searchResults = mutableMapOf>() +// private var invalidator: Invalidator? = null +// +// interface Invalidator { +// fun invalidate(ids: Map) +// +// fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) +// } +// +// fun attach(invalidator: Invalidator) { +// this.invalidator = invalidator +// musicRepository.addUpdateListener(this) +// } +// +// fun release() { +// browserJob.cancel() +// invalidator = null +// musicRepository.removeUpdateListener(this) +// } +// +// override fun onMusicChanges(changes: MusicRepository.Changes) { +// val deviceLibrary = musicRepository.deviceLibrary +// var invalidateSearch = false +// val invalidate = mutableMapOf() +// if (changes.deviceLibrary && deviceLibrary != null) { +// MediaSessionUID.Category.DEVICE_MUSIC.forEach { +// invalidate[it.toString()] = getCategorySize(it, musicRepository) +// } +// +// deviceLibrary.albums.forEach { +// val id = MediaSessionUID.Single(it.uid).toString() +// invalidate[id] = it.songs.size +// } +// +// deviceLibrary.artists.forEach { +// val id = MediaSessionUID.Single(it.uid).toString() +// invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size +// } +// +// deviceLibrary.genres.forEach { +// val id = MediaSessionUID.Single(it.uid).toString() +// invalidate[id] = it.songs.size + it.artists.size +// } +// +// invalidateSearch = true +// } +// val userLibrary = musicRepository.userLibrary +// if (changes.userLibrary && userLibrary != null) { +// MediaSessionUID.Category.USER_MUSIC.forEach { +// invalidate[it.toString()] = getCategorySize(it, musicRepository) +// } +// userLibrary.playlists.forEach { +// val id = MediaSessionUID.Single(it.uid).toString() +// invalidate[id] = it.songs.size +// } +// invalidateSearch = true +// } +// +// if (invalidate.isNotEmpty()) { +// invalidator?.invalidate(invalidate) +// } +// +// if (invalidateSearch) { +// for (entry in searchResults.entries) { +// searchResults[entry.key]?.cancel() +// } +// searchResults.clear() +// +// for (entry in searchSubscribers.entries) { +// if (searchResults[entry.value] != null) { +// continue +// } +// searchResults[entry.value] = searchTo(entry.value) +// } +// } +// } +// +// val root: MediaItem +// get() = MediaSessionUID.Category.ROOT.toMediaItem(context) +// +// fun getItem(mediaId: String): MediaItem? { +// val music = +// when (val uid = MediaSessionUID.fromString(mediaId)) { +// is MediaSessionUID.Category -> return uid.toMediaItem(context) +// is MediaSessionUID.Single -> +// musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } +// is MediaSessionUID.Joined -> +// musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } +// null -> null +// } +// ?: return null +// +// return when (music) { +// is Album -> music.toMediaItem(context) +// is Artist -> music.toMediaItem(context) +// is Genre -> music.toMediaItem(context) +// is Playlist -> music.toMediaItem(context) +// is Song -> music.toMediaItem(context, null) +// } +// } +// +// fun getChildren(parentId: String, page: Int, pageSize: Int): List? { +// val deviceLibrary = musicRepository.deviceLibrary +// val userLibrary = musicRepository.userLibrary +// if (deviceLibrary == null || userLibrary == null) { +// return listOf() +// } +// +// val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null +// return items.paginate(page, pageSize) +// } +// +// private fun getMediaItemList( +// id: String, +// deviceLibrary: DeviceLibrary, +// userLibrary: UserLibrary +// ): List? { +// return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { +// is MediaSessionUID.Category -> { +// when (mediaSessionUID) { +// MediaSessionUID.Category.ROOT -> +// MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } +// MediaSessionUID.Category.SONGS -> +// listSettings.songSort.songs(deviceLibrary.songs).map { +// it.toMediaItem(context, null) +// } +// MediaSessionUID.Category.ALBUMS -> +// listSettings.albumSort.albums(deviceLibrary.albums).map { +// it.toMediaItem(context) +// } +// MediaSessionUID.Category.ARTISTS -> +// listSettings.artistSort.artists(deviceLibrary.artists).map { +// it.toMediaItem(context) +// } +// MediaSessionUID.Category.GENRES -> +// listSettings.genreSort.genres(deviceLibrary.genres).map { +// it.toMediaItem(context) +// } +// MediaSessionUID.Category.PLAYLISTS -> +// userLibrary.playlists.map { it.toMediaItem(context) } +// } +// } +// is MediaSessionUID.Single -> { +// getChildMediaItems(mediaSessionUID.uid) +// } +// is MediaSessionUID.Joined -> { +// getChildMediaItems(mediaSessionUID.childUid) +// } +// null -> { +// return null +// } +// } +// } +// +// private fun getChildMediaItems(uid: Music.UID): List? { +// return when (val item = musicRepository.find(uid)) { +// is Album -> { +// val songs = listSettings.albumSongSort.songs(item.songs) +// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } +// } +// is Artist -> { +// val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) +// val songs = listSettings.artistSongSort.songs(item.songs) +// albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + +// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } +// } +// is Genre -> { +// val artists = GENRE_ARTISTS_SORT.artists(item.artists) +// val songs = listSettings.genreSongSort.songs(item.songs) +// artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + +// songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } +// } +// is Playlist -> { +// item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } +// } +// is Song, +// null -> return null +// } +// } +// +// private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { +// val oldExtras = mediaMetadata.extras ?: Bundle() +// val newExtras = +// Bundle(oldExtras).apply { +// putString( +// MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, +// context.getString(res)) +// } +// return buildUpon() +// .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) +// .build() +// } +// +// private fun getCategorySize( +// category: MediaSessionUID.Category, +// musicRepository: MusicRepository +// ): Int { +// val deviceLibrary = musicRepository.deviceLibrary ?: return 0 +// val userLibrary = musicRepository.userLibrary ?: return 0 +// return when (category) { +// MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size +// MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size +// MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size +// MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size +// MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size +// MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size +// } +// } +// +// suspend fun prepareSearch(query: String, controller: ControllerInfo) { +// searchSubscribers[controller] = query +// val existing = searchResults[query] +// if (existing == null) { +// val new = searchTo(query) +// searchResults[query] = new +// new.await() +// } else { +// val items = existing.await() +// invalidator?.invalidate(controller, query, items.count()) +// } +// } +// +// suspend fun getSearchResult( +// query: String, +// page: Int, +// pageSize: Int, +// ): List? { +// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } +// return deferred.await().concat().paginate(page, pageSize) +// } +// +// private fun SearchEngine.Items.concat(): MutableList { +// val music = mutableListOf() +// if (songs != null) { +// music.addAll(songs.map { it.toMediaItem(context, null) }) +// } +// if (albums != null) { +// music.addAll(albums.map { it.toMediaItem(context) }) +// } +// if (artists != null) { +// music.addAll(artists.map { it.toMediaItem(context) }) +// } +// if (genres != null) { +// music.addAll(genres.map { it.toMediaItem(context) }) +// } +// if (playlists != null) { +// music.addAll(playlists.map { it.toMediaItem(context) }) +// } +// return music +// } +// +// private fun SearchEngine.Items.count(): Int { +// var count = 0 +// if (songs != null) { +// count += songs.size +// } +// if (albums != null) { +// count += albums.size +// } +// if (artists != null) { +// count += artists.size +// } +// if (genres != null) { +// count += genres.size +// } +// if (playlists != null) { +// count += playlists.size +// } +// return count +// } +// +// private fun searchTo(query: String) = +// searchScope.async { +// if (query.isEmpty()) { +// return@async SearchEngine.Items() +// } +// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() +// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() +// val items = +// SearchEngine.Items( +// deviceLibrary.songs, +// deviceLibrary.albums, +// deviceLibrary.artists, +// deviceLibrary.genres, +// userLibrary.playlists) +// val results = searchEngine.search(items, query) +// for (entry in searchSubscribers.entries) { +// if (entry.value == query) { +// invalidator?.invalidate(entry.key, query, results.count()) +// } +// } +// results +// } +// +// private fun List.paginate(page: Int, pageSize: Int): List? { +// if (page == Int.MAX_VALUE) { +// // I think if someone requests this page it more or less implies that I should +// // return all of the pages. +// return this +// } +// val start = page * pageSize +// val end = min((page + 1) * pageSize, size) // Tolerate partial page queries +// if (pageSize == 0 || start !in indices) { +// // These pages are probably invalid. Hopefully this won't backfire. +// return null +// } +// return subList(start, end).toMutableList() +// } +// +// private companion object { +// // TODO: Rely on detail item gen logic? +// val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) +// val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) +// } +// } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt rename to app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 571e96ca7c..4616daf138 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/IndexerServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -35,7 +36,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD -class IndexerServiceFragment +class MusicServiceFragment @Inject constructor( @ApplicationContext override val workerContext: Context, @@ -85,7 +86,7 @@ constructor( } } - fun createNotification(post: (IndexerNotification?) -> Unit) { + fun createNotification(post: (ForegroundServiceNotification?) -> Unit) { val state = musicRepository.indexingState if (state is IndexingState.Indexing) { // There are a few reasons why we stay in the foreground with automatic rescanning: diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt new file mode 100644 index 0000000000..4c8c367fcc --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -0,0 +1,609 @@ +/* + * Copyright (c) 2021 Auxio Project + * MediaSessionHolder.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.playback.system + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +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 androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import androidx.media.app.NotificationCompat.MediaStyle +import javax.inject.Inject +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.ActionMode +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.service.PlaybackActions +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression +import org.oxycblt.auxio.playback.state.QueueChange +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.newBroadcastPendingIntent +import org.oxycblt.auxio.util.newMainPendingIntent + +/** + * A component that mirrors the current playback state into the [MediaSessionCompat] and + * [NotificationComponent]. + * + * @author Alexander Capehart (OxygenCobalt) + */ +class MediaSessionHolder +private constructor( + private val context: Context, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val bitmapProvider: BitmapProvider, + private val imageSettings: ImageSettings +) : + MediaSessionCompat.Callback(), + PlaybackStateManager.Listener, + ImageSettings.Listener, + PlaybackSettings.Listener { + + class Factory + @Inject + constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val bitmapProvider: BitmapProvider, + private val imageSettings: ImageSettings + ) { + fun create(context: Context) = + MediaSessionHolder( + context, playbackManager, playbackSettings, bitmapProvider, imageSettings) + } + + private val mediaSession = + MediaSessionCompat(context, context.packageName).apply { + isActive = true + setQueueTitle(context.getString(R.string.lbl_queue)) + } + val token: MediaSessionCompat.Token + get() = mediaSession.sessionToken + + private val _notification = PlaybackNotification(context, mediaSession.sessionToken) + val notification: ForegroundServiceNotification + get() = _notification + + private var foregroundListener: ForegroundListener? = null + + fun attach(foregroundListener: ForegroundListener) { + this.foregroundListener = foregroundListener + playbackManager.addListener(this) + playbackSettings.registerListener(this) + imageSettings.registerListener(this) + mediaSession.setCallback(this) + } + + /** + * Release this instance, closing the [MediaSessionCompat] and preventing any further updates to + * the [NotificationComponent]. + */ + fun release() { + foregroundListener = null + bitmapProvider.release() + playbackSettings.unregisterListener(this) + imageSettings.unregisterListener(this) + playbackManager.removeListener(this) + mediaSession.apply { + isActive = false + release() + } + } + + // --- PLAYBACKSTATEMANAGER OVERRIDES --- + + override fun onIndexMoved(index: Int) { + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + invalidateSessionState() + } + + override fun onQueueChanged(queue: List, index: Int, change: QueueChange) { + updateQueue(queue) + when (change.type) { + // Nothing special to do with mapping changes. + QueueChange.Type.MAPPING -> {} + // Index changed, ensure playback state's index changes. + QueueChange.Type.INDEX -> invalidateSessionState() + // Song changed, ensure metadata changes. + QueueChange.Type.SONG -> + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + } + } + + override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { + updateQueue(queue) + invalidateSessionState() + mediaSession.setShuffleMode( + if (isShuffled) { + PlaybackStateCompat.SHUFFLE_MODE_ALL + } else { + PlaybackStateCompat.SHUFFLE_MODE_NONE + }) + invalidateSecondaryAction() + } + + override fun onNewPlayback( + parent: MusicParent?, + queue: List, + index: Int, + isShuffled: Boolean + ) { + updateMediaMetadata(playbackManager.currentSong, parent) + updateQueue(queue) + invalidateSessionState() + } + + override fun onProgressionChanged(progression: Progression) { + invalidateSessionState() + _notification.updatePlaying(playbackManager.progression.isPlaying) + if (!bitmapProvider.isBusy) { + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + } + + override fun onRepeatModeChanged(repeatMode: RepeatMode) { + mediaSession.setRepeatMode( + when (repeatMode) { + RepeatMode.NONE -> PlaybackStateCompat.REPEAT_MODE_NONE + RepeatMode.TRACK -> PlaybackStateCompat.REPEAT_MODE_ONE + RepeatMode.ALL -> PlaybackStateCompat.REPEAT_MODE_ALL + }) + + invalidateSecondaryAction() + } + + // --- SETTINGS OVERRIDES --- + + override fun onImageSettingsChanged() { + // Need to reload the metadata cover. + updateMediaMetadata(playbackManager.currentSong, playbackManager.parent) + } + + override fun onNotificationActionChanged() { + // Need to re-load the action shown in the notification. + invalidateSecondaryAction() + } + + // --- MEDIASESSION OVERRIDES --- + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPlayFromMediaId(mediaId, extras) + // STUB: Unimplemented, no media browser + } + + override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { + super.onPlayFromUri(uri, extras) + // STUB: Unimplemented, no media browser + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + super.onPlayFromSearch(query, extras) + // STUB: Unimplemented, no media browser + } + + override fun onAddQueueItem(description: MediaDescriptionCompat?) { + super.onAddQueueItem(description) + // STUB: Unimplemented + } + + override fun onRemoveQueueItem(description: MediaDescriptionCompat?) { + super.onRemoveQueueItem(description) + // STUB: Unimplemented + } + + override fun onPlay() { + playbackManager.playing(true) + } + + override fun onPause() { + playbackManager.playing(false) + } + + override fun onSkipToNext() { + playbackManager.next() + } + + override fun onSkipToPrevious() { + playbackManager.prev() + } + + override fun onSeekTo(position: Long) { + playbackManager.seekTo(position) + } + + override fun onFastForward() { + playbackManager.next() + } + + override fun onRewind() { + playbackManager.seekTo(0) + playbackManager.playing(true) + } + + override fun onSetRepeatMode(repeatMode: Int) { + playbackManager.repeatMode( + when (repeatMode) { + PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK + else -> RepeatMode.NONE + }) + } + + override fun onSetShuffleMode(shuffleMode: Int) { + playbackManager.shuffled( + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) + } + + override fun onSkipToQueueItem(id: Long) { + playbackManager.goto(id.toInt()) + } + + override fun onCustomAction(action: String, extras: Bundle?) { + super.onCustomAction(action, extras) + // Service already handles intents from the old notification actions, easier to + // plug into that system. + context.sendBroadcast(Intent(action)) + } + + override fun onStop() { + // Get the service to shut down with the ACTION_EXIT intent + context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) + } + + // --- INTERNAL --- + + /** + * Upload a new [MediaMetadataCompat] based on the current playback state to the + * [MediaSessionCompat] and [NotificationComponent]. + * + * @param song The current [Song] to create the [MediaMetadataCompat] from, or null if no [Song] + * is currently playing. + * @param parent The current [MusicParent] to create the [MediaMetadataCompat] from, or null if + * playback is currently occuring from all songs. + */ + private fun updateMediaMetadata(song: Song?, parent: MusicParent?) { + logD("Updating media metadata to $song with $parent") + if (song == null) { + // Nothing playing, reset the MediaSession and close the notification. + logD("Nothing playing, resetting media session") + mediaSession.setMetadata(emptyMetadata) + return + } + + // Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used + // several times. + val title = song.name.resolve(context) + val artist = song.artists.resolveNames(context) + val builder = + MediaMetadataCompat.Builder() + .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context)) + // Note: We would leave the artist field null if it didn't exist and let downstream + // consumers handle it, but that would break the notification display. + .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + .putText( + MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, + song.album.artists.resolveNames(context)) + .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) + .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) + .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) + .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context)) + .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) + .putText( + MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, + parent?.run { name.resolve(context) } + ?: context.getString(R.string.lbl_all_songs)) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) + // These fields are nullable and so we must check first before adding them to the fields. + song.track?.let { + logD("Adding track information") + builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong()) + } + song.disc?.let { + logD("Adding disc information") + builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong()) + } + song.date?.let { + logD("Adding date information") + builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) + } + + // We are normally supposed to use URIs for album art, but that removes some of the + // nice things we can do like square cropping or high quality covers. Instead, + // we load a full-size bitmap into the media session and take the performance hit. + bitmapProvider.load( + song, + object : BitmapProvider.Target { + override fun onCompleted(bitmap: Bitmap?) { + logD("Bitmap loaded, applying media session and posting notification") + if (bitmap != null) { + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) + } + val metadata = builder.build() + mediaSession.setMetadata(metadata) + _notification.updateMetadata(metadata) + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + }) + } + + /** + * Upload a new queue to the [MediaSessionCompat]. + * + * @param queue The current queue to upload. + */ + private fun updateQueue(queue: List) { + val queueItems = + queue.mapIndexed { i, song -> + val description = + MediaDescriptionCompat.Builder() + // Media ID should not be the item index but rather the UID, + // as it's used to request a song to be played from the queue. + .setMediaId(song.uid.toString()) + .setTitle(song.name.resolve(context)) + .setSubtitle(song.artists.resolveNames(context)) + // Since we usually have to load many songs into the queue, use the + // MediaStore URI instead of loading a bitmap. + .setIconUri(song.album.cover.single.mediaStoreCoverUri) + .setMediaUri(song.uri) + .build() + // Store the item index so we can then use the analogous index in the + // playback state. + MediaSessionCompat.QueueItem(description, i.toLong()) + } + logD("Uploading ${queueItems.size} songs to MediaSession queue") + mediaSession.setQueue(queueItems) + } + + /** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */ + private fun invalidateSessionState() { + logD("Updating media session playback state") + + val state = + // InternalPlayer.State handles position/state information. + playbackManager.progression + .intoPlaybackState(PlaybackStateCompat.Builder()) + .setActions(ACTIONS) + // Active queue ID corresponds to the indices we populated prior, use them here. + .setActiveQueueItemId(playbackManager.index.toLong()) + + // Android 13+ relies on custom actions in the notification. + + // Add the secondary action (either repeat/shuffle depending on the configuration) + val secondaryAction = + when (playbackSettings.notificationAction) { + ActionMode.SHUFFLE -> { + logD("Using shuffle MediaSession action") + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_INVERT_SHUFFLE, + context.getString(R.string.desc_shuffle), + if (playbackManager.isShuffled) { + R.drawable.ic_shuffle_on_24 + } else { + R.drawable.ic_shuffle_off_24 + }) + } + else -> { + logD("Using repeat mode MediaSession action") + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_INC_REPEAT_MODE, + context.getString(R.string.desc_change_repeat), + playbackManager.repeatMode.icon) + } + } + state.addCustomAction(secondaryAction.build()) + + // Add the exit action so the service can be closed + val exitAction = + PlaybackStateCompat.CustomAction.Builder( + PlaybackActions.ACTION_EXIT, + context.getString(R.string.desc_exit), + R.drawable.ic_close_24) + .build() + state.addCustomAction(exitAction) + + mediaSession.setPlaybackState(state.build()) + } + + /** Invalidate the "secondary" action (i.e shuffle/repeat mode). */ + private fun invalidateSecondaryAction() { + logD("Invalidating secondary action") + invalidateSessionState() + + when (playbackSettings.notificationAction) { + ActionMode.SHUFFLE -> { + logD("Using shuffle notification action") + _notification.updateShuffled(playbackManager.isShuffled) + } + else -> { + logD("Using repeat mode notification action") + _notification.updateRepeatMode(playbackManager.repeatMode) + } + } + + if (!bitmapProvider.isBusy) { + logD("Not loading a bitmap, post the notification") + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + } + + companion object { + private val emptyMetadata = MediaMetadataCompat.Builder().build() + private const val ACTIONS = + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SET_REPEAT_MODE or + PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_STOP + } +} + +/** + * The playback notification component. Due to race conditions regarding notification updates, this + * component is not self-sufficient. [MediaSessionHolder] should be used instead of manage it. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@SuppressLint("RestrictedApi") +private class PlaybackNotification( + private val context: Context, + sessionToken: MediaSessionCompat.Token +) : ForegroundServiceNotification(context, CHANNEL_INFO) { + init { + setSmallIcon(R.drawable.ic_auxio_24) + setCategory(NotificationCompat.CATEGORY_TRANSPORT) + setShowWhen(false) + setSilent(true) + setContentIntent(context.newMainPendingIntent()) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + addAction(buildRepeatAction(context, RepeatMode.NONE)) + addAction( + buildAction(context, PlaybackActions.ACTION_SKIP_PREV, R.drawable.ic_skip_prev_24)) + addAction(buildPlayPauseAction(context, true)) + addAction( + buildAction(context, PlaybackActions.ACTION_SKIP_NEXT, R.drawable.ic_skip_next_24)) + addAction(buildAction(context, PlaybackActions.ACTION_EXIT, R.drawable.ic_close_24)) + + setStyle( + MediaStyle(this).setMediaSession(sessionToken).setShowActionsInCompactView(1, 2, 3)) + } + + override val code: Int + get() = IntegerTable.PLAYBACK_NOTIFICATION_CODE + + // --- STATE FUNCTIONS --- + + /** + * Update the currently shown metadata in this notification. + * + * @param metadata The [MediaMetadataCompat] to display in this notification. + */ + fun updateMetadata(metadata: MediaMetadataCompat) { + logD("Updating shown metadata") + setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)) + setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)) + setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST)) + setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)) + } + + /** + * Update the playing state shown in this notification. + * + * @param isPlaying Whether playback should be indicated as ongoing or paused. + */ + fun updatePlaying(isPlaying: Boolean) { + logD("Updating playing state: $isPlaying") + mActions[2] = buildPlayPauseAction(context, isPlaying) + } + + /** + * Update the secondary action in this notification to show the current [RepeatMode]. + * + * @param repeatMode The current [RepeatMode]. + */ + fun updateRepeatMode(repeatMode: RepeatMode) { + logD("Applying repeat mode action: $repeatMode") + mActions[0] = buildRepeatAction(context, repeatMode) + } + + /** + * Update the secondary action in this notification to show the current shuffle state. + * + * @param isShuffled Whether the queue is currently shuffled or not. + */ + fun updateShuffled(isShuffled: Boolean) { + logD("Applying shuffle action: $isShuffled") + mActions[0] = buildShuffleAction(context, isShuffled) + } + + // --- NOTIFICATION ACTION BUILDERS --- + + private fun buildPlayPauseAction( + context: Context, + isPlaying: Boolean + ): NotificationCompat.Action { + val drawableRes = + if (isPlaying) { + R.drawable.ic_pause_24 + } else { + R.drawable.ic_play_24 + } + return buildAction(context, PlaybackActions.ACTION_PLAY_PAUSE, drawableRes) + } + + private fun buildRepeatAction( + context: Context, + repeatMode: RepeatMode + ): NotificationCompat.Action { + return buildAction(context, PlaybackActions.ACTION_INC_REPEAT_MODE, repeatMode.icon) + } + + private fun buildShuffleAction( + context: Context, + isShuffled: Boolean + ): NotificationCompat.Action { + val drawableRes = + if (isShuffled) { + R.drawable.ic_shuffle_on_24 + } else { + R.drawable.ic_shuffle_off_24 + } + return buildAction(context, PlaybackActions.ACTION_INVERT_SHUFFLE, drawableRes) + } + + private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) = + NotificationCompat.Action.Builder( + iconRes, actionName, context.newBroadcastPendingIntent(actionName)) + .build() + + private companion object { + /** Notification channel used by solely the playback notification. */ + val CHANNEL_INFO = + ChannelInfo( + id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK", + nameRes = R.string.lbl_playback) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt deleted file mode 100644 index a4be02ad21..0000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ /dev/null @@ -1,297 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaSessionServiceFragment.kt is part of Auxio. - * - * This program 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. - * - * This program 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 this program. If not, see . - */ - -package org.oxycblt.auxio.playback.service - -import android.app.Notification -import android.content.Context -import android.os.Bundle -import androidx.core.content.ContextCompat -import androidx.media3.common.MediaItem -import androidx.media3.session.CommandButton -import androidx.media3.session.DefaultActionFactory -import androidx.media3.session.DefaultMediaNotificationProvider -import androidx.media3.session.LibraryResult -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaLibraryService.MediaLibrarySession -import androidx.media3.session.MediaNotification -import androidx.media3.session.MediaNotification.ActionFactory -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSession.ConnectionResult -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionResult -import com.google.common.collect.ImmutableList -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.guava.asListenableFuture -import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.ForegroundListener -import org.oxycblt.auxio.IntegerTable -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.service.MediaItemBrowser -import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.state.DeferredPlayback -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.newMainPendingIntent -import org.oxycblt.auxio.widgets.WidgetComponent - -class MediaSessionServiceFragment -@Inject -constructor( - @ApplicationContext private val context: Context, - private val playbackManager: PlaybackStateManager, - private val actionHandler: PlaybackActionHandler, - private val playbackSettings: PlaybackSettings, - private val widgetComponent: WidgetComponent, - private val mediaItemBrowser: MediaItemBrowser, - exoHolderFactory: ExoPlaybackStateHolder.Factory -) : - MediaLibrarySession.Callback, - PlaybackActionHandler.Callback, - MediaItemBrowser.Invalidator, - PlaybackStateManager.Listener { - private val waitJob = Job() - private val waitScope = CoroutineScope(waitJob + Dispatchers.Default) - private val exoHolder = exoHolderFactory.create() - - private lateinit var actionFactory: ActionFactory - private val mediaNotificationProvider = - DefaultMediaNotificationProvider.Builder(context) - .setNotificationId(IntegerTable.PLAYBACK_NOTIFICATION_CODE) - .setChannelId(BuildConfig.APPLICATION_ID + ".channel.PLAYBACK") - .setChannelName(R.string.lbl_playback) - .setPlayDrawableResourceId(R.drawable.ic_play_24) - .setPauseDrawableResourceId(R.drawable.ic_pause_24) - .setSkipNextDrawableResourceId(R.drawable.ic_skip_next_24) - .setSkipPrevDrawableResourceId(R.drawable.ic_skip_prev_24) - .setContentIntent(context.newMainPendingIntent()) - .build() - .also { it.setSmallIcon(R.drawable.ic_auxio_24) } - private var foregroundListener: ForegroundListener? = null - - lateinit var systemReceiver: SystemPlaybackReceiver - lateinit var mediaSession: MediaLibrarySession - private set - - // --- MEDIASESSION CALLBACKS --- - - fun attach(service: MediaLibraryService, listener: ForegroundListener): MediaLibrarySession { - foregroundListener = listener - mediaSession = createSession(service) - service.addSession(mediaSession) - actionFactory = DefaultActionFactory(service) - playbackManager.addListener(this) - exoHolder.attach() - actionHandler.attach(this) - systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) - ContextCompat.registerReceiver( - context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) - widgetComponent.attach() - mediaItemBrowser.attach(this) - return mediaSession - } - - fun handleTaskRemoved() { - if (!playbackManager.progression.isPlaying) { - playbackManager.endSession() - } - } - - fun start(startedBy: Int) { - // At minimum we want to ensure an active playback state. - // TODO: Possibly also force to go foreground? - logD("Handling non-native start.") - val action = - when (startedBy) { - IntegerTable.START_ID_ACTIVITY -> null - IntegerTable.START_ID_TASKER -> - DeferredPlayback.RestoreState( - play = true, fallback = DeferredPlayback.ShuffleAll) - // External services using Auxio better know what they are doing. - else -> DeferredPlayback.RestoreState(play = false) - } - if (action != null) { - logD("Initing service fragment using action $action") - playbackManager.playDeferred(action) - } - } - - fun hasNotification(): Boolean = exoHolder.sessionOngoing - - fun createNotification(post: (MediaNotification) -> Unit) { - val notification = - mediaNotificationProvider.createNotification( - mediaSession, mediaSession.customLayout, actionFactory) { notification -> - post(wrapMediaNotification(notification)) - } - post(wrapMediaNotification(notification)) - } - - fun release() { - waitJob.cancel() - mediaItemBrowser.release() - context.unregisterReceiver(systemReceiver) - widgetComponent.release() - actionHandler.release() - exoHolder.release() - playbackManager.removeListener(this) - mediaSession.release() - foregroundListener = null - } - - private fun wrapMediaNotification(notification: MediaNotification): MediaNotification { - // Pulled from MediaNotificationManager: Need to specify MediaSession token manually - // in notification - val fwkToken = - mediaSession.sessionCompatToken.token as android.media.session.MediaSession.Token - notification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken) - return notification - } - - private fun createSession(service: MediaLibraryService) = - MediaLibrarySession.Builder(service, exoHolder.mediaSessionPlayer, this).build() - - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo - ): ConnectionResult { - val sessionCommands = - actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) - return ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(sessionCommands) - .setCustomLayout(actionHandler.createCustomLayout()) - .build() - } - - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture = - if (actionHandler.handleCommand(customCommand)) { - Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } else { - super.onCustomCommand(session, controller, customCommand, args) - } - - override fun onGetLibraryRoot( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - params: MediaLibraryService.LibraryParams? - ): ListenableFuture> = - Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String - ): ListenableFuture> { - val result = - mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - return Futures.immediateFuture(result) - } - - override fun onSetMediaItems( - mediaSession: MediaSession, - controller: MediaSession.ControllerInfo, - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ): ListenableFuture = - Futures.immediateFuture( - MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) - - override fun onGetChildren( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - parentId: String, - page: Int, - pageSize: Int, - params: MediaLibraryService.LibraryParams? - ): ListenableFuture>> { - val children = - mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { - LibraryResult.ofItemList(it, params) - } - ?: LibraryResult.ofError>( - LibraryResult.RESULT_ERROR_BAD_VALUE) - return Futures.immediateFuture(children) - } - - override fun onSearch( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - params: MediaLibraryService.LibraryParams? - ): ListenableFuture> = - waitScope - .async { - mediaItemBrowser.prepareSearch(query, browser) - // Invalidator will send the notify result - LibraryResult.ofVoid() - } - .asListenableFuture() - - override fun onGetSearchResult( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - page: Int, - pageSize: Int, - params: MediaLibraryService.LibraryParams? - ) = - waitScope - .async { - mediaItemBrowser.getSearchResult(query, page, pageSize)?.let { - LibraryResult.ofItemList(it, params) - } - ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - } - .asListenableFuture() - - override fun onSessionEnded() { - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) - } - - override fun onCustomLayoutChanged(layout: List) { - mediaSession.setCustomLayout(layout) - } - - override fun invalidate(ids: Map) { - for (id in ids) { - mediaSession.notifyChildrenChanged(id.key, id.value, null) - } - } - - override fun invalidate( - controller: MediaSession.ControllerInfo, - query: String, - itemCount: Int - ) { - mediaSession.notifySearchResultChanged(controller, query, itemCount, null) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 3dd0acf684..441bf52533 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -18,142 +18,7 @@ package org.oxycblt.auxio.playback.service -import android.content.Context -import android.os.Bundle -import androidx.media3.common.Player -import androidx.media3.session.CommandButton -import androidx.media3.session.SessionCommand -import androidx.media3.session.SessionCommands -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.ActionMode -import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Progression -import org.oxycblt.auxio.playback.state.RepeatMode - -class PlaybackActionHandler -@Inject -constructor( - @ApplicationContext private val context: Context, - private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings -) : PlaybackStateManager.Listener, PlaybackSettings.Listener { - - interface Callback { - fun onCustomLayoutChanged(layout: List) - } - - private var callback: Callback? = null - - fun attach(callback: Callback) { - this.callback = callback - playbackManager.addListener(this) - playbackSettings.registerListener(this) - } - - fun release() { - callback = null - playbackManager.removeListener(this) - playbackSettings.unregisterListener(this) - } - - fun withCommands(commands: SessionCommands) = - commands - .buildUpon() - .add(SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle.EMPTY)) - .add(SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle.EMPTY)) - .add(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle.EMPTY)) - .build() - - fun handleCommand(command: SessionCommand): Boolean { - when (command.customAction) { - PlaybackActions.ACTION_INC_REPEAT_MODE -> - playbackManager.repeatMode(playbackManager.repeatMode.increment()) - PlaybackActions.ACTION_INVERT_SHUFFLE -> - playbackManager.shuffled(!playbackManager.isShuffled) - PlaybackActions.ACTION_EXIT -> playbackManager.endSession() - else -> return false - } - return true - } - - fun createCustomLayout(): List { - val actions = mutableListOf() - - when (playbackSettings.notificationAction) { - ActionMode.REPEAT -> { - actions.add( - CommandButton.Builder() - .setIconResId(playbackManager.repeatMode.icon) - .setDisplayName(context.getString(R.string.desc_change_repeat)) - .setSessionCommand( - SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle())) - .setEnabled(true) - .build()) - } - ActionMode.SHUFFLE -> { - actions.add( - CommandButton.Builder() - .setIconResId( - if (playbackManager.isShuffled) R.drawable.ic_shuffle_on_24 - else R.drawable.ic_shuffle_off_24) - .setDisplayName(context.getString(R.string.lbl_shuffle)) - .setSessionCommand( - SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle())) - .setEnabled(true) - .build()) - } - else -> {} - } - - actions.add( - CommandButton.Builder() - .setIconResId(R.drawable.ic_skip_prev_24) - .setDisplayName(context.getString(R.string.desc_skip_prev)) - .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) - .setEnabled(true) - .build()) - - actions.add( - CommandButton.Builder() - .setIconResId(R.drawable.ic_close_24) - .setDisplayName(context.getString(R.string.desc_exit)) - .setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle())) - .setEnabled(true) - .build()) - - return actions - } - - override fun onPauseOnRepeatChanged() { - super.onPauseOnRepeatChanged() - callback?.onCustomLayoutChanged(createCustomLayout()) - } - - override fun onProgressionChanged(progression: Progression) { - super.onProgressionChanged(progression) - callback?.onCustomLayoutChanged(createCustomLayout()) - } - - override fun onRepeatModeChanged(repeatMode: RepeatMode) { - super.onRepeatModeChanged(repeatMode) - callback?.onCustomLayoutChanged(createCustomLayout()) - } - - override fun onQueueReordered(queue: List, index: Int, isShuffled: Boolean) { - super.onQueueReordered(queue, index, isShuffled) - callback?.onCustomLayoutChanged(createCustomLayout()) - } - - override fun onNotificationActionChanged() { - super.onNotificationActionChanged() - callback?.onCustomLayoutChanged(createCustomLayout()) - } -} object PlaybackActions { const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt new file mode 100644 index 0000000000..91d6d8b777 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionServiceFragment.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.playback.service + +import android.annotation.SuppressLint +import android.content.Context +import android.support.v4.media.session.MediaSessionCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.Job +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.state.DeferredPlayback +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.system.MediaSessionHolder +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.widgets.WidgetComponent + +class PlaybackServiceFragment +@Inject +constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val sessionHolderFactory: MediaSessionHolder.Factory, + private val widgetComponent: WidgetComponent, + exoHolderFactory: ExoPlaybackStateHolder.Factory +) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener { + private val waitJob = Job() + private val exoHolder = exoHolderFactory.create() + private var foregroundListener: ForegroundListener? = null + + private lateinit var sessionHolder: MediaSessionHolder + private lateinit var systemReceiver: SystemPlaybackReceiver + + // --- MEDIASESSION CALLBACKS --- + + @SuppressLint("WrongConstant") + fun attach(listener: ForegroundListener): MediaSessionCompat.Token { + foregroundListener = listener + playbackManager.addListener(this) + exoHolder.attach() + sessionHolder = sessionHolderFactory.create(context) + systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) + ContextCompat.registerReceiver( + context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) + widgetComponent.attach() + return sessionHolder.token + } + + fun handleTaskRemoved() { + if (!playbackManager.progression.isPlaying) { + playbackManager.endSession() + } + } + + fun start(startedBy: Int) { + // At minimum we want to ensure an active playback state. + // TODO: Possibly also force to go foreground? + logD("Handling non-native start.") + val action = + when (startedBy) { + IntegerTable.START_ID_ACTIVITY -> null + IntegerTable.START_ID_TASKER -> + DeferredPlayback.RestoreState( + play = true, fallback = DeferredPlayback.ShuffleAll) + // External services using Auxio better know what they are doing. + else -> DeferredPlayback.RestoreState(play = false) + } + if (action != null) { + logD("Initing service fragment using action $action") + playbackManager.playDeferred(action) + } + } + + val notification: ForegroundServiceNotification? + get() = if (exoHolder.sessionOngoing) sessionHolder.notification else null + + fun release() { + waitJob.cancel() + widgetComponent.release() + context.unregisterReceiver(systemReceiver) + sessionHolder.release() + exoHolder.release() + playbackManager.removeListener(this) + foregroundListener = null + } + + override fun onSessionEnded() { + foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + } + + // override fun onConnect( + // session: MediaSession, + // controller: MediaSession.ControllerInfo + // ): ConnectionResult { + // val sessionCommands = + // actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) + // return ConnectionResult.AcceptedResultBuilder(session) + // .setAvailableSessionCommands(sessionCommands) + // .setCustomLayout(actionHandler.createCustomLayout()) + // .build() + // } + // + // override fun onCustomCommand( + // session: MediaSession, + // controller: MediaSession.ControllerInfo, + // customCommand: SessionCommand, + // args: Bundle + // ): ListenableFuture = + // if (actionHandler.handleCommand(customCommand)) { + // Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + // } else { + // super.onCustomCommand(session, controller, customCommand, args) + // } + // + // override fun onGetLibraryRoot( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // params: MediaLibraryService.LibraryParams? + // ): ListenableFuture> = + // Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) + // + // override fun onGetItem( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // mediaId: String + // ): ListenableFuture> { + // val result = + // mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } + // ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + // return Futures.immediateFuture(result) + // } + // + // override fun onSetMediaItems( + // mediaSession: MediaSession, + // controller: MediaSession.ControllerInfo, + // mediaItems: MutableList, + // startIndex: Int, + // startPositionMs: Long + // ): ListenableFuture = + // Futures.immediateFuture( + // MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) + // + // override fun onGetChildren( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // parentId: String, + // page: Int, + // pageSize: Int, + // params: MediaLibraryService.LibraryParams? + // ): ListenableFuture>> { + // val children = + // mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { + // LibraryResult.ofItemList(it, params) + // } + // ?: LibraryResult.ofError>( + // LibraryResult.RESULT_ERROR_BAD_VALUE) + // return Futures.immediateFuture(children) + // } + // + // override fun onSearch( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // query: String, + // params: MediaLibraryService.LibraryParams? + // ): ListenableFuture> = + // waitScope + // .async { + // mediaItemBrowser.prepareSearch(query, browser) + // // Invalidator will send the notify result + // LibraryResult.ofVoid() + // } + // .asListenableFuture() + // + // override fun onGetSearchResult( + // session: MediaLibrarySession, + // browser: MediaSession.ControllerInfo, + // query: String, + // page: Int, + // pageSize: Int, + // params: MediaLibraryService.LibraryParams? + // ) = + // waitScope + // .async { + // mediaItemBrowser.getSearchResult(query, page, pageSize)?.let { + // LibraryResult.ofItemList(it, params) + // } + // ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) + // } + // .asListenableFuture() + +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index 31c931fdbe..d671219a10 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2024 Auxio Project + * SystemPlaybackReceiver.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + package org.oxycblt.auxio.playback.service import android.content.BroadcastReceiver @@ -109,4 +127,4 @@ class SystemPlaybackReceiver( playbackManager.playing(false) } } -} \ No newline at end of file +} From 69070e7b1329ca550887e9952d8ab492d40863cc Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 10:33:54 -0600 Subject: [PATCH 03/66] playback: port basic media descriptions --- .../java/org/oxycblt/auxio/AuxioService.kt | 13 +- .../auxio/music/service/MediaItemBrowser.kt | 374 ----------------- .../music/service/MediaItemTranslation.kt | 331 ++++++--------- .../auxio/music/service/MusicBrowser.kt | 388 +++++++++++++++++ .../service/ExoPlaybackStateHolder.kt | 63 ++- .../playback/service/MediaSessionHolder.kt | 83 +++- .../playback/service/MediaSessionPlayer.kt | 390 ------------------ .../service/PlaybackServiceFragment.kt | 26 +- app/src/main/res/values/strings.xml | 1 + 9 files changed, 642 insertions(+), 1027 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 9c62fdcd2b..777cb5ddbb 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -24,6 +24,7 @@ import android.content.Intent import android.os.Bundle import android.os.IBinder import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat @@ -81,31 +82,29 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? { - TODO("Not yet implemented") - } + ): BrowserRoot? = null override fun onLoadChildren( parentId: String, - result: Result> + result: Result> ) = throw NotImplementedError() override fun onLoadChildren( parentId: String, - result: Result>, + result: Result>, options: Bundle ) { super.onLoadChildren(parentId, result, options) } - override fun onLoadItem(itemId: String, result: Result) { + override fun onLoadItem(itemId: String, result: Result) { super.onLoadItem(itemId, result) } override fun onSearch( query: String, extras: Bundle?, - result: Result> + result: Result> ) { super.onSearch(query, extras, result) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt deleted file mode 100644 index 5d578119aa..0000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaItemBrowser.kt is part of Auxio. - * - * This program 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. - * - * This program 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 this program. If not, see . - */ - -package org.oxycblt.auxio.music.service -// -// import android.content.Context -// import android.os.Bundle -// import androidx.annotation.StringRes -// import androidx.media.utils.MediaConstants -// import androidx.media3.common.MediaItem -// import androidx.media3.session.MediaSession.ControllerInfo -// import dagger.hilt.android.qualifiers.ApplicationContext -// import javax.inject.Inject -// import kotlin.math.min -// import kotlinx.coroutines.CoroutineScope -// import kotlinx.coroutines.Deferred -// import kotlinx.coroutines.Dispatchers -// import kotlinx.coroutines.Job -// import kotlinx.coroutines.async -// import org.oxycblt.auxio.R -// import org.oxycblt.auxio.list.ListSettings -// import org.oxycblt.auxio.list.sort.Sort -// import org.oxycblt.auxio.music.Album -// import org.oxycblt.auxio.music.Artist -// import org.oxycblt.auxio.music.Genre -// import org.oxycblt.auxio.music.Music -// import org.oxycblt.auxio.music.MusicRepository -// import org.oxycblt.auxio.music.Playlist -// import org.oxycblt.auxio.music.Song -// import org.oxycblt.auxio.music.device.DeviceLibrary -// import org.oxycblt.auxio.music.user.UserLibrary -// import org.oxycblt.auxio.search.SearchEngine -// -// class MediaItemBrowser -// @Inject -// constructor( -// @ApplicationContext private val context: Context, -// private val musicRepository: MusicRepository, -// private val listSettings: ListSettings, -// private val searchEngine: SearchEngine -// ) : MusicRepository.UpdateListener { -// private val browserJob = Job() -// private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) -// private val searchSubscribers = mutableMapOf() -// private val searchResults = mutableMapOf>() -// private var invalidator: Invalidator? = null -// -// interface Invalidator { -// fun invalidate(ids: Map) -// -// fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) -// } -// -// fun attach(invalidator: Invalidator) { -// this.invalidator = invalidator -// musicRepository.addUpdateListener(this) -// } -// -// fun release() { -// browserJob.cancel() -// invalidator = null -// musicRepository.removeUpdateListener(this) -// } -// -// override fun onMusicChanges(changes: MusicRepository.Changes) { -// val deviceLibrary = musicRepository.deviceLibrary -// var invalidateSearch = false -// val invalidate = mutableMapOf() -// if (changes.deviceLibrary && deviceLibrary != null) { -// MediaSessionUID.Category.DEVICE_MUSIC.forEach { -// invalidate[it.toString()] = getCategorySize(it, musicRepository) -// } -// -// deviceLibrary.albums.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size -// } -// -// deviceLibrary.artists.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size -// } -// -// deviceLibrary.genres.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size + it.artists.size -// } -// -// invalidateSearch = true -// } -// val userLibrary = musicRepository.userLibrary -// if (changes.userLibrary && userLibrary != null) { -// MediaSessionUID.Category.USER_MUSIC.forEach { -// invalidate[it.toString()] = getCategorySize(it, musicRepository) -// } -// userLibrary.playlists.forEach { -// val id = MediaSessionUID.Single(it.uid).toString() -// invalidate[id] = it.songs.size -// } -// invalidateSearch = true -// } -// -// if (invalidate.isNotEmpty()) { -// invalidator?.invalidate(invalidate) -// } -// -// if (invalidateSearch) { -// for (entry in searchResults.entries) { -// searchResults[entry.key]?.cancel() -// } -// searchResults.clear() -// -// for (entry in searchSubscribers.entries) { -// if (searchResults[entry.value] != null) { -// continue -// } -// searchResults[entry.value] = searchTo(entry.value) -// } -// } -// } -// -// val root: MediaItem -// get() = MediaSessionUID.Category.ROOT.toMediaItem(context) -// -// fun getItem(mediaId: String): MediaItem? { -// val music = -// when (val uid = MediaSessionUID.fromString(mediaId)) { -// is MediaSessionUID.Category -> return uid.toMediaItem(context) -// is MediaSessionUID.Single -> -// musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } -// is MediaSessionUID.Joined -> -// musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } -// null -> null -// } -// ?: return null -// -// return when (music) { -// is Album -> music.toMediaItem(context) -// is Artist -> music.toMediaItem(context) -// is Genre -> music.toMediaItem(context) -// is Playlist -> music.toMediaItem(context) -// is Song -> music.toMediaItem(context, null) -// } -// } -// -// fun getChildren(parentId: String, page: Int, pageSize: Int): List? { -// val deviceLibrary = musicRepository.deviceLibrary -// val userLibrary = musicRepository.userLibrary -// if (deviceLibrary == null || userLibrary == null) { -// return listOf() -// } -// -// val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null -// return items.paginate(page, pageSize) -// } -// -// private fun getMediaItemList( -// id: String, -// deviceLibrary: DeviceLibrary, -// userLibrary: UserLibrary -// ): List? { -// return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { -// is MediaSessionUID.Category -> { -// when (mediaSessionUID) { -// MediaSessionUID.Category.ROOT -> -// MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } -// MediaSessionUID.Category.SONGS -> -// listSettings.songSort.songs(deviceLibrary.songs).map { -// it.toMediaItem(context, null) -// } -// MediaSessionUID.Category.ALBUMS -> -// listSettings.albumSort.albums(deviceLibrary.albums).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.ARTISTS -> -// listSettings.artistSort.artists(deviceLibrary.artists).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.GENRES -> -// listSettings.genreSort.genres(deviceLibrary.genres).map { -// it.toMediaItem(context) -// } -// MediaSessionUID.Category.PLAYLISTS -> -// userLibrary.playlists.map { it.toMediaItem(context) } -// } -// } -// is MediaSessionUID.Single -> { -// getChildMediaItems(mediaSessionUID.uid) -// } -// is MediaSessionUID.Joined -> { -// getChildMediaItems(mediaSessionUID.childUid) -// } -// null -> { -// return null -// } -// } -// } -// -// private fun getChildMediaItems(uid: Music.UID): List? { -// return when (val item = musicRepository.find(uid)) { -// is Album -> { -// val songs = listSettings.albumSongSort.songs(item.songs) -// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Artist -> { -// val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) -// val songs = listSettings.artistSongSort.songs(item.songs) -// albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + -// songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Genre -> { -// val artists = GENRE_ARTISTS_SORT.artists(item.artists) -// val songs = listSettings.genreSongSort.songs(item.songs) -// artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + -// songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } -// } -// is Playlist -> { -// item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } -// } -// is Song, -// null -> return null -// } -// } -// -// private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { -// val oldExtras = mediaMetadata.extras ?: Bundle() -// val newExtras = -// Bundle(oldExtras).apply { -// putString( -// MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, -// context.getString(res)) -// } -// return buildUpon() -// .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) -// .build() -// } -// -// private fun getCategorySize( -// category: MediaSessionUID.Category, -// musicRepository: MusicRepository -// ): Int { -// val deviceLibrary = musicRepository.deviceLibrary ?: return 0 -// val userLibrary = musicRepository.userLibrary ?: return 0 -// return when (category) { -// MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size -// MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size -// MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size -// MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size -// MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size -// MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size -// } -// } -// -// suspend fun prepareSearch(query: String, controller: ControllerInfo) { -// searchSubscribers[controller] = query -// val existing = searchResults[query] -// if (existing == null) { -// val new = searchTo(query) -// searchResults[query] = new -// new.await() -// } else { -// val items = existing.await() -// invalidator?.invalidate(controller, query, items.count()) -// } -// } -// -// suspend fun getSearchResult( -// query: String, -// page: Int, -// pageSize: Int, -// ): List? { -// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } -// return deferred.await().concat().paginate(page, pageSize) -// } -// -// private fun SearchEngine.Items.concat(): MutableList { -// val music = mutableListOf() -// if (songs != null) { -// music.addAll(songs.map { it.toMediaItem(context, null) }) -// } -// if (albums != null) { -// music.addAll(albums.map { it.toMediaItem(context) }) -// } -// if (artists != null) { -// music.addAll(artists.map { it.toMediaItem(context) }) -// } -// if (genres != null) { -// music.addAll(genres.map { it.toMediaItem(context) }) -// } -// if (playlists != null) { -// music.addAll(playlists.map { it.toMediaItem(context) }) -// } -// return music -// } -// -// private fun SearchEngine.Items.count(): Int { -// var count = 0 -// if (songs != null) { -// count += songs.size -// } -// if (albums != null) { -// count += albums.size -// } -// if (artists != null) { -// count += artists.size -// } -// if (genres != null) { -// count += genres.size -// } -// if (playlists != null) { -// count += playlists.size -// } -// return count -// } -// -// private fun searchTo(query: String) = -// searchScope.async { -// if (query.isEmpty()) { -// return@async SearchEngine.Items() -// } -// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() -// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() -// val items = -// SearchEngine.Items( -// deviceLibrary.songs, -// deviceLibrary.albums, -// deviceLibrary.artists, -// deviceLibrary.genres, -// userLibrary.playlists) -// val results = searchEngine.search(items, query) -// for (entry in searchSubscribers.entries) { -// if (entry.value == query) { -// invalidator?.invalidate(entry.key, query, results.count()) -// } -// } -// results -// } -// -// private fun List.paginate(page: Int, pageSize: Int): List? { -// if (page == Int.MAX_VALUE) { -// // I think if someone requests this page it more or less implies that I should -// // return all of the pages. -// return this -// } -// val start = page * pageSize -// val end = min((page + 1) * pageSize, size) // Tolerate partial page queries -// if (pageSize == 0 || start !in indices) { -// // These pages are probably invalid. Hopefully this won't backfire. -// return null -// } -// return subList(start, end).toMutableList() -// } -// -// private companion object { -// // TODO: Rely on detail item gen logic? -// val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) -// val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) -// } -// } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 9a5bb53c2a..043aa0d462 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -22,12 +22,13 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat.MediaItem +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata -import java.io.ByteArrayOutputStream import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album @@ -37,11 +38,78 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural +import java.io.ByteArrayOutputStream +import kotlin.math.ceil + +enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) { + ROOT("root", R.string.info_app_name, null), + MORE("more", R.string.lbl_more, R.drawable.ic_more_24), + SONGS("songs", R.string.lbl_songs, R.drawable.ic_song_bitmap_24), + ALBUMS("albums", R.string.lbl_albums, R.drawable.ic_album_bitmap_24), + ARTISTS("artists", R.string.lbl_artists, R.drawable.ic_artist_bitmap_24), + GENRES("genres", R.string.lbl_genres, R.drawable.ic_genre_bitmap_24), + PLAYLISTS("playlists", R.string.lbl_playlists, R.drawable.ic_playlist_bitmap_24); + + companion object { + val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) + val USER_MUSIC = listOf(ROOT, PLAYLISTS) + val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) + } +} + +sealed interface MediaSessionUID { + data class CategoryItem(val category: Category) : MediaSessionUID { + override fun toString() = "$ID_CATEGORY:$category" + } + + data class SingleItem(val uid: Music.UID) : MediaSessionUID { + override fun toString() = "$ID_ITEM:$uid" + } + + data class ChildItem(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { + override fun toString() = "$ID_ITEM:$parentUid>$childUid" + } + + companion object { + const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" + const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" + + fun fromString(str: String): MediaSessionUID? { + val parts = str.split(":", limit = 2) + if (parts.size != 2) { + return null + } + return when (parts[0]) { + ID_CATEGORY -> + CategoryItem(when (parts[1]) { + Category.ROOT.id -> Category.ROOT + Category.MORE.id -> Category.MORE + Category.SONGS.id -> Category.SONGS + Category.ALBUMS.id -> Category.ALBUMS + Category.ARTISTS.id -> Category.ARTISTS + Category.GENRES.id -> Category.GENRES + Category.PLAYLISTS.id -> Category.PLAYLISTS + else -> return null + }) + ID_ITEM -> { + val uids = parts[1].split(">", limit = 2) + if (uids.size == 1) { + Music.UID.fromString(uids[0])?.let { SingleItem(it) } + } else { + Music.UID.fromString(uids[0])?.let { parent -> + Music.UID.fromString(uids[1])?.let { child -> ChildItem(parent, child) } + } + } + } + else -> return null + } + } + } +} -fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { +fun Category.toMediaItem(context: Context): MediaItem { // TODO: Make custom overflow menu for compat val style = Bundle().apply { @@ -49,95 +117,48 @@ fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) } - val metadata = - MediaMetadata.Builder() - .setTitle(context.getString(nameRes)) - .setIsPlayable(false) - .setIsBrowsable(true) - .setMediaType(mediaType) - .setExtras(style) + val mediaSessionUID = MediaSessionUID.CategoryItem(this) + val description = MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(context.getString(nameRes)) if (bitmapRes != null) { - val data = ByteArrayOutputStream() - BitmapFactory.decodeResource(context.resources, bitmapRes) - .compress(Bitmap.CompressFormat.PNG, 100, data) - metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON) + val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) + description.setIconBitmap(bitmap) } - return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build() + return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } - fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { val mediaSessionUID = if (parent == null) { - MediaSessionUID.Single(uid) + MediaSessionUID.SingleItem(uid) } else { - MediaSessionUID.Joined(parent.uid, uid) + MediaSessionUID.ChildItem(parent.uid, uid) } - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(album.name.resolve(context)) - .setAlbumArtist(album.artists.resolveNames(context)) - .setTrackNumber(track) - .setDiscNumber(disc?.number) - .setGenre(genres.resolveNames(context)) - .setDisplayTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setRecordingYear(album.dates?.min?.year) - .setRecordingMonth(album.dates?.min?.month) - .setRecordingDay(album.dates?.min?.day) - .setReleaseYear(album.dates?.min?.year) - .setReleaseMonth(album.dates?.min?.month) - .setReleaseDay(album.dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) - .setIsPlayable(true) - .setIsBrowsable(false) - .setArtworkUri(cover.mediaStoreCoverUri) - .setExtras( - Bundle().apply { - putString("uid", mediaSessionUID.toString()) - putLong("durationMs", durationMs) - }) - .build() - return MediaItem.Builder() - .setUri(uri) + val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(album.cover.single.mediaStoreCoverUri) + .setMediaUri(uri) .build() + return MediaItem(description, MediaItem.FLAG_PLAYABLE) } fun Album.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setArtist(artists.resolveNames(context)) - .setAlbumTitle(name.resolve(context)) - .setAlbumArtist(artists.resolveNames(context)) - .setRecordingYear(dates?.min?.year) - .setRecordingMonth(dates?.min?.month) - .setRecordingDay(dates?.min?.day) - .setReleaseYear(dates?.min?.year) - .setReleaseMonth(dates?.min?.month) - .setReleaseDay(dates?.min?.day) - .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setIconUri(cover.single.mediaStoreCoverUri) .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Artist.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = context.getString( R.string.fmt_two, if (explicitAlbums.isNotEmpty()) { @@ -149,162 +170,46 @@ fun Artist.toMediaItem(context: Context): MediaItem { context.getPlural(R.plurals.fmt_song_count, songs.size) } else { context.getString(R.string.def_song_count) - })) - .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) - .setIsPlayable(false) - .setIsBrowsable(true) - .setGenre(genres.resolveNames(context)) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() + }) + val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Genre.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = if (songs.isNotEmpty()) { context.getPlural(R.plurals.fmt_song_count, songs.size) } else { context.getString(R.string.def_song_count) - }) - .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover.single.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() + } + val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) .build() + return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Playlist.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.Single(uid) - val metadata = - MediaMetadata.Builder() - .setTitle(name.resolve(context)) - .setSubtitle( + val mediaSessionUID = MediaSessionUID.SingleItem(uid) + val counts = if (songs.isNotEmpty()) { context.getPlural(R.plurals.fmt_song_count, songs.size) } else { context.getString(R.string.def_song_count) - }) - .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) - .setIsPlayable(false) - .setIsBrowsable(true) - .setArtworkUri(cover?.single?.mediaStoreCoverUri) - .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) - .build() - return MediaItem.Builder() + } + val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) - .setMediaMetadata(metadata) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover?.single?.mediaStoreCoverUri) .build() -} - -fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { - val uid = MediaSessionUID.fromString(mediaId) ?: return null - return when (uid) { - is MediaSessionUID.Single -> { - deviceLibrary.findSong(uid.uid) - } - is MediaSessionUID.Joined -> { - deviceLibrary.findSong(uid.childUid) - } - is MediaSessionUID.Category -> null - } -} - -sealed interface MediaSessionUID { - enum class Category( - val id: String, - @StringRes val nameRes: Int, - @DrawableRes val bitmapRes: Int?, - val mediaType: Int? - ) : MediaSessionUID { - ROOT("root", R.string.info_app_name, null, null), - SONGS( - "songs", - R.string.lbl_songs, - R.drawable.ic_song_bitmap_24, - MediaMetadata.MEDIA_TYPE_MUSIC), - ALBUMS( - "albums", - R.string.lbl_albums, - R.drawable.ic_album_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), - ARTISTS( - "artists", - R.string.lbl_artists, - R.drawable.ic_artist_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), - GENRES( - "genres", - R.string.lbl_genres, - R.drawable.ic_genre_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), - PLAYLISTS( - "playlists", - R.string.lbl_playlists, - R.drawable.ic_playlist_bitmap_24, - MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); - - override fun toString() = "$ID_CATEGORY:$id" - - companion object { - val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) - val USER_MUSIC = listOf(ROOT, PLAYLISTS) - val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) - } - } - - data class Single(val uid: Music.UID) : MediaSessionUID { - override fun toString() = "$ID_ITEM:$uid" - } - - data class Joined(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { - override fun toString() = "$ID_ITEM:$parentUid>$childUid" - } - - companion object { - const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" - const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" - - fun fromString(str: String): MediaSessionUID? { - val parts = str.split(":", limit = 2) - if (parts.size != 2) { - return null - } - return when (parts[0]) { - ID_CATEGORY -> - when (parts[1]) { - Category.ROOT.id -> Category.ROOT - Category.SONGS.id -> Category.SONGS - Category.ALBUMS.id -> Category.ALBUMS - Category.ARTISTS.id -> Category.ARTISTS - Category.GENRES.id -> Category.GENRES - Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> null - } - ID_ITEM -> { - val uids = parts[1].split(">", limit = 2) - if (uids.size == 1) { - Music.UID.fromString(uids[0])?.let { Single(it) } - } else { - Music.UID.fromString(uids[0])?.let { parent -> - Music.UID.fromString(uids[1])?.let { child -> Joined(parent, child) } - } - } - } - else -> return null - } - } - } + return MediaItem(description, MediaItem.FLAG_BROWSABLE) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt new file mode 100644 index 0000000000..62238c5b95 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2024 Auxio Project + * MusicBrowser.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaBrowserCompat.MediaItem +import androidx.annotation.StringRes +import androidx.media.utils.MediaConstants +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import org.oxycblt.auxio.R +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.search.SearchEngine +import javax.inject.Inject +import kotlin.math.min + +class MediaItemBrowser +@Inject +constructor( + @ApplicationContext private val context: Context, + private val musicRepository: MusicRepository, + private val listSettings: ListSettings +) : MusicRepository.UpdateListener { + private val browserJob = Job() + private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) + private val searchSubscribers = mutableMapOf() + private val searchResults = mutableMapOf>() + private var invalidator: Invalidator? = null + + interface Invalidator { + fun invalidate(ids: Map) + + fun invalidate(controller: String, query: String, itemCount: Int) + } + + fun attach(invalidator: Invalidator) { + this.invalidator = invalidator + musicRepository.addUpdateListener(this) + } + + fun release() { + browserJob.cancel() + invalidator = null + musicRepository.removeUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary + var invalidateSearch = false + val invalidate = mutableMapOf() + if (changes.deviceLibrary && deviceLibrary != null) { + MediaSessionUID.Category.DEVICE_MUSIC.forEach { + invalidate[it.toString()] = getCategorySize(it, musicRepository) + } + + deviceLibrary.albums.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + } + + deviceLibrary.artists.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size + } + + deviceLibrary.genres.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + it.artists.size + } + + invalidateSearch = true + } + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + MediaSessionUID.Category.USER_MUSIC.forEach { + invalidate[it.toString()] = getCategorySize(it, musicRepository) + } + userLibrary.playlists.forEach { + val id = MediaSessionUID.SingleItem(it.uid).toString() + invalidate[id] = it.songs.size + } + invalidateSearch = true + } + + if (invalidate.isNotEmpty()) { + invalidator?.invalidate(invalidate) + } + + if (invalidateSearch) { + for (entry in searchResults.entries) { + searchResults[entry.key]?.cancel() + } + searchResults.clear() + + for (entry in searchSubscribers.entries) { + if (searchResults[entry.value] != null) { + continue + } + searchResults[entry.value] = searchTo(entry.value) + } + } + } + + val root: MediaItem + get() = MediaSessionUID.Category.ROOT.toMediaItem(context) + + fun getItem(mediaId: String): MediaItem? { + val music = + when (val uid = MediaSessionUID.fromString(mediaId)) { + is MediaSessionUID.Category -> return uid.toMediaItem(context) + is MediaSessionUID.SingleItem -> + musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } + + is MediaSessionUID.ChildItem -> + musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } + + null -> null + } + ?: return null + + return when (music) { + is Album -> music.toMediaItem(context) + is Artist -> music.toMediaItem(context) + is Genre -> music.toMediaItem(context) + is Playlist -> music.toMediaItem(context) + is Song -> music.toMediaItem(context, null) + } + } + + fun getChildren(parentId: String, page: Int, pageSize: Int): List? { + val deviceLibrary = musicRepository.deviceLibrary + val userLibrary = musicRepository.userLibrary + if (deviceLibrary == null || userLibrary == null) { + return listOf() + } + + val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null + return items.paginate(page, pageSize) + } + + private fun getMediaItemList( + id: String, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): List? { + return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { + is MediaSessionUID.Category -> { + when (mediaSessionUID) { + MediaSessionUID.Category.ROOT -> + MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } + + MediaSessionUID.Category.SONGS -> + listSettings.songSort.songs(deviceLibrary.songs).map { + it.toMediaItem(context, null) + } + + MediaSessionUID.Category.ALBUMS -> + listSettings.albumSort.albums(deviceLibrary.albums).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.ARTISTS -> + listSettings.artistSort.artists(deviceLibrary.artists).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.GENRES -> + listSettings.genreSort.genres(deviceLibrary.genres).map { + it.toMediaItem(context) + } + + MediaSessionUID.Category.PLAYLISTS -> + userLibrary.playlists.map { it.toMediaItem(context) } + } + } + + is MediaSessionUID.SingleItem -> { + getChildMediaItems(mediaSessionUID.uid) + } + + is MediaSessionUID.ChildItem -> { + getChildMediaItems(mediaSessionUID.childUid) + } + + null -> { + return null + } + } + } + + private fun getChildMediaItems(uid: Music.UID): List? { + return when (val item = musicRepository.find(uid)) { + is Album -> { + val songs = listSettings.albumSongSort.songs(item.songs) + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Artist -> { + val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) + val songs = listSettings.artistSongSort.songs(item.songs) + albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Genre -> { + val artists = GENRE_ARTISTS_SORT.artists(item.artists) + val songs = listSettings.genreSongSort.songs(item.songs) + artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + + songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } + } + + is Playlist -> { + item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + } + + is Song, + null -> return null + } + } + + private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { + val oldExtras = mediaMetadata.extras ?: Bundle() + val newExtras = + Bundle(oldExtras).apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + context.getString(res) + ) + } + return buildUpon() + .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) + .build() + } + + private fun getCategorySize( + category: MediaSessionUID.Category, + musicRepository: MusicRepository + ): Int { + val deviceLibrary = musicRepository.deviceLibrary ?: return 0 + val userLibrary = musicRepository.userLibrary ?: return 0 + return when (category) { + MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size + MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size + MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size + MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size + MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size + MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size + } + } + + suspend fun prepareSearch(query: String, controller: ControllerInfo) { + searchSubscribers[controller] = query + val existing = searchResults[query] + if (existing == null) { + val new = searchTo(query) + searchResults[query] = new + new.await() + } else { + val items = existing.await() + invalidator?.invalidate(controller, query, items.count()) + } + } + + suspend fun getSearchResult( + query: String, + page: Int, + pageSize: Int, + ): List? { + val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } + return deferred.await().concat().paginate(page, pageSize) + } + + private fun SearchEngine.Items.concat(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context) }) + } + return music + } + + private fun SearchEngine.Items.count(): Int { + var count = 0 + if (songs != null) { + count += songs.size + } + if (albums != null) { + count += albums.size + } + if (artists != null) { + count += artists.size + } + if (genres != null) { + count += genres.size + } + if (playlists != null) { + count += playlists.size + } + return count + } + + private fun searchTo(query: String) = + searchScope.async { + if (query.isEmpty()) { + return@async SearchEngine.Items() + } + val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() + val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists + ) + val results = searchEngine.search(items, query) + for (entry in searchSubscribers.entries) { + if (entry.value == query) { + invalidator?.invalidate(entry.key, query, results.count()) + } + } + results + } + + private fun List.paginate(page: Int, pageSize: Int): List? { + if (page == Int.MAX_VALUE) { + // I think if someone requests this page it more or less implies that I should + // return all of the pages. + return this + } + val start = page * pageSize + val end = min((page + 1) * pageSize, size) // Tolerate partial page queries + if (pageSize == 0 || start !in indices) { + // These pages are probably invalid. Hopefully this won't backfire. + return null + } + return subList(start, end).toMutableList() + } + + private companion object { + // TODO: Rely on detail item gen logic? + val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 59ac16d95d..1abc6bfb39 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -47,7 +47,6 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.service.toMediaItem -import org.oxycblt.auxio.music.service.toSong import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -110,10 +109,6 @@ class ExoPlaybackStateHolder( override var parent: MusicParent? = null private set - val mediaSessionPlayer: Player - get() = - MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository) - override val progression: Progression get() { val mediaItem = player.currentMediaItem ?: return Progression.nil() @@ -147,7 +142,7 @@ class ExoPlaybackStateHolder( emptyList() } return RawQueue( - heap.mapNotNull { it.toSong(deviceLibrary) }, + heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex) } @@ -226,7 +221,7 @@ class ExoPlaybackStateHolder( override fun newPlayback(command: PlaybackCommand) { parent = command.parent player.shuffleModeEnabled = command.shuffled - player.setMediaItems(command.queue.map { it.toMediaItem(context, null) }) + player.setMediaItems(command.queue.map { it.buildMediaItem() }) val startIndex = command.song ?.let { command.queue.indexOf(it) } @@ -316,16 +311,16 @@ class ExoPlaybackStateHolder( } if (nextIndex == C.INDEX_UNSET) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(songs.map { it.buildMediaItem() }) } else { - player.addMediaItems(nextIndex, songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(nextIndex, songs.map { it.buildMediaItem() }) } playbackManager.ack(this, ack) deferSave() } override fun addToQueue(songs: List, ack: StateAck.AddToQueue) { - player.addMediaItems(songs.map { it.toMediaItem(context, null) }) + player.addMediaItems(songs.map { it.buildMediaItem() }) playbackManager.ack(this, ack) deferSave() } @@ -382,7 +377,7 @@ class ExoPlaybackStateHolder( sendEvent = true } if (rawQueue != resolveQueue()) { - player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) + player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() }) if (rawQueue.isShuffled) { player.shuffleModeEnabled = true player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) @@ -538,6 +533,52 @@ class ExoPlaybackStateHolder( currentSaveJob = saveScope.launch { block() } } + private fun Song.buildMediaItem() = MediaItem.Builder() + .setUri(uri) + .setTag(this) + .build() + + private val MediaItem.song: Song? get() = this.localConfiguration?.tag as? Song? + + private fun Player.unscrambleQueueIndices(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = mutableListOf() + + // Add the active queue item. + val currentMediaItemIndex = currentMediaItemIndex + queue.add(currentMediaItemIndex) + + // Fill queue alternating with next and/or previous queue items. + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { + // Begin with next to have a longer tail than head if an even sized queue needs to be + // trimmed. + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex( + lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(lastMediaItemIndex) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET) { + firstMediaItemIndex = + timeline.getPreviousWindowIndex( + firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.add(0, firstMediaItemIndex) + } + } + } + + return queue + } + class Factory @Inject constructor( diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 4c8c367fcc..63c281080d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -39,16 +39,25 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.PlaybackActions +import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent @@ -64,6 +73,8 @@ private constructor( private val context: Context, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) : @@ -77,12 +88,14 @@ private constructor( constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) { fun create(context: Context) = MediaSessionHolder( - context, playbackManager, playbackSettings, bitmapProvider, imageSettings) + context, playbackManager, playbackSettings, commandFactory, musicRepository, bitmapProvider, imageSettings) } private val mediaSession = @@ -201,27 +214,47 @@ private constructor( override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) - // STUB: Unimplemented, no media browser + val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return + val command = expandIntoCommand(uid) + requireNotNull(command) { "Invalid playback configuration" } + playbackManager.play(command) } override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { super.onPlayFromUri(uri, extras) - // STUB: Unimplemented, no media browser + // STUB } override fun onPlayFromSearch(query: String?, extras: Bundle?) { super.onPlayFromSearch(query, extras) - // STUB: Unimplemented, no media browser + // STUB: Unimplemented, no search engine } - override fun onAddQueueItem(description: MediaDescriptionCompat?) { + override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) - // STUB: Unimplemented + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } ?: return + playbackManager.addToQueue(song) } - override fun onRemoveQueueItem(description: MediaDescriptionCompat?) { + override fun onRemoveQueueItem(description: MediaDescriptionCompat) { super.onRemoveQueueItem(description) - // STUB: Unimplemented + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } ?: return + val queueIndex = playbackManager.queue.indexOf(song) + if (queueIndex > -1) { + playbackManager.removeQueueItem(queueIndex) + } } override fun onPlay() { @@ -392,6 +425,40 @@ private constructor( mediaSession.setQueue(queueItems) } + private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { + val music: Music + var parent: MusicParent? = null + when (uid) { + is MediaSessionUID.SingleItem -> { + music = musicRepository.find(uid.uid) ?: return null + } + is MediaSessionUID.ChildItem -> { + music = musicRepository.find(uid.childUid) ?: return null + parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null + } + else -> return null + } + + return when (music) { + is Song -> inferSongFromParent(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.OFF) + is Artist -> commandFactory.artist(music, ShuffleMode.OFF) + is Genre -> commandFactory.genre(music, ShuffleMode.OFF) + is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + } + } + + private fun inferSongFromParent(music: Song, parent: MusicParent?) = + when (parent) { + is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) + null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) + } + /** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */ private fun invalidateSessionState() { logD("Updating media session playback state") diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt deleted file mode 100644 index f5ea4215c6..0000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ /dev/null @@ -1,390 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * MediaSessionPlayer.kt is part of Auxio. - * - * This program 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. - * - * This program 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 this program. If not, see . - */ - -package org.oxycblt.auxio.playback.service - -import android.content.Context -import android.os.Bundle -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.TextureView -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.ForwardingPlayer -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.PlaybackParameters -import androidx.media3.common.Player -import androidx.media3.common.TrackSelectionParameters -import java.lang.Exception -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.service.MediaSessionUID -import org.oxycblt.auxio.music.service.toSong -import org.oxycblt.auxio.playback.state.PlaybackCommand -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.ShuffleMode -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE - -/** - * A thin wrapper around the player instance that drastically reduces the command surface and - * forwards all commands to PlaybackStateManager so I can ensure that all the unhinged commands that - * Media3 will throw at me will be handled in a predictable way, rather than just clobbering the - * playback state. Largely limited to the legacy media APIs. - * - * I'll add more support as I go along when I can confirm that apps will use the Media3 API and send - * more advanced commands. - * - * @author Alexander Capehart - */ -class MediaSessionPlayer( - private val context: Context, - player: Player, - private val playbackManager: PlaybackStateManager, - private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository -) : ForwardingPlayer(player) { - override fun getAvailableCommands(): Player.Commands { - return super.getAvailableCommands() - .buildUpon() - .addAll(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - .build() - } - - override fun isCommandAvailable(command: Int): Boolean { - // We can always skip forward and backward (this is to retain parity with the old behavior) - return super.isCommandAvailable(command) || - command in setOf(Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_PREVIOUS) - } - - override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { - if (!resetPosition) { - error("Playing MediaItems with custom position parameters is not supported") - } - - setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET) - } - - override fun getMediaMetadata() = - super.getMediaMetadata().run { - val existingExtras = extras - val newExtras = existingExtras?.let { Bundle(it) } ?: Bundle() - newExtras.apply { - putString( - "parent", - playbackManager.parent?.name?.resolve(context) - ?: context.getString(R.string.lbl_all_songs)) - } - - buildUpon().setExtras(newExtras).build() - } - - override fun setMediaItems( - mediaItems: MutableList, - startIndex: Int, - startPositionMs: Long - ) { - // We assume the only people calling this method are going to be the MediaSession callbacks. - // As part of this, we expand the given MediaItems into the command that should be sent to - // the player. - if (startIndex != C.INDEX_UNSET || startPositionMs != C.TIME_UNSET) { - error("Playing MediaItems with custom position parameters is not supported") - } - if (mediaItems.size != 1) { - error("Playing multiple MediaItems is not supported") - } - val command = expandMediaItemIntoCommand(mediaItems.first()) - requireNotNull(command) { "Invalid playback configuration" } - playbackManager.play(command) - } - - private fun expandMediaItemIntoCommand(mediaItem: MediaItem): PlaybackCommand? { - val uid = MediaSessionUID.fromString(mediaItem.mediaId) ?: return null - val music: Music - var parent: MusicParent? = null - when (uid) { - is MediaSessionUID.Single -> { - music = musicRepository.find(uid.uid) ?: return null - } - is MediaSessionUID.Joined -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - else -> return null - } - - return when (music) { - is Song -> inferSongFromParentCommand(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } - } - - private fun inferSongFromParentCommand(music: Song, parent: MusicParent?) = - when (parent) { - is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) - is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) - null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) - } - - override fun play() = playbackManager.playing(true) - - override fun pause() = playbackManager.playing(false) - - override fun setRepeatMode(repeatMode: Int) { - val appRepeatMode = - when (repeatMode) { - Player.REPEAT_MODE_OFF -> RepeatMode.NONE - Player.REPEAT_MODE_ONE -> RepeatMode.TRACK - Player.REPEAT_MODE_ALL -> RepeatMode.ALL - else -> throw IllegalStateException("Unknown repeat mode: $repeatMode") - } - playbackManager.repeatMode(appRepeatMode) - } - - override fun seekToDefaultPosition(mediaItemIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeIndex = indices.indexOf(mediaItemIndex) - if (fakeIndex < 0) { - return - } - playbackManager.goto(fakeIndex) - } - - override fun seekToNext() = playbackManager.next() - - override fun seekToNextMediaItem() = playbackManager.next() - - override fun seekToPrevious() = playbackManager.prev() - - override fun seekToPreviousMediaItem() = playbackManager.prev() - - override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) - - override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed() - - override fun seekToDefaultPosition() = notAllowed() - - override fun addMediaItems(index: Int, mediaItems: MutableList) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - val songs = mediaItems.mapNotNull { it.toSong(deviceLibrary) } - when { - index == - currentTimeline.getNextWindowIndex( - currentMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) -> { - playbackManager.playNext(songs) - } - index >= mediaItemCount -> playbackManager.addToQueue(songs) - else -> error("Unsupported index $index") - } - } - - override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { - playbackManager.shuffled(shuffleModeEnabled) - } - - override fun moveMediaItem(currentIndex: Int, newIndex: Int) { - val indices = unscrambleQueueIndices() - val fakeFrom = indices.indexOf(currentIndex) - if (fakeFrom < 0) { - return - } - val fakeTo = - if (newIndex >= mediaItemCount) { - currentTimeline.getLastWindowIndex(shuffleModeEnabled) - } else { - indices.indexOf(newIndex) - } - if (fakeTo < 0) { - return - } - playbackManager.moveQueueItem(fakeFrom, fakeTo) - } - - override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) = - error("Multi-item queue moves are unsupported") - - override fun removeMediaItem(index: Int) { - val indices = unscrambleQueueIndices() - val fakeAt = indices.indexOf(index) - if (fakeAt < 0) { - return - } - playbackManager.removeQueueItem(fakeAt) - } - - override fun removeMediaItems(fromIndex: Int, toIndex: Int) = - error("Any multi-item queue removal is unsupported") - - override fun stop() = playbackManager.endSession() - - // These methods I don't want MediaSession calling in any way since they'll do insane things - // that I'm not tracking. If they do call them, I will know. - - override fun setMediaItem(mediaItem: MediaItem) = notAllowed() - - override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) = notAllowed() - - override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) = notAllowed() - - override fun setMediaItems(mediaItems: MutableList) = notAllowed() - - override fun addMediaItem(mediaItem: MediaItem) = notAllowed() - - override fun addMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() - - override fun addMediaItems(mediaItems: MutableList) = notAllowed() - - override fun replaceMediaItem(index: Int, mediaItem: MediaItem) = notAllowed() - - override fun replaceMediaItems( - fromIndex: Int, - toIndex: Int, - mediaItems: MutableList - ) = notAllowed() - - override fun clearMediaItems() = notAllowed() - - override fun setPlaybackSpeed(speed: Float) = notAllowed() - - override fun seekForward() = notAllowed() - - override fun seekBack() = notAllowed() - - @Deprecated("Deprecated in Java") override fun next() = notAllowed() - - @Deprecated("Deprecated in Java") override fun previous() = notAllowed() - - @Deprecated("Deprecated in Java") override fun seekToPreviousWindow() = notAllowed() - - @Deprecated("Deprecated in Java") override fun seekToNextWindow() = notAllowed() - - override fun prepare() = notAllowed() - - override fun release() = notAllowed() - - override fun setPlayWhenReady(playWhenReady: Boolean) = notAllowed() - - override fun hasNextMediaItem() = notAllowed() - - override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) = - notAllowed() - - override fun setVolume(volume: Float) = notAllowed() - - override fun setDeviceVolume(volume: Int, flags: Int) = notAllowed() - - override fun setDeviceMuted(muted: Boolean, flags: Int) = notAllowed() - - override fun increaseDeviceVolume(flags: Int) = notAllowed() - - override fun decreaseDeviceVolume(flags: Int) = notAllowed() - - @Deprecated("Deprecated in Java") override fun increaseDeviceVolume() = notAllowed() - - @Deprecated("Deprecated in Java") override fun decreaseDeviceVolume() = notAllowed() - - @Deprecated("Deprecated in Java") override fun setDeviceVolume(volume: Int) = notAllowed() - - @Deprecated("Deprecated in Java") override fun setDeviceMuted(muted: Boolean) = notAllowed() - - override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = notAllowed() - - override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) = notAllowed() - - override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) = notAllowed() - - override fun setVideoSurface(surface: Surface?) = notAllowed() - - override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() - - override fun setVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() - - override fun setVideoTextureView(textureView: TextureView?) = notAllowed() - - override fun clearVideoSurface() = notAllowed() - - override fun clearVideoSurface(surface: Surface?) = notAllowed() - - override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) = notAllowed() - - override fun clearVideoSurfaceView(surfaceView: SurfaceView?) = notAllowed() - - override fun clearVideoTextureView(textureView: TextureView?) = notAllowed() - - private fun notAllowed(): Nothing { - logD("MediaSession unexpectedly called this method") - logE(Exception().stackTraceToString()) - error("MediaSession unexpectedly called this method") - } -} - -fun Player.unscrambleQueueIndices(): List { - val timeline = currentTimeline - if (timeline.isEmpty) { - return emptyList() - } - val queue = mutableListOf() - - // Add the active queue item. - val currentMediaItemIndex = currentMediaItemIndex - queue.add(currentMediaItemIndex) - - // Fill queue alternating with next and/or previous queue items. - var firstMediaItemIndex = currentMediaItemIndex - var lastMediaItemIndex = currentMediaItemIndex - val shuffleModeEnabled = shuffleModeEnabled - while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET)) { - // Begin with next to have a longer tail than head if an even sized queue needs to be - // trimmed. - if (lastMediaItemIndex != C.INDEX_UNSET) { - lastMediaItemIndex = - timeline.getNextWindowIndex( - lastMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (lastMediaItemIndex != C.INDEX_UNSET) { - queue.add(lastMediaItemIndex) - } - } - if (firstMediaItemIndex != C.INDEX_UNSET) { - firstMediaItemIndex = - timeline.getPreviousWindowIndex( - firstMediaItemIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled) - if (firstMediaItemIndex != C.INDEX_UNSET) { - queue.add(0, firstMediaItemIndex) - } - } - } - - return queue -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 91d6d8b777..26c1eea01a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -109,30 +109,8 @@ constructor( foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - // override fun onConnect( - // session: MediaSession, - // controller: MediaSession.ControllerInfo - // ): ConnectionResult { - // val sessionCommands = - // actionHandler.withCommands(ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS) - // return ConnectionResult.AcceptedResultBuilder(session) - // .setAvailableSessionCommands(sessionCommands) - // .setCustomLayout(actionHandler.createCustomLayout()) - // .build() - // } - // - // override fun onCustomCommand( - // session: MediaSession, - // controller: MediaSession.ControllerInfo, - // customCommand: SessionCommand, - // args: Bundle - // ): ListenableFuture = - // if (actionHandler.handleCommand(customCommand)) { - // Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - // } else { - // super.onCustomCommand(session, controller, customCommand, args) - // } - // + + // override fun onGetLibraryRoot( // session: MediaLibrarySession, // browser: MediaSession.ControllerInfo, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 886e5ce550..8bdd5fa2ee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,6 +163,7 @@ Reset Add + More Path style Absolute From b1e871c6e129513801a31ac0981804f2918f8b02 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:12:41 -0600 Subject: [PATCH 04/66] music: re-add music browsing --- .../java/org/oxycblt/auxio/AuxioService.kt | 43 +-- .../oxycblt/auxio/music/service/Indexer.kt | 146 +++++++++ .../music/service/MediaItemTranslation.kt | 34 +- .../auxio/music/service/MusicBrowser.kt | 296 +++++++----------- .../music/service/MusicServiceFragment.kt | 166 ++++------ 5 files changed, 371 insertions(+), 314 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 777cb5ddbb..b1a9da89ba 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -23,7 +23,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.IBinder -import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat @@ -37,16 +36,16 @@ import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint -class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { - @Inject lateinit var mediaSessionFragment: PlaybackServiceFragment +class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { + @Inject lateinit var playbackFragment: PlaybackServiceFragment - @Inject lateinit var indexingFragment: MusicServiceFragment + @Inject lateinit var musicFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() - setSessionToken(mediaSessionFragment.attach(this)) - indexingFragment.attach(this) + sessionToken = playbackFragment.attach(this) + musicFragment.attach(this, this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -63,26 +62,31 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { private fun onHandleForeground(intent: Intent?) { val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1 - indexingFragment.start() - mediaSessionFragment.start(startId) + musicFragment.start() + playbackFragment.start(startId) } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - mediaSessionFragment.handleTaskRemoved() + playbackFragment.handleTaskRemoved() } override fun onDestroy() { super.onDestroy() - indexingFragment.release() - mediaSessionFragment.release() + musicFragment.release() + playbackFragment.release() + sessionToken = null } override fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? = null + ): BrowserRoot = musicFragment.getRoot() + + override fun onLoadItem(itemId: String, result: Result) { + musicFragment.getItem(itemId, result) + } override fun onLoadChildren( parentId: String, @@ -93,13 +97,8 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { parentId: String, result: Result>, options: Bundle - ) { - super.onLoadChildren(parentId, result, options) - } + ) = musicFragment.getChildren(parentId, result) - override fun onLoadItem(itemId: String, result: Result) { - super.onLoadItem(itemId, result) - } override fun onSearch( query: String, @@ -120,7 +119,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { } override fun updateForeground(change: ForegroundListener.Change) { - val mediaNotification = mediaSessionFragment.notification + val mediaNotification = playbackFragment.notification if (mediaNotification != null) { if (change == ForegroundListener.Change.MEDIA_SESSION) { startForeground(mediaNotification.code, mediaNotification.build()) @@ -128,7 +127,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { // Nothing changed, but don't show anything music related since we can always // index during playback. } else { - indexingFragment.createNotification { + musicFragment.createNotification { if (it != null) { startForeground(it.code, it.build()) isForeground = true @@ -140,6 +139,10 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener { } } + override fun invalidateMusic(mediaId: String) { + notifyChildrenChanged(mediaId) + } + companion object { var isForeground = false private set diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt new file mode 100644 index 0000000000..398afdbb57 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2024 Auxio Project + * IndexerServiceFragment.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.music.service + +import android.content.Context +import android.os.PowerManager +import coil.ImageLoader +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.music.IndexingState +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD + +class Indexer +@Inject +constructor( + @ApplicationContext override val workerContext: Context, + private val playbackManager: PlaybackStateManager, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings, + private val imageLoader: ImageLoader +) : + MusicRepository.IndexingWorker, + MusicRepository.IndexingListener, + MusicRepository.UpdateListener, + MusicSettings.Listener { + private val indexJob = Job() + private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) + private var currentIndexJob: Job? = null + private var foregroundListener: ForegroundListener? = null + private val wakeLock = + workerContext + .getSystemServiceCompat(PowerManager::class) + .newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") + + fun attach(listener: ForegroundListener) { + foregroundListener = listener + musicSettings.registerListener(this) + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) + musicRepository.registerWorker(this) + } + + fun release() { + musicSettings.registerListener(this) + musicRepository.addIndexingListener(this) + musicRepository.addUpdateListener(this) + musicRepository.removeIndexingListener(this) + foregroundListener = null + } + + override fun requestIndex(withCache: Boolean) { + logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") + // Cancel the previous music loading job. + currentIndexJob?.cancel() + // Start a new music loading job on a co-routine. + currentIndexJob = musicRepository.index(this, withCache) + } + + override val scope = indexScope + + override fun onIndexingStateChanged() { + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + wakeLock.acquireSafe() + } else { + wakeLock.releaseSafe() + } + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + logD("Music changed, updating shared objects") + // Wipe possibly-invalidated outdated covers + imageLoader.memoryCache?.clear() + // Clear invalid models from PlaybackStateManager. This is not connected + // to a listener as it is bad practice for a shared object to attach to + // the listener system of another. + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + savedState.copy( + heap = + savedState.heap.map { song -> + song?.let { deviceLibrary.findSong(it.uid) } + }), + true) + } + } + + override fun onIndexingSettingChanged() { + super.onIndexingSettingChanged() + musicRepository.requestIndex(true) + } + + /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.acquireSafe() { + // Avoid unnecessary acquire calls. + if (!wakeLock.isHeld) { + logD("Acquiring wake lock") + // Time out after a minute, which is the average music loading time for a medium-sized + // library. If this runs out, we will re-request the lock, and if music loading is + // shorter than the timeout, it will be released early. + acquire(WAKELOCK_TIMEOUT_MS) + } + } + + /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ + private fun PowerManager.WakeLock.releaseSafe() { + // Avoid unnecessary release calls. + if (wakeLock.isHeld) { + logD("Releasing wake lock") + release() + } + } + + companion object { + const val WAKELOCK_TIMEOUT_MS = 60 * 1000L + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 043aa0d462..afe6c3eda1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -19,16 +19,13 @@ package org.oxycblt.auxio.music.service import android.content.Context -import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.media.utils.MediaConstants -import androidx.media3.common.MediaMetadata import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album @@ -40,8 +37,6 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural -import java.io.ByteArrayOutputStream -import kotlin.math.ceil enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) { ROOT("root", R.string.info_app_name, null), @@ -109,9 +104,15 @@ sealed interface MediaSessionUID { } } +typealias Sugar = Bundle.(Context) -> Unit + +fun header(@StringRes nameRes: Int): Sugar = { + putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) +} + fun Category.toMediaItem(context: Context): MediaItem { // TODO: Make custom overflow menu for compat - val style = + val extras = Bundle().apply { putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, @@ -121,19 +122,22 @@ fun Category.toMediaItem(context: Context): MediaItem { val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(context.getString(nameRes)) + .setExtras(extras) if (bitmapRes != null) { val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) description.setIconBitmap(bitmap) } return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { + +fun Song.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) } else { MediaSessionUID.ChildItem(parent.uid, uid) } + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) @@ -141,12 +145,18 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { .setDescription(album.name.resolve(context)) .setIconUri(album.cover.single.mediaStoreCoverUri) .setMediaUri(uri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_PLAYABLE) } -fun Album.toMediaItem(context: Context): MediaItem { - val mediaSessionUID = MediaSessionUID.SingleItem(uid) +fun Album.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem { + val mediaSessionUID = + if (parent == null) { + MediaSessionUID.SingleItem(uid) + } else { + MediaSessionUID.ChildItem(parent.uid, uid) + } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) @@ -156,7 +166,7 @@ fun Album.toMediaItem(context: Context): MediaItem { return MediaItem(description, MediaItem.FLAG_BROWSABLE) } -fun Artist.toMediaItem(context: Context): MediaItem { +fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = context.getString( @@ -180,7 +190,7 @@ fun Artist.toMediaItem(context: Context): MediaItem { return MediaItem(description, MediaItem.FLAG_BROWSABLE) } -fun Genre.toMediaItem(context: Context): MediaItem { +fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = if (songs.isNotEmpty()) { @@ -197,7 +207,7 @@ fun Genre.toMediaItem(context: Context): MediaItem { return MediaItem(description, MediaItem.FLAG_BROWSABLE) } -fun Playlist.toMediaItem(context: Context): MediaItem { +fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = if (songs.isNotEmpty()) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 62238c5b95..542095a90f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -45,100 +45,71 @@ import org.oxycblt.auxio.search.SearchEngine import javax.inject.Inject import kotlin.math.min -class MediaItemBrowser +class MusicBrowser @Inject constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, private val listSettings: ListSettings ) : MusicRepository.UpdateListener { - private val browserJob = Job() - private val searchScope = CoroutineScope(browserJob + Dispatchers.Default) - private val searchSubscribers = mutableMapOf() - private val searchResults = mutableMapOf>() - private var invalidator: Invalidator? = null - interface Invalidator { - fun invalidate(ids: Map) - - fun invalidate(controller: String, query: String, itemCount: Int) + fun invalidateMusic(ids: Set) } + private var invalidator: Invalidator? = null + fun attach(invalidator: Invalidator) { this.invalidator = invalidator musicRepository.addUpdateListener(this) } fun release() { - browserJob.cancel() - invalidator = null musicRepository.removeUpdateListener(this) } override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary - var invalidateSearch = false - val invalidate = mutableMapOf() + val invalidate = mutableSetOf() if (changes.deviceLibrary && deviceLibrary != null) { - MediaSessionUID.Category.DEVICE_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) + Category.DEVICE_MUSIC.forEach { + invalidate.add(MediaSessionUID.CategoryItem(it).toString()) } deviceLibrary.albums.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate[id] = it.songs.size + invalidate.add(id) } deviceLibrary.artists.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate[id] = it.songs.size + it.explicitAlbums.size + it.implicitAlbums.size + invalidate.add(id) } deviceLibrary.genres.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate[id] = it.songs.size + it.artists.size + invalidate.add(id) } - - invalidateSearch = true } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { - MediaSessionUID.Category.USER_MUSIC.forEach { - invalidate[it.toString()] = getCategorySize(it, musicRepository) + Category.USER_MUSIC.forEach { + invalidate.add(MediaSessionUID.CategoryItem(it).toString()) } userLibrary.playlists.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate[id] = it.songs.size + invalidate.add(id) } - invalidateSearch = true } if (invalidate.isNotEmpty()) { - invalidator?.invalidate(invalidate) - } - - if (invalidateSearch) { - for (entry in searchResults.entries) { - searchResults[entry.key]?.cancel() - } - searchResults.clear() - - for (entry in searchSubscribers.entries) { - if (searchResults[entry.value] != null) { - continue - } - searchResults[entry.value] = searchTo(entry.value) - } + invalidator?.invalidateMusic(invalidate) } } - val root: MediaItem - get() = MediaSessionUID.Category.ROOT.toMediaItem(context) - fun getItem(mediaId: String): MediaItem? { val music = when (val uid = MediaSessionUID.fromString(mediaId)) { - is MediaSessionUID.Category -> return uid.toMediaItem(context) + is MediaSessionUID.CategoryItem -> return uid.category.toMediaItem(context) is MediaSessionUID.SingleItem -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } @@ -158,15 +129,14 @@ constructor( } } - fun getChildren(parentId: String, page: Int, pageSize: Int): List? { + fun getChildren(parentId: String): List? { val deviceLibrary = musicRepository.deviceLibrary val userLibrary = musicRepository.userLibrary if (deviceLibrary == null || userLibrary == null) { return listOf() } - val items = getMediaItemList(parentId, deviceLibrary, userLibrary) ?: return null - return items.paginate(page, pageSize) + return getMediaItemList(parentId, deviceLibrary, userLibrary) } private fun getMediaItemList( @@ -175,32 +145,34 @@ constructor( userLibrary: UserLibrary ): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { - is MediaSessionUID.Category -> { - when (mediaSessionUID) { - MediaSessionUID.Category.ROOT -> - MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } + is MediaSessionUID.CategoryItem -> { + when (mediaSessionUID.category) { + Category.ROOT -> + Category.IMPORTANT.map { it.toMediaItem(context) } + + Category.MORE -> TODO() - MediaSessionUID.Category.SONGS -> + Category.SONGS -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } - MediaSessionUID.Category.ALBUMS -> + Category.ALBUMS -> listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } - MediaSessionUID.Category.ARTISTS -> + Category.ARTISTS -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } - MediaSessionUID.Category.GENRES -> + Category.GENRES -> listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } - MediaSessionUID.Category.PLAYLISTS -> + Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } } } @@ -223,25 +195,25 @@ constructor( return when (val item = musicRepository.find(uid)) { is Album -> { val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs))} } is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + - songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + albums.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + + songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } } is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + - songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } + artists.map { it.toMediaItem(context, header(R.string.lbl_songs)) } + + songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } } is Playlist -> { - item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } + item.songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } } is Song, @@ -249,121 +221,91 @@ constructor( } } - private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { - val oldExtras = mediaMetadata.extras ?: Bundle() - val newExtras = - Bundle(oldExtras).apply { - putString( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, - context.getString(res) - ) - } - return buildUpon() - .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) - .build() - } - - private fun getCategorySize( - category: MediaSessionUID.Category, - musicRepository: MusicRepository - ): Int { - val deviceLibrary = musicRepository.deviceLibrary ?: return 0 - val userLibrary = musicRepository.userLibrary ?: return 0 - return when (category) { - MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.size - MediaSessionUID.Category.SONGS -> deviceLibrary.songs.size - MediaSessionUID.Category.ALBUMS -> deviceLibrary.albums.size - MediaSessionUID.Category.ARTISTS -> deviceLibrary.artists.size - MediaSessionUID.Category.GENRES -> deviceLibrary.genres.size - MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.size - } - } - - suspend fun prepareSearch(query: String, controller: ControllerInfo) { - searchSubscribers[controller] = query - val existing = searchResults[query] - if (existing == null) { - val new = searchTo(query) - searchResults[query] = new - new.await() - } else { - val items = existing.await() - invalidator?.invalidate(controller, query, items.count()) - } - } - - suspend fun getSearchResult( - query: String, - page: Int, - pageSize: Int, - ): List? { - val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } - return deferred.await().concat().paginate(page, pageSize) - } - - private fun SearchEngine.Items.concat(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context) }) - } - return music - } - - private fun SearchEngine.Items.count(): Int { - var count = 0 - if (songs != null) { - count += songs.size - } - if (albums != null) { - count += albums.size - } - if (artists != null) { - count += artists.size - } - if (genres != null) { - count += genres.size - } - if (playlists != null) { - count += playlists.size - } - return count - } - - private fun searchTo(query: String) = - searchScope.async { - if (query.isEmpty()) { - return@async SearchEngine.Items() - } - val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() - val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists - ) - val results = searchEngine.search(items, query) - for (entry in searchSubscribers.entries) { - if (entry.value == query) { - invalidator?.invalidate(entry.key, query, results.count()) - } - } - results - } +// suspend fun prepareSearch(query: String, controller: String) { +// searchSubscribers[controller] = query +// val existing = searchResults[query] +// if (existing == null) { +// val new = searchTo(query) +// searchResults[query] = new +// new.await() +// } else { +// val items = existing.await() +// invalidator?.invalidate(controller, query, items.count()) +// } +// } +// +// suspend fun getSearchResult( +// query: String, +// page: Int, +// pageSize: Int, +// ): List? { +// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } +// return deferred.await().concat().paginate(page, pageSize) +// } +// +// private fun SearchEngine.Items.concat(): MutableList { +// val music = mutableListOf() +// if (songs != null) { +// music.addAll(songs.map { it.toMediaItem(context, null) }) +// } +// if (albums != null) { +// music.addAll(albums.map { it.toMediaItem(context) }) +// } +// if (artists != null) { +// music.addAll(artists.map { it.toMediaItem(context) }) +// } +// if (genres != null) { +// music.addAll(genres.map { it.toMediaItem(context) }) +// } +// if (playlists != null) { +// music.addAll(playlists.map { it.toMediaItem(context) }) +// } +// return music +// } +// +// private fun SearchEngine.Items.count(): Int { +// var count = 0 +// if (songs != null) { +// count += songs.size +// } +// if (albums != null) { +// count += albums.size +// } +// if (artists != null) { +// count += artists.size +// } +// if (genres != null) { +// count += genres.size +// } +// if (playlists != null) { +// count += playlists.size +// } +// return count +// } +// +// private fun searchTo(query: String) = +// searchScope.async { +// if (query.isEmpty()) { +// return@async SearchEngine.Items() +// } +// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() +// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() +// val items = +// SearchEngine.Items( +// deviceLibrary.songs, +// deviceLibrary.albums, +// deviceLibrary.artists, +// deviceLibrary.genres, +// userLibrary.playlists +// ) +// val results = searchEngine.search(items, query) +// for (entry in searchSubscribers.entries) { +// if (entry.value == query) { +// invalidator?.invalidate(entry.key, query, results.count()) +// } +// } +// results +// } private fun List.paginate(page: Int, pageSize: Int): List? { if (page == Int.MAX_VALUE) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 4616daf138..ae7f8a3ff3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -19,7 +19,12 @@ package org.oxycblt.auxio.music.service import android.content.Context +import android.os.Bundle import android.os.PowerManager +import androidx.media.MediaBrowserServiceCompat.BrowserRoot +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import android.support.v4.media.MediaBrowserCompat.MediaItem import coil.ImageLoader import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -35,54 +40,65 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logW class MusicServiceFragment @Inject constructor( - @ApplicationContext override val workerContext: Context, - private val playbackManager: PlaybackStateManager, + @ApplicationContext context: Context, + private val indexer: Indexer, + private val browser: MusicBrowser, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, private val contentObserver: SystemContentObserver, - private val imageLoader: ImageLoader -) : - MusicRepository.IndexingWorker, - MusicRepository.IndexingListener, - MusicRepository.UpdateListener, - MusicSettings.Listener { - private val indexJob = Job() - private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) - private var currentIndexJob: Job? = null - private val indexingNotification = IndexingNotification(workerContext) - private val observingNotification = ObservingNotification(workerContext) +) : MusicBrowser.Invalidator, MusicSettings.Listener { + private val indexingNotification = IndexingNotification(context) + private val observingNotification = ObservingNotification(context) + private var invalidator: Invalidator? = null private var foregroundListener: ForegroundListener? = null - private val wakeLock = - workerContext - .getSystemServiceCompat(PowerManager::class) - .newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") - fun attach(listener: ForegroundListener) { - foregroundListener = listener - musicSettings.registerListener(this) - musicRepository.addUpdateListener(this) - musicRepository.addIndexingListener(this) - musicRepository.registerWorker(this) + interface Invalidator { + fun invalidateMusic(mediaId: String) + } + + fun attach(foregroundListener: ForegroundListener, invalidator: Invalidator) { + this.invalidator = invalidator + indexer.attach(foregroundListener) + browser.attach(this) contentObserver.attach() + musicSettings.registerListener(this) } fun release() { + musicSettings.unregisterListener(this) contentObserver.release() - musicSettings.registerListener(this) - musicRepository.addIndexingListener(this) - musicRepository.addUpdateListener(this) - musicRepository.removeIndexingListener(this) - foregroundListener = null + browser.release() + indexer.release() + invalidator = null + } + + + override fun invalidateMusic(ids: Set) { + ids.forEach { mediaId -> + requireNotNull(invalidator) { "Invalidator not available" }.invalidateMusic(mediaId) + } + } + + override fun onObservingChanged() { + super.onObservingChanged() + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (musicRepository.indexingState == null) { + logD("Not loading, updating idle session") + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + } } fun start() { if (musicRepository.indexingState == null) { - requestIndex(true) + musicRepository.requestIndex(true) } } @@ -108,84 +124,24 @@ constructor( } } - override fun requestIndex(withCache: Boolean) { - logD("Starting new indexing job (previous=${currentIndexJob?.hashCode()})") - // Cancel the previous music loading job. - currentIndexJob?.cancel() - // Start a new music loading job on a co-routine. - currentIndexJob = musicRepository.index(this, withCache) - } + fun getRoot() = BrowserRoot(Category.ROOT.id, null) - override val scope = indexScope + fun getItem(mediaId: String, result: MediaBrowserServiceCompat.Result) = + result.dispatch { browser.getItem(mediaId) } - override fun onIndexingStateChanged() { - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) - val state = musicRepository.indexingState - if (state is IndexingState.Indexing) { - wakeLock.acquireSafe() - } else { - wakeLock.releaseSafe() - } - } - - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary ?: return - logD("Music changed, updating shared objects") - // Wipe possibly-invalidated outdated covers - imageLoader.memoryCache?.clear() - // Clear invalid models from PlaybackStateManager. This is not connected - // to a listener as it is bad practice for a shared object to attach to - // the listener system of another. - playbackManager.toSavedState()?.let { savedState -> - playbackManager.applySavedState( - savedState.copy( - heap = - savedState.heap.map { song -> - song?.let { deviceLibrary.findSong(it.uid) } - }), - true) - } - } - - override fun onIndexingSettingChanged() { - super.onIndexingSettingChanged() - musicRepository.requestIndex(true) - } - - override fun onObservingChanged() { - super.onObservingChanged() - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (currentIndexJob == null) { - logD("Not loading, updating idle session") - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) - } - } + fun getChildren(mediaId: String, result: MediaBrowserServiceCompat.Result>) = + result.dispatch { browser.getChildren(mediaId)?.toMutableList() } - /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.acquireSafe() { - // Avoid unnecessary acquire calls. - if (!wakeLock.isHeld) { - logD("Acquiring wake lock") - // Time out after a minute, which is the average music loading time for a medium-sized - // library. If this runs out, we will re-request the lock, and if music loading is - // shorter than the timeout, it will be released early. - acquire(WAKELOCK_TIMEOUT_MS) - } - } - - /** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */ - private fun PowerManager.WakeLock.releaseSafe() { - // Avoid unnecessary release calls. - if (wakeLock.isHeld) { - logD("Releasing wake lock") - release() + private fun MediaBrowserServiceCompat.Result.dispatch(body: () -> T?) { + try { + val result = body() + if (result == null) { + logW("Result is null") + } + sendResult(result) + } catch (e: Exception) { + logD("Error while dispatching: $e") + sendResult(null) } } - - companion object { - const val WAKELOCK_TIMEOUT_MS = 60 * 1000L - } } From 924e3d1801311e7031bd96ac6b467383824c1d3e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:46:44 -0600 Subject: [PATCH 05/66] music: re-add search browsing --- .../java/org/oxycblt/auxio/AuxioService.kt | 2 +- .../auxio/music/service/MusicBrowser.kt | 15 --- .../music/service/MusicServiceFragment.kt | 93 +++++++++++++++---- .../org/oxycblt/auxio/search/SearchEngine.kt | 4 +- 4 files changed, 79 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index b1a9da89ba..c93006f18a 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -105,7 +105,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServi extras: Bundle?, result: Result> ) { - super.onSearch(query, extras, result) + musicFragment.search(query, result) } @SuppressLint("RestrictedApi") diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 542095a90f..5a2a37c05e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -307,21 +307,6 @@ constructor( // results // } - private fun List.paginate(page: Int, pageSize: Int): List? { - if (page == Int.MAX_VALUE) { - // I think if someone requests this page it more or less implies that I should - // return all of the pages. - return this - } - val start = page * pageSize - val end = min((page + 1) * pageSize, size) // Tolerate partial page queries - if (pageSize == 0 || start !in indices) { - // These pages are probably invalid. Hopefully this won't backfire. - return null - } - return subList(start, end).toMutableList() - } - private companion object { // TODO: Rely on detail item gen logic? val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index ae7f8a3ff3..9dca63e646 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -15,47 +15,45 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.service import android.content.Context -import android.os.Bundle -import android.os.PowerManager -import androidx.media.MediaBrowserServiceCompat.BrowserRoot -import androidx.media.MediaBrowserServiceCompat -import androidx.media.utils.MediaConstants import android.support.v4.media.MediaBrowserCompat.MediaItem -import coil.ImageLoader +import androidx.media.MediaBrowserServiceCompat +import androidx.media.MediaBrowserServiceCompat.BrowserRoot import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import org.oxycblt.auxio.BuildConfig +import kotlinx.coroutines.launch import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.search.SearchEngine import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW +import javax.inject.Inject class MusicServiceFragment @Inject constructor( - @ApplicationContext context: Context, + @ApplicationContext private val context: Context, private val indexer: Indexer, - private val browser: MusicBrowser, + private val musicBrowser: MusicBrowser, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, + private val searchEngine: SearchEngine, private val contentObserver: SystemContentObserver, ) : MusicBrowser.Invalidator, MusicSettings.Listener { private val indexingNotification = IndexingNotification(context) private val observingNotification = ObservingNotification(context) private var invalidator: Invalidator? = null private var foregroundListener: ForegroundListener? = null + private val dispatchJob = Job() + private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) interface Invalidator { fun invalidateMusic(mediaId: String) @@ -64,7 +62,7 @@ constructor( fun attach(foregroundListener: ForegroundListener, invalidator: Invalidator) { this.invalidator = invalidator indexer.attach(foregroundListener) - browser.attach(this) + musicBrowser.attach(this) contentObserver.attach() musicSettings.registerListener(this) } @@ -72,7 +70,8 @@ constructor( fun release() { musicSettings.unregisterListener(this) contentObserver.release() - browser.release() + dispatchJob.cancel() + musicBrowser.release() indexer.release() invalidator = null } @@ -127,10 +126,53 @@ constructor( fun getRoot() = BrowserRoot(Category.ROOT.id, null) fun getItem(mediaId: String, result: MediaBrowserServiceCompat.Result) = - result.dispatch { browser.getItem(mediaId) } + result.dispatch { musicBrowser.getItem(mediaId) } + + fun getChildren( + mediaId: String, + result: MediaBrowserServiceCompat.Result> + ) = + result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } + + fun search(query: String, result: MediaBrowserServiceCompat.Result>) = + result.dispatchAsync { + if (query.isEmpty()) { + return@dispatchAsync mutableListOf() + } + val deviceLibrary = + musicRepository.deviceLibrary ?: return@dispatchAsync mutableListOf() + val userLibrary = musicRepository.userLibrary ?: return@dispatchAsync mutableListOf() + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists + ) + searchEngine.search(items, query).concat() + } - fun getChildren(mediaId: String, result: MediaBrowserServiceCompat.Result>) = - result.dispatch { browser.getChildren(mediaId)?.toMutableList() } + + private fun SearchEngine.Items.concat(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context) }) + } + return music + } private fun MediaBrowserServiceCompat.Result.dispatch(body: () -> T?) { try { @@ -144,4 +186,19 @@ constructor( sendResult(null) } } + + private fun MediaBrowserServiceCompat.Result.dispatchAsync(body: suspend () -> T?) { + dispatchScope.launch { + try { + val result = body() + if (result == null) { + logW("Result is null") + } + sendResult(result) + } catch (e: Exception) { + logD("Error while dispatching: $e") + sendResult(null) + } + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index 7853bcca33..3c8565f684 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -61,7 +61,9 @@ interface SearchEngine { val artists: Collection? = null, val genres: Collection? = null, val playlists: Collection? = null - ) + ) { + + } } class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) : From f0dda6c43ee5d1c4738a43d45def7a29fdbd1544 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:48:56 -0600 Subject: [PATCH 06/66] all: cleanup --- .../java/org/oxycblt/auxio/AuxioService.kt | 16 +- .../oxycblt/auxio/music/service/Indexer.kt | 3 +- .../music/service/MediaItemTranslation.kt | 160 ++++++++++-------- .../auxio/music/service/MusicBrowser.kt | 127 +------------- .../music/service/MusicServiceFragment.kt | 14 +- .../service/ExoPlaybackStateHolder.kt | 14 +- .../playback/service/MediaSessionHolder.kt | 34 ++-- .../service/PlaybackServiceFragment.kt | 4 +- .../org/oxycblt/auxio/search/SearchEngine.kt | 4 +- 9 files changed, 134 insertions(+), 242 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index c93006f18a..09d6fd3793 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -36,7 +36,8 @@ import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint -class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { +class AuxioService : + MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { @Inject lateinit var playbackFragment: PlaybackServiceFragment @Inject lateinit var musicFragment: MusicServiceFragment @@ -88,10 +89,8 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServi musicFragment.getItem(itemId, result) } - override fun onLoadChildren( - parentId: String, - result: Result> - ) = throw NotImplementedError() + override fun onLoadChildren(parentId: String, result: Result>) = + throw NotImplementedError() override fun onLoadChildren( parentId: String, @@ -99,12 +98,7 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServi options: Bundle ) = musicFragment.getChildren(parentId, result) - - override fun onSearch( - query: String, - extras: Bundle?, - result: Result> - ) { + override fun onSearch(query: String, extras: Bundle?, result: Result>) { musicFragment.search(query, result) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index 398afdbb57..427f653ea7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * IndexerServiceFragment.kt is part of Auxio. + * Indexer.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -28,7 +28,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener -import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index afe6c3eda1..1fc825185c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -78,16 +78,17 @@ sealed interface MediaSessionUID { } return when (parts[0]) { ID_CATEGORY -> - CategoryItem(when (parts[1]) { - Category.ROOT.id -> Category.ROOT - Category.MORE.id -> Category.MORE - Category.SONGS.id -> Category.SONGS - Category.ALBUMS.id -> Category.ALBUMS - Category.ARTISTS.id -> Category.ARTISTS - Category.GENRES.id -> Category.GENRES - Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> return null - }) + CategoryItem( + when (parts[1]) { + Category.ROOT.id -> Category.ROOT + Category.MORE.id -> Category.MORE + Category.SONGS.id -> Category.SONGS + Category.ALBUMS.id -> Category.ALBUMS + Category.ARTISTS.id -> Category.ARTISTS + Category.GENRES.id -> Category.GENRES + Category.PLAYLISTS.id -> Category.PLAYLISTS + else -> return null + }) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { @@ -107,7 +108,8 @@ sealed interface MediaSessionUID { typealias Sugar = Bundle.(Context) -> Unit fun header(@StringRes nameRes: Int): Sugar = { - putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) } fun Category.toMediaItem(context: Context): MediaItem { @@ -119,10 +121,11 @@ fun Category.toMediaItem(context: Context): MediaItem { MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) } val mediaSessionUID = MediaSessionUID.CategoryItem(this) - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(context.getString(nameRes)) - .setExtras(extras) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(context.getString(nameRes)) + .setExtras(extras) if (bitmapRes != null) { val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) description.setIconBitmap(bitmap) @@ -130,7 +133,11 @@ fun Category.toMediaItem(context: Context): MediaItem { return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem { +fun Song.toMediaItem( + context: Context, + parent: MusicParent? = null, + vararg sugar: Sugar +): MediaItem { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) @@ -138,88 +145,97 @@ fun Song.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar MediaSessionUID.ChildItem(parent.uid, uid) } val extras = Bundle().apply { sugar.forEach { this.it(context) } } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setDescription(album.name.resolve(context)) - .setIconUri(album.cover.single.mediaStoreCoverUri) - .setMediaUri(uri) - .setExtras(extras) - .build() + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(album.cover.single.mediaStoreCoverUri) + .setMediaUri(uri) + .setExtras(extras) + .build() return MediaItem(description, MediaItem.FLAG_PLAYABLE) } -fun Album.toMediaItem(context: Context, parent: MusicParent? = null, vararg sugar: Sugar): MediaItem { +fun Album.toMediaItem( + context: Context, + parent: MusicParent? = null, + vararg sugar: Sugar +): MediaItem { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setIconUri(cover.single.mediaStoreCoverUri) - .build() + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = - context.getString( - R.string.fmt_two, - if (explicitAlbums.isNotEmpty()) { - context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) - } else { - context.getString(R.string.def_album_count) - }, - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - }) - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(counts) - .setIconUri(cover.single.mediaStoreCoverUri) - .build() + context.getString( + R.string.fmt_two, + if (explicitAlbums.isNotEmpty()) { + context.getPlural(R.plurals.fmt_album_count, explicitAlbums.size) + } else { + context.getString(R.string.def_album_count) + }, + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + }) + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(counts) - .setIconUri(cover.single.mediaStoreCoverUri) - .build() + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover.single.mediaStoreCoverUri) + .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { val mediaSessionUID = MediaSessionUID.SingleItem(uid) val counts = - if (songs.isNotEmpty()) { - context.getPlural(R.plurals.fmt_song_count, songs.size) - } else { - context.getString(R.string.def_song_count) - } - val description = MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(counts) - .setIconUri(cover?.single?.mediaStoreCoverUri) - .build() + if (songs.isNotEmpty()) { + context.getPlural(R.plurals.fmt_song_count, songs.size) + } else { + context.getString(R.string.def_song_count) + } + val description = + MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(counts) + .setIconUri(cover?.single?.mediaStoreCoverUri) + .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 5a2a37c05e..7391632fda 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -15,20 +15,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.service import android.content.Context -import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem -import androidx.annotation.StringRes -import androidx.media.utils.MediaConstants import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async +import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.sort.Sort @@ -41,9 +34,6 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.user.UserLibrary -import org.oxycblt.auxio.search.SearchEngine -import javax.inject.Inject -import kotlin.math.min class MusicBrowser @Inject @@ -112,10 +102,8 @@ constructor( is MediaSessionUID.CategoryItem -> return uid.category.toMediaItem(context) is MediaSessionUID.SingleItem -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.ChildItem -> musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - null -> null } ?: return null @@ -147,44 +135,33 @@ constructor( return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.CategoryItem -> { when (mediaSessionUID.category) { - Category.ROOT -> - Category.IMPORTANT.map { it.toMediaItem(context) } - + Category.ROOT -> Category.IMPORTANT.map { it.toMediaItem(context) } Category.MORE -> TODO() - Category.SONGS -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } - Category.ALBUMS -> listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } - Category.ARTISTS -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } - Category.GENRES -> listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } - - Category.PLAYLISTS -> - userLibrary.playlists.map { it.toMediaItem(context) } + Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } } } - is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) } - is MediaSessionUID.ChildItem -> { getChildMediaItems(mediaSessionUID.childUid) } - null -> { return null } @@ -195,118 +172,28 @@ constructor( return when (val item = musicRepository.find(uid)) { is Album -> { val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs))} + songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } } - is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) albums.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + - songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } + songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } } - is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) artists.map { it.toMediaItem(context, header(R.string.lbl_songs)) } + - songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } } - is Playlist -> { item.songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } } - is Song, null -> return null } } -// suspend fun prepareSearch(query: String, controller: String) { -// searchSubscribers[controller] = query -// val existing = searchResults[query] -// if (existing == null) { -// val new = searchTo(query) -// searchResults[query] = new -// new.await() -// } else { -// val items = existing.await() -// invalidator?.invalidate(controller, query, items.count()) -// } -// } -// -// suspend fun getSearchResult( -// query: String, -// page: Int, -// pageSize: Int, -// ): List? { -// val deferred = searchResults[query] ?: searchTo(query).also { searchResults[query] = it } -// return deferred.await().concat().paginate(page, pageSize) -// } -// -// private fun SearchEngine.Items.concat(): MutableList { -// val music = mutableListOf() -// if (songs != null) { -// music.addAll(songs.map { it.toMediaItem(context, null) }) -// } -// if (albums != null) { -// music.addAll(albums.map { it.toMediaItem(context) }) -// } -// if (artists != null) { -// music.addAll(artists.map { it.toMediaItem(context) }) -// } -// if (genres != null) { -// music.addAll(genres.map { it.toMediaItem(context) }) -// } -// if (playlists != null) { -// music.addAll(playlists.map { it.toMediaItem(context) }) -// } -// return music -// } -// -// private fun SearchEngine.Items.count(): Int { -// var count = 0 -// if (songs != null) { -// count += songs.size -// } -// if (albums != null) { -// count += albums.size -// } -// if (artists != null) { -// count += artists.size -// } -// if (genres != null) { -// count += genres.size -// } -// if (playlists != null) { -// count += playlists.size -// } -// return count -// } -// -// private fun searchTo(query: String) = -// searchScope.async { -// if (query.isEmpty()) { -// return@async SearchEngine.Items() -// } -// val deviceLibrary = musicRepository.deviceLibrary ?: return@async SearchEngine.Items() -// val userLibrary = musicRepository.userLibrary ?: return@async SearchEngine.Items() -// val items = -// SearchEngine.Items( -// deviceLibrary.songs, -// deviceLibrary.albums, -// deviceLibrary.artists, -// deviceLibrary.genres, -// userLibrary.playlists -// ) -// val results = searchEngine.search(items, query) -// for (entry in searchSubscribers.entries) { -// if (entry.value == query) { -// invalidator?.invalidate(entry.key, query, results.count()) -// } -// } -// results -// } - private companion object { // TODO: Rely on detail item gen logic? val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 9dca63e646..92ee54e60f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * IndexerServiceFragment.kt is part of Auxio. + * MusicServiceFragment.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.service import android.content.Context @@ -23,6 +23,7 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat.BrowserRoot import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -35,7 +36,6 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.search.SearchEngine import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW -import javax.inject.Inject class MusicServiceFragment @Inject @@ -76,7 +76,6 @@ constructor( invalidator = null } - override fun invalidateMusic(ids: Set) { ids.forEach { mediaId -> requireNotNull(invalidator) { "Invalidator not available" }.invalidateMusic(mediaId) @@ -131,8 +130,7 @@ constructor( fun getChildren( mediaId: String, result: MediaBrowserServiceCompat.Result> - ) = - result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } + ) = result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } fun search(query: String, result: MediaBrowserServiceCompat.Result>) = result.dispatchAsync { @@ -148,12 +146,10 @@ constructor( deviceLibrary.albums, deviceLibrary.artists, deviceLibrary.genres, - userLibrary.playlists - ) + userLibrary.playlists) searchEngine.search(items, query).concat() } - private fun SearchEngine.Items.concat(): MutableList { val music = mutableListOf() if (songs != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 1abc6bfb39..203176ebdc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -46,7 +46,6 @@ import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.service.toMediaItem import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -141,10 +140,7 @@ class ExoPlaybackStateHolder( } else { emptyList() } - return RawQueue( - heap.mapNotNull { it.song }, - shuffledMapping, - player.currentMediaItemIndex) + return RawQueue(heap.mapNotNull { it.song }, shuffledMapping, player.currentMediaItemIndex) } override fun handleDeferred(action: DeferredPlayback): Boolean { @@ -533,12 +529,10 @@ class ExoPlaybackStateHolder( currentSaveJob = saveScope.launch { block() } } - private fun Song.buildMediaItem() = MediaItem.Builder() - .setUri(uri) - .setTag(this) - .build() + private fun Song.buildMediaItem() = MediaItem.Builder().setUri(uri).setTag(this).build() - private val MediaItem.song: Song? get() = this.localConfiguration?.tag as? Song? + private val MediaItem.song: Song? + get() = this.localConfiguration?.tag as? Song? private fun Player.unscrambleQueueIndices(): List { val timeline = currentTimeline diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 63c281080d..1cecbf9380 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -88,14 +88,20 @@ private constructor( constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, - private val commandFactory: PlaybackCommand.Factory, + private val commandFactory: PlaybackCommand.Factory, private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) { fun create(context: Context) = MediaSessionHolder( - context, playbackManager, playbackSettings, commandFactory, musicRepository, bitmapProvider, imageSettings) + context, + playbackManager, + playbackSettings, + commandFactory, + musicRepository, + bitmapProvider, + imageSettings) } private val mediaSession = @@ -234,11 +240,13 @@ private constructor( super.onAddQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } ?: return + val song = + when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } + ?: return playbackManager.addToQueue(song) } @@ -246,11 +254,13 @@ private constructor( super.onRemoveQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } ?: return + val song = + when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } + ?: return val queueIndex = playbackManager.queue.indexOf(song) if (queueIndex > -1) { playbackManager.removeQueueItem(queueIndex) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 26c1eea01a..b6ca0d721e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * MediaSessionServiceFragment.kt is part of Auxio. + * PlaybackServiceFragment.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -109,8 +109,6 @@ constructor( foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - - // override fun onGetLibraryRoot( // session: MediaLibrarySession, // browser: MediaSession.ControllerInfo, diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt index 3c8565f684..32c6da8ded 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -61,9 +61,7 @@ interface SearchEngine { val artists: Collection? = null, val genres: Collection? = null, val playlists: Collection? = null - ) { - - } + ) {} } class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) : From 35646d6a2dc093f126dee88e256cbd2b6277a1fe Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:50:54 -0600 Subject: [PATCH 07/66] playback: re-add headers to search --- .../auxio/music/service/MusicServiceFragment.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 92ee54e60f..80c374b0eb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -147,25 +148,25 @@ constructor( deviceLibrary.artists, deviceLibrary.genres, userLibrary.playlists) - searchEngine.search(items, query).concat() + searchEngine.search(items, query).toMediaItems() } - private fun SearchEngine.Items.concat(): MutableList { + private fun SearchEngine.Items.toMediaItems(): MutableList { val music = mutableListOf() if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null) }) + music.addAll(songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }) } if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context) }) + music.addAll(albums.map { it.toMediaItem(context, null, header(R.string.lbl_albums)) }) } if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context) }) + music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) } if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context) }) + music.addAll(genres.map { it.toMediaItem(context, header(R.string.lbl_genres)) }) } if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context) }) + music.addAll(playlists.map { it.toMediaItem(context, header(R.string.lbl_playlists)) }) } return music } From f30c426c775c48adb3f881e47519cd34e6170544 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 27 Aug 2024 16:52:22 -0600 Subject: [PATCH 08/66] music: apply headers to all mediaitems --- .../oxycblt/auxio/music/service/MediaItemTranslation.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 1fc825185c..f86a539ec4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -169,12 +169,14 @@ fun Album.toMediaItem( } else { MediaSessionUID.ChildItem(parent.uid, uid) } + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(artists.resolveNames(context)) .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } @@ -194,12 +196,14 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) }) + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(counts) .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } @@ -212,6 +216,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -230,12 +235,14 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } + val extras = Bundle().apply { sugar.forEach { this.it(context) } } val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(counts) .setIconUri(cover?.single?.mediaStoreCoverUri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } From 30b3603cf1a9d6a6ec3ae52b1ed84158a1d4d491 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 08:42:14 -0600 Subject: [PATCH 09/66] music: move search/notif out of service fragment Generally cleaner this way --- .../oxycblt/auxio/music/service/Indexer.kt | 42 +++++++++- .../auxio/music/service/MusicBrowser.kt | 39 +++++++++ .../music/service/MusicServiceFragment.kt | 84 ++----------------- 3 files changed, 85 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index 427f653ea7..4618a3fb23 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.ForegroundListener +import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings @@ -42,7 +43,8 @@ constructor( private val playbackManager: PlaybackStateManager, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, - private val imageLoader: ImageLoader + private val imageLoader: ImageLoader, + private val contentObserver: SystemContentObserver ) : MusicRepository.IndexingWorker, MusicRepository.IndexingListener, @@ -52,6 +54,8 @@ constructor( private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) private var currentIndexJob: Job? = null private var foregroundListener: ForegroundListener? = null + private val indexingNotification = IndexingNotification(workerContext) + private val observingNotification = ObservingNotification(workerContext) private val wakeLock = workerContext .getSystemServiceCompat(PowerManager::class) @@ -64,9 +68,11 @@ constructor( musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) musicRepository.registerWorker(this) + contentObserver.attach() } fun release() { + contentObserver.release() musicSettings.registerListener(this) musicRepository.addIndexingListener(this) musicRepository.addUpdateListener(this) @@ -118,6 +124,40 @@ constructor( musicRepository.requestIndex(true) } + override fun onObservingChanged() { + super.onObservingChanged() + // Make sure we don't override the service state with the observing + // notification if we were actively loading when the automatic rescanning + // setting changed. In such a case, the state will still be updated when + // the music loading process ends. + if (musicRepository.indexingState == null) { + logD("Not loading, updating idle session") + foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + } + } + + fun createNotification(post: (ForegroundServiceNotification?) -> Unit) { + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + // There are a few reasons why we stay in the foreground with automatic rescanning: + // 1. Newer versions of Android have become more and more restrictive regarding + // how a foreground service starts. Thus, it's best to go foreground now so that + // we can go foreground later. + // 2. If a non-foreground service is killed, the app will probably still be alive, + // and thus the music library will not be updated at all. + val changed = indexingNotification.updateIndexingState(state.progress) + if (changed) { + post(indexingNotification) + } + } else if (musicSettings.shouldBeObserving) { + // Not observing and done loading, exit foreground. + logD("Exiting foreground") + post(observingNotification) + } else { + post(null) + } + } + /** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */ private fun PowerManager.WakeLock.acquireSafe() { // Avoid unnecessary acquire calls. diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 7391632fda..eceff47d3e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -34,12 +34,14 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.user.UserLibrary +import org.oxycblt.auxio.search.SearchEngine class MusicBrowser @Inject constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, private val listSettings: ListSettings ) : MusicRepository.UpdateListener { interface Invalidator { @@ -127,6 +129,43 @@ constructor( return getMediaItemList(parentId, deviceLibrary, userLibrary) } + suspend fun search(query: String): MutableList { + if (query.isEmpty()) { + return mutableListOf() + } + val deviceLibrary = + musicRepository.deviceLibrary ?: return mutableListOf() + val userLibrary = musicRepository.userLibrary ?: return mutableListOf() + val items = + SearchEngine.Items( + deviceLibrary.songs, + deviceLibrary.albums, + deviceLibrary.artists, + deviceLibrary.genres, + userLibrary.playlists) + return searchEngine.search(items, query).toMediaItems() + } + + private fun SearchEngine.Items.toMediaItems(): MutableList { + val music = mutableListOf() + if (songs != null) { + music.addAll(songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }) + } + if (albums != null) { + music.addAll(albums.map { it.toMediaItem(context, null, header(R.string.lbl_albums)) }) + } + if (artists != null) { + music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) + } + if (genres != null) { + music.addAll(genres.map { it.toMediaItem(context, header(R.string.lbl_genres)) }) + } + if (playlists != null) { + music.addAll(playlists.map { it.toMediaItem(context, header(R.string.lbl_playlists)) }) + } + return music + } + private fun getMediaItemList( id: String, deviceLibrary: DeviceLibrary, diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 80c374b0eb..c1acf51444 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -41,18 +41,11 @@ import org.oxycblt.auxio.util.logW class MusicServiceFragment @Inject constructor( - @ApplicationContext private val context: Context, private val indexer: Indexer, private val musicBrowser: MusicBrowser, - private val musicRepository: MusicRepository, - private val musicSettings: MusicSettings, - private val searchEngine: SearchEngine, - private val contentObserver: SystemContentObserver, -) : MusicBrowser.Invalidator, MusicSettings.Listener { - private val indexingNotification = IndexingNotification(context) - private val observingNotification = ObservingNotification(context) + private val musicRepository: MusicRepository +) : MusicBrowser.Invalidator { private var invalidator: Invalidator? = null - private var foregroundListener: ForegroundListener? = null private val dispatchJob = Job() private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) @@ -64,13 +57,9 @@ constructor( this.invalidator = invalidator indexer.attach(foregroundListener) musicBrowser.attach(this) - contentObserver.attach() - musicSettings.registerListener(this) } fun release() { - musicSettings.unregisterListener(this) - contentObserver.release() dispatchJob.cancel() musicBrowser.release() indexer.release() @@ -83,17 +72,6 @@ constructor( } } - override fun onObservingChanged() { - super.onObservingChanged() - // Make sure we don't override the service state with the observing - // notification if we were actively loading when the automatic rescanning - // setting changed. In such a case, the state will still be updated when - // the music loading process ends. - if (musicRepository.indexingState == null) { - logD("Not loading, updating idle session") - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) - } - } fun start() { if (musicRepository.indexingState == null) { @@ -102,25 +80,7 @@ constructor( } fun createNotification(post: (ForegroundServiceNotification?) -> Unit) { - val state = musicRepository.indexingState - if (state is IndexingState.Indexing) { - // There are a few reasons why we stay in the foreground with automatic rescanning: - // 1. Newer versions of Android have become more and more restrictive regarding - // how a foreground service starts. Thus, it's best to go foreground now so that - // we can go foreground later. - // 2. If a non-foreground service is killed, the app will probably still be alive, - // and thus the music library will not be updated at all. - val changed = indexingNotification.updateIndexingState(state.progress) - if (changed) { - post(indexingNotification) - } - } else if (musicSettings.shouldBeObserving) { - // Not observing and done loading, exit foreground. - logD("Exiting foreground") - post(observingNotification) - } else { - post(null) - } + indexer.createNotification(post) } fun getRoot() = BrowserRoot(Category.ROOT.id, null) @@ -134,42 +94,7 @@ constructor( ) = result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } fun search(query: String, result: MediaBrowserServiceCompat.Result>) = - result.dispatchAsync { - if (query.isEmpty()) { - return@dispatchAsync mutableListOf() - } - val deviceLibrary = - musicRepository.deviceLibrary ?: return@dispatchAsync mutableListOf() - val userLibrary = musicRepository.userLibrary ?: return@dispatchAsync mutableListOf() - val items = - SearchEngine.Items( - deviceLibrary.songs, - deviceLibrary.albums, - deviceLibrary.artists, - deviceLibrary.genres, - userLibrary.playlists) - searchEngine.search(items, query).toMediaItems() - } - - private fun SearchEngine.Items.toMediaItems(): MutableList { - val music = mutableListOf() - if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }) - } - if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context, null, header(R.string.lbl_albums)) }) - } - if (artists != null) { - music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) - } - if (genres != null) { - music.addAll(genres.map { it.toMediaItem(context, header(R.string.lbl_genres)) }) - } - if (playlists != null) { - music.addAll(playlists.map { it.toMediaItem(context, header(R.string.lbl_playlists)) }) - } - return music - } + result.dispatchAsync { musicBrowser.search(query) } private fun MediaBrowserServiceCompat.Result.dispatch(body: () -> T?) { try { @@ -187,6 +112,7 @@ constructor( private fun MediaBrowserServiceCompat.Result.dispatchAsync(body: suspend () -> T?) { dispatchScope.launch { try { + detach() val result = body() if (result == null) { logW("Result is null") From 66c31f431807bee181db1ddbd77367fb84608235 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 09:21:03 -0600 Subject: [PATCH 10/66] playback: apply missing extras --- .../java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index f86a539ec4..e0354ba3a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -223,6 +223,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { .setTitle(name.resolve(context)) .setSubtitle(counts) .setIconUri(cover.single.mediaStoreCoverUri) + .setExtras(extras) .build() return MediaItem(description, MediaItem.FLAG_BROWSABLE) } From e23ac33b855db277b27a10aeb3fe8d42af6d695f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 09:21:23 -0600 Subject: [PATCH 11/66] music: reformat --- .../auxio/music/service/MusicBrowser.kt | 3 +-- .../music/service/MusicServiceFragment.kt | 19 ++++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index eceff47d3e..a2008b0ccd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -133,8 +133,7 @@ constructor( if (query.isEmpty()) { return mutableListOf() } - val deviceLibrary = - musicRepository.deviceLibrary ?: return mutableListOf() + val deviceLibrary = musicRepository.deviceLibrary ?: return mutableListOf() val userLibrary = musicRepository.userLibrary ?: return mutableListOf() val items = SearchEngine.Items( diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index c1acf51444..eb37dd0ec7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -18,11 +18,9 @@ package org.oxycblt.auxio.music.service -import android.content.Context import android.support.v4.media.MediaBrowserCompat.MediaItem -import androidx.media.MediaBrowserServiceCompat +import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.MediaBrowserServiceCompat.BrowserRoot -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -30,11 +28,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification -import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.search.SearchEngine import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -72,7 +66,6 @@ constructor( } } - fun start() { if (musicRepository.indexingState == null) { musicRepository.requestIndex(true) @@ -85,18 +78,18 @@ constructor( fun getRoot() = BrowserRoot(Category.ROOT.id, null) - fun getItem(mediaId: String, result: MediaBrowserServiceCompat.Result) = + fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } fun getChildren( mediaId: String, - result: MediaBrowserServiceCompat.Result> + result: Result> ) = result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } - fun search(query: String, result: MediaBrowserServiceCompat.Result>) = + fun search(query: String, result: Result>) = result.dispatchAsync { musicBrowser.search(query) } - private fun MediaBrowserServiceCompat.Result.dispatch(body: () -> T?) { + private fun Result.dispatch(body: () -> T?) { try { val result = body() if (result == null) { @@ -109,7 +102,7 @@ constructor( } } - private fun MediaBrowserServiceCompat.Result.dispatchAsync(body: suspend () -> T?) { + private fun Result.dispatchAsync(body: suspend () -> T?) { dispatchScope.launch { try { detach() From f1e1152e21b7c02d8256db4b4584f7ccb01670fc Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 10:11:30 -0600 Subject: [PATCH 12/66] music: make compat more menu This way we can make sure that external providers never truncate our MediaItem count. --- .../java/org/oxycblt/auxio/AuxioService.kt | 8 +- .../oxycblt/auxio/music/service/Category.kt | 94 +++++++++++++++++++ .../music/service/MediaItemTranslation.kt | 34 +------ .../auxio/music/service/MusicBrowser.kt | 54 ++++++----- .../music/service/MusicServiceFragment.kt | 3 +- 5 files changed, 139 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/Category.kt diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 09d6fd3793..9c9ddd595b 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -30,6 +30,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.music.service.MusicServiceFragment @@ -83,7 +84,12 @@ class AuxioService : clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot = musicFragment.getRoot() + ): BrowserRoot { + val maximumRootChildLimit = + rootHints?.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 + return musicFragment.getRoot(maximumRootChildLimit) + } override fun onLoadItem(itemId: String, result: Result) { musicFragment.getItem(itemId, result) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt new file mode 100644 index 0000000000..c2babde295 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt @@ -0,0 +1,94 @@ +package org.oxycblt.auxio.music.service + +import org.oxycblt.auxio.R + +sealed interface Category { + val id: String + val nameRes: Int + val bitmapRes: Int? + + data class Root(val amount: Int) : Category { + override val id = "root/$amount" + override val nameRes = R.string.info_app_name + override val bitmapRes = null + + companion object { + const val ID_PREFIX = "root" + + fun fromString(str: String): Root? { + val split = str.split("/", limit = 2) + if (split.size != 2) { + return null + } + val limit = split[1].toIntOrNull() ?: return null + return Root(limit) + } + } + } + + data class More(val remainder: Int) : Category { + override val id = "more/$remainder" + override val nameRes = R.string.lbl_more + override val bitmapRes = null + + companion object { + const val ID_PREFIX = "more" + + fun fromString(str: String): More? { + val split = str.split("/", limit = 2) + if (split.size != 2) { + return null + } + val remainder = split[1].toIntOrNull() ?: return null + return More(remainder) + } + } + } + + data object Songs : Category { + override val id = "songs" + override val nameRes = R.string.lbl_songs + override val bitmapRes = R.drawable.ic_song_bitmap_24 + } + + data object Albums : Category { + override val id = "albums" + override val nameRes = R.string.lbl_albums + override val bitmapRes = R.drawable.ic_album_bitmap_24 + } + + data object Artists : Category { + override val id = "artists" + override val nameRes = R.string.lbl_artists + override val bitmapRes = R.drawable.ic_artist_bitmap_24 + } + + data object Genres : Category { + override val id = "genres" + override val nameRes = R.string.lbl_genres + override val bitmapRes = R.drawable.ic_genre_bitmap_24 + } + + data object Playlists : Category { + override val id = "playlists" + override val nameRes = R.string.lbl_playlists + override val bitmapRes = R.drawable.ic_playlist_bitmap_24 + } + + companion object { + val MUSIC = arrayOf(Songs, Albums, Artists, Genres, Playlists) + val DEVICE_MUSIC = arrayOf(Songs, Albums, Artists, Genres) + val USER_MUSIC = arrayOf(Playlists) + fun fromString(str: String): Category? = + when { + str.startsWith(Root.ID_PREFIX) -> Root.fromString(str) + str.startsWith(More.ID_PREFIX) -> More.fromString(str) + str == Songs.id -> Songs + str == Albums.id -> Albums + str == Artists.id -> Artists + str == Genres.id -> Genres + str == Playlists.id -> Playlists + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index e0354ba3a7..b04afbc236 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -23,7 +23,6 @@ import android.graphics.BitmapFactory import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat -import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.media.utils.MediaConstants import org.oxycblt.auxio.BuildConfig @@ -38,22 +37,6 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural -enum class Category(val id: String, @StringRes val nameRes: Int, @DrawableRes val bitmapRes: Int?) { - ROOT("root", R.string.info_app_name, null), - MORE("more", R.string.lbl_more, R.drawable.ic_more_24), - SONGS("songs", R.string.lbl_songs, R.drawable.ic_song_bitmap_24), - ALBUMS("albums", R.string.lbl_albums, R.drawable.ic_album_bitmap_24), - ARTISTS("artists", R.string.lbl_artists, R.drawable.ic_artist_bitmap_24), - GENRES("genres", R.string.lbl_genres, R.drawable.ic_genre_bitmap_24), - PLAYLISTS("playlists", R.string.lbl_playlists, R.drawable.ic_playlist_bitmap_24); - - companion object { - val DEVICE_MUSIC = listOf(ROOT, SONGS, ALBUMS, ARTISTS, GENRES) - val USER_MUSIC = listOf(ROOT, PLAYLISTS) - val IMPORTANT = listOf(SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS) - } -} - sealed interface MediaSessionUID { data class CategoryItem(val category: Category) : MediaSessionUID { override fun toString() = "$ID_CATEGORY:$category" @@ -78,17 +61,7 @@ sealed interface MediaSessionUID { } return when (parts[0]) { ID_CATEGORY -> - CategoryItem( - when (parts[1]) { - Category.ROOT.id -> Category.ROOT - Category.MORE.id -> Category.MORE - Category.SONGS.id -> Category.SONGS - Category.ALBUMS.id -> Category.ALBUMS - Category.ARTISTS.id -> Category.ARTISTS - Category.GENRES.id -> Category.GENRES - Category.PLAYLISTS.id -> Category.PLAYLISTS - else -> return null - }) + CategoryItem(Category.fromString(parts[1]) ?: return null) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { @@ -113,7 +86,6 @@ fun header(@StringRes nameRes: Int): Sugar = { } fun Category.toMediaItem(context: Context): MediaItem { - // TODO: Make custom overflow menu for compat val extras = Bundle().apply { putInt( @@ -126,8 +98,8 @@ fun Category.toMediaItem(context: Context): MediaItem { .setMediaId(mediaSessionUID.toString()) .setTitle(context.getString(nameRes)) .setExtras(extras) - if (bitmapRes != null) { - val bitmap = BitmapFactory.decodeResource(context.resources, bitmapRes) + bitmapRes?.let { res -> + val bitmap = BitmapFactory.decodeResource(context.resources, res) description.setIconBitmap(bitmap) } return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index a2008b0ccd..576454204c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -172,27 +172,7 @@ constructor( ): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.CategoryItem -> { - when (mediaSessionUID.category) { - Category.ROOT -> Category.IMPORTANT.map { it.toMediaItem(context) } - Category.MORE -> TODO() - Category.SONGS -> - listSettings.songSort.songs(deviceLibrary.songs).map { - it.toMediaItem(context, null) - } - Category.ALBUMS -> - listSettings.albumSort.albums(deviceLibrary.albums).map { - it.toMediaItem(context) - } - Category.ARTISTS -> - listSettings.artistSort.artists(deviceLibrary.artists).map { - it.toMediaItem(context) - } - Category.GENRES -> - listSettings.genreSort.genres(deviceLibrary.genres).map { - it.toMediaItem(context) - } - Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } - } + getCategoryMediaItems(mediaSessionUID.category, deviceLibrary, userLibrary) } is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) @@ -206,6 +186,38 @@ constructor( } } + private fun getCategoryMediaItems(category: Category, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) = + when (category) { + is Category.Root -> { + val base = Category.MUSIC.take(category.amount) + if (base.size < Category.MUSIC.size) { + base + Category.More(Category.MUSIC.size - base.size) + } else { + base + }.map { it.toMediaItem(context) } + } + is Category.More -> Category.MUSIC.takeLast(category.remainder).map { + it.toMediaItem(context) + } + is Category.Songs -> + listSettings.songSort.songs(deviceLibrary.songs).map { + it.toMediaItem(context, null) + } + is Category.Albums -> + listSettings.albumSort.albums(deviceLibrary.albums).map { + it.toMediaItem(context) + } + is Category.Artists -> + listSettings.artistSort.artists(deviceLibrary.artists).map { + it.toMediaItem(context) + } + is Category.Genres -> + listSettings.genreSort.genres(deviceLibrary.genres).map { + it.toMediaItem(context) + } + is Category.Playlists -> userLibrary.playlists.map { it.toMediaItem(context) } + } + private fun getChildMediaItems(uid: Music.UID): List? { return when (val item = musicRepository.find(uid)) { is Album -> { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index eb37dd0ec7..8d439b2c4e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -76,7 +76,8 @@ constructor( indexer.createNotification(post) } - fun getRoot() = BrowserRoot(Category.ROOT.id, null) + fun getRoot(maxItems: Int) = + BrowserRoot(MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), null) fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } From 916c3c46df0e0d1c9b0cda852f6370d9bc546b99 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 13:26:52 -0600 Subject: [PATCH 13/66] playback: split up mediasession interface and holder --- app/build.gradle | 3 + .../java/org/oxycblt/auxio/AuxioService.kt | 10 - .../playback/service/MediaSessionHolder.kt | 193 ++--------------- .../playback/service/MediaSessionInterface.kt | 202 ++++++++++++++++++ 4 files changed, 218 insertions(+), 190 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt diff --git a/app/build.gradle b/app/build.gradle index 2312bcb385..ac4e8f7292 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -153,6 +153,9 @@ dependencies { // Tasker integration implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10' + // Fuzzy search + implementation 'org.apache.commons:commons-text:1.9' + // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 9c9ddd595b..eb2c8dc43f 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -108,16 +108,6 @@ class AuxioService : musicFragment.search(query, result) } - @SuppressLint("RestrictedApi") - override fun onSubscribe(id: String?, option: Bundle?) { - super.onSubscribe(id, option) - } - - @SuppressLint("RestrictedApi") - override fun onUnsubscribe(id: String?) { - super.onUnsubscribe(id) - } - override fun updateForeground(change: ForegroundListener.Change) { val mediaNotification = playbackFragment.notification if (mediaNotification != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 1cecbf9380..6fcda18308 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -20,10 +20,8 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context -import android.content.Intent import android.graphics.Bitmap -import android.net.Uri -import android.os.Bundle +import android.media.session.MediaSession import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat @@ -39,25 +37,17 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.service.MediaSessionInterface import org.oxycblt.auxio.playback.service.PlaybackActions -import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange import org.oxycblt.auxio.playback.state.RepeatMode -import org.oxycblt.auxio.playback.state.ShuffleMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newBroadcastPendingIntent import org.oxycblt.auxio.util.newMainPendingIntent @@ -71,10 +61,9 @@ import org.oxycblt.auxio.util.newMainPendingIntent class MediaSessionHolder private constructor( private val context: Context, + private val sessionInterface: MediaSessionInterface, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, - private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings ) : @@ -86,29 +75,23 @@ private constructor( class Factory @Inject constructor( + private val sessionInterface: MediaSessionInterface, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, - private val commandFactory: PlaybackCommand.Factory, - private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, - private val imageSettings: ImageSettings + private val imageSettings: ImageSettings, ) { fun create(context: Context) = MediaSessionHolder( context, + sessionInterface, playbackManager, playbackSettings, - commandFactory, - musicRepository, bitmapProvider, imageSettings) } - private val mediaSession = - MediaSessionCompat(context, context.packageName).apply { - isActive = true - setQueueTitle(context.getString(R.string.lbl_queue)) - } + private val mediaSession = MediaSessionCompat(context, context.packageName) val token: MediaSessionCompat.Token get() = mediaSession.sessionToken @@ -119,6 +102,11 @@ private constructor( private var foregroundListener: ForegroundListener? = null fun attach(foregroundListener: ForegroundListener) { + mediaSession.apply { + isActive = true + setQueueTitle(context.getString(R.string.lbl_queue)) + setCallback(sessionInterface) + } this.foregroundListener = foregroundListener playbackManager.addListener(this) playbackSettings.registerListener(this) @@ -218,116 +206,6 @@ private constructor( // --- MEDIASESSION OVERRIDES --- - override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { - super.onPlayFromMediaId(mediaId, extras) - val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return - val command = expandIntoCommand(uid) - requireNotNull(command) { "Invalid playback configuration" } - playbackManager.play(command) - } - - override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { - super.onPlayFromUri(uri, extras) - // STUB - } - - override fun onPlayFromSearch(query: String?, extras: Bundle?) { - super.onPlayFromSearch(query, extras) - // STUB: Unimplemented, no search engine - } - - override fun onAddQueueItem(description: MediaDescriptionCompat) { - super.onAddQueueItem(description) - val deviceLibrary = musicRepository.deviceLibrary ?: return - val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = - when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } - ?: return - playbackManager.addToQueue(song) - } - - override fun onRemoveQueueItem(description: MediaDescriptionCompat) { - super.onRemoveQueueItem(description) - val deviceLibrary = musicRepository.deviceLibrary ?: return - val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = - when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } - ?: return - val queueIndex = playbackManager.queue.indexOf(song) - if (queueIndex > -1) { - playbackManager.removeQueueItem(queueIndex) - } - } - - override fun onPlay() { - playbackManager.playing(true) - } - - override fun onPause() { - playbackManager.playing(false) - } - - override fun onSkipToNext() { - playbackManager.next() - } - - override fun onSkipToPrevious() { - playbackManager.prev() - } - - override fun onSeekTo(position: Long) { - playbackManager.seekTo(position) - } - - override fun onFastForward() { - playbackManager.next() - } - - override fun onRewind() { - playbackManager.seekTo(0) - playbackManager.playing(true) - } - - override fun onSetRepeatMode(repeatMode: Int) { - playbackManager.repeatMode( - when (repeatMode) { - PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL - PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL - PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK - else -> RepeatMode.NONE - }) - } - - override fun onSetShuffleMode(shuffleMode: Int) { - playbackManager.shuffled( - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) - } - - override fun onSkipToQueueItem(id: Long) { - playbackManager.goto(id.toInt()) - } - - override fun onCustomAction(action: String, extras: Bundle?) { - super.onCustomAction(action, extras) - // Service already handles intents from the old notification actions, easier to - // plug into that system. - context.sendBroadcast(Intent(action)) - } - - override fun onStop() { - // Get the service to shut down with the ACTION_EXIT intent - context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) - } - // --- INTERNAL --- /** @@ -435,40 +313,6 @@ private constructor( mediaSession.setQueue(queueItems) } - private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { - val music: Music - var parent: MusicParent? = null - when (uid) { - is MediaSessionUID.SingleItem -> { - music = musicRepository.find(uid.uid) ?: return null - } - is MediaSessionUID.ChildItem -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - else -> return null - } - - return when (music) { - is Song -> inferSongFromParent(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } - } - - private fun inferSongFromParent(music: Song, parent: MusicParent?) = - when (parent) { - is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) - is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) - null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) - } - /** Invalidate the current [MediaSessionCompat]'s [PlaybackStateCompat]. */ private fun invalidateSessionState() { logD("Updating media session playback state") @@ -477,7 +321,7 @@ private constructor( // InternalPlayer.State handles position/state information. playbackManager.progression .intoPlaybackState(PlaybackStateCompat.Builder()) - .setActions(ACTIONS) + .setActions(MediaSessionInterface.ACTIONS) // Active queue ID corresponds to the indices we populated prior, use them here. .setActiveQueueItemId(playbackManager.index.toLong()) @@ -543,17 +387,6 @@ private constructor( companion object { private val emptyMetadata = MediaMetadataCompat.Builder().build() - private const val ACTIONS = - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_SET_REPEAT_MODE or - PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or - PlaybackStateCompat.ACTION_SEEK_TO or - PlaybackStateCompat.ACTION_STOP } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt new file mode 100644 index 0000000000..2b0a45769f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -0,0 +1,202 @@ +package org.oxycblt.auxio.playback.service + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.playback.state.PlaybackCommand +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.RepeatMode +import org.oxycblt.auxio.playback.state.ShuffleMode +import javax.inject.Inject + +class MediaSessionInterface @Inject constructor( + @ApplicationContext private val context: Context, + private val playbackManager: PlaybackStateManager, + private val commandFactory: PlaybackCommand.Factory, + private val musicRepository: MusicRepository, +) : MediaSessionCompat.Callback() { + + override fun onPrepareFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPrepareFromMediaId(mediaId, extras) + } + + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { + super.onPlayFromMediaId(mediaId, extras) + val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return + val command = expandIntoCommand(uid) + requireNotNull(command) { "Invalid playback configuration" } + playbackManager.play(command) + } + + override fun onPrepareFromSearch(query: String?, extras: Bundle?) { + super.onPrepareFromSearch(query, extras) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + super.onPlayFromSearch(query, extras) + // STUB: Unimplemented, no search engine + } + + override fun onPrepareFromUri(uri: Uri?, extras: Bundle?) { + super.onPrepareFromUri(uri, extras) + } + + override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { + super.onPlayFromUri(uri, extras) + // STUB + } + + override fun onAddQueueItem(description: MediaDescriptionCompat) { + super.onAddQueueItem(description) + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = + when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } + ?: return + playbackManager.addToQueue(song) + } + + override fun onRemoveQueueItem(description: MediaDescriptionCompat) { + super.onRemoveQueueItem(description) + val deviceLibrary = musicRepository.deviceLibrary ?: return + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val song = + when (uid) { + is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) + is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) + else -> null + } + ?: return + val queueIndex = playbackManager.queue.indexOf(song) + if (queueIndex > -1) { + playbackManager.removeQueueItem(queueIndex) + } + } + + override fun onPlay() { + playbackManager.playing(true) + } + + override fun onPause() { + playbackManager.playing(false) + } + + override fun onSkipToNext() { + playbackManager.next() + } + + override fun onSkipToPrevious() { + playbackManager.prev() + } + + override fun onSeekTo(position: Long) { + playbackManager.seekTo(position) + } + + override fun onFastForward() { + playbackManager.next() + } + + override fun onRewind() { + playbackManager.seekTo(0) + playbackManager.playing(true) + } + + override fun onSetRepeatMode(repeatMode: Int) { + playbackManager.repeatMode( + when (repeatMode) { + PlaybackStateCompat.REPEAT_MODE_ALL -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL + PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK + else -> RepeatMode.NONE + }) + } + + override fun onSetShuffleMode(shuffleMode: Int) { + playbackManager.shuffled( + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) + } + + override fun onSkipToQueueItem(id: Long) { + playbackManager.goto(id.toInt()) + } + + override fun onCustomAction(action: String, extras: Bundle?) { + super.onCustomAction(action, extras) + // Service already handles intents from the old notification actions, easier to + // plug into that system. + context.sendBroadcast(Intent(action)) + } + + override fun onStop() { + // Get the service to shut down with the ACTION_EXIT intent + context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) + } + + private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { + val music: Music + var parent: MusicParent? = null + when (uid) { + is MediaSessionUID.SingleItem -> { + music = musicRepository.find(uid.uid) ?: return null + } + is MediaSessionUID.ChildItem -> { + music = musicRepository.find(uid.childUid) ?: return null + parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null + } + else -> return null + } + + return when (music) { + is Song -> inferSongFromParent(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.OFF) + is Artist -> commandFactory.artist(music, ShuffleMode.OFF) + is Genre -> commandFactory.genre(music, ShuffleMode.OFF) + is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + } + } + + private fun inferSongFromParent(music: Song, parent: MusicParent?) = + when (parent) { + is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) + null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) + } + + companion object { + const val ACTIONS = + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SET_REPEAT_MODE or + PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_STOP + } +} \ No newline at end of file From 44f9617307ed42003845f1f8fadce0964da0ddef Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 13:29:24 -0600 Subject: [PATCH 14/66] playback: add missing session actions --- .../playback/service/MediaSessionInterface.kt | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 2b0a45769f..65b55b51f0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -24,11 +24,14 @@ import org.oxycblt.auxio.playback.state.ShuffleMode import javax.inject.Inject class MediaSessionInterface @Inject constructor( - @ApplicationContext private val context: Context, + @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, private val commandFactory: PlaybackCommand.Factory, private val musicRepository: MusicRepository, ) : MediaSessionCompat.Callback() { + override fun onPrepare() { + super.onPrepare() + } override fun onPrepareFromMediaId(mediaId: String?, extras: Bundle?) { super.onPrepareFromMediaId(mediaId, extras) @@ -127,13 +130,15 @@ class MediaSessionInterface @Inject constructor( PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK else -> RepeatMode.NONE - }) + } + ) } override fun onSetShuffleMode(shuffleMode: Int) { playbackManager.shuffled( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP + ) } override fun onSkipToQueueItem(id: Long) { @@ -159,10 +164,12 @@ class MediaSessionInterface @Inject constructor( is MediaSessionUID.SingleItem -> { music = musicRepository.find(uid.uid) ?: return null } + is MediaSessionUID.ChildItem -> { music = musicRepository.find(uid.childUid) ?: return null parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null } + else -> return null } @@ -179,16 +186,19 @@ class MediaSessionInterface @Inject constructor( when (parent) { is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) + is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) } companion object { const val ACTIONS = - PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SET_REPEAT_MODE or @@ -197,6 +207,7 @@ class MediaSessionInterface @Inject constructor( PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_REWIND or PlaybackStateCompat.ACTION_STOP } -} \ No newline at end of file +} From 3dea060a288f25907e61f545c23978001e1aac57 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 13:29:48 -0600 Subject: [PATCH 15/66] all: cleanup --- .../java/org/oxycblt/auxio/AuxioService.kt | 3 +- .../oxycblt/auxio/music/service/Category.kt | 21 ++++- .../music/service/MediaItemTranslation.kt | 3 +- .../auxio/music/service/MusicBrowser.kt | 30 ++++---- .../music/service/MusicServiceFragment.kt | 8 +- .../playback/service/MediaSessionHolder.kt | 1 - .../playback/service/MediaSessionInterface.kt | 38 ++++++--- .../service/PlaybackServiceFragment.kt | 77 ------------------- 8 files changed, 66 insertions(+), 115 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index eb2c8dc43f..4f45fff6a3 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -86,8 +86,7 @@ class AuxioService : rootHints: Bundle? ): BrowserRoot { val maximumRootChildLimit = - rootHints?.getInt( - MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 + rootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 return musicFragment.getRoot(maximumRootChildLimit) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt index c2babde295..46676d3cca 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2024 Auxio Project + * Category.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + package org.oxycblt.auxio.music.service import org.oxycblt.auxio.R @@ -79,6 +97,7 @@ sealed interface Category { val MUSIC = arrayOf(Songs, Albums, Artists, Genres, Playlists) val DEVICE_MUSIC = arrayOf(Songs, Albums, Artists, Genres) val USER_MUSIC = arrayOf(Playlists) + fun fromString(str: String): Category? = when { str.startsWith(Root.ID_PREFIX) -> Root.fromString(str) @@ -91,4 +110,4 @@ sealed interface Category { else -> null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index b04afbc236..be320131b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -60,8 +60,7 @@ sealed interface MediaSessionUID { return null } return when (parts[0]) { - ID_CATEGORY -> - CategoryItem(Category.fromString(parts[1]) ?: return null) + ID_CATEGORY -> CategoryItem(Category.fromString(parts[1]) ?: return null) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 576454204c..b62f9b2481 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -186,35 +186,35 @@ constructor( } } - private fun getCategoryMediaItems(category: Category, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary) = + private fun getCategoryMediaItems( + category: Category, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ) = when (category) { is Category.Root -> { val base = Category.MUSIC.take(category.amount) if (base.size < Category.MUSIC.size) { - base + Category.More(Category.MUSIC.size - base.size) - } else { - base - }.map { it.toMediaItem(context) } - } - is Category.More -> Category.MUSIC.takeLast(category.remainder).map { - it.toMediaItem(context) - } + base + Category.More(Category.MUSIC.size - base.size) + } else { + base + } + .map { it.toMediaItem(context) } + } + is Category.More -> + Category.MUSIC.takeLast(category.remainder).map { it.toMediaItem(context) } is Category.Songs -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } is Category.Albums -> - listSettings.albumSort.albums(deviceLibrary.albums).map { - it.toMediaItem(context) - } + listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } is Category.Artists -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } is Category.Genres -> - listSettings.genreSort.genres(deviceLibrary.genres).map { - it.toMediaItem(context) - } + listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } is Category.Playlists -> userLibrary.playlists.map { it.toMediaItem(context) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 8d439b2c4e..51563a49b1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -19,8 +19,8 @@ package org.oxycblt.auxio.music.service import android.support.v4.media.MediaBrowserCompat.MediaItem -import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.MediaBrowserServiceCompat.BrowserRoot +import androidx.media.MediaBrowserServiceCompat.Result import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -82,10 +82,8 @@ constructor( fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } - fun getChildren( - mediaId: String, - result: Result> - ) = result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } + fun getChildren(mediaId: String, result: Result>) = + result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } fun search(query: String, result: Result>) = result.dispatchAsync { musicBrowser.search(query) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 6fcda18308..e561b81809 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -21,7 +21,6 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap -import android.media.session.MediaSession import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 65b55b51f0..6688ed956b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -1,3 +1,21 @@ +/* + * Copyright (c) 2024 Auxio Project + * MediaSessionInterface.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + package org.oxycblt.auxio.playback.service import android.content.Context @@ -8,6 +26,7 @@ import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -21,9 +40,10 @@ import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.ShuffleMode -import javax.inject.Inject -class MediaSessionInterface @Inject constructor( +class MediaSessionInterface +@Inject +constructor( @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, private val commandFactory: PlaybackCommand.Factory, @@ -130,15 +150,13 @@ class MediaSessionInterface @Inject constructor( PlaybackStateCompat.REPEAT_MODE_GROUP -> RepeatMode.ALL PlaybackStateCompat.REPEAT_MODE_ONE -> RepeatMode.TRACK else -> RepeatMode.NONE - } - ) + }) } override fun onSetShuffleMode(shuffleMode: Int) { playbackManager.shuffled( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL || - shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP - ) + shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) } override fun onSkipToQueueItem(id: Long) { @@ -164,12 +182,10 @@ class MediaSessionInterface @Inject constructor( is MediaSessionUID.SingleItem -> { music = musicRepository.find(uid.uid) ?: return null } - is MediaSessionUID.ChildItem -> { music = musicRepository.find(uid.childUid) ?: return null parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null } - else -> return null } @@ -186,11 +202,9 @@ class MediaSessionInterface @Inject constructor( when (parent) { is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) - + ?: commandFactory.songFromArtist(music, music.artists[0], ShuffleMode.IMPLICIT) is Genre -> commandFactory.songFromGenre(music, parent, ShuffleMode.IMPLICIT) - ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) - + ?: commandFactory.songFromGenre(music, music.genres[0], ShuffleMode.IMPLICIT) is Playlist -> commandFactory.songFromPlaylist(music, parent, ShuffleMode.IMPLICIT) null -> commandFactory.songFromAll(music, ShuffleMode.IMPLICIT) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index b6ca0d721e..42e60d7fd9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -108,81 +108,4 @@ constructor( override fun onSessionEnded() { foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } - - // override fun onGetLibraryRoot( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // params: MediaLibraryService.LibraryParams? - // ): ListenableFuture> = - // Futures.immediateFuture(LibraryResult.ofItem(mediaItemBrowser.root, params)) - // - // override fun onGetItem( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // mediaId: String - // ): ListenableFuture> { - // val result = - // mediaItemBrowser.getItem(mediaId)?.let { LibraryResult.ofItem(it, null) } - // ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - // return Futures.immediateFuture(result) - // } - // - // override fun onSetMediaItems( - // mediaSession: MediaSession, - // controller: MediaSession.ControllerInfo, - // mediaItems: MutableList, - // startIndex: Int, - // startPositionMs: Long - // ): ListenableFuture = - // Futures.immediateFuture( - // MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)) - // - // override fun onGetChildren( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // parentId: String, - // page: Int, - // pageSize: Int, - // params: MediaLibraryService.LibraryParams? - // ): ListenableFuture>> { - // val children = - // mediaItemBrowser.getChildren(parentId, page, pageSize)?.let { - // LibraryResult.ofItemList(it, params) - // } - // ?: LibraryResult.ofError>( - // LibraryResult.RESULT_ERROR_BAD_VALUE) - // return Futures.immediateFuture(children) - // } - // - // override fun onSearch( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // query: String, - // params: MediaLibraryService.LibraryParams? - // ): ListenableFuture> = - // waitScope - // .async { - // mediaItemBrowser.prepareSearch(query, browser) - // // Invalidator will send the notify result - // LibraryResult.ofVoid() - // } - // .asListenableFuture() - // - // override fun onGetSearchResult( - // session: MediaLibrarySession, - // browser: MediaSession.ControllerInfo, - // query: String, - // page: Int, - // pageSize: Int, - // params: MediaLibraryService.LibraryParams? - // ) = - // waitScope - // .async { - // mediaItemBrowser.getSearchResult(query, page, pageSize)?.let { - // LibraryResult.ofItemList(it, params) - // } - // ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - // } - // .asListenableFuture() - } From ba5bccaa3703e6df77b1a19dd34a71e203751f46 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 13:31:29 -0600 Subject: [PATCH 16/66] playback: remove specific queue item in android auto --- .../playback/service/MediaSessionHolder.kt | 2 ++ .../playback/service/MediaSessionInterface.kt | 17 ++++------------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index e561b81809..9f301fcd33 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap +import android.os.Bundle import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat @@ -303,6 +304,7 @@ private constructor( // MediaStore URI instead of loading a bitmap. .setIconUri(song.album.cover.single.mediaStoreCoverUri) .setMediaUri(song.uri) + .setExtras(Bundle().apply { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) .build() // Store the item index so we can then use the analogous index in the // playback state. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 6688ed956b..25c3078f55 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -26,6 +26,7 @@ import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import dagger.hilt.android.qualifiers.ApplicationContext +import org.oxycblt.auxio.BuildConfig import javax.inject.Inject import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -99,19 +100,8 @@ constructor( override fun onRemoveQueueItem(description: MediaDescriptionCompat) { super.onRemoveQueueItem(description) - val deviceLibrary = musicRepository.deviceLibrary ?: return - val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = - when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null - } - ?: return - val queueIndex = playbackManager.queue.indexOf(song) - if (queueIndex > -1) { - playbackManager.removeQueueItem(queueIndex) - } + val at = description.extras?.getInt(KEY_QUEUE_POS) ?: return + playbackManager.removeQueueItem(at) } override fun onPlay() { @@ -210,6 +200,7 @@ constructor( } companion object { + const val KEY_QUEUE_POS = BuildConfig.APPLICATION_ID + ".metadata.QUEUE_POS" const val ACTIONS = PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or PlaybackStateCompat.ACTION_PLAY or From cce33e14147e8136208d9c3328049e936857f311 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 14:08:58 -0600 Subject: [PATCH 17/66] playback: improve published playback metadata --- app/build.gradle | 3 +++ .../playback/service/MediaSessionHolder.kt | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ac4e8f7292..a0198d7373 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -114,6 +114,9 @@ dependencies { // Media implementation "androidx.media:media:1.7.0" + // Android Auto + implementation "androidx.car.app:app:1.4.0" + // Preferences implementation "androidx.preference:preference-ktx:1.2.1" diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 9f301fcd33..acc9401107 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -27,6 +27,7 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.annotation.DrawableRes +import androidx.car.app.mediaextensions.MetadataExtras import androidx.core.app.NotificationCompat import androidx.media.app.NotificationCompat.MediaStyle import javax.inject.Inject @@ -40,6 +41,7 @@ import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.MediaSessionInterface @@ -230,10 +232,11 @@ private constructor( // several times. val title = song.name.resolve(context) val artist = song.artists.resolveNames(context) + val album = song.album.name.resolve(context) val builder = MediaMetadataCompat.Builder() .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, song.album.name.resolve(context)) + .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, album) // Note: We would leave the artist field null if it didn't exist and let downstream // consumers handle it, but that would break the notification display. .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) @@ -246,11 +249,12 @@ private constructor( .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context)) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) - .putText( - MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, - parent?.run { name.resolve(context) } - ?: context.getString(R.string.lbl_all_songs)) + .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, album) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) + .putText(PlaybackNotification.KEY_PARENT, + parent?.name?.resolve(context) ?: context.getString(R.string.lbl_all_songs)) + .putText(MetadataExtras.KEY_SUBTITLE_LINK_MEDIA_ID, MediaSessionUID.SingleItem(song.artists[0].uid).toString()) + .putText(MetadataExtras.KEY_DESCRIPTION_LINK_MEDIA_ID, MediaSessionUID.SingleItem(song.album.uid).toString()) // These fields are nullable and so we must check first before adding them to the fields. song.track?.let { logD("Adding track information") @@ -263,6 +267,7 @@ private constructor( song.date?.let { logD("Adding date information") builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) + builder.putString(MediaMetadataCompat.METADATA_KEY_YEAR, it.year.toString()) } // We are normally supposed to use URIs for album art, but that removes some of the @@ -510,9 +515,11 @@ private class PlaybackNotification( iconRes, actionName, context.newBroadcastPendingIntent(actionName)) .build() - private companion object { + companion object { + const val KEY_PARENT = BuildConfig.APPLICATION_ID + ".metadata.PARENT" + /** Notification channel used by solely the playback notification. */ - val CHANNEL_INFO = + private val CHANNEL_INFO = ChannelInfo( id = BuildConfig.APPLICATION_ID + ".channel.PLAYBACK", nameRes = R.string.lbl_playback) From fda4548515fd544a4a66636e464ebe48f2d9de8e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 14:11:19 -0600 Subject: [PATCH 18/66] music: apply descriptions everywhere --- .../org/oxycblt/auxio/music/service/MediaItemTranslation.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index be320131b0..72b0f7e7ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames +import org.oxycblt.auxio.playback.formatDurationDs import org.oxycblt.auxio.util.getPlural sealed interface MediaSessionUID { @@ -141,11 +142,13 @@ fun Album.toMediaItem( MediaSessionUID.ChildItem(parent.uid, uid) } val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(artists.resolveNames(context)) + .setDescription(counts) .setIconUri(cover.single.mediaStoreCoverUri) .setExtras(extras) .build() @@ -173,6 +176,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(counts) + .setDescription(genres.resolveNames(context)) .setIconUri(cover.single.mediaStoreCoverUri) .setExtras(extras) .build() @@ -213,6 +217,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) .setSubtitle(counts) + .setDescription(durationMs.formatDurationDs(true)) .setIconUri(cover?.single?.mediaStoreCoverUri) .setExtras(extras) .build() From b2e7c1eb50f7cef89b380b43ed156dc0be3fe47f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 15:52:42 -0600 Subject: [PATCH 19/66] playback: basic play from search functionality --- .../playback/service/MediaSessionHolder.kt | 14 +- .../playback/service/MediaSessionInterface.kt | 122 +++++++++++++++--- 2 files changed, 111 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index acc9401107..e133388b46 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -251,10 +251,15 @@ private constructor( .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, album) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, song.durationMs) - .putText(PlaybackNotification.KEY_PARENT, + .putText( + PlaybackNotification.KEY_PARENT, parent?.name?.resolve(context) ?: context.getString(R.string.lbl_all_songs)) - .putText(MetadataExtras.KEY_SUBTITLE_LINK_MEDIA_ID, MediaSessionUID.SingleItem(song.artists[0].uid).toString()) - .putText(MetadataExtras.KEY_DESCRIPTION_LINK_MEDIA_ID, MediaSessionUID.SingleItem(song.album.uid).toString()) + .putText( + MetadataExtras.KEY_SUBTITLE_LINK_MEDIA_ID, + MediaSessionUID.SingleItem(song.artists[0].uid).toString()) + .putText( + MetadataExtras.KEY_DESCRIPTION_LINK_MEDIA_ID, + MediaSessionUID.SingleItem(song.album.uid).toString()) // These fields are nullable and so we must check first before adding them to the fields. song.track?.let { logD("Adding track information") @@ -309,7 +314,8 @@ private constructor( // MediaStore URI instead of loading a bitmap. .setIconUri(song.album.cover.single.mediaStoreCoverUri) .setMediaUri(song.uri) - .setExtras(Bundle().apply { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) + .setExtras( + Bundle().apply { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) .build() // Store the item index so we can then use the analogous index in the // playback state. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 25c3078f55..66805f8017 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -22,12 +22,14 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import android.provider.MediaStore import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import dagger.hilt.android.qualifiers.ApplicationContext -import org.oxycblt.auxio.BuildConfig import javax.inject.Inject +import org.apache.commons.text.similarity.JaroWinklerSimilarity +import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -36,7 +38,9 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode @@ -50,39 +54,65 @@ constructor( private val commandFactory: PlaybackCommand.Factory, private val musicRepository: MusicRepository, ) : MediaSessionCompat.Callback() { + private val jaroWinkler = JaroWinklerSimilarity() + override fun onPrepare() { super.onPrepare() + // STUB, we already automatically prepare playback. } override fun onPrepareFromMediaId(mediaId: String?, extras: Bundle?) { super.onPrepareFromMediaId(mediaId, extras) + // STUB, can't tell when this is called + } + + override fun onPrepareFromUri(uri: Uri?, extras: Bundle?) { + super.onPrepareFromUri(uri, extras) + // STUB, can't tell when this is called + } + + override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { + super.onPlayFromUri(uri, extras) + // STUB, can't tell when this is called } override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return - val command = expandIntoCommand(uid) - requireNotNull(command) { "Invalid playback configuration" } - playbackManager.play(command) + val command = expandUidIntoCommand(uid) + playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } override fun onPrepareFromSearch(query: String?, extras: Bundle?) { super.onPrepareFromSearch(query, extras) + // STUB, can't tell when this is called } - override fun onPlayFromSearch(query: String?, extras: Bundle?) { + override fun onPlayFromSearch(query: String, extras: Bundle) { super.onPlayFromSearch(query, extras) - // STUB: Unimplemented, no search engine + val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return + val queryBundle = + QueryBundle( + (extras.getString(MediaStore.EXTRA_MEDIA_TITLE) ?: query).ifBlank { null }, + extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.ifBlank { null }, + extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.ifBlank { null }, + extras.getString(MediaStore.EXTRA_MEDIA_GENRE)?.ifBlank { null }, + extras.getString(@Suppress("DEPRECATION") MediaStore.EXTRA_MEDIA_PLAYLIST)) + val command = expandSearchInfoCommand(queryBundle, deviceLibrary, userLibrary) + playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } - override fun onPrepareFromUri(uri: Uri?, extras: Bundle?) { - super.onPrepareFromUri(uri, extras) - } + data class QueryBundle( + val title: String?, + val album: String?, + val artist: String?, + val genre: String?, + val playlist: String? + ) - override fun onPlayFromUri(uri: Uri?, extras: Bundle?) { - super.onPlayFromUri(uri, extras) - // STUB - } + private fun Collection.fuzzyBest(query: String): T = + maxByOrNull { jaroWinkler.apply(it.name.resolve(context), query) } ?: first() override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) @@ -120,6 +150,10 @@ constructor( playbackManager.prev() } + override fun onSkipToQueueItem(id: Long) { + playbackManager.goto(id.toInt()) + } + override fun onSeekTo(position: Long) { playbackManager.seekTo(position) } @@ -149,8 +183,9 @@ constructor( shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP) } - override fun onSkipToQueueItem(id: Long) { - playbackManager.goto(id.toInt()) + override fun onStop() { + // Get the service to shut down with the ACTION_EXIT intent + context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) } override fun onCustomAction(action: String, extras: Bundle?) { @@ -160,12 +195,7 @@ constructor( context.sendBroadcast(Intent(action)) } - override fun onStop() { - // Get the service to shut down with the ACTION_EXIT intent - context.sendBroadcast(Intent(PlaybackActions.ACTION_EXIT)) - } - - private fun expandIntoCommand(uid: MediaSessionUID): PlaybackCommand? { + private fun expandUidIntoCommand(uid: MediaSessionUID): PlaybackCommand? { val music: Music var parent: MusicParent? = null when (uid) { @@ -188,6 +218,55 @@ constructor( } } + private fun expandSearchInfoCommand( + query: QueryBundle, + deviceLibrary: DeviceLibrary, + userLibrary: UserLibrary + ): PlaybackCommand? { + if (query.album != null) { + val album = deviceLibrary.albums.fuzzyBest(query.album) + if (query.title == null) { + return commandFactory.album(album, ShuffleMode.OFF) + } + val song = album.songs.fuzzyBest(query.title) + return commandFactory.songFromAlbum(song, ShuffleMode.OFF) + } + + if (query.artist != null) { + val artist = deviceLibrary.artists.fuzzyBest(query.artist) + if (query.title == null) { + return commandFactory.artist(artist, ShuffleMode.OFF) + } + val song = artist.songs.fuzzyBest(query.title) + return commandFactory.songFromArtist(song, artist, ShuffleMode.OFF) + } + + if (query.genre != null) { + val genre = deviceLibrary.genres.fuzzyBest(query.genre) + if (query.title == null) { + return commandFactory.genre(genre, ShuffleMode.OFF) + } + val song = genre.songs.fuzzyBest(query.title) + return commandFactory.songFromGenre(song, genre, ShuffleMode.OFF) + } + + if (query.playlist != null) { + val playlist = userLibrary.playlists.fuzzyBest(query.playlist) + if (query.title == null) { + return commandFactory.playlist(playlist, ShuffleMode.OFF) + } + val song = playlist.songs.fuzzyBest(query.title) + return commandFactory.songFromPlaylist(song, playlist, ShuffleMode.OFF) + } + + if (query.title != null) { + val song = deviceLibrary.songs.fuzzyBest(query.title) + return commandFactory.songFromAll(song, ShuffleMode.OFF) + } + + return commandFactory.all(ShuffleMode.ON) + } + private fun inferSongFromParent(music: Song, parent: MusicParent?) = when (parent) { is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) @@ -203,6 +282,7 @@ constructor( const val KEY_QUEUE_POS = BuildConfig.APPLICATION_ID + ".metadata.QUEUE_POS" const val ACTIONS = PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or + PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or From a712a773b080a041463e66eb73adfb8e7ba7c4dd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 16:35:06 -0600 Subject: [PATCH 20/66] playback: correctly voice search for music Completely misunderstood how "focus" worked. --- .../playback/service/MediaSessionHolder.kt | 1 + .../playback/service/MediaSessionInterface.kt | 117 ++++++++++-------- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index e133388b46..f89016f31a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -108,6 +108,7 @@ private constructor( isActive = true setQueueTitle(context.getString(R.string.lbl_queue)) setCallback(sessionInterface) + setFlags(MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS) } this.foregroundListener = foregroundListener playbackManager.addListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 66805f8017..6ec93146dc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -39,6 +39,7 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.playback.state.PlaybackCommand @@ -92,14 +93,11 @@ constructor( super.onPlayFromSearch(query, extras) val deviceLibrary = musicRepository.deviceLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return - val queryBundle = - QueryBundle( - (extras.getString(MediaStore.EXTRA_MEDIA_TITLE) ?: query).ifBlank { null }, - extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.ifBlank { null }, - extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.ifBlank { null }, - extras.getString(MediaStore.EXTRA_MEDIA_GENRE)?.ifBlank { null }, - extras.getString(@Suppress("DEPRECATION") MediaStore.EXTRA_MEDIA_PLAYLIST)) - val command = expandSearchInfoCommand(queryBundle, deviceLibrary, userLibrary) + val command = expandSearchInfoCommand( + query.ifBlank { null }, + extras, + deviceLibrary, + userLibrary) playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } @@ -111,9 +109,6 @@ constructor( val playlist: String? ) - private fun Collection.fuzzyBest(query: String): T = - maxByOrNull { jaroWinkler.apply(it.name.resolve(context), query) } ?: first() - override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return @@ -209,65 +204,85 @@ constructor( else -> return null } - return when (music) { - is Song -> inferSongFromParent(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) - } + return expandMusicIntoCommand(music, parent) } + @Suppress("DEPRECATION") private fun expandSearchInfoCommand( - query: QueryBundle, + query: String?, + extras: Bundle, deviceLibrary: DeviceLibrary, userLibrary: UserLibrary ): PlaybackCommand? { - if (query.album != null) { - val album = deviceLibrary.albums.fuzzyBest(query.album) - if (query.title == null) { - return commandFactory.album(album, ShuffleMode.OFF) - } - val song = album.songs.fuzzyBest(query.title) - return commandFactory.songFromAlbum(song, ShuffleMode.OFF) + if (query == null) { + // User just wanted to 'play some music', shuffle all + return commandFactory.all(ShuffleMode.ON) } - if (query.artist != null) { - val artist = deviceLibrary.artists.fuzzyBest(query.artist) - if (query.title == null) { - return commandFactory.artist(artist, ShuffleMode.OFF) + val bestCommand = when (extras.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { + MediaStore.Audio.Media.ENTRY_CONTENT_TYPE -> { + val songQuery = extras.getString(MediaStore.EXTRA_MEDIA_TITLE) + val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = deviceLibrary.songs.maxByOrNull { + fuzzy(it.name, songQuery) + fuzzy(it.album.name, albumQuery) + + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } + } + best?.let { commandFactory.song(it, ShuffleMode.IMPLICIT) } } - val song = artist.songs.fuzzyBest(query.title) - return commandFactory.songFromArtist(song, artist, ShuffleMode.OFF) - } - if (query.genre != null) { - val genre = deviceLibrary.genres.fuzzyBest(query.genre) - if (query.title == null) { - return commandFactory.genre(genre, ShuffleMode.OFF) + MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { + val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = deviceLibrary.albums.maxByOrNull { + fuzzy(it.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } + } + best?.let { commandFactory.album(it, ShuffleMode.OFF) } + } + + MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { + val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) + val best = deviceLibrary.artists.maxByOrNull { fuzzy(it.name, artistQuery) } + best?.let { commandFactory.artist(it, ShuffleMode.OFF) } } - val song = genre.songs.fuzzyBest(query.title) - return commandFactory.songFromGenre(song, genre, ShuffleMode.OFF) - } - if (query.playlist != null) { - val playlist = userLibrary.playlists.fuzzyBest(query.playlist) - if (query.title == null) { - return commandFactory.playlist(playlist, ShuffleMode.OFF) + MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> { + val genreQuery = extras.getString(MediaStore.EXTRA_MEDIA_GENRE) + val best = deviceLibrary.genres.maxByOrNull { fuzzy(it.name, genreQuery) } + best?.let { commandFactory.genre(it, ShuffleMode.OFF) } } - val song = playlist.songs.fuzzyBest(query.title) - return commandFactory.songFromPlaylist(song, playlist, ShuffleMode.OFF) + + MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE -> { + val playlistQuery = extras.getString(MediaStore.EXTRA_MEDIA_PLAYLIST) + val best = userLibrary.playlists.maxByOrNull { fuzzy(it.name, playlistQuery) } + best?.let { commandFactory.playlist(it, ShuffleMode.OFF) } + } + + else -> null } - if (query.title != null) { - val song = deviceLibrary.songs.fuzzyBest(query.title) - return commandFactory.songFromAll(song, ShuffleMode.OFF) + if (bestCommand != null) { + return bestCommand } - return commandFactory.all(ShuffleMode.ON) + val bestMusic = (deviceLibrary.songs + deviceLibrary.albums + deviceLibrary.artists + deviceLibrary.genres + userLibrary.playlists) + .maxByOrNull { fuzzy(it.name, query) } + return bestMusic?.let { expandMusicIntoCommand(it, null) } } - private fun inferSongFromParent(music: Song, parent: MusicParent?) = + private fun fuzzy(name: Name, query: String?): Double = + query?.let { jaroWinkler.apply(name.resolve(context), it) } ?: 0.0 + + private fun expandMusicIntoCommand(music: Music, parent: MusicParent?) = + when (music) { + is Song -> expandSongIntoCommand(music, parent) + is Album -> commandFactory.album(music, ShuffleMode.OFF) + is Artist -> commandFactory.artist(music, ShuffleMode.OFF) + is Genre -> commandFactory.genre(music, ShuffleMode.OFF) + is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + } + + private fun expandSongIntoCommand(music: Song, parent: MusicParent?) = when (parent) { is Album -> commandFactory.songFromAlbum(music, ShuffleMode.IMPLICIT) is Artist -> commandFactory.songFromArtist(music, parent, ShuffleMode.IMPLICIT) From 130d30c70d72ae4e430173da86822984b05bc337 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 28 Aug 2024 16:37:20 -0600 Subject: [PATCH 21/66] playback: immprove search error cases --- .../playback/service/MediaSessionInterface.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 6ec93146dc..dbe726dde5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -219,7 +219,7 @@ constructor( return commandFactory.all(ShuffleMode.ON) } - val bestCommand = when (extras.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { + when (extras.getString(MediaStore.EXTRA_MEDIA_FOCUS)) { MediaStore.Audio.Media.ENTRY_CONTENT_TYPE -> { val songQuery = extras.getString(MediaStore.EXTRA_MEDIA_TITLE) val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) @@ -228,7 +228,9 @@ constructor( fuzzy(it.name, songQuery) + fuzzy(it.album.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } } - best?.let { commandFactory.song(it, ShuffleMode.IMPLICIT) } + if (best != null) { + return expandSongIntoCommand(best, null) + } } MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { @@ -237,37 +239,42 @@ constructor( val best = deviceLibrary.albums.maxByOrNull { fuzzy(it.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } } - best?.let { commandFactory.album(it, ShuffleMode.OFF) } + if (best != null) { + return commandFactory.album(best, ShuffleMode.OFF) + } } MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) val best = deviceLibrary.artists.maxByOrNull { fuzzy(it.name, artistQuery) } - best?.let { commandFactory.artist(it, ShuffleMode.OFF) } + if (best != null) { + return commandFactory.artist(best, ShuffleMode.OFF) + } } MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> { val genreQuery = extras.getString(MediaStore.EXTRA_MEDIA_GENRE) val best = deviceLibrary.genres.maxByOrNull { fuzzy(it.name, genreQuery) } - best?.let { commandFactory.genre(it, ShuffleMode.OFF) } + if (best != null) { + return commandFactory.genre(best, ShuffleMode.OFF) + } } MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE -> { val playlistQuery = extras.getString(MediaStore.EXTRA_MEDIA_PLAYLIST) val best = userLibrary.playlists.maxByOrNull { fuzzy(it.name, playlistQuery) } - best?.let { commandFactory.playlist(it, ShuffleMode.OFF) } + if (best != null) { + return commandFactory.playlist(best, ShuffleMode.OFF) + } } - else -> null - } - - if (bestCommand != null) { - return bestCommand + else -> {} } val bestMusic = (deviceLibrary.songs + deviceLibrary.albums + deviceLibrary.artists + deviceLibrary.genres + userLibrary.playlists) .maxByOrNull { fuzzy(it.name, query) } - return bestMusic?.let { expandMusicIntoCommand(it, null) } + // TODO: Error out when we can't correctly resolve the query + return bestMusic?.let { expandMusicIntoCommand(it, null) } ?: commandFactory.all(ShuffleMode.ON) } private fun fuzzy(name: Name, query: String?): Double = From 889713d5e035fecef71ce8fe153c023c1343a39b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 09:29:46 -0600 Subject: [PATCH 22/66] playback: improve queue item setup - Use same media description code - Make queue removal more reliable --- .../music/service/MediaItemTranslation.kt | 35 +++++----- .../playback/service/MediaSessionHolder.kt | 17 +---- .../playback/service/MediaSessionInterface.kt | 66 +++++++++++-------- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 72b0f7e7ea..cc250c3421 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -105,11 +105,8 @@ fun Category.toMediaItem(context: Context): MediaItem { return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaItem( - context: Context, - parent: MusicParent? = null, - vararg sugar: Sugar -): MediaItem { +fun Song.toMediaDescription(context: Context, parent: MusicParent? = null, + vararg sugar: Sugar): MediaDescriptionCompat { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) @@ -117,17 +114,23 @@ fun Song.toMediaItem( MediaSessionUID.ChildItem(parent.uid, uid) } val extras = Bundle().apply { sugar.forEach { this.it(context) } } - val description = - MediaDescriptionCompat.Builder() - .setMediaId(mediaSessionUID.toString()) - .setTitle(name.resolve(context)) - .setSubtitle(artists.resolveNames(context)) - .setDescription(album.name.resolve(context)) - .setIconUri(album.cover.single.mediaStoreCoverUri) - .setMediaUri(uri) - .setExtras(extras) - .build() - return MediaItem(description, MediaItem.FLAG_PLAYABLE) + return MediaDescriptionCompat.Builder() + .setMediaId(mediaSessionUID.toString()) + .setTitle(name.resolve(context)) + .setSubtitle(artists.resolveNames(context)) + .setDescription(album.name.resolve(context)) + .setIconUri(cover.mediaStoreCoverUri) + .setMediaUri(uri) + .setExtras(extras) + .build() +} + +fun Song.toMediaItem( + context: Context, + parent: MusicParent? = null, + vararg sugar: Sugar +): MediaItem { + return MediaItem(toMediaDescription(context, parent, *sugar), MediaItem.FLAG_PLAYABLE) } fun Album.toMediaItem( diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index f89016f31a..a07c2ad30f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -42,6 +42,8 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.service.toMediaDescription +import org.oxycblt.auxio.music.service.toMediaItem import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.MediaSessionInterface @@ -304,20 +306,7 @@ private constructor( private fun updateQueue(queue: List) { val queueItems = queue.mapIndexed { i, song -> - val description = - MediaDescriptionCompat.Builder() - // Media ID should not be the item index but rather the UID, - // as it's used to request a song to be played from the queue. - .setMediaId(song.uid.toString()) - .setTitle(song.name.resolve(context)) - .setSubtitle(song.artists.resolveNames(context)) - // Since we usually have to load many songs into the queue, use the - // MediaStore URI instead of loading a bitmap. - .setIconUri(song.album.cover.single.mediaStoreCoverUri) - .setMediaUri(song.uri) - .setExtras( - Bundle().apply { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) - .build() + val description = song.toMediaDescription(context, null, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) // Store the item index so we can then use the analogous index in the // playback state. MediaSessionCompat.QueueItem(description, i.toLong()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index dbe726dde5..3ab8eaa1e5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -93,22 +93,11 @@ constructor( super.onPlayFromSearch(query, extras) val deviceLibrary = musicRepository.deviceLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return - val command = expandSearchInfoCommand( - query.ifBlank { null }, - extras, - deviceLibrary, - userLibrary) + val command = + expandSearchInfoCommand(query.ifBlank { null }, extras, deviceLibrary, userLibrary) playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } - data class QueryBundle( - val title: String?, - val album: String?, - val artist: String?, - val genre: String?, - val playlist: String? - ) - override fun onAddQueueItem(description: MediaDescriptionCompat) { super.onAddQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return @@ -125,8 +114,22 @@ constructor( override fun onRemoveQueueItem(description: MediaDescriptionCompat) { super.onRemoveQueueItem(description) - val at = description.extras?.getInt(KEY_QUEUE_POS) ?: return - playbackManager.removeQueueItem(at) + val at = description.extras?.getInt(KEY_QUEUE_POS) + if (at != null) { + // Direct queue item removal w/preserved extras, we can explicitly remove + // the correct item rather than a duplicate elsewhere. + playbackManager.removeQueueItem(at) + return + } + // Non-queue item or queue item lost it's extras in transit, remove the first item + val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return + val songUid = when (uid) { + is MediaSessionUID.SingleItem -> uid.uid + is MediaSessionUID.ChildItem -> uid.childUid + else -> return + } + val firstAt = playbackManager.queue.indexOfFirst { it.uid == songUid } + playbackManager.removeQueueItem(firstAt) } override fun onPlay() { @@ -224,26 +227,28 @@ constructor( val songQuery = extras.getString(MediaStore.EXTRA_MEDIA_TITLE) val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) - val best = deviceLibrary.songs.maxByOrNull { - fuzzy(it.name, songQuery) + fuzzy(it.album.name, albumQuery) + + val best = + deviceLibrary.songs.maxByOrNull { + fuzzy(it.name, songQuery) + + fuzzy(it.album.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } - } + } if (best != null) { return expandSongIntoCommand(best, null) } } - MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> { val albumQuery = extras.getString(MediaStore.EXTRA_MEDIA_ALBUM) val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) - val best = deviceLibrary.albums.maxByOrNull { - fuzzy(it.name, albumQuery) + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } - } + val best = + deviceLibrary.albums.maxByOrNull { + fuzzy(it.name, albumQuery) + + it.artists.maxOf { artist -> fuzzy(artist.name, artistQuery) } + } if (best != null) { return commandFactory.album(best, ShuffleMode.OFF) } } - MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> { val artistQuery = extras.getString(MediaStore.EXTRA_MEDIA_ARTIST) val best = deviceLibrary.artists.maxByOrNull { fuzzy(it.name, artistQuery) } @@ -251,7 +256,6 @@ constructor( return commandFactory.artist(best, ShuffleMode.OFF) } } - MediaStore.Audio.Genres.ENTRY_CONTENT_TYPE -> { val genreQuery = extras.getString(MediaStore.EXTRA_MEDIA_GENRE) val best = deviceLibrary.genres.maxByOrNull { fuzzy(it.name, genreQuery) } @@ -259,7 +263,6 @@ constructor( return commandFactory.genre(best, ShuffleMode.OFF) } } - MediaStore.Audio.Playlists.ENTRY_CONTENT_TYPE -> { val playlistQuery = extras.getString(MediaStore.EXTRA_MEDIA_PLAYLIST) val best = userLibrary.playlists.maxByOrNull { fuzzy(it.name, playlistQuery) } @@ -267,14 +270,19 @@ constructor( return commandFactory.playlist(best, ShuffleMode.OFF) } } - else -> {} } - val bestMusic = (deviceLibrary.songs + deviceLibrary.albums + deviceLibrary.artists + deviceLibrary.genres + userLibrary.playlists) - .maxByOrNull { fuzzy(it.name, query) } + val bestMusic = + (deviceLibrary.songs + + deviceLibrary.albums + + deviceLibrary.artists + + deviceLibrary.genres + + userLibrary.playlists) + .maxByOrNull { fuzzy(it.name, query) } // TODO: Error out when we can't correctly resolve the query - return bestMusic?.let { expandMusicIntoCommand(it, null) } ?: commandFactory.all(ShuffleMode.ON) + return bestMusic?.let { expandMusicIntoCommand(it, null) } + ?: commandFactory.all(ShuffleMode.ON) } private fun fuzzy(name: Name, query: String?): Double = From bf50867b372b09ce8937eb652bed7818527cf1e0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 09:31:15 -0600 Subject: [PATCH 23/66] all: various cleanup --- .../music/service/MediaItemTranslation.kt | 7 +++++-- .../playback/service/MediaSessionHolder.kt | 7 +++---- .../playback/service/MediaSessionInterface.kt | 21 ++++++++++--------- ...ackActionHandler.kt => PlaybackActions.kt} | 2 +- 4 files changed, 20 insertions(+), 17 deletions(-) rename app/src/main/java/org/oxycblt/auxio/playback/service/{PlaybackActionHandler.kt => PlaybackActions.kt} (96%) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index cc250c3421..7cb6b40009 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -105,8 +105,11 @@ fun Category.toMediaItem(context: Context): MediaItem { return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaDescription(context: Context, parent: MusicParent? = null, - vararg sugar: Sugar): MediaDescriptionCompat { +fun Song.toMediaDescription( + context: Context, + parent: MusicParent? = null, + vararg sugar: Sugar +): MediaDescriptionCompat { val mediaSessionUID = if (parent == null) { MediaSessionUID.SingleItem(uid) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index a07c2ad30f..c7b1007926 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -21,8 +21,6 @@ package org.oxycblt.auxio.playback.system import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap -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 @@ -43,7 +41,6 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.music.service.toMediaDescription -import org.oxycblt.auxio.music.service.toMediaItem import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.service.MediaSessionInterface @@ -306,7 +303,9 @@ private constructor( private fun updateQueue(queue: List) { val queueItems = queue.mapIndexed { i, song -> - val description = song.toMediaDescription(context, null, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) + val description = + song.toMediaDescription( + context, null, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) // Store the item index so we can then use the analogous index in the // playback state. MediaSessionCompat.QueueItem(description, i.toLong()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 3ab8eaa1e5..3f846ad4fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -102,13 +102,13 @@ constructor( super.onAddQueueItem(description) val deviceLibrary = musicRepository.deviceLibrary ?: return val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val song = + val songUid = when (uid) { - is MediaSessionUID.SingleItem -> deviceLibrary.findSong(uid.uid) - is MediaSessionUID.ChildItem -> deviceLibrary.findSong(uid.childUid) - else -> null + is MediaSessionUID.SingleItem -> uid.uid + is MediaSessionUID.ChildItem -> uid.childUid + else -> return } - ?: return + val song = deviceLibrary.songs.find { it.uid == songUid } ?: return playbackManager.addToQueue(song) } @@ -123,11 +123,12 @@ constructor( } // Non-queue item or queue item lost it's extras in transit, remove the first item val uid = MediaSessionUID.fromString(description.mediaId ?: return) ?: return - val songUid = when (uid) { - is MediaSessionUID.SingleItem -> uid.uid - is MediaSessionUID.ChildItem -> uid.childUid - else -> return - } + val songUid = + when (uid) { + is MediaSessionUID.SingleItem -> uid.uid + is MediaSessionUID.ChildItem -> uid.childUid + else -> return + } val firstAt = playbackManager.queue.indexOfFirst { it.uid == songUid } playbackManager.removeQueueItem(firstAt) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt index 441bf52533..484cb85416 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActions.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * PlaybackActionHandler.kt is part of Auxio. + * PlaybackActions.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by From b43dbb3e89c409e0a56da3096bdf5a2a1279cf89 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 09:55:02 -0600 Subject: [PATCH 24/66] playback: define menu options --- .../music/service/MediaItemTranslation.kt | 16 ++++++++++++++ .../music/service/MusicServiceFragment.kt | 22 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 7cb6b40009..821b8a9618 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -78,6 +78,22 @@ sealed interface MediaSessionUID { } } +enum class MediaMenuItem(val id: String, val labelRes: Int, val iconRes: Int) { + PLAY(BuildConfig.APPLICATION_ID + ".menu.PLAY", R.string.lbl_play, R.drawable.ic_play_24), + SHUFFLE( + BuildConfig.APPLICATION_ID + ".menu.SHUFFLE", + R.string.lbl_shuffle, + R.drawable.ic_shuffle_off_24), + PLAY_NEXT( + BuildConfig.APPLICATION_ID + ".menu.PLAY_NEXT", + R.string.lbl_play_next, + R.drawable.ic_play_next_24), + ADD_TO_QUEUE( + BuildConfig.APPLICATION_ID + ".menu.ADD_TO_QUEUE", + R.string.lbl_queue_add, + R.drawable.ic_queue_add_24) +} + typealias Sugar = Bundle.(Context) -> Unit fun header(@StringRes nameRes: Int): Sugar = { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 51563a49b1..8f24bcd5bf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -18,9 +18,13 @@ package org.oxycblt.auxio.music.service +import android.content.Context +import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.media.MediaBrowserServiceCompat.BrowserRoot import androidx.media.MediaBrowserServiceCompat.Result +import androidx.media.utils.MediaConstants +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -35,6 +39,7 @@ import org.oxycblt.auxio.util.logW class MusicServiceFragment @Inject constructor( + @ApplicationContext private val context: Context, private val indexer: Indexer, private val musicBrowser: MusicBrowser, private val musicRepository: MusicRepository @@ -77,7 +82,22 @@ constructor( } fun getRoot(maxItems: Int) = - BrowserRoot(MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), null) + BrowserRoot( + MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), + Bundle().apply { + val actions = + MediaMenuItem.entries.mapTo(ArrayList()) { + Bundle().apply { + putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.id) + putString( + MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, + context.getString(it.labelRes)) + } + } + putParcelableArrayList( + MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST, + actions) + }) fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } From a29f747341055539cbc635080d5f125a3155e56c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 16:39:07 -0600 Subject: [PATCH 25/66] music: build session menus from resources --- .../music/service/MediaItemTranslation.kt | 69 +++++++++++++++---- .../music/service/MusicServiceFragment.kt | 4 +- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 821b8a9618..85c7d76750 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -18,12 +18,17 @@ package org.oxycblt.auxio.music.service +import android.annotation.SuppressLint import android.content.Context import android.graphics.BitmapFactory import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat +import android.view.MenuInflater +import androidx.annotation.MenuRes import androidx.annotation.StringRes +import androidx.appcompat.view.menu.MenuBuilder +import androidx.core.view.children import androidx.media.utils.MediaConstants import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R @@ -78,7 +83,7 @@ sealed interface MediaSessionUID { } } -enum class MediaMenuItem(val id: String, val labelRes: Int, val iconRes: Int) { +enum class BrowserOption(val actionId: String, val labelRes: Int, val iconRes: Int) { PLAY(BuildConfig.APPLICATION_ID + ".menu.PLAY", R.string.lbl_play, R.drawable.ic_play_24), SHUFFLE( BuildConfig.APPLICATION_ID + ".menu.SHUFFLE", @@ -91,7 +96,31 @@ enum class MediaMenuItem(val id: String, val labelRes: Int, val iconRes: Int) { ADD_TO_QUEUE( BuildConfig.APPLICATION_ID + ".menu.ADD_TO_QUEUE", R.string.lbl_queue_add, - R.drawable.ic_queue_add_24) + R.drawable.ic_queue_add_24), + DETAILS( + BuildConfig.APPLICATION_ID + ".menu.DETAILS", + R.string.lbl_parent_detail, + R.drawable.ic_details_24), + ALBUM_DETAILS( + BuildConfig.APPLICATION_ID + ".menu.ALBUM_DETAILS", + R.string.lbl_album_details, + R.drawable.ic_album_24), + ARTIST_DETAILS( + BuildConfig.APPLICATION_ID + ".menu.ARTIST_DETAILS", + R.string.lbl_artist_details, + R.drawable.ic_artist_24), + + companion object { + +val ITEM_ID_MAP = mapOf( + R.id.action_play to BrowserOption.PLAY, + R.id.action_shuffle to BrowserOption.SHUFFLE, + R.id.action_play_next to BrowserOption.PLAY_NEXT, + R.id.action_queue_add to BrowserOption.ADD_TO_QUEUE, + R.id.action_detail to BrowserOption.DETAILS, + R.id.action_album_details to BrowserOption.ALBUM_DETAILS, + R.id.action_artist_details to BrowserOption.ARTIST_DETAILS + )} } typealias Sugar = Bundle.(Context) -> Unit @@ -101,13 +130,29 @@ fun header(@StringRes nameRes: Int): Sugar = { MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) } +private fun style(style: Int): Sugar = { + putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) +} + +private fun menu(@MenuRes res: Int): Sugar = { context -> + @SuppressLint("RestrictedApi") val builder = MenuBuilder(context) + MenuInflater(context).inflate(res, builder) + val menuIds = builder.children.mapNotNullTo(ArrayList()){ + BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId + } + putStringArrayList( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds) +} + +private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { + return Bundle().apply { sugars.forEach { this.it(context) } } +} + fun Category.toMediaItem(context: Context): MediaItem { val extras = - Bundle().apply { - putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) - } + makeExtras( + context, + style(MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)) val mediaSessionUID = MediaSessionUID.CategoryItem(this) val description = MediaDescriptionCompat.Builder() @@ -132,7 +177,7 @@ fun Song.toMediaDescription( } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.song)) return MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) @@ -163,7 +208,7 @@ fun Album.toMediaItem( } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.album)) val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val description = MediaDescriptionCompat.Builder() @@ -192,7 +237,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) }) - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.parent)) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -213,7 +258,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.parent)) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -233,7 +278,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } - val extras = Bundle().apply { sugar.forEach { this.it(context) } } + val extras = makeExtras(context, *sugar, menu(R.menu.playlist)) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 8f24bcd5bf..4b181e4174 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -86,9 +86,9 @@ constructor( MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), Bundle().apply { val actions = - MediaMenuItem.entries.mapTo(ArrayList()) { + BrowserOption.entries.mapTo(ArrayList()) { Bundle().apply { - putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.id) + putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId) putString( MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, context.getString(it.labelRes)) From 463b02f8715620a10d8d2e4eb41cf8beb1a8a179 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 20:59:32 -0600 Subject: [PATCH 26/66] service: remove external media3 support --- app/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cbf7ea3042..308962b34a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -94,7 +94,6 @@ android:exported="true" android:roundIcon="@mipmap/ic_launcher"> - From 6ff2d55a6889b435c861dd18b82471552c507cf5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:00:13 -0600 Subject: [PATCH 27/66] music: fix category id --- .../org/oxycblt/auxio/music/service/MediaItemTranslation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 85c7d76750..d80613ea66 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -45,7 +45,7 @@ import org.oxycblt.auxio.util.getPlural sealed interface MediaSessionUID { data class CategoryItem(val category: Category) : MediaSessionUID { - override fun toString() = "$ID_CATEGORY:$category" + override fun toString() = "$ID_CATEGORY:${category.id}" } data class SingleItem(val uid: Music.UID) : MediaSessionUID { From 2bc4ed020b0e7553acc7b4cbd6e1667b0a0d6246 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:03:26 -0600 Subject: [PATCH 28/66] playback: fix broken mediasession lifecycle --- .../playback/service/MediaSessionHolder.kt | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index c7b1007926..5646a0e0e8 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -43,8 +43,6 @@ import org.oxycblt.auxio.music.service.MediaSessionUID import org.oxycblt.auxio.music.service.toMediaDescription import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.playback.service.MediaSessionInterface -import org.oxycblt.auxio.playback.service.PlaybackActions import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.QueueChange @@ -62,34 +60,32 @@ import org.oxycblt.auxio.util.newMainPendingIntent class MediaSessionHolder private constructor( private val context: Context, - private val sessionInterface: MediaSessionInterface, + private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val bitmapProvider: BitmapProvider, - private val imageSettings: ImageSettings -) : - MediaSessionCompat.Callback(), - PlaybackStateManager.Listener, - ImageSettings.Listener, - PlaybackSettings.Listener { + private val imageSettings: ImageSettings, + private val mediaSessionInterface: MediaSessionInterface +) : PlaybackStateManager.Listener, ImageSettings.Listener, PlaybackSettings.Listener { class Factory @Inject constructor( - private val sessionInterface: MediaSessionInterface, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val bitmapProvider: BitmapProvider, private val imageSettings: ImageSettings, + private val mediaSessionInterface: MediaSessionInterface ) { - fun create(context: Context) = + fun create(context: Context, foregroundListener: ForegroundListener) = MediaSessionHolder( context, - sessionInterface, + foregroundListener, playbackManager, playbackSettings, bitmapProvider, - imageSettings) + imageSettings, + mediaSessionInterface) } private val mediaSession = MediaSessionCompat(context, context.packageName) @@ -100,20 +96,15 @@ private constructor( val notification: ForegroundServiceNotification get() = _notification - private var foregroundListener: ForegroundListener? = null - - fun attach(foregroundListener: ForegroundListener) { + init { + playbackManager.addListener(this) + playbackSettings.registerListener(this) + imageSettings.registerListener(this) mediaSession.apply { isActive = true setQueueTitle(context.getString(R.string.lbl_queue)) - setCallback(sessionInterface) - setFlags(MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS) + setCallback(mediaSessionInterface) } - this.foregroundListener = foregroundListener - playbackManager.addListener(this) - playbackSettings.registerListener(this) - imageSettings.registerListener(this) - mediaSession.setCallback(this) } /** @@ -121,7 +112,6 @@ private constructor( * the [NotificationComponent]. */ fun release() { - foregroundListener = null bitmapProvider.release() playbackSettings.unregisterListener(this) imageSettings.unregisterListener(this) @@ -179,7 +169,7 @@ private constructor( invalidateSessionState() _notification.updatePlaying(playbackManager.progression.isPlaying) if (!bitmapProvider.isBusy) { - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } } @@ -272,7 +262,7 @@ private constructor( song.date?.let { logD("Adding date information") builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) - builder.putString(MediaMetadataCompat.METADATA_KEY_YEAR, it.year.toString()) + builder.putLong(MediaMetadataCompat.METADATA_KEY_YEAR, it.year.toLong()) } // We are normally supposed to use URIs for album art, but that removes some of the @@ -290,7 +280,7 @@ private constructor( val metadata = builder.build() mediaSession.setMetadata(metadata) _notification.updateMetadata(metadata) - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } }) } @@ -382,7 +372,7 @@ private constructor( if (!bitmapProvider.isBusy) { logD("Not loading a bitmap, post the notification") - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } } From 4e4a99bbf3784f1882a4cbf4e968172e38ce4360 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:04:06 -0600 Subject: [PATCH 29/66] music: fix crash on browser child load --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 4f45fff6a3..5c556844a0 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -94,14 +94,9 @@ class AuxioService : musicFragment.getItem(itemId, result) } - override fun onLoadChildren(parentId: String, result: Result>) = - throw NotImplementedError() - - override fun onLoadChildren( - parentId: String, - result: Result>, - options: Bundle - ) = musicFragment.getChildren(parentId, result) + override fun onLoadChildren(parentId: String, result: Result>) { + musicFragment.getChildren(parentId, result) + } override fun onSearch(query: String, extras: Bundle?, result: Result>) { musicFragment.search(query, result) From 3af81404ac573ebec0ccdc5e5b599715bd2ed2e9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:05:35 -0600 Subject: [PATCH 30/66] playback: fix mediasessionholder instantiation --- .../oxycblt/auxio/playback/service/PlaybackServiceFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 42e60d7fd9..ceed7bb589 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -59,7 +59,7 @@ constructor( foregroundListener = listener playbackManager.addListener(this) exoHolder.attach() - sessionHolder = sessionHolderFactory.create(context) + sessionHolder = sessionHolderFactory.create(context, listener) systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) ContextCompat.registerReceiver( context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) From 48568d2a1dde47ba2f390396de26617d99448f20 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:05:48 -0600 Subject: [PATCH 31/66] playback: fix mediasessionholder package --- .../org/oxycblt/auxio/playback/service/MediaSessionHolder.kt | 2 +- .../oxycblt/auxio/playback/service/PlaybackServiceFragment.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 5646a0e0e8..f683876d2f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.system +package org.oxycblt.auxio.playback.service import android.annotation.SuppressLint import android.content.Context diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index ceed7bb589..0bb5b9fdaf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -31,7 +31,6 @@ import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.system.MediaSessionHolder import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent From 2857f7d92c80dce01e4eda15e16a2ebeaf7e82dd Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 29 Aug 2024 21:07:32 -0600 Subject: [PATCH 32/66] all: format/syntax fixes --- .../music/service/MediaItemTranslation.kt | 32 +++++++++---------- .../music/service/MusicServiceFragment.kt | 3 +- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index d80613ea66..8fb340a016 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -108,19 +108,19 @@ enum class BrowserOption(val actionId: String, val labelRes: Int, val iconRes: I ARTIST_DETAILS( BuildConfig.APPLICATION_ID + ".menu.ARTIST_DETAILS", R.string.lbl_artist_details, - R.drawable.ic_artist_24), + R.drawable.ic_artist_24); companion object { - -val ITEM_ID_MAP = mapOf( - R.id.action_play to BrowserOption.PLAY, - R.id.action_shuffle to BrowserOption.SHUFFLE, - R.id.action_play_next to BrowserOption.PLAY_NEXT, - R.id.action_queue_add to BrowserOption.ADD_TO_QUEUE, - R.id.action_detail to BrowserOption.DETAILS, - R.id.action_album_details to BrowserOption.ALBUM_DETAILS, - R.id.action_artist_details to BrowserOption.ARTIST_DETAILS - )} + val ITEM_ID_MAP = + mapOf( + R.id.action_play to PLAY, + R.id.action_shuffle to SHUFFLE, + R.id.action_play_next to PLAY_NEXT, + R.id.action_queue_add to ADD_TO_QUEUE, + R.id.action_detail to DETAILS, + R.id.action_album_details to ALBUM_DETAILS, + R.id.action_artist_details to ARTIST_DETAILS) + } } typealias Sugar = Bundle.(Context) -> Unit @@ -137,11 +137,11 @@ private fun style(style: Int): Sugar = { private fun menu(@MenuRes res: Int): Sugar = { context -> @SuppressLint("RestrictedApi") val builder = MenuBuilder(context) MenuInflater(context).inflate(res, builder) - val menuIds = builder.children.mapNotNullTo(ArrayList()){ - BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId - } - putStringArrayList( - MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds) + val menuIds = + builder.children.mapNotNullTo(ArrayList()) { + BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId + } + putStringArrayList(MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds) } private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 4b181e4174..8b212a89cb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -88,7 +88,8 @@ constructor( val actions = BrowserOption.entries.mapTo(ArrayList()) { Bundle().apply { - putString(MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId) + putString( + MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId) putString( MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, context.getString(it.labelRes)) From fd597ea16ad7a1591a9a9ccc995e7da55fde96b7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 30 Aug 2024 10:18:50 -0600 Subject: [PATCH 33/66] music: fix root menus shown --- .../main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index b62f9b2481..9988a049bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -193,7 +193,7 @@ constructor( ) = when (category) { is Category.Root -> { - val base = Category.MUSIC.take(category.amount) + val base = Category.MUSIC.take(category.amount - 1) if (base.size < Category.MUSIC.size) { base + Category.More(Category.MUSIC.size - base.size) } else { From e4310cfe170e3579b8a02c967890e2145d7a8b22 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 30 Aug 2024 10:19:31 -0600 Subject: [PATCH 34/66] music: fix broken android auto search --- .../org/oxycblt/auxio/music/service/MusicServiceFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 8b212a89cb..ba26d47788 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -123,9 +123,9 @@ constructor( } private fun Result.dispatchAsync(body: suspend () -> T?) { + detach() dispatchScope.launch { try { - detach() val result = body() if (result == null) { logW("Result is null") From 29d663f5000358ccc321f4bc855217f6b728f891 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:21 -0600 Subject: [PATCH 35/66] service: share home list logic between service/ui --- .../org/oxycblt/auxio/home/HomeSettings.kt | 4 +- .../org/oxycblt/auxio/home/HomeViewModel.kt | 78 +++++-------- .../auxio/home/list/HomeListGenerator.kt | 110 ++++++++++++++++++ .../org/oxycblt/auxio/list/ListSettings.kt | 22 +++- .../auxio/music/service/MusicBrowser.kt | 51 ++++---- 5 files changed, 187 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt index 5fc218cfe7..ec54942f3a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt @@ -42,9 +42,9 @@ interface HomeSettings : Settings { interface Listener { /** Called when the [homeTabs] configuration changes. */ - fun onTabsChanged() + fun onTabsChanged() {} /** Called when the [shouldHideCollaborators] configuration changes. */ - fun onHideCollaboratorsChanged() + fun onHideCollaboratorsChanged() {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 51bc049764..d681d9ac1a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -23,6 +23,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import org.oxycblt.auxio.home.list.HomeListGenerator import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions @@ -52,8 +53,9 @@ constructor( private val homeSettings: HomeSettings, private val listSettings: ListSettings, private val playbackSettings: PlaybackSettings, - private val musicRepository: MusicRepository, -) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener { + homeGeneratorFactory: HomeListGenerator.Factory +) : ViewModel(), HomeSettings.Listener, HomeListGenerator.Invalidator { + private val generator = homeGeneratorFactory.create(this) private val _songList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ @@ -165,46 +167,37 @@ constructor( get() = _showOuter init { - musicRepository.addUpdateListener(this) homeSettings.registerListener(this) } override fun onCleared() { super.onCleared() - musicRepository.removeUpdateListener(this) homeSettings.unregisterListener(this) + generator.release() } - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary - if (changes.deviceLibrary && deviceLibrary != null) { - logD("Refreshing library") - // Get the each list of items in the library to use as our list data. - // Applying the preferred sorting to them. - _songInstructions.put(UpdateInstructions.Diff) - _songList.value = listSettings.songSort.songs(deviceLibrary.songs) - _albumInstructions.put(UpdateInstructions.Diff) - _albumList.value = listSettings.albumSort.albums(deviceLibrary.albums) - _artistInstructions.put(UpdateInstructions.Diff) - _artistList.value = - listSettings.artistSort.artists( - if (homeSettings.shouldHideCollaborators) { - logD("Filtering collaborator artists") - // Hide Collaborators is enabled, filter out collaborators. - deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() } - } else { - logD("Using all artists") - deviceLibrary.artists - }) - _genreInstructions.put(UpdateInstructions.Diff) - _genreList.value = listSettings.genreSort.genres(deviceLibrary.genres) - } - - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - logD("Refreshing playlists") - _playlistInstructions.put(UpdateInstructions.Diff) - _playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists) + override fun invalidate(type: MusicType, instructions: UpdateInstructions) { + when (type) { + MusicType.SONGS -> { + _songList.value = generator.songs() + _songInstructions.put(instructions) + } + MusicType.ALBUMS -> { + _albumList.value = generator.albums() + _albumInstructions.put(instructions) + } + MusicType.ARTISTS -> { + _artistList.value = generator.artists() + _artistInstructions.put(instructions) + } + MusicType.GENRES -> { + _genreList.value = generator.genres() + _genreInstructions.put(instructions) + } + MusicType.PLAYLISTS -> { + _playlistList.value = generator.playlists() + _playlistInstructions.put(instructions) + } } } @@ -215,13 +208,6 @@ constructor( _shouldRecreate.put(Unit) } - override fun onHideCollaboratorsChanged() { - // Changes in the hide collaborator setting will change the artist contents - // of the library, consider it a library update. - logD("Collaborator setting changed, forwarding update") - onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - } - /** * Apply a new [Sort] to [songList]. * @@ -229,8 +215,6 @@ constructor( */ fun applySongSort(sort: Sort) { listSettings.songSort = sort - _songInstructions.put(UpdateInstructions.Replace(0)) - _songList.value = listSettings.songSort.songs(_songList.value) } /** @@ -240,8 +224,6 @@ constructor( */ fun applyAlbumSort(sort: Sort) { listSettings.albumSort = sort - _albumInstructions.put(UpdateInstructions.Replace(0)) - _albumList.value = listSettings.albumSort.albums(_albumList.value) } /** @@ -251,8 +233,6 @@ constructor( */ fun applyArtistSort(sort: Sort) { listSettings.artistSort = sort - _artistInstructions.put(UpdateInstructions.Replace(0)) - _artistList.value = listSettings.artistSort.artists(_artistList.value) } /** @@ -262,8 +242,6 @@ constructor( */ fun applyGenreSort(sort: Sort) { listSettings.genreSort = sort - _genreInstructions.put(UpdateInstructions.Replace(0)) - _genreList.value = listSettings.genreSort.genres(_genreList.value) } /** @@ -273,8 +251,6 @@ constructor( */ fun applyPlaylistSort(sort: Sort) { listSettings.playlistSort = sort - _playlistInstructions.put(UpdateInstructions.Replace(0)) - _playlistList.value = listSettings.playlistSort.playlists(_playlistList.value) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt new file mode 100644 index 0000000000..faca4bb568 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt @@ -0,0 +1,110 @@ +package org.oxycblt.auxio.home.list + +import org.oxycblt.auxio.home.HomeSettings +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.adapter.UpdateInstructions +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.util.logD +import javax.inject.Inject + +interface HomeListGenerator { + fun songs(): List + fun albums(): List + fun artists(): List + fun genres(): List + fun playlists(): List + fun release() + + interface Invalidator { + fun invalidate(type: MusicType, instructions: UpdateInstructions) + } + + interface Factory { + fun create(invalidator: Invalidator): HomeListGenerator + } +} + +private class HomeListGeneratorImpl( + private val invalidator: HomeListGenerator.Invalidator, + private val homeSettings: HomeSettings, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository, +) : HomeListGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { + override fun songs() = + musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() + override fun albums() = musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList() + override fun artists() = musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } ?: emptyList() + override fun genres() = musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList() + override fun playlists() = musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } ?: emptyList() + + init { + homeSettings.registerListener(this) + listSettings.registerListener(this) + musicRepository.addUpdateListener(this) + } + + override fun release() { + homeSettings.unregisterListener(this) + listSettings.unregisterListener(this) + musicRepository.removeUpdateListener(this) + } + + override fun onHideCollaboratorsChanged() { + // Changes in the hide collaborator setting will change the artist contents + // of the library, consider it a library update. + logD("Collaborator setting changed, forwarding update") + onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) + } + + override fun onSongSortChanged() { + super.onSongSortChanged() + invalidator.invalidate(MusicType.SONGS, UpdateInstructions.Replace(0)) + } + + override fun onAlbumSortChanged() { + super.onAlbumSortChanged() + invalidator.invalidate(MusicType.ALBUMS, UpdateInstructions.Replace(0)) + } + + override fun onArtistSortChanged() { + super.onArtistSortChanged() + invalidator.invalidate(MusicType.ARTISTS, UpdateInstructions.Replace(0)) + } + + override fun onGenreSortChanged() { + super.onGenreSortChanged() + invalidator.invalidate(MusicType.GENRES, UpdateInstructions.Replace(0)) + } + + override fun onPlaylistSortChanged() { + super.onPlaylistSortChanged() + invalidator.invalidate(MusicType.PLAYLISTS, UpdateInstructions.Replace(0)) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + val deviceLibrary = musicRepository.deviceLibrary + if (changes.deviceLibrary && deviceLibrary != null) { + logD("Refreshing library") + // Get the each list of items in the library to use as our list data. + // Applying the preferred sorting to them. + invalidator.invalidate(MusicType.SONGS, UpdateInstructions.Diff) + invalidator.invalidate(MusicType.ALBUMS, UpdateInstructions.Diff) + invalidator.invalidate(MusicType.ARTISTS, UpdateInstructions.Diff) + invalidator.invalidate(MusicType.GENRES, UpdateInstructions.Diff) + } + + val userLibrary = musicRepository.userLibrary + if (changes.userLibrary && userLibrary != null) { + logD("Refreshing playlists") + invalidator.invalidate(MusicType.PLAYLISTS, UpdateInstructions.Diff) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index 3f3388b732..9b0bb7f4f2 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -26,7 +26,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.settings.Settings -interface ListSettings : Settings { +interface ListSettings : Settings { /** The [Sort] mode used in Song lists. */ var songSort: Sort /** The [Sort] mode used in Album lists. */ @@ -43,10 +43,18 @@ interface ListSettings : Settings { var artistSongSort: Sort /** The [Sort] mode used in a Genre's Song list. */ var genreSongSort: Sort + + interface Listener { + fun onSongSortChanged() {} + fun onAlbumSortChanged() {} + fun onArtistSortChanged() {} + fun onGenreSortChanged() {} + fun onPlaylistSortChanged() {} + } } class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) : - Settings.Impl(context), ListSettings { + Settings.Impl(context), ListSettings { override var songSort: Sort get() = Sort.fromIntCode( @@ -145,4 +153,14 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont apply() } } + + override fun onSettingChanged(key: String, listener: ListSettings.Listener) { + when (key) { + getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged() + getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged() + getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged() + getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged() + getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged() + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 9988a049bd..c4d83f6f92 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -23,13 +23,16 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R +import org.oxycblt.auxio.home.list.HomeListGenerator import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary @@ -42,12 +45,14 @@ constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, - private val listSettings: ListSettings -) : MusicRepository.UpdateListener { + private val listSettings: ListSettings, + homeGeneratorFactory: HomeListGenerator.Factory +) : MusicRepository.UpdateListener, HomeListGenerator.Invalidator { interface Invalidator { fun invalidateMusic(ids: Set) } + private val generator = homeGeneratorFactory.create(this) private var invalidator: Invalidator? = null fun attach(invalidator: Invalidator) { @@ -59,6 +64,18 @@ constructor( musicRepository.removeUpdateListener(this) } + override fun invalidate(type: MusicType, instructions: UpdateInstructions) { + val category = when (type) { + MusicType.SONGS -> Category.Songs + MusicType.ALBUMS -> Category.Albums + MusicType.ARTISTS -> Category.Artists + MusicType.GENRES -> Category.Genres + MusicType.PLAYLISTS -> Category.Playlists + } + val id = MediaSessionUID.CategoryItem(category).toString() + invalidator?.invalidateMusic(setOf(id)) + } + override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary val invalidate = mutableSetOf() @@ -126,7 +143,7 @@ constructor( return listOf() } - return getMediaItemList(parentId, deviceLibrary, userLibrary) + return getMediaItemList(parentId) } suspend fun search(query: String): MutableList { @@ -166,13 +183,11 @@ constructor( } private fun getMediaItemList( - id: String, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary + id: String ): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.CategoryItem -> { - getCategoryMediaItems(mediaSessionUID.category, deviceLibrary, userLibrary) + getCategoryMediaItems(mediaSessionUID.category) } is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) @@ -187,9 +202,7 @@ constructor( } private fun getCategoryMediaItems( - category: Category, - deviceLibrary: DeviceLibrary, - userLibrary: UserLibrary + category: Category ) = when (category) { is Category.Root -> { @@ -203,19 +216,11 @@ constructor( } is Category.More -> Category.MUSIC.takeLast(category.remainder).map { it.toMediaItem(context) } - is Category.Songs -> - listSettings.songSort.songs(deviceLibrary.songs).map { - it.toMediaItem(context, null) - } - is Category.Albums -> - listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } - is Category.Artists -> - listSettings.artistSort.artists(deviceLibrary.artists).map { - it.toMediaItem(context) - } - is Category.Genres -> - listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } - is Category.Playlists -> userLibrary.playlists.map { it.toMediaItem(context) } + is Category.Songs -> generator.songs().map { it.toMediaItem(context) } + is Category.Albums -> generator.albums().map { it.toMediaItem(context) } + is Category.Artists -> generator.artists().map { it.toMediaItem(context) } + is Category.Genres -> generator.genres().map { it.toMediaItem(context) } + is Category.Playlists -> generator.playlists().map { it.toMediaItem(context) } } private fun getChildMediaItems(uid: Music.UID): List? { From 3832c4e525257f29ff221d785bfd907e2d7a1136 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:37 -0600 Subject: [PATCH 36/66] home: mirror tabs to mediasession browser --- .../HomeListGenerator.kt => HomeGenerator.kt} | 51 +++++++------ .../org/oxycblt/auxio/home/HomeViewModel.kt | 46 ++++-------- .../auxio/home/tabs/AdaptiveTabStrategy.kt | 37 +++------- .../java/org/oxycblt/auxio/music/MusicType.kt | 11 +++ .../music/service/MediaItemTranslation.kt | 10 +-- .../auxio/music/service/MusicBrowser.kt | 74 +++++++++---------- .../oxycblt/auxio/music/service/TabNode.kt | 70 ++++++++++++++++++ 7 files changed, 175 insertions(+), 124 deletions(-) rename app/src/main/java/org/oxycblt/auxio/home/{list/HomeListGenerator.kt => HomeGenerator.kt} (67%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt similarity index 67% rename from app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt rename to app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt index faca4bb568..2da76e2e7d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -1,6 +1,6 @@ -package org.oxycblt.auxio.home.list +package org.oxycblt.auxio.home -import org.oxycblt.auxio.home.HomeSettings +import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.Album @@ -10,39 +10,46 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD -import javax.inject.Inject -interface HomeListGenerator { +interface HomeGenerator { fun songs(): List fun albums(): List fun artists(): List fun genres(): List fun playlists(): List + fun tabs(): List fun release() interface Invalidator { - fun invalidate(type: MusicType, instructions: UpdateInstructions) + fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) + fun invalidateTabs() } interface Factory { - fun create(invalidator: Invalidator): HomeListGenerator + fun create(invalidator: Invalidator): HomeGenerator } } -private class HomeListGeneratorImpl( - private val invalidator: HomeListGenerator.Invalidator, +private class HomeGeneratorImpl( + private val invalidator: HomeGenerator.Invalidator, private val homeSettings: HomeSettings, private val listSettings: ListSettings, private val musicRepository: MusicRepository, -) : HomeListGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { +) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { override fun songs() = musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() override fun albums() = musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList() override fun artists() = musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } ?: emptyList() override fun genres() = musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList() override fun playlists() = musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } ?: emptyList() + override fun tabs() = + homeSettings.homeTabs.filterIsInstance().map { it.type } + + + override fun onTabsChanged() { + invalidator.invalidateTabs() + } init { homeSettings.registerListener(this) @@ -51,9 +58,9 @@ private class HomeListGeneratorImpl( } override fun release() { - homeSettings.unregisterListener(this) - listSettings.unregisterListener(this) musicRepository.removeUpdateListener(this) + listSettings.unregisterListener(this) + homeSettings.unregisterListener(this) } override fun onHideCollaboratorsChanged() { @@ -65,27 +72,27 @@ private class HomeListGeneratorImpl( override fun onSongSortChanged() { super.onSongSortChanged() - invalidator.invalidate(MusicType.SONGS, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Replace(0)) } override fun onAlbumSortChanged() { super.onAlbumSortChanged() - invalidator.invalidate(MusicType.ALBUMS, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Replace(0)) } override fun onArtistSortChanged() { super.onArtistSortChanged() - invalidator.invalidate(MusicType.ARTISTS, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Replace(0)) } override fun onGenreSortChanged() { super.onGenreSortChanged() - invalidator.invalidate(MusicType.GENRES, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Replace(0)) } override fun onPlaylistSortChanged() { super.onPlaylistSortChanged() - invalidator.invalidate(MusicType.PLAYLISTS, UpdateInstructions.Replace(0)) + invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Replace(0)) } override fun onMusicChanges(changes: MusicRepository.Changes) { @@ -94,16 +101,16 @@ private class HomeListGeneratorImpl( logD("Refreshing library") // Get the each list of items in the library to use as our list data. // Applying the preferred sorting to them. - invalidator.invalidate(MusicType.SONGS, UpdateInstructions.Diff) - invalidator.invalidate(MusicType.ALBUMS, UpdateInstructions.Diff) - invalidator.invalidate(MusicType.ARTISTS, UpdateInstructions.Diff) - invalidator.invalidate(MusicType.GENRES, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff) } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { logD("Refreshing playlists") - invalidator.invalidate(MusicType.PLAYLISTS, UpdateInstructions.Diff) + invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index d681d9ac1a..b2d62a6b60 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -23,15 +23,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.home.list.HomeListGenerator import org.oxycblt.auxio.home.tabs.Tab +import org.oxycblt.auxio.home.tabs.TabListGenerator import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song @@ -50,12 +49,11 @@ import org.oxycblt.auxio.util.logD class HomeViewModel @Inject constructor( - private val homeSettings: HomeSettings, private val listSettings: ListSettings, private val playbackSettings: PlaybackSettings, - homeGeneratorFactory: HomeListGenerator.Factory -) : ViewModel(), HomeSettings.Listener, HomeListGenerator.Invalidator { - private val generator = homeGeneratorFactory.create(this) + homeGeneratorFactory: HomeGenerator.Factory +) : ViewModel(), HomeGenerator.Invalidator { + private val homeGenerator = homeGeneratorFactory.create(this) private val _songList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ @@ -138,7 +136,7 @@ constructor( * A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible * [Tab]s. */ - var currentTabTypes = makeTabTypes() + var currentTabTypes = homeGenerator.tabs() private set private val _currentTabType = MutableStateFlow(currentTabTypes[0]) @@ -166,45 +164,38 @@ constructor( val showOuter: Event get() = _showOuter - init { - homeSettings.registerListener(this) - } - override fun onCleared() { super.onCleared() - homeSettings.unregisterListener(this) - generator.release() + homeGenerator.release() } - override fun invalidate(type: MusicType, instructions: UpdateInstructions) { + override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { when (type) { MusicType.SONGS -> { - _songList.value = generator.songs() + _songList.value = homeGenerator.songs() _songInstructions.put(instructions) } MusicType.ALBUMS -> { - _albumList.value = generator.albums() + _albumList.value = homeGenerator.albums() _albumInstructions.put(instructions) } MusicType.ARTISTS -> { - _artistList.value = generator.artists() + _artistList.value = homeGenerator.artists() _artistInstructions.put(instructions) } MusicType.GENRES -> { - _genreList.value = generator.genres() + _genreList.value = homeGenerator.genres() _genreInstructions.put(instructions) } MusicType.PLAYLISTS -> { - _playlistList.value = generator.playlists() + _playlistList.value = homeGenerator.playlists() _playlistInstructions.put(instructions) } } } - override fun onTabsChanged() { - // Tabs changed, update the current tabs and set up a re-create event. - currentTabTypes = makeTabTypes() - logD("Updating tabs: ${currentTabType.value}") + override fun invalidateTabs() { + currentTabTypes = homeGenerator.tabs() _shouldRecreate.put(Unit) } @@ -290,15 +281,6 @@ constructor( fun showAbout() { _showOuter.put(Outer.About) } - - /** - * Create a list of [MusicType]s representing a simpler version of the [Tab] configuration. - * - * @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in - * the same way as the configuration. - */ - private fun makeTabTypes() = - homeSettings.homeTabs.filterIsInstance().map { it.type } } sealed interface Outer { diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 73170ef4c3..237d8edd6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -37,40 +37,23 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : private val width = context.resources.configuration.smallestScreenWidthDp override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - val icon: Int - val string: Int - - when (tabs[position]) { - MusicType.SONGS -> { - icon = R.drawable.ic_song_24 - string = R.string.lbl_songs - } - MusicType.ALBUMS -> { - icon = R.drawable.ic_album_24 - string = R.string.lbl_albums - } - MusicType.ARTISTS -> { - icon = R.drawable.ic_artist_24 - string = R.string.lbl_artists - } - MusicType.GENRES -> { - icon = R.drawable.ic_genre_24 - string = R.string.lbl_genres - } - MusicType.PLAYLISTS -> { - icon = R.drawable.ic_playlist_24 - string = R.string.lbl_playlists - } + val homeTab = tabs[position] + val icon = when (homeTab) { + MusicType.SONGS -> R.drawable.ic_song_24 + MusicType.ALBUMS -> R.drawable.ic_album_24 + MusicType.ARTISTS -> R.drawable.ic_artist_24 + MusicType.GENRES -> R.drawable.ic_genre_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_24 } // Use expected sw* size thresholds when choosing a configuration. when { // On small screens, only display an icon. - width < 370 -> tab.setIcon(icon).setContentDescription(string) + width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes) // On large screens, display an icon and text. - width < 600 -> tab.setText(string) + width < 600 -> tab.setText(homeTab.nameRes).setIcon(icon) // On medium-size screens, display text. - else -> tab.setIcon(icon).setText(string) + else -> tab.setIcon(icon).setText(homeTab.nameRes) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt index 19f535af16..572280f8ea 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicType.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.music import org.oxycblt.auxio.IntegerTable +import org.oxycblt.auxio.R /** * General configuration enum to control what kind of music is being worked with. @@ -52,6 +53,16 @@ enum class MusicType { PLAYLISTS -> IntegerTable.MUSIC_MODE_PLAYLISTS } + val nameRes: Int + get() = + when (this) { + SONGS -> R.string.lbl_songs + ALBUMS -> R.string.lbl_albums + ARTISTS -> R.string.lbl_artists + GENRES -> R.string.lbl_genres + PLAYLISTS -> R.string.lbl_playlists + } + companion object { /** * Convert a [MusicType] integer representation into an instance. diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 8fb340a016..624247f8eb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -44,8 +44,8 @@ import org.oxycblt.auxio.playback.formatDurationDs import org.oxycblt.auxio.util.getPlural sealed interface MediaSessionUID { - data class CategoryItem(val category: Category) : MediaSessionUID { - override fun toString() = "$ID_CATEGORY:${category.id}" + data class Tab(val node: TabNode) : MediaSessionUID { + override fun toString() = "$ID_CATEGORY:${node.id}" } data class SingleItem(val uid: Music.UID) : MediaSessionUID { @@ -66,7 +66,7 @@ sealed interface MediaSessionUID { return null } return when (parts[0]) { - ID_CATEGORY -> CategoryItem(Category.fromString(parts[1]) ?: return null) + ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null) ID_ITEM -> { val uids = parts[1].split(">", limit = 2) if (uids.size == 1) { @@ -148,12 +148,12 @@ private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { return Bundle().apply { sugars.forEach { this.it(context) } } } -fun Category.toMediaItem(context: Context): MediaItem { +fun TabNode.toMediaItem(context: Context): MediaItem { val extras = makeExtras( context, style(MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM)) - val mediaSessionUID = MediaSessionUID.CategoryItem(this) + val mediaSessionUID = MediaSessionUID.Tab(this) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index c4d83f6f92..0ab9db8dbc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -23,7 +23,7 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.home.list.HomeListGenerator +import org.oxycblt.auxio.home.HomeGenerator import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort @@ -35,8 +35,6 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.search.SearchEngine class MusicBrowser @@ -46,13 +44,13 @@ constructor( private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, private val listSettings: ListSettings, - homeGeneratorFactory: HomeListGenerator.Factory -) : MusicRepository.UpdateListener, HomeListGenerator.Invalidator { + homeGeneratorFactory: HomeGenerator.Factory +) : MusicRepository.UpdateListener, HomeGenerator.Invalidator { interface Invalidator { fun invalidateMusic(ids: Set) } - private val generator = homeGeneratorFactory.create(this) + private val homeGenerator = homeGeneratorFactory.create(this) private var invalidator: Invalidator? = null fun attach(invalidator: Invalidator) { @@ -64,26 +62,24 @@ constructor( musicRepository.removeUpdateListener(this) } - override fun invalidate(type: MusicType, instructions: UpdateInstructions) { - val category = when (type) { - MusicType.SONGS -> Category.Songs - MusicType.ALBUMS -> Category.Albums - MusicType.ARTISTS -> Category.Artists - MusicType.GENRES -> Category.Genres - MusicType.PLAYLISTS -> Category.Playlists - } - val id = MediaSessionUID.CategoryItem(category).toString() + override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { + val id = MediaSessionUID.Tab(TabNode.Home(type)).toString() invalidator?.invalidateMusic(setOf(id)) } + override fun invalidateTabs() { + for (i in 0..10) { + // TODO: Temporary bodge, move the amount parameter to a bundle extra + val rootId = MediaSessionUID.Tab(TabNode.Root(i)).toString() + val moreId = MediaSessionUID.Tab(TabNode.More(i)).toString() + invalidator?.invalidateMusic(setOf(rootId, moreId)) + } + } + override fun onMusicChanges(changes: MusicRepository.Changes) { val deviceLibrary = musicRepository.deviceLibrary val invalidate = mutableSetOf() if (changes.deviceLibrary && deviceLibrary != null) { - Category.DEVICE_MUSIC.forEach { - invalidate.add(MediaSessionUID.CategoryItem(it).toString()) - } - deviceLibrary.albums.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() invalidate.add(id) @@ -101,9 +97,6 @@ constructor( } val userLibrary = musicRepository.userLibrary if (changes.userLibrary && userLibrary != null) { - Category.USER_MUSIC.forEach { - invalidate.add(MediaSessionUID.CategoryItem(it).toString()) - } userLibrary.playlists.forEach { val id = MediaSessionUID.SingleItem(it.uid).toString() invalidate.add(id) @@ -118,7 +111,7 @@ constructor( fun getItem(mediaId: String): MediaItem? { val music = when (val uid = MediaSessionUID.fromString(mediaId)) { - is MediaSessionUID.CategoryItem -> return uid.category.toMediaItem(context) + is MediaSessionUID.Tab -> return uid.node.toMediaItem(context) is MediaSessionUID.SingleItem -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } is MediaSessionUID.ChildItem -> @@ -186,8 +179,8 @@ constructor( id: String ): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { - is MediaSessionUID.CategoryItem -> { - getCategoryMediaItems(mediaSessionUID.category) + is MediaSessionUID.Tab -> { + getCategoryMediaItems(mediaSessionUID.node) } is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) @@ -202,25 +195,30 @@ constructor( } private fun getCategoryMediaItems( - category: Category + node: TabNode ) = - when (category) { - is Category.Root -> { - val base = Category.MUSIC.take(category.amount - 1) - if (base.size < Category.MUSIC.size) { - base + Category.More(Category.MUSIC.size - base.size) + when (node) { + is TabNode.Root -> { + val tabs = homeGenerator.tabs() + val base = tabs.take(node.amount - 1).map { TabNode.Home(it) } + if (base.size < tabs.size) { + base + TabNode.More(Category.MUSIC.size - base.size) } else { base } .map { it.toMediaItem(context) } } - is Category.More -> - Category.MUSIC.takeLast(category.remainder).map { it.toMediaItem(context) } - is Category.Songs -> generator.songs().map { it.toMediaItem(context) } - is Category.Albums -> generator.albums().map { it.toMediaItem(context) } - is Category.Artists -> generator.artists().map { it.toMediaItem(context) } - is Category.Genres -> generator.genres().map { it.toMediaItem(context) } - is Category.Playlists -> generator.playlists().map { it.toMediaItem(context) } + is TabNode.More -> + homeGenerator.tabs().takeLast(node.remainder).map { + TabNode.Home(it).toMediaItem(context) } + is TabNode.Home -> + when (node.type) { + MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context, null) } + MusicType.ALBUMS -> homeGenerator.albums().map { it.toMediaItem(context) } + MusicType.ARTISTS -> homeGenerator.artists().map { it.toMediaItem(context) } + MusicType.GENRES -> homeGenerator.genres().map { it.toMediaItem(context) } + MusicType.PLAYLISTS -> homeGenerator.playlists().map { it.toMediaItem(context) } + } } private fun getChildMediaItems(uid: Music.UID): List? { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt new file mode 100644 index 0000000000..02a4aa595f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -0,0 +1,70 @@ +package org.oxycblt.auxio.music.service + +import org.oxycblt.auxio.R +import org.oxycblt.auxio.home.tabs.Tab +import org.oxycblt.auxio.music.MusicType + +sealed class TabNode { + abstract val id: String + abstract val data: Int + abstract val nameRes: Int + abstract val bitmapRes: Int? + + override fun toString() = "${id}/${data}" + + data class Root(val amount: Int) : TabNode() { + override val id = ID + override val data = amount + override val nameRes = R.string.info_app_name + override val bitmapRes = null + + companion object { + const val ID = "root" + } + } + + data class More(val remainder: Int) : TabNode() { + override val id = ID + override val data = remainder + override val nameRes = R.string.lbl_more + override val bitmapRes = null + + companion object { + const val ID = "more" + } + } + + data class Home(val type: MusicType) : TabNode() { + override val id = ID + override val data = type.intCode + override val bitmapRes: Int + get() = when (type) { + MusicType.SONGS -> R.drawable.ic_song_bitmap_24 + MusicType.ALBUMS -> R.drawable.ic_album_bitmap_24 + MusicType.ARTISTS -> R.drawable.ic_artist_bitmap_24 + MusicType.GENRES -> R.drawable.ic_genre_bitmap_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_bitmap_24 + } + override val nameRes = type.nameRes + + companion object { + const val ID = "home" + } + } + + companion object { + fun fromString(str: String): TabNode? { + val split = str.split("/", limit = 2) + if (split.size != 2) { + return null + } + val data = split[1].toIntOrNull() ?: return null + return when (split[0]) { + Root.ID -> Root(data) + More.ID -> More(data) + Home.ID -> Home(MusicType.fromIntCode(data) ?: return null) + else -> null + } + } + } +} \ No newline at end of file From fcd4ef3dc8c61cbaac8896e7d75a111b9f0634d0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:39 -0600 Subject: [PATCH 37/66] all: build fixes --- .../org/oxycblt/auxio/home/HomeGenerator.kt | 63 ++++++++++++++++--- .../java/org/oxycblt/auxio/home/HomeModule.kt | 2 + .../org/oxycblt/auxio/home/HomeViewModel.kt | 1 - .../auxio/home/tabs/AdaptiveTabStrategy.kt | 15 ++--- .../org/oxycblt/auxio/list/ListSettings.kt | 4 ++ .../auxio/music/service/MusicBrowser.kt | 11 ++-- .../music/service/MusicServiceFragment.kt | 2 +- .../oxycblt/auxio/music/service/TabNode.kt | 37 ++++++++--- 8 files changed, 102 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt index 2da76e2e7d..5c551db0e7 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -1,5 +1,24 @@ +/* + * Copyright (c) 2024 Auxio Project + * HomeGenerator.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + package org.oxycblt.auxio.home +import javax.inject.Inject import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions @@ -14,15 +33,22 @@ import org.oxycblt.auxio.util.logD interface HomeGenerator { fun songs(): List + fun albums(): List + fun artists(): List + fun genres(): List + fun playlists(): List + fun tabs(): List + fun release() interface Invalidator { fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) + fun invalidateTabs() } @@ -31,6 +57,17 @@ interface HomeGenerator { } } +class HomeGeneratorFactoryImpl +@Inject +constructor( + private val homeSettings: HomeSettings, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository, +) : HomeGenerator.Factory { + override fun create(invalidator: HomeGenerator.Invalidator): HomeGenerator = + HomeGeneratorImpl(invalidator, homeSettings, listSettings, musicRepository) +} + private class HomeGeneratorImpl( private val invalidator: HomeGenerator.Invalidator, private val homeSettings: HomeSettings, @@ -39,13 +76,24 @@ private class HomeGeneratorImpl( ) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { override fun songs() = musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() - override fun albums() = musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList() - override fun artists() = musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } ?: emptyList() - override fun genres() = musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList() - override fun playlists() = musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } ?: emptyList() - override fun tabs() = - homeSettings.homeTabs.filterIsInstance().map { it.type } + override fun albums() = + musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } + ?: emptyList() + + override fun artists() = + musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } + ?: emptyList() + + override fun genres() = + musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } + ?: emptyList() + + override fun playlists() = + musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } + ?: emptyList() + + override fun tabs() = homeSettings.homeTabs.filterIsInstance().map { it.type } override fun onTabsChanged() { invalidator.invalidateTabs() @@ -113,5 +161,4 @@ private class HomeGeneratorImpl( invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt index a578b6e077..e7e2f91183 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt @@ -27,4 +27,6 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) interface HomeModule { @Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings + + @Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index b2d62a6b60..206b4bd0a1 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -24,7 +24,6 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.home.tabs.TabListGenerator import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 237d8edd6c..45f63fd7d0 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -38,13 +38,14 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { val homeTab = tabs[position] - val icon = when (homeTab) { - MusicType.SONGS -> R.drawable.ic_song_24 - MusicType.ALBUMS -> R.drawable.ic_album_24 - MusicType.ARTISTS -> R.drawable.ic_artist_24 - MusicType.GENRES -> R.drawable.ic_genre_24 - MusicType.PLAYLISTS -> R.drawable.ic_playlist_24 - } + val icon = + when (homeTab) { + MusicType.SONGS -> R.drawable.ic_song_24 + MusicType.ALBUMS -> R.drawable.ic_album_24 + MusicType.ARTISTS -> R.drawable.ic_artist_24 + MusicType.GENRES -> R.drawable.ic_genre_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_24 + } // Use expected sw* size thresholds when choosing a configuration. when { diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index 9b0bb7f4f2..c817dcf0e6 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -46,9 +46,13 @@ interface ListSettings : Settings { interface Listener { fun onSongSortChanged() {} + fun onAlbumSortChanged() {} + fun onArtistSortChanged() {} + fun onGenreSortChanged() {} + fun onPlaylistSortChanged() {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 0ab9db8dbc..8fe591f986 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -175,9 +175,7 @@ constructor( return music } - private fun getMediaItemList( - id: String - ): List? { + private fun getMediaItemList(id: String): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.Tab -> { getCategoryMediaItems(mediaSessionUID.node) @@ -194,9 +192,7 @@ constructor( } } - private fun getCategoryMediaItems( - node: TabNode - ) = + private fun getCategoryMediaItems(node: TabNode) = when (node) { is TabNode.Root -> { val tabs = homeGenerator.tabs() @@ -210,7 +206,8 @@ constructor( } is TabNode.More -> homeGenerator.tabs().takeLast(node.remainder).map { - TabNode.Home(it).toMediaItem(context) } + TabNode.Home(it).toMediaItem(context) + } is TabNode.Home -> when (node.type) { MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context, null) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index ba26d47788..b3617ee5d2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -83,7 +83,7 @@ constructor( fun getRoot(maxItems: Int) = BrowserRoot( - MediaSessionUID.CategoryItem(Category.Root(maxItems)).toString(), + MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), Bundle().apply { val actions = BrowserOption.entries.mapTo(ArrayList()) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt index 02a4aa595f..1e97055113 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -1,7 +1,24 @@ +/* + * Copyright (c) 2024 Auxio Project + * TabNode.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + package org.oxycblt.auxio.music.service import org.oxycblt.auxio.R -import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.music.MusicType sealed class TabNode { @@ -38,13 +55,15 @@ sealed class TabNode { override val id = ID override val data = type.intCode override val bitmapRes: Int - get() = when (type) { - MusicType.SONGS -> R.drawable.ic_song_bitmap_24 - MusicType.ALBUMS -> R.drawable.ic_album_bitmap_24 - MusicType.ARTISTS -> R.drawable.ic_artist_bitmap_24 - MusicType.GENRES -> R.drawable.ic_genre_bitmap_24 - MusicType.PLAYLISTS -> R.drawable.ic_playlist_bitmap_24 - } + get() = + when (type) { + MusicType.SONGS -> R.drawable.ic_song_bitmap_24 + MusicType.ALBUMS -> R.drawable.ic_album_bitmap_24 + MusicType.ARTISTS -> R.drawable.ic_artist_bitmap_24 + MusicType.GENRES -> R.drawable.ic_genre_bitmap_24 + MusicType.PLAYLISTS -> R.drawable.ic_playlist_bitmap_24 + } + override val nameRes = type.nameRes companion object { @@ -67,4 +86,4 @@ sealed class TabNode { } } } -} \ No newline at end of file +} From d2aed8ee23540a9cc9bcb999345730390578abba Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:41 -0600 Subject: [PATCH 38/66] music: remove category --- .../oxycblt/auxio/music/service/Category.kt | 113 ------------------ .../auxio/music/service/MusicBrowser.kt | 2 +- 2 files changed, 1 insertion(+), 114 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/music/service/Category.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt deleted file mode 100644 index 46676d3cca..0000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Category.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2024 Auxio Project - * Category.kt is part of Auxio. - * - * This program 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. - * - * This program 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 this program. If not, see . - */ - -package org.oxycblt.auxio.music.service - -import org.oxycblt.auxio.R - -sealed interface Category { - val id: String - val nameRes: Int - val bitmapRes: Int? - - data class Root(val amount: Int) : Category { - override val id = "root/$amount" - override val nameRes = R.string.info_app_name - override val bitmapRes = null - - companion object { - const val ID_PREFIX = "root" - - fun fromString(str: String): Root? { - val split = str.split("/", limit = 2) - if (split.size != 2) { - return null - } - val limit = split[1].toIntOrNull() ?: return null - return Root(limit) - } - } - } - - data class More(val remainder: Int) : Category { - override val id = "more/$remainder" - override val nameRes = R.string.lbl_more - override val bitmapRes = null - - companion object { - const val ID_PREFIX = "more" - - fun fromString(str: String): More? { - val split = str.split("/", limit = 2) - if (split.size != 2) { - return null - } - val remainder = split[1].toIntOrNull() ?: return null - return More(remainder) - } - } - } - - data object Songs : Category { - override val id = "songs" - override val nameRes = R.string.lbl_songs - override val bitmapRes = R.drawable.ic_song_bitmap_24 - } - - data object Albums : Category { - override val id = "albums" - override val nameRes = R.string.lbl_albums - override val bitmapRes = R.drawable.ic_album_bitmap_24 - } - - data object Artists : Category { - override val id = "artists" - override val nameRes = R.string.lbl_artists - override val bitmapRes = R.drawable.ic_artist_bitmap_24 - } - - data object Genres : Category { - override val id = "genres" - override val nameRes = R.string.lbl_genres - override val bitmapRes = R.drawable.ic_genre_bitmap_24 - } - - data object Playlists : Category { - override val id = "playlists" - override val nameRes = R.string.lbl_playlists - override val bitmapRes = R.drawable.ic_playlist_bitmap_24 - } - - companion object { - val MUSIC = arrayOf(Songs, Albums, Artists, Genres, Playlists) - val DEVICE_MUSIC = arrayOf(Songs, Albums, Artists, Genres) - val USER_MUSIC = arrayOf(Playlists) - - fun fromString(str: String): Category? = - when { - str.startsWith(Root.ID_PREFIX) -> Root.fromString(str) - str.startsWith(More.ID_PREFIX) -> More.fromString(str) - str == Songs.id -> Songs - str == Albums.id -> Albums - str == Artists.id -> Artists - str == Genres.id -> Genres - str == Playlists.id -> Playlists - else -> null - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 8fe591f986..3a86473269 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -198,7 +198,7 @@ constructor( val tabs = homeGenerator.tabs() val base = tabs.take(node.amount - 1).map { TabNode.Home(it) } if (base.size < tabs.size) { - base + TabNode.More(Category.MUSIC.size - base.size) + base + TabNode.More(tabs.size - base.size) } else { base } From 8418dccdc6ca17d84ccbd36d0a46bec9f5301c3b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:43 -0600 Subject: [PATCH 39/66] music: use factory pattern in service components --- .../java/org/oxycblt/auxio/AuxioService.kt | 5 ++-- .../oxycblt/auxio/music/service/Indexer.kt | 28 +++++++++++------- .../auxio/music/service/MusicBrowser.kt | 25 ++++++++++------ .../music/service/MusicServiceFragment.kt | 29 +++++++++++-------- 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 5c556844a0..1b6a1a4c50 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -41,13 +41,14 @@ class AuxioService : MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { @Inject lateinit var playbackFragment: PlaybackServiceFragment - @Inject lateinit var musicFragment: MusicServiceFragment + @Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory + lateinit var musicFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() sessionToken = playbackFragment.attach(this) - musicFragment.attach(this, this) + musicFragment = musicFragmentFactory.create(this, this, this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index 4618a3fb23..ef3ecc8937 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -36,10 +36,9 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD -class Indexer -@Inject -constructor( - @ApplicationContext override val workerContext: Context, +class Indexer private constructor( + override val workerContext: Context, + private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, @@ -50,10 +49,21 @@ constructor( MusicRepository.IndexingListener, MusicRepository.UpdateListener, MusicSettings.Listener { + class Factory @Inject constructor( + private val playbackManager: PlaybackStateManager, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings, + private val imageLoader: ImageLoader, + private val contentObserver: SystemContentObserver + ) { + fun create(context: Context, listener: ForegroundListener) = + Indexer(context, listener, playbackManager, + musicRepository, musicSettings, imageLoader, contentObserver) + } + private val indexJob = Job() private val indexScope = CoroutineScope(indexJob + Dispatchers.IO) private var currentIndexJob: Job? = null - private var foregroundListener: ForegroundListener? = null private val indexingNotification = IndexingNotification(workerContext) private val observingNotification = ObservingNotification(workerContext) private val wakeLock = @@ -62,8 +72,7 @@ constructor( .newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") - fun attach(listener: ForegroundListener) { - foregroundListener = listener + init { musicSettings.registerListener(this) musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) @@ -77,7 +86,6 @@ constructor( musicRepository.addIndexingListener(this) musicRepository.addUpdateListener(this) musicRepository.removeIndexingListener(this) - foregroundListener = null } override fun requestIndex(withCache: Boolean) { @@ -91,7 +99,7 @@ constructor( override val scope = indexScope override fun onIndexingStateChanged() { - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + foregroundListener.updateForeground(ForegroundListener.Change.INDEXER) val state = musicRepository.indexingState if (state is IndexingState.Indexing) { wakeLock.acquireSafe() @@ -132,7 +140,7 @@ constructor( // the music loading process ends. if (musicRepository.indexingState == null) { logD("Not loading, updating idle session") - foregroundListener?.updateForeground(ForegroundListener.Change.INDEXER) + foregroundListener.updateForeground(ForegroundListener.Change.INDEXER) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 3a86473269..6547122878 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -37,24 +37,31 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.search.SearchEngine -class MusicBrowser -@Inject -constructor( - @ApplicationContext private val context: Context, +class MusicBrowser private constructor( + private val context: Context, + private val invalidator: Invalidator, private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, private val listSettings: ListSettings, homeGeneratorFactory: HomeGenerator.Factory ) : MusicRepository.UpdateListener, HomeGenerator.Invalidator { + + class Factory @Inject constructor( + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, + private val listSettings: ListSettings, + private val homeGeneratorFactory: HomeGenerator.Factory + ) { + fun create(context: Context, invalidator: Invalidator): MusicBrowser = + MusicBrowser(context, invalidator, musicRepository, searchEngine, listSettings, homeGeneratorFactory) + } interface Invalidator { fun invalidateMusic(ids: Set) } private val homeGenerator = homeGeneratorFactory.create(this) - private var invalidator: Invalidator? = null - fun attach(invalidator: Invalidator) { - this.invalidator = invalidator + init { musicRepository.addUpdateListener(this) } @@ -64,7 +71,7 @@ constructor( override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { val id = MediaSessionUID.Tab(TabNode.Home(type)).toString() - invalidator?.invalidateMusic(setOf(id)) + invalidator.invalidateMusic(setOf(id)) } override fun invalidateTabs() { @@ -72,7 +79,7 @@ constructor( // TODO: Temporary bodge, move the amount parameter to a bundle extra val rootId = MediaSessionUID.Tab(TabNode.Root(i)).toString() val moreId = MediaSessionUID.Tab(TabNode.More(i)).toString() - invalidator?.invalidateMusic(setOf(rootId, moreId)) + invalidator.invalidateMusic(setOf(rootId, moreId)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index b3617ee5d2..6cd420928b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -39,35 +39,40 @@ import org.oxycblt.auxio.util.logW class MusicServiceFragment @Inject constructor( - @ApplicationContext private val context: Context, - private val indexer: Indexer, - private val musicBrowser: MusicBrowser, + private val context: Context, + foregroundListener: ForegroundListener, + private val invalidator: Invalidator, + indexerFactory: Indexer.Factory, + musicBrowserFactory: MusicBrowser.Factory, private val musicRepository: MusicRepository ) : MusicBrowser.Invalidator { - private var invalidator: Invalidator? = null + private val indexer = indexerFactory.create(context, foregroundListener) + private val musicBrowser = musicBrowserFactory.create(context, this) private val dispatchJob = Job() private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) - interface Invalidator { - fun invalidateMusic(mediaId: String) + class Factory @Inject constructor( + private val indexerFactory: Indexer.Factory, + private val musicBrowserFactory: MusicBrowser.Factory, + private val musicRepository: MusicRepository + ) { + fun create(context: Context, foregroundListener: ForegroundListener, invalidator: Invalidator): MusicServiceFragment = + MusicServiceFragment(context, foregroundListener, invalidator, indexerFactory, musicBrowserFactory, musicRepository) } - fun attach(foregroundListener: ForegroundListener, invalidator: Invalidator) { - this.invalidator = invalidator - indexer.attach(foregroundListener) - musicBrowser.attach(this) + interface Invalidator { + fun invalidateMusic(mediaId: String) } fun release() { dispatchJob.cancel() musicBrowser.release() indexer.release() - invalidator = null } override fun invalidateMusic(ids: Set) { ids.forEach { mediaId -> - requireNotNull(invalidator) { "Invalidator not available" }.invalidateMusic(mediaId) + invalidator.invalidateMusic(mediaId) } } From a3af24688a97653cef6baa4976abd9069fca811f Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:46 -0600 Subject: [PATCH 40/66] playback: use factory pattern --- .../java/org/oxycblt/auxio/AuxioService.kt | 10 ++-- .../replaygain/ReplayGainAudioProcessor.kt | 13 +++-- .../service/ExoPlaybackStateHolder.kt | 6 +-- .../service/PlaybackServiceFragment.kt | 47 ++++++++++--------- .../service/SystemPlaybackReceiver.kt | 38 ++++++++++----- .../oxycblt/auxio/widgets/WidgetComponent.kt | 17 +++++-- 6 files changed, 81 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 1b6a1a4c50..fb0f2aaaca 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -38,16 +38,18 @@ import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint class AuxioService : - MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { - @Inject lateinit var playbackFragment: PlaybackServiceFragment + MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { + @Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory + private lateinit var playbackFragment: PlaybackServiceFragment @Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory - lateinit var musicFragment: MusicServiceFragment + private lateinit var musicFragment: MusicServiceFragment @SuppressLint("WrongConstant") override fun onCreate() { super.onCreate() - sessionToken = playbackFragment.attach(this) + playbackFragment = playbackFragmentFactory.create(this, this) + sessionToken = playbackFragment.token musicFragment = musicFragmentFactory.create(this, this, this) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 1152ef4e30..9c9b8e6952 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -44,12 +44,17 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor -@Inject -constructor( +class ReplayGainAudioProcessor private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { + class Factory @Inject constructor( + + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings + ) { + fun create() = ReplayGainAudioProcessor(playbackManager, playbackSettings) + } private var volume = 1f set(value) { field = value @@ -57,7 +62,7 @@ constructor( flush() } - fun attach() { + init { playbackManager.addListener(this) playbackSettings.registerListener(this) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 203176ebdc..b705cf1ab0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -86,10 +86,9 @@ class ExoPlaybackStateHolder( var sessionOngoing = false private set - fun attach() { + init { imageSettings.registerListener(this) player.addListener(this) - replayGainProcessor.attach() playbackManager.registerStateHolder(this) playbackSettings.registerListener(this) musicRepository.addUpdateListener(this) @@ -582,13 +581,14 @@ class ExoPlaybackStateHolder( private val playbackSettings: PlaybackSettings, private val commandFactory: PlaybackCommand.Factory, private val mediaSourceFactory: MediaSource.Factory, - private val replayGainProcessor: ReplayGainAudioProcessor, + private val replayGainProcessorFactory: ReplayGainAudioProcessor.Factory, private val musicRepository: MusicRepository, private val imageSettings: ImageSettings, ) { fun create(): ExoPlaybackStateHolder { // Since Auxio is a music player, only specify an audio renderer to save // battery/apk size/cache size + val replayGainProcessor = replayGainProcessorFactory.create() val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 0bb5b9fdaf..399aefa55b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -34,36 +34,38 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent -class PlaybackServiceFragment -@Inject -constructor( - @ApplicationContext private val context: Context, +class PlaybackServiceFragment private constructor( + private val context: Context, + private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings, - private val sessionHolderFactory: MediaSessionHolder.Factory, - private val widgetComponent: WidgetComponent, - exoHolderFactory: ExoPlaybackStateHolder.Factory + exoHolderFactory: ExoPlaybackStateHolder.Factory, + sessionHolderFactory: MediaSessionHolder.Factory, + widgetComponentFactory: WidgetComponent.Factory, + systemReceiverFactory: SystemPlaybackReceiver.Factory, ) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener { + class Factory @Inject constructor( + private val playbackManager: PlaybackStateManager, + private val exoHolderFactory: ExoPlaybackStateHolder.Factory, + private val sessionHolderFactory: MediaSessionHolder.Factory, + private val widgetComponentFactory: WidgetComponent.Factory, + private val systemReceiverFactory: SystemPlaybackReceiver.Factory, + ) { + fun create(context: Context, foregroundListener: ForegroundListener) = + PlaybackServiceFragment(context, foregroundListener, playbackManager, exoHolderFactory, sessionHolderFactory, widgetComponentFactory, systemReceiverFactory) + } + private val waitJob = Job() private val exoHolder = exoHolderFactory.create() - private var foregroundListener: ForegroundListener? = null + private val sessionHolder = sessionHolderFactory.create(context, foregroundListener) + private val widgetComponent = widgetComponentFactory.create(context) + private val systemReceiver = systemReceiverFactory.create(context) - private lateinit var sessionHolder: MediaSessionHolder - private lateinit var systemReceiver: SystemPlaybackReceiver + val token: MediaSessionCompat.Token get() = sessionHolder.token // --- MEDIASESSION CALLBACKS --- - @SuppressLint("WrongConstant") - fun attach(listener: ForegroundListener): MediaSessionCompat.Token { - foregroundListener = listener + init { playbackManager.addListener(this) - exoHolder.attach() - sessionHolder = sessionHolderFactory.create(context, listener) - systemReceiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) - ContextCompat.registerReceiver( - context, systemReceiver, systemReceiver.intentFilter, ContextCompat.RECEIVER_EXPORTED) - widgetComponent.attach() - return sessionHolder.token } fun handleTaskRemoved() { @@ -101,10 +103,9 @@ constructor( sessionHolder.release() exoHolder.release() playbackManager.removeListener(this) - foregroundListener = null } override fun onSessionEnded() { - foregroundListener?.updateForeground(ForegroundListener.Change.MEDIA_SESSION) + foregroundListener.updateForeground(ForegroundListener.Change.MEDIA_SESSION) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index d671219a10..f7c7110f08 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -23,34 +23,36 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.media.AudioManager +import androidx.core.content.ContextCompat import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider +import javax.inject.Inject /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * active [IntentFilter] to be registered. */ -class SystemPlaybackReceiver( +class SystemPlaybackReceiver private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val widgetComponent: WidgetComponent ) : BroadcastReceiver() { private var initialHeadsetPlugEventHandled = false - val intentFilter = - IntentFilter().apply { - addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) - addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) - addAction(PlaybackActions.ACTION_SKIP_PREV) - addAction(PlaybackActions.ACTION_PLAY_PAUSE) - addAction(PlaybackActions.ACTION_SKIP_NEXT) - addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + class Factory @Inject constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent + ) { + fun create(context: Context): SystemPlaybackReceiver { + val receiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) + ContextCompat.registerReceiver(context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) + return receiver } + } override fun onReceive(context: Context, intent: Intent) { when (intent.action) { @@ -127,4 +129,18 @@ class SystemPlaybackReceiver( playbackManager.playing(false) } } + + private companion object { + val INTENT_FILTER = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index bb8caf6931..2afa5b1772 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -46,18 +46,25 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class WidgetComponent -@Inject -constructor( - @ApplicationContext private val context: Context, +class WidgetComponent private constructor( + private val context: Context, private val imageSettings: ImageSettings, private val bitmapProvider: BitmapProvider, private val playbackManager: PlaybackStateManager, private val uiSettings: UISettings ) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { + class Factory @Inject constructor( + private val imageSettings: ImageSettings, + private val bitmapProvider: BitmapProvider, + private val playbackManager: PlaybackStateManager, + private val uiSettings: UISettings + ) { + fun create(context: Context) = WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings) + } + private val widgetProvider = WidgetProvider() - fun attach() { + init { playbackManager.addListener(this) uiSettings.registerListener(this) imageSettings.registerListener(this) From f4e1681044b6c11d491f8ad5b6a0203775922896 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 13 Sep 2024 13:35:48 -0600 Subject: [PATCH 41/66] all: reformat --- .../java/org/oxycblt/auxio/AuxioService.kt | 2 +- .../oxycblt/auxio/music/service/Indexer.kt | 18 ++++++--- .../auxio/music/service/MusicBrowser.kt | 23 ++++++++---- .../music/service/MusicServiceFragment.kt | 23 ++++++++---- .../replaygain/ReplayGainAudioProcessor.kt | 13 ++++--- .../service/PlaybackServiceFragment.kt | 23 ++++++++---- .../service/SystemPlaybackReceiver.kt | 37 +++++++++++-------- .../oxycblt/auxio/widgets/WidgetComponent.kt | 11 ++++-- 8 files changed, 97 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index fb0f2aaaca..aeb0e6db76 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -38,7 +38,7 @@ import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @AndroidEntryPoint class AuxioService : - MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { + MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator { @Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory private lateinit var playbackFragment: PlaybackServiceFragment diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index ef3ecc8937..de59692160 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -21,7 +21,6 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.os.PowerManager import coil.ImageLoader -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,7 +35,8 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD -class Indexer private constructor( +class Indexer +private constructor( override val workerContext: Context, private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, @@ -49,7 +49,9 @@ class Indexer private constructor( MusicRepository.IndexingListener, MusicRepository.UpdateListener, MusicSettings.Listener { - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val playbackManager: PlaybackStateManager, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings, @@ -57,8 +59,14 @@ class Indexer private constructor( private val contentObserver: SystemContentObserver ) { fun create(context: Context, listener: ForegroundListener) = - Indexer(context, listener, playbackManager, - musicRepository, musicSettings, imageLoader, contentObserver) + Indexer( + context, + listener, + playbackManager, + musicRepository, + musicSettings, + imageLoader, + contentObserver) } private val indexJob = Job() diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 6547122878..ecb53daa96 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.support.v4.media.MediaBrowserCompat.MediaItem -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.home.HomeGenerator @@ -37,7 +36,8 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.search.SearchEngine -class MusicBrowser private constructor( +class MusicBrowser +private constructor( private val context: Context, private val invalidator: Invalidator, private val musicRepository: MusicRepository, @@ -46,15 +46,24 @@ class MusicBrowser private constructor( homeGeneratorFactory: HomeGenerator.Factory ) : MusicRepository.UpdateListener, HomeGenerator.Invalidator { - class Factory @Inject constructor( - private val musicRepository: MusicRepository, - private val searchEngine: SearchEngine, - private val listSettings: ListSettings, + class Factory + @Inject + constructor( + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, + private val listSettings: ListSettings, private val homeGeneratorFactory: HomeGenerator.Factory ) { fun create(context: Context, invalidator: Invalidator): MusicBrowser = - MusicBrowser(context, invalidator, musicRepository, searchEngine, listSettings, homeGeneratorFactory) + MusicBrowser( + context, + invalidator, + musicRepository, + searchEngine, + listSettings, + homeGeneratorFactory) } + interface Invalidator { fun invalidateMusic(ids: Set) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 6cd420928b..a36769d9ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -24,7 +24,6 @@ import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.media.MediaBrowserServiceCompat.BrowserRoot import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.utils.MediaConstants -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -51,13 +50,25 @@ constructor( private val dispatchJob = Job() private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val indexerFactory: Indexer.Factory, private val musicBrowserFactory: MusicBrowser.Factory, private val musicRepository: MusicRepository ) { - fun create(context: Context, foregroundListener: ForegroundListener, invalidator: Invalidator): MusicServiceFragment = - MusicServiceFragment(context, foregroundListener, invalidator, indexerFactory, musicBrowserFactory, musicRepository) + fun create( + context: Context, + foregroundListener: ForegroundListener, + invalidator: Invalidator + ): MusicServiceFragment = + MusicServiceFragment( + context, + foregroundListener, + invalidator, + indexerFactory, + musicBrowserFactory, + musicRepository) } interface Invalidator { @@ -71,9 +82,7 @@ constructor( } override fun invalidateMusic(ids: Set) { - ids.forEach { mediaId -> - invalidator.invalidateMusic(mediaId) - } + ids.forEach { mediaId -> invalidator.invalidateMusic(mediaId) } } fun start() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 9c9b8e6952..1a17707360 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -44,17 +44,20 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor private constructor( +class ReplayGainAudioProcessor +private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { - class Factory @Inject constructor( - - private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings + class Factory + @Inject + constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings ) { fun create() = ReplayGainAudioProcessor(playbackManager, playbackSettings) } + private var volume = 1f set(value) { field = value diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 399aefa55b..d0c419ae10 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -18,23 +18,20 @@ package org.oxycblt.auxio.playback.service -import android.annotation.SuppressLint import android.content.Context import android.support.v4.media.session.MediaSessionCompat -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.Job import org.oxycblt.auxio.ForegroundListener import org.oxycblt.auxio.ForegroundServiceNotification import org.oxycblt.auxio.IntegerTable -import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent -class PlaybackServiceFragment private constructor( +class PlaybackServiceFragment +private constructor( private val context: Context, private val foregroundListener: ForegroundListener, private val playbackManager: PlaybackStateManager, @@ -43,7 +40,9 @@ class PlaybackServiceFragment private constructor( widgetComponentFactory: WidgetComponent.Factory, systemReceiverFactory: SystemPlaybackReceiver.Factory, ) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener { - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val playbackManager: PlaybackStateManager, private val exoHolderFactory: ExoPlaybackStateHolder.Factory, private val sessionHolderFactory: MediaSessionHolder.Factory, @@ -51,7 +50,14 @@ class PlaybackServiceFragment private constructor( private val systemReceiverFactory: SystemPlaybackReceiver.Factory, ) { fun create(context: Context, foregroundListener: ForegroundListener) = - PlaybackServiceFragment(context, foregroundListener, playbackManager, exoHolderFactory, sessionHolderFactory, widgetComponentFactory, systemReceiverFactory) + PlaybackServiceFragment( + context, + foregroundListener, + playbackManager, + exoHolderFactory, + sessionHolderFactory, + widgetComponentFactory, + systemReceiverFactory) } private val waitJob = Job() @@ -60,7 +66,8 @@ class PlaybackServiceFragment private constructor( private val widgetComponent = widgetComponentFactory.create(context) private val systemReceiver = systemReceiverFactory.create(context) - val token: MediaSessionCompat.Token get() = sessionHolder.token + val token: MediaSessionCompat.Token + get() = sessionHolder.token // --- MEDIASESSION CALLBACKS --- diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index f7c7110f08..3a10039db0 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -24,32 +24,37 @@ import android.content.Intent import android.content.IntentFilter import android.media.AudioManager import androidx.core.content.ContextCompat +import javax.inject.Inject import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent import org.oxycblt.auxio.widgets.WidgetProvider -import javax.inject.Inject /** * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * active [IntentFilter] to be registered. */ -class SystemPlaybackReceiver private constructor( +class SystemPlaybackReceiver +private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val widgetComponent: WidgetComponent ) : BroadcastReceiver() { private var initialHeadsetPlugEventHandled = false - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val widgetComponent: WidgetComponent ) { fun create(context: Context): SystemPlaybackReceiver { - val receiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) - ContextCompat.registerReceiver(context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) + val receiver = + SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) + ContextCompat.registerReceiver( + context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) return receiver } } @@ -131,16 +136,16 @@ class SystemPlaybackReceiver private constructor( } private companion object { - val INTENT_FILTER = - IntentFilter().apply { - addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) - addAction(AudioManager.ACTION_HEADSET_PLUG) - addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) - addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) - addAction(PlaybackActions.ACTION_SKIP_PREV) - addAction(PlaybackActions.ACTION_PLAY_PAUSE) - addAction(PlaybackActions.ACTION_SKIP_NEXT) - addAction(WidgetProvider.ACTION_WIDGET_UPDATE) - } + val INTENT_FILTER = + IntentFilter().apply { + addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + addAction(AudioManager.ACTION_HEADSET_PLUG) + addAction(PlaybackActions.ACTION_INC_REPEAT_MODE) + addAction(PlaybackActions.ACTION_INVERT_SHUFFLE) + addAction(PlaybackActions.ACTION_SKIP_PREV) + addAction(PlaybackActions.ACTION_PLAY_PAUSE) + addAction(PlaybackActions.ACTION_SKIP_NEXT) + addAction(WidgetProvider.ACTION_WIDGET_UPDATE) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 2afa5b1772..367b44f427 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -23,7 +23,6 @@ import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest import coil.size.Size -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider @@ -46,20 +45,24 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class WidgetComponent private constructor( +class WidgetComponent +private constructor( private val context: Context, private val imageSettings: ImageSettings, private val bitmapProvider: BitmapProvider, private val playbackManager: PlaybackStateManager, private val uiSettings: UISettings ) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { - class Factory @Inject constructor( + class Factory + @Inject + constructor( private val imageSettings: ImageSettings, private val bitmapProvider: BitmapProvider, private val playbackManager: PlaybackStateManager, private val uiSettings: UISettings ) { - fun create(context: Context) = WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings) + fun create(context: Context) = + WidgetComponent(context, imageSettings, bitmapProvider, playbackManager, uiSettings) } private val widgetProvider = WidgetProvider() From 26f27d0edd810b7ad48c82dcca916c376cab9ae6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 18 Sep 2024 14:50:53 -0600 Subject: [PATCH 42/66] detail: split off detail list into generator --- .../oxycblt/auxio/detail/DetailGenerator.kt | 216 ++++++++++++ .../org/oxycblt/auxio/detail/DetailModule.kt | 30 ++ .../oxycblt/auxio/detail/DetailViewModel.kt | 320 +++++------------- .../org/oxycblt/auxio/list/ListSettings.kt | 10 +- .../music/service/MediaItemTranslation.kt | 4 + .../auxio/music/service/MusicBrowser.kt | 113 +++---- 6 files changed, 395 insertions(+), 298 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt new file mode 100644 index 0000000000..bd5356df53 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -0,0 +1,216 @@ +package org.oxycblt.auxio.detail + +import android.content.Context +import androidx.annotation.StringRes +import org.oxycblt.auxio.R +import org.oxycblt.auxio.detail.list.DiscHeader +import org.oxycblt.auxio.list.ListSettings +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType +import org.oxycblt.auxio.music.Playlist +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.info.ReleaseType +import org.oxycblt.auxio.util.logD +import java.util.SortedMap +import javax.inject.Inject + +interface DetailGenerator { + fun any(uid: Music.UID): Detail? + fun album(uid: Music.UID): Detail? + fun artist(uid: Music.UID): Detail? + fun genre(uid: Music.UID): Detail? + fun playlist(uid: Music.UID): Detail? + fun release() + + interface Factory { + fun create(invalidator: Invalidator): DetailGenerator + } + + interface Invalidator { + fun invalidate(type: MusicType, replace: Int?) + } +} + +class DetailGeneratorFactoryImpl @Inject constructor( + private val listSettings: ListSettings, + private val musicRepository: MusicRepository +) : DetailGenerator.Factory { + override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator = + DetailGeneratorImpl(invalidator, listSettings, musicRepository) +} + +private class DetailGeneratorImpl( + private val invalidator: DetailGenerator.Invalidator, + private val listSettings: ListSettings, + private val musicRepository: MusicRepository +) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener { + init { + listSettings.registerListener(this) + musicRepository.addUpdateListener(this) + } + + override fun onAlbumSongSortChanged() { + super.onAlbumSongSortChanged() + invalidator.invalidate(MusicType.ALBUMS, -1) + } + + override fun onArtistSongSortChanged() { + super.onArtistSongSortChanged() + invalidator.invalidate(MusicType.ARTISTS, -1) + } + + override fun onGenreSongSortChanged() { + super.onGenreSongSortChanged() + invalidator.invalidate(MusicType.GENRES, -1) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.deviceLibrary) { + invalidator.invalidate(MusicType.ALBUMS, null) + invalidator.invalidate(MusicType.ARTISTS, null) + invalidator.invalidate(MusicType.GENRES, null) + } + if (changes.userLibrary) { + invalidator.invalidate(MusicType.PLAYLISTS, null) + } + } + + override fun release() { + listSettings.unregisterListener(this) + musicRepository.removeUpdateListener(this) + } + + override fun any(uid: Music.UID): Detail? { + val music = musicRepository.find(uid) ?: return null + return when (music) { + is Album -> album(uid) + is Artist -> artist(uid) + is Genre -> genre(uid) + is Playlist -> playlist(uid) + else -> null + } + } + + override fun album(uid: Music.UID): Detail? { + val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null + val songs = listSettings.albumSongSort.songs(album.songs) + val discs = songs.groupBy { it.disc } + val section = if (discs.size > 1 || discs.keys.first() != null) { + DetailSection.Discs(discs) + } else { + DetailSection.Songs(songs) + } + return Detail(album, listOf(section)) + } + + override fun artist(uid: Music.UID): Detail? { + val artist = musicRepository.deviceLibrary?.findArtist(uid) ?: return null + val grouping = + artist.explicitAlbums.groupByTo(sortedMapOf()) { + // Remap the complicated ReleaseType data structure into detail sections + when (it.releaseType.refinement) { + ReleaseType.Refinement.LIVE -> DetailSection.Albums.Category.LIVE + ReleaseType.Refinement.REMIX -> DetailSection.Albums.Category.REMIXES + null -> + when (it.releaseType) { + is ReleaseType.Album -> DetailSection.Albums.Category.ALBUMS + is ReleaseType.EP -> DetailSection.Albums.Category.EPS + is ReleaseType.Single -> DetailSection.Albums.Category.SINGLES + is ReleaseType.Compilation -> DetailSection.Albums.Category.COMPILATIONS + is ReleaseType.Soundtrack -> DetailSection.Albums.Category.SOUNDTRACKS + is ReleaseType.Mix -> DetailSection.Albums.Category.DJ_MIXES + is ReleaseType.Mixtape -> DetailSection.Albums.Category.MIXTAPES + is ReleaseType.Demo -> DetailSection.Albums.Category.DEMOS + } + } + } + + if (artist.implicitAlbums.isNotEmpty()) { + // groupByTo normally returns a mapping to a MutableList mapping. Since MutableList + // inherits list, we can cast upwards and save a copy by directly inserting the + // implicit album list into the mapping. + logD("Implicit albums present, adding to list") + @Suppress("UNCHECKED_CAST") + (grouping as MutableMap>)[DetailSection.Albums.Category.APPEARANCES] = + artist.implicitAlbums + } + + val sections = grouping.mapTo(mutableListOf()) { (category, albums) -> + DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums)) + } + val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs)) + sections.add(songs) + return Detail(artist, sections) + } + + override fun genre(uid: Music.UID): Detail? { + val genre = musicRepository.deviceLibrary?.findGenre(uid) ?: return null + val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists)) + val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs)) + return Detail(genre, listOf(artists, songs)) + } + + override fun playlist(uid: Music.UID): Detail? { + val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null + val songs = DetailSection.Songs(playlist.songs) + return Detail(playlist, listOf(songs)) + } + + private companion object { + val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) + val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + } +} + +data class Detail

(val parent: P, val sections: List) + +sealed interface DetailSection { + val order: Int + val stringRes: Int + + abstract class PlainSection : DetailSection { + abstract val items: List + } + + data class Artists(override val items: List) : PlainSection() { + override val order = 0 + override val stringRes = R.string.lbl_songs + } + + data class Albums(val category: Category, override val items: List) : PlainSection() { + override val order = 1 + category.ordinal + override val stringRes = category.stringRes + + enum class Category(@StringRes val stringRes: Int) { + ALBUMS(R.string.lbl_albums), + EPS(R.string.lbl_eps), + SINGLES(R.string.lbl_singles), + COMPILATIONS(R.string.lbl_compilations), + SOUNDTRACKS(R.string.lbl_soundtracks), + DJ_MIXES(R.string.lbl_mixes), + MIXTAPES(R.string.lbl_mixtapes), + DEMOS(R.string.lbl_demos), + APPEARANCES(R.string.lbl_appears_on), + LIVE(R.string.lbl_live_group), + REMIXES(R.string.lbl_remix_group) + } + } + + + data class Songs(override val items: List) : PlainSection() { + override val order = 12 + override val stringRes = R.string.lbl_songs + } + + data class Discs(val discs: Map>) : DetailSection { + override val order = 13 + override val stringRes = R.string.lbl_songs + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt new file mode 100644 index 0000000000..4d41529db5 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 Auxio Project + * HomeModule.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.oxycblt.auxio.detail + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface DetailModule { + @Binds fun detailGeneratorFactory(factory: DetailGeneratorFactoryImpl): DetailGenerator.Factory +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 3613d96c61..03390b2a1b 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.detail -import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -43,10 +42,11 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music +import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository +import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.metadata.AudioProperties import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaybackSettings @@ -69,8 +69,12 @@ constructor( private val listSettings: ListSettings, private val musicRepository: MusicRepository, private val audioPropertiesFactory: AudioProperties.Factory, - private val playbackSettings: PlaybackSettings -) : ViewModel(), MusicRepository.UpdateListener { + private val playbackSettings: PlaybackSettings, + detailGeneratorFactory: DetailGenerator.Factory +) : ViewModel(), DetailGenerator.Invalidator { + private val detailGenerator = detailGeneratorFactory.create(this) + + private val _toShow = MutableEvent() /** * A [Show] command that is awaiting a view capable of responding to it. Null if none currently. @@ -133,13 +137,8 @@ constructor( get() = _artistSongInstructions /** The current [Sort] used for [Song]s in [artistSongList]. */ - var artistSongSort: Sort + val artistSongSort: Sort get() = listSettings.artistSongSort - set(value) { - listSettings.artistSongSort = value - // Refresh the artist list to reflect the new sort. - currentArtist.value?.let { refreshArtistList(it, true) } - } /** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */ val playInArtistWith @@ -162,13 +161,8 @@ constructor( get() = _genreSongInstructions /** The current [Sort] used for [Song]s in [genreSongList]. */ - var genreSongSort: Sort + val genreSongSort: Sort get() = listSettings.genreSongSort - set(value) { - listSettings.genreSongSort = value - // Refresh the genre list to reflect the new sort. - currentGenre.value?.let { refreshGenreList(it, true) } - } /** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */ val playInGenreWith @@ -204,54 +198,32 @@ constructor( playbackSettings.inParentPlaybackMode ?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value)) - init { - musicRepository.addUpdateListener(this) - } - override fun onCleared() { - musicRepository.removeUpdateListener(this) + detailGenerator.release() } - override fun onMusicChanges(changes: MusicRepository.Changes) { - // If we are showing any item right now, we will need to refresh it (and any information - // related to it) with the new library in order to prevent stale items from showing up - // in the UI. - val deviceLibrary = musicRepository.deviceLibrary - if (changes.deviceLibrary && deviceLibrary != null) { - val song = currentSong.value - if (song != null) { - _currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo) - logD("Updated song to ${currentSong.value}") + override fun invalidate(type: MusicType, replace: Int?) { + when (type) { + MusicType.ALBUMS -> { + val album = detailGenerator.album(currentAlbum.value?.uid ?: return) + refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace) } - val album = currentAlbum.value - if (album != null) { - _currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList) - logD("Updated album to ${currentAlbum.value}") + MusicType.ARTISTS -> { + val artist = detailGenerator.artist(currentArtist.value?.uid ?: return) + refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, replace) } - val artist = currentArtist.value - if (artist != null) { - _currentArtist.value = - deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList) - logD("Updated artist to ${currentArtist.value}") + MusicType.GENRES -> { + val genre = detailGenerator.genre(currentGenre.value?.uid ?: return) + refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace) } - val genre = currentGenre.value - if (genre != null) { - _currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList) - logD("Updated genre to ${currentGenre.value}") + MusicType.PLAYLISTS -> { + refreshPlaylist(currentPlaylist.value?.uid ?: return) } - } - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - val playlist = currentPlaylist.value - if (playlist != null) { - _currentPlaylist.value = - userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList) - logD("Updated playlist to ${currentPlaylist.value}") - } + else -> error("Unexpected music type $type") } } @@ -356,8 +328,11 @@ constructor( */ fun setAlbum(uid: Music.UID) { logD("Opening album $uid") - _currentAlbum.value = - musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList) + if (uid === _currentAlbum.value?.uid) { + return + } + val album = detailGenerator.album(uid) + refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null) if (_currentAlbum.value == null) { logW("Given album UID was invalid") } @@ -370,7 +345,6 @@ constructor( */ fun applyAlbumSongSort(sort: Sort) { listSettings.albumSongSort = sort - _currentAlbum.value?.let { refreshAlbumList(it, true) } } /** @@ -381,11 +355,11 @@ constructor( */ fun setArtist(uid: Music.UID) { logD("Opening artist $uid") - _currentArtist.value = - musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList) - if (_currentArtist.value == null) { - logW("Given artist UID was invalid") + if (uid === _currentArtist.value?.uid) { + return } + val artist = detailGenerator.artist(uid) + refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null) } /** @@ -395,7 +369,6 @@ constructor( */ fun applyArtistSongSort(sort: Sort) { listSettings.artistSongSort = sort - _currentArtist.value?.let { refreshArtistList(it, true) } } /** @@ -406,11 +379,11 @@ constructor( */ fun setGenre(uid: Music.UID) { logD("Opening genre $uid") - _currentGenre.value = - musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList) - if (_currentGenre.value == null) { - logW("Given genre UID was invalid") + if (uid === _currentGenre.value?.uid) { + return } + val genre = detailGenerator.genre(uid) + refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null) } /** @@ -420,7 +393,6 @@ constructor( */ fun applyGenreSongSort(sort: Sort) { listSettings.genreSongSort = sort - _currentGenre.value?.let { refreshGenreList(it, true) } } /** @@ -431,11 +403,10 @@ constructor( */ fun setPlaylist(uid: Music.UID) { logD("Opening playlist $uid") - _currentPlaylist.value = - musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList) - if (_currentPlaylist.value == null) { - logW("Given playlist UID was invalid") + if (uid === _currentPlaylist.value?.uid) { + return } + refreshPlaylist(uid) } /** Start a playlist editing session. Does nothing if a playlist is not being shown. */ @@ -443,7 +414,7 @@ constructor( val playlist = _currentPlaylist.value ?: return logD("Starting playlist edit") _editedPlaylist.value = playlist.songs - refreshPlaylistList(playlist) + refreshPlaylist(playlist.uid) } /** @@ -474,9 +445,8 @@ constructor( // Nothing to do. return false } - logD("Discarding playlist edits") _editedPlaylist.value = null - refreshPlaylistList(playlist) + refreshPlaylist(playlist.uid) return true } @@ -488,7 +458,7 @@ constructor( fun applyPlaylistSongSort(sort: Sort) { val playlist = _currentPlaylist.value ?: return _editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return) - refreshPlaylistList(playlist, UpdateInstructions.Replace(2)) + refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2)) } /** @@ -509,7 +479,7 @@ constructor( logD("Moving playlist song from $realFrom [$from] to $realTo [$to]") editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo)) _editedPlaylist.value = editedPlaylist - refreshPlaylistList(playlist, UpdateInstructions.Move(from, to)) + refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to)) return true } @@ -528,8 +498,8 @@ constructor( logD("Removing playlist song at $realAt [$at]") editedPlaylist.removeAt(realAt) _editedPlaylist.value = editedPlaylist - refreshPlaylistList( - playlist, + refreshPlaylist( + playlist.uid, if (editedPlaylist.isNotEmpty()) { UpdateInstructions.Remove(at, 1) } else { @@ -552,173 +522,69 @@ constructor( } } - private fun refreshAlbumList(album: Album, replace: Boolean = false) { - logD("Refreshing album list") - val list = mutableListOf() - val header = SortHeader(R.string.lbl_songs) - list.add(Divider(header)) - list.add(header) - val instructions = - if (replace) { - // Intentional so that the header item isn't replaced with the songs - UpdateInstructions.Replace(list.size) - } else { - UpdateInstructions.Diff - } - // To create a good user experience regarding disc numbers, we group the album's - // songs up by disc and then delimit the groups by a disc header. - val songs = albumSongSort.songs(album.songs) - val byDisc = songs.groupBy { it.disc } - if (byDisc.size > 1) { - logD("Album has more than one disc, interspersing headers") - for (entry in byDisc.entries) { - list.add(DiscHeader(entry.key)) - list.addAll(entry.value) - } - } else { - // Album only has one disc, don't add any redundant headers - list.addAll(songs) + private fun refreshDetail( + detail: Detail?, + parent: MutableStateFlow, + list: MutableStateFlow>, + instructions: MutableEvent, + replace: Int? + ) { + if (detail == null) { + parent.value = null + return } + val newList = mutableListOf() + var newInstructions: UpdateInstructions = UpdateInstructions.Diff + for ((i, section) in detail.sections.withIndex()) { + val items = when (section) { + is DetailSection.PlainSection<*> -> { + val header = if (section is DetailSection.Songs) + SortHeader(section.stringRes) else BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.items + } - logD("Update album list to ${list.size} items with $instructions") - _albumSongInstructions.put(instructions) - _albumSongList.value = list - } - - private fun refreshArtistList(artist: Artist, replace: Boolean = false) { - logD("Refreshing artist list") - val list = mutableListOf() - - val grouping = - artist.explicitAlbums.groupByTo(sortedMapOf()) { - // Remap the complicated ReleaseType data structure into an easier - // "AlbumGrouping" enum that will automatically group and sort - // the artist's albums. - when (it.releaseType.refinement) { - ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE - ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES - null -> - when (it.releaseType) { - is ReleaseType.Album -> AlbumGrouping.ALBUMS - is ReleaseType.EP -> AlbumGrouping.EPS - is ReleaseType.Single -> AlbumGrouping.SINGLES - is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS - is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS - is ReleaseType.Mix -> AlbumGrouping.DJMIXES - is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES - is ReleaseType.Demo -> AlbumGrouping.DEMOS - } + is DetailSection.Discs -> { + val header = BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.discs.flatMap { + listOf(DiscHeader(it.key)) + it.value + } } } - - if (artist.implicitAlbums.isNotEmpty()) { - // groupByTo normally returns a mapping to a MutableList mapping. Since MutableList - // inherits list, we can cast upwards and save a copy by directly inserting the - // implicit album list into the mapping. - logD("Implicit albums present, adding to list") - @Suppress("UNCHECKED_CAST") - (grouping as MutableMap>)[AlbumGrouping.APPEARANCES] = - artist.implicitAlbums - } - - logD("Release groups for this artist: ${grouping.keys}") - - for (entry in grouping.entries) { - val header = BasicHeader(entry.key.headerTitleRes) - list.add(Divider(header)) - list.add(header) - list.addAll(ARTIST_ALBUM_SORT.albums(entry.value)) - } - - // Artists may not be linked to any songs, only include a header entry if we have any. - var instructions: UpdateInstructions = UpdateInstructions.Diff - if (artist.songs.isNotEmpty()) { - logD("Songs present in this artist, adding header") - val header = SortHeader(R.string.lbl_songs) - list.add(Divider(header)) - list.add(header) - if (replace) { + // Currently only the final section (songs, which can be sorted) are invalidatable + // and thus need to be replaced. + if (replace == -1 && i == detail.sections.lastIndex) { // Intentional so that the header item isn't replaced with the songs - instructions = UpdateInstructions.Replace(list.size) + newInstructions = UpdateInstructions.Replace(newList.size) } - list.addAll(artistSongSort.songs(artist.songs)) + newList.addAll(items) } - - logD("Updating artist list to ${list.size} items with $instructions") - _artistSongInstructions.put(instructions) - _artistSongList.value = list.toList() - } - - private fun refreshGenreList(genre: Genre, replace: Boolean = false) { - logD("Refreshing genre list") - val list = mutableListOf() - // Genre is guaranteed to always have artists and songs. - val artistHeader = BasicHeader(R.string.lbl_artists) - list.add(Divider(artistHeader)) - list.add(artistHeader) - list.addAll(GENRE_ARTIST_SORT.artists(genre.artists)) - - val songHeader = SortHeader(R.string.lbl_songs) - list.add(Divider(songHeader)) - list.add(songHeader) - val instructions = - if (replace) { - // Intentional so that the header item isn't replaced alongside the songs - UpdateInstructions.Replace(list.size) - } else { - UpdateInstructions.Diff - } - list.addAll(genreSongSort.songs(genre.songs)) - - logD("Updating genre list to ${list.size} items with $instructions") - _genreSongInstructions.put(instructions) - _genreSongList.value = list + parent.value = detail.parent + list.value = newList + instructions.put(newInstructions) } - private fun refreshPlaylistList( - playlist: Playlist, - instructions: UpdateInstructions = UpdateInstructions.Diff - ) { + private fun refreshPlaylist(uid: Music.UID, instructions: UpdateInstructions = UpdateInstructions.Diff) { logD("Refreshing playlist list") + val edited = editedPlaylist.value + if (edited == null) { + val playlist = detailGenerator.playlist(uid) + refreshDetail(playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) + return + } val list = mutableListOf() - - val songs = editedPlaylist.value ?: playlist.songs - if (songs.isNotEmpty()) { + if (edited.isNotEmpty()) { val header = EditHeader(R.string.lbl_songs) list.add(Divider(header)) list.add(header) - list.addAll(songs) + list.addAll(edited) } - - logD("Updating playlist list to ${list.size} items with $instructions") - _playlistSongInstructions.put(instructions) _playlistSongList.value = list - } - - /** - * A simpler mapping of [ReleaseType] used for grouping and sorting songs. - * - * @param headerTitleRes The title string resource to use for a header created out of an - * instance of this enum. - */ - private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) { - ALBUMS(R.string.lbl_albums), - EPS(R.string.lbl_eps), - SINGLES(R.string.lbl_singles), - COMPILATIONS(R.string.lbl_compilations), - SOUNDTRACKS(R.string.lbl_soundtracks), - DJMIXES(R.string.lbl_mixes), - MIXTAPES(R.string.lbl_mixtapes), - DEMOS(R.string.lbl_demos), - APPEARANCES(R.string.lbl_appears_on), - LIVE(R.string.lbl_live_group), - REMIXES(R.string.lbl_remix_group), - } - - private companion object { - val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + _playlistSongInstructions.put(instructions) } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index c817dcf0e6..37cb2cc234 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -46,13 +46,12 @@ interface ListSettings : Settings { interface Listener { fun onSongSortChanged() {} - fun onAlbumSortChanged() {} - + fun onAlbumSongSortChanged() {} fun onArtistSortChanged() {} - + fun onArtistSongSortChanged() {} fun onGenreSortChanged() {} - + fun onGenreSongSortChanged() {} fun onPlaylistSortChanged() {} } } @@ -162,8 +161,11 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont when (key) { getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged() getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged() + getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged() getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged() + getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged() getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged() + getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged() getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 624247f8eb..1d3c8ff020 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -130,6 +130,10 @@ fun header(@StringRes nameRes: Int): Sugar = { MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, it.getString(nameRes)) } +fun header(name: String): Sugar = { + putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, name) +} + private fun style(style: Int): Sugar = { putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index ecb53daa96..2a109204a6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -22,7 +22,12 @@ import android.content.Context import android.support.v4.media.MediaBrowserCompat.MediaItem import javax.inject.Inject import org.oxycblt.auxio.R +import org.oxycblt.auxio.detail.DetailGenerator +import org.oxycblt.auxio.detail.DetailSection +import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.home.HomeGenerator +import org.oxycblt.auxio.list.BasicHeader +import org.oxycblt.auxio.list.Divider import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.sort.Sort @@ -42,17 +47,17 @@ private constructor( private val invalidator: Invalidator, private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, - private val listSettings: ListSettings, - homeGeneratorFactory: HomeGenerator.Factory -) : MusicRepository.UpdateListener, HomeGenerator.Invalidator { + homeGeneratorFactory: HomeGenerator.Factory, + detailGeneratorFactory: DetailGenerator.Factory +) : HomeGenerator.Invalidator, DetailGenerator.Invalidator { class Factory @Inject constructor( private val musicRepository: MusicRepository, private val searchEngine: SearchEngine, - private val listSettings: ListSettings, - private val homeGeneratorFactory: HomeGenerator.Factory + private val homeGeneratorFactory: HomeGenerator.Factory, + private val detailGeneratorFactory: DetailGenerator.Factory ) { fun create(context: Context, invalidator: Invalidator): MusicBrowser = MusicBrowser( @@ -60,8 +65,8 @@ private constructor( invalidator, musicRepository, searchEngine, - listSettings, - homeGeneratorFactory) + homeGeneratorFactory, + detailGeneratorFactory) } interface Invalidator { @@ -69,13 +74,11 @@ private constructor( } private val homeGenerator = homeGeneratorFactory.create(this) - - init { - musicRepository.addUpdateListener(this) - } + private val detailGenerator = detailGeneratorFactory.create(this) fun release() { - musicRepository.removeUpdateListener(this) + homeGenerator.release() + detailGenerator.release() } override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { @@ -92,36 +95,21 @@ private constructor( } } - override fun onMusicChanges(changes: MusicRepository.Changes) { - val deviceLibrary = musicRepository.deviceLibrary - val invalidate = mutableSetOf() - if (changes.deviceLibrary && deviceLibrary != null) { - deviceLibrary.albums.forEach { - val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate.add(id) - } - - deviceLibrary.artists.forEach { - val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate.add(id) - } - - deviceLibrary.genres.forEach { - val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate.add(id) - } + override fun invalidate(type: MusicType, replace: Int?) { + val deviceLibrary = musicRepository.deviceLibrary ?: return + val userLibrary = musicRepository.userLibrary ?: return + val music = when (type) { + MusicType.ALBUMS -> deviceLibrary.albums + MusicType.ARTISTS -> deviceLibrary.artists + MusicType.GENRES -> deviceLibrary.genres + MusicType.PLAYLISTS -> userLibrary.playlists + else -> return } - val userLibrary = musicRepository.userLibrary - if (changes.userLibrary && userLibrary != null) { - userLibrary.playlists.forEach { - val id = MediaSessionUID.SingleItem(it.uid).toString() - invalidate.add(id) - } - } - - if (invalidate.isNotEmpty()) { - invalidator?.invalidateMusic(invalidate) + if (music.isEmpty()) { + return } + val ids = music.map { MediaSessionUID.SingleItem(it.uid).toString() }.toSet() + invalidator.invalidateMusic(ids) } fun getItem(mediaId: String): MediaItem? { @@ -235,34 +223,25 @@ private constructor( } private fun getChildMediaItems(uid: Music.UID): List? { - return when (val item = musicRepository.find(uid)) { - is Album -> { - val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } - } - is Artist -> { - val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) - val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + - songs.map { it.toMediaItem(context, item, header(R.string.lbl_songs)) } - } - is Genre -> { - val artists = GENRE_ARTISTS_SORT.artists(item.artists) - val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context, header(R.string.lbl_songs)) } + - songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } - } - is Playlist -> { - item.songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) } + val detail = detailGenerator.any(uid) ?: return null + return detail.sections.flatMap { section -> + when (section) { + is DetailSection.Songs -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + is DetailSection.Albums -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } + is DetailSection.Discs -> section.discs.flatMap { + section.discs.flatMap { entry -> + val disc = entry.key + val discString = if (disc != null) { + context.getString(R.string.fmt_disc_no, disc.number) + } else { + context.getString(R.string.def_disc) + } + entry.value.map { it.toMediaItem(context, null, header(discString)) } + } + } + else -> error("Unknown section type: $section") } - is Song, - null -> return null } } - - private companion object { - // TODO: Rely on detail item gen logic? - val ARTIST_ALBUMS_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING) - val GENRE_ARTISTS_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) - } } From cbdad3fe3981cb1bd2460cfd19e745cd0c1be62b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 43/66] all: reformat/fixes --- .../oxycblt/auxio/detail/DetailGenerator.kt | 62 +++++++++++++------ .../org/oxycblt/auxio/detail/DetailModule.kt | 2 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 48 +++++++------- .../org/oxycblt/auxio/list/ListSettings.kt | 7 +++ .../auxio/music/service/MusicBrowser.kt | 43 +++++++------ .../service/PlaybackServiceFragment.kt | 2 +- .../service/SystemPlaybackReceiver.kt | 5 +- media | 2 +- 8 files changed, 98 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt index bd5356df53..0b48b060c9 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -1,9 +1,26 @@ +/* + * Copyright (c) 2024 Auxio Project + * DetailGenerator.kt is part of Auxio. + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + package org.oxycblt.auxio.detail -import android.content.Context import androidx.annotation.StringRes +import javax.inject.Inject import org.oxycblt.auxio.R -import org.oxycblt.auxio.detail.list.DiscHeader import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album @@ -18,15 +35,18 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.util.logD -import java.util.SortedMap -import javax.inject.Inject interface DetailGenerator { fun any(uid: Music.UID): Detail? + fun album(uid: Music.UID): Detail? + fun artist(uid: Music.UID): Detail? + fun genre(uid: Music.UID): Detail? + fun playlist(uid: Music.UID): Detail? + fun release() interface Factory { @@ -38,10 +58,10 @@ interface DetailGenerator { } } -class DetailGeneratorFactoryImpl @Inject constructor( - private val listSettings: ListSettings, - private val musicRepository: MusicRepository -) : DetailGenerator.Factory { +class DetailGeneratorFactoryImpl +@Inject +constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) : + DetailGenerator.Factory { override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator = DetailGeneratorImpl(invalidator, listSettings, musicRepository) } @@ -102,11 +122,12 @@ private class DetailGeneratorImpl( val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null val songs = listSettings.albumSongSort.songs(album.songs) val discs = songs.groupBy { it.disc } - val section = if (discs.size > 1 || discs.keys.first() != null) { - DetailSection.Discs(discs) - } else { - DetailSection.Songs(songs) - } + val section = + if (discs.size > 1 || discs.keys.first() != null) { + DetailSection.Discs(discs) + } else { + DetailSection.Songs(songs) + } return Detail(album, listOf(section)) } @@ -138,13 +159,14 @@ private class DetailGeneratorImpl( // implicit album list into the mapping. logD("Implicit albums present, adding to list") @Suppress("UNCHECKED_CAST") - (grouping as MutableMap>)[DetailSection.Albums.Category.APPEARANCES] = - artist.implicitAlbums + (grouping as MutableMap>)[ + DetailSection.Albums.Category.APPEARANCES] = artist.implicitAlbums } - val sections = grouping.mapTo(mutableListOf()) { (category, albums) -> - DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums)) - } + val sections = + grouping.mapTo(mutableListOf()) { (category, albums) -> + DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums)) + } val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs)) sections.add(songs) return Detail(artist, sections) @@ -184,7 +206,8 @@ sealed interface DetailSection { override val stringRes = R.string.lbl_songs } - data class Albums(val category: Category, override val items: List) : PlainSection() { + data class Albums(val category: Category, override val items: List) : + PlainSection() { override val order = 1 + category.ordinal override val stringRes = category.stringRes @@ -203,7 +226,6 @@ sealed interface DetailSection { } } - data class Songs(override val items: List) : PlainSection() { override val order = 12 override val stringRes = R.string.lbl_songs diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt index 4d41529db5..2fde9b6a6f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailModule.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * HomeModule.kt is part of Auxio. + * DetailModule.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 03390b2a1b..d5fbc9806f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -74,7 +74,6 @@ constructor( ) : ViewModel(), DetailGenerator.Invalidator { private val detailGenerator = detailGeneratorFactory.create(this) - private val _toShow = MutableEvent() /** * A [Show] command that is awaiting a view capable of responding to it. Null if none currently. @@ -208,21 +207,18 @@ constructor( val album = detailGenerator.album(currentAlbum.value?.uid ?: return) refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace) } - MusicType.ARTISTS -> { val artist = detailGenerator.artist(currentArtist.value?.uid ?: return) - refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, replace) + refreshDetail( + artist, _currentArtist, _artistSongList, _artistSongInstructions, replace) } - MusicType.GENRES -> { val genre = detailGenerator.genre(currentGenre.value?.uid ?: return) refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace) } - MusicType.PLAYLISTS -> { refreshPlaylist(currentPlaylist.value?.uid ?: return) } - else -> error("Unexpected music type $type") } } @@ -522,7 +518,6 @@ constructor( } } - private fun refreshDetail( detail: Detail?, parent: MutableStateFlow, @@ -537,24 +532,23 @@ constructor( val newList = mutableListOf() var newInstructions: UpdateInstructions = UpdateInstructions.Diff for ((i, section) in detail.sections.withIndex()) { - val items = when (section) { - is DetailSection.PlainSection<*> -> { - val header = if (section is DetailSection.Songs) - SortHeader(section.stringRes) else BasicHeader(section.stringRes) - newList.add(Divider(header)) - newList.add(header) - section.items - } - - is DetailSection.Discs -> { - val header = BasicHeader(section.stringRes) - newList.add(Divider(header)) - newList.add(header) - section.discs.flatMap { - listOf(DiscHeader(it.key)) + it.value + val items = + when (section) { + is DetailSection.PlainSection<*> -> { + val header = + if (section is DetailSection.Songs) SortHeader(section.stringRes) + else BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.items + } + is DetailSection.Discs -> { + val header = BasicHeader(section.stringRes) + newList.add(Divider(header)) + newList.add(header) + section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value } } } - } // Currently only the final section (songs, which can be sorted) are invalidatable // and thus need to be replaced. if (replace == -1 && i == detail.sections.lastIndex) { @@ -568,12 +562,16 @@ constructor( instructions.put(newInstructions) } - private fun refreshPlaylist(uid: Music.UID, instructions: UpdateInstructions = UpdateInstructions.Diff) { + private fun refreshPlaylist( + uid: Music.UID, + instructions: UpdateInstructions = UpdateInstructions.Diff + ) { logD("Refreshing playlist list") val edited = editedPlaylist.value if (edited == null) { val playlist = detailGenerator.playlist(uid) - refreshDetail(playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) + refreshDetail( + playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) return } val list = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt index 37cb2cc234..7dcbfa13f6 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt @@ -46,12 +46,19 @@ interface ListSettings : Settings { interface Listener { fun onSongSortChanged() {} + fun onAlbumSortChanged() {} + fun onAlbumSongSortChanged() {} + fun onArtistSortChanged() {} + fun onArtistSongSortChanged() {} + fun onGenreSortChanged() {} + fun onGenreSongSortChanged() {} + fun onPlaylistSortChanged() {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 2a109204a6..0e82b5d86e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -24,13 +24,8 @@ import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.detail.DetailGenerator import org.oxycblt.auxio.detail.DetailSection -import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.home.HomeGenerator -import org.oxycblt.auxio.list.BasicHeader -import org.oxycblt.auxio.list.Divider -import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.adapter.UpdateInstructions -import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -98,13 +93,14 @@ private constructor( override fun invalidate(type: MusicType, replace: Int?) { val deviceLibrary = musicRepository.deviceLibrary ?: return val userLibrary = musicRepository.userLibrary ?: return - val music = when (type) { - MusicType.ALBUMS -> deviceLibrary.albums - MusicType.ARTISTS -> deviceLibrary.artists - MusicType.GENRES -> deviceLibrary.genres - MusicType.PLAYLISTS -> userLibrary.playlists - else -> return - } + val music = + when (type) { + MusicType.ALBUMS -> deviceLibrary.albums + MusicType.ARTISTS -> deviceLibrary.artists + MusicType.GENRES -> deviceLibrary.genres + MusicType.PLAYLISTS -> userLibrary.playlists + else -> return + } if (music.isEmpty()) { return } @@ -226,20 +222,23 @@ private constructor( val detail = detailGenerator.any(uid) ?: return null return detail.sections.flatMap { section -> when (section) { - is DetailSection.Songs -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } - is DetailSection.Albums -> section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } - is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } - is DetailSection.Discs -> section.discs.flatMap { + is DetailSection.Songs -> + section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + is DetailSection.Albums -> + section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + is DetailSection.Artists -> + section.items.map { it.toMediaItem(context, header(section.stringRes)) } + is DetailSection.Discs -> section.discs.flatMap { entry -> val disc = entry.key - val discString = if (disc != null) { - context.getString(R.string.fmt_disc_no, disc.number) - } else { - context.getString(R.string.def_disc) - } + val discString = + if (disc != null) { + context.getString(R.string.fmt_disc_no, disc.number) + } else { + context.getString(R.string.def_disc) + } entry.value.map { it.toMediaItem(context, null, header(discString)) } } - } else -> error("Unknown section type: $section") } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index d0c419ae10..9f77f714e1 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -64,7 +64,7 @@ private constructor( private val exoHolder = exoHolderFactory.create() private val sessionHolder = sessionHolderFactory.create(context, foregroundListener) private val widgetComponent = widgetComponentFactory.create(context) - private val systemReceiver = systemReceiverFactory.create(context) + private val systemReceiver = systemReceiverFactory.create(context, widgetComponent) val token: MediaSessionCompat.Token get() = sessionHolder.token diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index 3a10039db0..4a846cdfec 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -47,10 +47,9 @@ private constructor( @Inject constructor( private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings, - private val widgetComponent: WidgetComponent + private val playbackSettings: PlaybackSettings ) { - fun create(context: Context): SystemPlaybackReceiver { + fun create(context: Context, widgetComponent: WidgetComponent): SystemPlaybackReceiver { val receiver = SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) ContextCompat.registerReceiver( diff --git a/media b/media index 34b33175c0..9fc2401b8f 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 34b33175c00183dc95cdcb8c735033b6785041e1 +Subproject commit 9fc2401b8fdc2b23905402462e775c6db4e1527f From 14035956e6b38cee5b46345cdd72a37f5128dd65 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 44/66] music: tear down menus Only works on automotive OS, which I am not targeting right now. --- .../music/service/MediaItemTranslation.kt | 60 ++----------------- .../music/service/MusicServiceFragment.kt | 16 +---- 2 files changed, 6 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 1d3c8ff020..b2181e5c68 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -83,46 +83,6 @@ sealed interface MediaSessionUID { } } -enum class BrowserOption(val actionId: String, val labelRes: Int, val iconRes: Int) { - PLAY(BuildConfig.APPLICATION_ID + ".menu.PLAY", R.string.lbl_play, R.drawable.ic_play_24), - SHUFFLE( - BuildConfig.APPLICATION_ID + ".menu.SHUFFLE", - R.string.lbl_shuffle, - R.drawable.ic_shuffle_off_24), - PLAY_NEXT( - BuildConfig.APPLICATION_ID + ".menu.PLAY_NEXT", - R.string.lbl_play_next, - R.drawable.ic_play_next_24), - ADD_TO_QUEUE( - BuildConfig.APPLICATION_ID + ".menu.ADD_TO_QUEUE", - R.string.lbl_queue_add, - R.drawable.ic_queue_add_24), - DETAILS( - BuildConfig.APPLICATION_ID + ".menu.DETAILS", - R.string.lbl_parent_detail, - R.drawable.ic_details_24), - ALBUM_DETAILS( - BuildConfig.APPLICATION_ID + ".menu.ALBUM_DETAILS", - R.string.lbl_album_details, - R.drawable.ic_album_24), - ARTIST_DETAILS( - BuildConfig.APPLICATION_ID + ".menu.ARTIST_DETAILS", - R.string.lbl_artist_details, - R.drawable.ic_artist_24); - - companion object { - val ITEM_ID_MAP = - mapOf( - R.id.action_play to PLAY, - R.id.action_shuffle to SHUFFLE, - R.id.action_play_next to PLAY_NEXT, - R.id.action_queue_add to ADD_TO_QUEUE, - R.id.action_detail to DETAILS, - R.id.action_album_details to ALBUM_DETAILS, - R.id.action_artist_details to ARTIST_DETAILS) - } -} - typealias Sugar = Bundle.(Context) -> Unit fun header(@StringRes nameRes: Int): Sugar = { @@ -138,16 +98,6 @@ private fun style(style: Int): Sugar = { putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) } -private fun menu(@MenuRes res: Int): Sugar = { context -> - @SuppressLint("RestrictedApi") val builder = MenuBuilder(context) - MenuInflater(context).inflate(res, builder) - val menuIds = - builder.children.mapNotNullTo(ArrayList()) { - BrowserOption.ITEM_ID_MAP[it.itemId]?.actionId - } - putStringArrayList(MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, menuIds) -} - private fun makeExtras(context: Context, vararg sugars: Sugar): Bundle { return Bundle().apply { sugars.forEach { this.it(context) } } } @@ -181,7 +131,7 @@ fun Song.toMediaDescription( } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val extras = makeExtras(context, *sugar, menu(R.menu.song)) + val extras = makeExtras(context, *sugar) return MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) .setTitle(name.resolve(context)) @@ -212,7 +162,7 @@ fun Album.toMediaItem( } else { MediaSessionUID.ChildItem(parent.uid, uid) } - val extras = makeExtras(context, *sugar, menu(R.menu.album)) + val extras = makeExtras(context, *sugar) val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val description = MediaDescriptionCompat.Builder() @@ -241,7 +191,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) }) - val extras = makeExtras(context, *sugar, menu(R.menu.parent)) + val extras = makeExtras(context, *sugar) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -262,7 +212,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } - val extras = makeExtras(context, *sugar, menu(R.menu.parent)) + val extras = makeExtras(context, *sugar) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -282,7 +232,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { } else { context.getString(R.string.def_song_count) } - val extras = makeExtras(context, *sugar, menu(R.menu.playlist)) + val extras = makeExtras(context, *sugar) val description = MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index a36769d9ce..2cc93401d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -98,21 +98,7 @@ constructor( fun getRoot(maxItems: Int) = BrowserRoot( MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), - Bundle().apply { - val actions = - BrowserOption.entries.mapTo(ArrayList()) { - Bundle().apply { - putString( - MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID, it.actionId) - putString( - MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL, - context.getString(it.labelRes)) - } - } - putParcelableArrayList( - MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST, - actions) - }) + Bundle()) fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } From e12ce82615a6f095242f676f7697a7b81ebd6f7e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 45/66] all: reformat --- .../org/oxycblt/auxio/music/service/MediaItemTranslation.kt | 5 ----- .../org/oxycblt/auxio/music/service/MusicServiceFragment.kt | 5 +---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index b2181e5c68..421405123c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -18,17 +18,12 @@ package org.oxycblt.auxio.music.service -import android.annotation.SuppressLint import android.content.Context import android.graphics.BitmapFactory import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import android.support.v4.media.MediaDescriptionCompat -import android.view.MenuInflater -import androidx.annotation.MenuRes import androidx.annotation.StringRes -import androidx.appcompat.view.menu.MenuBuilder -import androidx.core.view.children import androidx.media.utils.MediaConstants import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 2cc93401d1..2d425e793c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -23,7 +23,6 @@ import android.os.Bundle import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.media.MediaBrowserServiceCompat.BrowserRoot import androidx.media.MediaBrowserServiceCompat.Result -import androidx.media.utils.MediaConstants import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -96,9 +95,7 @@ constructor( } fun getRoot(maxItems: Int) = - BrowserRoot( - MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), - Bundle()) + BrowserRoot(MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), Bundle()) fun getItem(mediaId: String, result: Result) = result.dispatch { musicBrowser.getItem(mediaId) } From 751cd9473626bfa61238e53a7fb8d1a283c67cdb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 46/66] service: re-add attach pattern Turns out I can't actually couple creation/attach without creating a huge amount of variable issues. --- .../java/org/oxycblt/auxio/AuxioService.kt | 3 +- .../oxycblt/auxio/detail/DetailGenerator.kt | 4 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 4 +- .../org/oxycblt/auxio/home/HomeGenerator.kt | 64 +++++++++---------- .../org/oxycblt/auxio/home/HomeViewModel.kt | 4 +- .../oxycblt/auxio/music/service/Indexer.kt | 2 +- .../auxio/music/service/MusicBrowser.kt | 5 ++ .../music/service/MusicServiceFragment.kt | 5 ++ .../replaygain/ReplayGainAudioProcessor.kt | 14 +--- .../service/ExoPlaybackStateHolder.kt | 7 +- .../playback/service/MediaSessionHolder.kt | 2 +- .../service/PlaybackServiceFragment.kt | 10 +-- .../service/SystemPlaybackReceiver.kt | 19 ++++-- .../oxycblt/auxio/widgets/WidgetComponent.kt | 2 +- 14 files changed, 77 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index aeb0e6db76..ecfc404fea 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -49,8 +49,9 @@ class AuxioService : override fun onCreate() { super.onCreate() playbackFragment = playbackFragmentFactory.create(this, this) - sessionToken = playbackFragment.token + sessionToken = playbackFragment.attach() musicFragment = musicFragmentFactory.create(this, this, this) + musicFragment.attach() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt index 0b48b060c9..5e8cc4ed87 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -47,6 +47,8 @@ interface DetailGenerator { fun playlist(uid: Music.UID): Detail? + fun attach() + fun release() interface Factory { @@ -71,7 +73,7 @@ private class DetailGeneratorImpl( private val listSettings: ListSettings, private val musicRepository: MusicRepository ) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener { - init { + override fun attach() { listSettings.registerListener(this) musicRepository.addUpdateListener(this) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index d5fbc9806f..1ca0a70086 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -72,8 +72,6 @@ constructor( private val playbackSettings: PlaybackSettings, detailGeneratorFactory: DetailGenerator.Factory ) : ViewModel(), DetailGenerator.Invalidator { - private val detailGenerator = detailGeneratorFactory.create(this) - private val _toShow = MutableEvent() /** * A [Show] command that is awaiting a view capable of responding to it. Null if none currently. @@ -197,6 +195,8 @@ constructor( playbackSettings.inParentPlaybackMode ?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value)) + private val detailGenerator = detailGeneratorFactory.create(this) + override fun onCleared() { detailGenerator.release() } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt index 5c551db0e7..6b8706cbf6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -32,6 +32,10 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logD interface HomeGenerator { + fun attach() + + fun release() + fun songs(): List fun albums(): List @@ -44,8 +48,6 @@ interface HomeGenerator { fun tabs(): List - fun release() - interface Invalidator { fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) @@ -74,41 +76,14 @@ private class HomeGeneratorImpl( private val listSettings: ListSettings, private val musicRepository: MusicRepository, ) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener { - override fun songs() = - musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() - - override fun albums() = - musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } - ?: emptyList() - - override fun artists() = - musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } - ?: emptyList() - - override fun genres() = - musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } - ?: emptyList() - - override fun playlists() = - musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } - ?: emptyList() - - override fun tabs() = homeSettings.homeTabs.filterIsInstance().map { it.type } - - override fun onTabsChanged() { - invalidator.invalidateTabs() - } - - init { + override fun attach() { homeSettings.registerListener(this) listSettings.registerListener(this) musicRepository.addUpdateListener(this) } - override fun release() { - musicRepository.removeUpdateListener(this) - listSettings.unregisterListener(this) - homeSettings.unregisterListener(this) + override fun onTabsChanged() { + invalidator.invalidateTabs() } override fun onHideCollaboratorsChanged() { @@ -161,4 +136,29 @@ private class HomeGeneratorImpl( invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) } } + override fun release() { + musicRepository.removeUpdateListener(this) + listSettings.unregisterListener(this) + homeSettings.unregisterListener(this) + } + override fun songs() = + musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() + + override fun albums() = + musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } + ?: emptyList() + + override fun artists() = + musicRepository.deviceLibrary?.let { listSettings.artistSort.artists(it.artists) } + ?: emptyList() + + override fun genres() = + musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } + ?: emptyList() + + override fun playlists() = + musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } + ?: emptyList() + + override fun tabs() = homeSettings.homeTabs.filterIsInstance().map { it.type } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 206b4bd0a1..eec145be10 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -52,8 +52,6 @@ constructor( private val playbackSettings: PlaybackSettings, homeGeneratorFactory: HomeGenerator.Factory ) : ViewModel(), HomeGenerator.Invalidator { - private val homeGenerator = homeGeneratorFactory.create(this) - private val _songList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ val songList: StateFlow> @@ -131,6 +129,8 @@ constructor( val playlistSort: Sort get() = listSettings.playlistSort + private val homeGenerator = homeGeneratorFactory.create(this) + /** * A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible * [Tab]s. diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt index de59692160..6b3dfb29fe 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/Indexer.kt @@ -80,7 +80,7 @@ private constructor( .newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexingComponent") - init { + fun attach() { musicSettings.registerListener(this) musicRepository.addUpdateListener(this) musicRepository.addIndexingListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 0e82b5d86e..2fadb2dff7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -71,6 +71,11 @@ private constructor( private val homeGenerator = homeGeneratorFactory.create(this) private val detailGenerator = detailGeneratorFactory.create(this) + fun attach() { + homeGenerator.attach() + detailGenerator.attach() + } + fun release() { homeGenerator.release() detailGenerator.release() diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 2d425e793c..ebf91c001a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -74,6 +74,11 @@ constructor( fun invalidateMusic(mediaId: String) } + fun attach() { + indexer.attach() + musicBrowser.attach() + } + fun release() { dispatchJob.cancel() musicBrowser.release() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 1a17707360..5f28485084 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -44,20 +44,10 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor -private constructor( +class ReplayGainAudioProcessor @Inject constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { - class Factory - @Inject - constructor( - private val playbackManager: PlaybackStateManager, - private val playbackSettings: PlaybackSettings - ) { - fun create() = ReplayGainAudioProcessor(playbackManager, playbackSettings) - } - private var volume = 1f set(value) { field = value @@ -65,7 +55,7 @@ private constructor( flush() } - init { + fun attach() { playbackManager.addListener(this) playbackSettings.registerListener(this) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index b705cf1ab0..4e95e54d4f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -86,7 +86,7 @@ class ExoPlaybackStateHolder( var sessionOngoing = false private set - init { + fun attach() { imageSettings.registerListener(this) player.addListener(this) playbackManager.registerStateHolder(this) @@ -581,14 +581,13 @@ class ExoPlaybackStateHolder( private val playbackSettings: PlaybackSettings, private val commandFactory: PlaybackCommand.Factory, private val mediaSourceFactory: MediaSource.Factory, - private val replayGainProcessorFactory: ReplayGainAudioProcessor.Factory, + private val replayGainProcessor: ReplayGainAudioProcessor, private val musicRepository: MusicRepository, private val imageSettings: ImageSettings, ) { fun create(): ExoPlaybackStateHolder { // Since Auxio is a music player, only specify an audio renderer to save - // battery/apk size/cache size - val replayGainProcessor = replayGainProcessorFactory.create() + // battery/apk size/cache size] val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> arrayOf( FfmpegAudioRenderer(handler, audioListener, replayGainProcessor), diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index f683876d2f..9d3af9bab4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -96,7 +96,7 @@ private constructor( val notification: ForegroundServiceNotification get() = _notification - init { + fun attach() { playbackManager.addListener(this) playbackSettings.registerListener(this) imageSettings.registerListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt index 9f77f714e1..04af2a40fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackServiceFragment.kt @@ -66,13 +66,15 @@ private constructor( private val widgetComponent = widgetComponentFactory.create(context) private val systemReceiver = systemReceiverFactory.create(context, widgetComponent) - val token: MediaSessionCompat.Token - get() = sessionHolder.token - // --- MEDIASESSION CALLBACKS --- - init { + fun attach(): MediaSessionCompat.Token { + exoHolder.attach() + sessionHolder.attach() + widgetComponent.attach() + systemReceiver.attach() playbackManager.addListener(this) + return sessionHolder.token } fun handleTaskRemoved() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt index 4a846cdfec..4e7c214e0f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReceiver.kt @@ -37,6 +37,7 @@ import org.oxycblt.auxio.widgets.WidgetProvider */ class SystemPlaybackReceiver private constructor( + private val context: Context, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, private val widgetComponent: WidgetComponent @@ -49,13 +50,17 @@ private constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) { - fun create(context: Context, widgetComponent: WidgetComponent): SystemPlaybackReceiver { - val receiver = - SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) - ContextCompat.registerReceiver( - context, receiver, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) - return receiver - } + fun create(context: Context, widgetComponent: WidgetComponent) = + SystemPlaybackReceiver(context, playbackManager, playbackSettings, widgetComponent) + } + + fun attach() { + ContextCompat.registerReceiver( + context, this, INTENT_FILTER, ContextCompat.RECEIVER_EXPORTED) + } + + fun release() { + context.unregisterReceiver(this) } override fun onReceive(context: Context, intent: Intent) { diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 367b44f427..909ff7e453 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -67,7 +67,7 @@ private constructor( private val widgetProvider = WidgetProvider() - init { + fun attach() { playbackManager.addListener(this) uiSettings.registerListener(this) imageSettings.registerListener(this) From 2bd468bce3f51097190aa8db35e01e79d3a19960 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:04 -0600 Subject: [PATCH 47/66] detail: fix incorrect disc section generation --- app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt index 5e8cc4ed87..0a4ffd3790 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -125,7 +125,7 @@ private class DetailGeneratorImpl( val songs = listSettings.albumSongSort.songs(album.songs) val discs = songs.groupBy { it.disc } val section = - if (discs.size > 1 || discs.keys.first() != null) { + if (discs.size > 1) { DetailSection.Discs(discs) } else { DetailSection.Songs(songs) From 3afbedb6bd1bb1d27f5336a28332627c20960870 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 48/66] ui: attach to generators --- app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt | 4 ++++ app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 1ca0a70086..7baa215548 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -197,6 +197,10 @@ constructor( private val detailGenerator = detailGeneratorFactory.create(this) + init { + detailGenerator.attach() + } + override fun onCleared() { detailGenerator.release() } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index eec145be10..0894e0576b 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -163,6 +163,10 @@ constructor( val showOuter: Event get() = _showOuter + init { + homeGenerator.attach() + } + override fun onCleared() { super.onCleared() homeGenerator.release() From 6f3fc5904a28af41b8a42a66233ad1c611276e09 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 49/66] detail: generate sort header w/discs --- app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 7baa215548..a73e379ea2 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -547,7 +547,7 @@ constructor( section.items } is DetailSection.Discs -> { - val header = BasicHeader(section.stringRes) + val header = SortHeader(section.stringRes) newList.add(Divider(header)) newList.add(header) section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value } From d6a0b756183bb4133812448fd720c23f23f49d26 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 50/66] detail: fix broken item refresh --- app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index a73e379ea2..285a21d6fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -562,8 +562,8 @@ constructor( newList.addAll(items) } parent.value = detail.parent - list.value = newList instructions.put(newInstructions) + list.value = newList } private fun refreshPlaylist( @@ -585,8 +585,8 @@ constructor( list.add(header) list.addAll(edited) } - _playlistSongList.value = list _playlistSongInstructions.put(instructions) + _playlistSongList.value = list } } From f84e3428f03aa14b180d343eb8f105bb78e6274b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 51/66] home: fix broken item refresh --- .../main/java/org/oxycblt/auxio/home/HomeViewModel.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 0894e0576b..2eb6a7d682 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -175,24 +175,24 @@ constructor( override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { when (type) { MusicType.SONGS -> { - _songList.value = homeGenerator.songs() _songInstructions.put(instructions) + _songList.value = homeGenerator.songs() } MusicType.ALBUMS -> { - _albumList.value = homeGenerator.albums() _albumInstructions.put(instructions) + _albumList.value = homeGenerator.albums() } MusicType.ARTISTS -> { - _artistList.value = homeGenerator.artists() _artistInstructions.put(instructions) + _artistList.value = homeGenerator.artists() } MusicType.GENRES -> { - _genreList.value = homeGenerator.genres() _genreInstructions.put(instructions) + _genreList.value = homeGenerator.genres() } MusicType.PLAYLISTS -> { - _playlistList.value = homeGenerator.playlists() _playlistInstructions.put(instructions) + _playlistList.value = homeGenerator.playlists() } } } From c9664d75c090e8a9ca9ca79c3539495aa40ddb1e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 52/66] home: dont show tab icons in phone mode --- .../java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt index 45f63fd7d0..7cf0c2a536 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -52,7 +52,7 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List) : // On small screens, only display an icon. width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes) // On large screens, display an icon and text. - width < 600 -> tab.setText(homeTab.nameRes).setIcon(icon) + width < 600 -> tab.setText(homeTab.nameRes) // On medium-size screens, display text. else -> tab.setIcon(icon).setText(homeTab.nameRes) } From f4589616bea7b4caba2d6f46f78f104f0e0f41ee Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 53/66] music: simplify disc number resolution Introduce a resolveDisc extension function to share disc name resolution between detail/browser --- .../auxio/detail/list/AlbumDetailListAdapter.kt | 15 +++++---------- .../java/org/oxycblt/auxio/home/HomeGenerator.kt | 2 ++ .../java/org/oxycblt/auxio/music/info/Disc.kt | 5 +++++ .../oxycblt/auxio/music/service/MusicBrowser.kt | 13 ++++--------- .../replaygain/ReplayGainAudioProcessor.kt | 4 +++- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index 63419e1e51..dc672d4211 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -35,6 +35,7 @@ import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.info.resolveNumber import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater @@ -111,16 +112,10 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : */ fun bind(discHeader: DiscHeader) { val disc = discHeader.inner - if (disc != null) { - binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number) - binding.discName.apply { - text = disc.name - isGone = disc.name == null - } - } else { - logD("Disc is null, defaulting to no disc") - binding.discNumber.text = binding.context.getString(R.string.def_disc) - binding.discName.isGone = true + binding.discNumber.text = disc.resolveNumber(binding.context) + binding.discName.apply { + text = disc?.name + isGone = disc?.name == null } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt index 6b8706cbf6..52135d3d46 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeGenerator.kt @@ -136,11 +136,13 @@ private class HomeGeneratorImpl( invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) } } + override fun release() { musicRepository.removeUpdateListener(this) listSettings.unregisterListener(this) homeSettings.unregisterListener(this) } + override fun songs() = musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index 2c8fd360be..b71bb65e9e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -18,6 +18,8 @@ package org.oxycblt.auxio.music.info +import android.content.Context +import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Item /** @@ -34,3 +36,6 @@ class Disc(val number: Int, val name: String?) : Item, Comparable { override fun compareTo(other: Disc) = number.compareTo(other.number) } + + +fun Disc?.resolveNumber(context: Context) = this?.run { context.getString(R.string.fmt_disc_no, number) } ?: context.getString(R.string.def_disc) \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 2fadb2dff7..1d8bb09d1f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -34,6 +34,7 @@ import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.info.resolveNumber import org.oxycblt.auxio.search.SearchEngine class MusicBrowser @@ -234,15 +235,9 @@ private constructor( is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } is DetailSection.Discs -> - section.discs.flatMap { entry -> - val disc = entry.key - val discString = - if (disc != null) { - context.getString(R.string.fmt_disc_no, disc.number) - } else { - context.getString(R.string.def_disc) - } - entry.value.map { it.toMediaItem(context, null, header(discString)) } + section.discs.flatMap { (disc, songs) -> + val discString = disc.resolveNumber(context) + songs.map { it.toMediaItem(context, null, header(discString)) } } else -> error("Unknown section type: $section") } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 5f28485084..1152ef4e30 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -44,7 +44,9 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor @Inject constructor( +class ReplayGainAudioProcessor +@Inject +constructor( private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings ) : BaseAudioProcessor(), PlaybackStateManager.Listener, PlaybackSettings.Listener { From cb43b0f074f3bc53787ea4db92dba32cb16abae4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 54/66] service: decouple maxtab handling and ids Simpler and more versatile. --- .../java/org/oxycblt/auxio/AuxioService.kt | 9 ++-- .../auxio/music/service/MusicBrowser.kt | 32 +++++++------- .../music/service/MusicServiceFragment.kt | 13 +++--- .../oxycblt/auxio/music/service/TabNode.kt | 42 +++++++------------ 4 files changed, 43 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index ecfc404fea..c5842f2f54 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -89,9 +89,7 @@ class AuxioService : clientUid: Int, rootHints: Bundle? ): BrowserRoot { - val maximumRootChildLimit = - rootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 - return musicFragment.getRoot(maximumRootChildLimit) + return musicFragment.getRoot() } override fun onLoadItem(itemId: String, result: Result) { @@ -99,7 +97,10 @@ class AuxioService : } override fun onLoadChildren(parentId: String, result: Result>) { - musicFragment.getChildren(parentId, result) + val maximumRootChildLimit = + browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) + ?: 4 + musicFragment.getChildren(parentId, maximumRootChildLimit, result) } override fun onSearch(query: String, extras: Bundle?, result: Result>) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index 1d8bb09d1f..bccdef87c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -88,12 +88,9 @@ private constructor( } override fun invalidateTabs() { - for (i in 0..10) { - // TODO: Temporary bodge, move the amount parameter to a bundle extra - val rootId = MediaSessionUID.Tab(TabNode.Root(i)).toString() - val moreId = MediaSessionUID.Tab(TabNode.More(i)).toString() - invalidator.invalidateMusic(setOf(rootId, moreId)) - } + val rootId = MediaSessionUID.Tab(TabNode.Root).toString() + val moreId = MediaSessionUID.Tab(TabNode.More).toString() + invalidator.invalidateMusic(setOf(rootId, moreId)) } override fun invalidate(type: MusicType, replace: Int?) { @@ -135,14 +132,13 @@ private constructor( } } - fun getChildren(parentId: String): List? { + fun getChildren(parentId: String, maxTabs: Int): List? { val deviceLibrary = musicRepository.deviceLibrary val userLibrary = musicRepository.userLibrary if (deviceLibrary == null || userLibrary == null) { return listOf() } - - return getMediaItemList(parentId) + return getMediaItemList(parentId, maxTabs) } suspend fun search(query: String): MutableList { @@ -181,10 +177,10 @@ private constructor( return music } - private fun getMediaItemList(id: String): List? { + private fun getMediaItemList(id: String, maxTabs: Int): List? { return when (val mediaSessionUID = MediaSessionUID.fromString(id)) { is MediaSessionUID.Tab -> { - getCategoryMediaItems(mediaSessionUID.node) + getCategoryMediaItems(mediaSessionUID.node, maxTabs) } is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) @@ -198,22 +194,22 @@ private constructor( } } - private fun getCategoryMediaItems(node: TabNode) = + private fun getCategoryMediaItems(node: TabNode, maxTabs: Int) = when (node) { is TabNode.Root -> { val tabs = homeGenerator.tabs() - val base = tabs.take(node.amount - 1).map { TabNode.Home(it) } + val base = tabs.take(maxTabs - 1).map { TabNode.Home(it) } if (base.size < tabs.size) { - base + TabNode.More(tabs.size - base.size) + base + TabNode.More } else { base } .map { it.toMediaItem(context) } } - is TabNode.More -> - homeGenerator.tabs().takeLast(node.remainder).map { - TabNode.Home(it).toMediaItem(context) - } + is TabNode.More -> { + val tabs = homeGenerator.tabs() + tabs.takeLast(tabs.size - maxTabs).map { TabNode.Home(it).toMediaItem(context) } + } is TabNode.Home -> when (node.type) { MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context, null) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index ebf91c001a..a076e3c7aa 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -99,14 +99,17 @@ constructor( indexer.createNotification(post) } - fun getRoot(maxItems: Int) = - BrowserRoot(MediaSessionUID.Tab(TabNode.Root(maxItems)).toString(), Bundle()) + fun getRoot() = BrowserRoot(MediaSessionUID.Tab(TabNode.Root).toString(), Bundle()) fun getItem(mediaId: String, result: Result) = - result.dispatch { musicBrowser.getItem(mediaId) } + result.dispatch { + musicBrowser.getItem( + mediaId, + ) + } - fun getChildren(mediaId: String, result: Result>) = - result.dispatch { musicBrowser.getChildren(mediaId)?.toMutableList() } + fun getChildren(mediaId: String, maxTabs: Int, result: Result>) = + result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.toMutableList() } fun search(query: String, result: Result>) = result.dispatchAsync { musicBrowser.search(query) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt index 1e97055113..0c972659a3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -23,37 +23,27 @@ import org.oxycblt.auxio.music.MusicType sealed class TabNode { abstract val id: String - abstract val data: Int abstract val nameRes: Int abstract val bitmapRes: Int? - override fun toString() = "${id}/${data}" + override fun toString() = id - data class Root(val amount: Int) : TabNode() { - override val id = ID - override val data = amount + data object Root : TabNode() { + override val id = "root" override val nameRes = R.string.info_app_name override val bitmapRes = null - companion object { - const val ID = "root" - } + override fun toString() = id } - data class More(val remainder: Int) : TabNode() { - override val id = ID - override val data = remainder + data object More : TabNode() { + override val id = "more" override val nameRes = R.string.lbl_more override val bitmapRes = null - - companion object { - const val ID = "more" - } } data class Home(val type: MusicType) : TabNode() { - override val id = ID - override val data = type.intCode + override val id = "$ID/${type.intCode}" override val bitmapRes: Int get() = when (type) { @@ -73,15 +63,15 @@ sealed class TabNode { companion object { fun fromString(str: String): TabNode? { - val split = str.split("/", limit = 2) - if (split.size != 2) { - return null - } - val data = split[1].toIntOrNull() ?: return null - return when (split[0]) { - Root.ID -> Root(data) - More.ID -> More(data) - Home.ID -> Home(MusicType.fromIntCode(data) ?: return null) + return when { + str == Root.id -> Root + str == More.id -> More + str.startsWith(Home.ID) -> { + val split = str.split("/") + if (split.size != 2) return null + val intCode = split[1].toIntOrNull() ?: return null + Home(MusicType.fromIntCode(intCode) ?: return null) + } else -> null } } From f0bda0c99f3a02ade1ae661c6ed635dea04e9399 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:05 -0600 Subject: [PATCH 55/66] service: avoid crash on death --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index c5842f2f54..0d1eb0bbca 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -81,7 +81,6 @@ class AuxioService : super.onDestroy() musicFragment.release() playbackFragment.release() - sessionToken = null } override fun onGetRoot( From c236a449c860e49b6bfe6a744d16c3b0156e81f2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 56/66] music: introduce icon for backport more tab --- .../org/oxycblt/auxio/music/service/TabNode.kt | 2 +- .../main/res/drawable-hdpi/ic_more_bitmap_24.png | Bin 0 -> 123 bytes .../main/res/drawable-mdpi/ic_more_bitmap_24.png | Bin 0 -> 97 bytes .../main/res/drawable-xhdpi/ic_more_bitmap_24.png | Bin 0 -> 141 bytes .../res/drawable-xxhdpi/ic_more_bitmap_24.png | Bin 0 -> 179 bytes .../res/drawable-xxxhdpi/ic_more_bitmap_24.png | Bin 0 -> 217 bytes 6 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png create mode 100644 app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt index 0c972659a3..d13dd42be9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/TabNode.kt @@ -39,7 +39,7 @@ sealed class TabNode { data object More : TabNode() { override val id = "more" override val nameRes = R.string.lbl_more - override val bitmapRes = null + override val bitmapRes = R.drawable.ic_more_bitmap_24 } data class Home(val type: MusicType) : TabNode() { diff --git a/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..ee3339aa0de870bb172759efbc4b64b9e5250326 GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k0wldT1B8K;lc$Sgh{y4_7Yz9r6gZp%#n$Vo zw`O1QRA=`o_?@5QF*%8iq1MD?s+VtXRgrd4Zk&IfYhmA_c{RFOv#)*4VW`;7;M}$M U;AStwT%c(Tp00i_>zopr04)t<8 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..fabe49c281cb36636c9083f7c428c0fb26a58ff8 GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_*14Nn)x5R22v2@ u`(b0Csm(8brv48bHLp0Vn(TJafSutHdr0Cg%`JjJJq(_%elF{r5}E*(&K!FH literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..16c932bde39cf7e4794547af9ce4dd84d5e55d79 GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA0wn)(8}b0D5KkA!kch)?FKy&Kpupo2_${HX zpH0*{MvFx=rsagu(fX&J&s1guwH{a+lKSeE(N+7M|NmStxpw0E9esnhZ%n>(-^yMv o_gN?FL+viRq95k03=EobS9Dw_c$R;01Dem^>FVdQ&MBb@04kv}^Z)<= literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..37710f06b803e66e1e45c21077eddded86d70bb1 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhawsytmBLo)8Yy}DblK|!D;QK@@@ z>l$GtjW)JxA+k-}du9gz*T0uIt=tQ!76|q)_1(Q{*|Jq-x$`8w=dM|}dEuL>8`t_> zkKKRn-O8S|0T;bKp18ra^{K;I>CC?UXS}&vZ*MASx4N~J|GNR(A1jDKb`Kbq W-M;yKPu@IlkbtMFpUXO@geCwvEJfb{ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_more_bitmap_24.png new file mode 100644 index 0000000000000000000000000000000000000000..b9280995377874d5ddea4f74ebdd83f9bd52fcd3 GIT binary patch literal 217 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%xcg7I?ZihE&{od+i`+i-7=ZfWBGs z#-?e>vbQ?YAHFY53_anf`mXvp|KvtbBVT5qdJwR_wDkNW)9h(qUd}r=U3TBu8M^nk z^<>UJHC?{`)V$37N3Q<-#MUMwIq$^u^Z&{s5`NFUy881|-Y=Gy@6XlGPq5Q90MoX* z8}7~hf0%8R{K8lIg>TPie3ie+9oG43`cCmRmmwB0>^a7MPW%(2c6X0GNW|0C&t;uc GLK6T^d|CGZ literal 0 HcmV?d00001 From 437d3391e71dfeee03d3ef0c4841021ecbcb2dd5 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 57/66] all: reformat --- .../org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt | 1 - app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index dc672d4211..c1134b2071 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -39,7 +39,6 @@ import org.oxycblt.auxio.music.info.resolveNumber import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.inflater -import org.oxycblt.auxio.util.logD /** * An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view. diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt index b71bb65e9e..5f2b52bd64 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Disc.kt @@ -37,5 +37,6 @@ class Disc(val number: Int, val name: String?) : Item, Comparable { override fun compareTo(other: Disc) = number.compareTo(other.number) } - -fun Disc?.resolveNumber(context: Context) = this?.run { context.getString(R.string.fmt_disc_no, number) } ?: context.getString(R.string.def_disc) \ No newline at end of file +fun Disc?.resolveNumber(context: Context) = + this?.run { context.getString(R.string.fmt_disc_no, number) } + ?: context.getString(R.string.def_disc) From 1a78e973d765108f1b3148a881b0e8c2dc9b8931 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 58/66] playback: use implicit shuffle in detail playback --- .../auxio/playback/service/MediaSessionInterface.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 3f846ad4fc..38153be4ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -292,10 +292,10 @@ constructor( private fun expandMusicIntoCommand(music: Music, parent: MusicParent?) = when (music) { is Song -> expandSongIntoCommand(music, parent) - is Album -> commandFactory.album(music, ShuffleMode.OFF) - is Artist -> commandFactory.artist(music, ShuffleMode.OFF) - is Genre -> commandFactory.genre(music, ShuffleMode.OFF) - is Playlist -> commandFactory.playlist(music, ShuffleMode.OFF) + is Album -> commandFactory.album(music, ShuffleMode.IMPLICIT) + is Artist -> commandFactory.artist(music, ShuffleMode.IMPLICIT) + is Genre -> commandFactory.genre(music, ShuffleMode.IMPLICIT) + is Playlist -> commandFactory.playlist(music, ShuffleMode.IMPLICIT) } private fun expandSongIntoCommand(music: Song, parent: MusicParent?) = From adfed98b715a1976181d154f48b331b05dd9ec4e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 59/66] music: paginate browser results Hopefully now that I'm self-rolling this it'll actually work. --- .../java/org/oxycblt/auxio/AuxioService.kt | 30 +++++++++++++++---- .../music/service/MusicServiceFragment.kt | 22 +++++++++++--- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 0d1eb0bbca..cd1fe2e915 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -23,6 +23,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.IBinder +import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat.MediaItem import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat @@ -32,6 +33,7 @@ import androidx.core.app.ServiceCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants import dagger.hilt.android.AndroidEntryPoint +import org.oxycblt.auxio.music.service.MusicBrowser import javax.inject.Inject import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @@ -96,14 +98,32 @@ class AuxioService : } override fun onLoadChildren(parentId: String, result: Result>) { - val maximumRootChildLimit = - browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) - ?: 4 - musicFragment.getChildren(parentId, maximumRootChildLimit, result) + val maximumRootChildLimit = getRootChildrenLimit() + musicFragment.getChildren(parentId, maximumRootChildLimit, result, null) + } + + override fun onLoadChildren( + parentId: String, + result: Result>, + options: Bundle + ) { + val maximumRootChildLimit = getRootChildrenLimit() + musicFragment.getChildren(parentId, maximumRootChildLimit, result, options.getPage()) } override fun onSearch(query: String, extras: Bundle?, result: Result>) { - musicFragment.search(query, result) + musicFragment.search(query, result, extras?.getPage()) + } + + private fun getRootChildrenLimit(): Int { + return browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) + ?: 4 + } + + private fun Bundle.getPage(): MusicServiceFragment.Page? { + val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null + val pageSize = getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null + return MusicServiceFragment.Page(page, pageSize) } override fun updateForeground(change: ForegroundListener.Change) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index a076e3c7aa..5f597d50c6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -49,6 +49,8 @@ constructor( private val dispatchJob = Job() private val dispatchScope = CoroutineScope(dispatchJob + Dispatchers.Default) + data class Page(val num: Int, val size: Int) + class Factory @Inject constructor( @@ -108,11 +110,12 @@ constructor( ) } - fun getChildren(mediaId: String, maxTabs: Int, result: Result>) = - result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.toMutableList() } + fun getChildren(mediaId: String, maxTabs: Int, result: Result>, page: Page?) = + result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) } + - fun search(query: String, result: Result>) = - result.dispatchAsync { musicBrowser.search(query) } + fun search(query: String, result: Result>, page: Page?) = + result.dispatchAsync { musicBrowser.search(query).expose(page) } private fun Result.dispatch(body: () -> T?) { try { @@ -142,4 +145,15 @@ constructor( } } } + + private fun List.expose(page: Page?): MutableList { + if (page == null) return toMutableList() + val start = page.num * page.size + val end = start + page.size + return if (start >= size) { + mutableListOf() + } else { + subList(start, end.coerceAtMost(size)).toMutableList() + } + } } From 0b3a136320c8e8d1f55adc2245db4ce4f80984e1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 60/66] all: reformat --- app/src/main/java/org/oxycblt/auxio/AuxioService.kt | 7 ++++--- .../oxycblt/auxio/music/service/MusicServiceFragment.kt | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index cd1fe2e915..5f8cbbe6f6 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -33,7 +33,6 @@ import androidx.core.app.ServiceCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants import dagger.hilt.android.AndroidEntryPoint -import org.oxycblt.auxio.music.service.MusicBrowser import javax.inject.Inject import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment @@ -116,13 +115,15 @@ class AuxioService : } private fun getRootChildrenLimit(): Int { - return browserRootHints?.getInt(MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) + return browserRootHints?.getInt( + MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4 } private fun Bundle.getPage(): MusicServiceFragment.Page? { val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null - val pageSize = getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null + val pageSize = + getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null return MusicServiceFragment.Page(page, pageSize) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt index 5f597d50c6..7cdd3fb9e8 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicServiceFragment.kt @@ -110,9 +110,12 @@ constructor( ) } - fun getChildren(mediaId: String, maxTabs: Int, result: Result>, page: Page?) = - result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) } - + fun getChildren( + mediaId: String, + maxTabs: Int, + result: Result>, + page: Page? + ) = result.dispatch { musicBrowser.getChildren(mediaId, maxTabs)?.expose(page) } fun search(query: String, result: Result>, page: Page?) = result.dispatchAsync { musicBrowser.search(query).expose(page) } From 19f3e07c8e2875bf2ba2daa4bf9b0926c5365fec Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 61/66] service: bundle parent info into extras Instead of using mediaId. This makes it so that there is only really one mediaId to work with, with an optional extra for playback that I desperately hope is preserved on all instances of Android Auto. --- .../java/org/oxycblt/auxio/AuxioService.kt | 2 + .../oxycblt/auxio/detail/DetailGenerator.kt | 2 +- .../music/service/MediaItemTranslation.kt | 53 ++++--------------- .../auxio/music/service/MusicBrowser.kt | 28 +++++----- .../playback/service/MediaSessionHolder.kt | 2 +- .../playback/service/MediaSessionInterface.kt | 31 +++++------ 6 files changed, 44 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt index 5f8cbbe6f6..da79b8a117 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioService.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioService.kt @@ -36,6 +36,7 @@ import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment +import org.oxycblt.auxio.util.logD @AndroidEntryPoint class AuxioService : @@ -149,6 +150,7 @@ class AuxioService : } override fun invalidateMusic(mediaId: String) { + logD(mediaId) notifyChildrenChanged(mediaId) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt index 0a4ffd3790..348badcdb0 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailGenerator.kt @@ -205,7 +205,7 @@ sealed interface DetailSection { data class Artists(override val items: List) : PlainSection() { override val order = 0 - override val stringRes = R.string.lbl_songs + override val stringRes = R.string.lbl_artists } data class Albums(val category: Category, override val items: List) : diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 421405123c..48488a376a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -47,10 +47,6 @@ sealed interface MediaSessionUID { override fun toString() = "$ID_ITEM:$uid" } - data class ChildItem(val parentUid: Music.UID, val childUid: Music.UID) : MediaSessionUID { - override fun toString() = "$ID_ITEM:$parentUid>$childUid" - } - companion object { const val ID_CATEGORY = BuildConfig.APPLICATION_ID + ".category" const val ID_ITEM = BuildConfig.APPLICATION_ID + ".item" @@ -62,16 +58,7 @@ sealed interface MediaSessionUID { } return when (parts[0]) { ID_CATEGORY -> Tab(TabNode.fromString(parts[1]) ?: return null) - ID_ITEM -> { - val uids = parts[1].split(">", limit = 2) - if (uids.size == 1) { - Music.UID.fromString(uids[0])?.let { SingleItem(it) } - } else { - Music.UID.fromString(uids[0])?.let { parent -> - Music.UID.fromString(uids[1])?.let { child -> ChildItem(parent, child) } - } - } - } + ID_ITEM -> SingleItem(Music.UID.fromString(parts[1]) ?: return null) else -> return null } } @@ -89,6 +76,10 @@ fun header(name: String): Sugar = { putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, name) } +fun child(of: MusicParent): Sugar = { + putString(MusicBrowser.KEY_CHILD_OF, MediaSessionUID.SingleItem(of.uid).toString()) +} + private fun style(style: Int): Sugar = { putInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, style) } @@ -115,17 +106,8 @@ fun TabNode.toMediaItem(context: Context): MediaItem { return MediaItem(description.build(), MediaItem.FLAG_BROWSABLE) } -fun Song.toMediaDescription( - context: Context, - parent: MusicParent? = null, - vararg sugar: Sugar -): MediaDescriptionCompat { - val mediaSessionUID = - if (parent == null) { - MediaSessionUID.SingleItem(uid) - } else { - MediaSessionUID.ChildItem(parent.uid, uid) - } +fun Song.toMediaDescription(context: Context, vararg sugar: Sugar): MediaDescriptionCompat { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) val extras = makeExtras(context, *sugar) return MediaDescriptionCompat.Builder() .setMediaId(mediaSessionUID.toString()) @@ -138,25 +120,12 @@ fun Song.toMediaDescription( .build() } -fun Song.toMediaItem( - context: Context, - parent: MusicParent? = null, - vararg sugar: Sugar -): MediaItem { - return MediaItem(toMediaDescription(context, parent, *sugar), MediaItem.FLAG_PLAYABLE) +fun Song.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + return MediaItem(toMediaDescription(context, *sugar), MediaItem.FLAG_PLAYABLE) } -fun Album.toMediaItem( - context: Context, - parent: MusicParent? = null, - vararg sugar: Sugar -): MediaItem { - val mediaSessionUID = - if (parent == null) { - MediaSessionUID.SingleItem(uid) - } else { - MediaSessionUID.ChildItem(parent.uid, uid) - } +fun Album.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem { + val mediaSessionUID = MediaSessionUID.SingleItem(uid) val extras = makeExtras(context, *sugar) val counts = context.getPlural(R.plurals.fmt_song_count, songs.size) val description = diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index bccdef87c5..caffe06882 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -21,6 +21,7 @@ package org.oxycblt.auxio.music.service import android.content.Context import android.support.v4.media.MediaBrowserCompat.MediaItem import javax.inject.Inject +import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.detail.DetailGenerator import org.oxycblt.auxio.detail.DetailSection @@ -117,8 +118,6 @@ private constructor( is MediaSessionUID.Tab -> return uid.node.toMediaItem(context) is MediaSessionUID.SingleItem -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.ChildItem -> - musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } null -> null } ?: return null @@ -128,7 +127,7 @@ private constructor( is Artist -> music.toMediaItem(context) is Genre -> music.toMediaItem(context) is Playlist -> music.toMediaItem(context) - is Song -> music.toMediaItem(context, null) + is Song -> music.toMediaItem(context) } } @@ -160,10 +159,10 @@ private constructor( private fun SearchEngine.Items.toMediaItems(): MutableList { val music = mutableListOf() if (songs != null) { - music.addAll(songs.map { it.toMediaItem(context, null, header(R.string.lbl_songs)) }) + music.addAll(songs.map { it.toMediaItem(context, header(R.string.lbl_songs)) }) } if (albums != null) { - music.addAll(albums.map { it.toMediaItem(context, null, header(R.string.lbl_albums)) }) + music.addAll(albums.map { it.toMediaItem(context, header(R.string.lbl_albums)) }) } if (artists != null) { music.addAll(artists.map { it.toMediaItem(context, header(R.string.lbl_artists)) }) @@ -185,9 +184,6 @@ private constructor( is MediaSessionUID.SingleItem -> { getChildMediaItems(mediaSessionUID.uid) } - is MediaSessionUID.ChildItem -> { - getChildMediaItems(mediaSessionUID.childUid) - } null -> { return null } @@ -208,11 +204,11 @@ private constructor( } is TabNode.More -> { val tabs = homeGenerator.tabs() - tabs.takeLast(tabs.size - maxTabs).map { TabNode.Home(it).toMediaItem(context) } + tabs.takeLast(tabs.size - maxTabs + 1).map { TabNode.Home(it).toMediaItem(context) } } is TabNode.Home -> when (node.type) { - MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context, null) } + MusicType.SONGS -> homeGenerator.songs().map { it.toMediaItem(context) } MusicType.ALBUMS -> homeGenerator.albums().map { it.toMediaItem(context) } MusicType.ARTISTS -> homeGenerator.artists().map { it.toMediaItem(context) } MusicType.GENRES -> homeGenerator.genres().map { it.toMediaItem(context) } @@ -225,18 +221,24 @@ private constructor( return detail.sections.flatMap { section -> when (section) { is DetailSection.Songs -> - section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + section.items.map { + it.toMediaItem(context, header(section.stringRes), child(detail.parent)) + } is DetailSection.Albums -> - section.items.map { it.toMediaItem(context, null, header(section.stringRes)) } + section.items.map { it.toMediaItem(context, header(section.stringRes)) } is DetailSection.Artists -> section.items.map { it.toMediaItem(context, header(section.stringRes)) } is DetailSection.Discs -> section.discs.flatMap { (disc, songs) -> val discString = disc.resolveNumber(context) - songs.map { it.toMediaItem(context, null, header(discString)) } + songs.map { it.toMediaItem(context, header(discString)) } } else -> error("Unknown section type: $section") } } } + + companion object { + const val KEY_CHILD_OF = BuildConfig.APPLICATION_ID + ".key.CHILD_OF" + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt index 9d3af9bab4..b5724f6b4c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionHolder.kt @@ -295,7 +295,7 @@ private constructor( queue.mapIndexed { i, song -> val description = song.toMediaDescription( - context, null, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) + context, { putInt(MediaSessionInterface.KEY_QUEUE_POS, i) }) // Store the item index so we can then use the analogous index in the // playback state. MediaSessionCompat.QueueItem(description, i.toLong()) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt index 38153be4ac..2ea4f8db28 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionInterface.kt @@ -41,11 +41,13 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.service.MediaSessionUID +import org.oxycblt.auxio.music.service.MusicBrowser import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.auxio.playback.state.PlaybackCommand import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.ShuffleMode +import org.oxycblt.auxio.util.logD class MediaSessionInterface @Inject @@ -80,7 +82,10 @@ constructor( override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) val uid = MediaSessionUID.fromString(mediaId ?: return) ?: return - val command = expandUidIntoCommand(uid) + val parentUid = + extras?.getString(MusicBrowser.KEY_CHILD_OF)?.let { MediaSessionUID.fromString(it) } + val command = expandUidIntoCommand(uid, parentUid) + logD(extras?.getString(MusicBrowser.KEY_CHILD_OF)) playbackManager.play(requireNotNull(command) { "Invalid playback configuration" }) } @@ -105,7 +110,6 @@ constructor( val songUid = when (uid) { is MediaSessionUID.SingleItem -> uid.uid - is MediaSessionUID.ChildItem -> uid.childUid else -> return } val song = deviceLibrary.songs.find { it.uid == songUid } ?: return @@ -126,7 +130,6 @@ constructor( val songUid = when (uid) { is MediaSessionUID.SingleItem -> uid.uid - is MediaSessionUID.ChildItem -> uid.childUid else -> return } val firstAt = playbackManager.queue.indexOfFirst { it.uid == songUid } @@ -194,20 +197,14 @@ constructor( context.sendBroadcast(Intent(action)) } - private fun expandUidIntoCommand(uid: MediaSessionUID): PlaybackCommand? { - val music: Music - var parent: MusicParent? = null - when (uid) { - is MediaSessionUID.SingleItem -> { - music = musicRepository.find(uid.uid) ?: return null - } - is MediaSessionUID.ChildItem -> { - music = musicRepository.find(uid.childUid) ?: return null - parent = musicRepository.find(uid.parentUid) as? MusicParent ?: return null - } - else -> return null - } - + private fun expandUidIntoCommand( + uid: MediaSessionUID, + parentUid: MediaSessionUID? + ): PlaybackCommand? { + val unwrappedUid = (uid as? MediaSessionUID.SingleItem)?.uid ?: return null + val unwrappedParentUid = (parentUid as? MediaSessionUID.SingleItem)?.uid + val music = musicRepository.find(unwrappedUid) ?: return null + val parent = unwrappedParentUid?.let { musicRepository.find(it) as? MusicParent } return expandMusicIntoCommand(music, parent) } From bed1dc43cd7fd4ba8eeb7f66bf84a3475fff86f2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 62/66] playback: fix gaps on playlist change --- .../service/ExoPlaybackStateHolder.kt | 22 ++++++++++++++----- .../playback/state/PlaybackStateHolder.kt | 8 ++++++- .../playback/state/PlaybackStateManager.kt | 18 ++++++++++----- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 4e95e54d4f..2947052c2f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -47,6 +47,7 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.msToSecs import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.state.DeferredPlayback @@ -363,13 +364,15 @@ class ExoPlaybackStateHolder( override fun applySavedState( parent: MusicParent?, rawQueue: RawQueue, + positionMs: Long, + repeatMode: RepeatMode, ack: StateAck.NewPlayback? ) { - logD("Applying saved state") - var sendEvent = false + var sendNewPlaybackEvent = false + var shouldSeek = false if (this.parent != parent) { this.parent = parent - sendEvent = true + sendNewPlaybackEvent = true } if (rawQueue != resolveQueue()) { player.setMediaItems(rawQueue.heap.map { it.buildMediaItem() }) @@ -382,9 +385,18 @@ class ExoPlaybackStateHolder( player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) player.prepare() player.pause() - sendEvent = true + sendNewPlaybackEvent = true + shouldSeek = true } - if (sendEvent) { + + repeatMode(repeatMode) + // Positions in milliseconds will drift during tight restores (i.e what the music loader + // does to sanitize the state), compare by seconds instead. + if (positionMs.msToSecs() != player.currentPosition.msToSecs() || shouldSeek) { + player.seekTo(positionMs) + } + + if (sendNewPlaybackEvent) { ack?.let { playbackManager.ack(this, it) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt index c2c51f0cea..4d1cdca981 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateHolder.kt @@ -145,7 +145,13 @@ interface PlaybackStateHolder { * @param ack The [StateAck] to return to [PlaybackStateManager]. If null, do not return any * ack. */ - fun applySavedState(parent: MusicParent?, rawQueue: RawQueue, ack: StateAck.NewPlayback?) + fun applySavedState( + parent: MusicParent?, + rawQueue: RawQueue, + positionMs: Long, + repeatMode: RepeatMode, + ack: StateAck.NewPlayback? + ) /** End whatever ongoing playback session may be going on */ fun endSession() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 494ab2c0e8..283a3e6f70 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -416,9 +416,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { this.stateHolder = stateHolder if (isInitialized && currentSong != null) { - stateHolder.applySavedState(stateMirror.parent, stateMirror.rawQueue, null) - stateHolder.seekTo(stateMirror.progression.calculateElapsedPositionMs()) - stateHolder.playing(false) + stateHolder.applySavedState( + stateMirror.parent, + stateMirror.rawQueue, + stateMirror.progression.calculateElapsedPositionMs(), + stateMirror.repeatMode, + null) } pendingDeferredPlayback?.let(stateHolder::handleDeferred) } @@ -795,9 +798,12 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { index }) - stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback) - stateHolder.seekTo(savedState.positionMs) - stateHolder.repeatMode(savedState.repeatMode) + stateHolder.applySavedState( + savedState.parent, + rawQueue, + savedState.positionMs, + savedState.repeatMode, + StateAck.NewPlayback) isInitialized = true } From 10d7f5d1977cb50d93b15e1d0713da6b2885478b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:06 -0600 Subject: [PATCH 63/66] actions: add ninja requirement --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 61ec47e0c8..65221f0a54 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -11,6 +11,8 @@ jobs: runs-on: ubuntu-latest steps: + - name: Install xmllint + run: sudo apt-get install -y ninja-build - name: Clone repository uses: actions/checkout@v3 - name: Clone submodules From 29e29d3cab12685182b5a5fe930fc8fc3df3b8e4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:07 -0600 Subject: [PATCH 64/66] actions: fix ninja-build install step name --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 65221f0a54..9b7aae1cd7 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - name: Install xmllint + - name: Install ninja-build run: sudo apt-get install -y ninja-build - name: Clone repository uses: actions/checkout@v3 From 344a49532b6f26262aac4d4a1cec8ec99df0f0b6 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:07 -0600 Subject: [PATCH 65/66] music: fix more tab compat --- .../oxycblt/auxio/music/service/MusicBrowser.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt index caffe06882..35a43c7b53 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MusicBrowser.kt @@ -194,17 +194,15 @@ private constructor( when (node) { is TabNode.Root -> { val tabs = homeGenerator.tabs() - val base = tabs.take(maxTabs - 1).map { TabNode.Home(it) } - if (base.size < tabs.size) { - base + TabNode.More - } else { - base - } - .map { it.toMediaItem(context) } + if (maxTabs < tabs.size) { + tabs.take(maxTabs - 1).map { TabNode.Home(it).toMediaItem(context) } + + TabNode.More.toMediaItem(context) + } else { + tabs.map { TabNode.Home(it).toMediaItem(context) } + } } is TabNode.More -> { - val tabs = homeGenerator.tabs() - tabs.takeLast(tabs.size - maxTabs + 1).map { TabNode.Home(it).toMediaItem(context) } + homeGenerator.tabs().drop(maxTabs - 1).map { TabNode.Home(it).toMediaItem(context) } } is TabNode.Home -> when (node.type) { From e23643f3aba1a37750a47d08661a872c96c7f3c8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Oct 2024 12:46:07 -0600 Subject: [PATCH 66/66] build: bump to 3.6.0 --- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 4 ++-- app/build.gradle | 4 ++-- .../metadata/android/en-US/changelogs/50.txt | 2 ++ 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/50.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 840e6e0a8a..699da229b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 3.6.0 + +#### What's New +- Added support for playback from google assistant + +#### What's Improved +- Home and detail UIs in Android Auto now reflect app sort settings +- Album view now shows discs in android auto + +#### What's Fixed +- Fixed playback briefly pausing when adding songs to playlist +- Fixed media lists in Android Auto being truncated in some cases +- Possibly fixed duplicated song items depending on album/all children +- Possibly fixed truncated tab lists in android auto + +#### Dev/Meta +- Moved to raw media session apis rather than media3 session + ## 3.5.3 #### What's New diff --git a/README.md b/README.md index 191ca51ecf..68a4139fe1 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index a0198d7373..2cd8a66feb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.5.3" - versionCode 49 + versionName "3.6.0" + versionCode 50 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/50.txt b/fastlane/metadata/android/en-US/changelogs/50.txt new file mode 100644 index 0000000000..bd58c45f8a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/50.txt @@ -0,0 +1,2 @@ +Auxio 3.6.0 improves support for android auto and fixes several small regressions. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.5.3