mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
feat: add opt-in Discord Rich Presence on Android
Use the official Discord Social SDK through JNI to share built-in player activity, clear presence when playback stops, and guard against late callbacks after disabling. Package the SDK optionally through SPOTIFLAC_DISCORD_SDK_DIR with license notices. Credit: @itsmegaaa for the Rich Presence initiative and contribution in #575 and #576. Validation: 15 Dart tests, scoped Flutter analysis, and one Android 16 ARM64 SDK/JNI instrumentation test passed. Visible presence on a signed-in Discord profile remains unverified.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/discord_presence_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('test/discord');
|
||||
late DiscordPresenceService service;
|
||||
late BaseAudioHandler player;
|
||||
late List<MethodCall> calls;
|
||||
|
||||
Future<void> settle() async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
calls = [];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return call.method == 'configure'
|
||||
? ((call.arguments as Map)['enabled'] == true
|
||||
? 'ready'
|
||||
: 'disabled')
|
||||
: null;
|
||||
});
|
||||
player = BaseAudioHandler();
|
||||
service = DiscordPresenceService(channel: channel)..bind(player);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await service.dispose();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
void play({String title = 'Track 🎵'}) {
|
||||
player.mediaItem.add(
|
||||
MediaItem(
|
||||
id: 'local-track',
|
||||
title: title,
|
||||
artist: 'Lead & Guest',
|
||||
album: 'Album',
|
||||
duration: const Duration(minutes: 3),
|
||||
artUri: Uri.parse('file:///private/cover.jpg'),
|
||||
),
|
||||
);
|
||||
player.playbackState.add(
|
||||
PlaybackState(playing: true, processingState: AudioProcessingState.ready),
|
||||
);
|
||||
}
|
||||
|
||||
test('opt-in persists and defaults to off', () {
|
||||
expect(AppSettings.fromJson({}).discordRichPresence, isFalse);
|
||||
final settings = const AppSettings().copyWith(discordRichPresence: true);
|
||||
expect(AppSettings.fromJson(settings.toJson()).discordRichPresence, isTrue);
|
||||
});
|
||||
|
||||
test('publishes playback only after opt-in and clears on pause', () async {
|
||||
play();
|
||||
await settle();
|
||||
expect(calls, isEmpty);
|
||||
await service.setEnabled(true);
|
||||
await settle();
|
||||
final update = calls.singleWhere((c) => c.method == 'update');
|
||||
final data = update.arguments as Map;
|
||||
expect(data['title'], 'Track 🎵');
|
||||
expect(data['artist'], 'Lead & Guest');
|
||||
expect(data['cover'], '');
|
||||
expect((data['end'] as int) - (data['start'] as int), 180000);
|
||||
player.playbackState.add(
|
||||
PlaybackState(
|
||||
playing: false,
|
||||
processingState: AudioProcessingState.ready,
|
||||
),
|
||||
);
|
||||
await settle();
|
||||
expect(calls.last.method, 'clear');
|
||||
await service.setEnabled(false);
|
||||
final count = calls.length;
|
||||
play(title: 'Next');
|
||||
await settle();
|
||||
expect(calls.length, count);
|
||||
});
|
||||
|
||||
test('late configure completion cannot republish after disabling', () async {
|
||||
final ready = Completer<String>();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return (call.arguments as Map)['enabled'] == true
|
||||
? ready.future
|
||||
: 'disabled';
|
||||
});
|
||||
play();
|
||||
final enabling = service.setEnabled(true);
|
||||
await settle();
|
||||
final disabling = service.setEnabled(false);
|
||||
ready.complete('ready');
|
||||
await Future.wait([enabling, disabling]);
|
||||
await settle();
|
||||
expect(calls.map((c) => c.method), ['configure', 'configure']);
|
||||
expect(service.status.value, 'disabled');
|
||||
});
|
||||
|
||||
test('position ticks are deduplicated but seek republishes', () async {
|
||||
play();
|
||||
await service.setEnabled(true);
|
||||
await settle();
|
||||
final initial = calls.where((c) => c.method == 'update').length;
|
||||
for (var i = 0; i < 8; i++) {
|
||||
player.playbackState.add(
|
||||
PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
),
|
||||
);
|
||||
}
|
||||
await settle();
|
||||
expect(calls.where((c) => c.method == 'update').length, initial);
|
||||
player.playbackState.add(
|
||||
PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
updatePosition: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
await settle();
|
||||
expect(calls.where((c) => c.method == 'update').length, initial + 1);
|
||||
});
|
||||
|
||||
test('missing Discord does not publish or break playback', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return 'discord_missing';
|
||||
});
|
||||
play();
|
||||
await service.setEnabled(true);
|
||||
await settle();
|
||||
expect(service.status.value, 'discord_missing');
|
||||
expect(calls.map((c) => c.method), ['configure']);
|
||||
expect(player.playbackState.value.playing, isTrue);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user