diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt index 18f82cb25..0d39e5a84 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackNotification.kt @@ -5,20 +5,19 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context -import android.content.Intent import android.os.Build import android.support.v4.media.session.MediaSessionCompat import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat import androidx.media.app.NotificationCompat.MediaStyle -import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.MainActivity import org.oxycblt.auxio.R import org.oxycblt.auxio.coil.loadBitmap import org.oxycblt.auxio.music.Parent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.LoopMode import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.ui.newBroadcastIntent +import org.oxycblt.auxio.ui.newMainIntent /** * The unified notification for [PlaybackService]. This is not self-sufficient, updates have @@ -35,24 +34,18 @@ class PlaybackNotification private constructor( else 0 init { - val activityIntent = PendingIntent.getActivity( - context, REQUEST_CODE, - Intent(context, MainActivity::class.java), - pendingIntentFlags - ) - setSmallIcon(R.drawable.ic_song) setCategory(NotificationCompat.CATEGORY_SERVICE) setShowWhen(false) setSilent(true) - setContentIntent(activityIntent) + setContentIntent(context.newMainIntent()) setVisibility(NotificationCompat.VISIBILITY_PUBLIC) addAction(buildLoopAction(context, LoopMode.NONE)) - addAction(buildAction(context, ACTION_SKIP_PREV, R.drawable.ic_skip_prev)) + addAction(buildAction(context, PlaybackService.ACTION_SKIP_PREV, R.drawable.ic_skip_prev)) addAction(buildPlayPauseAction(context, true)) - addAction(buildAction(context, ACTION_SKIP_NEXT, R.drawable.ic_skip_next)) - addAction(buildAction(context, ACTION_EXIT, R.drawable.ic_exit)) + addAction(buildAction(context, PlaybackService.ACTION_SKIP_NEXT, R.drawable.ic_skip_next)) + addAction(buildAction(context, PlaybackService.ACTION_EXIT, R.drawable.ic_exit)) setStyle( MediaStyle() @@ -130,7 +123,7 @@ class PlaybackNotification private constructor( ): NotificationCompat.Action { val drawableRes = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play - return buildAction(context, ACTION_PLAY_PAUSE, drawableRes) + return buildAction(context, PlaybackService.ACTION_PLAY_PAUSE, drawableRes) } private fun buildLoopAction( @@ -143,7 +136,7 @@ class PlaybackNotification private constructor( LoopMode.TRACK -> R.drawable.ic_loop_one } - return buildAction(context, ACTION_LOOP, drawableRes) + return buildAction(context, PlaybackService.ACTION_LOOP, drawableRes) } private fun buildShuffleAction( @@ -152,7 +145,7 @@ class PlaybackNotification private constructor( ): NotificationCompat.Action { val drawableRes = if (isShuffled) R.drawable.ic_shuffle else R.drawable.ic_shuffle_inactive - return buildAction(context, ACTION_SHUFFLE, drawableRes) + return buildAction(context, PlaybackService.ACTION_SHUFFLE, drawableRes) } private fun buildAction( @@ -162,10 +155,7 @@ class PlaybackNotification private constructor( ): NotificationCompat.Action { val action = NotificationCompat.Action.Builder( iconRes, actionName, - PendingIntent.getBroadcast( - context, REQUEST_CODE, - Intent(actionName), pendingIntentFlags - ) + context.newBroadcastIntent(actionName) ) return action.build() @@ -174,14 +164,6 @@ class PlaybackNotification private constructor( companion object { const val CHANNEL_ID = "CHANNEL_AUXIO_PLAYBACK" const val NOTIFICATION_ID = 0xA0A0 - const val REQUEST_CODE = 0xA0C0 - - const val ACTION_LOOP = BuildConfig.APPLICATION_ID + ".action.LOOP" - const val ACTION_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" - const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" - const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" - const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" - const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" /** * Build a new instance of [PlaybackNotification]. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 02fa12f23..695dea2ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -43,7 +43,7 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.ui.getSystemServiceSafe import org.oxycblt.auxio.widgets.BaseWidget -import org.oxycblt.auxio.widgets.MinimalWidgetProvider +import org.oxycblt.auxio.widgets.WidgetController /** * A service that manages the system-side aspects of playback, such as: @@ -56,6 +56,8 @@ import org.oxycblt.auxio.widgets.MinimalWidgetProvider * This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback], * so therefore there's no need to bind to it to deliver commands. * @author OxygenCobalt + * + * TODO: Try to split up this god object somewhat, such as making the notification state-aware. */ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback { @@ -71,15 +73,13 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac // System backend components private lateinit var audioReactor: AudioReactor private lateinit var wakeLock: PowerManager.WakeLock + private lateinit var widgets: WidgetController private val systemReceiver = SystemEventReceiver() // Managers private val playbackManager = PlaybackStateManager.getInstance() private val settingsManager = SettingsManager.getInstance() - // Widgets - private val minimalWidget = MinimalWidgetProvider() - // State private var isForeground = false @@ -113,12 +113,14 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac false ) + // --- SYSTEM SETUP --- + audioReactor = AudioReactor(this, player) wakeLock = getSystemServiceSafe(PowerManager::class).newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, this::class.simpleName ) - // --- CALLBACKS --- + widgets = WidgetController(this) // Set up the media button callbacks mediaSession = MediaSessionCompat(this, packageName).apply { @@ -129,12 +131,12 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac // Then the notif/headset callbacks IntentFilter().apply { - addAction(PlaybackNotification.ACTION_LOOP) - addAction(PlaybackNotification.ACTION_SHUFFLE) - addAction(PlaybackNotification.ACTION_SKIP_PREV) - addAction(PlaybackNotification.ACTION_PLAY_PAUSE) - addAction(PlaybackNotification.ACTION_SKIP_NEXT) - addAction(PlaybackNotification.ACTION_EXIT) + addAction(ACTION_LOOP) + addAction(ACTION_SHUFFLE) + addAction(ACTION_SKIP_PREV) + addAction(ACTION_PLAY_PAUSE) + addAction(ACTION_SKIP_NEXT) + addAction(ACTION_EXIT) addAction(BluetoothDevice.ACTION_ACL_CONNECTED) addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) @@ -177,12 +179,9 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac connector.release() mediaSession.release() audioReactor.release() + widgets.release() releaseWakelock() - // Technically the widgets don't *have* to be reset, but any commands from them - // won't work if the service is dead, so we do it anyway - minimalWidget.stop(this) - playbackManager.removeCallback(this) settingsManager.removeCallback(this) @@ -250,14 +249,11 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac this, song, settingsManager.colorizeNotif, ::startForegroundOrNotify ) - minimalWidget.update(this, playbackManager) - return } // Clear if there's nothing to play. player.stop() - minimalWidget.stop(this) stopForegroundAndNotification() } @@ -458,22 +454,22 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac // --- NOTIFICATION CASES --- - PlaybackNotification.ACTION_PLAY_PAUSE -> playbackManager.setPlaying( + ACTION_PLAY_PAUSE -> playbackManager.setPlaying( !playbackManager.isPlaying ) - PlaybackNotification.ACTION_LOOP -> playbackManager.setLoopMode( + ACTION_LOOP -> playbackManager.setLoopMode( playbackManager.loopMode.increment() ) - PlaybackNotification.ACTION_SHUFFLE -> playbackManager.setShuffling( + ACTION_SHUFFLE -> playbackManager.setShuffling( !playbackManager.isShuffling, keepSong = true ) - PlaybackNotification.ACTION_SKIP_PREV -> playbackManager.prev() - PlaybackNotification.ACTION_SKIP_NEXT -> playbackManager.next() + ACTION_SKIP_PREV -> playbackManager.prev() + ACTION_SKIP_NEXT -> playbackManager.next() - PlaybackNotification.ACTION_EXIT -> { + ACTION_EXIT -> { playbackManager.setPlaying(false) stopForegroundAndNotification() } @@ -499,14 +495,9 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac } } - // Newly added widgets need PlaybackService to - BaseWidget.ACTION_WIDGET_UPDATE -> { - when (intent.getIntExtra(BaseWidget.KEY_WIDGET_TYPE, -1)) { - MinimalWidgetProvider.TYPE -> minimalWidget?.update( - this@PlaybackService, playbackManager - ) - } - } + BaseWidget.ACTION_WIDGET_UPDATE -> widgets.initWidget( + intent.getIntExtra(BaseWidget.ACTION_WIDGET_UPDATE, -1) + ) } } @@ -539,6 +530,11 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac private const val WAKELOCK_TIME = 25000L private const val POS_POLL_INTERVAL = 500L - const val BROADCAST_WIDGET_START = BuildConfig.APPLICATION_ID + ".key.WIDGETS_START" + const val ACTION_LOOP = BuildConfig.APPLICATION_ID + ".action.LOOP" + const val ACTION_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE" + const val ACTION_SKIP_PREV = BuildConfig.APPLICATION_ID + ".action.PREV" + const val ACTION_PLAY_PAUSE = BuildConfig.APPLICATION_ID + ".action.PLAY_PAUSE" + const val ACTION_SKIP_NEXT = BuildConfig.APPLICATION_ID + ".action.NEXT" + const val ACTION_EXIT = BuildConfig.APPLICATION_ID + ".action.EXIT" } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt index b062cd308..8ff89bede 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackSessionConnector.kt @@ -85,7 +85,7 @@ class PlaybackSessionConnector( override fun onStop() { // Get the service to shut down with the ACTION_EXIT intent - context.sendBroadcast(Intent(PlaybackNotification.ACTION_EXIT)) + context.sendBroadcast(Intent(PlaybackService.ACTION_EXIT)) } // --- PLAYBACKSTATEMANAGER CALLBACKS --- diff --git a/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt b/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt index 554996188..a16057604 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt @@ -1,7 +1,9 @@ package org.oxycblt.auxio.ui import android.app.Activity +import android.app.PendingIntent import android.content.Context +import android.content.Intent import android.content.res.ColorStateList import android.content.res.Configuration import android.content.res.Resources @@ -24,6 +26,7 @@ import androidx.annotation.PluralsRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.MainActivity import org.oxycblt.auxio.R import org.oxycblt.auxio.logE import kotlin.reflect.KClass @@ -151,6 +154,32 @@ fun Context.showToast(@StringRes str: Int) { Toast.makeText(applicationContext, getString(str), Toast.LENGTH_SHORT).show() } +const val INTENT_REQUEST_CODE = 0xA0A0 + +/** + * Create a broadcast [PendingIntent] + */ +fun Context.newBroadcastIntent(what: String): PendingIntent { + return PendingIntent.getBroadcast( + this, INTENT_REQUEST_CODE, Intent(what), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + PendingIntent.FLAG_IMMUTABLE + else 0 + ) +} + +/** + * Create a [PendingIntent] that leads to Auxio's [MainActivity] + */ +fun Context.newMainIntent(): PendingIntent { + return PendingIntent.getActivity( + this, INTENT_REQUEST_CODE, Intent(this, MainActivity::class.java), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + PendingIntent.FLAG_IMMUTABLE + else 0 + ) +} + /** * Assert that we are on a background thread. */ diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/BaseWidget.kt b/app/src/main/java/org/oxycblt/auxio/widgets/BaseWidget.kt index f75a847c1..a732a0bf8 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/BaseWidget.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/BaseWidget.kt @@ -1,19 +1,18 @@ package org.oxycblt.auxio.widgets -import android.app.PendingIntent +import android.annotation.SuppressLint import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context import android.content.Intent -import android.os.Build import android.widget.RemoteViews import androidx.annotation.LayoutRes import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.MainActivity import org.oxycblt.auxio.R import org.oxycblt.auxio.logD import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.ui.newMainIntent /** * The base widget class for all widget implementations in Auxio. @@ -21,11 +20,16 @@ import org.oxycblt.auxio.playback.state.PlaybackStateManager abstract class BaseWidget : AppWidgetProvider() { abstract val type: Int - /* - * Returns the default view for this widget. This should be the "No music playing" screen - * in pretty much all cases. - */ - protected abstract fun getDefaultViews(context: Context): RemoteViews + protected open fun createViews(context: Context, @LayoutRes layout: Int): RemoteViews { + val views = RemoteViews(context.packageName, layout) + + views.setOnClickPendingIntent( + android.R.id.background, + context.newMainIntent() + ) + + return views + } /* * Update views based off of the playback state. This job is asynchronous and should be @@ -59,7 +63,7 @@ abstract class BaseWidget : AppWidgetProvider() { logD("Stopping widget") val manager = AppWidgetManager.getInstance(context) - manager.applyViews(context, getDefaultViews(context)) + manager.applyViews(context, defaultViews(context)) } override fun onUpdate( @@ -67,7 +71,7 @@ abstract class BaseWidget : AppWidgetProvider() { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { - appWidgetManager.applyViews(context, getDefaultViews(context)) + appWidgetManager.applyViews(context, defaultViews(context)) logD("Sending update intent to PlaybackService") @@ -78,20 +82,12 @@ abstract class BaseWidget : AppWidgetProvider() { context.sendBroadcast(intent) } - protected fun getRemoteViews(context: Context, @LayoutRes layout: Int): RemoteViews { - val views = RemoteViews(context.packageName, layout) - - views.setOnClickPendingIntent( - android.R.id.background, - PendingIntent.getActivity( - context, 0xA0A0, Intent(context, MainActivity::class.java), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - PendingIntent.FLAG_IMMUTABLE - else 0 - ) + @SuppressLint("RemoteViewLayout") + protected fun defaultViews(context: Context): RemoteViews { + return RemoteViews( + context.packageName, + R.layout.widget_default ) - - return views } private fun AppWidgetManager.applyViews(context: Context, views: RemoteViews) { diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/MinimalWidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/MinimalWidgetProvider.kt index 2ce69d23a..7f6e4f2f2 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/MinimalWidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/MinimalWidgetProvider.kt @@ -7,16 +7,40 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.coil.loadBitmap import org.oxycblt.auxio.logD import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.system.PlaybackService +import org.oxycblt.auxio.ui.newBroadcastIntent /** - * The minimal widget. This widget only shows the album, song name, and artist without any - * controls. Because you know. Minimalism. + * The minimal widget, which shows the primary song controls and basic playback information. */ class MinimalWidgetProvider : BaseWidget() { override val type: Int get() = TYPE - override fun getDefaultViews(context: Context): RemoteViews { - return getRemoteViews(context, R.layout.widget_default) + override fun createViews(context: Context, layout: Int): RemoteViews { + val views = super.createViews(context, layout) + + views.setOnClickPendingIntent( + R.id.widget_skip_prev, + context.newBroadcastIntent( + PlaybackService.ACTION_SKIP_PREV + ) + ) + + views.setOnClickPendingIntent( + R.id.widget_play_pause, + context.newBroadcastIntent( + PlaybackService.ACTION_PLAY_PAUSE + ) + ) + + views.setOnClickPendingIntent( + R.id.widget_skip_next, + context.newBroadcastIntent( + PlaybackService.ACTION_SKIP_NEXT + ) + ) + + return views } override fun updateViews( @@ -29,29 +53,48 @@ class MinimalWidgetProvider : BaseWidget() { if (song != null) { logD("updating view to ${song.name}") - val views = getRemoteViews(context, R.layout.widget_minimal) + val views = createViews(context, R.layout.widget_minimal) // Update the metadata views.setTextViewText(R.id.widget_song, song.name) views.setTextViewText(R.id.widget_artist, song.album.artist.name) + views.setInt( + R.id.widget_play_pause, + "setImageResource", + if (playbackManager.isPlaying) { + R.drawable.ic_pause + } else { + R.drawable.ic_play + } + ) + // loadBitmap is async, hence the need for onDone loadBitmap(context, song) { bitmap -> if (bitmap != null) { views.setBitmap(R.id.widget_cover, "setImageBitmap", bitmap) + views.setCharSequence( + R.id.widget_cover, "setContentDescription", + context.getString(R.string.description_album_cover, song.album.name) + ) } else { views.setInt(R.id.widget_cover, "setImageResource", R.drawable.ic_song) + views.setCharSequence( + R.id.widget_cover, "setContentDescription", + context.getString(R.string.description_placeholder_cover) + ) } onDone(views) } } else { - onDone(getDefaultViews(context)) + onDone(defaultViews(context)) } } companion object { const val TYPE = 0xA0D0 + fun new(): MinimalWidgetProvider? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { MinimalWidgetProvider() diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt new file mode 100644 index 000000000..b25ce4751 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetController.kt @@ -0,0 +1,33 @@ +package org.oxycblt.auxio.widgets + +import android.content.Context +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.state.PlaybackStateManager + +class WidgetController(private val context: Context) : PlaybackStateManager.Callback { + private val manager = PlaybackStateManager.getInstance() + private val minimal = MinimalWidgetProvider() + + init { + manager.addCallback(this) + } + + fun initWidget(type: Int) { + when (type) { + MinimalWidgetProvider.TYPE -> minimal.update(context, manager) + } + } + + fun release() { + minimal.stop(context) + manager.removeCallback(this) + } + + override fun onSongUpdate(song: Song?) { + minimal.update(context, manager) + } + + override fun onPlayingUpdate(isPlaying: Boolean) { + minimal.update(context, manager) + } +} diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml index 8f01b7f95..3e9e47d43 100644 --- a/app/src/main/res/drawable/ic_pause.xml +++ b/app/src/main/res/drawable/ic_pause.xml @@ -2,7 +2,7 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_default.xml b/app/src/main/res/layout/widget_default.xml index 104816740..6bd504e65 100644 --- a/app/src/main/res/layout/widget_default.xml +++ b/app/src/main/res/layout/widget_default.xml @@ -1,6 +1,5 @@ @@ -49,6 +50,45 @@ android:textColor="?android:attr/textColorSecondary" tools:text="Artist Name" /> + + + + + + + + + + + + diff --git a/app/src/main/res/values-v29/styles_core.xml b/app/src/main/res/values-v29/styles_core.xml index a40fc64bf..a8f0a408b 100644 --- a/app/src/main/res/values-v29/styles_core.xml +++ b/app/src/main/res/values-v29/styles_core.xml @@ -6,6 +6,8 @@ useless. --> ?android:attr/colorAccent ?android:attr/colorAccent + @color/control_color + ?android:attr/colorControlHighlight @color/surface_color ?attr/colorSurface diff --git a/app/src/main/res/values-v31/styles_core.xml b/app/src/main/res/values-v31/styles_core.xml new file mode 100644 index 000000000..f21751114 --- /dev/null +++ b/app/src/main/res/values-v31/styles_core.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e7087e0c3..5377107c4 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -12,6 +12,7 @@ 2dp + 28dp 32dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c571b781..8be41782b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -126,6 +126,7 @@ Error Auxio icon + Album cover Album Cover for %s Artist Image for %s Genre Image for %s