mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
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:
@@ -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" />
|
||||
Reference in New Issue
Block a user