feat(discord): add opt-in mobile rich presence with track artwork

This commit is contained in:
zarzet committed 2026-10-03 02:14:11 +07:00
1 parent bbe2caa754
commit 0b2cf0e4e5
40 files changed
+1839 -18

No files matched your search

+11
View File
@@ -7071,6 +7071,17 @@
"motionArtworkDescription": "Loop video covers supplied by metadata providers. Turn off to use still artwork and reduce battery use.",
"libraryLoading": "Loading Library…",
"libraryLoadFailed": "Library could not be loaded. Retry to load the saved folders and track index.",
"discordPresenceDescription": "Share the title, artist, album and progress of music played in the built-in player on your Discord profile. Local audio and artwork files are never uploaded. Clears when playback pauses or stops.",
"discordPresenceDisabled": "Disabled",
"discordPresenceLink": "Link Discord account",
"discordPresenceConnecting": "Connecting to Discord…",
"discordPresenceReady": "Ready when music plays",
"discordPresenceActive": "Sharing current track",
"discordPresenceUnavailable": "Discord is unavailable. Retrying during playback.",
"discordPresenceBuildUnavailable": "Discord SDK is not available in this build.",
"discordPresenceAndroidHelp": "Install Discord and sign in on this phone. Allow activity sharing in Discord. External players are not supported.",
"discordPresenceIosHelp": "Link your account through Discord and allow activity sharing. Tap to link again if authorization fails.",
"discordPresenceDisconnect": "Disconnect and forget account",
"optionsEmbeddedCoverSizeOriginal": "Original resolution",
"@optionsEmbeddedCoverSizeOriginal": {
"description": "Option that preserves the provider artwork at its original resolution"
+12 -1
View File
@@ -6986,5 +6986,16 @@
"weeklyReleasesLoadError": "Rilis baru tidak dapat diperiksa. Tarik untuk memuat ulang.",
"motionArtworkDescription": "Putar berulang cover video dari penyedia metadata. Matikan untuk memakai cover diam dan menghemat baterai.",
"libraryLoading": "Memuat Library…",
"libraryLoadFailed": "Library tidak dapat dimuat. Coba lagi untuk memuat folder tersimpan dan indeks lagu."
"libraryLoadFailed": "Library tidak dapat dimuat. Coba lagi untuk memuat folder tersimpan dan indeks lagu.",
"discordPresenceDescription": "Tampilkan judul, artis, album, dan progres musik dari pemutar bawaan di profil Discord Anda. File audio dan cover lokal tidak diunggah. Status dihapus saat musik dijeda atau dihentikan.",
"discordPresenceDisabled": "Nonaktif",
"discordPresenceLink": "Hubungkan akun Discord",
"discordPresenceConnecting": "Menghubungkan ke Discord…",
"discordPresenceReady": "Siap saat musik diputar",
"discordPresenceActive": "Menampilkan lagu saat ini",
"discordPresenceUnavailable": "Discord tidak tersedia. Mencoba lagi saat musik diputar.",
"discordPresenceBuildUnavailable": "SDK Discord tidak tersedia di build ini.",
"discordPresenceAndroidHelp": "Pasang Discord dan masuk di ponsel ini. Izinkan berbagi aktivitas di Discord. Pemutar eksternal tidak didukung.",
"discordPresenceIosHelp": "Hubungkan akun melalui Discord dan izinkan berbagi aktivitas. Ketuk untuk menghubungkan ulang jika otorisasi gagal.",
"discordPresenceDisconnect": "Putuskan dan lupakan akun"
}
+5
View File
@@ -32,6 +32,7 @@ class AppSettings {
// Apply ReplayGain/R128 tags as volume normalization in the built-in player.
final bool playbackNormalization;
final bool autoMix;
final bool discordRichPresenceEnabled;
final bool usbBitPerfect;
final bool usbDirect;
final bool usbDsdOverPcm;
@@ -153,6 +154,7 @@ class AppSettings {
this.embedReplayGain = false,
this.playbackNormalization = false,
this.autoMix = false,
this.discordRichPresenceEnabled = false,
this.usbBitPerfect = false,
this.usbDirect = false,
this.usbDsdOverPcm = false,
@@ -249,6 +251,7 @@ class AppSettings {
bool? embedReplayGain,
bool? playbackNormalization,
bool? autoMix,
bool? discordRichPresenceEnabled,
bool? usbBitPerfect,
bool? usbDirect,
bool? usbDsdOverPcm,
@@ -341,6 +344,8 @@ class AppSettings {
playbackNormalization:
playbackNormalization ?? this.playbackNormalization,
autoMix: autoMix ?? this.autoMix,
discordRichPresenceEnabled:
discordRichPresenceEnabled ?? this.discordRichPresenceEnabled,
usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect,
usbDirect: usbDirect ?? this.usbDirect,
usbDsdOverPcm: usbDsdOverPcm ?? this.usbDsdOverPcm,
+3
View File
@@ -23,6 +23,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
playbackNormalization: json['playbackNormalization'] as bool? ?? false,
autoMix: json['autoMix'] as bool? ?? false,
discordRichPresenceEnabled:
json['discordRichPresenceEnabled'] as bool? ?? false,
usbBitPerfect: json['usbBitPerfect'] as bool? ?? false,
usbDirect: json['usbDirect'] as bool? ?? false,
usbDsdOverPcm: json['usbDsdOverPcm'] as bool? ?? false,
@@ -132,6 +134,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'autoMix': instance.autoMix,
'discordRichPresenceEnabled': instance.discordRichPresenceEnabled,
'usbBitPerfect': instance.usbBitPerfect,
'usbDirect': instance.usbDirect,
'usbDsdOverPcm': instance.usbDsdOverPcm,
+5
View File
@@ -556,6 +556,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDiscordRichPresenceEnabled(bool enabled) {
state = state.copyWith(discordRichPresenceEnabled: enabled);
_saveSettings();
}
void setAutoplay(bool enabled) {
state = state.copyWith(autoplay: enabled);
_saveSettings();
+12
View File
@@ -29,6 +29,7 @@ import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
@@ -107,6 +108,11 @@ class _MainShellState extends ConsumerState<MainShell>
ref.read(settingsProvider).playbackNormalization,
);
setAutoMixEnabled(ref.read(settingsProvider).autoMix);
unawaited(
DiscordPresenceService.instance.setEnabled(
ref.read(settingsProvider).discordRichPresenceEnabled,
),
);
listeningRecorder.setEnabled(
ref.read(settingsProvider).listeningStatisticsEnabled,
);
@@ -785,6 +791,12 @@ class _MainShellState extends ConsumerState<MainShell>
ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) {
setAutoMixEnabled(enabled);
});
ref.listen(settingsProvider.select((s) => s.discordRichPresenceEnabled), (
_,
enabled,
) {
unawaited(DiscordPresenceService.instance.setEnabled(enabled));
});
ref.listen(
settingsProvider.select((s) => s.listeningStatisticsEnabled),
(_, enabled) => listeningRecorder.setEnabled(enabled),
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/discord_presence_settings.dart';
class PlaybackSettingsPage extends ConsumerStatefulWidget {
const PlaybackSettingsPage({super.key});
@@ -132,6 +133,12 @@ class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
],
),
),
if (Platform.isAndroid || Platform.isIOS) ...[
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Discord'),
),
const SliverToBoxAdapter(child: DiscordPresenceSettings()),
],
if (Platform.isAndroid)
SliverToBoxAdapter(
child: SettingsSectionHeader(
+167
View File
@@ -0,0 +1,167 @@
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
/// Discord fetches images itself. Local artwork must never be sent as an asset
/// key, and private/signed URLs must not be published as listening activity.
String? discordPublicArtwork(String? value) {
final uri = Uri.tryParse(value?.trim() ?? '');
if (uri == null ||
uri.scheme != 'https' ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.hasFragment ||
uri.toString().length > 300) {
return null;
}
final host = uri.host.toLowerCase();
if (!host.contains('.') ||
host.endsWith('.local') ||
host.endsWith('.localhost') ||
host.endsWith('.internal') ||
InternetAddress.tryParse(host) != null) {
return null;
}
// Image CDN transforms are public, unlike authentication/signature params.
const transforms = {
'w',
'h',
'width',
'height',
'size',
'quality',
'q',
'format',
'fm',
'fit',
'crop',
'auto',
};
if (uri.queryParametersAll.entries.any(
(entry) =>
!transforms.contains(entry.key.toLowerCase()) ||
entry.value.any(
(value) => !RegExp(r'^[a-zA-Z0-9,._-]{1,40}$').hasMatch(value),
),
)) {
return null;
}
return uri.toString();
}
String _normalized(String? text) =>
(text ?? '').trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
/// Strictly retain version/album qualifiers: a remix or reissue must not borrow
/// a different track's cover merely because it ranked first in search.
String? discordMatchedArtwork(MediaItem item, Iterable<Track> candidates) {
for (final track in candidates) {
if (_normalized(track.name) != _normalized(item.title) ||
_normalized(track.artistName) != _normalized(item.artist) ||
(item.album?.trim().isNotEmpty == true &&
_normalized(track.albumName) != _normalized(item.album))) {
continue;
}
if (item.duration != null &&
track.duration > 0 &&
(item.duration!.inSeconds - track.duration).abs() > 5) {
continue;
}
final cover = discordPublicArtwork(track.coverUrl);
if (cover != null) return cover;
}
return null;
}
class DiscordArtworkResolver {
DiscordArtworkResolver({
Future<String?> Function(MediaItem)? historyCover,
Future<List<Track>> Function(String)? search,
}) : _historyCover = historyCover ?? _fromHistory,
_search = search ?? _searchCatalog;
final Future<String?> Function(MediaItem) _historyCover;
final Future<List<Track>> Function(String) _search;
final _cache = <String, ({String? url, DateTime expires})>{};
final _pending = <String, Future<String?>>{};
Future<String?> resolve(MediaItem item) async {
final direct = discordPublicArtwork(item.artUri?.toString());
if (direct != null) return direct;
final key = [
item.id,
item.title,
item.artist,
item.album,
item.artUri,
item.duration,
].join('\u0000');
final cached = _cache[key];
if (cached != null && cached.expires.isAfter(DateTime.now())) {
return cached.url;
}
if (_pending[key] case final pending?) return pending;
final deadline = DateTime.now().add(const Duration(seconds: 8));
final request = _lookup(
item,
deadline,
).timeout(const Duration(seconds: 8), onTimeout: () => null);
_pending[key] = request;
try {
final url = await request;
_cache.remove(key);
while (_cache.length >= 64) {
_cache.remove(_cache.keys.first);
}
_cache[key] = (
url: url,
expires: DateTime.now().add(Duration(minutes: url == null ? 5 : 60)),
);
return url;
} finally {
_pending.remove(key);
}
}
Future<String?> _lookup(MediaItem item, DateTime deadline) async {
try {
final saved = discordPublicArtwork(await _historyCover(item));
if (saved != null) return saved;
} catch (_) {
/* Library access must not block presence. */
}
if (!DateTime.now().isBefore(deadline) ||
item.title.trim().isEmpty ||
(item.artist ?? '').trim().isEmpty) {
return null;
}
try {
return discordMatchedArtwork(
item,
await _search('${item.title} ${item.artist}'),
);
} catch (_) {
return null;
}
}
static Future<String?> _fromHistory(MediaItem item) async {
final source = item.extras?['source'];
final row = source is String && source.isNotEmpty
? await HistoryDatabase.instance.findByFilePath(source)
: await HistoryDatabase.instance.getById(item.id);
return row?['coverUrl'] as String?;
}
static Future<List<Track>> _searchCatalog(String query) async => [
for (final row in await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 8,
includeExtensions: true,
))
Track.fromBackendMap(row),
];
}
+373
View File
@@ -0,0 +1,373 @@
import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/services/discord_artwork_resolver.dart';
enum DiscordPresenceStatus {
disabled,
linkRequired,
connecting,
ready,
active,
unavailable,
sdkUnavailable,
}
/// Only presentation metadata goes to Discord. Never send media IDs, file paths,
/// content URIs, credentials in artwork URLs, or upload local artwork.
Map<String, Object>? discordPresencePayload(
MediaItem? item,
PlaybackState state,
DateTime now,
) {
if (item == null ||
!state.playing ||
state.processingState != AudioProcessingState.ready) {
return null;
}
String text(String? value) =>
String.fromCharCodes((value ?? '').trim().runes.take(128));
final artwork = discordPublicArtwork(item.artUri?.toString()) ?? '';
final elapsed = now.difference(state.updateTime).inMilliseconds;
final position =
(state.updatePosition.inMilliseconds +
elapsed.clamp(0, 1 << 50) * state.speed)
.clamp(0, item.duration?.inMilliseconds ?? (1 << 50));
final speed = state.speed;
return {
'title': text(item.title),
'artist': text(item.artist),
'state': text(item.artist),
'artwork': artwork,
if (speed > 0) ...{
'start': ((now.millisecondsSinceEpoch - position / speed) / 1000).floor(),
if (item.duration != null)
'end':
((now.millisecondsSinceEpoch +
(item.duration!.inMilliseconds - position) / speed) /
1000)
.floor(),
},
};
}
/// Owns one SDK connection, independent of the player screen's lifetime.
/// All failures stay in this optional integration and cannot stop playback.
class DiscordPresenceService {
DiscordPresenceService({
MethodChannel channel = const MethodChannel('com.zarz.spotiflac/discord'),
bool? requiresLink,
Future<String?> Function()? readRefreshToken,
Future<void> Function(String?)? writeRefreshToken,
DateTime Function()? now,
Future<String?> Function(MediaItem)? resolveArtwork,
}) : _channel = channel,
requiresLink = requiresLink ?? Platform.isIOS,
_readRefreshToken = readRefreshToken ?? _readToken,
_writeRefreshToken = writeRefreshToken ?? _writeToken,
_now = now ?? DateTime.now,
_resolveArtwork = resolveArtwork ?? DiscordArtworkResolver().resolve {
_channel.setMethodCallHandler(_onNativeCall);
}
static final instance = DiscordPresenceService();
static const _tokenKey = 'discord_presence_refresh_token';
static const _storage = FlutterSecureStorage(
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
static Future<String?> _readToken() => _storage.read(key: _tokenKey);
static Future<void> _writeToken(String? token) => token == null
? _storage.delete(key: _tokenKey)
: _storage.write(key: _tokenKey, value: token);
final MethodChannel _channel;
final bool requiresLink;
final Future<String?> Function() _readRefreshToken;
final Future<void> Function(String?) _writeRefreshToken;
final DateTime Function() _now;
final Future<String?> Function(MediaItem) _resolveArtwork;
String? _artworkKey;
String? _artwork;
int _artworkRequest = 0;
final status = ValueNotifier(DiscordPresenceStatus.disabled);
final _subscriptions = <StreamSubscription<dynamic>>[];
AudioHandler? _handler;
bool _enabled = false;
bool _initialized = false;
bool _ready = false;
bool _authenticating = false;
bool _visible = false;
int _generation = 0;
Map<String, Object>? _lastPayload;
DateTime? _lastUpdate;
Timer? _pending;
Timer? _retry;
Timer? _refresh;
Future<void> _tokenWrites = Future.value();
void bind(AudioHandler handler) {
if (identical(handler, _handler)) return;
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
_subscriptions.clear();
_handler = handler;
_subscriptions.add(handler.mediaItem.listen((_) => _schedule()));
_subscriptions.add(handler.playbackState.listen((_) => _schedule()));
_schedule();
}
Future<void> setEnabled(bool enabled) async {
if (_enabled == enabled) return;
_enabled = enabled;
final generation = ++_generation;
_artworkRequest++;
_artworkKey = null;
_artwork = null;
_pending?.cancel();
_retry?.cancel();
_refresh?.cancel();
_ready = false;
_visible = false;
_lastPayload = null;
_lastUpdate = null;
_authenticating = false;
if (!enabled) {
_initialized = false;
status.value = DiscordPresenceStatus.disabled;
await _invoke('shutdown');
return;
}
status.value = DiscordPresenceStatus.connecting;
try {
final available = await _channel.invokeMethod<bool>('initialize');
if (generation != _generation) return;
_initialized = available == true;
if (!_initialized) {
status.value = DiscordPresenceStatus.sdkUnavailable;
return;
}
_retry = Timer.periodic(const Duration(seconds: 30), (_) {
if (status.value == DiscordPresenceStatus.unavailable) {
_lastPayload = null;
_schedule();
}
});
if (requiresLink) {
await _authenticate(interactive: false);
} else {
_ready = true;
status.value = DiscordPresenceStatus.ready;
_schedule();
}
} catch (_) {
if (generation == _generation) {
status.value = DiscordPresenceStatus.sdkUnavailable;
}
}
}
Future<void> link() => _authenticate(interactive: true);
Future<void> forgetAccount() async {
await setEnabled(false);
// Serialize writes so a late refresh cannot restore a removed account.
_tokenWrites = _tokenWrites.then((_) => _writeRefreshToken(null));
try {
await _tokenWrites;
} catch (_) {
_tokenWrites = Future.value();
status.value = DiscordPresenceStatus.unavailable;
}
}
Future<void> _authenticate({required bool interactive}) async {
if (!_enabled || !_initialized || _authenticating || !requiresLink) return;
_authenticating = true;
_ready = false;
_pending?.cancel();
_refresh?.cancel();
final generation = _generation;
status.value = DiscordPresenceStatus.connecting;
try {
await _tokenWrites;
final token = interactive ? null : await _readRefreshToken();
if (generation != _generation) return;
if (!interactive && (token == null || token.isEmpty)) {
status.value = DiscordPresenceStatus.linkRequired;
return;
}
final credentials = await _channel.invokeMapMethod<String, dynamic>(
interactive ? 'authorize' : 'refresh',
interactive ? null : {'refresh': token},
);
if (generation != _generation) return;
final access = credentials?['access'] as String?;
final refresh = credentials?['refresh'] as String?;
if (access == null ||
access.isEmpty ||
refresh == null ||
refresh.isEmpty) {
status.value = DiscordPresenceStatus.linkRequired;
return;
}
_tokenWrites = _tokenWrites.then((_) => _writeRefreshToken(refresh));
await _tokenWrites;
if (generation != _generation) return;
await _channel.invokeMethod<void>('connect', {'access': access});
if (generation != _generation) return;
final expires = (credentials?['expiresIn'] as num?)?.toInt() ?? 3600;
_refresh = Timer(Duration(seconds: (expires - 60).clamp(30, 604800)), () {
unawaited(_authenticate(interactive: false));
});
} catch (_) {
// Do not log OAuth tokens or native exception details.
_tokenWrites = Future.value();
if (generation == _generation) {
status.value = DiscordPresenceStatus.linkRequired;
}
} finally {
if (generation == _generation) _authenticating = false;
}
}
Future<void> _onNativeCall(MethodCall call) async {
if (call.method != 'status' || !_enabled) return;
switch (call.arguments) {
case 'ready':
_ready = true;
_lastPayload = null;
status.value = DiscordPresenceStatus.ready;
_schedule();
case 'active':
if (_visible) status.value = DiscordPresenceStatus.active;
case 'connecting':
_ready = false;
status.value = DiscordPresenceStatus.connecting;
case 'expired':
unawaited(_authenticate(interactive: false));
case 'unavailable':
status.value = DiscordPresenceStatus.unavailable;
}
}
bool _samePayload(Map<String, Object> payload) {
final previous = _lastPayload;
if (previous == null) return false;
for (final key in ['title', 'artist', 'state', 'artwork']) {
if (payload[key] != previous[key]) return false;
}
for (final key in ['start', 'end']) {
final a = payload[key] as int?;
final b = previous[key] as int?;
if (a == null || b == null) {
if (a != b) return false;
} else if ((a - b).abs() > 2) {
return false;
}
}
return true;
}
void _schedule() {
if (!_enabled || !_initialized) return;
final handler = _handler;
if (handler == null) return;
final now = _now();
final payload = discordPresencePayload(
handler.mediaItem.value,
handler.playbackState.value,
now,
);
if (payload == null) {
_artworkRequest++;
_artworkKey = null;
_artwork = null;
_pending?.cancel();
_pending = null;
_lastPayload = null;
if (_visible) {
_visible = false;
status.value = DiscordPresenceStatus.ready;
unawaited(_invoke('clear'));
}
return;
}
if (!_ready) return;
final item = handler.mediaItem.value!;
final key = [
item.id,
item.title,
item.artist,
item.album,
item.artUri,
item.duration,
].join('\u0000');
if (_artworkKey != key) {
_artworkKey = key;
_artwork = null;
final request = ++_artworkRequest;
if (payload['artwork'] == '') {
unawaited(_loadArtwork(item, request));
}
}
if (payload['artwork'] == '' && _artwork != null) {
payload['artwork'] = _artwork!;
}
if (_samePayload(payload)) return;
final elapsed = _lastUpdate == null
? const Duration(seconds: 15)
: now.difference(_lastUpdate!);
if (elapsed < const Duration(seconds: 15)) {
_pending ??= Timer(const Duration(seconds: 15) - elapsed, () {
_pending = null;
_schedule();
});
return;
}
_pending?.cancel();
_pending = null;
_lastPayload = payload;
_lastUpdate = now;
_visible = true;
unawaited(_invoke('update', payload));
}
Future<void> _loadArtwork(MediaItem item, int request) async {
try {
final url = discordPublicArtwork(await _resolveArtwork(item));
if (!_enabled || request != _artworkRequest || url == null) return;
_artwork = url;
_schedule();
} catch (_) {
// Artwork is optional; an offline lookup cannot interrupt playback.
}
}
Future<void> _invoke(String method, [Map<String, Object>? arguments]) async {
final generation = _generation;
try {
await _channel.invokeMethod<void>(method, arguments);
} catch (_) {
if (_enabled && generation == _generation) {
status.value = DiscordPresenceStatus.unavailable;
}
}
}
Future<void> dispose() async {
await setEnabled(false);
for (final subscription in _subscriptions) {
await subscription.cancel();
}
_subscriptions.clear();
_channel.setMethodCallHandler(null);
status.dispose();
}
}
+2
View File
@@ -6,6 +6,7 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/services/playback_notification.dart';
import 'package:spotiflac_android/services/player_widget_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';
@@ -2092,6 +2093,7 @@ Future<MusicPlayerHandler> _doInitMusicPlayer() async {
_handler = handler;
if (Platform.isAndroid || Platform.isIOS) {
PlayerWidgetService.instance.bind(handler);
DiscordPresenceService.instance.bind(handler);
}
_handlerReadyController.add(handler);
return handler;
@@ -0,0 +1,76 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DiscordPresenceSettings extends ConsumerWidget {
const DiscordPresenceSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final enabled = ref.watch(
settingsProvider.select((s) => s.discordRichPresenceEnabled),
);
final service = DiscordPresenceService.instance;
final l10n = context.l10n;
return ValueListenableBuilder<DiscordPresenceStatus>(
valueListenable: service.status,
builder: (context, status, _) => SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.headphones_outlined,
title: 'Discord Rich Presence',
subtitle: l10n.discordPresenceDescription,
value: enabled,
onChanged: ref
.read(settingsProvider.notifier)
.setDiscordRichPresenceEnabled,
showDivider: enabled,
),
if (enabled)
SettingsItem(
icon: status == DiscordPresenceStatus.active
? Icons.check_circle_outline
: Icons.info_outline,
title: switch (status) {
DiscordPresenceStatus.disabled => l10n.discordPresenceDisabled,
DiscordPresenceStatus.linkRequired => l10n.discordPresenceLink,
DiscordPresenceStatus.connecting =>
l10n.discordPresenceConnecting,
DiscordPresenceStatus.ready => l10n.discordPresenceReady,
DiscordPresenceStatus.active => l10n.discordPresenceActive,
DiscordPresenceStatus.unavailable =>
l10n.discordPresenceUnavailable,
DiscordPresenceStatus.sdkUnavailable =>
l10n.discordPresenceBuildUnavailable,
},
subtitle: Platform.isAndroid
? l10n.discordPresenceAndroidHelp
: l10n.discordPresenceIosHelp,
onTap: status == DiscordPresenceStatus.linkRequired
? () => unawaited(service.link())
: null,
showDivider: Platform.isIOS,
),
if (enabled && Platform.isIOS)
SettingsItem(
icon: Icons.link_off,
title: l10n.discordPresenceDisconnect,
onTap: () {
ref
.read(settingsProvider.notifier)
.setDiscordRichPresenceEnabled(false);
unawaited(service.forgetAccount());
},
showDivider: false,
),
],
),
);
}
}