mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
perf(widget): publish artwork only for installed consumers
This commit is contained in:
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user