feat(widgets): add native Android and iOS home screen players

Publish artwork, metadata, and transport state through a shared Dart bridge. Add configurable Android RemoteViews and iOS WidgetKit players with controls and links into the full player.

Serialize widget commands and session restoration, and share one headless-capable iOS Flutter engine with the foreground scene. Include native integration tests, Dart bridge tests, and widget setup documentation.
This commit is contained in:
zarzet committed 2026-09-25 01:22:58 +07:00
1 parent 64445a09fc
commit 040c328479
34 files changed
+1601 -16

No files matched your search

@@ -0,0 +1,97 @@
package com.zarz.spotiflac
import android.Manifest
import android.appwidget.AppWidgetHost
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/** Applies real RemoteViews through an Android widget host, including resizing. */
@RunWith(AndroidJUnit4::class)
class PlayerWidgetTest {
@Test
fun launcherRendersEverySizeAndAppearance() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val context = instrumentation.targetContext
val manager = AppWidgetManager.getInstance(context)
val prefs = PlayerWidgetProvider.preferences(context)
val previousState = prefs.getString("state", null)
val title = "Widget rendering test"
val state = JSONObject().apply {
put("title", title)
put("artist", "Test artist")
put("canPlay", true)
put("playing", true)
put("canNext", true)
put("canPrevious", false)
put("background", 0xff6b3945L)
}
prefs.edit().putString("state", state.toString()).commit()
instrumentation.uiAutomation.adoptShellPermissionIdentity(Manifest.permission.BIND_APPWIDGET)
try {
instrumentation.runOnMainSync {
val host = AppWidgetHost(context, 595)
val id = host.allocateAppWidgetId()
try {
assertTrue(manager.bindAppWidgetIdIfAllowed(
id, ComponentName(context, PlayerWidgetProvider::class.java),
))
val info = manager.getAppWidgetInfo(id)
assertNotNull(info)
for ((width, height) in listOf(140 to 156, 300 to 160, 320 to 350)) {
manager.updateAppWidgetOptions(id, Bundle().apply {
putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, width)
putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, width)
putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, height)
putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, height)
})
for (theme in listOf("artwork", "light", "dark")) {
prefs.edit().putString("theme_$id", theme).commit()
PlayerWidgetProvider.update(context, intArrayOf(id))
val view = host.createView(context, id, info)
val titleView = view.findViewById<TextView>(R.id.player_widget_title)
assertNotNull("RemoteViews failed at $width x $height, $theme", titleView)
assertEquals(title, titleView.text.toString())
assertTrue(view.findViewById<android.view.View>(R.id.player_widget_play).isEnabled)
assertTrue(!view.findViewById<android.view.View>(R.id.player_widget_previous).isEnabled)
val density = context.resources.displayMetrics.density
val pixelsWide = (width * density).toInt()
val pixelsHigh = (height * density).toInt()
view.measure(
View.MeasureSpec.makeMeasureSpec(pixelsWide, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(pixelsHigh, View.MeasureSpec.EXACTLY),
)
view.layout(0, 0, pixelsWide, pixelsHigh)
val bitmap = Bitmap.createBitmap(pixelsWide, pixelsHigh, Bitmap.Config.ARGB_8888)
view.draw(Canvas(bitmap))
File(context.cacheDir, "player-widget-$width-$height-$theme.png").outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
}
bitmap.recycle()
}
}
} finally {
host.deleteHost()
prefs.edit().remove("theme_$id").remove("artist_$id").commit()
}
}
} finally {
instrumentation.uiAutomation.dropShellPermissionIdentity()
prefs.edit().putString("state", previousState).commit()
PlayerWidgetProvider.update(context)
}
}
}
+21
View File
@@ -121,6 +121,27 @@
</intent-filter>
</activity>
<!-- Home screen player -->
<receiver
android:name=".PlayerWidgetProvider"
android:label="@string/player_widget_name"
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_player_info" />
</receiver>
<activity
android:name=".PlayerWidgetConfigurationActivity"
android:exported="true"
android:theme="@android:style/Theme.DeviceDefault.Dialog">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<!-- Download Service -->
<service
android:name=".DownloadService"
@@ -782,6 +782,7 @@ class MainActivity: FlutterFragmentActivity() {
super.onCreate(savedInstanceState)
handleVerificationNotificationIntent(intent)
handleExtensionOAuthIntent(intent)
PlayerWidgetBridge.handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
@@ -789,6 +790,7 @@ class MainActivity: FlutterFragmentActivity() {
setIntent(intent)
handleVerificationNotificationIntent(intent)
handleExtensionOAuthIntent(intent)
PlayerWidgetBridge.handleIntent(intent)
}
private fun handleVerificationNotificationIntent(intent: Intent?) {
@@ -950,6 +952,7 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
PlayerWidgetBridge.attach(this, flutterEngine)
com.zarz.spotiflac.discord.DiscordPresenceBridge.attach(this, flutterEngine.dartExecutor.binaryMessenger)
// Select and initialize the runtime before Flutter can dispatch a call.
val selectedBackend = coreBackend
@@ -0,0 +1,54 @@
package com.zarz.spotiflac
import android.app.Activity
import android.appwidget.AppWidgetManager
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.CheckBox
import android.widget.LinearLayout
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
class PlayerWidgetConfigurationActivity : Activity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
setResult(RESULT_CANCELED)
val 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()
}
})
setContentView(layout)
}
}
@@ -0,0 +1,267 @@
package com.zarz.spotiflac
import android.app.ActivityOptions
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.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.os.Bundle
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import org.json.JSONObject
import java.io.File
import java.util.concurrent.Executors
/** Native launcher rendering; audio remains in the app's shared Flutter engine. */
class PlayerWidgetProvider : AppWidgetProvider() {
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
update(context, ids)
}
override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, id: Int, options: Bundle) {
update(context, intArrayOf(id))
}
override fun onDeleted(context: Context, ids: IntArray) {
val editor = preferences(context).edit()
ids.forEach { editor.remove("theme_$it").remove("artist_$it") }
editor.apply()
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action != ACTION) return
val command = intent.getStringExtra(COMMAND) ?: return
if (command !in setOf("toggle", "next", "previous")) return
val pending = goAsync()
if (!PlayerWidgetBridge.command(command) { pending.finish() }) {
// A killed process has no audio engine. A widget click may launch
// the activity to restore the queue and then execute this command.
val options = if (Build.VERSION.SDK_INT >= 34) {
ActivityOptions.makeBasic().apply {
// Preserve the launch privilege from this widget tap when
// the sender is a receiver rather than a visible activity.
setPendingIntentBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED,
)
}.toBundle()
} else null
try {
launchIntent(context, command).send(context, 0, null, null, null, null, options)
} catch (error: PendingIntent.CanceledException) {
Log.w("PlayerWidget", "Widget launch was cancelled", error)
} finally {
pending.finish()
}
}
}
companion object {
const val ACTION = "com.zarz.spotiflac.PLAYER_WIDGET_ACTION"
const val COMMAND = "player_widget_command"
fun preferences(context: Context) = context.getSharedPreferences("player_widget", Context.MODE_PRIVATE)
fun launchIntent(context: Context, command: String): PendingIntent = PendingIntent.getActivity(
context, 8100 + command.hashCode(),
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(COMMAND, command)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
fun update(context: Context, ids: IntArray? = null) {
val manager = AppWidgetManager.getInstance(context)
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
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)
}
cover?.recycle()
}
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 {
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)
}
Canvas(bitmap).drawRoundRect(0f, 0f, w.toFloat(), h.toFloat(), 26f, 26f, paint)
return bitmap
}
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
}
}
}
object PlayerWidgetBridge : FlutterEngine.EngineLifecycleListener {
private var engine: FlutterEngine? = null
private var channel: MethodChannel? = null
private var ready = false
private var pendingCommand: String? = null
private val worker = Executors.newSingleThreadExecutor()
private val main = Handler(Looper.getMainLooper())
fun attach(context: Context, flutterEngine: FlutterEngine) {
if (engine !== flutterEngine) {
engine?.removeEngineLifecycleListener(this)
engine = flutterEngine
flutterEngine.addEngineLifecycleListener(this)
ready = false
}
val app = context.applicationContext
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.zarz.spotiflac/player_widget").also { bridge ->
bridge.setMethodCallHandler { call, result ->
when (call.method) {
"ready" -> {
ready = true
result.success(null)
pendingCommand?.let { command(it) {} }
pendingCommand = null
}
"update" -> {
val data = call.arguments as? Map<*, *>
if (data == null) { result.error("bad_state", "Missing widget state", null); return@setMethodCallHandler }
worker.execute {
try {
val prefs = PlayerWidgetProvider.preferences(app)
val bytes = data["artwork"] as? ByteArray
val old = JSONObject(prefs.getString("state", "{}")!!)
if (bytes != null && old.optString("artworkKey") != data["artworkKey"]) {
val temp = File(app.filesDir, "player-widget-cover.tmp")
temp.writeBytes(bytes)
check(temp.renameTo(File(app.filesDir, "player-widget-cover.png")))
}
val state = JSONObject()
data.forEach { (key, value) -> if (key is String && key != "artwork") state.put(key, value) }
prefs.edit().putString("state", state.toString()).commit()
PlayerWidgetProvider.update(app)
main.post { result.success(null) }
} catch (error: Exception) {
main.post { result.error("widget_update", error.message, null) }
}
}
}
else -> result.notImplemented()
}
}
}
}
override fun onPreEngineRestart() {
ready = false
}
override fun onEngineWillDestroy() {
ready = false
channel = null
pendingCommand = null
engine = null
}
fun handleIntent(intent: Intent?) {
val action = intent?.getStringExtra(PlayerWidgetProvider.COMMAND) ?: return
intent.removeExtra(PlayerWidgetProvider.COMMAND)
if (!command(action) {}) pendingCommand = action
}
fun command(action: String, completion: () -> Unit): Boolean {
val bridge = channel ?: return false
if (!ready) return false
var finished = false
fun finish() { if (!finished) { finished = true; completion() } }
main.postDelayed({ finish() }, 8500)
bridge.invokeMethod("command", action, object : MethodChannel.Result {
override fun success(result: Any?) = finish()
override fun error(code: String, message: String?, details: Any?) = finish()
override fun notImplemented() = finish()
})
return true
}
}
@@ -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="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>
@@ -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="M1,4 L12,12 L1,20 Z M12,4 L23,12 L12,20 Z" />
</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="M5,3 H10 V21 H5 Z M14,3 H19 V21 H14 Z" />
</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="M7,3 L22,12 L7,21 Z" />
</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="M12,4 L1,12 L12,20 Z M23,4 L12,12 L23,20 Z" />
</vector>
@@ -0,0 +1,4 @@
<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,15 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,14 @@
<resources>
<string name="player_widget_name">Player SpotiFLAC</string>
<string name="player_widget_description">Musik, cover, dan kontrol playback. Ubah ukuran atau edit tampilannya.</string>
<string name="player_widget_empty">Pilih musik di aplikasi</string>
<string name="player_widget_play">Putar</string>
<string name="player_widget_pause">Jeda</string>
<string name="player_widget_previous">Lagu sebelumnya</string>
<string name="player_widget_next">Lagu berikutnya</string>
<string name="player_widget_artwork">Warna artwork</string>
<string name="player_widget_light">Terang</string>
<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>
</resources>
@@ -0,0 +1,14 @@
<resources>
<string name="player_widget_name">SpotiFLAC Player</string>
<string name="player_widget_description">Your music, artwork, and playback controls. Resize or edit to customize.</string>
<string name="player_widget_empty">Choose music in the app</string>
<string name="player_widget_play">Play</string>
<string name="player_widget_pause">Pause</string>
<string name="player_widget_previous">Previous track</string>
<string name="player_widget_next">Next track</string>
<string name="player_widget_artwork">Artwork colors</string>
<string name="player_widget_light">Light</string>
<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>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp" android:minHeight="120dp"
android:minResizeWidth="120dp" android:minResizeHeight="120dp"
android:targetCellWidth="4" android:targetCellHeight="2"
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
android:initialLayout="@layout/widget_player_wide"
android:previewLayout="@layout/widget_player_wide"
android:description="@string/player_widget_description"
android:configure="com.zarz.spotiflac.PlayerWidgetConfigurationActivity"
android:widgetFeatures="reconfigurable|configuration_optional"
android:widgetCategory="home_screen" />
+56
View File
@@ -0,0 +1,56 @@
# Home screen player widgets
SpotiFLAC 5.0.1 includes a native home screen player for Android and iOS,
inspired by Apple Music. Widgets share the existing playback queue and audio
handler. They display the current artwork, track, artist, and playback state.
## Adding and customizing
- **Android:** open the launcher's widget picker and add **SpotiFLAC Player**.
Resize it for a compact, horizontal, or large artwork layout. Open the widget's
configuration to choose artwork colors, light, or dark, and toggle the artist
name. Reconfiguration availability depends on the launcher.
- **iOS 17 or later:** add **SpotiFLAC Player** from the home screen widget
gallery. Small includes play/pause; medium and large also include previous
and next. Long-press and choose **Edit Widget** for appearance and artist name.
- Choose a track in the app first. Tap the widget's artwork or text to open
the full player. An empty widget opens the app with transport disabled.
Artwork is a static thumbnail. Updates follow track and transport changes;
there is no continuous timer or animated artwork in the home screen widget.
The operating system controls when widget updates appear.
## Native integration
`PlayerWidgetService` publishes presentation data through
`com.zarz.spotiflac/player_widget`. Artwork loading discards stale results when
tracks change. Commands are serialized and publish the latest state before
acknowledging completion. A restored session starts paused and uses the same
audio handler as the app.
Android uses `RemoteViews` and per-widget preferences. Controls call the active
Flutter engine. If the process has been killed, a control opens the activity to
restore the saved queue and execute the command.
iOS uses WidgetKit with `AudioPlaybackIntent`, shared between Runner and the
widget extension. Runner starts one headless-capable Flutter engine at process
launch; UIScene attaches the player interface to that same engine. This keeps
widget commands available before a foreground scene exists.
`player.json` and thumbnail images live in the App Group
`group.com.zarz.spotiflac`; the widget does not receive audio files.
The extension target is embedded by Runner and inherits the app's build version.
For physical iOS builds, both Runner and PlayerWidget signing profiles must
include `group.com.zarz.spotiflac`. If using another bundle identifier, update
`PLAYER_WIDGET_APP_GROUP` in all Runner configurations and
`ios/PlayerWidget/PlayerWidget.xcconfig`, together with the extension bundle ID.
## Verification
- `flutter test test/player_widget_service_test.dart`
- `flutter analyze lib/services/player_widget_service.dart lib/main.dart`
- Build Runner for an iOS simulator and verify `PlayerWidget.appex` is embedded.
- Build Android and run `PlayerWidgetTest` on an emulator to apply real
`RemoteViews` for all layout sizes and themes.
- On devices, add widgets and check play/pause, skip, opening the player,
cold launch, resizing, appearance, and cover changes while backgrounded.
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>SpotiFLAC Player</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key><string>XPC!</string>
<key>CFBundleShortVersionString</key><string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key><string>$(FLUTTER_BUILD_NUMBER)</string>
<key>PlayerWidgetAppGroup</key><string>$(PLAYER_WIDGET_APP_GROUP)</string>
<key>NSExtension</key><dict>
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
</dict>
</dict></plist>
@@ -0,0 +1,19 @@
import AppIntents
@available(iOS 17.0, *)
struct PlayerControlIntent: AudioPlaybackIntent {
static var title: LocalizedStringResource = "Control music playback"
static var openAppWhenRun = false
@Parameter(title: "Action") var action: String
init() { action = "toggle" }
init(_ action: String) { self.action = action }
func perform() async throws -> some IntentResult {
#if !PLAYER_WIDGET_EXTENSION
_ = await PlayerWidgetBridge.shared.perform(action)
#endif
return .result()
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.application-groups</key><array><string>$(PLAYER_WIDGET_APP_GROUP)</string></array>
</dict></plist>
+160
View File
@@ -0,0 +1,160 @@
import AppIntents
import SwiftUI
import WidgetKit
enum PlayerWidgetAppearance: String, AppEnum {
case artwork, light, dark
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Appearance"
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.artwork: "Artwork colors", .light: "Light", .dark: "Dark",
]
}
struct PlayerWidgetConfiguration: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Player appearance"
static var description = IntentDescription("Choose the colors and details shown in your music widget.")
@Parameter(title: "Appearance", default: .artwork) var appearance: PlayerWidgetAppearance
@Parameter(title: "Show artist name", default: true) var showArtist: Bool
}
struct PlayerEntry: TimelineEntry {
let date: Date
let state: PlayerWidgetState
let configuration: PlayerWidgetConfiguration
}
struct PlayerTimelineProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> PlayerEntry {
PlayerEntry(date: .now, state: .preview, configuration: PlayerWidgetConfiguration())
}
func snapshot(for configuration: PlayerWidgetConfiguration, in context: Context) async -> PlayerEntry {
PlayerEntry(date: .now, state: context.isPreview ? .preview : .read(), configuration: configuration)
}
func timeline(for configuration: PlayerWidgetConfiguration, in context: Context) async -> Timeline<PlayerEntry> {
Timeline(entries: [PlayerEntry(date: .now, state: .read(), configuration: configuration)], policy: .never)
}
}
struct PlayerWidgetView: View {
let entry: PlayerEntry
@Environment(\.widgetFamily) private var family
private var isLight: Bool { entry.configuration.appearance == .light }
private var foreground: Color { isLight ? Color(red: 0.10, green: 0.09, blue: 0.12) : .white }
private var background: Color {
switch entry.configuration.appearance {
case .light: return Color(red: 0.96, green: 0.95, blue: 0.97)
case .dark: return Color(red: 0.11, green: 0.10, blue: 0.13)
case .artwork:
let value = entry.state.background
return Color(red: Double((value >> 16) & 255) / 255,
green: Double((value >> 8) & 255) / 255,
blue: Double(value & 255) / 255)
}
}
var body: some View {
GeometryReader { bounds in
if family == .systemMedium {
HStack(spacing: 16) {
artwork.frame(width: min(116, bounds.size.height), height: min(116, bounds.size.height))
VStack(alignment: .leading, spacing: 8) {
metadata
Spacer(minLength: 0)
controls
}
}
} else if family == .systemLarge {
VStack(alignment: .leading, spacing: 12) {
artwork.frame(maxWidth: .infinity, maxHeight: .infinity)
metadata
controls
}
} else {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
artwork.frame(width: max(44, bounds.size.height - 76), height: max(44, bounds.size.height - 76))
Spacer(minLength: 4)
Image(systemName: "music.note").font(.system(size: 19, weight: .semibold)).opacity(0.8)
}
Spacer(minLength: 0)
HStack(alignment: .bottom, spacing: 6) {
metadata
Spacer(minLength: 0)
control(entry.state.playing ? "pause" : "play", symbol: entry.state.playing ? "pause.fill" : "play.fill", prominent: true, enabled: entry.state.canPlay)
}
}
}
}
.foregroundStyle(foreground)
.containerBackground(for: .widget) {
LinearGradient(colors: [background.opacity(0.88), background], startPoint: .topLeading, endPoint: .bottomTrailing)
}
.widgetURL(URL(string: "spotiflac://player"))
}
private var artwork: some View {
Group {
if let url = entry.state.artworkURL, let data = try? Data(contentsOf: url), let image = UIImage(data: data) {
Image(uiImage: image).resizable().scaledToFit()
} else {
RoundedRectangle(cornerRadius: 12).fill(foreground.opacity(0.08))
.overlay(Image(systemName: "music.note").font(.system(size: 32, weight: .medium)))
}
}
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.accessibilityHidden(true)
}
private var metadata: some View {
VStack(alignment: .leading, spacing: 3) {
Text(entry.state.title.isEmpty ? "SpotiFLAC Player" : entry.state.title)
.font(.system(size: family == .systemSmall ? 14 : 17, weight: .semibold))
.lineLimit(family == .systemMedium ? 2 : 1)
.minimumScaleFactor(0.85)
if entry.configuration.showArtist {
Text(entry.state.canPlay ? entry.state.artist : "Choose music in the app")
.font(.system(size: family == .systemSmall ? 12 : 14))
.foregroundStyle(foreground.opacity(0.7)).lineLimit(1)
}
}
}
private var controls: some View {
HStack {
control("previous", symbol: "backward.fill", enabled: entry.state.canPlay && entry.state.canPrevious)
Spacer(minLength: 4)
control(entry.state.playing ? "pause" : "play", symbol: entry.state.playing ? "pause.fill" : "play.fill", prominent: true, enabled: entry.state.canPlay)
Spacer(minLength: 4)
control("next", symbol: "forward.fill", enabled: entry.state.canPlay && entry.state.canNext)
}
}
private func control(_ action: String, symbol: String, prominent: Bool = false, enabled: Bool) -> some View {
Button(intent: PlayerControlIntent(action == "play" || action == "pause" ? "toggle" : action)) {
Image(systemName: symbol)
.font(.system(size: prominent ? 19 : 20, weight: .semibold))
.frame(width: family == .systemSmall ? 36 : 44, height: family == .systemSmall ? 36 : 44)
.foregroundStyle(prominent ? background : foreground)
.background(prominent ? foreground : .clear, in: Circle())
}
.buttonStyle(.plain)
.disabled(!enabled)
.opacity(enabled ? 1 : 0.4)
.accessibilityLabel(action == "previous" ? "Previous track" : action == "next" ? "Next track" : action == "pause" ? "Pause" : "Play")
}
}
@main
struct SpotiFLACPlayerWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: PlayerWidgetState.kind, intent: PlayerWidgetConfiguration.self, provider: PlayerTimelineProvider()) { entry in
PlayerWidgetView(entry: entry)
}
.configurationDisplayName("SpotiFLAC Player")
.description("Your music, artwork, and playback controls.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
+15
View File
@@ -0,0 +1,15 @@
#include "../Flutter/Generated.xcconfig"
APPLICATION_EXTENSION_API_ONLY = YES
CODE_SIGN_ENTITLEMENTS = PlayerWidget/PlayerWidget.entitlements
CODE_SIGN_STYLE = Automatic
INFOPLIST_FILE = PlayerWidget/Info.plist
IPHONEOS_DEPLOYMENT_TARGET = 17.0
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @executable_path/../../Frameworks
PLAYER_WIDGET_APP_GROUP = group.com.zarz.spotiflac
PRODUCT_BUNDLE_IDENTIFIER = com.zarz.spotiflac.PlayerWidget
PRODUCT_NAME = $(TARGET_NAME)
SKIP_INSTALL = YES
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) PLAYER_WIDGET_EXTENSION
SWIFT_VERSION = 5.0
TARGETED_DEVICE_FAMILY = 1,2
+45
View File
@@ -0,0 +1,45 @@
import Foundation
struct PlayerWidgetState: Codable {
var id = ""
var title = ""
var artist = ""
var album = ""
var playing = false
var canPlay = false
var canPrevious = false
var canNext = false
var background: UInt32 = 0xff292433
var artworkKey = ""
var artworkName = ""
static let kind = "SpotiFLACPlayer"
static var container: URL? {
let group = Bundle.main.object(forInfoDictionaryKey: "PlayerWidgetAppGroup") as? String
?? "group.com.zarz.spotiflac"
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)
}
static func read() -> PlayerWidgetState {
guard let url = container?.appendingPathComponent("player.json"),
let data = try? Data(contentsOf: url),
let state = try? JSONDecoder().decode(Self.self, from: data) else { return Self() }
return state
}
var artworkURL: URL? {
guard !artworkName.isEmpty, !artworkName.contains("/") else { return nil }
return Self.container?.appendingPathComponent(artworkName)
}
static var preview: PlayerWidgetState {
var value = Self()
value.title = "Your favorite music"
value.artist = "SpotiFLAC"
value.album = "Now Playing"
value.canPlay = true
value.canPrevious = true
value.canNext = true
return value
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
SpotiFLACBackend: e0c81656e8e4c31e86b9f14bbb8e245fb3a14011
PODFILE CHECKSUM: 6ca58d78915f3e4fcb64938b9167b26e33d44a8a
+105
View File
@@ -7,6 +7,13 @@
objects = {
/* Begin PBXBuildFile section */
F59500000000000000000011 /* PlayerWidgetState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000001 /* PlayerWidgetState.swift */; };
F59500000000000000000012 /* PlayerControlIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000002 /* PlayerControlIntent.swift */; };
F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000004 /* PlayerWidgetBridge.swift */; };
F59500000000000000000014 /* PlayerWidgetState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000001 /* PlayerWidgetState.swift */; };
F59500000000000000000015 /* PlayerControlIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000002 /* PlayerControlIntent.swift */; };
F59500000000000000000016 /* PlayerWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59500000000000000000003 /* PlayerWidget.swift */; };
F59500000000000000000017 /* PlayerWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = F59500000000000000000008 /* PlayerWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
@@ -23,6 +30,13 @@
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
F59500000000000000000052 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = F59500000000000000000031;
remoteInfo = PlayerWidget;
};
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
@@ -33,6 +47,15 @@
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
F59500000000000000000024 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (F59500000000000000000017 /* PlayerWidget.appex in Embed App Extensions */, );
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -46,6 +69,15 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
F59500000000000000000009 /* PlayerWidget.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PlayerWidget.xcconfig; sourceTree = "<group>"; };
F59500000000000000000001 /* PlayerWidgetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetState.swift; sourceTree = "<group>"; };
F59500000000000000000002 /* PlayerControlIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlIntent.swift; sourceTree = "<group>"; };
F59500000000000000000003 /* PlayerWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidget.swift; sourceTree = "<group>"; };
F59500000000000000000004 /* PlayerWidgetBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetBridge.swift; sourceTree = "<group>"; };
F59500000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
F59500000000000000000006 /* PlayerWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PlayerWidget.entitlements; sourceTree = "<group>"; };
F59500000000000000000007 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
F59500000000000000000008 /* PlayerWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; path = PlayerWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
@@ -76,6 +108,7 @@
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
F59500000000000000000022 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -96,6 +129,19 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
F59500000000000000000061 /* PlayerWidget */ = {
isa = PBXGroup;
children = (
F59500000000000000000001 /* PlayerWidgetState.swift */,
F59500000000000000000002 /* PlayerControlIntent.swift */,
F59500000000000000000003 /* PlayerWidget.swift */,
F59500000000000000000005 /* Info.plist */,
F59500000000000000000006 /* PlayerWidget.entitlements */,
F59500000000000000000009 /* PlayerWidget.xcconfig */,
);
path = PlayerWidget;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
@@ -135,6 +181,7 @@
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
F59500000000000000000061 /* PlayerWidget */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
3824DF5478555AFB009D7C8C /* Pods */,
@@ -146,6 +193,7 @@
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
F59500000000000000000008 /* PlayerWidget.appex */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
@@ -161,6 +209,8 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
F59500000000000000000004 /* PlayerWidgetBridge.swift */,
F59500000000000000000007 /* Runner.entitlements */,
C03400000000000000000002 /* CoreBackend.swift */,
D08A594730CF000100000002 /* DownloadProgressSubscription.swift */,
A11CE0022F00000000000001 /* ExtensionCallbackParser.swift */,
@@ -181,6 +231,17 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
F59500000000000000000031 /* PlayerWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = F59500000000000000000044 /* Build configuration list for PBXNativeTarget "PlayerWidget" */;
buildPhases = (F59500000000000000000021 /* Sources */, F59500000000000000000022 /* Frameworks */, F59500000000000000000023 /* Resources */, );
buildRules = ();
dependencies = ();
name = PlayerWidget;
productName = PlayerWidget;
productReference = F59500000000000000000008 /* PlayerWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
@@ -210,12 +271,14 @@
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
F59500000000000000000024 /* Embed App Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
936893445B11DF44026A1E38 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
F59500000000000000000051 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
@@ -263,11 +326,13 @@
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
F59500000000000000000031 /* PlayerWidget */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
F59500000000000000000023 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -384,6 +449,12 @@
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
F59500000000000000000021 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (F59500000000000000000014 /* PlayerWidgetState.swift in Sources */, F59500000000000000000015 /* PlayerControlIntent.swift in Sources */, F59500000000000000000016 /* PlayerWidget.swift in Sources */, );
runOnlyForDeploymentPostprocessing = 0;
};
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -397,6 +468,9 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
F59500000000000000000011 /* PlayerWidgetState.swift in Sources */,
F59500000000000000000012 /* PlayerControlIntent.swift in Sources */,
F59500000000000000000013 /* PlayerWidgetBridge.swift in Sources */,
C03400000000000000000001 /* CoreBackend.swift in Sources */,
D08A594730CF000100000001 /* DownloadProgressSubscription.swift in Sources */,
A11CE0012F00000000000001 /* ExtensionCallbackParser.swift in Sources */,
@@ -407,6 +481,7 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
F59500000000000000000051 /* PBXTargetDependency */ = {isa = PBXTargetDependency; target = F59500000000000000000031 /* PlayerWidget */; targetProxy = F59500000000000000000052 /* PBXContainerItemProxy */; };
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
@@ -434,6 +509,24 @@
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
F59500000000000000000041 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F59500000000000000000009 /* PlayerWidget.xcconfig */;
buildSettings = {SWIFT_OPTIMIZATION_LEVEL = "-Onone"; };
name = Debug;
};
F59500000000000000000042 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F59500000000000000000009 /* PlayerWidget.xcconfig */;
buildSettings = {};
name = Release;
};
F59500000000000000000043 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F59500000000000000000009 /* PlayerWidget.xcconfig */;
buildSettings = {};
name = Profile;
};
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -490,6 +583,8 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PLAYER_WIDGET_APP_GROUP = group.com.zarz.spotiflac;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -672,6 +767,8 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PLAYER_WIDGET_APP_GROUP = group.com.zarz.spotiflac;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -694,6 +791,8 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
PLAYER_WIDGET_APP_GROUP = group.com.zarz.spotiflac;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -714,6 +813,12 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
F59500000000000000000044 /* Build configuration list for PBXNativeTarget "PlayerWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (F59500000000000000000041 /* Debug */, F59500000000000000000042 /* Release */, F59500000000000000000043 /* Profile */, );
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
+21 -10
View File
@@ -4,7 +4,10 @@ import UIKit
import UniformTypeIdentifiers
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@objc class AppDelegate: FlutterAppDelegate {
// Widget playback intents can launch the process before any scene exists.
// Keep one headless-capable engine and attach that same player to the UI.
lazy var playerEngine = FlutterEngine(name: "spotiflac-player", project: nil, allowHeadlessExecution: true)
private let CHANNEL = "com.zarz.spotiflac/backend"
private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream"
private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream"
@@ -43,18 +46,16 @@ import UniformTypeIdentifiers
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// With the UIScene lifecycle (required from iOS 27) there is no root
// view controller yet: channels and plugins are registered once the
// storyboard's implicit engine exists, in
// didInitializeImplicitFlutterEngine below.
playerEngine.run()
configureFlutterEngine(playerEngine.binaryMessenger, registry: playerEngine)
if let url = launchOptions?[.url] as? URL {
_ = handleExtensionOAuthRedirect(url: url)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
let messenger = engineBridge.applicationRegistrar.messenger()
private func configureFlutterEngine(_ messenger: FlutterBinaryMessenger, registry: FlutterPluginRegistry) {
PlayerWidgetBridge.shared.attach(messenger)
let channel = FlutterMethodChannel(
name: CHANNEL,
binaryMessenger: messenger
@@ -103,8 +104,8 @@ import UniformTypeIdentifiers
}
)
)
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
GeneratedPluginRegistrant.register(with: registry)
}
/// The window of the foreground scene. Under the UIScene lifecycle the
@@ -120,6 +121,10 @@ import UniformTypeIdentifiers
/// - Signed session: spotiflac://session-grant?grant=...&state=<one_time_nonce>
@discardableResult
func handleExtensionOAuthRedirect(url: URL) -> Bool {
if url.scheme == "spotiflac", url.host == "player" {
Task { _ = await PlayerWidgetBridge.shared.perform("open") }
return true
}
guard let route = ExtensionCallbackParser.parse(url) else { return false }
streamQueue.async {
var extensionId = ""
@@ -848,7 +853,7 @@ private final class ClosureStreamHandler: NSObject, FlutterStreamHandler {
}
/// UIScene lifecycle entry point (required from iOS 27). FlutterSceneDelegate
/// hosts the storyboard's FlutterViewController; this subclass forwards what
/// hosts the shared engine's FlutterViewController; this subclass forwards what
/// UIKit now delivers to the scene instead of the app delegate: OAuth
/// redirect URLs and the background/foreground transitions that keep
/// downloads alive.
@@ -862,6 +867,12 @@ class SceneDelegate: FlutterSceneDelegate {
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
if let windowScene = scene as? UIWindowScene, let appDelegate {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = FlutterViewController(engine: appDelegate.playerEngine, nibName: nil, bundle: nil)
self.window = window
window.makeKeyAndVisible()
}
super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts {
_ = appDelegate?.handleExtensionOAuthRedirect(url: context.url)
+2 -4
View File
@@ -41,10 +41,10 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>PlayerWidgetAppGroup</key>
<string>$(PLAYER_WIDGET_APP_GROUP)</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -60,8 +60,6 @@
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
+111
View File
@@ -0,0 +1,111 @@
import Flutter
import Foundation
import WidgetKit
@MainActor
final class PlayerWidgetBridge {
static let shared = PlayerWidgetBridge()
private var channel: FlutterMethodChannel?
private var ready = false
private var pending: [(UUID, String, CheckedContinuation<Bool, Never>)] = []
private let writer = DispatchQueue(label: "com.zarz.spotiflac.player-widget", qos: .utility)
func attach(_ messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(name: "com.zarz.spotiflac/player_widget", binaryMessenger: messenger)
self.channel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { result(nil); return }
switch call.method {
case "ready":
self.ready = true
result(nil)
self.flush()
case "update":
guard let values = call.arguments as? [String: Any] else {
result(FlutterError(code: "bad_state", message: "Missing widget state", details: nil))
return
}
self.writer.async {
do {
try Self.write(values)
WidgetCenter.shared.reloadTimelines(ofKind: PlayerWidgetState.kind)
DispatchQueue.main.async { result(nil) }
} catch {
DispatchQueue.main.async {
result(FlutterError(code: "widget_update", message: error.localizedDescription, details: nil))
}
}
}
default:
result(FlutterMethodNotImplemented)
}
}
}
func perform(_ command: String) async -> Bool {
guard ["play", "pause", "toggle", "previous", "next", "open"].contains(command) else { return false }
return await withCheckedContinuation { continuation in
let id = UUID()
pending.append((id, command, continuation))
flush()
Task { [weak self] in
try? await Task.sleep(nanoseconds: 15_000_000_000)
self?.finish(id, success: false)
}
}
}
private var sent = Set<UUID>()
private func flush() {
guard ready, let channel else { return }
for (id, command, _) in pending where !sent.contains(id) {
sent.insert(id)
channel.invokeMethod("command", arguments: command) { [weak self] result in
self?.finish(id, success: result as? Bool == true)
}
}
}
private func finish(_ id: UUID, success: Bool) {
guard let index = pending.firstIndex(where: { $0.0 == id }) else { return }
let entry = pending.remove(at: index)
sent.remove(id)
entry.2.resume(returning: success)
}
nonisolated private static func write(_ values: [String: Any]) throws {
guard let directory = PlayerWidgetState.container else {
throw NSError(domain: "PlayerWidget", code: 1, userInfo: [NSLocalizedDescriptionKey: "Widget App Group is unavailable"])
}
let previous = PlayerWidgetState.read()
var state = PlayerWidgetState()
state.id = values["id"] as? String ?? ""
state.title = values["title"] as? String ?? ""
state.artist = values["artist"] as? String ?? ""
state.album = values["album"] as? String ?? ""
state.playing = values["playing"] as? Bool ?? false
state.canPlay = values["canPlay"] as? Bool ?? false
state.canPrevious = values["canPrevious"] as? Bool ?? false
state.canNext = values["canNext"] as? Bool ?? false
state.background = (values["background"] as? NSNumber)?.uint32Value ?? state.background
state.artworkKey = values["artworkKey"] as? String ?? ""
if !state.artworkKey.isEmpty {
if state.artworkKey == previous.artworkKey, previous.artworkURL != nil {
state.artworkName = previous.artworkName
} else if let artwork = values["artwork"] as? FlutterStandardTypedData {
state.artworkName = "cover-\(UUID().uuidString).png"
try artwork.data.write(to: directory.appendingPathComponent(state.artworkName), options: .atomic)
}
}
try JSONEncoder().encode(state).write(to: directory.appendingPathComponent("player.json"), options: .atomic)
// Retain the current and preceding image while an older timeline is
// being rendered. The widget never receives the music files.
for url in (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] {
if url.lastPathComponent.hasPrefix("cover-"), url.pathExtension == "png",
url.lastPathComponent != state.artworkName, url.lastPathComponent != previous.artworkName {
try? FileManager.default.removeItem(at: url)
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.application-groups</key><array><string>$(PLAYER_WIDGET_APP_GROUP)</string></array>
</dict></plist>
+19
View File
@@ -1,8 +1,27 @@
import Foundation
import Flutter
import XCTest
@testable import Runner
class RunnerTests: XCTestCase {
@MainActor
func testPlayerWidgetSharesSceneEngineAndCompletesCommands() async throws {
let delegate = try XCTUnwrap(UIApplication.shared.delegate as? AppDelegate)
let controller = try XCTUnwrap(delegate.activeWindow?.rootViewController as? FlutterViewController)
XCTAssertTrue(controller.engine === delegate.playerEngine)
// Exercise the real native -> Dart -> audio handler -> native path.
// With an empty queue, opening the widget leaves the app on its home
// or onboarding screen and still publishes a valid empty snapshot.
let completed = await PlayerWidgetBridge.shared.perform("open")
XCTAssertTrue(completed)
let container = try XCTUnwrap(PlayerWidgetState.container)
let data = try Data(contentsOf: container.appendingPathComponent("player.json"))
let state = try JSONDecoder().decode(PlayerWidgetState.self, from: data)
XCTAssertFalse(state.canPlay)
XCTAssertFalse(state.playing)
}
private func isSymbolicLink(_ url: URL) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]) else { return false }
return values.isSymbolicLink == true
+22
View File
@@ -22,6 +22,10 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/player_widget_service.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
@@ -87,6 +91,24 @@ void main() {
),
),
);
if (Platform.isAndroid || Platform.isIOS) {
unawaited(
PlayerWidgetService.instance.initialize((command) async {
await restorePersistedPlaybackSession();
final handler = await initMusicPlayer();
if (command == 'open') {
await WidgetsBinding.instance.endOfFrame;
final navigator =
AppNavigationService.rootNavigatorKey.currentState;
if (navigator != null && handler.mediaItem.value != null) {
unawaited(navigator.push<void>(NowPlayingRoute()));
}
} else {
await PlayerWidgetService.control(handler, command);
}
}),
);
}
},
(error, stack) {
_log.e('Uncaught zone error: $error');
+10 -1
View File
@@ -4,6 +4,7 @@ import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/services/player_widget_service.dart';
import 'package:audio_session/audio_session.dart'
show AudioSession, AudioSessionConfiguration, AudioInterruptionType;
import 'package:audioplayers/audioplayers.dart';
@@ -1648,6 +1649,9 @@ Future<MusicPlayerHandler> _doInitMusicPlayer() async {
);
_handler = handler;
DiscordPresenceService.instance.bind(handler);
if (Platform.isAndroid || Platform.isIOS) {
PlayerWidgetService.instance.bind(handler);
}
_handlerReadyController.add(handler);
return handler;
} catch (_) {
@@ -1659,7 +1663,12 @@ Future<MusicPlayerHandler> _doInitMusicPlayer() async {
/// Restores the last persisted playback session (if any) into a freshly
/// initialized handler, paused. Entries whose plain file paths no longer
/// exist are dropped; content URIs are kept and fail gracefully at play time.
Future<void> restorePersistedPlaybackSession() async {
Future<void>? _restoreSessionFuture;
Future<void> restorePersistedPlaybackSession() =>
_restoreSessionFuture ??= _restorePersistedPlaybackSession();
Future<void> _restorePersistedPlaybackSession() async {
try {
final session = await AppStateDatabase.instance.getPlaybackSession();
if (session == null) return;
+244
View File
@@ -0,0 +1,244 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/utils/logger.dart';
class PlayerWidgetArtwork {
const PlayerWidgetArtwork(this.png, this.background);
final Uint8List png;
final int background;
}
/// Shares only presentation data with the launcher. Playback stays owned by
/// the existing audio handler, including when a widget sends a command.
class PlayerWidgetService {
PlayerWidgetService({
MethodChannel channel = const MethodChannel(
'com.zarz.spotiflac/player_widget',
),
Future<PlayerWidgetArtwork?> Function(Uri?)? loadArtwork,
}) : _channel = channel,
_loadArtwork = loadArtwork ?? _readArtwork;
static final instance = PlayerWidgetService();
static final _log = AppLogger('PlayerWidget');
final MethodChannel _channel;
final Future<PlayerWidgetArtwork?> Function(Uri?) _loadArtwork;
final _subscriptions = <StreamSubscription<dynamic>>[];
AudioHandler? _handler;
Uri? _artUri;
PlayerWidgetArtwork? _artwork;
String? _lastPayload;
int _artworkGeneration = 0;
Future<void> _writes = Future.value();
Future<void> _commands = Future.value();
bool _initialized = false;
Future<void> initialize(Future<void> Function(String) onCommand) async {
if (_initialized) return;
_initialized = true;
_channel.setMethodCallHandler((call) async {
if (call.method != 'command' || call.arguments is! String) return false;
final command = call.arguments as String;
if (!const {
'play',
'pause',
'toggle',
'next',
'previous',
'open',
}.contains(command)) {
return false;
}
final operation = _commands.then((_) async {
await onCommand(command);
await publish(force: true);
});
_commands = operation.catchError((Object error) {
_log.w('Widget command failed: $error');
});
await operation;
return true;
});
try {
await _channel.invokeMethod<void>('ready');
} on MissingPluginException {
// Desktop and tests do not install the mobile widget bridge.
} on PlatformException catch (error) {
_log.w('Widget bridge unavailable: ${error.code}');
}
}
void bind(AudioHandler handler) {
if (identical(_handler, handler)) return;
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
_subscriptions.clear();
_handler = handler;
_subscriptions.add(
handler.mediaItem.listen((item) {
if (_artUri != item?.artUri) {
_artUri = item?.artUri;
_artwork = null;
final generation = ++_artworkGeneration;
unawaited(_updateArtwork(item?.artUri, generation));
}
unawaited(publish());
}),
);
_subscriptions.add(
handler.playbackState.listen((_) => unawaited(publish())),
);
}
Future<void> _updateArtwork(Uri? uri, int generation) async {
try {
final artwork = await _loadArtwork(uri);
if (generation != _artworkGeneration) return;
_artwork = artwork;
await publish();
} catch (error) {
_log.w('Widget artwork unavailable: $error');
}
}
Future<void> publish({bool force = false}) {
final handler = _handler;
if (handler == null) return Future.value();
final item = handler.mediaItem.value;
final state = handler.playbackState.value;
final payload = <String, Object?>{
'id': item?.id ?? '',
'title': item?.title ?? '',
'artist': item?.artist ?? '',
'album': item?.album ?? '',
'playing': item != null && state.playing,
'canPlay': item != null,
'canPrevious': state.controls.contains(MediaControl.skipToPrevious),
'canNext': state.controls.contains(MediaControl.skipToNext),
'artworkKey': _artwork == null ? '' : _artUri.toString(),
'background': _artwork?.background ?? 0xff292433,
};
final key = jsonEncode(payload);
if (!force && key == _lastPayload) return _writes;
_lastPayload = key;
// No position ticks cross the platform channel. Widgets refresh on a
// track/transport change, not twice a second alongside the player UI.
final artwork = _artwork;
_writes = _writes.then((_) async {
try {
await _channel.invokeMethod<void>('update', {
...payload,
if (artwork != null) 'artwork': artwork.png,
});
} on MissingPluginException {
// Mobile-only capability.
} on PlatformException catch (error) {
_lastPayload = null;
_log.w('Widget update failed: ${error.code}');
}
});
return _writes;
}
static Future<void> control(AudioHandler handler, String command) async {
switch (command) {
case 'play':
await handler.play();
case 'pause':
await handler.pause();
case 'toggle':
if (handler.playbackState.value.playing) {
await handler.pause();
} else {
await handler.play();
}
case 'next':
await handler.skipToNext();
case 'previous':
await handler.skipToPrevious();
}
}
Future<void> dispose() async {
_artworkGeneration++;
for (final subscription in _subscriptions) {
await subscription.cancel();
}
_subscriptions.clear();
_handler = null;
await _writes;
_channel.setMethodCallHandler(null);
}
static Future<PlayerWidgetArtwork?> _readArtwork(Uri? uri) async {
if (uri == null) return null;
final File file;
if (uri.scheme == 'http' || uri.scheme == 'https') {
file = await CoverCacheManager.instance.getSingleFile(uri.toString());
} else if (uri.scheme == 'file' || !uri.hasScheme) {
file = File(uri.scheme == 'file' ? uri.toFilePath() : uri.toString());
} else {
return null;
}
if (!await file.exists() || await file.length() > 20 * 1024 * 1024) {
return null;
}
final codec = await ui.instantiateImageCodec(
await file.readAsBytes(),
targetWidth: 256,
targetHeight: 256,
);
try {
final frame = await codec.getNextFrame();
try {
final rgba = await frame.image.toByteData(
format: ui.ImageByteFormat.rawStraightRgba,
);
final png = await frame.image.toByteData(
format: ui.ImageByteFormat.png,
);
if (rgba == null || png == null) return null;
var red = 0.0;
var green = 0.0;
var blue = 0.0;
var weight = 0.0;
for (var i = 0; i < rgba.lengthInBytes; i += 64) {
final alpha = rgba.getUint8(i + 3) / 255;
red += rgba.getUint8(i) * alpha;
green += rgba.getUint8(i + 1) * alpha;
blue += rgba.getUint8(i + 2) * alpha;
weight += alpha;
}
final average = weight == 0
? const Color(0xff292433)
: Color.fromARGB(
255,
(red / weight).round(),
(green / weight).round(),
(blue / weight).round(),
);
final hsl = HSLColor.fromColor(average);
final background = hsl
.withLightness(hsl.lightness.clamp(0.12, 0.26))
.toColor();
return PlayerWidgetArtwork(
png.buffer.asUint8List(),
background.toARGB32(),
);
} finally {
frame.image.dispose();
}
} finally {
codec.dispose();
}
}
}
+213
View File
@@ -0,0 +1,213 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/player_widget_service.dart';
class _Player extends BaseAudioHandler {
final actions = <String>[];
@override
Future<void> play() async {
actions.add('play');
playbackState.add(playbackState.value.copyWith(playing: true));
}
@override
Future<void> pause() async {
actions.add('pause');
playbackState.add(playbackState.value.copyWith(playing: false));
}
@override
Future<void> skipToNext() async => actions.add('next');
@override
Future<void> skipToPrevious() async => actions.add('previous');
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('test/player_widget');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
late PlayerWidgetService service;
late _Player player;
late List<Map<Object?, Object?>> updates;
late Map<String, Completer<PlayerWidgetArtwork?>> images;
Future<void> settle() async {
for (var i = 0; i < 6; i++) {
await Future<void>.delayed(Duration.zero);
}
}
MediaItem item(String id) => MediaItem(
id: id,
title: 'Track $id',
artist: 'Example Artist',
album: 'Example Album',
artUri: Uri.file('/artwork/$id.png'),
extras: const {'source': '/private/music.flac'},
);
Future<Object?> command(String action) {
final result = Completer<Object?>();
messenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(MethodCall('command', action)),
(data) {
try {
result.complete(channel.codec.decodeEnvelope(data!));
} catch (error) {
result.completeError(error);
}
},
);
return result.future;
}
setUp(() {
updates = [];
images = {};
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'update') {
updates.add(Map<Object?, Object?>.from(call.arguments as Map));
}
return null;
});
player = _Player();
service = PlayerWidgetService(
channel: channel,
loadArtwork: (uri) async => uri == null
? null
: (images[uri.toString()] ??= Completer<PlayerWidgetArtwork?>())
.future,
)..bind(player);
});
tearDown(() async {
await service.dispose();
messenger.setMockMethodCallHandler(channel, null);
});
test(
'publishes track and transport changes without position tick writes',
() async {
player.mediaItem.add(item('one'));
player.playbackState.add(
PlaybackState(
playing: true,
processingState: AudioProcessingState.ready,
controls: const [
MediaControl.skipToPrevious,
MediaControl.pause,
MediaControl.skipToNext,
],
),
);
await settle();
final count = updates.length;
for (var second = 1; second < 20; second++) {
player.playbackState.add(
player.playbackState.value.copyWith(
updatePosition: Duration(seconds: second),
),
);
}
await settle();
expect(updates.length, count);
expect(updates.last['title'], 'Track one');
expect(updates.last['canNext'], isTrue);
expect(updates.last.toString(), isNot(contains('/private/music.flac')));
await player.pause();
await settle();
expect(updates.last['playing'], isFalse);
expect(updates.last['canPlay'], isTrue);
},
);
test(
'late artwork from a previous track cannot replace current artwork',
() async {
player.mediaItem.add(item('one'));
await settle();
player.mediaItem.add(item('two'));
await settle();
images[item('two').artUri.toString()]!.complete(
PlayerWidgetArtwork(Uint8List.fromList([2]), 0xff223344),
);
await settle();
images[item('one').artUri.toString()]!.complete(
PlayerWidgetArtwork(Uint8List.fromList([1]), 0xff443322),
);
await settle();
expect(updates.last['id'], 'two');
expect(updates.last['artwork'], orderedEquals([2]));
expect(updates.last['background'], 0xff223344);
expect(
updates.any(
(update) =>
update['id'] == 'two' && update['background'] == 0xff443322,
),
isFalse,
);
},
);
test(
'clearing playback also removes artwork and disables the player',
() async {
player.mediaItem.add(item('one'));
await settle();
images[item('one').artUri.toString()]!.complete(
PlayerWidgetArtwork(Uint8List.fromList([1]), 0xff223344),
);
await settle();
player.mediaItem.add(null);
await settle();
expect(updates.last['canPlay'], isFalse);
expect(updates.last['title'], isEmpty);
expect(updates.last['artworkKey'], isEmpty);
expect(updates.last['artwork'], isNull);
},
);
test(
'commands use the existing handler and finish after publishing state',
() async {
player.mediaItem.add(item('one'));
await service.initialize(
(action) => PlayerWidgetService.control(player, action),
);
expect(await command('toggle'), isTrue);
expect(updates.last['playing'], isTrue);
expect(await command('toggle'), isTrue);
expect(updates.last['playing'], isFalse);
await command('next');
await command('previous');
expect(player.actions, ['play', 'pause', 'next', 'previous']);
expect(await command('delete'), isFalse);
expect(player.actions.length, 4);
},
);
test(
'rapid widget commands remain in order during player initialization',
() async {
final startup = Completer<void>();
await service.initialize((action) async {
await startup.future;
await PlayerWidgetService.control(player, action);
});
final first = command('play');
final second = command('pause');
await settle();
expect(player.actions, isEmpty);
startup.complete();
await Future.wait([first, second]);
expect(player.actions, ['play', 'pause']);
},
);
}