mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user