feat(android): refresh launcher widgets with adaptive expressive layouts

This commit is contained in:
zarzet committed 2026-10-18 03:10:40 +07:00
1 parent 6b6db4ca2e
commit 75cd4c39b2
39 files changed
+878 -250

No files matched your search

+2 -1
View File
@@ -146,7 +146,7 @@
<activity
android:name=".PlayerWidgetConfigurationActivity"
android:exported="true"
android:theme="@android:style/Theme.DeviceDefault.Dialog">
android:theme="@style/WidgetConfigTheme">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
@@ -166,6 +166,7 @@
<receiver
android:name=".DownloadQueueWidgetProvider"
android:label="@string/download_widget_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -6,13 +6,22 @@ import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.graphics.RectF
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* Home-screen widget showing the active download. Updated only on discrete
* events (track transition, status change, coarse progress steps) from
* DownloadService — never per progress byte — to respect battery discipline.
* events (track transition, status change, throttled progress ticks) from
* DownloadService, never per progress byte, to respect battery discipline.
* The last pushed state is persisted so launcher-driven refreshes (reboot,
* resize) render without the service running.
*/
@@ -37,6 +46,15 @@ class DownloadQueueWidgetProvider : AppWidgetProvider() {
render(context, appWidgetManager, appWidgetIds)
}
override fun onAppWidgetOptionsChanged(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
newOptions: Bundle,
) {
render(context, appWidgetManager, intArrayOf(appWidgetId))
}
companion object {
private const val PREFS = "download_widget_state"
private const val KEY_ENABLED = "enabled"
@@ -44,6 +62,7 @@ class DownloadQueueWidgetProvider : AppWidgetProvider() {
private const val KEY_TITLE = "title"
private const val KEY_SUBTITLE = "subtitle"
private const val KEY_PERCENT = "percent" // -1 = indeterminate
private const val KEY_QUEUED = "queued"
/** Persists the state and re-renders all widget instances. */
fun push(
@@ -52,6 +71,7 @@ class DownloadQueueWidgetProvider : AppWidgetProvider() {
title: String = "",
subtitle: String = "",
percent: Int = -1,
queued: Int = 0,
) {
val prefs = widgetPreferences(context)
// Most users never add the optional launcher widget. Keep the
@@ -72,6 +92,7 @@ class DownloadQueueWidgetProvider : AppWidgetProvider() {
.putString(KEY_TITLE, title)
.putString(KEY_SUBTITLE, subtitle)
.putInt(KEY_PERCENT, percent)
.putInt(KEY_QUEUED, queued)
.apply()
render(context, manager, ids)
}
@@ -86,46 +107,179 @@ class DownloadQueueWidgetProvider : AppWidgetProvider() {
) {
val prefs = widgetPreferences(context)
val running = prefs.getBoolean(KEY_RUNNING, false)
val views = RemoteViews(context.packageName, R.layout.widget_download_queue)
if (running) {
views.setTextViewText(
R.id.widget_title,
prefs.getString(KEY_TITLE, "").orEmpty().ifEmpty { "Downloading..." }
)
views.setTextViewText(
R.id.widget_subtitle,
prefs.getString(KEY_SUBTITLE, "").orEmpty()
)
views.setViewVisibility(R.id.widget_progress, View.VISIBLE)
val percent = prefs.getInt(KEY_PERCENT, -1)
if (percent in 0..100) {
views.setProgressBar(R.id.widget_progress, 100, percent, false)
} else {
views.setProgressBar(R.id.widget_progress, 100, 0, true)
}
} else {
views.setTextViewText(R.id.widget_title, "SpotiFLAC")
views.setTextViewText(R.id.widget_subtitle, "No active downloads")
views.setViewVisibility(R.id.widget_progress, View.GONE)
}
val launchIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
views.setOnClickPendingIntent(
R.id.widget_root,
PendingIntent.getActivity(
context,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val percent = if (running) prefs.getInt(KEY_PERCENT, -1) else -1
val queued = if (running) prefs.getInt(KEY_QUEUED, 0) else 0
val launch = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
for (id in ids) {
val options = manager.getAppWidgetOptions(id)
val width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 280)
val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 72)
val large = height >= 180
val views = RemoteViews(
context.packageName,
if (large) R.layout.widget_download_queue_large else R.layout.widget_download_queue,
)
val determinate = percent in 0..100
if (running) {
val subtitle = prefs.getString(KEY_SUBTITLE, "").orEmpty()
views.setTextViewText(
R.id.widget_title,
prefs.getString(KEY_TITLE, "").orEmpty()
.ifEmpty { context.getString(R.string.download_widget_downloading) }
)
views.setTextViewText(R.id.widget_subtitle, subtitle)
views.setViewVisibility(
R.id.widget_subtitle,
if (subtitle.isEmpty()) View.GONE else View.VISIBLE,
)
} else {
views.setTextViewText(
R.id.widget_title,
context.getString(if (large) R.string.download_widget_idle_title else R.string.download_widget_name),
)
views.setTextViewText(R.id.widget_subtitle, context.getString(R.string.download_widget_idle))
views.setViewVisibility(R.id.widget_subtitle, View.VISIBLE)
}
views.setImageViewResource(
R.id.widget_icon,
if (running) R.drawable.ic_widget_download else R.drawable.ic_widget_done,
)
views.setTextViewText(
R.id.widget_percent,
context.getString(R.string.download_widget_percent, percent.coerceAtLeast(0)),
)
views.setViewVisibility(
R.id.widget_percent,
if (determinate && (!large || height >= 220)) View.VISIBLE else View.GONE,
)
views.setTextViewText(R.id.widget_queue, context.getString(R.string.download_widget_queue, queued))
views.setViewVisibility(
R.id.widget_queue,
if (queued > 1 && (large || width >= 260)) View.VISIBLE else View.GONE,
)
if (large) {
views.setTextViewText(
R.id.widget_status,
context.getString(
if (running) R.string.download_widget_status_active else R.string.download_widget_status_idle
),
)
views.setViewVisibility(R.id.widget_wave, if (running) View.VISIBLE else View.GONE)
if (running) {
views.setImageViewBitmap(R.id.widget_wave, wavyBar(context, width - 32, percent))
}
} else {
// The percent replaces the icon inside the ring once known.
views.setViewVisibility(R.id.widget_icon, if (determinate) View.GONE else View.VISIBLE)
if (running) {
views.setImageViewBitmap(R.id.widget_ring, wavyRing(context, percent))
} else {
views.setImageViewResource(R.id.widget_ring, R.drawable.widget_disc_tonal)
}
}
views.setOnClickPendingIntent(R.id.widget_root, launch)
manager.updateAppWidget(id, views)
}
}
private fun color(context: Context, id: Int) = context.getColor(id)
/**
* Material 3 Expressive linear indicator: a wavy active track, a gap,
* the flat remaining track and a stop dot. -1 draws an empty track.
*/
private fun wavyBar(context: Context, widthDp: Int, percent: Int): Bitmap {
val density = context.resources.displayMetrics.density
val w = (widthDp.coerceIn(120, 600) * density).toInt()
val h = (16 * density).toInt()
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val stroke = 4 * density
val mid = h / 2f
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = stroke
strokeCap = Paint.Cap.ROUND
}
val start = stroke / 2
val end = w - stroke / 2
val split = start + (end - start) * percent.coerceIn(0, 100) / 100f
val gap = 6 * density
if (percent > 0) {
val amplitude = 3 * density
val wavelength = 28 * density
val path = Path().apply {
moveTo(start, mid)
var x = start
while (x < split) {
x = minOf(x + density, split)
lineTo(x, mid + amplitude * sin(2 * PI * (x - start) / wavelength).toFloat())
}
}
paint.color = color(context, R.color.widgetAccent)
canvas.drawPath(path, paint)
}
val trackStart = if (percent > 0) split + gap else start
if (trackStart < end) {
paint.color = color(context, R.color.widgetTrack)
canvas.drawLine(trackStart, mid, end, mid, paint)
paint.style = Paint.Style.FILL
paint.color = color(context, R.color.widgetAccent)
canvas.drawCircle(end, mid, stroke / 2, paint)
}
return bitmap
}
/** Circular counterpart on a tonal disc; the active arc ripples around it. */
private fun wavyRing(context: Context, percent: Int): Bitmap {
val density = context.resources.displayMetrics.density
val size = (56 * density).toInt()
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val c = size / 2f
canvas.drawCircle(c, c, c, Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = color(context, R.color.widgetAccentContainer)
})
val stroke = 3.5f * density
val radius = c - 6 * density
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = stroke
strokeCap = Paint.Cap.ROUND
}
val sweep = 360f * percent.coerceIn(0, 100) / 100f
val gap = if (percent in 1..99) 14f else 0f
val oval = RectF(c - radius, c - radius, c + radius, c + radius)
if (sweep < 360f) {
paint.color = color(context, R.color.widgetOnAccentContainer) and 0x00ffffff or (0x33 shl 24)
canvas.drawArc(oval, -90f + sweep + gap / 2, (360f - sweep - gap).coerceAtLeast(0f), false, paint)
}
if (sweep > 0f) {
val amplitude = 1.2f * density
val path = Path()
var angle = 0f
while (angle <= sweep) {
val theta = Math.toRadians((angle - 90f).toDouble())
val r = radius + amplitude * sin(angle / 360f * 2 * PI * 10).toFloat()
val x = c + r * cos(theta).toFloat()
val y = c + r * sin(theta).toFloat()
if (angle == 0f) path.moveTo(x, y) else path.lineTo(x, y)
angle += 2f
}
paint.color = color(context, R.color.widgetAccent)
canvas.drawPath(path, paint)
}
return bitmap
}
}
}
@@ -52,6 +52,7 @@ class DownloadService : Service() {
private const val CHANNEL_ID = "download_channel"
private const val ALERT_CHANNEL_ID = "download_alerts_v1"
private const val NOTIFICATION_ID = 1001
private const val WIDGET_PROGRESS_INTERVAL_MS = 1000L
private const val DOWNLOAD_RESULT_NOTIFICATION_ID = 1
private const val VERIFICATION_REQUIRED_NOTIFICATION_ID = 4
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
@@ -345,8 +346,10 @@ class DownloadService : Service() {
internal var currentStatus = "preparing"
internal var queueCount = 0
// Signature of the last home-screen widget push; keeps widget updates
// event-driven (track/status/queue changes, 25% steps), never per byte.
// event-driven (track/status/queue changes, throttled percent ticks),
// never per byte.
private var widgetSignature = ""
private var lastWidgetPushAt = 0L
// NotificationManager updates are also deduplicated by the visible
// notification state. Progress writers can report many byte-level changes
// that render the same percentage/text.
@@ -1592,31 +1595,33 @@ class DownloadService : Service() {
} else {
-1
}
val bucket = if (percent < 0) -1 else percent / 25
val signature = "$currentTrackName|$currentStatus|$queueCount|$bucket"
val event = "$currentTrackName|$currentStatus|$queueCount"
val signature = "$event|$percent"
if (signature == widgetSignature) return
// Track, status and queue changes publish at once; percent ticks are
// spaced so the widget moves in small steps without per-byte IPC.
val now = SystemClock.elapsedRealtime()
if (widgetSignature.startsWith("$event|") && percent in 1..99 &&
now - lastWidgetPushAt < WIDGET_PROGRESS_INTERVAL_MS) return
widgetSignature = signature
lastWidgetPushAt = now
val subtitle = when (currentStatus) {
"verification_required" -> "Verification required"
"rate_limited" -> "Rate limited, retrying..."
"waiting_wifi" -> "Waiting for Wi-Fi..."
"finalizing" -> "Finalizing..."
else -> buildString {
append(currentArtistName)
if (queueCount > 1) {
if (isNotEmpty()) append(" • ")
append("$queueCount in queue")
}
}
// The widget shows the queue as its own chip.
else -> currentArtistName
}
try {
DownloadQueueWidgetProvider.push(
this,
running = true,
title = currentTrackName.ifEmpty { "Downloading..." },
title = currentTrackName,
subtitle = subtitle,
percent = percent,
queued = queueCount,
)
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Widget update failed: ${e.message}")
@@ -2,53 +2,174 @@ package com.zarz.spotiflac
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.widget.Button
import android.widget.CheckBox
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
import android.view.animation.PathInterpolator
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.Switch
import android.widget.TextView
import org.json.JSONObject
/** Bottom sheet that styles one player widget, previewing the real widget live. */
class PlayerWidgetConfigurationActivity : Activity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
private var widgetId = AppWidgetManager.INVALID_APPWIDGET_ID
private var theme = "artwork"
private var showArtist = true
private var cover: Bitmap? = null
private lateinit var state: JSONObject
private lateinit var sheet: View
private lateinit var preview: FrameLayout
private val tiles = mutableListOf<Pair<String, LinearLayout>>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setResult(RESULT_CANCELED)
val widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { finish(); return }
val prefs = PlayerWidgetProvider.preferences(this)
val layout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
val padding = (24 * resources.displayMetrics.density).toInt()
setPadding(padding, padding, padding, padding)
}
layout.addView(TextView(this).apply { setText(R.string.player_widget_name); textSize = 24f })
val group = RadioGroup(this)
val themes = listOf("artwork" to R.string.player_widget_artwork, "light" to R.string.player_widget_light, "dark" to R.string.player_widget_dark)
themes.forEachIndexed { index, (key, label) ->
group.addView(RadioButton(this).apply {
this.id = index + 1
setText(label)
isChecked = prefs.getString("theme_$widgetId", "artwork") == key
})
}
layout.addView(group)
val artist = CheckBox(this).apply {
setText(R.string.player_widget_show_artist)
isChecked = prefs.getBoolean("artist_$widgetId", true)
}
layout.addView(artist)
layout.addView(Button(this).apply {
setText(R.string.player_widget_save)
setOnClickListener {
prefs.edit().putString("theme_$widgetId", themes[(group.checkedRadioButtonId - 1).coerceIn(0, 2)].first)
.putBoolean("artist_$widgetId", artist.isChecked).apply()
PlayerWidgetProvider.update(this@PlayerWidgetConfigurationActivity, intArrayOf(widgetId))
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId))
finish()
theme = prefs.getString("theme_$widgetId", "artwork") ?: "artwork"
showArtist = prefs.getBoolean("artist_$widgetId", true)
state = PlayerWidgetProvider.state(this)
cover = PlayerWidgetProvider.cover(this, state)
setContentView(R.layout.widget_player_configure)
sheet = findViewById(R.id.config_sheet)
preview = findViewById(R.id.config_preview)
val bottomPadding = sheet.paddingBottom
sheet.setOnApplyWindowInsetsListener { view, insets ->
val bottom = if (android.os.Build.VERSION.SDK_INT >= 30) {
insets.getInsets(WindowInsets.Type.systemBars()).bottom
} else {
@Suppress("DEPRECATION") insets.systemWindowInsetBottom
}
})
setContentView(layout)
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding + bottom)
insets
}
findViewById<View>(R.id.config_scrim).setOnClickListener { finish() }
val themes = findViewById<LinearLayout>(R.id.config_themes)
listOf(
"artwork" to R.string.player_widget_artwork,
"light" to R.string.player_widget_light,
"dark" to R.string.player_widget_dark,
).forEachIndexed { index, (key, label) ->
val tile = themeTile(key, getString(label))
themes.addView(tile, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
if (index > 0) marginStart = dp(12)
})
tiles += key to tile
}
val artist = findViewById<Switch>(R.id.config_artist)
artist.isChecked = showArtist
artist.setOnCheckedChangeListener { _, checked -> showArtist = checked; refresh() }
findViewById<View>(R.id.config_artist_row).setOnClickListener { artist.toggle() }
findViewById<View>(R.id.config_save).setOnClickListener {
prefs.edit().putString("theme_$widgetId", theme).putBoolean("artist_$widgetId", showArtist).apply()
PlayerWidgetProvider.update(this, intArrayOf(widgetId))
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId))
finish()
}
refresh()
// Emphasized-decelerate entrance, as Material 3 sheets do.
sheet.alpha = 0f
sheet.post {
sheet.translationY = sheet.height.toFloat()
sheet.alpha = 1f
sheet.animate().translationY(0f).setDuration(400)
.setInterpolator(PathInterpolator(0.05f, 0.7f, 0.1f, 1f)).start()
}
}
override fun onDestroy() {
cover?.recycle()
cover = null
super.onDestroy()
}
private fun refresh() {
val width = (resources.displayMetrics.widthPixels / resources.displayMetrics.density).toInt() - 48
val views = PlayerWidgetProvider.render(this, state, cover, theme, showArtist, width, 148)
preview.removeAllViews()
preview.addView(InertFrame(this).apply { addView(views.apply(this@PlayerWidgetConfigurationActivity, this)) })
for ((key, tile) in tiles) select(tile, key == theme)
}
/** A swatch drawn from the theme's real palette, with its label below. */
private fun themeTile(key: String, label: String): LinearLayout {
val colors = PlayerWidgetProvider.palette(this, key, PlayerWidgetProvider.seed(state))
val swatch = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
setPadding(dp(12), 0, dp(12), 0)
addView(block(colors.tonal, 28, 28, 8), LinearLayout.LayoutParams(dp(28), dp(28)))
addView(LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
addView(block(colors.onContainer, 36, 6, 3), LinearLayout.LayoutParams(dp(36), dp(6)))
addView(block(colors.primary, 24, 12, 6), LinearLayout.LayoutParams(dp(24), dp(12)).apply { topMargin = dp(6) })
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = dp(8)
})
tag = colors.container
}
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
isClickable = true
contentDescription = label
addView(swatch, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(76)))
addView(TextView(context).apply {
text = label
textSize = 13f
gravity = Gravity.CENTER
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = dp(8)
})
setOnClickListener {
if (theme != key) { theme = key; refresh() }
}
}
}
/** Selection morphs the swatch from a rounded square to a rounder, outlined one. */
private fun select(tile: LinearLayout, selected: Boolean) {
val swatch = tile.getChildAt(0)
val accent = getColor(R.color.widgetAccent)
swatch.background = GradientDrawable().apply {
setColor(swatch.tag as Int)
cornerRadius = dp(if (selected) 28 else 16).toFloat()
setStroke(dp(if (selected) 3 else 1), if (selected) accent else getColor(R.color.widgetTrack))
}
(tile.getChildAt(1) as TextView).apply {
setTextColor(if (selected) accent else getColor(R.color.widgetTextSecondary))
typeface = Typeface.create("sans-serif-medium", if (selected) Typeface.BOLD else Typeface.NORMAL)
}
tile.isSelected = selected
}
private fun block(color: Int, width: Int, height: Int, radius: Int) = View(this).apply {
background = GradientDrawable().apply {
setColor(color)
cornerRadius = dp(radius).toFloat()
setSize(dp(width), dp(height))
}
}
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
/** The preview shows the real widget but must not trigger its playback intents. */
private class InertFrame(context: Context) : FrameLayout(context) {
override fun onInterceptTouchEvent(event: MotionEvent) = true
}
}
@@ -22,6 +22,7 @@ import android.os.Looper
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.core.graphics.ColorUtils
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
@@ -107,91 +108,217 @@ class PlayerWidgetProvider : AppWidgetProvider() {
val widgets = ids ?: manager.getAppWidgetIds(ComponentName(context, PlayerWidgetProvider::class.java))
if (widgets.isEmpty()) return
val prefs = preferences(context)
val state = runCatching { JSONObject(prefs.getString("state", "{}")!!) }.getOrDefault(JSONObject())
val active = state.optBoolean("canPlay")
val cover = if (state.optString("artworkKey").isNotEmpty()) {
BitmapFactory.decodeFile(File(context.filesDir, "player-widget-cover.png").path)
} else null
val state = state(context)
val cover = cover(context, state)
for (id in widgets) {
val options = manager.getAppWidgetOptions(id)
val width = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 280)
val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 150)
val horizontal = width >= 240 && height < 240
val views = RemoteViews(context.packageName, if (horizontal) R.layout.widget_player_wide else R.layout.widget_player)
val theme = prefs.getString("theme_$id", "artwork")
val light = theme == "light"
val foreground = if (light) Color.rgb(26, 24, 31) else Color.WHITE
val background = when (theme) {
"light" -> Color.rgb(246, 243, 248)
"dark" -> Color.rgb(28, 27, 33)
else -> state.optLong("background", 0xff292433).toInt()
}
views.setImageViewBitmap(R.id.player_widget_background, renderBackground(background, width, height))
views.setTextViewText(R.id.player_widget_title, state.optString("title").ifEmpty { context.getString(R.string.player_widget_name) })
views.setTextViewText(R.id.player_widget_artist, if (active) state.optString("artist") else context.getString(R.string.player_widget_empty))
views.setTextColor(R.id.player_widget_title, foreground)
views.setTextColor(R.id.player_widget_artist, (foreground and 0x00ffffff) or (0xb8 shl 24))
views.setViewVisibility(R.id.player_widget_artist, if (prefs.getBoolean("artist_$id", true)) View.VISIBLE else View.GONE)
views.setImageViewBitmap(R.id.player_widget_art, cover?.let(::roundedCover) ?: icon(context, R.drawable.ic_widget_music, foreground, false))
val playing = state.optBoolean("playing")
val buttons = listOf(
Triple(R.id.player_widget_previous, R.drawable.ic_widget_previous, "previous"),
Triple(R.id.player_widget_play, if (playing) R.drawable.ic_widget_pause else R.drawable.ic_widget_play, "toggle"),
Triple(R.id.player_widget_next, R.drawable.ic_widget_next, "next"),
)
for ((view, drawable, command) in buttons) {
val enabled = active && when (command) {
"previous" -> state.optBoolean("canPrevious")
"next" -> state.optBoolean("canNext")
else -> true
}
views.setImageViewBitmap(view, icon(context, drawable, foreground, command == "toggle"))
views.setFloat(view, "setAlpha", if (enabled) 1f else 0.4f)
views.setBoolean(view, "setEnabled", enabled)
views.setOnClickPendingIntent(view, PendingIntent.getBroadcast(
context, 8200 + command.hashCode(),
Intent(context, PlayerWidgetProvider::class.java).setAction(ACTION).putExtra(COMMAND, command),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
))
}
views.setContentDescription(R.id.player_widget_play, context.getString(if (playing) R.string.player_widget_pause else R.string.player_widget_play))
views.setOnClickPendingIntent(R.id.player_widget_root, launchIntent(context, "open"))
manager.updateAppWidget(id, views)
manager.updateAppWidget(id, render(
context, state, cover, prefs.getString("theme_$id", "artwork"),
prefs.getBoolean("artist_$id", true), width, height,
))
}
cover?.recycle()
}
fun state(context: Context): JSONObject =
runCatching { JSONObject(preferences(context).getString("state", "{}")!!) }.getOrDefault(JSONObject())
fun cover(context: Context, state: JSONObject): Bitmap? = if (state.optString("artworkKey").isNotEmpty()) {
BitmapFactory.decodeFile(File(context.filesDir, "player-widget-cover.png").path)
} else null
/** Builds one widget instance; the configuration screen reuses it as a live preview. */
fun render(
context: Context, state: JSONObject, cover: Bitmap?, theme: String?,
showArtist: Boolean, width: Int, height: Int,
): RemoteViews {
val active = state.optBoolean("canPlay")
val playing = state.optBoolean("playing")
val density = context.resources.displayMetrics.density
val corner = if (Build.VERSION.SDK_INT >= 31) {
context.resources.getDimension(android.R.dimen.system_app_widget_background_radius)
} else 28 * density
// The wide row needs room for the cover and all three controls;
// shorter sizes without that width use the full-bleed cover.
val wide = width >= 340 && height < 240
val compact = !wide && (width < 200 || height < 240)
val layout = when {
wide -> R.layout.widget_player_wide
compact -> R.layout.widget_player_compact
else -> R.layout.widget_player
}
val views = RemoteViews(context.packageName, layout)
val colors = palette(context, theme, seed(state))
views.setInt(R.id.player_widget_background, "setColorFilter", colors.container)
views.setTextViewText(R.id.player_widget_title, state.optString("title").ifEmpty { context.getString(R.string.player_widget_name) })
views.setTextViewText(R.id.player_widget_artist, if (active) state.optString("artist") else context.getString(R.string.player_widget_empty))
views.setViewVisibility(R.id.player_widget_artist, if (showArtist) View.VISIBLE else View.GONE)
// Over a full-bleed cover the text sits on a dark scrim.
val overCover = compact && cover != null
views.setTextColor(R.id.player_widget_title, if (overCover) Color.WHITE else colors.onContainer)
views.setTextColor(R.id.player_widget_artist, if (overCover) 0xd9ffffff.toInt() else colors.onContainerVariant)
views.setImageViewBitmap(R.id.player_widget_art, when {
compact -> filledCover(context, cover, width, height, density, corner, colors)
cover != null -> roundedCover(cover)
else -> placeholder(context, colors)
})
fun bind(root: Int, shape: Int, icon: Int, iconRes: Int, fill: Int, tint: Int, command: String) {
val enabled = active && when (command) {
"previous" -> state.optBoolean("canPrevious")
"next" -> state.optBoolean("canNext")
else -> true
}
views.setInt(shape, "setColorFilter", fill)
views.setImageViewResource(icon, iconRes)
views.setInt(icon, "setColorFilter", tint)
views.setFloat(root, "setAlpha", if (enabled) 1f else 0.38f)
views.setBoolean(root, "setEnabled", enabled)
views.setOnClickPendingIntent(root, PendingIntent.getBroadcast(
context, 8200 + command.hashCode(),
Intent(context, PlayerWidgetProvider::class.java).setAction(ACTION).putExtra(COMMAND, command),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
))
}
// Expressive shape morph: a pill while paused, a rounder square while playing.
views.setImageViewResource(R.id.player_widget_play_shape, if (playing) R.drawable.widget_shape_squircle else R.drawable.widget_shape_pill)
bind(R.id.player_widget_play, R.id.player_widget_play_shape, R.id.player_widget_play_icon,
if (playing) R.drawable.ic_widget_pause else R.drawable.ic_widget_play, colors.primary, colors.onPrimary, "toggle")
if (!compact) {
bind(R.id.player_widget_previous, R.id.player_widget_previous_shape, R.id.player_widget_previous_icon,
R.drawable.ic_widget_previous, colors.tonal, colors.onTonal, "previous")
bind(R.id.player_widget_next, R.id.player_widget_next_shape, R.id.player_widget_next_icon,
R.drawable.ic_widget_next, colors.tonal, colors.onTonal, "next")
}
views.setContentDescription(R.id.player_widget_play, context.getString(if (playing) R.string.player_widget_pause else R.string.player_widget_play))
views.setOnClickPendingIntent(R.id.player_widget_root, launchIntent(context, "open"))
return views
}
fun seed(state: JSONObject) = state.optLong("background", 0xff292433).toInt()
/** Material 3 colour roles for one widget instance. */
class Palette(
val container: Int,
val onContainer: Int,
val onContainerVariant: Int,
val primary: Int,
val onPrimary: Int,
val tonal: Int,
val onTonal: Int,
)
/**
* Light and Dark follow the wallpaper's dynamic colours where the
* system provides them. Artwork derives tonal roles from the cover.
*/
fun palette(context: Context, theme: String?, seed: Int): Palette {
if (theme != "light" && theme != "dark") return tonalPalette(seed, dark = true)
val dark = theme == "dark"
if (Build.VERSION.SDK_INT < 31) return tonalPalette(0xff6750a4.toInt(), dark)
fun c(id: Int) = context.getColor(id)
return if (dark) Palette(
c(android.R.color.system_neutral1_900), c(android.R.color.system_neutral1_50),
c(android.R.color.system_neutral2_200), c(android.R.color.system_accent1_200),
c(android.R.color.system_accent1_800), c(android.R.color.system_accent2_700),
c(android.R.color.system_accent2_100),
) else Palette(
c(android.R.color.system_neutral1_50), c(android.R.color.system_neutral1_900),
c(android.R.color.system_neutral2_700), c(android.R.color.system_accent1_600),
c(android.R.color.system_accent1_0), c(android.R.color.system_accent2_100),
c(android.R.color.system_accent2_900),
)
}
private fun tonalPalette(seed: Int, dark: Boolean): Palette {
val hsl = FloatArray(3)
ColorUtils.colorToHSL(seed, hsl)
val s = hsl[1]
fun tone(saturation: Float, lightness: Float) =
ColorUtils.HSLToColor(floatArrayOf(hsl[0], saturation.coerceIn(0f, 1f), lightness))
return if (dark) Palette(
container = tone(minOf(s, 0.42f), 0.15f),
onContainer = tone(minOf(s, 0.12f), 0.95f),
onContainerVariant = tone(minOf(s, 0.18f), 0.76f),
primary = tone(s.coerceIn(0.35f, 0.75f), 0.80f),
onPrimary = tone(minOf(s, 0.5f), 0.16f),
tonal = tone(minOf(s, 0.38f), 0.26f),
onTonal = tone(minOf(s, 0.3f), 0.90f),
) else Palette(
container = tone(minOf(s, 0.4f), 0.96f),
onContainer = tone(minOf(s, 0.2f), 0.11f),
onContainerVariant = tone(minOf(s, 0.15f), 0.32f),
primary = tone(s.coerceIn(0.3f, 0.6f), 0.42f),
onPrimary = Color.WHITE,
tonal = tone(minOf(s, 0.45f), 0.88f),
onTonal = tone(minOf(s, 0.4f), 0.14f),
)
}
private fun roundedCover(source: Bitmap): Bitmap {
val scaled = Bitmap.createScaledBitmap(source, 256, 256, true)
val result = Bitmap.createBitmap(256, 256, Bitmap.Config.ARGB_8888)
Canvas(result).drawRoundRect(0f, 0f, 256f, 256f, 20f, 20f, Paint(Paint.ANTI_ALIAS_FLAG).apply {
val size = 384
val scaled = Bitmap.createScaledBitmap(source, size, size, true)
val result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
// Expressive covers use large corners: about a fifth of the side.
val radius = size * 0.2f
Canvas(result).drawRoundRect(0f, 0f, size.toFloat(), size.toFloat(), radius, radius, Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(scaled, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
})
if (scaled !== source) scaled.recycle()
return result
}
private fun renderBackground(color: Int, width: Int, height: Int): Bitmap {
val w = width.coerceIn(120, 640)
val h = height.coerceIn(100, 640)
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val highlight = Color.rgb((Color.red(color) + 12).coerceAtMost(255), (Color.green(color) + 12).coerceAtMost(255), (Color.blue(color) + 12).coerceAtMost(255))
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(0f, 0f, w.toFloat(), h.toFloat(), highlight, color, Shader.TileMode.CLAMP)
/** The compact widget's cover fills it edge to edge under a bottom scrim. */
private fun filledCover(
context: Context, source: Bitmap?, width: Int, height: Int,
density: Float, corner: Float, colors: Palette,
): Bitmap {
val scale = minOf(density, 2f, 512f / maxOf(width, height, 1))
val w = (width * scale).toInt().coerceAtLeast(1)
val h = (height * scale).toInt().coerceAtLeast(1)
val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
val radius = corner * scale / density
if (source == null) {
val icon = context.getDrawable(R.drawable.ic_widget_music)!!.mutate()
icon.setTint(colors.onContainerVariant)
val side = minOf(w, h) * 0.34f
val left = (w - side) / 2
val top = (h - side) / 2 - h * 0.12f
icon.setBounds(left.toInt(), top.toInt(), (left + side).toInt(), (top + side).toInt())
icon.draw(canvas)
return result
}
Canvas(bitmap).drawRoundRect(0f, 0f, w.toFloat(), h.toFloat(), 26f, 26f, paint)
return bitmap
val crop = minOf(source.width / w.toFloat(), source.height / h.toFloat())
val sw = w * crop
val sh = h * crop
val matrix = android.graphics.Matrix().apply {
setScale(1 / crop, 1 / crop)
preTranslate(-(source.width - sw) / 2, -(source.height - sh) / 2)
}
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply { setLocalMatrix(matrix) }
}
canvas.drawRoundRect(0f, 0f, w.toFloat(), h.toFloat(), radius, radius, paint)
canvas.drawRoundRect(0f, 0f, w.toFloat(), h.toFloat(), radius, radius, Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = LinearGradient(0f, h * 0.35f, 0f, h.toFloat(), Color.TRANSPARENT, 0xcc000000.toInt(), Shader.TileMode.CLAMP)
})
return result
}
private fun icon(context: Context, resource: Int, color: Int, circle: Boolean): Bitmap {
val bitmap = Bitmap.createBitmap(96, 96, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
if (circle) canvas.drawCircle(48f, 48f, 46f, Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color })
val drawable = context.getDrawable(resource)!!.mutate()
drawable.setTint(if (circle && color == Color.WHITE) Color.rgb(35, 30, 42) else if (circle) Color.WHITE else color)
drawable.setBounds(25, 25, 71, 71)
drawable.draw(canvas)
return bitmap
private fun placeholder(context: Context, colors: Palette): Bitmap {
val size = 384
val result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
val radius = size * 0.2f
canvas.drawRoundRect(0f, 0f, size.toFloat(), size.toFloat(), radius, radius, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = colors.tonal })
val icon = context.getDrawable(R.drawable.ic_widget_music)!!.mutate()
icon.setTint(colors.onTonal)
icon.setBounds(132, 132, 252, 252)
icon.draw(canvas)
return result
}
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Matches the launcher's own widget corner radius. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="@android:dimen/system_app_widget_background_radius" />
</shape>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M11,4h2v8.17l2.59,-2.58L17,11l-5,5 -5,-5 1.41,-1.41L11,12.17V4zM5,18h14v2H5z" />
</vector>
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M1,4 L12,12 L1,20 Z M12,4 L23,12 L12,20 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M7.58,16.89l5.77,-4.07c0.56,-0.4 0.56,-1.24 0,-1.63L7.58,7.11C6.91,6.65 6,7.12 6,7.93v8.14c0,0.81 0.91,1.28 1.58,0.82zM16,7v10c0,0.55 0.45,1 1,1s1,-0.45 1,-1V7c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1z" />
</vector>
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M5,3 H10 V21 H5 Z M14,3 H19 V21 H14 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M8,19c1.1,0 2,-0.9 2,-2V7c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2v10c0,1.1 0.9,2 2,2zM14,7v10c0,1.1 0.9,2 2,2s2,-0.9 2,-2V7c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2z" />
</vector>
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M7,3 L22,12 L7,21 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M8,6.82v10.36c0,0.79 0.87,1.27 1.54,0.84l8.14,-5.18c0.62,-0.39 0.62,-1.29 0,-1.69L9.54,5.98C8.87,5.55 8,6.03 8,6.82z" />
</vector>
@@ -1,3 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M12,4 L1,12 L12,20 Z M23,4 L12,12 L23,20 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M7,6c0.55,0 1,0.45 1,1v10c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V7c0,-0.55 0.45,-1 1,-1zM10.66,12.82l5.77,4.07c0.66,0.47 1.58,-0.01 1.58,-0.82V7.93c0,-0.81 -0.91,-1.28 -1.58,-0.82l-5.77,4.07c-0.57,0.4 -0.57,1.24 0,1.64z" />
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Picker preview and initial cover before the provider renders one. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:width="96dp" android:height="96dp">
<shape android:shape="rectangle">
<solid android:color="@color/widgetAccentContainer" />
<corners android:radius="20dp" />
</shape>
</item>
<item android:width="40dp" android:height="40dp" android:gravity="center">
<vector android:width="24dp" android:height="24dp" android:tint="@color/widgetOnAccentContainer"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M10,4 L21,2 V16 A3.5,3.5 0,1 1,19 12.84 V6 L12,7.3 V19 A3.5,3.5 0,1 1,10 15.84 Z" />
</vector>
</item>
</layer-list>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Bottom sheet surface with Expressive extra-large top corners. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/widgetBackground" />
<corners android:topLeftRadius="28dp" android:topRightRadius="28dp" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/widgetAccentContainer" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widgetBackground" />
<corners android:radius="16dp" />
</shape>
@@ -1,4 +0,0 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#292433" />
<corners android:radius="26dp" />
</shape>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#FFFFFF" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- White so the provider can tint it to any palette role. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="28dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Paused play button: a full pill. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="999dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Playing button: Material 3 Expressive morphs the pill to a rounder square. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="18dp" />
</shape>
@@ -1,52 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_download_bg"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp">
<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:contentDescription="@null"
android:src="@mipmap/ic_launcher" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/widget_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widgetTextPrimary"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/widgetTextSecondary"
android:textSize="11sp" />
<ProgressBar
android:id="@+id/widget_progress"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_marginTop="6dp"
android:max="100"
android:progressTint="@color/widgetAccent" />
<!-- 4x1: progress ring with the percent, the track, and a queue chip. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root" android:layout_width="match_parent" android:layout_height="match_parent">
<ImageView android:id="@+id/widget_background" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_container" android:tint="@color/widgetBackground"
android:importantForAccessibility="no" />
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent"
android:gravity="center_vertical" android:orientation="horizontal" android:paddingStart="10dp" android:paddingEnd="16dp">
<FrameLayout android:layout_width="56dp" android:layout_height="56dp">
<ImageView android:id="@+id/widget_ring" android:layout_width="match_parent" android:layout_height="match_parent"
android:src="@drawable/widget_disc_tonal" android:importantForAccessibility="no" />
<ImageView android:id="@+id/widget_icon" android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center"
android:src="@drawable/ic_widget_download" android:tint="@color/widgetOnAccentContainer" android:importantForAccessibility="no" />
<TextView android:id="@+id/widget_percent" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:fontFamily="sans-serif-medium" android:includeFontPadding="false"
android:letterSpacing="-0.02" android:textColor="@color/widgetOnAccentContainer" android:textSize="15sp"
android:textStyle="bold" android:visibility="gone" />
</FrameLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="14dp"
android:layout_weight="1" android:orientation="vertical">
<TextView android:id="@+id/widget_title" android:layout_width="match_parent" android:layout_height="wrap_content"
android:ellipsize="end" android:fontFamily="sans-serif" android:includeFontPadding="false" android:singleLine="true"
android:text="@string/download_widget_name" android:textColor="@color/widgetTextPrimary" android:textSize="16sp"
android:textStyle="bold" />
<TextView android:id="@+id/widget_subtitle" android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:ellipsize="end" android:fontFamily="sans-serif-medium" android:singleLine="true"
android:text="@string/download_widget_idle" android:textColor="@color/widgetTextSecondary" android:textSize="13sp" />
</LinearLayout>
<TextView android:id="@+id/widget_queue" android:layout_width="wrap_content" android:layout_height="32dp"
android:layout_marginStart="10dp" android:background="@drawable/widget_shape_pill"
android:backgroundTint="@color/widgetAccentContainer" android:fontFamily="sans-serif-medium" android:gravity="center"
android:includeFontPadding="false" android:paddingStart="12dp" android:paddingEnd="12dp"
android:textColor="@color/widgetOnAccentContainer" android:textSize="13sp" android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 4x2 and taller: status chip, display-size percent, track, wavy progress. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root" android:layout_width="match_parent" android:layout_height="match_parent">
<ImageView android:id="@+id/widget_background" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_container" android:tint="@color/widgetBackground"
android:importantForAccessibility="no" />
<!-- RemoteViews cannot inflate plain spacer Views; gravity places the rows. -->
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:padding="16dp">
<LinearLayout android:layout_width="wrap_content" android:layout_height="32dp" android:layout_gravity="top|start"
android:background="@drawable/widget_shape_pill" android:backgroundTint="@color/widgetAccentContainer"
android:gravity="center_vertical" android:orientation="horizontal" android:paddingStart="10dp" android:paddingEnd="12dp">
<ImageView android:id="@+id/widget_icon" android:layout_width="18dp" android:layout_height="18dp"
android:src="@drawable/ic_widget_download" android:tint="@color/widgetOnAccentContainer"
android:importantForAccessibility="no" />
<TextView android:id="@+id/widget_status" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="6dp" android:fontFamily="sans-serif-medium" android:includeFontPadding="false"
android:text="@string/download_widget_status_idle" android:textColor="@color/widgetOnAccentContainer"
android:textSize="13sp" />
</LinearLayout>
<TextView android:id="@+id/widget_queue" android:layout_width="wrap_content" android:layout_height="32dp"
android:layout_gravity="top|end" android:fontFamily="sans-serif-medium" android:gravity="center"
android:includeFontPadding="false" android:textColor="@color/widgetTextSecondary" android:textSize="13sp"
android:visibility="gone" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom"
android:orientation="vertical">
<TextView android:id="@+id/widget_percent" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:fontFamily="sans-serif" android:includeFontPadding="false" android:letterSpacing="-0.03"
android:textColor="@color/widgetAccent" android:textSize="44sp" android:textStyle="bold" android:visibility="gone" />
<TextView android:id="@+id/widget_title" android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:ellipsize="end" android:fontFamily="sans-serif" android:includeFontPadding="false"
android:singleLine="true" android:text="@string/download_widget_idle_title" android:textColor="@color/widgetTextPrimary"
android:textSize="18sp" android:textStyle="bold" />
<TextView android:id="@+id/widget_subtitle" android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:ellipsize="end" android:fontFamily="sans-serif-medium" android:singleLine="true"
android:text="@string/download_widget_idle" android:textColor="@color/widgetTextSecondary" android:textSize="13sp" />
<ImageView android:id="@+id/widget_wave" android:layout_width="match_parent" android:layout_height="16dp"
android:layout_marginTop="12dp" android:scaleType="fitXY" android:importantForAccessibility="no" android:visibility="gone" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
@@ -1,15 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tall sizes: the cover leads, then title and transport. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_widget_root" android:layout_width="match_parent" android:layout_height="match_parent">
<ImageView android:id="@+id/player_widget_background" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="fitXY" android:src="@drawable/widget_player_background" android:importantForAccessibility="no" />
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="12dp">
<ImageView android:id="@+id/player_widget_art" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:scaleType="fitCenter" android:src="@drawable/ic_widget_music" android:importantForAccessibility="no" />
<TextView android:id="@+id/player_widget_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="6dp" android:singleLine="true" android:ellipsize="end" android:text="@string/player_widget_name" android:textSize="15sp" android:textStyle="bold" android:textColor="#FFFFFF" />
<TextView android:id="@+id/player_widget_artist" android:layout_width="match_parent" android:layout_height="wrap_content" android:singleLine="true" android:ellipsize="end" android:text="@string/player_widget_empty" android:textSize="12sp" android:textColor="#B8FFFFFF" />
<LinearLayout android:layout_width="match_parent" android:layout_height="44dp" android:gravity="center" android:orientation="horizontal">
<ImageButton android:id="@+id/player_widget_previous" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="4dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_previous" android:contentDescription="@string/player_widget_previous" />
<ImageButton android:id="@+id/player_widget_play" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="0dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_play" android:contentDescription="@string/player_widget_play" />
<ImageButton android:id="@+id/player_widget_next" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="4dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_next" android:contentDescription="@string/player_widget_next" />
</LinearLayout>
<ImageView android:id="@+id/player_widget_background" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_container" android:tint="@color/widgetBackground" android:importantForAccessibility="no" />
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:padding="14dp">
<ImageView android:id="@+id/player_widget_art" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"
android:scaleType="fitCenter" android:src="@drawable/widget_art_placeholder" android:importantForAccessibility="no" />
<TextView android:id="@+id/player_widget_title" android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:ellipsize="end" android:fontFamily="sans-serif" android:includeFontPadding="false"
android:letterSpacing="-0.01" android:singleLine="true" android:text="@string/player_widget_name"
android:textColor="@color/widgetTextPrimary" android:textSize="18sp" android:textStyle="bold" />
<TextView android:id="@+id/player_widget_artist" android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:ellipsize="end" android:fontFamily="sans-serif-medium" android:singleLine="true"
android:text="@string/player_widget_empty" android:textColor="@color/widgetTextSecondary" android:textSize="14sp" />
<include layout="@layout/widget_player_controls" android:layout_width="match_parent" android:layout_height="56dp"
android:layout_marginTop="10dp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 2x2: the cover fills the widget; title and a play button sit on a scrim. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_widget_root" android:layout_width="match_parent" android:layout_height="match_parent">
<ImageView android:id="@+id/player_widget_background" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_container" android:tint="@color/widgetBackground" android:importantForAccessibility="no" />
<ImageView android:id="@+id/player_widget_art" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:importantForAccessibility="no" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom"
android:gravity="center_vertical" android:orientation="horizontal" android:padding="12dp">
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
<TextView android:id="@+id/player_widget_title" android:layout_width="match_parent" android:layout_height="wrap_content"
android:ellipsize="end" android:fontFamily="sans-serif" android:includeFontPadding="false" android:singleLine="true"
android:text="@string/player_widget_name" android:textColor="#FFFFFF" android:textSize="15sp" android:textStyle="bold" />
<TextView android:id="@+id/player_widget_artist" android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:ellipsize="end" android:fontFamily="sans-serif-medium" android:singleLine="true"
android:text="@string/player_widget_empty" android:textColor="#D9FFFFFF" android:textSize="12sp" />
</LinearLayout>
<FrameLayout android:id="@+id/player_widget_play" android:layout_width="52dp" android:layout_height="52dp"
android:layout_marginStart="8dp" android:contentDescription="@string/player_widget_play">
<ImageView android:id="@+id/player_widget_play_shape" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_pill" android:tint="@color/widgetAccent" android:importantForAccessibility="no" />
<ImageView android:id="@+id/player_widget_play_icon" android:layout_width="26dp" android:layout_height="26dp" android:layout_gravity="center"
android:src="@drawable/ic_widget_play" android:tint="@color/widgetOnAccent" android:importantForAccessibility="no" />
</FrameLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Player widget settings as a bottom sheet with a live widget preview. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/config_scrim" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#66000000">
<ScrollView android:id="@+id/config_sheet" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:background="@drawable/widget_config_sheet" android:clickable="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingTop="12dp" android:paddingEnd="24dp" android:paddingBottom="24dp">
<View android:layout_width="32dp" android:layout_height="4dp" android:layout_gravity="center_horizontal" android:alpha="0.4" android:background="@drawable/widget_shape_pill" android:backgroundTint="@color/widgetTextSecondary"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:fontFamily="sans-serif" android:text="@string/player_widget_name" android:textColor="@color/widgetTextPrimary" android:textSize="26sp"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:fontFamily="sans-serif" android:text="@string/player_widget_customize" android:textColor="@color/widgetTextSecondary" android:textSize="14sp"/>
<FrameLayout android:id="@+id/config_preview" android:layout_width="match_parent" android:layout_height="148dp" android:layout_marginTop="20dp" android:importantForAccessibility="noHideDescendants"/>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:fontFamily="sans-serif-medium" android:text="@string/player_widget_style" android:textColor="@color/widgetAccent" android:textSize="14sp"/>
<LinearLayout android:id="@+id/config_themes" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal"/>
<LinearLayout android:id="@+id/config_artist_row" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginTop="12dp" android:gravity="center_vertical" android:orientation="horizontal">
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:fontFamily="sans-serif" android:text="@string/player_widget_show_artist" android:textColor="@color/widgetTextPrimary" android:textSize="16sp"/>
<Switch android:id="@+id/config_artist" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
</LinearLayout>
<TextView android:id="@+id/config_save" android:layout_width="match_parent" android:layout_height="56dp" android:layout_marginTop="16dp" android:background="@drawable/widget_shape_pill" android:backgroundTint="@color/widgetAccent" android:clickable="true" android:focusable="true" android:fontFamily="sans-serif-medium" android:gravity="center" android:text="@string/player_widget_save" android:textColor="@color/widgetOnAccent" android:textSize="16sp"/>
</LinearLayout>
</ScrollView>
</FrameLayout>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Expressive transport: tonal circles around a wide, morphing play button. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent"
android:gravity="center_vertical" android:orientation="horizontal">
<FrameLayout android:id="@+id/player_widget_previous" android:layout_width="48dp" android:layout_height="48dp"
android:contentDescription="@string/player_widget_previous">
<ImageView android:id="@+id/player_widget_previous_shape" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_circle" android:tint="@color/widgetAccentContainer" android:importantForAccessibility="no" />
<ImageView android:id="@+id/player_widget_previous_icon" android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center"
android:src="@drawable/ic_widget_previous" android:importantForAccessibility="no" />
</FrameLayout>
<FrameLayout android:id="@+id/player_widget_play" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
android:layout_marginStart="8dp" android:layout_marginEnd="8dp" android:contentDescription="@string/player_widget_play">
<ImageView android:id="@+id/player_widget_play_shape" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_pill" android:tint="@color/widgetAccent" android:importantForAccessibility="no" />
<ImageView android:id="@+id/player_widget_play_icon" android:layout_width="28dp" android:layout_height="28dp" android:layout_gravity="center"
android:src="@drawable/ic_widget_play" android:tint="@color/widgetOnAccent" android:importantForAccessibility="no" />
</FrameLayout>
<FrameLayout android:id="@+id/player_widget_next" android:layout_width="48dp" android:layout_height="48dp"
android:contentDescription="@string/player_widget_next">
<ImageView android:id="@+id/player_widget_next_shape" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_circle" android:tint="@color/widgetAccentContainer" android:importantForAccessibility="no" />
<ImageView android:id="@+id/player_widget_next_icon" android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center"
android:src="@drawable/ic_widget_next" android:importantForAccessibility="no" />
</FrameLayout>
</LinearLayout>
@@ -1,17 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 4x2: large rounded cover, emphasized title and the transport row. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_widget_root" android:layout_width="match_parent" android:layout_height="match_parent">
<ImageView android:id="@+id/player_widget_background" android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="fitXY" android:src="@drawable/widget_player_background" android:importantForAccessibility="no" />
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal" android:padding="16dp" android:gravity="center_vertical">
<ImageView android:id="@+id/player_widget_art" android:layout_width="96dp" android:layout_height="96dp" android:scaleType="fitCenter" android:src="@drawable/ic_widget_music" android:importantForAccessibility="no" />
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:layout_marginStart="14dp" android:orientation="vertical" android:gravity="center_vertical">
<TextView android:id="@+id/player_widget_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:maxLines="2" android:ellipsize="end" android:text="@string/player_widget_name" android:textSize="16sp" android:textStyle="bold" android:textColor="#FFFFFF" />
<TextView android:id="@+id/player_widget_artist" android:layout_width="match_parent" android:layout_height="wrap_content" android:singleLine="true" android:ellipsize="end" android:text="@string/player_widget_empty" android:textSize="13sp" android:textColor="#B8FFFFFF" />
<LinearLayout android:layout_width="match_parent" android:layout_height="44dp" android:layout_marginTop="4dp" android:gravity="center" android:orientation="horizontal">
<ImageButton android:id="@+id/player_widget_previous" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="4dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_previous" android:contentDescription="@string/player_widget_previous" />
<ImageButton android:id="@+id/player_widget_play" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="0dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_play" android:contentDescription="@string/player_widget_play" />
<ImageButton android:id="@+id/player_widget_next" android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:padding="4dp" android:background="@android:color/transparent" android:scaleType="fitCenter" android:src="@drawable/ic_widget_next" android:contentDescription="@string/player_widget_next" />
</LinearLayout>
<ImageView android:id="@+id/player_widget_background" android:layout_width="match_parent" android:layout_height="match_parent"
android:scaleType="fitXY" android:src="@drawable/widget_shape_container" android:tint="@color/widgetBackground" android:importantForAccessibility="no" />
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent"
android:gravity="center_vertical" android:orientation="horizontal" android:padding="12dp">
<ImageView android:id="@+id/player_widget_art" android:layout_width="wrap_content" android:layout_height="match_parent"
android:adjustViewBounds="true" android:maxWidth="136dp" android:scaleType="fitCenter" android:src="@drawable/widget_art_placeholder" android:importantForAccessibility="no" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
android:layout_marginStart="16dp" android:gravity="center_vertical" android:orientation="vertical">
<TextView android:id="@+id/player_widget_title" android:layout_width="match_parent" android:layout_height="wrap_content"
android:ellipsize="end" android:fontFamily="sans-serif" android:includeFontPadding="false" android:letterSpacing="-0.01"
android:singleLine="true" android:text="@string/player_widget_name" android:textColor="@color/widgetTextPrimary"
android:textSize="18sp" android:textStyle="bold" />
<TextView android:id="@+id/player_widget_artist" android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:ellipsize="end" android:fontFamily="sans-serif-medium" android:singleLine="true"
android:text="@string/player_widget_empty" android:textColor="@color/widgetTextSecondary" android:textSize="14sp" />
<include layout="@layout/widget_player_controls" android:layout_width="match_parent" android:layout_height="48dp"
android:layout_marginTop="10dp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,11 @@
<resources>
<string name="download_widget_name">Unduhan SpotiFLAC</string>
<string name="download_widget_description">Progres lagu yang sedang diunduh.</string>
<string name="download_widget_downloading">Mengunduh…</string>
<string name="download_widget_idle">Tidak ada unduhan aktif</string>
<string name="download_widget_idle_title">Semua beres</string>
<string name="download_widget_status_active">Mengunduh</string>
<string name="download_widget_status_idle">Selesai</string>
<string name="download_widget_percent">%1$d%%</string>
<string name="download_widget_queue">%1$d dalam antrean</string>
</resources>
@@ -11,4 +11,6 @@
<string name="player_widget_dark">Gelap</string>
<string name="player_widget_show_artist">Tampilkan nama artis</string>
<string name="player_widget_save">Simpan widget</string>
<string name="player_widget_customize">Pilih tampilan widget ini di layar utama.</string>
<string name="player_widget_style">Gaya</string>
</resources>
@@ -4,4 +4,8 @@
<color name="widgetTextPrimary">@android:color/system_neutral1_50</color>
<color name="widgetTextSecondary">@android:color/system_neutral2_200</color>
<color name="widgetAccent">@android:color/system_accent1_200</color>
<color name="widgetOnAccent">@android:color/system_accent1_800</color>
<color name="widgetAccentContainer">@android:color/system_accent1_700</color>
<color name="widgetOnAccentContainer">@android:color/system_accent1_100</color>
<color name="widgetTrack">@android:color/system_accent2_700</color>
</resources>
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widgetBackground">#1C1B1F</color>
<color name="widgetTextPrimary">#E6E1E5</color>
<color name="widgetBackground">#211F26</color>
<color name="widgetTextPrimary">#E6E0E9</color>
<color name="widgetTextSecondary">#CAC4D0</color>
<color name="widgetAccent">#D0BCFF</color>
<color name="widgetOnAccent">#381E72</color>
<color name="widgetAccentContainer">#4F378B</color>
<color name="widgetOnAccentContainer">#EADDFF</color>
<color name="widgetTrack">#4A4458</color>
</resources>
@@ -6,4 +6,10 @@
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="WidgetConfigTheme" parent="@android:style/Theme.DeviceDefault.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowAnimationStyle">@null</item>
<item name="android:colorAccent">@color/widgetAccent</item>
</style>
</resources>
@@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widgetBackground">@android:color/system_neutral1_10</color>
<color name="widgetBackground">@android:color/system_neutral1_50</color>
<color name="widgetTextPrimary">@android:color/system_neutral1_900</color>
<color name="widgetTextSecondary">@android:color/system_neutral2_700</color>
<color name="widgetAccent">@android:color/system_accent1_600</color>
<color name="widgetOnAccent">@android:color/system_accent1_0</color>
<color name="widgetAccentContainer">@android:color/system_accent1_100</color>
<color name="widgetOnAccentContainer">@android:color/system_accent1_900</color>
<color name="widgetTrack">@android:color/system_accent2_100</color>
</resources>
+9 -4
View File
@@ -1,8 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
<color name="widgetBackground">#F3F3F3</color>
<color name="widgetTextPrimary">#1C1B1F</color>
<!-- Material 3 baseline roles for launcher widgets without dynamic color. -->
<color name="widgetBackground">#F7F2FA</color>
<color name="widgetTextPrimary">#1D1B20</color>
<color name="widgetTextSecondary">#49454F</color>
<color name="widgetAccent">#1DB954</color>
</resources>
<color name="widgetAccent">#6750A4</color>
<color name="widgetOnAccent">#FFFFFF</color>
<color name="widgetAccentContainer">#EADDFF</color>
<color name="widgetOnAccentContainer">#21005D</color>
<color name="widgetTrack">#E8DEF8</color>
</resources>
@@ -0,0 +1,11 @@
<resources>
<string name="download_widget_name">SpotiFLAC Downloads</string>
<string name="download_widget_description">Progress of the track downloading now.</string>
<string name="download_widget_downloading">Downloading…</string>
<string name="download_widget_idle">No active downloads</string>
<string name="download_widget_idle_title">All caught up</string>
<string name="download_widget_status_active">Downloading</string>
<string name="download_widget_status_idle">Done</string>
<string name="download_widget_percent">%1$d%%</string>
<string name="download_widget_queue">%1$d in queue</string>
</resources>
@@ -11,4 +11,6 @@
<string name="player_widget_dark">Dark</string>
<string name="player_widget_show_artist">Show artist name</string>
<string name="player_widget_save">Save widget</string>
<string name="player_widget_customize">Choose how this widget looks on your home screen.</string>
<string name="player_widget_style">Style</string>
</resources>
@@ -6,4 +6,11 @@
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<!-- Translucent host for the player widget's bottom sheet. -->
<style name="WidgetConfigTheme" parent="@android:style/Theme.DeviceDefault.Light.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowAnimationStyle">@null</item>
<item name="android:colorAccent">@color/widgetAccent</item>
</style>
</resources>
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp" android:minHeight="56dp"
android:minResizeWidth="180dp" android:minResizeHeight="56dp"
android:targetCellWidth="4" android:targetCellHeight="1"
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
android:initialLayout="@layout/widget_download_queue"
android:minWidth="250dp"
android:minHeight="40dp"
android:resizeMode="horizontal"
android:updatePeriodMillis="0"
android:previewLayout="@layout/widget_download_queue"
android:description="@string/download_widget_description"
android:widgetCategory="home_screen" />