mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +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,133 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/discord_artwork_resolver.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final item = MediaItem(
|
||||
id: 'local:1',
|
||||
title: 'Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
duration: const Duration(seconds: 240),
|
||||
artUri: Uri.file('/private/cover.jpg'),
|
||||
);
|
||||
Track candidate({
|
||||
String name = 'Song',
|
||||
String artist = 'Artist',
|
||||
String album = 'Album',
|
||||
int duration = 240,
|
||||
}) => Track(
|
||||
id: 'catalog:1',
|
||||
name: name,
|
||||
artistName: artist,
|
||||
albumName: album,
|
||||
duration: duration,
|
||||
coverUrl: 'https://example.com/cover.jpg',
|
||||
);
|
||||
|
||||
test('accepts public CDN sizing but excludes private and signed URLs', () {
|
||||
expect(
|
||||
discordPublicArtwork('https://example.com/a.jpg?w=512&format=jpg'),
|
||||
'https://example.com/a.jpg?w=512&format=jpg',
|
||||
);
|
||||
for (final url in [
|
||||
'file:///private/cover.jpg',
|
||||
'content://media/1',
|
||||
'https://localhost/a.jpg',
|
||||
'https://127.0.0.1/a.jpg',
|
||||
'https://192.168.0.2/a.jpg',
|
||||
'https://nas.local/a.jpg',
|
||||
'https://user:password@example.com/a.jpg',
|
||||
'https://example.com/a.jpg?token=secret',
|
||||
'https://example.com/a.jpg?signature=secret',
|
||||
]) {
|
||||
expect(discordPublicArtwork(url), isNull, reason: url);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a different remix, artist, album or recording length', () {
|
||||
expect(
|
||||
discordMatchedArtwork(item, [
|
||||
candidate(name: 'Song (Remix)'),
|
||||
candidate(artist: 'Other Artist'),
|
||||
candidate(album: 'Other Album'),
|
||||
candidate(duration: 300),
|
||||
]),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
discordMatchedArtwork(item, [candidate(name: ' song ')]),
|
||||
'https://example.com/cover.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('uses original public history cover without catalog requests', () async {
|
||||
final resolver = DiscordArtworkResolver(
|
||||
historyCover: (_) async => 'https://example.com/original.jpg',
|
||||
search: (_) async => throw StateError('Should not search'),
|
||||
);
|
||||
expect(await resolver.resolve(item), 'https://example.com/original.jpg');
|
||||
});
|
||||
|
||||
test('default catalog searches enabled metadata extensions', () async {
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'searchTracksWithMetadataProviders');
|
||||
expect((call.arguments as Map)['include_extensions'], isTrue);
|
||||
return [
|
||||
{
|
||||
'id': 'catalog:1',
|
||||
'name': 'Song',
|
||||
'artists': 'Artist',
|
||||
'album_name': 'Album',
|
||||
'duration_ms': 240000,
|
||||
'cover_url': 'https://example.com/cover.jpg',
|
||||
},
|
||||
];
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
final resolver = DiscordArtworkResolver(historyCover: (_) async => null);
|
||||
expect(await resolver.resolve(item), 'https://example.com/cover.jpg');
|
||||
});
|
||||
|
||||
test('deduplicates local-cover lookups and caches catalog matches', () async {
|
||||
var lookups = 0;
|
||||
final response = Completer<List<Track>>();
|
||||
final resolver = DiscordArtworkResolver(
|
||||
historyCover: (_) async => null,
|
||||
search: (query) {
|
||||
lookups++;
|
||||
expect(query, 'Song Artist');
|
||||
return response.future;
|
||||
},
|
||||
);
|
||||
final first = resolver.resolve(item);
|
||||
final second = resolver.resolve(item);
|
||||
response.complete([candidate()]);
|
||||
expect(await first, 'https://example.com/cover.jpg');
|
||||
expect(await second, 'https://example.com/cover.jpg');
|
||||
expect(await resolver.resolve(item), 'https://example.com/cover.jpg');
|
||||
expect(lookups, 1);
|
||||
});
|
||||
|
||||
test('offline lookup is optional and negatively cached', () async {
|
||||
var lookups = 0;
|
||||
final resolver = DiscordArtworkResolver(
|
||||
historyCover: (_) async => throw StateError('Storage offline'),
|
||||
search: (_) async {
|
||||
lookups++;
|
||||
throw StateError('Network offline');
|
||||
},
|
||||
);
|
||||
expect(await resolver.resolve(item), isNull);
|
||||
expect(await resolver.resolve(item), isNull);
|
||||
expect(lookups, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/discord_presence_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('test/discord');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
MediaItem track(String title, {String? artwork}) => MediaItem(
|
||||
id: '/private/music/$title.flac',
|
||||
title: title,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
duration: const Duration(minutes: 4),
|
||||
artUri: artwork == null ? null : Uri.parse(artwork),
|
||||
extras: const {'source': 'content://private/tree/music'},
|
||||
);
|
||||
|
||||
PlaybackState playing(DateTime now, {int position = 20, double speed = 1}) =>
|
||||
PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
updatePosition: Duration(seconds: position),
|
||||
updateTime: now,
|
||||
speed: speed,
|
||||
);
|
||||
|
||||
test('old settings default to private and opt-in survives round trip', () {
|
||||
expect(AppSettings.fromJson({}).discordRichPresenceEnabled, isFalse);
|
||||
final settings = const AppSettings().copyWith(
|
||||
discordRichPresenceEnabled: true,
|
||||
);
|
||||
expect(
|
||||
AppSettings.fromJson(settings.toJson()).discordRichPresenceEnabled,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('payload uses seconds, extrapolates progress and omits local data', () {
|
||||
final now = DateTime.utc(2026, 10, 2, 12);
|
||||
final payload = discordPresencePayload(
|
||||
track('Title', artwork: 'file:///private/cover.jpg'),
|
||||
playing(now.subtract(const Duration(seconds: 5))),
|
||||
now,
|
||||
)!;
|
||||
expect(payload['start'], now.millisecondsSinceEpoch ~/ 1000 - 25);
|
||||
expect(payload['end'], now.millisecondsSinceEpoch ~/ 1000 + 215);
|
||||
expect(payload['state'], 'Artist');
|
||||
expect(payload.containsKey('album'), isFalse);
|
||||
expect(payload['artwork'], '');
|
||||
expect(payload.toString(), isNot(contains('/private')));
|
||||
expect(payload.toString(), isNot(contains('content://')));
|
||||
final fast = discordPresencePayload(
|
||||
track('Title'),
|
||||
playing(now, speed: 2),
|
||||
now,
|
||||
)!;
|
||||
expect(fast['end'], now.millisecondsSinceEpoch ~/ 1000 + 110);
|
||||
});
|
||||
|
||||
test('only public HTTPS artwork is eligible; metadata handles Unicode', () {
|
||||
final now = DateTime.utc(2026);
|
||||
for (final uri in [
|
||||
'content://media/1',
|
||||
'http://example.com/cover.jpg',
|
||||
'https://user:secret@example.com/cover.jpg',
|
||||
'https://example.com/cover.jpg?token=private',
|
||||
]) {
|
||||
expect(
|
||||
discordPresencePayload(
|
||||
track('Title', artwork: uri),
|
||||
playing(now),
|
||||
now,
|
||||
)!['artwork'],
|
||||
'',
|
||||
);
|
||||
}
|
||||
final payload = discordPresencePayload(
|
||||
track('🎵' * 150, artwork: 'https://example.com/cover.jpg'),
|
||||
playing(now),
|
||||
now,
|
||||
)!;
|
||||
expect((payload['title']! as String).runes.length, 128);
|
||||
expect(payload['artwork'], 'https://example.com/cover.jpg');
|
||||
expect(
|
||||
discordPresencePayload(track('Title'), PlaybackState(), now),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'off makes no SDK calls; updates coalesce and pause cancels pending track',
|
||||
(tester) async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return call.method == 'initialize' ? true : null;
|
||||
});
|
||||
final service = DiscordPresenceService(
|
||||
channel: channel,
|
||||
requiresLink: false,
|
||||
resolveArtwork: (_) async => null,
|
||||
now: tester.binding.clock.now,
|
||||
);
|
||||
final player = BaseAudioHandler();
|
||||
service.bind(player);
|
||||
player.mediaItem.add(track('First'));
|
||||
player.playbackState.add(playing(tester.binding.clock.now()));
|
||||
await tester.pump();
|
||||
expect(calls, isEmpty);
|
||||
await service.setEnabled(true);
|
||||
await tester.pump();
|
||||
expect(calls.where((c) => c.method == 'update'), hasLength(1));
|
||||
player.mediaItem.add(track('Second'));
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
player.mediaItem.add(track('Latest'));
|
||||
await tester.pump(const Duration(seconds: 12));
|
||||
final updates = calls.where((c) => c.method == 'update').toList();
|
||||
expect(updates, hasLength(2));
|
||||
expect((updates.last.arguments as Map)['title'], 'Latest');
|
||||
player.mediaItem.add(track('Must not send'));
|
||||
player.playbackState.add(PlaybackState());
|
||||
await tester.pump();
|
||||
expect(calls.last.method, 'clear');
|
||||
await tester.pump(const Duration(seconds: 30));
|
||||
expect(calls.where((c) => c.method == 'update'), hasLength(2));
|
||||
await tester.runAsync(service.dispose);
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('position ticks do not publish, but a seek updates timestamps', (
|
||||
tester,
|
||||
) async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return call.method == 'initialize' ? true : null;
|
||||
});
|
||||
final service = DiscordPresenceService(
|
||||
channel: channel,
|
||||
requiresLink: false,
|
||||
resolveArtwork: (_) async => null,
|
||||
now: tester.binding.clock.now,
|
||||
);
|
||||
final player = BaseAudioHandler();
|
||||
service.bind(player);
|
||||
player.mediaItem.add(track('First'));
|
||||
player.playbackState.add(playing(tester.binding.clock.now()));
|
||||
await service.setEnabled(true);
|
||||
await tester.pump();
|
||||
for (var i = 1; i <= 20; i++) {
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
player.playbackState.add(
|
||||
playing(tester.binding.clock.now(), position: 20 + i),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(calls.where((c) => c.method == 'update'), hasLength(1));
|
||||
player.playbackState.add(
|
||||
playing(tester.binding.clock.now(), position: 120),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(calls.where((c) => c.method == 'update'), hasLength(2));
|
||||
await tester.runAsync(service.dispose);
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'late artwork follows the current track and stops when disabled',
|
||||
(tester) async {
|
||||
final calls = <MethodCall>[];
|
||||
final requests = <String, Completer<String?>>{};
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return call.method == 'initialize' ? true : null;
|
||||
});
|
||||
final service = DiscordPresenceService(
|
||||
channel: channel,
|
||||
requiresLink: false,
|
||||
now: tester.binding.clock.now,
|
||||
resolveArtwork: (item) =>
|
||||
(requests[item.title] = Completer<String?>()).future,
|
||||
);
|
||||
final player = BaseAudioHandler();
|
||||
service.bind(player);
|
||||
player.mediaItem.add(track('First'));
|
||||
player.playbackState.add(playing(tester.binding.clock.now()));
|
||||
await tester.pump();
|
||||
expect(requests, isEmpty);
|
||||
await service.setEnabled(true);
|
||||
await tester.pump();
|
||||
player.mediaItem.add(track('Second'));
|
||||
await tester.pump();
|
||||
requests['First']!.complete('https://example.com/first.jpg');
|
||||
requests['Second']!.complete('https://example.com/second.jpg');
|
||||
await tester.pump(const Duration(seconds: 15));
|
||||
final updates = calls.where((call) => call.method == 'update').toList();
|
||||
expect(updates, hasLength(2));
|
||||
expect((updates.last.arguments as Map)['title'], 'Second');
|
||||
expect(
|
||||
(updates.last.arguments as Map)['artwork'],
|
||||
'https://example.com/second.jpg',
|
||||
);
|
||||
expect(
|
||||
updates.any(
|
||||
(call) =>
|
||||
(call.arguments as Map)['artwork'] ==
|
||||
'https://example.com/first.jpg',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
player.mediaItem.add(track('Third'));
|
||||
await tester.pump();
|
||||
await service.setEnabled(false);
|
||||
requests['Third']!.complete('https://example.com/third.jpg');
|
||||
await tester.pump(const Duration(seconds: 30));
|
||||
expect(calls.where((call) => call.method == 'update'), hasLength(2));
|
||||
await tester.runAsync(service.dispose);
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'iOS never opens authorization automatically; disabling ignores a late login',
|
||||
(tester) async {
|
||||
final calls = <MethodCall>[];
|
||||
final login = Completer<Map<String, Object>>();
|
||||
final stored = <String?>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
if (call.method == 'initialize') return true;
|
||||
if (call.method == 'authorize') return login.future;
|
||||
return null;
|
||||
});
|
||||
final service = DiscordPresenceService(
|
||||
channel: channel,
|
||||
requiresLink: true,
|
||||
resolveArtwork: (_) async => null,
|
||||
readRefreshToken: () async => null,
|
||||
writeRefreshToken: (token) async => stored.add(token),
|
||||
);
|
||||
await service.setEnabled(true);
|
||||
expect(service.status.value, DiscordPresenceStatus.linkRequired);
|
||||
expect(calls.map((c) => c.method), ['initialize']);
|
||||
final linking = service.link();
|
||||
await tester.pump();
|
||||
await service.setEnabled(false);
|
||||
login.complete({
|
||||
'access': 'test-access',
|
||||
'refresh': 'test-refresh',
|
||||
'expiresIn': 3600,
|
||||
});
|
||||
await linking;
|
||||
expect(stored, isEmpty);
|
||||
expect(calls.where((c) => c.method == 'connect'), isEmpty);
|
||||
expect(service.status.value, DiscordPresenceStatus.disabled);
|
||||
await tester.runAsync(service.dispose);
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('SDK failure stays isolated from playback', (tester) async {
|
||||
messenger.setMockMethodCallHandler(
|
||||
channel,
|
||||
(_) async => throw PlatformException(code: 'sdk_unavailable'),
|
||||
);
|
||||
final service = DiscordPresenceService(
|
||||
channel: channel,
|
||||
requiresLink: false,
|
||||
resolveArtwork: (_) async => null,
|
||||
);
|
||||
final player = BaseAudioHandler();
|
||||
service.bind(player);
|
||||
player.mediaItem.add(track('First'));
|
||||
player.playbackState.add(playing(DateTime.now()));
|
||||
await service.setEnabled(true);
|
||||
expect(service.status.value, DiscordPresenceStatus.sdkUnavailable);
|
||||
expect(player.playbackState.value.playing, isTrue);
|
||||
await tester.runAsync(service.dispose);
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
testWidgets('saved iOS account rotates refresh token and waits for ready', (
|
||||
tester,
|
||||
) async {
|
||||
final calls = <MethodCall>[];
|
||||
final stored = <String?>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
if (call.method == 'initialize') return true;
|
||||
if (call.method == 'refresh') {
|
||||
expect((call.arguments as Map)['refresh'], 'previous-refresh');
|
||||
return {
|
||||
'access': 'temporary-access',
|
||||
'refresh': 'rotated-refresh',
|
||||
'expiresIn': 3600,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final service = DiscordPresenceService(
|
||||
channel: channel,
|
||||
requiresLink: true,
|
||||
resolveArtwork: (_) async => null,
|
||||
readRefreshToken: () async => 'previous-refresh',
|
||||
writeRefreshToken: (token) async => stored.add(token),
|
||||
now: tester.binding.clock.now,
|
||||
);
|
||||
final player = BaseAudioHandler();
|
||||
service.bind(player);
|
||||
player.mediaItem.add(track('First'));
|
||||
player.playbackState.add(playing(tester.binding.clock.now()));
|
||||
await service.setEnabled(true);
|
||||
await tester.pump();
|
||||
expect(stored, ['rotated-refresh']);
|
||||
expect(calls.where((c) => c.method == 'authorize'), isEmpty);
|
||||
expect(calls.where((c) => c.method == 'update'), isEmpty);
|
||||
final handled = Completer<void>();
|
||||
messenger.handlePlatformMessage(
|
||||
channel.name,
|
||||
channel.codec.encodeMethodCall(const MethodCall('status', 'ready')),
|
||||
(_) => handled.complete(),
|
||||
);
|
||||
await tester.pump();
|
||||
await handled.future;
|
||||
expect(calls.where((c) => c.method == 'update'), hasLength(1));
|
||||
await service.forgetAccount();
|
||||
expect(stored, ['rotated-refresh', null]);
|
||||
expect(service.status.value, DiscordPresenceStatus.disabled);
|
||||
await tester.runAsync(service.dispose);
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user