mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
feat(widgets): add native Android and iOS home screen players
Publish artwork, metadata, and transport state through a shared Dart bridge. Add configurable Android RemoteViews and iOS WidgetKit players with controls and links into the full player. Serialize widget commands and session restoration, and share one headless-capable iOS Flutter engine with the foreground scene. Include native integration tests, Dart bridge tests, and widget setup documentation.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/player_widget_service.dart';
|
||||
|
||||
class _Player extends BaseAudioHandler {
|
||||
final actions = <String>[];
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
actions.add('play');
|
||||
playbackState.add(playbackState.value.copyWith(playing: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
actions.add('pause');
|
||||
playbackState.add(playbackState.value.copyWith(playing: false));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToNext() async => actions.add('next');
|
||||
|
||||
@override
|
||||
Future<void> skipToPrevious() async => actions.add('previous');
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('test/player_widget');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
late PlayerWidgetService service;
|
||||
late _Player player;
|
||||
late List<Map<Object?, Object?>> updates;
|
||||
late Map<String, Completer<PlayerWidgetArtwork?>> images;
|
||||
|
||||
Future<void> settle() async {
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
MediaItem item(String id) => MediaItem(
|
||||
id: id,
|
||||
title: 'Track $id',
|
||||
artist: 'Example Artist',
|
||||
album: 'Example Album',
|
||||
artUri: Uri.file('/artwork/$id.png'),
|
||||
extras: const {'source': '/private/music.flac'},
|
||||
);
|
||||
|
||||
Future<Object?> command(String action) {
|
||||
final result = Completer<Object?>();
|
||||
messenger.handlePlatformMessage(
|
||||
channel.name,
|
||||
channel.codec.encodeMethodCall(MethodCall('command', action)),
|
||||
(data) {
|
||||
try {
|
||||
result.complete(channel.codec.decodeEnvelope(data!));
|
||||
} catch (error) {
|
||||
result.completeError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
return result.future;
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
updates = [];
|
||||
images = {};
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'update') {
|
||||
updates.add(Map<Object?, Object?>.from(call.arguments as Map));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
player = _Player();
|
||||
service = PlayerWidgetService(
|
||||
channel: channel,
|
||||
loadArtwork: (uri) async => uri == null
|
||||
? null
|
||||
: (images[uri.toString()] ??= Completer<PlayerWidgetArtwork?>())
|
||||
.future,
|
||||
)..bind(player);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await service.dispose();
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test(
|
||||
'publishes track and transport changes without position tick writes',
|
||||
() async {
|
||||
player.mediaItem.add(item('one'));
|
||||
player.playbackState.add(
|
||||
PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
controls: const [
|
||||
MediaControl.skipToPrevious,
|
||||
MediaControl.pause,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
),
|
||||
);
|
||||
await settle();
|
||||
final count = updates.length;
|
||||
for (var second = 1; second < 20; second++) {
|
||||
player.playbackState.add(
|
||||
player.playbackState.value.copyWith(
|
||||
updatePosition: Duration(seconds: second),
|
||||
),
|
||||
);
|
||||
}
|
||||
await settle();
|
||||
expect(updates.length, count);
|
||||
expect(updates.last['title'], 'Track one');
|
||||
expect(updates.last['canNext'], isTrue);
|
||||
expect(updates.last.toString(), isNot(contains('/private/music.flac')));
|
||||
await player.pause();
|
||||
await settle();
|
||||
expect(updates.last['playing'], isFalse);
|
||||
expect(updates.last['canPlay'], isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'late artwork from a previous track cannot replace current artwork',
|
||||
() async {
|
||||
player.mediaItem.add(item('one'));
|
||||
await settle();
|
||||
player.mediaItem.add(item('two'));
|
||||
await settle();
|
||||
images[item('two').artUri.toString()]!.complete(
|
||||
PlayerWidgetArtwork(Uint8List.fromList([2]), 0xff223344),
|
||||
);
|
||||
await settle();
|
||||
images[item('one').artUri.toString()]!.complete(
|
||||
PlayerWidgetArtwork(Uint8List.fromList([1]), 0xff443322),
|
||||
);
|
||||
await settle();
|
||||
expect(updates.last['id'], 'two');
|
||||
expect(updates.last['artwork'], orderedEquals([2]));
|
||||
expect(updates.last['background'], 0xff223344);
|
||||
expect(
|
||||
updates.any(
|
||||
(update) =>
|
||||
update['id'] == 'two' && update['background'] == 0xff443322,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'clearing playback also removes artwork and disables the player',
|
||||
() async {
|
||||
player.mediaItem.add(item('one'));
|
||||
await settle();
|
||||
images[item('one').artUri.toString()]!.complete(
|
||||
PlayerWidgetArtwork(Uint8List.fromList([1]), 0xff223344),
|
||||
);
|
||||
await settle();
|
||||
player.mediaItem.add(null);
|
||||
await settle();
|
||||
expect(updates.last['canPlay'], isFalse);
|
||||
expect(updates.last['title'], isEmpty);
|
||||
expect(updates.last['artworkKey'], isEmpty);
|
||||
expect(updates.last['artwork'], isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'commands use the existing handler and finish after publishing state',
|
||||
() async {
|
||||
player.mediaItem.add(item('one'));
|
||||
await service.initialize(
|
||||
(action) => PlayerWidgetService.control(player, action),
|
||||
);
|
||||
expect(await command('toggle'), isTrue);
|
||||
expect(updates.last['playing'], isTrue);
|
||||
expect(await command('toggle'), isTrue);
|
||||
expect(updates.last['playing'], isFalse);
|
||||
await command('next');
|
||||
await command('previous');
|
||||
expect(player.actions, ['play', 'pause', 'next', 'previous']);
|
||||
expect(await command('delete'), isFalse);
|
||||
expect(player.actions.length, 4);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'rapid widget commands remain in order during player initialization',
|
||||
() async {
|
||||
final startup = Completer<void>();
|
||||
await service.initialize((action) async {
|
||||
await startup.future;
|
||||
await PlayerWidgetService.control(player, action);
|
||||
});
|
||||
final first = command('play');
|
||||
final second = command('pause');
|
||||
await settle();
|
||||
expect(player.actions, isEmpty);
|
||||
startup.complete();
|
||||
await Future.wait([first, second]);
|
||||
expect(player.actions, ['play', 'pause']);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user