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

+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;