diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt index abe55e3d..47b9ec83 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/PlayerWidgetProvider.kt @@ -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 diff --git a/ios/Runner/PlayerWidgetBridge.swift b/ios/Runner/PlayerWidgetBridge.swift index 3c2ca9b6..b88a2efb 100644 --- a/ios/Runner/PlayerWidgetBridge.swift +++ b/ios/Runner/PlayerWidgetBridge.swift @@ -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)] = [] 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" diff --git a/lib/services/player_widget_service.dart b/lib/services/player_widget_service.dart index 56ec88b1..ba469724 100644 --- a/lib/services/player_widget_service.dart +++ b/lib/services/player_widget_service.dart @@ -36,6 +36,9 @@ class PlayerWidgetService { Uri? _artUri; PlayerWidgetArtwork? _artwork; String? _lastPayload; + String? _lastArtworkKey; + int? _installedWidgetCount; + Future? _consumerQuery; int _artworkGeneration = 0; Future _writes = Future.value(); Future _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('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 _refreshConsumers() => _consumerQuery ??= () async { + try { + final count = await _channel.invokeMethod('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 _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 _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('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); } diff --git a/test/player_widget_service_test.dart b/test/player_widget_service_test.dart index 25707313..fec982ec 100644 --- a/test/player_widget_service_test.dart +++ b/test/player_widget_service_test.dart @@ -37,6 +37,7 @@ void main() { late _Player player; late List> updates; late Map> images; + late int installedWidgets; Future settle() async { for (var i = 0; i < 6; i++) { @@ -53,11 +54,11 @@ void main() { extras: const {'source': '/private/music.flac'}, ); - Future command(String action) { + Future nativeEvent(String method, Object? arguments) { final result = Completer(); 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 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.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 {