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