diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d283aa656..13dc201eb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,8 +7,10 @@
-
-
+
+
@@ -21,12 +23,13 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Base">
+
@@ -46,10 +49,25 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
index 673b0db11..abefa566b 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
@@ -19,6 +19,9 @@ import org.oxycblt.auxio.ui.isNight
/**
* The single [AppCompatActivity] for Auxio.
+ * TODO: Port widgets to non-12 android
+ * TODO: Change how I handle lifecycle owners
+ * TODO: Fix intent issues
*/
class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by viewModels()
diff --git a/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt
index 4dafdc54b..493c4464d 100644
--- a/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/library/LibraryAdapter.kt
@@ -1,5 +1,6 @@
package org.oxycblt.auxio.library
+import android.annotation.SuppressLint
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
@@ -61,6 +62,7 @@ class LibraryAdapter(
/**
* Update the data with [newData]. [notifyDataSetChanged] will be called.
*/
+ @SuppressLint("NotifyDataSetChanged")
fun updateData(newData: List) {
data = newData
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 7bf692edb..280aa00c2 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
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
+import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.Parent
import org.oxycblt.auxio.music.Song
@@ -41,6 +42,8 @@ import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.getSystemServiceSafe
+import org.oxycblt.auxio.widget.BaseWidget
+import org.oxycblt.auxio.widget.MinimalWidgetProvider
/**
* A service that manages the system-side aspects of playback, such as:
@@ -48,26 +51,36 @@ import org.oxycblt.auxio.ui.getSystemServiceSafe
* - The [MediaSessionCompat]
* - The Media Notification
* - Headset management
+ * - Widgets
*
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback],
* so therefore there's no need to bind to it to deliver commands.
* @author OxygenCobalt
*/
class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
+
+ // Player components
private lateinit var player: SimpleExoPlayer
private lateinit var mediaSession: MediaSessionCompat
private lateinit var connector: PlaybackSessionConnector
+ // Notification components
private lateinit var notification: PlaybackNotification
private lateinit var notificationManager: NotificationManager
+ // System backend components
private lateinit var audioReactor: AudioReactor
private lateinit var wakeLock: PowerManager.WakeLock
private val systemReceiver = SystemEventReceiver()
+ // Managers
private val playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance()
+ // Widgets
+ private val minimalWidget = MinimalWidgetProvider.new()
+
+ // State
private var isForeground = false
private val serviceJob = Job()
@@ -81,8 +94,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
return START_NOT_STICKY
}
- // No binding, service is headless.
- // Deliver updates through PlaybackStateManager/SettingsManager instead.
+ // No binding, service is headless
+ // Communicate using PlaybackStateManager, SettingsManager, or Broadcasts instead.
override fun onBind(intent: Intent): IBinder? = null
override fun onCreate() {
@@ -128,6 +141,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
addAction(Intent.ACTION_HEADSET_PLUG)
+ addAction(BaseWidget.ACTION_WIDGET_UPDATE)
+
registerReceiver(systemReceiver, this)
}
@@ -231,11 +246,14 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
this, song, settingsManager.colorizeNotif, ::startForegroundOrNotify
)
+ minimalWidget?.update(this, playbackManager)
+
return
}
- // Stop playing/the notification if there's nothing to play.
+ // Clear if there's nothing to play.
player.stop()
+ minimalWidget?.stop(this)
stopForegroundAndNotification()
}
@@ -476,6 +494,15 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
DISCONNECTED -> pauseFromPlug()
}
}
+
+ // 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
+ )
+ }
+ }
}
}
@@ -507,5 +534,7 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
private const val CONNECTED = 1
private const val WAKELOCK_TIME = 25000L
private const val POS_POLL_INTERVAL = 500L
+
+ const val BROADCAST_WIDGET_START = BuildConfig.APPLICATION_ID + ".key.WIDGETS_START"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/SlideLinearLayout.kt b/app/src/main/java/org/oxycblt/auxio/ui/SlideLinearLayout.kt
index 81208bac8..e5f83dddc 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/SlideLinearLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/SlideLinearLayout.kt
@@ -1,5 +1,6 @@
package org.oxycblt.auxio.ui
+import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
@@ -24,7 +25,11 @@ class SlideLinearLayout @JvmOverloads constructor(
attrs: AttributeSet? = null,
defStyleAttr: Int = -1
) : LinearLayout(context, attrs, defStyleAttr) {
+ @SuppressLint("DiscouragedPrivateApi")
private val disappearingChildrenField: Field? = try {
+ // We need to read this field to correctly draw the disappearing children
+ // If google ever hides this API I am going to scream, because its their busted
+ // ViewGroup code that forces me to do this in the first place
ViewGroup::class.java.getDeclaredField("mDisappearingChildren").also {
it.isAccessible = true
}
@@ -90,13 +95,13 @@ class SlideLinearLayout @JvmOverloads constructor(
return true
}
- @Suppress("UNCHECKED_CAST")
private fun getDisappearingChildren(): List? {
if (disappearingChildrenField == null || disappearingChildren != null) {
return disappearingChildren
}
try {
+ @Suppress("UNCHECKED_CAST")
disappearingChildren = disappearingChildrenField.get(this) as List?
} catch (e: Exception) {
logD("Could not get list of disappearing children.")
diff --git a/app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt b/app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt
new file mode 100644
index 000000000..c9f935331
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt
@@ -0,0 +1,121 @@
+package org.oxycblt.auxio.widget
+
+import android.app.PendingIntent
+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
+
+/**
+ * The base widget class for all widget implementations in Auxio.
+ */
+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
+
+ /*
+ * Update views based off of the playback state. This job is asynchronous and should be
+ * notified as completed by calling [onDone]
+ */
+ protected abstract fun updateViews(
+ context: Context,
+ playbackManager: PlaybackStateManager,
+ onDone: (RemoteViews) -> Unit
+ )
+
+ /*
+ * Update the widget based on the playback state.
+ */
+ fun update(context: Context, playbackManager: PlaybackStateManager) {
+ logD("Dispatching playback state update")
+
+ val manager = AppWidgetManager.getInstance(context)
+
+ // View updates are often async due to image loading, so only push the views
+ // when the callback is called.
+ updateViews(context, playbackManager) { views ->
+ manager.applyViews(context, views)
+ }
+ }
+
+ /*
+ * Stop this widget, reverting it to its default state.
+ */
+ fun stop(context: Context) {
+ logD("Stopping widget")
+
+ val manager = AppWidgetManager.getInstance(context)
+ manager.applyViews(context, getDefaultViews(context))
+ }
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray
+ ) {
+ appWidgetManager.applyViews(context, getDefaultViews(context))
+
+ logD("Sending update intent to PlaybackService")
+
+ val intent = Intent(ACTION_WIDGET_UPDATE)
+ .putExtra(KEY_WIDGET_TYPE, type)
+ .addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
+
+ 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
+ )
+ )
+
+ return views
+ }
+
+ private fun AppWidgetManager.applyViews(context: Context, views: RemoteViews) {
+ val ids = getAppWidgetIds(ComponentName(context, this::class.java))
+
+ if (ids.isNotEmpty()) {
+ // Existing widgets found, update thoughs
+ ids.forEach { id ->
+ updateAppWidget(id, views)
+ }
+ } else {
+ // No existing widgets found. Fall back to the name of the widget class
+ updateAppWidget(ComponentName(context, this@BaseWidget::class.java), views)
+ }
+ }
+
+ companion object {
+ const val ACTION_WIDGET_UPDATE = BuildConfig.APPLICATION_ID + ".action.WIDGET_UPDATE"
+ const val KEY_WIDGET_TYPE = BuildConfig.APPLICATION_ID + ".key.WIDGET_TYPE"
+ }
+}
+
+// PLAN:
+// - Service calls methods on the widgets, think a subset of PlaybackStateManager.Callback
+// - Widgets send BACK broadcasts to control playback, as other parts of the code do
+// - Since widgets are broadcastrecievers, this is okay, shouldn't cause memory leaks
+// - Can't use playbackstatemanager here since that would make the app unkillable
+// - Callbacks also need to handle PlaybackService dying and being unable to send any
+// more updates, and PlaybackService starting up as well
diff --git a/app/src/main/java/org/oxycblt/auxio/widget/MinimalWidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widget/MinimalWidgetProvider.kt
new file mode 100644
index 000000000..967f1d36a
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/widget/MinimalWidgetProvider.kt
@@ -0,0 +1,80 @@
+package org.oxycblt.auxio.widget
+
+import android.content.Context
+import android.os.Build
+import android.view.View
+import android.widget.RemoteViews
+import org.oxycblt.auxio.R
+import org.oxycblt.auxio.coil.loadBitmap
+import org.oxycblt.auxio.logD
+import org.oxycblt.auxio.playback.state.PlaybackStateManager
+
+// Workaround to make studio shut up about perfectly valid layouts somehow
+// being invalid for remote views.
+
+/**
+ * The minimal widget. This widget only shows the album, song name, and artist without any
+ * controls. Because you know. Minimalism.
+ */
+class MinimalWidgetProvider : BaseWidget() {
+ override val type: Int get() = TYPE
+
+ override fun getDefaultViews(context: Context): RemoteViews {
+ val views = getRemoteViews(context, LAYOUT)
+
+ views.setInt(R.id.widget_cover, "setImageResource", R.drawable.ic_song_clear)
+ views.setInt(R.id.widget_cover, "setVisibility", View.VISIBLE)
+ views.setInt(R.id.widget_placeholder_msg, "setVisibility", View.VISIBLE)
+ views.setInt(R.id.widget_meta, "setVisibility", View.GONE)
+
+ return views
+ }
+
+ override fun updateViews(
+ context: Context,
+ playbackManager: PlaybackStateManager,
+ onDone: (RemoteViews) -> Unit
+ ) {
+ val views = getRemoteViews(context, LAYOUT)
+ val song = playbackManager.song
+
+ if (song != null) {
+ logD("updating view to ${song.name}")
+
+ // Show the proper widget views
+ views.setInt(R.id.widget_placeholder_msg, "setVisibility", View.GONE)
+ views.setInt(R.id.widget_meta, "setVisibility", View.VISIBLE)
+
+ // Update the metadata
+ views.setTextViewText(R.id.widget_song, song.name)
+ views.setTextViewText(R.id.widget_artist, song.album.artist.name)
+
+ // loadBitmap is async, hence the need for onDone
+ loadBitmap(context, song) { bitmap ->
+ if (bitmap != null) {
+ views.setBitmap(R.id.widget_cover, "setImageBitmap", bitmap)
+ } else {
+ views.setInt(R.id.widget_cover, "setImageResource", R.drawable.ic_song_clear)
+ }
+
+ onDone(views)
+ }
+ } else {
+ views.setInt(R.id.widget_cover, "setImageResource", R.drawable.ic_song_clear)
+ onDone(views)
+ }
+ }
+
+ companion object {
+ const val TYPE = 0xA0D0
+ const val LAYOUT = R.layout.widget_minimal
+
+ fun new(): MinimalWidgetProvider? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MinimalWidgetProvider()
+ } else {
+ null
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_song_clear.xml b/app/src/main/res/drawable/ic_song_clear.xml
new file mode 100644
index 000000000..4f9f68627
--- /dev/null
+++ b/app/src/main/res/drawable/ic_song_clear.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ui_widget_black.xml b/app/src/main/res/drawable/ui_widget_black.xml
new file mode 100644
index 000000000..fb887911b
--- /dev/null
+++ b/app/src/main/res/drawable/ui_widget_black.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ui_widget_day.xml b/app/src/main/res/drawable/ui_widget_day.xml
new file mode 100644
index 000000000..f1d95642f
--- /dev/null
+++ b/app/src/main/res/drawable/ui_widget_day.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ui_widget_night.xml b/app/src/main/res/drawable/ui_widget_night.xml
new file mode 100644
index 000000000..cea57ae3a
--- /dev/null
+++ b/app/src/main/res/drawable/ui_widget_night.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout-v31/widget_minimal.xml b/app/src/main/res/layout-v31/widget_minimal.xml
new file mode 100644
index 000000000..212318c4d
--- /dev/null
+++ b/app/src/main/res/layout-v31/widget_minimal.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index 484429c40..4b02d8367 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -1,6 +1,6 @@
- #151515
+ @color/surface_night
#323232
#484848
#404040
diff --git a/app/src/main/res/values-v31/bools.xml b/app/src/main/res/values-v31/bools.xml
new file mode 100644
index 000000000..1ea4bc90a
--- /dev/null
+++ b/app/src/main/res/values-v31/bools.xml
@@ -0,0 +1,7 @@
+
+
+
+ true
+
\ No newline at end of file
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..5be1df180
--- /dev/null
+++ b/app/src/main/res/values-v31/styles_core.xml
@@ -0,0 +1,7 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/bools.xml b/app/src/main/res/values/bools.xml
new file mode 100644
index 000000000..9ec485ae0
--- /dev/null
+++ b/app/src/main/res/values/bools.xml
@@ -0,0 +1,4 @@
+
+
+ false
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 7b3349e1c..d6d3f3258 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,13 +1,20 @@
- #fafafa
- #cbcbcb
- #cbcbcb
- #c4c4c4
- #202020
- #01fafafa
+ #fffafafa
+ #ff151515
+ #ff000000
- #000000
+ #ff000000
+ #ff323232
+ #ffffffff
+ #ffbebebe
+
+ @color/surface_day
+ #cbcbcb
+ #202020
+ #cbcbcb
+ #c4c4c4
+ #01fafafa
Red
diff --git a/app/src/main/res/values/styles_android.xml b/app/src/main/res/values/styles_android.xml
index ec70e1d96..ef98f1369 100644
--- a/app/src/main/res/values/styles_android.xml
+++ b/app/src/main/res/values/styles_android.xml
@@ -36,7 +36,7 @@
diff --git a/app/src/main/res/values/styles_component.xml b/app/src/main/res/values/styles_component.xml
index 2e840279a..68deedcda 100644
--- a/app/src/main/res/values/styles_component.xml
+++ b/app/src/main/res/values/styles_component.xml
@@ -157,8 +157,6 @@
- ?attr/colorPrimary
-
-
\ No newline at end of file
diff --git a/app/src/main/res/xml-v31/widget_minimal.xml b/app/src/main/res/xml-v31/widget_minimal.xml
new file mode 100644
index 000000000..a4255ba1a
--- /dev/null
+++ b/app/src/main/res/xml-v31/widget_minimal.xml
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file