feat(android): home-screen download queue widget

A RemoteViews widget (no new dependencies) shows the active track,
artist, queue count, and a coarse progress bar, with tap-to-open.
DownloadService pushes updates only on discrete events — track or
status changes and 25% progress steps, never per byte — matching the
battery discipline; the last state persists so launcher refreshes
render after process death. Colors use Material You system tokens on
API 31+ with static light/dark fallbacks below.
This commit is contained in:
zarzet
2026-07-26 19:21:58 +07:00
parent 97b87472fe
commit 183744c41e
10 changed files with 258 additions and 1 deletions
+11
View File
@@ -118,6 +118,17 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".DownloadQueueWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_download_queue_info" />
</receiver>
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
@@ -0,0 +1,108 @@
package com.zarz.spotiflac
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.view.View
import android.widget.RemoteViews
/**
* 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.
* The last pushed state is persisted so launcher-driven refreshes (reboot,
* resize) render without the service running.
*/
class DownloadQueueWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
render(context, appWidgetManager, appWidgetIds)
}
companion object {
private const val PREFS = "download_widget_state"
private const val KEY_RUNNING = "running"
private const val KEY_TITLE = "title"
private const val KEY_SUBTITLE = "subtitle"
private const val KEY_PERCENT = "percent" // -1 = indeterminate
/** Persists the state and re-renders all widget instances. */
fun push(
context: Context,
running: Boolean,
title: String = "",
subtitle: String = "",
percent: Int = -1,
) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(
ComponentName(context, DownloadQueueWidgetProvider::class.java)
)
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_RUNNING, running)
.putString(KEY_TITLE, title)
.putString(KEY_SUBTITLE, subtitle)
.putInt(KEY_PERCENT, percent)
.apply()
if (ids.isEmpty()) return
render(context, manager, ids)
}
private fun render(
context: Context,
manager: AppWidgetManager,
ids: IntArray,
) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
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,
)
)
for (id in ids) {
manager.updateAppWidget(id, views)
}
}
}
}
@@ -288,6 +288,9 @@ class DownloadService : Service() {
private var currentArtistName = ""
private var currentStatus = "preparing"
private 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.
private var widgetSignature = ""
private var lastProgress = 0L
private var lastTotal = 0L
private var nativeWorkerRunId = ""
@@ -507,6 +510,7 @@ class DownloadService : Service() {
} else {
startForeground(NOTIFICATION_ID, notification)
}
pushWidgetState(0, 0)
}
private fun startNativeWorker(requestsJson: String, settingsJson: String) {
@@ -1709,6 +1713,12 @@ class DownloadService : Service() {
nativeWorkerNetworkPaused = false
nativeWorkerJob = null
isRunning = false
widgetSignature = ""
try {
DownloadQueueWidgetProvider.push(this, running = false)
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Widget clear failed: ${e.message}")
}
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -1724,10 +1734,48 @@ class DownloadService : Service() {
private fun updateNotification(progress: Long, total: Long) {
if (!isRunning) return
ensureWakeLock()
val notification = buildNotification(progress, total)
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, notification)
pushWidgetState(progress, total)
}
private fun pushWidgetState(progress: Long, total: Long) {
val percent = if (total > 0) {
((progress * 100) / total).toInt().coerceIn(0, 100)
} else {
-1
}
val bucket = if (percent < 0) -1 else percent / 25
val signature = "$currentTrackName|$currentStatus|$queueCount|$bucket"
if (signature == widgetSignature) return
widgetSignature = signature
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")
}
}
}
try {
DownloadQueueWidgetProvider.push(
this,
running = true,
title = currentTrackName.ifEmpty { "Downloading..." },
subtitle = subtitle,
percent = percent,
)
} catch (e: Exception) {
android.util.Log.w("DownloadService", "Widget update failed: ${e.message}")
}
}
private fun buildNotification(progress: Long, total: Long): Notification {
@@ -0,0 +1,6 @@
<?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>
@@ -0,0 +1,52 @@
<?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" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widgetBackground">@android:color/system_neutral1_900</color>
<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>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widgetBackground">#1C1B1F</color>
<color name="widgetTextPrimary">#E6E1E5</color>
<color name="widgetTextSecondary">#CAC4D0</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widgetBackground">@android:color/system_neutral1_10</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>
</resources>
@@ -1,4 +1,8 @@
<?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>
<color name="widgetTextSecondary">#49454F</color>
<color name="widgetAccent">#1DB954</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_download_queue"
android:minWidth="250dp"
android:minHeight="40dp"
android:resizeMode="horizontal"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />