mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-01 05:39:34 +02:00
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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -19,7 +19,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
SpotiFLACBackend: e0c81656e8e4c31e86b9f14bbb8e245fb3a14011
|
||||
|
||||
PODFILE CHECKSUM: 6ca58d78915f3e4fcb64938b9167b26e33d44a8a
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user