mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
feat(android): refresh launcher widgets with adaptive expressive layouts
This commit is contained in:
1 parent
6b6db4ca2e
commit
75cd4c39b2
39 files changed
+878
-250
No files matched your search
@@ -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}")
|
||||
|
||||
+159
-38
@@ -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>
|
||||
@@ -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" />
|
||||
Reference in new issue
Block a user