mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 14:37:33 +02:00
feat(discord): add opt-in mobile rich presence with track artwork
This commit is contained in:
1 parent
bbe2caa754
commit
0b2cf0e4e5
40 files changed
+1839
-18
No files matched your search
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user