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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 (_) {
|
||||
|
||||
Reference in New Issue
Block a user