diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 74400f41..a84ebebd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,7 @@ jobs:
filters: |
dart:
- 'third_party/audio_service/**'
+ - 'third_party/spotiflac_discord/**'
- 'lib/**'
- 'test/**'
- 'android/app/src/test/resources/**'
@@ -50,6 +51,8 @@ jobs:
- '.github/workflows/ci.yml'
ios:
- 'third_party/audio_service/**'
+ - 'third_party/spotiflac_discord/**'
+ - 'scripts/setup_discord_sdk.sh'
- 'ios/**'
- 'scripts/build_ios.sh'
- 'scripts/check_backend_ios.py'
@@ -63,6 +66,8 @@ jobs:
- '.github/workflows/ci.yml'
android:
- 'third_party/audio_service/**'
+ - 'third_party/spotiflac_discord/**'
+ - 'scripts/setup_discord_sdk.sh'
- 'android/**'
- 'rust_backend/**'
- 'scripts/build_android.sh'
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 05496bf7..267263b3 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -35,6 +35,9 @@ android {
buildFeatures {
buildConfig = true
}
+ packaging {
+ jniLibs.pickFirsts += "**/libdiscord_partner_sdk.so"
+ }
externalNativeBuild {
cmake {
@@ -167,6 +170,10 @@ flutter {
}
dependencies {
+ val discordSdk = file("../../third_party/spotiflac_discord/sdk/discord_partner_sdk.aar")
+ if (discordSdk.exists()) {
+ implementation(files(discordSdk))
+ }
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
implementation("net.java.dev.jna:jna:5.19.1@aar")
@@ -180,6 +187,9 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20260814")
+ // Keep Flutter integration_test's older runner aligned with native tests.
+ debugImplementation("androidx.test:runner:1.7.0")
+ debugImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b00bce3b..5afcd9a7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -38,6 +38,9 @@
android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locale_config">
+
+
+
('initialize'), isTrue);
+ await tester.pump(const Duration(milliseconds: 350));
+ await channel.invokeMethod('clear');
+ } finally {
+ await channel.invokeMethod('shutdown');
+ }
+ }
+ });
+}
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
index ec97fc6f..a9a38d20 100644
--- a/ios/Flutter/Debug.xcconfig
+++ b/ios/Flutter/Debug.xcconfig
@@ -1,2 +1,3 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+#include? "../../third_party/spotiflac_discord/ios/Frameworks/DiscordSimulator.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
index c4855bfe..e3b83f5f 100644
--- a/ios/Flutter/Release.xcconfig
+++ b/ios/Flutter/Release.xcconfig
@@ -1,2 +1,3 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+#include? "../../third_party/spotiflac_discord/ios/Frameworks/DiscordSimulator.xcconfig"
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 7e853beb..0ce66ad8 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2,11 +2,14 @@ PODS:
- Flutter (1.0.0)
- open_filex (0.0.2):
- Flutter
+ - spotiflac_discord (0.1.0):
+ - Flutter
- SpotiFLACBackend (0.1.0)
DEPENDENCIES:
- Flutter (from `Flutter`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
+ - spotiflac_discord (from `.symlinks/plugins/spotiflac_discord/ios`)
- SpotiFLACBackend (from `../rust_backend/target/ios`)
EXTERNAL SOURCES:
@@ -14,12 +17,15 @@ EXTERNAL SOURCES:
:path: Flutter
open_filex:
:path: ".symlinks/plugins/open_filex/ios"
+ spotiflac_discord:
+ :path: ".symlinks/plugins/spotiflac_discord/ios"
SpotiFLACBackend:
:path: "../rust_backend/target/ios"
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
+ spotiflac_discord: 4f9417d5583e2d8a8ef15b5a746041d2cb064fd5
SpotiFLACBackend: e0c81656e8e4c31e86b9f14bbb8e245fb3a14011
PODFILE CHECKSUM: 6ca58d78915f3e4fcb64938b9167b26e33d44a8a
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 4efc1f8c..553675fb 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -114,6 +114,7 @@
CFBundleURLSchemes
spotiflac
+ discord-1549854098801692862
@@ -122,6 +123,7 @@
LSApplicationQueriesSchemes
spotify
+ discord
deezer
tidal
youtube-music
diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb
index e869d47a..78d2b008 100644
--- a/lib/l10n/arb/app_en.arb
+++ b/lib/l10n/arb/app_en.arb
@@ -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"
diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb
index 7ccd4f4f..70794bcb 100644
--- a/lib/l10n/arb/app_id.arb
+++ b/lib/l10n/arb/app_id.arb
@@ -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"
}
diff --git a/lib/models/settings.dart b/lib/models/settings.dart
index acf3b190..67e37496 100644
--- a/lib/models/settings.dart
+++ b/lib/models/settings.dart
@@ -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,
diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart
index 109ce340..72fff67f 100644
--- a/lib/models/settings.g.dart
+++ b/lib/models/settings.g.dart
@@ -23,6 +23,8 @@ AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson(
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'autoMix': instance.autoMix,
+ 'discordRichPresenceEnabled': instance.discordRichPresenceEnabled,
'usbBitPerfect': instance.usbBitPerfect,
'usbDirect': instance.usbDirect,
'usbDsdOverPcm': instance.usbDsdOverPcm,
diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart
index a423281a..1722b0ca 100644
--- a/lib/providers/settings_provider.dart
+++ b/lib/providers/settings_provider.dart
@@ -556,6 +556,11 @@ class SettingsNotifier extends Notifier {
_saveSettings();
}
+ void setDiscordRichPresenceEnabled(bool enabled) {
+ state = state.copyWith(discordRichPresenceEnabled: enabled);
+ _saveSettings();
+ }
+
void setAutoplay(bool enabled) {
state = state.copyWith(autoplay: enabled);
_saveSettings();
diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart
index 86086732..792b4c14 100644
--- a/lib/screens/main_shell.dart
+++ b/lib/screens/main_shell.dart
@@ -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
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
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),
diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart
index 7e404fd0..2040b5fd 100644
--- a/lib/screens/settings/playback_settings_page.dart
+++ b/lib/screens/settings/playback_settings_page.dart
@@ -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 {
],
),
),
+ if (Platform.isAndroid || Platform.isIOS) ...[
+ const SliverToBoxAdapter(
+ child: SettingsSectionHeader(title: 'Discord'),
+ ),
+ const SliverToBoxAdapter(child: DiscordPresenceSettings()),
+ ],
if (Platform.isAndroid)
SliverToBoxAdapter(
child: SettingsSectionHeader(
diff --git a/lib/services/discord_artwork_resolver.dart b/lib/services/discord_artwork_resolver.dart
new file mode 100644
index 00000000..cb9d1b74
--- /dev/null
+++ b/lib/services/discord_artwork_resolver.dart
@@ -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