widgets: make widget responsive

The plans for widgets have changed somewhat. Instead of 4 or so
variants, there will instead be one unified widget that chooses
different layouts depending on its size. The first one added is the
full widget, which shows more controls as long as theres enough
space.
This commit is contained in:
OxygenCobalt 2021-08-05 10:17:33 -06:00
parent 5d7d86b17e
commit 6f8f333b72
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
24 changed files with 655 additions and 289 deletions

View file

@ -57,8 +57,8 @@
android:roundIcon="@mipmap/ic_launcher_round" /> android:roundIcon="@mipmap/ic_launcher_round" />
<receiver <receiver
android:label="@string/info_widget_minimal" android:label="@string/info_channel_name"
android:name=".widgets.MinimalWidgetProvider" android:name=".widgets.WidgetProvider"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>

View file

@ -19,7 +19,8 @@ import org.oxycblt.auxio.ui.isNight
/** /**
* The single [AppCompatActivity] for Auxio. * The single [AppCompatActivity] for Auxio.
* TODO: Port widgets to non-12 android * TODO: Migrate to colorAccent
*
*/ */
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by viewModels() private val playbackModel: PlaybackViewModel by viewModels()

View file

@ -42,8 +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.widgets.BaseWidget
import org.oxycblt.auxio.widgets.WidgetController import org.oxycblt.auxio.widgets.WidgetController
import org.oxycblt.auxio.widgets.WidgetProvider
/** /**
* A service that manages the system-side aspects of playback, such as: * A service that manages the system-side aspects of playback, such as:
@ -140,7 +140,7 @@ 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) addAction(WidgetProvider.ACTION_WIDGET_UPDATE)
registerReceiver(systemReceiver, this) registerReceiver(systemReceiver, this)
} }
@ -495,10 +495,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
} }
} }
BaseWidget.ACTION_WIDGET_UPDATE -> { WidgetProvider.ACTION_WIDGET_UPDATE -> {
widgets.initWidget( widgets.update()
intent.getIntExtra(BaseWidget.KEY_WIDGET_TYPE, -1)
)
} }
} }
} }

View file

@ -28,6 +28,7 @@ import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.MainActivity import org.oxycblt.auxio.MainActivity
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.logE import org.oxycblt.auxio.logE
import kotlin.reflect.KClass import kotlin.reflect.KClass
@ -101,7 +102,7 @@ fun @receiver:ColorRes Int.toColor(context: Context): Int {
return try { return try {
ContextCompat.getColor(context, this) ContextCompat.getColor(context, this)
} catch (e: Resources.NotFoundException) { } catch (e: Resources.NotFoundException) {
logE("Attempted color load failed.") logE("Attempted color load failed: ${e.stackTraceToString()}")
// Default to the emergency color [Black] if the loading fails. // Default to the emergency color [Black] if the loading fails.
ContextCompat.getColor(context, android.R.color.black) ContextCompat.getColor(context, android.R.color.black)
@ -146,6 +147,8 @@ fun @receiver:AttrRes Int.resolveAttr(context: Context): Int {
resolvedAttr.data resolvedAttr.data
} }
logD(context.theme)
return color.toColor(context) return color.toColor(context)
} }

View file

@ -1,109 +0,0 @@
package org.oxycblt.auxio.widgets
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.widget.RemoteViews
import androidx.annotation.LayoutRes
import org.oxycblt.auxio.BuildConfig
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.
*/
abstract class BaseWidget : AppWidgetProvider() {
abstract val type: Int
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
* 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) {
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)
}
}
/*
* Revert this widget to its default view
*/
fun reset(context: Context) {
logD("Resetting widget")
val manager = AppWidgetManager.getInstance(context)
manager.applyViews(context, defaultViews(context))
}
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
logD("Sending update intent to PlaybackService")
appWidgetManager.applyViews(context, defaultViews(context))
val intent = Intent(ACTION_WIDGET_UPDATE)
.putExtra(KEY_WIDGET_TYPE, type)
.addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
context.sendBroadcast(intent)
}
@SuppressLint("RemoteViewLayout")
protected fun defaultViews(context: Context): RemoteViews {
return RemoteViews(
context.packageName,
R.layout.widget_default
)
}
private fun AppWidgetManager.applyViews(context: Context, views: RemoteViews) {
val ids = getAppWidgetIds(ComponentName(context, this::class.java))
if (ids.isNotEmpty()) {
// Existing widgets found, update those
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"
}
}

View file

@ -1,108 +0,0 @@
package org.oxycblt.auxio.widgets
import android.content.Context
import android.os.Build
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
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.newBroadcastIntent
/**
* The minimal widget, which shows the primary song controls and basic playback information.
*/
class MinimalWidgetProvider : BaseWidget() {
override val type: Int get() = TYPE
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(
context: Context,
playbackManager: PlaybackStateManager,
onDone: (RemoteViews) -> Unit
) {
val song = playbackManager.song
if (song != null) {
logD("Updating view to ${song.name}")
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 {
logD("No song playing, reverting to default view")
onDone(defaultViews(context))
}
}
companion object {
const val TYPE = 0xA0D0
fun new(): MinimalWidgetProvider? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MinimalWidgetProvider()
} else {
null
}
}
}
}

View file

@ -1,54 +1,36 @@
package org.oxycblt.auxio.widgets package org.oxycblt.auxio.widgets
import android.content.Context import android.content.Context
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
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
/** /**
* A wrapper around each widget subclass that manages which updates to deliver from the * A wrapper around each [WidgetProvider] that plugs into the main Auxio process and updates the
* main process's [PlaybackStateManager] and [SettingsManager] instances to the widgets themselves. * widget state based off of that. This cannot be rolled into [WidgetProvider] directly.
*/ */
class WidgetController(private val context: Context) : class WidgetController(private val context: Context) :
PlaybackStateManager.Callback, PlaybackStateManager.Callback,
SettingsManager.Callback { SettingsManager.Callback {
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager = PlaybackStateManager.getInstance()
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
private val minimal = MinimalWidgetProvider() private val widget = WidgetProvider()
init { init {
playbackManager.addCallback(this) playbackManager.addCallback(this)
settingsManager.addCallback(this) settingsManager.addCallback(this)
} }
/*
* Initialize a newly added widget. This usually comes from the WIDGET_UPDATE intent.
*/
fun initWidget(type: Int) {
logD("Updating new widget $type")
when (type) {
MinimalWidgetProvider.TYPE -> minimal.update(context, playbackManager)
}
}
/*
* Update every widget, regardless of whether it needs to or not.
*/
fun update() { fun update() {
logD("Updating all widgets") widget.update(context, playbackManager)
minimal.update(context, playbackManager)
} }
/* /*
* Release this instance, removing the callbacks and resetting all widgets * Release this instance, removing the callbacks and resetting all widgets
*/ */
fun release() { fun release() {
logD("Resetting widgets") widget.reset(context)
minimal.reset(context)
playbackManager.removeCallback(this) playbackManager.removeCallback(this)
settingsManager.removeCallback(this) settingsManager.removeCallback(this)
} }
@ -56,20 +38,28 @@ class WidgetController(private val context: Context) :
// --- PLAYBACKSTATEMANAGER CALLBACKS --- // --- PLAYBACKSTATEMANAGER CALLBACKS ---
override fun onSongUpdate(song: Song?) { override fun onSongUpdate(song: Song?) {
minimal.update(context, playbackManager) widget.update(context, playbackManager)
} }
override fun onPlayingUpdate(isPlaying: Boolean) { override fun onPlayingUpdate(isPlaying: Boolean) {
minimal.update(context, playbackManager) widget.update(context, playbackManager)
}
override fun onShuffleUpdate(isShuffling: Boolean) {
widget.update(context, playbackManager)
}
override fun onLoopUpdate(loopMode: LoopMode) {
widget.update(context, playbackManager)
} }
// --- SETTINGSMANAGER CALLBACKS --- // --- SETTINGSMANAGER CALLBACKS ---
override fun onShowCoverUpdate(showCovers: Boolean) { override fun onShowCoverUpdate(showCovers: Boolean) {
minimal.update(context, playbackManager) widget.update(context, playbackManager)
} }
override fun onQualityCoverUpdate(doQualityCovers: Boolean) { override fun onQualityCoverUpdate(doQualityCovers: Boolean) {
minimal.update(context, playbackManager) widget.update(context, playbackManager)
} }
} }

View file

@ -0,0 +1,146 @@
package org.oxycblt.auxio.widgets
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.util.SizeF
import android.widget.RemoteViews
import org.oxycblt.auxio.BuildConfig
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.ui.newMainIntent
import org.oxycblt.auxio.widgets.forms.FullWidgetForm
import org.oxycblt.auxio.widgets.forms.SmallWidgetForm
import org.oxycblt.auxio.widgets.forms.WidgetForm
/**
* Auxio's one and only appwidget. This widget follows a more unorthodox approach, effectively
* packing what could be considered 3 or 4 widgets into a single responsive widget. More specifically:
*
* - TODO?: For widgets 3x1 or lower, show a text-only view with minimal controls
* - TODO?: For widgets 4x1, show a minimized view with album art
* - For widgets Wx2 or higher, show an expanded view with album art and basic controls
* - For widgets 4x2 or higher, show a complete view with all playback controls
*
* For more specific details about these sub-widgets, see [WidgetForm].
*/
class WidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
logD("Sending update intent to PlaybackService")
appWidgetManager.applyViews(context, defaultViews(context))
val intent = Intent(ACTION_WIDGET_UPDATE)
.addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY)
context.sendBroadcast(intent)
}
/*
* Update the widget based on the playback state.
*/
fun update(context: Context, playbackManager: PlaybackStateManager) {
val manager = AppWidgetManager.getInstance(context)
val song = playbackManager.song
if (song == null) {
manager.applyViews(context, defaultViews(context))
return
}
// What we do here depends on how we're responding to layout changes.
// If we are on Android 11 or below, then we use the current widget form and default
// to SmallWidgetForm if one couldn't be figured out.
// If we are using Android S, we use the standard method of creating each RemoteView
// instance and putting them into a Map with their size ranges. This isn't as nice
// as it means we have to always load album art, even with the text only widget forms.
// But it's still preferable than to the Pre-12 method.
// FIXME: Fix the race conditions with the bitmap loading.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
loadBitmap(context, song) { bitmap ->
val state = WidgetState(
song,
bitmap,
playbackManager.isPlaying,
playbackManager.isShuffling,
playbackManager.loopMode
)
// Map each widget form to the rough dimensions where it would look nice.
// This might need to be adjusted.
val views = mapOf(
SizeF(110f, 110f) to SmallWidgetForm().createViews(context, state),
SizeF(272f, 110f) to FullWidgetForm().createViews(context, state)
)
manager.applyViews(context, RemoteViews(views))
}
} else {
loadBitmap(context, song) { bitmap ->
val state = WidgetState(
song,
bitmap,
playbackManager.isPlaying,
playbackManager.isShuffling,
playbackManager.loopMode
)
// TODO: Make sub-12 widgets responsive.
manager.applyViews(context, FullWidgetForm().createViews(context, state))
}
}
}
/*
* Revert this widget to its default view
*/
fun reset(context: Context) {
logD("Resetting widget")
val manager = AppWidgetManager.getInstance(context)
manager.applyViews(context, defaultViews(context))
}
@SuppressLint("RemoteViewLayout")
private fun defaultViews(context: Context): RemoteViews {
val views = RemoteViews(context.packageName, R.layout.widget_default)
views.setOnClickPendingIntent(
android.R.id.background,
context.newMainIntent()
)
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 those
ids.forEach { id ->
updateAppWidget(id, views)
}
} else {
// No existing widgets found. Fall back to the name of the widget class
updateAppWidget(ComponentName(context, this@WidgetProvider::class.java), views)
}
}
companion object {
const val ACTION_WIDGET_UPDATE = BuildConfig.APPLICATION_ID + ".action.WIDGET_UPDATE"
}
}

View file

@ -0,0 +1,17 @@
package org.oxycblt.auxio.widgets
import android.graphics.Bitmap
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.LoopMode
/*
* A condensed variant of the current playback state, used so that PlaybackStateManager does not
* need to be queried directly.
*/
data class WidgetState(
val song: Song,
val albumArt: Bitmap?,
val isPlaying: Boolean,
val isShuffled: Boolean,
val loopMode: LoopMode,
)

View file

@ -0,0 +1,98 @@
package org.oxycblt.auxio.widgets.forms
import android.content.Context
import android.widget.RemoteViews
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.state.LoopMode
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.newBroadcastIntent
import org.oxycblt.auxio.widgets.WidgetState
class FullWidgetForm : WidgetForm(R.layout.widget_full) {
override fun createViews(context: Context, state: WidgetState): RemoteViews {
val views = super.createViews(context, state)
views.setOnClickPendingIntent(
R.id.widget_loop,
context.newBroadcastIntent(
PlaybackService.ACTION_LOOP
)
)
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
)
)
views.setOnClickPendingIntent(
R.id.widget_shuffle,
context.newBroadcastIntent(
PlaybackService.ACTION_SHUFFLE
)
)
views.setTextViewText(R.id.widget_song, state.song.name)
views.setTextViewText(R.id.widget_artist, state.song.album.artist.name)
views.setImageViewResource(
R.id.widget_play_pause,
if (state.isPlaying) {
R.drawable.ic_pause
} else {
R.drawable.ic_play
}
)
if (state.albumArt != null) {
views.setImageViewBitmap(R.id.widget_cover, state.albumArt)
views.setCharSequence(
R.id.widget_cover, "setContentDescription",
context.getString(R.string.description_album_cover, state.song.album.name)
)
} else {
views.setImageViewResource(R.id.widget_cover, R.drawable.ic_song)
views.setCharSequence(
R.id.widget_cover,
"setContentDescription",
context.getString(R.string.description_placeholder_cover)
)
}
// The main way the large widget differs from the other widgets is the addition of extra
// controls. However, since the context we use to load attributes is from the main process,
// attempting to dynamically color anything will result in an error. More duplicate
// resources it is, then. This is getting really tiring.
val shuffleRes = if (state.isShuffled)
R.drawable.ic_shuffle_tinted
else
R.drawable.ic_shuffle
val loopRes = when (state.loopMode) {
LoopMode.NONE -> R.drawable.ic_loop
LoopMode.ALL -> R.drawable.ic_loop_all_tinted
LoopMode.TRACK -> R.drawable.ic_loop_one_tinted
}
views.setImageViewResource(R.id.widget_shuffle, shuffleRes)
views.setImageViewResource(R.id.widget_loop, loopRes)
return views
}
}

View file

@ -0,0 +1,64 @@
package org.oxycblt.auxio.widgets.forms
import android.content.Context
import android.widget.RemoteViews
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.newBroadcastIntent
import org.oxycblt.auxio.widgets.WidgetState
class SmallWidgetForm : WidgetForm(R.layout.widget_small) {
override fun createViews(context: Context, state: WidgetState): RemoteViews {
val views = super.createViews(context, state)
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
)
)
views.setTextViewText(R.id.widget_song, state.song.name)
views.setTextViewText(R.id.widget_artist, state.song.album.artist.name)
views.setImageViewResource(
R.id.widget_play_pause,
if (state.isPlaying) {
R.drawable.ic_pause
} else {
R.drawable.ic_play
}
)
if (state.albumArt != null) {
views.setImageViewBitmap(R.id.widget_cover, state.albumArt)
views.setCharSequence(
R.id.widget_cover, "setContentDescription",
context.getString(R.string.description_album_cover, state.song.album.name)
)
} else {
views.setImageViewResource(R.id.widget_cover, R.drawable.ic_song)
views.setCharSequence(
R.id.widget_cover,
"setContentDescription",
context.getString(R.string.description_placeholder_cover)
)
}
return views
}
}

View file

@ -0,0 +1,20 @@
package org.oxycblt.auxio.widgets.forms
import android.content.Context
import android.widget.RemoteViews
import androidx.annotation.LayoutRes
import org.oxycblt.auxio.ui.newMainIntent
import org.oxycblt.auxio.widgets.WidgetState
abstract class WidgetForm(@LayoutRes private val layout: Int) {
open fun createViews(context: Context, state: WidgetState): RemoteViews {
val views = RemoteViews(context.packageName, layout)
views.setOnClickPendingIntent(
android.R.id.background,
context.newMainIntent()
)
return views
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z" />
</vector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorPrimary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm0.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
</vector>

View file

@ -0,0 +1,101 @@
<?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:orientation="vertical"
android:theme="@style/Theme.Widget">
<ImageView
android:id="@+id/widget_cover"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="?attr/colorSurface"
android:scaleType="centerCrop"
tools:ignore="contentDescription"
android:src="@drawable/ic_song" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="?attr/colorSurface"
android:orientation="vertical"
android:padding="@dimen/spacing_medium">
<TextView
android:id="@+id/widget_song"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/inter"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
android:text="@string/placeholder_widget_song" />
<TextView
android:id="@+id/widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/inter"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:attr/textColorSecondary"
android:text="@string/placeholder_widget_artist" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:orientation="horizontal">
<ImageButton
android:id="@+id/widget_loop"
style="@style/Widget.Button.Unbounded.Widget.V31"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_change_loop"
android:src="@drawable/ic_loop" />
<ImageButton
android:id="@+id/widget_skip_prev"
style="@style/Widget.Button.Unbounded.Widget.V31"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_skip_prev"
android:src="@drawable/ic_skip_prev" />
<ImageButton
android:id="@+id/widget_play_pause"
style="@style/Widget.Button.Unbounded.Widget.V31"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_play_pause"
android:src="@drawable/ic_play" />
<ImageButton
android:id="@+id/widget_skip_next"
style="@style/Widget.Button.Unbounded.Widget.V31"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_skip_next"
android:src="@drawable/ic_skip_next" />
<ImageButton
android:id="@+id/widget_shuffle"
style="@style/Widget.Button.Unbounded.Widget.V31"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_shuffle_off"
android:src="@drawable/ic_shuffle" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View file

@ -7,7 +7,7 @@
android:orientation="vertical" android:orientation="vertical"
android:theme="@style/Theme.Widget"> android:theme="@style/Theme.Widget">
<android.widget.ImageView <ImageView
android:id="@+id/widget_cover" android:id="@+id/widget_cover"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
@ -17,7 +17,7 @@
tools:ignore="contentDescription" tools:ignore="contentDescription"
android:src="@drawable/ic_song" /> android:src="@drawable/ic_song" />
<android.widget.LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom" android:layout_gravity="bottom"
@ -25,7 +25,7 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="@dimen/spacing_medium"> android:padding="@dimen/spacing_medium">
<android.widget.TextView <TextView
android:id="@+id/widget_song" android:id="@+id/widget_song"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -37,7 +37,7 @@
android:textStyle="bold" android:textStyle="bold"
android:text="@string/placeholder_widget_song" /> android:text="@string/placeholder_widget_song" />
<android.widget.TextView <TextView
android:id="@+id/widget_artist" android:id="@+id/widget_artist"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -51,32 +51,35 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_small" android:layout_marginTop="@dimen/spacing_medium"
android:orientation="horizontal"> android:orientation="horizontal">
<ImageButton <ImageButton
android:id="@+id/widget_skip_prev" android:id="@+id/widget_skip_prev"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded.Widget.V31"
android:layout_width="0dp"
android:layout_weight="1" android:layout_weight="1"
android:contentDescription="@string/description_skip_prev" android:contentDescription="@string/description_skip_prev"
android:src="@drawable/ic_skip_prev" /> android:src="@drawable/ic_skip_prev" />
<ImageButton <ImageButton
android:id="@+id/widget_play_pause" android:id="@+id/widget_play_pause"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded.Widget.V31"
android:layout_width="0dp"
android:layout_weight="1" android:layout_weight="1"
android:contentDescription="@string/description_play_pause" android:contentDescription="@string/description_play_pause"
android:src="@drawable/ic_play" /> android:src="@drawable/ic_play" />
<ImageButton <ImageButton
android:id="@+id/widget_skip_next" android:id="@+id/widget_skip_next"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded.Widget.V31"
android:layout_width="0dp"
android:layout_weight="1" android:layout_weight="1"
android:contentDescription="@string/description_skip_next" android:contentDescription="@string/description_skip_next"
android:src="@drawable/ic_skip_next" /> android:src="@drawable/ic_skip_next" />
</LinearLayout> </LinearLayout>
</android.widget.LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View file

@ -6,7 +6,7 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:theme="@style/Theme.Widget"> android:theme="@style/Theme.Widget">
<android.widget.ImageView <ImageView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:alpha="0.3" android:alpha="0.3"
@ -14,7 +14,7 @@
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:src="@drawable/ic_song" /> android:src="@drawable/ic_song" />
<android.widget.TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"

View file

@ -0,0 +1,101 @@
<?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:orientation="vertical"
android:theme="@style/Theme.Widget">
<ImageView
android:id="@+id/widget_cover"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="?attr/colorSurface"
android:scaleType="centerCrop"
tools:ignore="contentDescription"
android:src="@drawable/ic_song" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="?attr/colorSurface"
android:orientation="vertical"
android:padding="@dimen/spacing_medium">
<TextView
android:id="@+id/widget_song"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/inter"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
android:text="@string/placeholder_widget_song" />
<TextView
android:id="@+id/widget_artist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/inter"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:attr/textColorSecondary"
android:text="@string/placeholder_widget_artist" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_medium"
android:orientation="horizontal">
<ImageButton
android:id="@+id/widget_loop"
style="@style/Widget.Button.Unbounded.Widget"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_change_loop"
android:src="@drawable/ic_loop" />
<ImageButton
android:id="@+id/widget_skip_prev"
style="@style/Widget.Button.Unbounded.Widget"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_skip_prev"
android:src="@drawable/ic_skip_prev" />
<ImageButton
android:id="@+id/widget_play_pause"
style="@style/Widget.Button.Unbounded.Widget"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_play_pause"
android:src="@drawable/ic_play" />
<ImageButton
android:id="@+id/widget_skip_next"
style="@style/Widget.Button.Unbounded.Widget"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_skip_next"
android:src="@drawable/ic_skip_next" />
<ImageButton
android:id="@+id/widget_shuffle"
style="@style/Widget.Button.Unbounded.Widget"
android:layout_width="0dp"
android:layout_weight="1"
android:contentDescription="@string/description_shuffle_off"
android:src="@drawable/ic_shuffle" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View file

@ -7,7 +7,7 @@
android:orientation="vertical" android:orientation="vertical"
android:theme="@style/Theme.Widget"> android:theme="@style/Theme.Widget">
<android.widget.ImageView <ImageView
android:id="@+id/widget_cover" android:id="@+id/widget_cover"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
@ -17,16 +17,16 @@
tools:ignore="contentDescription" tools:ignore="contentDescription"
tools:src="@drawable/ic_song" /> tools:src="@drawable/ic_song" />
<android.widget.LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:background="?android:attr/colorBackground" android:background="?attr/colorSurface"
android:elevation="@dimen/elevation_normal" android:elevation="@dimen/elevation_normal"
android:orientation="vertical" android:orientation="vertical"
android:padding="@dimen/spacing_medium"> android:padding="@dimen/spacing_medium">
<android.widget.TextView <TextView
android:id="@+id/widget_song" android:id="@+id/widget_song"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -38,7 +38,7 @@
android:textStyle="bold" android:textStyle="bold"
tools:text="Song Name" /> tools:text="Song Name" />
<android.widget.TextView <TextView
android:id="@+id/widget_artist" android:id="@+id/widget_artist"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -52,7 +52,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_small"> android:layout_marginTop="@dimen/spacing_medium">
<!-- <!--
Can't use a normal unbounded ripple here since it causes a weird bug Can't use a normal unbounded ripple here since it causes a weird bug
@ -62,30 +62,30 @@
<ImageButton <ImageButton
android:id="@+id/widget_skip_prev" android:id="@+id/widget_skip_prev"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded.Widget"
android:layout_width="0dp"
android:layout_weight="1" android:layout_weight="1"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/description_skip_prev" android:contentDescription="@string/description_skip_prev"
android:src="@drawable/ic_skip_prev" /> android:src="@drawable/ic_skip_prev" />
<ImageButton <ImageButton
android:id="@+id/widget_play_pause" android:id="@+id/widget_play_pause"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded.Widget"
android:layout_width="0dp"
android:layout_weight="1" android:layout_weight="1"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/description_play_pause" android:contentDescription="@string/description_play_pause"
android:src="@drawable/ic_play" /> android:src="@drawable/ic_play" />
<ImageButton <ImageButton
android:id="@+id/widget_skip_next" android:id="@+id/widget_skip_next"
style="@style/Widget.Button.Unbounded" style="@style/Widget.Button.Unbounded.Widget"
android:layout_width="0dp"
android:layout_weight="1" android:layout_weight="1"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/description_skip_next" android:contentDescription="@string/description_skip_next"
android:src="@drawable/ic_skip_next" /> android:src="@drawable/ic_skip_next" />
</LinearLayout> </LinearLayout>
</android.widget.LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View file

@ -5,7 +5,7 @@
<!-- Info namespace | App labels --> <!-- Info namespace | App labels -->
<string name="info_app_desc">A simple, rational music player for android.</string> <string name="info_app_desc">A simple, rational music player for android.</string>
<string name="info_channel_name">Music Playback</string> <string name="info_channel_name">Music Playback</string>
<string name="info_widget_minimal">Minimal</string> <string name="info_widget_desc">See and control playing music</string>
<!-- Label Namespace | Static Labels --> <!-- Label Namespace | Static Labels -->
<string name="label_retry">Retry</string> <string name="label_retry">Retry</string>

View file

@ -87,4 +87,15 @@
<item name="android:fontFamily">@font/inter_semibold</item> <item name="android:fontFamily">@font/inter_semibold</item>
<item name="android:textColor">?attr/colorPrimary</item> <item name="android:textColor">?attr/colorPrimary</item>
</style> </style>
<style name="Widget.Button.Unbounded.Widget" parent="Widget.AppCompat.Button.Borderless">
<item name="android:layout_height">32dp</item>
<item name="android:background">?android:attr/selectableItemBackgroundBorderless</item>
<item name="android:scaleType">fitCenter</item>
<item name="android:padding">@dimen/spacing_micro</item>
</style>
<style name="Widget.Button.Unbounded.Widget.V31" parent="Widget.Button.Unbounded.Widget">
<item name="android:background">@drawable/ui_unbounded_ripple</item>
</style>
</resources> </resources>

View file

@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_minimal" android:initialLayout="@layout/widget_small"
android:targetCellWidth="2" android:targetCellWidth="2"
android:targetCellHeight="2" android:targetCellHeight="2"
android:minResizeWidth="110dp" android:minResizeWidth="110dp"
android:minResizeHeight="110dp" android:minResizeHeight="110dp"
android:previewLayout="@layout/widget_minimal" android:previewLayout="@layout/widget_small"
android:description="@string/info_widget_desc"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0" android:updatePeriodMillis="0"
android:widgetCategory="home_screen"> android:widgetCategory="home_screen" />
</appwidget-provider>

View file

@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_minimal" android:initialLayout="@layout/widget_small"
android:minWidth="110dp" android:minWidth="110dp"
android:minHeight="110dp" android:minHeight="110dp"
android:minResizeWidth="110dp" android:minResizeWidth="110dp"
android:minResizeHeight="110dp" android:minResizeHeight="110dp"
android:resizeMode="horizontal|vertical" android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0" android:updatePeriodMillis="0"
android:widgetCategory="home_screen"> android:widgetCategory="home_screen" />
</appwidget-provider>