diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3a83e953..260c3afc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -146,7 +146,7 @@ + android:theme="@style/WidgetConfigTheme"> @@ -166,6 +166,7 @@ diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadQueueWidgetProvider.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadQueueWidgetProvider.kt index 83484e66..24ecae9b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadQueueWidgetProvider.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadQueueWidgetProvider.kt @@ -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 + } } } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index cf3f167a..b64693a7 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -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}") diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetConfigurationActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetConfigurationActivity.kt index e0a8da61..9b8a9edd 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetConfigurationActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetConfigurationActivity.kt @@ -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>() + + 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(R.id.config_scrim).setOnClickListener { finish() } + + val themes = findViewById(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(R.id.config_artist) + artist.isChecked = showArtist + artist.setOnCheckedChangeListener { _, checked -> showArtist = checked; refresh() } + findViewById(R.id.config_artist_row).setOnClickListener { artist.toggle() } + + findViewById(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 } } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt index 47b9ec83..1a2106bc 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt @@ -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 } } } diff --git a/android/app/src/main/res/drawable-v31/widget_shape_container.xml b/android/app/src/main/res/drawable-v31/widget_shape_container.xml new file mode 100644 index 00000000..7b29907f --- /dev/null +++ b/android/app/src/main/res/drawable-v31/widget_shape_container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_widget_done.xml b/android/app/src/main/res/drawable/ic_widget_done.xml new file mode 100644 index 00000000..c4e68506 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_done.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_download.xml b/android/app/src/main/res/drawable/ic_widget_download.xml new file mode 100644 index 00000000..3326b7ef --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_download.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_next.xml b/android/app/src/main/res/drawable/ic_widget_next.xml index 0367d847..c8cd5882 100644 --- a/android/app/src/main/res/drawable/ic_widget_next.xml +++ b/android/app/src/main/res/drawable/ic_widget_next.xml @@ -1,3 +1,3 @@ - + diff --git a/android/app/src/main/res/drawable/ic_widget_pause.xml b/android/app/src/main/res/drawable/ic_widget_pause.xml index 28fe8cf5..c76f2551 100644 --- a/android/app/src/main/res/drawable/ic_widget_pause.xml +++ b/android/app/src/main/res/drawable/ic_widget_pause.xml @@ -1,3 +1,3 @@ - + diff --git a/android/app/src/main/res/drawable/ic_widget_play.xml b/android/app/src/main/res/drawable/ic_widget_play.xml index 4f7a649b..b4f5787d 100644 --- a/android/app/src/main/res/drawable/ic_widget_play.xml +++ b/android/app/src/main/res/drawable/ic_widget_play.xml @@ -1,3 +1,3 @@ - + diff --git a/android/app/src/main/res/drawable/ic_widget_previous.xml b/android/app/src/main/res/drawable/ic_widget_previous.xml index 6007c856..dd48ea2a 100644 --- a/android/app/src/main/res/drawable/ic_widget_previous.xml +++ b/android/app/src/main/res/drawable/ic_widget_previous.xml @@ -1,3 +1,3 @@ - + diff --git a/android/app/src/main/res/drawable/widget_art_placeholder.xml b/android/app/src/main/res/drawable/widget_art_placeholder.xml new file mode 100644 index 00000000..ffb4d435 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_art_placeholder.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_config_sheet.xml b/android/app/src/main/res/drawable/widget_config_sheet.xml new file mode 100644 index 00000000..6a428200 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_config_sheet.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/widget_disc_tonal.xml b/android/app/src/main/res/drawable/widget_disc_tonal.xml new file mode 100644 index 00000000..81db1f40 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_disc_tonal.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/widget_download_bg.xml b/android/app/src/main/res/drawable/widget_download_bg.xml deleted file mode 100644 index ab808957..00000000 --- a/android/app/src/main/res/drawable/widget_download_bg.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_player_background.xml b/android/app/src/main/res/drawable/widget_player_background.xml deleted file mode 100644 index 4b149e89..00000000 --- a/android/app/src/main/res/drawable/widget_player_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/android/app/src/main/res/drawable/widget_shape_circle.xml b/android/app/src/main/res/drawable/widget_shape_circle.xml new file mode 100644 index 00000000..bf1b2591 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_shape_circle.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/drawable/widget_shape_container.xml b/android/app/src/main/res/drawable/widget_shape_container.xml new file mode 100644 index 00000000..72be4206 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_shape_container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/widget_shape_pill.xml b/android/app/src/main/res/drawable/widget_shape_pill.xml new file mode 100644 index 00000000..28efc642 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_shape_pill.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/widget_shape_squircle.xml b/android/app/src/main/res/drawable/widget_shape_squircle.xml new file mode 100644 index 00000000..8ee62dc5 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_shape_squircle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/layout/widget_download_queue.xml b/android/app/src/main/res/layout/widget_download_queue.xml index 0b5e95de..fc30e413 100644 --- a/android/app/src/main/res/layout/widget_download_queue.xml +++ b/android/app/src/main/res/layout/widget_download_queue.xml @@ -1,52 +1,36 @@ - - - - - - - - - - - + + + + + + + + + + + + + + - + diff --git a/android/app/src/main/res/layout/widget_download_queue_large.xml b/android/app/src/main/res/layout/widget_download_queue_large.xml new file mode 100644 index 00000000..5e3a48d8 --- /dev/null +++ b/android/app/src/main/res/layout/widget_download_queue_large.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_player.xml b/android/app/src/main/res/layout/widget_player.xml index 3813de0d..ea94d5b5 100644 --- a/android/app/src/main/res/layout/widget_player.xml +++ b/android/app/src/main/res/layout/widget_player.xml @@ -1,15 +1,21 @@ + - - - - - - - - - - + + + + + + diff --git a/android/app/src/main/res/layout/widget_player_compact.xml b/android/app/src/main/res/layout/widget_player_compact.xml new file mode 100644 index 00000000..e17bb7e3 --- /dev/null +++ b/android/app/src/main/res/layout/widget_player_compact.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_player_configure.xml b/android/app/src/main/res/layout/widget_player_configure.xml new file mode 100644 index 00000000..6bf4879a --- /dev/null +++ b/android/app/src/main/res/layout/widget_player_configure.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_player_controls.xml b/android/app/src/main/res/layout/widget_player_controls.xml new file mode 100644 index 00000000..8593bb83 --- /dev/null +++ b/android/app/src/main/res/layout/widget_player_controls.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_player_wide.xml b/android/app/src/main/res/layout/widget_player_wide.xml index a11dcba9..28dc6bf2 100644 --- a/android/app/src/main/res/layout/widget_player_wide.xml +++ b/android/app/src/main/res/layout/widget_player_wide.xml @@ -1,17 +1,24 @@ + - - - - - - - - - - - + + + + + + + diff --git a/android/app/src/main/res/values-in/download_widget_strings.xml b/android/app/src/main/res/values-in/download_widget_strings.xml new file mode 100644 index 00000000..053a97b2 --- /dev/null +++ b/android/app/src/main/res/values-in/download_widget_strings.xml @@ -0,0 +1,11 @@ + + Unduhan SpotiFLAC + Progres lagu yang sedang diunduh. + Mengunduh… + Tidak ada unduhan aktif + Semua beres + Mengunduh + Selesai + %1$d%% + %1$d dalam antrean + diff --git a/android/app/src/main/res/values-in/player_widget_strings.xml b/android/app/src/main/res/values-in/player_widget_strings.xml index 05e15135..751e4021 100644 --- a/android/app/src/main/res/values-in/player_widget_strings.xml +++ b/android/app/src/main/res/values-in/player_widget_strings.xml @@ -11,4 +11,6 @@ Gelap Tampilkan nama artis Simpan widget + Pilih tampilan widget ini di layar utama. + Gaya diff --git a/android/app/src/main/res/values-night-v31/colors.xml b/android/app/src/main/res/values-night-v31/colors.xml index 4524607e..1fa3712c 100644 --- a/android/app/src/main/res/values-night-v31/colors.xml +++ b/android/app/src/main/res/values-night-v31/colors.xml @@ -4,4 +4,8 @@ @android:color/system_neutral1_50 @android:color/system_neutral2_200 @android:color/system_accent1_200 + @android:color/system_accent1_800 + @android:color/system_accent1_700 + @android:color/system_accent1_100 + @android:color/system_accent2_700 diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index cd97213c..516f8665 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -1,6 +1,11 @@ - #1C1B1F - #E6E1E5 + #211F26 + #E6E0E9 #CAC4D0 + #D0BCFF + #381E72 + #4F378B + #EADDFF + #4A4458 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index c6e7031f..497e4583 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -6,4 +6,10 @@ + diff --git a/android/app/src/main/res/values-v31/colors.xml b/android/app/src/main/res/values-v31/colors.xml index 769fafd2..206bed0d 100644 --- a/android/app/src/main/res/values-v31/colors.xml +++ b/android/app/src/main/res/values-v31/colors.xml @@ -1,7 +1,11 @@ - @android:color/system_neutral1_10 + @android:color/system_neutral1_50 @android:color/system_neutral1_900 @android:color/system_neutral2_700 @android:color/system_accent1_600 + @android:color/system_accent1_0 + @android:color/system_accent1_100 + @android:color/system_accent1_900 + @android:color/system_accent2_100 diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 74e1334d..1e2f44ec 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,8 +1,13 @@ #000000 - #F3F3F3 - #1C1B1F + + #F7F2FA + #1D1B20 #49454F - #1DB954 - \ No newline at end of file + #6750A4 + #FFFFFF + #EADDFF + #21005D + #E8DEF8 + diff --git a/android/app/src/main/res/values/download_widget_strings.xml b/android/app/src/main/res/values/download_widget_strings.xml new file mode 100644 index 00000000..b5a4e46c --- /dev/null +++ b/android/app/src/main/res/values/download_widget_strings.xml @@ -0,0 +1,11 @@ + + SpotiFLAC Downloads + Progress of the track downloading now. + Downloading… + No active downloads + All caught up + Downloading + Done + %1$d%% + %1$d in queue + diff --git a/android/app/src/main/res/values/player_widget_strings.xml b/android/app/src/main/res/values/player_widget_strings.xml index 6bb8d598..4b038d96 100644 --- a/android/app/src/main/res/values/player_widget_strings.xml +++ b/android/app/src/main/res/values/player_widget_strings.xml @@ -11,4 +11,6 @@ Dark Show artist name Save widget + Choose how this widget looks on your home screen. + Style diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index ff81bae8..66aa7a82 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -6,4 +6,11 @@ + + diff --git a/android/app/src/main/res/xml/widget_download_queue_info.xml b/android/app/src/main/res/xml/widget_download_queue_info.xml index b4310db8..474d1153 100644 --- a/android/app/src/main/res/xml/widget_download_queue_info.xml +++ b/android/app/src/main/res/xml/widget_download_queue_info.xml @@ -1,8 +1,10 @@