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.any( (control) => control.action == MediaAction.skipToPrevious, ), 'canNext': state.controls.any( (control) => control.action == MediaAction.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(); } } }