perf(widget): publish artwork only for installed consumers

This commit is contained in:
zarzet committed 2026-10-04 01:41:55 +07:00
1 parent fe306119fe
commit 5f0577837d
4 files changed
+160 -11

No files matched your search

@@ -30,8 +30,17 @@ import java.util.concurrent.Executors
/** Native launcher rendering; audio remains in the app's shared Flutter engine. */
class PlayerWidgetProvider : AppWidgetProvider() {
override fun onEnabled(context: Context) {
PlayerWidgetBridge.installedWidgetsChanged(context)
}
override fun onDisabled(context: Context) {
PlayerWidgetBridge.installedWidgetsChanged(context, 0)
}
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
update(context, ids)
PlayerWidgetBridge.installedWidgetsChanged(context)
}
override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, id: Int, options: Bundle) {
@@ -42,6 +51,10 @@ class PlayerWidgetProvider : AppWidgetProvider() {
val editor = preferences(context).edit()
ids.forEach { editor.remove("theme_$it").remove("artist_$it") }
editor.apply()
val count = AppWidgetManager.getInstance(context)
.getAppWidgetIds(ComponentName(context, PlayerWidgetProvider::class.java))
.count { it !in ids }
PlayerWidgetBridge.installedWidgetsChanged(context, count)
}
override fun onReceive(context: Context, intent: Intent) {
@@ -77,6 +90,9 @@ class PlayerWidgetProvider : AppWidgetProvider() {
const val COMMAND = "player_widget_command"
fun preferences(context: Context) = context.getSharedPreferences("player_widget", Context.MODE_PRIVATE)
fun installedCount(context: Context): Int = AppWidgetManager.getInstance(context)
.getAppWidgetIds(ComponentName(context, PlayerWidgetProvider::class.java)).size
fun launchIntent(context: Context, command: String): PendingIntent = PendingIntent.getActivity(
context, 8100 + command.hashCode(),
Intent(context, MainActivity::class.java).apply {
@@ -205,6 +221,7 @@ object PlayerWidgetBridge : FlutterEngine.EngineLifecycleListener {
pendingCommand?.let { command(it) {} }
pendingCommand = null
}
"getInstalledWidgetCount" -> result.success(PlayerWidgetProvider.installedCount(app))
"update" -> {
val data = call.arguments as? Map<*, *>
if (data == null) { result.error("bad_state", "Missing widget state", null); return@setMethodCallHandler }
@@ -213,14 +230,17 @@ object PlayerWidgetBridge : FlutterEngine.EngineLifecycleListener {
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 cover = File(app.filesDir, "player-widget-cover.png")
if (PlayerWidgetProvider.installedCount(app) > 0 && bytes != null &&
(old.optString("artworkKey") != data["artworkKey"] || !cover.isFile)) {
val temp = File(app.filesDir, "player-widget-cover.tmp")
temp.writeBytes(bytes)
check(temp.renameTo(File(app.filesDir, "player-widget-cover.png")))
check(temp.renameTo(cover))
}
val state = JSONObject()
data.forEach { (key, value) -> if (key is String && key != "artwork") state.put(key, value) }
prefs.edit().putString("state", state.toString()).commit()
val serialized = state.toString()
if (serialized != old.toString()) prefs.edit().putString("state", serialized).commit()
PlayerWidgetProvider.update(app)
main.post { result.success(null) }
} catch (error: Exception) {
@@ -238,6 +258,12 @@ object PlayerWidgetBridge : FlutterEngine.EngineLifecycleListener {
ready = false
}
fun installedWidgetsChanged(context: Context, count: Int = PlayerWidgetProvider.installedCount(context)) {
main.post {
if (ready) channel?.invokeMethod("installedWidgetCountChanged", count)
}
}
override fun onEngineWillDestroy() {
ready = false
channel = null
+38 -3
View File
@@ -1,5 +1,6 @@
import Flutter
import Foundation
import UIKit
import WidgetKit
@MainActor
@@ -9,10 +10,19 @@ final class PlayerWidgetBridge {
private var ready = false
private var pending: [(UUID, String, CheckedContinuation<Bool, Never>)] = []
private let writer = DispatchQueue(label: "com.zarz.spotiflac.player-widget", qos: .utility)
private var installedWidgetCount: Int?
private var foregroundObserver: NSObjectProtocol?
func attach(_ messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(name: "com.zarz.spotiflac/player_widget", binaryMessenger: messenger)
self.channel = channel
if foregroundObserver == nil {
foregroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.refreshInstalledWidgetCount() }
}
}
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { result(nil); return }
switch call.method {
@@ -20,15 +30,20 @@ final class PlayerWidgetBridge {
self.ready = true
result(nil)
self.flush()
self.refreshInstalledWidgetCount()
case "getInstalledWidgetCount":
self.refreshInstalledWidgetCount(result)
case "update":
guard let values = call.arguments as? [String: Any] else {
result(FlutterError(code: "bad_state", message: "Missing widget state", details: nil))
return
}
let hasWidgets = (self.installedWidgetCount ?? 1) > 0
let presentation = hasWidgets ? values : values.filter { $0.key != "artwork" }
self.writer.async {
do {
try Self.write(values)
WidgetCenter.shared.reloadTimelines(ofKind: PlayerWidgetState.kind)
try Self.write(presentation)
if hasWidgets { WidgetCenter.shared.reloadTimelines(ofKind: PlayerWidgetState.kind) }
DispatchQueue.main.async { result(nil) }
} catch {
DispatchQueue.main.async {
@@ -42,6 +57,25 @@ final class PlayerWidgetBridge {
}
}
private func refreshInstalledWidgetCount(_ result: FlutterResult? = nil) {
WidgetCenter.shared.getCurrentConfigurations { [weak self] configurations in
Task { @MainActor in
guard let self else { result?(0); return }
switch configurations {
case .success(let widgets):
let count = widgets.filter { $0.kind == PlayerWidgetState.kind }.count
if count != self.installedWidgetCount {
self.installedWidgetCount = count
if self.ready { self.channel?.invokeMethod("installedWidgetCountChanged", arguments: count) }
}
result?(count)
case .failure(let error):
result?(FlutterError(code: "widget_count", message: error.localizedDescription, details: nil))
}
}
}
}
func perform(_ command: String) async -> Bool {
guard ["play", "pause", "toggle", "previous", "next", "open"].contains(command) else { return false }
return await withCheckedContinuation { continuation in
@@ -91,7 +125,8 @@ final class PlayerWidgetBridge {
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 {
if state.artworkKey == previous.artworkKey, let previousURL = previous.artworkURL,
FileManager.default.fileExists(atPath: previousURL.path) {
state.artworkName = previous.artworkName
} else if let artwork = values["artwork"] as? FlutterStandardTypedData {
state.artworkName = "cover-\(UUID().uuidString).png"
+54 -3
View File
@@ -36,6 +36,9 @@ class PlayerWidgetService {
Uri? _artUri;
PlayerWidgetArtwork? _artwork;
String? _lastPayload;
String? _lastArtworkKey;
int? _installedWidgetCount;
Future<void>? _consumerQuery;
int _artworkGeneration = 0;
Future<void> _writes = Future.value();
Future<void> _commands = Future.value();
@@ -45,6 +48,11 @@ class PlayerWidgetService {
if (_initialized) return;
_initialized = true;
_channel.setMethodCallHandler((call) async {
if (call.method == 'installedWidgetCountChanged' &&
call.arguments is int) {
await _setInstalledWidgetCount(call.arguments as int);
return true;
}
if (call.method != 'command' || call.arguments is! String) return false;
final command = call.arguments as String;
if (!const {
@@ -69,6 +77,7 @@ class PlayerWidgetService {
});
try {
await _channel.invokeMethod<void>('ready');
await _refreshConsumers();
} on MissingPluginException {
// Desktop and tests do not install the mobile widget bridge.
} on PlatformException catch (error) {
@@ -76,6 +85,32 @@ class PlayerWidgetService {
}
}
Future<void> _refreshConsumers() => _consumerQuery ??= () async {
try {
final count = await _channel.invokeMethod<int>('getInstalledWidgetCount');
if (count != null) await _setInstalledWidgetCount(count);
} on MissingPluginException {
// Older bridges and desktop keep the existing lightweight publishing.
} on PlatformException catch (error) {
_log.w('Widget consumer query failed: ${error.code}');
}
}();
Future<void> _setInstalledWidgetCount(int count) async {
final wasAbsent = _installedWidgetCount == 0;
_installedWidgetCount = count;
if (count == 0) {
_artworkGeneration++;
_artwork = null;
_lastArtworkKey = null;
await publish();
} else if (wasAbsent) {
final generation = ++_artworkGeneration;
await _updateArtwork(_artUri, generation);
await publish(force: true);
}
}
void bind(AudioHandler handler) {
if (identical(_handler, handler)) return;
for (final subscription in _subscriptions) {
@@ -89,7 +124,13 @@ class PlayerWidgetService {
_artUri = item?.artUri;
_artwork = null;
final generation = ++_artworkGeneration;
unawaited(_updateArtwork(item?.artUri, generation));
unawaited(() async {
await _refreshConsumers();
if (_installedWidgetCount != 0 &&
generation == _artworkGeneration) {
await _updateArtwork(item?.artUri, generation);
}
}());
}
unawaited(publish());
}),
@@ -102,7 +143,9 @@ class PlayerWidgetService {
Future<void> _updateArtwork(Uri? uri, int generation) async {
try {
final artwork = await _loadArtwork(uri);
if (generation != _artworkGeneration) return;
if (generation != _artworkGeneration || _installedWidgetCount == 0) {
return;
}
_artwork = artwork;
await publish();
} catch (error) {
@@ -141,12 +184,15 @@ class PlayerWidgetService {
try {
await _channel.invokeMethod<void>('update', {
...payload,
if (artwork != null) 'artwork': artwork.png,
if (artwork != null && payload['artworkKey'] != _lastArtworkKey)
'artwork': artwork.png,
});
_lastArtworkKey = payload['artworkKey'] as String;
} on MissingPluginException {
// Mobile-only capability.
} on PlatformException catch (error) {
_lastPayload = null;
_lastArtworkKey = null;
_log.w('Widget update failed: ${error.code}');
}
});
@@ -180,6 +226,11 @@ class PlayerWidgetService {
_subscriptions.clear();
_handler = null;
await _writes;
_consumerQuery = null;
_installedWidgetCount = null;
_lastArtworkKey = null;
_lastPayload = null;
_initialized = false;
_channel.setMethodCallHandler(null);
}
+39 -2
View File
@@ -37,6 +37,7 @@ void main() {
late _Player player;
late List<Map<Object?, Object?>> updates;
late Map<String, Completer<PlayerWidgetArtwork?>> images;
late int installedWidgets;
Future<void> settle() async {
for (var i = 0; i < 6; i++) {
@@ -53,11 +54,11 @@ void main() {
extras: const {'source': '/private/music.flac'},
);
Future<Object?> command(String action) {
Future<Object?> nativeEvent(String method, Object? arguments) {
final result = Completer<Object?>();
messenger.handlePlatformMessage(
channel.name,
channel.codec.encodeMethodCall(MethodCall('command', action)),
channel.codec.encodeMethodCall(MethodCall(method, arguments)),
(data) {
try {
result.complete(channel.codec.decodeEnvelope(data!));
@@ -69,10 +70,14 @@ void main() {
return result.future;
}
Future<Object?> command(String action) => nativeEvent('command', action);
setUp(() {
updates = [];
images = {};
installedWidgets = 1;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getInstalledWidgetCount') return installedWidgets;
if (call.method == 'update') {
updates.add(Map<Object?, Object?>.from(call.arguments as Map));
}
@@ -93,6 +98,38 @@ void main() {
messenger.setMockMethodCallHandler(channel, null);
});
test(
'no widgets skips artwork and first installed widget loads current cover',
() async {
installedWidgets = 0;
await service.initialize((_) async {});
player.mediaItem.add(item('one'));
await settle();
expect(images, isEmpty);
expect(updates.last['title'], 'Track one');
final added = nativeEvent('installedWidgetCountChanged', 1);
await settle();
images[item('one').artUri.toString()]!.complete(
PlayerWidgetArtwork(Uint8List.fromList([1]), 0xff223344),
);
await added;
expect(updates.any((update) => update['artwork'] != null), isTrue);
final artworkWrites = updates
.where((update) => update['artwork'] != null)
.length;
await player.play();
await settle();
expect(
updates.where((update) => update['artwork'] != null).length,
artworkWrites,
);
await nativeEvent('installedWidgetCountChanged', 0);
player.mediaItem.add(item('two'));
await settle();
expect(images.containsKey(item('two').artUri.toString()), isFalse);
},
);
test(
'publishes track and transport changes without position tick writes',
() async {