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:
zarzet
2026-09-17 02:12:24 +07:00
parent b2ea07c41e
commit f0ed293d48
17 changed files with 662 additions and 1 deletions
+4
View File
@@ -1,4 +1,8 @@
{
"discordRichPresence": "Discord Rich Presence",
"discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.",
"discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.",
"discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.",
"@@locale": "en",
"@@last_modified": "2026-04-28",
"appName": "SpotiFLAC Mobile",
+4
View File
@@ -1,4 +1,8 @@
{
"discordRichPresence": "Discord Rich Presence",
"discordPresenceDescription": "Bagikan lagu yang diputar di pemutar bawaan ke Discord. Discord harus terpasang dan sudah login.",
"discordPresenceMissing": "Pasang Discord dan login, lalu matikan dan nyalakan kembali pengaturan ini.",
"discordPresenceUnavailable": "Discord Rich Presence tidak tersedia pada build atau perangkat ini.",
"@@locale": "id",
"@@last_modified": "2026-04-28",
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
+4
View File
@@ -31,6 +31,7 @@ class AppSettings {
final bool embedReplayGain;
// Apply ReplayGain/R128 tags as volume normalization in the built-in player.
final bool playbackNormalization;
final bool discordRichPresence;
final bool isFirstLaunch;
final bool checkForUpdates;
final String updateChannel;
@@ -138,6 +139,7 @@ class AppSettings {
this.embedLyrics = true,
this.embedReplayGain = false,
this.playbackNormalization = false,
this.discordRichPresence = false,
this.isFirstLaunch = true,
this.checkForUpdates = true,
this.updateChannel = 'stable',
@@ -221,6 +223,7 @@ class AppSettings {
bool? embedLyrics,
bool? embedReplayGain,
bool? playbackNormalization,
bool? discordRichPresence,
bool? isFirstLaunch,
bool? checkForUpdates,
String? updateChannel,
@@ -300,6 +303,7 @@ class AppSettings {
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
playbackNormalization:
playbackNormalization ?? this.playbackNormalization,
discordRichPresence: discordRichPresence ?? this.discordRichPresence,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
+2
View File
@@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
embedLyrics: json['embedLyrics'] as bool? ?? true,
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
playbackNormalization: json['playbackNormalization'] as bool? ?? false,
discordRichPresence: json['discordRichPresence'] as bool? ?? false,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
@@ -117,6 +118,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'embedLyrics': instance.embedLyrics,
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'discordRichPresence': instance.discordRichPresence,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
+5
View File
@@ -551,6 +551,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDiscordRichPresence(bool enabled) {
state = state.copyWith(discordRichPresence: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
+12
View File
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/repo_provider.dart';
@@ -79,6 +80,11 @@ class _MainShellState extends ConsumerState<MainShell>
setPlaybackNormalizationEnabled(
ref.read(settingsProvider).playbackNormalization,
);
unawaited(
DiscordPresenceService.instance.setEnabled(
ref.read(settingsProvider).discordRichPresence,
),
);
// Deezer & co. localize artist/genre names by IP unless told the app's
// language (issue #480).
unawaited(
@@ -631,6 +637,12 @@ class _MainShellState extends ConsumerState<MainShell>
@override
Widget build(BuildContext context) {
ref.listen(settingsProvider.select((s) => s.discordRichPresence), (
_,
enabled,
) {
unawaited(DiscordPresenceService.instance.setEnabled(enabled));
});
ref.listen(settingsProvider.select((s) => s.playbackNormalization), (
_,
enabled,
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart';
@@ -910,8 +911,27 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setPlaybackNormalization(v),
showDivider: false,
showDivider: Platform.isAndroid,
),
if (Platform.isAndroid)
ValueListenableBuilder<String>(
valueListenable: DiscordPresenceService.instance.status,
builder: (context, status, _) => SettingsSwitchItem(
icon: Icons.share_outlined,
title: context.l10n.discordRichPresence,
subtitle: status == 'discord_missing'
? context.l10n.discordPresenceMissing
: status == 'sdk_unavailable' ||
status == 'unavailable'
? context.l10n.discordPresenceUnavailable
: context.l10n.discordPresenceDescription,
value: settings.discordRichPresence,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setDiscordRichPresence(value),
showDivider: false,
),
),
],
),
),
+135
View File
@@ -0,0 +1,135 @@
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();
}
}
+2
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:audio_session/audio_session.dart'
show AudioSession, AudioSessionConfiguration, AudioInterruptionType;
import 'package:audioplayers/audioplayers.dart';
@@ -1545,6 +1546,7 @@ Future<MusicPlayerHandler> _doInitMusicPlayer() async {
),
);
_handler = handler;
DiscordPresenceService.instance.bind(handler);
_handlerReadyController.add(handler);
return handler;
} catch (_) {