Files
SpotiFLAC-Mobile/lib/services/discord_presence_service.dart
T
zarzet f0ed293d48 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.
2026-09-17 02:12:24 +07:00

136 lines
4.3 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Opt-in Rich Presence, inspired by @itsmegaaa's contribution in #575/#576.
/// Playback remains independent of Discord availability.
class DiscordPresenceService {
DiscordPresenceService({
MethodChannel channel = const MethodChannel('com.zarz.spotiflac/discord'),
}) : _channel = channel {
_channel.setMethodCallHandler((call) async {
if (call.method == 'status' && _enabled && _transportReady) {
status.value = call.arguments as String;
}
});
}
static final instance = DiscordPresenceService();
final MethodChannel _channel;
final ValueNotifier<String> status = ValueNotifier('disabled');
StreamSubscription<MediaItem?>? _mediaSubscription;
StreamSubscription<PlaybackState>? _stateSubscription;
MediaItem? _item;
PlaybackState? _playback;
bool _enabled = false;
bool _configured = false;
bool _transportReady = false;
int _generation = 0;
String? _lastPayload;
int? _lastStart;
Future<void> _pending = Future.value();
void bind(AudioHandler handler) {
unawaited(_mediaSubscription?.cancel());
unawaited(_stateSubscription?.cancel());
_mediaSubscription = handler.mediaItem.listen((item) {
_item = item;
_publish();
});
_stateSubscription = handler.playbackState.listen((state) {
_playback = state;
_publish();
});
}
Future<void> setEnabled(bool enabled) {
if (_configured && _enabled == enabled) return _pending;
_configured = true;
_enabled = enabled;
_transportReady = false;
final generation = ++_generation;
_lastPayload = null;
_lastStart = null;
_pending = _pending.then((_) async {
try {
final result = await _channel.invokeMethod<String>('configure', {
'enabled': enabled,
});
if (generation != _generation) return;
status.value = result ?? 'unavailable';
_transportReady = enabled && result == 'ready';
if (_transportReady) _publish();
} on PlatformException {
if (generation == _generation) status.value = 'unavailable';
} on MissingPluginException {
if (generation == _generation) status.value = 'sdk_unavailable';
}
});
return _pending;
}
void _publish() {
if (!_enabled || !_transportReady) return;
final item = _item;
final playback = _playback;
final playing =
item != null &&
playback != null &&
playback.playing &&
playback.processingState == AudioProcessingState.ready;
final position = playback?.position ?? Duration.zero;
final now = DateTime.now().millisecondsSinceEpoch;
final start = now - position.inMilliseconds;
final payload = playing
? <String, Object?>{
'title': item.title,
'artist': item.artist ?? '',
'album': item.album ?? '',
'cover': item.artUri?.scheme == 'https'
? item.artUri.toString()
: '',
'start': start,
'end': item.duration == null
? 0
: start + item.duration!.inMilliseconds,
}
: null;
// Position streams tick frequently. Only republish on track/state changes
// or a seek that shifts the playback timeline by at least two seconds.
final key = playing
? '${item.id}|${item.title}|${item.artist}|${item.album}|${item.artUri}|${item.duration}'
: 'clear';
if (key == _lastPayload &&
(!playing ||
(_lastStart != null && (start - _lastStart!).abs() < 2000))) {
return;
}
_lastPayload = key;
_lastStart = start;
final generation = _generation;
_pending = _pending.then((_) async {
if (!_enabled || generation != _generation) return;
try {
await _channel.invokeMethod<void>(
payload == null ? 'clear' : 'update',
payload,
);
} on PlatformException {
_lastPayload = null;
} on MissingPluginException {
_lastPayload = null;
}
});
}
Future<void> dispose() async {
await _mediaSubscription?.cancel();
await _stateSubscription?.cancel();
await setEnabled(false);
_channel.setMethodCallHandler(null);
status.dispose();
}
}