From 040c3284799b963eed853d884d2d5860992bfcff Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:22:58 +0700 Subject: [PATCH] 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. --- .../com/zarz/spotiflac/PlayerWidgetTest.kt | 97 +++++++ android/app/src/main/AndroidManifest.xml | 21 ++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 3 + .../PlayerWidgetConfigurationActivity.kt | 54 ++++ .../zarz/spotiflac/PlayerWidgetProvider.kt | 267 ++++++++++++++++++ .../src/main/res/drawable/ic_widget_music.xml | 3 + .../src/main/res/drawable/ic_widget_next.xml | 3 + .../src/main/res/drawable/ic_widget_pause.xml | 3 + .../src/main/res/drawable/ic_widget_play.xml | 3 + .../main/res/drawable/ic_widget_previous.xml | 3 + .../res/drawable/widget_player_background.xml | 4 + .../app/src/main/res/layout/widget_player.xml | 15 + .../main/res/layout/widget_player_wide.xml | 17 ++ .../res/values-in/player_widget_strings.xml | 14 + .../main/res/values/player_widget_strings.xml | 14 + .../src/main/res/xml/widget_player_info.xml | 12 + docs/player_widgets.md | 56 ++++ ios/PlayerWidget/Info.plist | 15 + ios/PlayerWidget/PlayerControlIntent.swift | 19 ++ ios/PlayerWidget/PlayerWidget.entitlements | 5 + ios/PlayerWidget/PlayerWidget.swift | 160 +++++++++++ ios/PlayerWidget/PlayerWidget.xcconfig | 15 + ios/PlayerWidget/PlayerWidgetState.swift | 45 +++ ios/Podfile.lock | 2 +- ios/Runner.xcodeproj/project.pbxproj | 105 +++++++ ios/Runner/AppDelegate.swift | 31 +- ios/Runner/Info.plist | 6 +- ios/Runner/PlayerWidgetBridge.swift | 111 ++++++++ ios/Runner/Runner.entitlements | 5 + ios/RunnerTests/RunnerTests.swift | 19 ++ lib/main.dart | 22 ++ lib/services/music_player_service.dart | 11 +- lib/services/player_widget_service.dart | 244 ++++++++++++++++ test/player_widget_service_test.dart | 213 ++++++++++++++ 34 files changed, 1601 insertions(+), 16 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/zarz/spotiflac/PlayerWidgetTest.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetConfigurationActivity.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt create mode 100644 android/app/src/main/res/drawable/ic_widget_music.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_next.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_pause.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_play.xml create mode 100644 android/app/src/main/res/drawable/ic_widget_previous.xml create mode 100644 android/app/src/main/res/drawable/widget_player_background.xml create mode 100644 android/app/src/main/res/layout/widget_player.xml create mode 100644 android/app/src/main/res/layout/widget_player_wide.xml create mode 100644 android/app/src/main/res/values-in/player_widget_strings.xml create mode 100644 android/app/src/main/res/values/player_widget_strings.xml create mode 100644 android/app/src/main/res/xml/widget_player_info.xml create mode 100644 docs/player_widgets.md create mode 100644 ios/PlayerWidget/Info.plist create mode 100644 ios/PlayerWidget/PlayerControlIntent.swift create mode 100644 ios/PlayerWidget/PlayerWidget.entitlements create mode 100644 ios/PlayerWidget/PlayerWidget.swift create mode 100644 ios/PlayerWidget/PlayerWidget.xcconfig create mode 100644 ios/PlayerWidget/PlayerWidgetState.swift create mode 100644 ios/Runner/PlayerWidgetBridge.swift create mode 100644 ios/Runner/Runner.entitlements create mode 100644 lib/services/player_widget_service.dart create mode 100644 test/player_widget_service_test.dart diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/PlayerWidgetTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/PlayerWidgetTest.kt new file mode 100644 index 00000000..fd75cfe0 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/PlayerWidgetTest.kt @@ -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(R.id.player_widget_title) + assertNotNull("RemoteViews failed at $width x $height, $theme", titleView) + assertEquals(title, titleView.text.toString()) + assertTrue(view.findViewById(R.id.player_widget_play).isEnabled) + assertTrue(!view.findViewById(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) + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ff38d69b..956eaf1a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -121,6 +121,27 @@ + + + + + + + + + + + + + + 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) + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt new file mode 100644 index 00000000..abe55e3d --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt @@ -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 + } +} diff --git a/android/app/src/main/res/drawable/ic_widget_music.xml b/android/app/src/main/res/drawable/ic_widget_music.xml new file mode 100644 index 00000000..8dc916e1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_music.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_next.xml b/android/app/src/main/res/drawable/ic_widget_next.xml new file mode 100644 index 00000000..0367d847 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_next.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_pause.xml b/android/app/src/main/res/drawable/ic_widget_pause.xml new file mode 100644 index 00000000..28fe8cf5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_pause.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_play.xml b/android/app/src/main/res/drawable/ic_widget_play.xml new file mode 100644 index 00000000..4f7a649b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_play.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_widget_previous.xml b/android/app/src/main/res/drawable/ic_widget_previous.xml new file mode 100644 index 00000000..6007c856 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_widget_previous.xml @@ -0,0 +1,3 @@ + + + diff --git a/android/app/src/main/res/drawable/widget_player_background.xml b/android/app/src/main/res/drawable/widget_player_background.xml new file mode 100644 index 00000000..4b149e89 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_player_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/layout/widget_player.xml b/android/app/src/main/res/layout/widget_player.xml new file mode 100644 index 00000000..3813de0d --- /dev/null +++ b/android/app/src/main/res/layout/widget_player.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_player_wide.xml b/android/app/src/main/res/layout/widget_player_wide.xml new file mode 100644 index 00000000..a11dcba9 --- /dev/null +++ b/android/app/src/main/res/layout/widget_player_wide.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-in/player_widget_strings.xml b/android/app/src/main/res/values-in/player_widget_strings.xml new file mode 100644 index 00000000..05e15135 --- /dev/null +++ b/android/app/src/main/res/values-in/player_widget_strings.xml @@ -0,0 +1,14 @@ + + Player SpotiFLAC + Musik, cover, dan kontrol playback. Ubah ukuran atau edit tampilannya. + Pilih musik di aplikasi + Putar + Jeda + Lagu sebelumnya + Lagu berikutnya + Warna artwork + Terang + Gelap + Tampilkan nama artis + Simpan widget + diff --git a/android/app/src/main/res/values/player_widget_strings.xml b/android/app/src/main/res/values/player_widget_strings.xml new file mode 100644 index 00000000..6bb8d598 --- /dev/null +++ b/android/app/src/main/res/values/player_widget_strings.xml @@ -0,0 +1,14 @@ + + SpotiFLAC Player + Your music, artwork, and playback controls. Resize or edit to customize. + Choose music in the app + Play + Pause + Previous track + Next track + Artwork colors + Light + Dark + Show artist name + Save widget + diff --git a/android/app/src/main/res/xml/widget_player_info.xml b/android/app/src/main/res/xml/widget_player_info.xml new file mode 100644 index 00000000..eab2b737 --- /dev/null +++ b/android/app/src/main/res/xml/widget_player_info.xml @@ -0,0 +1,12 @@ + + diff --git a/docs/player_widgets.md b/docs/player_widgets.md new file mode 100644 index 00000000..8882e1aa --- /dev/null +++ b/docs/player_widgets.md @@ -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. diff --git a/ios/PlayerWidget/Info.plist b/ios/PlayerWidget/Info.plist new file mode 100644 index 00000000..3f9d4e49 --- /dev/null +++ b/ios/PlayerWidget/Info.plist @@ -0,0 +1,15 @@ + + + + CFBundleDisplayNameSpotiFLAC Player + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable$(EXECUTABLE_NAME) + CFBundleName$(PRODUCT_NAME) + CFBundlePackageTypeXPC! + CFBundleShortVersionString$(FLUTTER_BUILD_NAME) + CFBundleVersion$(FLUTTER_BUILD_NUMBER) + PlayerWidgetAppGroup$(PLAYER_WIDGET_APP_GROUP) + NSExtension + NSExtensionPointIdentifiercom.apple.widgetkit-extension + + diff --git a/ios/PlayerWidget/PlayerControlIntent.swift b/ios/PlayerWidget/PlayerControlIntent.swift new file mode 100644 index 00000000..5b84fa1f --- /dev/null +++ b/ios/PlayerWidget/PlayerControlIntent.swift @@ -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() + } +} diff --git a/ios/PlayerWidget/PlayerWidget.entitlements b/ios/PlayerWidget/PlayerWidget.entitlements new file mode 100644 index 00000000..fa64760e --- /dev/null +++ b/ios/PlayerWidget/PlayerWidget.entitlements @@ -0,0 +1,5 @@ + + + + com.apple.security.application-groups$(PLAYER_WIDGET_APP_GROUP) + diff --git a/ios/PlayerWidget/PlayerWidget.swift b/ios/PlayerWidget/PlayerWidget.swift new file mode 100644 index 00000000..8209937b --- /dev/null +++ b/ios/PlayerWidget/PlayerWidget.swift @@ -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 { + 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]) + } +} diff --git a/ios/PlayerWidget/PlayerWidget.xcconfig b/ios/PlayerWidget/PlayerWidget.xcconfig new file mode 100644 index 00000000..0785488d --- /dev/null +++ b/ios/PlayerWidget/PlayerWidget.xcconfig @@ -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 diff --git a/ios/PlayerWidget/PlayerWidgetState.swift b/ios/PlayerWidget/PlayerWidgetState.swift new file mode 100644 index 00000000..74f18ea9 --- /dev/null +++ b/ios/PlayerWidget/PlayerWidgetState.swift @@ -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 + } +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c5d12707..7e853beb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -19,7 +19,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 - open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 + open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4 SpotiFLACBackend: e0c81656e8e4c31e86b9f14bbb8e245fb3a14011 PODFILE CHECKSUM: 6ca58d78915f3e4fcb64938b9167b26e33d44a8a diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1b81b7d8..a7e970ad 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + F59500000000000000000001 /* PlayerWidgetState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetState.swift; sourceTree = ""; }; + F59500000000000000000002 /* PlayerControlIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlIntent.swift; sourceTree = ""; }; + F59500000000000000000003 /* PlayerWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidget.swift; sourceTree = ""; }; + F59500000000000000000004 /* PlayerWidgetBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerWidgetBridge.swift; sourceTree = ""; }; + F59500000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F59500000000000000000006 /* PlayerWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PlayerWidget.entitlements; sourceTree = ""; }; + F59500000000000000000007 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 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 = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; @@ -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 = ""; + }; 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 = ( diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index dd7cdcb8..382cc34a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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= @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) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4c1dc88f..a8248e32 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -41,10 +41,10 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + PlayerWidgetAppGroup + $(PLAYER_WIDGET_APP_GROUP) UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -60,8 +60,6 @@ flutter UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main diff --git a/ios/Runner/PlayerWidgetBridge.swift b/ios/Runner/PlayerWidgetBridge.swift new file mode 100644 index 00000000..3c2ca9b6 --- /dev/null +++ b/ios/Runner/PlayerWidgetBridge.swift @@ -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)] = [] + 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() + + 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) + } + } + } +} diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..fa64760e --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,5 @@ + + + + com.apple.security.application-groups$(PLAYER_WIDGET_APP_GROUP) + diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index a898da65..4a1482e3 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -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 diff --git a/lib/main.dart b/lib/main.dart index 4c133297..94cf45dd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(NowPlayingRoute())); + } + } else { + await PlayerWidgetService.control(handler, command); + } + }), + ); + } }, (error, stack) { _log.e('Uncaught zone error: $error'); diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index cbbe93b6..ef884754 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -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 _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 _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 restorePersistedPlaybackSession() async { +Future? _restoreSessionFuture; + +Future restorePersistedPlaybackSession() => + _restoreSessionFuture ??= _restorePersistedPlaybackSession(); + +Future _restorePersistedPlaybackSession() async { try { final session = await AppStateDatabase.instance.getPlaybackSession(); if (session == null) return; diff --git a/lib/services/player_widget_service.dart b/lib/services/player_widget_service.dart new file mode 100644 index 00000000..6a83731d --- /dev/null +++ b/lib/services/player_widget_service.dart @@ -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 Function(Uri?)? loadArtwork, + }) : _channel = channel, + _loadArtwork = loadArtwork ?? _readArtwork; + + static final instance = PlayerWidgetService(); + static final _log = AppLogger('PlayerWidget'); + final MethodChannel _channel; + final Future Function(Uri?) _loadArtwork; + final _subscriptions = >[]; + AudioHandler? _handler; + Uri? _artUri; + PlayerWidgetArtwork? _artwork; + String? _lastPayload; + int _artworkGeneration = 0; + Future _writes = Future.value(); + Future _commands = Future.value(); + bool _initialized = false; + + Future initialize(Future 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('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 _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 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 = { + '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('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 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 dispose() async { + _artworkGeneration++; + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + _subscriptions.clear(); + _handler = null; + await _writes; + _channel.setMethodCallHandler(null); + } + + static Future _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(); + } + } +} diff --git a/test/player_widget_service_test.dart b/test/player_widget_service_test.dart new file mode 100644 index 00000000..d2f6949c --- /dev/null +++ b/test/player_widget_service_test.dart @@ -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 = []; + + @override + Future play() async { + actions.add('play'); + playbackState.add(playbackState.value.copyWith(playing: true)); + } + + @override + Future pause() async { + actions.add('pause'); + playbackState.add(playbackState.value.copyWith(playing: false)); + } + + @override + Future skipToNext() async => actions.add('next'); + + @override + Future 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> updates; + late Map> images; + + Future settle() async { + for (var i = 0; i < 6; i++) { + await Future.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 command(String action) { + final result = Completer(); + 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.from(call.arguments as Map)); + } + return null; + }); + player = _Player(); + service = PlayerWidgetService( + channel: channel, + loadArtwork: (uri) async => uri == null + ? null + : (images[uri.toString()] ??= Completer()) + .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(); + 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']); + }, + ); +}