widgets: add minimal widget [#8]
Add the first widget implementation, the minimal widget. This took a good 2 days of hacking and frustration to get the first prototype working. And even then, its currently scoped to android 12 until I'm able to port them. The implementation is still quite rough, but should become manageable over time. More widgets will come.
This commit is contained in:
parent
58bea1dda5
commit
66be3da7e3
22 changed files with 404 additions and 19 deletions
|
@ -7,8 +7,10 @@
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<!-- Workaround for ExoPlayer's online functionality requiring unnecessary permissions -->
|
<!-- Workaround for ExoPlayer requiring unnecessary permissions -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove"/>
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_NETWORK_STATE"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
<queries />
|
<queries />
|
||||||
|
|
||||||
|
@ -21,12 +23,13 @@
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Base">
|
android:theme="@style/Theme.Base">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:exported="true"
|
|
||||||
android:windowSoftInputMode="adjustPan">
|
android:windowSoftInputMode="adjustPan">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
@ -46,10 +49,25 @@
|
||||||
<data android:mimeType="audio/*" />
|
<data android:mimeType="audio/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".playback.system.PlaybackService"
|
android:name=".playback.system.PlaybackService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round" />
|
android:roundIcon="@mipmap/ic_launcher_round" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.MinimalWidgetProvider"
|
||||||
|
android:exported="false"
|
||||||
|
android:enabled="@bool/widgets_supported">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_minimal" />
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
|
@ -19,6 +19,9 @@ import org.oxycblt.auxio.ui.isNight
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single [AppCompatActivity] for Auxio.
|
* 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() {
|
class MainActivity : AppCompatActivity() {
|
||||||
private val playbackModel: PlaybackViewModel by viewModels()
|
private val playbackModel: PlaybackViewModel by viewModels()
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package org.oxycblt.auxio.library
|
package org.oxycblt.auxio.library
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
@ -61,6 +62,7 @@ class LibraryAdapter(
|
||||||
/**
|
/**
|
||||||
* Update the data with [newData]. [notifyDataSetChanged] will be called.
|
* Update the data with [newData]. [notifyDataSetChanged] will be called.
|
||||||
*/
|
*/
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
fun updateData(newData: List<Parent>) {
|
fun updateData(newData: List<Parent>) {
|
||||||
data = newData
|
data = newData
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.conflate
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.takeWhile
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.logD
|
import org.oxycblt.auxio.logD
|
||||||
import org.oxycblt.auxio.music.Parent
|
import org.oxycblt.auxio.music.Parent
|
||||||
import org.oxycblt.auxio.music.Song
|
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.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.settings.SettingsManager
|
import org.oxycblt.auxio.settings.SettingsManager
|
||||||
import org.oxycblt.auxio.ui.getSystemServiceSafe
|
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:
|
* 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 [MediaSessionCompat]
|
||||||
* - The Media Notification
|
* - The Media Notification
|
||||||
* - Headset management
|
* - Headset management
|
||||||
|
* - Widgets
|
||||||
*
|
*
|
||||||
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback],
|
* This service relies on [PlaybackStateManager.Callback] and [SettingsManager.Callback],
|
||||||
* so therefore there's no need to bind to it to deliver commands.
|
* so therefore there's no need to bind to it to deliver commands.
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*/
|
*/
|
||||||
class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
|
class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callback, SettingsManager.Callback {
|
||||||
|
|
||||||
|
// Player components
|
||||||
private lateinit var player: SimpleExoPlayer
|
private lateinit var player: SimpleExoPlayer
|
||||||
private lateinit var mediaSession: MediaSessionCompat
|
private lateinit var mediaSession: MediaSessionCompat
|
||||||
private lateinit var connector: PlaybackSessionConnector
|
private lateinit var connector: PlaybackSessionConnector
|
||||||
|
|
||||||
|
// Notification components
|
||||||
private lateinit var notification: PlaybackNotification
|
private lateinit var notification: PlaybackNotification
|
||||||
private lateinit var notificationManager: NotificationManager
|
private lateinit var notificationManager: NotificationManager
|
||||||
|
|
||||||
|
// System backend components
|
||||||
private lateinit var audioReactor: AudioReactor
|
private lateinit var audioReactor: AudioReactor
|
||||||
private lateinit var wakeLock: PowerManager.WakeLock
|
private lateinit var wakeLock: PowerManager.WakeLock
|
||||||
private val systemReceiver = SystemEventReceiver()
|
private val systemReceiver = SystemEventReceiver()
|
||||||
|
|
||||||
|
// Managers
|
||||||
private val playbackManager = PlaybackStateManager.getInstance()
|
private val playbackManager = PlaybackStateManager.getInstance()
|
||||||
private val settingsManager = SettingsManager.getInstance()
|
private val settingsManager = SettingsManager.getInstance()
|
||||||
|
|
||||||
|
// Widgets
|
||||||
|
private val minimalWidget = MinimalWidgetProvider.new()
|
||||||
|
|
||||||
|
// State
|
||||||
private var isForeground = false
|
private var isForeground = false
|
||||||
|
|
||||||
private val serviceJob = Job()
|
private val serviceJob = Job()
|
||||||
|
@ -81,8 +94,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
// No binding, service is headless.
|
// No binding, service is headless
|
||||||
// Deliver updates through PlaybackStateManager/SettingsManager instead.
|
// Communicate using PlaybackStateManager, SettingsManager, or Broadcasts instead.
|
||||||
override fun onBind(intent: Intent): IBinder? = null
|
override fun onBind(intent: Intent): IBinder? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
@ -128,6 +141,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
||||||
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||||
addAction(Intent.ACTION_HEADSET_PLUG)
|
addAction(Intent.ACTION_HEADSET_PLUG)
|
||||||
|
|
||||||
|
addAction(BaseWidget.ACTION_WIDGET_UPDATE)
|
||||||
|
|
||||||
registerReceiver(systemReceiver, this)
|
registerReceiver(systemReceiver, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,11 +246,14 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
||||||
this, song, settingsManager.colorizeNotif, ::startForegroundOrNotify
|
this, song, settingsManager.colorizeNotif, ::startForegroundOrNotify
|
||||||
)
|
)
|
||||||
|
|
||||||
|
minimalWidget?.update(this, playbackManager)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop playing/the notification if there's nothing to play.
|
// Clear if there's nothing to play.
|
||||||
player.stop()
|
player.stop()
|
||||||
|
minimalWidget?.stop(this)
|
||||||
stopForegroundAndNotification()
|
stopForegroundAndNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -476,6 +494,15 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
||||||
DISCONNECTED -> pauseFromPlug()
|
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 CONNECTED = 1
|
||||||
private const val WAKELOCK_TIME = 25000L
|
private const val WAKELOCK_TIME = 25000L
|
||||||
private const val POS_POLL_INTERVAL = 500L
|
private const val POS_POLL_INTERVAL = 500L
|
||||||
|
|
||||||
|
const val BROADCAST_WIDGET_START = BuildConfig.APPLICATION_ID + ".key.WIDGETS_START"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package org.oxycblt.auxio.ui
|
package org.oxycblt.auxio.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
@ -24,7 +25,11 @@ class SlideLinearLayout @JvmOverloads constructor(
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
defStyleAttr: Int = -1
|
defStyleAttr: Int = -1
|
||||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||||
|
@SuppressLint("DiscouragedPrivateApi")
|
||||||
private val disappearingChildrenField: Field? = try {
|
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 {
|
ViewGroup::class.java.getDeclaredField("mDisappearingChildren").also {
|
||||||
it.isAccessible = true
|
it.isAccessible = true
|
||||||
}
|
}
|
||||||
|
@ -90,13 +95,13 @@ class SlideLinearLayout @JvmOverloads constructor(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun getDisappearingChildren(): List<View>? {
|
private fun getDisappearingChildren(): List<View>? {
|
||||||
if (disappearingChildrenField == null || disappearingChildren != null) {
|
if (disappearingChildrenField == null || disappearingChildren != null) {
|
||||||
return disappearingChildren
|
return disappearingChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
disappearingChildren = disappearingChildrenField.get(this) as List<View>?
|
disappearingChildren = disappearingChildrenField.get(this) as List<View>?
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logD("Could not get list of disappearing children.")
|
logD("Could not get list of disappearing children.")
|
||||||
|
|
121
app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt
Normal file
121
app/src/main/java/org/oxycblt/auxio/widget/BaseWidget.kt
Normal file
|
@ -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
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
app/src/main/res/drawable/ic_song_clear.xml
Normal file
11
app/src/main/res/drawable/ic_song_clear.xml
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:alpha="0.3"
|
||||||
|
android:tint="?attr/colorPrimary"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12 3v10.55C11.41 13.21 10.73 13 10 13c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||||
|
</vector>
|
6
app/src/main/res/drawable/ui_widget_black.xml
Normal file
6
app/src/main/res/drawable/ui_widget_black.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners />
|
||||||
|
<solid android:color="@color/surface_black" />
|
||||||
|
</shape>
|
6
app/src/main/res/drawable/ui_widget_day.xml
Normal file
6
app/src/main/res/drawable/ui_widget_day.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners />
|
||||||
|
<solid android:color="@color/surface_day" />
|
||||||
|
</shape>
|
6
app/src/main/res/drawable/ui_widget_night.xml
Normal file
6
app/src/main/res/drawable/ui_widget_night.xml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners />
|
||||||
|
<solid android:color="@color/surface_night" />
|
||||||
|
</shape>
|
62
app/src/main/res/layout-v31/widget_minimal.xml
Normal file
62
app/src/main/res/layout-v31/widget_minimal.xml
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@android:id/background"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="?android:attr/colorBackground"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:theme="@style/Theme.Widget">
|
||||||
|
|
||||||
|
<android.widget.ImageView
|
||||||
|
android:id="@+id/widget_cover"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
tools:ignore="contentDescription"
|
||||||
|
tools:src="@drawable/ic_song_clear" />
|
||||||
|
|
||||||
|
<android.widget.LinearLayout
|
||||||
|
android:id="@+id/widget_meta"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:background="?android:attr/colorBackground"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="@dimen/spacing_medium">
|
||||||
|
|
||||||
|
<android.widget.TextView
|
||||||
|
android:id="@+id/widget_song"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
|
||||||
|
android:textColor="?android:attr/textColorPrimary"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="Song Name" />
|
||||||
|
|
||||||
|
<android.widget.TextView
|
||||||
|
android:id="@+id/widget_artist"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
|
||||||
|
android:textColor="?android:attr/textColorSecondary"
|
||||||
|
tools:text="Artist Name" />
|
||||||
|
|
||||||
|
</android.widget.LinearLayout>
|
||||||
|
|
||||||
|
<android.widget.TextView
|
||||||
|
android:id="@+id/widget_placeholder_msg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="@dimen/spacing_medium"
|
||||||
|
android:text="@string/placeholder_playback"
|
||||||
|
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
|
||||||
|
android:textColor="?android:attr/textColorPrimary"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="surface_color">#151515</color>
|
<color name="surface_color">@color/surface_night</color>
|
||||||
<color name="divider_color">#323232</color>
|
<color name="divider_color">#323232</color>
|
||||||
<color name="selection_color">#484848</color>
|
<color name="selection_color">#484848</color>
|
||||||
<color name="inactive_color">#404040</color>
|
<color name="inactive_color">#404040</color>
|
||||||
|
|
7
app/src/main/res/values-v31/bools.xml
Normal file
7
app/src/main/res/values-v31/bools.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!--
|
||||||
|
Widgets are limited to android 12 until I can find an okay-ish way to backport them.
|
||||||
|
-->
|
||||||
|
<bool name="widgets_supported">true</bool>
|
||||||
|
</resources>
|
7
app/src/main/res/values-v31/styles_core.xml
Normal file
7
app/src/main/res/values-v31/styles_core.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Widget" parent="@android:style/Theme.DeviceDefault.DayNight">
|
||||||
|
<item name="colorPrimary">?android:attr/colorAccent</item>
|
||||||
|
<item name="colorSecondary">?android:attr/colorAccent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
4
app/src/main/res/values/bools.xml
Normal file
4
app/src/main/res/values/bools.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<bool name="widgets_supported">false</bool>
|
||||||
|
</resources>
|
|
@ -1,13 +1,20 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="surface_color">#fafafa</color>
|
<color name="surface_day">#fffafafa</color>
|
||||||
<color name="divider_color">#cbcbcb</color>
|
<color name="surface_night">#ff151515</color>
|
||||||
<color name="selection_color">#cbcbcb</color>
|
<color name="surface_black">#ff000000</color>
|
||||||
<color name="inactive_color">#c4c4c4</color>
|
|
||||||
<color name="control_color">#202020</color>
|
|
||||||
<color name="nav_color">#01fafafa</color>
|
|
||||||
|
|
||||||
<color name="surface_color_black">#000000</color>
|
<color name="text_primary_day">#ff000000</color>
|
||||||
|
<color name="text_secondary_day">#ff323232</color>
|
||||||
|
<color name="text_primary_night">#ffffffff</color>
|
||||||
|
<color name="text_secondary_night">#ffbebebe</color>
|
||||||
|
|
||||||
|
<color name="surface_color">@color/surface_day</color>
|
||||||
|
<color name="selection_color">#cbcbcb</color>
|
||||||
|
<color name="control_color">#202020</color>
|
||||||
|
<color name="divider_color">#cbcbcb</color>
|
||||||
|
<color name="inactive_color">#c4c4c4</color>
|
||||||
|
<color name="nav_color">#01fafafa</color>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Base color set derived from Music Player GO.
|
Base color set derived from Music Player GO.
|
||||||
|
|
|
@ -134,6 +134,7 @@
|
||||||
<string name="placeholder_artist">Unknown Artist</string>
|
<string name="placeholder_artist">Unknown Artist</string>
|
||||||
<string name="placeholder_album">Unknown Album</string>
|
<string name="placeholder_album">Unknown Album</string>
|
||||||
<string name="placeholder_no_date">No Date</string>
|
<string name="placeholder_no_date">No Date</string>
|
||||||
|
<string name="placeholder_playback">No music playing</string>
|
||||||
|
|
||||||
<!-- Color Label namespace | Accent names -->
|
<!-- Color Label namespace | Accent names -->
|
||||||
<string name="color_label_red">Red</string>
|
<string name="color_label_red">Red</string>
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
|
|
||||||
<!-- Black theme dialog theme -->
|
<!-- Black theme dialog theme -->
|
||||||
<style name="Theme.CustomDialog.Black" parent="Theme.CustomDialog.Base">
|
<style name="Theme.CustomDialog.Black" parent="Theme.CustomDialog.Base">
|
||||||
<item name="colorSurface">@color/surface_color_black</item>
|
<item name="colorSurface">@color/surface_black</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Material-specific dialog style -->
|
<!-- Material-specific dialog style -->
|
||||||
|
|
|
@ -157,8 +157,6 @@
|
||||||
<item name="android:thumbTint">?attr/colorPrimary</item>
|
<item name="android:thumbTint">?attr/colorPrimary</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Base style for the material buttons -->
|
<!-- Base style for the material buttons -->
|
||||||
<style name="Widget.MaterialComponents.Button.Base" parent="@style/Widget.MaterialComponents.Button.TextButton">
|
<style name="Widget.MaterialComponents.Button.Base" parent="@style/Widget.MaterialComponents.Button.TextButton">
|
||||||
<item name="android:layout_width">0dp</item>
|
<item name="android:layout_width">0dp</item>
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
|
|
||||||
<!-- The basic black theme derived in all black accents. -->
|
<!-- The basic black theme derived in all black accents. -->
|
||||||
<style name="Theme.Base.Black" parent="Theme.Base">
|
<style name="Theme.Base.Black" parent="Theme.Base">
|
||||||
<item name="colorSurface">@color/surface_color_black</item>
|
<item name="colorSurface">@color/surface_black</item>
|
||||||
<item name="materialAlertDialogTheme">@style/Theme.CustomDialog.Black</item>
|
<item name="materialAlertDialogTheme">@style/Theme.CustomDialog.Black</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
12
app/src/main/res/xml-v31/widget_minimal.xml
Normal file
12
app/src/main/res/xml-v31/widget_minimal.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:initialLayout="@layout/widget_minimal"
|
||||||
|
android:targetCellWidth="2"
|
||||||
|
android:targetCellHeight="2"
|
||||||
|
android:minResizeWidth="110dp"
|
||||||
|
android:minResizeHeight="110dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:updatePeriodMillis="0"
|
||||||
|
android:widgetCategory="home_screen">
|
||||||
|
|
||||||
|
</appwidget-provider>
|
Loading…
Reference in a new issue