mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
Sync notification favorites with Library, add Mornye transport icons and an output action on legacy Android, and expose the iOS favorite command. Preserve playback position when controls refresh. Vendor audio_service 0.18.19 with a small Android patch for custom notification actions. Modern Android keeps its system output switcher and transport layout.
249 lines
7.6 KiB
Dart
249 lines
7.6 KiB
Dart
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<PlayerWidgetArtwork?> Function(Uri?)? loadArtwork,
|
|
}) : _channel = channel,
|
|
_loadArtwork = loadArtwork ?? _readArtwork;
|
|
|
|
static final instance = PlayerWidgetService();
|
|
static final _log = AppLogger('PlayerWidget');
|
|
final MethodChannel _channel;
|
|
final Future<PlayerWidgetArtwork?> Function(Uri?) _loadArtwork;
|
|
final _subscriptions = <StreamSubscription<dynamic>>[];
|
|
AudioHandler? _handler;
|
|
Uri? _artUri;
|
|
PlayerWidgetArtwork? _artwork;
|
|
String? _lastPayload;
|
|
int _artworkGeneration = 0;
|
|
Future<void> _writes = Future.value();
|
|
Future<void> _commands = Future.value();
|
|
bool _initialized = false;
|
|
|
|
Future<void> initialize(Future<void> 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<void>('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<void> _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<void> 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 = <String, Object?>{
|
|
'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<void>('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<void> 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<void> dispose() async {
|
|
_artworkGeneration++;
|
|
for (final subscription in _subscriptions) {
|
|
await subscription.cancel();
|
|
}
|
|
_subscriptions.clear();
|
|
_handler = null;
|
|
await _writes;
|
|
_channel.setMethodCallHandler(null);
|
|
}
|
|
|
|
static Future<PlayerWidgetArtwork?> _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();
|
|
}
|
|
}
|
|
}
|