From f0ed293d4855ea892883ca72f2cd00ad3206b73f Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:12:24 +0700 Subject: [PATCH] feat: add opt-in Discord Rich Presence on Android Use the official Discord Social SDK through JNI to share built-in player activity, clear presence when playback stops, and guard against late callbacks after disabling. Package the SDK optionally through SPOTIFLAC_DISCORD_SDK_DIR with license notices. Credit: @itsmegaaa for the Rich Presence initiative and contribution in #575 and #576. Validation: 15 Dart tests, scoped Flutter analysis, and one Android 16 ARM64 SDK/JNI instrumentation test passed. Visible presence on a signed-in Discord profile remains unverified. --- android/app/build.gradle.kts | 32 ++++ .../spotiflac/discord/DiscordPresenceTest.kt | 54 +++++++ android/app/src/main/AndroidManifest.xml | 9 ++ android/app/src/main/cpp/CMakeLists.txt | 8 + android/app/src/main/cpp/discord_presence.cpp | 105 ++++++++++++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 1 + .../discord/DiscordPresenceBridge.kt | 113 +++++++++++++ lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_id.arb | 4 + lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 + lib/screens/main_shell.dart | 12 ++ .../settings/library_settings_page.dart | 22 ++- lib/services/discord_presence_service.dart | 135 ++++++++++++++++ lib/services/music_player_service.dart | 2 + test/discord_presence_service_test.dart | 151 ++++++++++++++++++ 17 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 android/app/src/androidTest/kotlin/com/zarz/spotiflac/discord/DiscordPresenceTest.kt create mode 100644 android/app/src/main/cpp/CMakeLists.txt create mode 100644 android/app/src/main/cpp/discord_presence.cpp create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresenceBridge.kt create mode 100644 lib/services/discord_presence_service.dart create mode 100644 test/discord_presence_service_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 81a1de00..425c592a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,6 +15,16 @@ if (keystorePropertiesFile.exists()) { } val rustBackendDir = rootProject.file("../rust_backend") +val discordSdkDir = providers.environmentVariable("SPOTIFLAC_DISCORD_SDK_DIR").orNull +val discordSdkAar = discordSdkDir?.let { file("$it/lib/release/discord_partner_sdk.aar") } +if (discordSdkAar != null) { + require(discordSdkAar.isFile) { "SPOTIFLAC_DISCORD_SDK_DIR must contain the official Social SDK" } +} +val discordNotices = if (discordSdkDir != null) tasks.register("copyDiscordNotices") { + from(file("$discordSdkDir/License-Notices.txt")) + into(layout.buildDirectory.dir("generated/discordAssets")) + rename { "discord-sdk-notices.txt" } +} else null val rustAndroidAbis = providers.environmentVariable("SPOTIFLAC_RUST_ANDROID_ABIS") .orElse("arm64-v8a,armeabi-v7a") .get() @@ -34,12 +44,16 @@ android { buildFeatures { buildConfig = true + prefab = discordSdkAar != null } sourceSets.getByName("main") { java.srcDir("src/rust/kotlin") java.srcDir(rustBackendDir.resolve("target/bindings/kotlin")) jniLibs.srcDir(rustBackendDir.resolve("target/android/jniLibs")) + if (discordNotices != null) { + assets.srcDir(layout.buildDirectory.dir("generated/discordAssets").get().asFile) + } } compileOptions { @@ -67,6 +81,8 @@ android { defaultConfig { applicationId = "com.zarz.spotiflac" + buildConfigField("boolean", "HAS_DISCORD_SDK", (discordSdkAar != null).toString()) + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = flutter.minSdkVersion targetSdk = 37 versionCode = flutter.versionCode @@ -79,6 +95,15 @@ android { } } + if (discordSdkAar != null) { + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + } + buildTypes { getByName("debug") { applicationIdSuffix = ".debug" @@ -144,12 +169,17 @@ val buildRustBackend = tasks.register("buildRustBackend") { outputs.dir(rustBackendDir.resolve("target/android/jniLibs")) } tasks.named("preBuild").configure { dependsOn(buildRustBackend) } +if (discordNotices != null) tasks.named("preBuild").configure { dependsOn(discordNotices) } flutter { source = "../.." } dependencies { + if (discordSdkAar != null) { + implementation(files(discordSdkAar)) + implementation("androidx.browser:browser:1.9.0") + } coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") implementation("net.java.dev.jna:jna:5.17.0@aar") @@ -162,4 +192,6 @@ dependencies { compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1") testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test.ext:junit:1.3.0") } diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/discord/DiscordPresenceTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/discord/DiscordPresenceTest.kt new file mode 100644 index 00000000..2e7c5139 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/discord/DiscordPresenceTest.kt @@ -0,0 +1,54 @@ +package com.zarz.spotiflac.discord + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.zarz.spotiflac.BuildConfig +import com.zarz.spotiflac.MainActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Exercises the actual JNI library/SDK in the emulator, without account credentials. */ +@RunWith(AndroidJUnit4::class) +class DiscordPresenceTest { + @Test + fun testSdkLifecycleAndLateCallbacks() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + assumeTrue("Build with SPOTIFLAC_DISCORD_SDK_DIR", BuildConfig.HAS_DISCORD_SDK) + assumeTrue("This test must not publish to a signed-in account", + instrumentation.targetContext.packageManager.getLaunchIntentForPackage("com.discord") == null) + val activity = instrumentation.startActivitySync( + Intent(instrumentation.targetContext, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + try { + instrumentation.runOnMainSync { + assertEquals("discord_missing", DiscordPresenceBridge.configure(activity, true)) + assertEquals("disabled", DiscordPresenceBridge.configure(activity, false)) + System.loadLibrary("spotiflac_discord") + Class.forName("com.discord.socialsdk.DiscordSocialSdkInit") + .getMethod("setEngineActivity", android.app.Activity::class.java) + .invoke(null, activity) + DiscordNative.start(1549854098801692862L) + DiscordNative.update("Track 🎵", "Lead & Guest", "Album", "", 1000, 2000) + DiscordNative.clear() + DiscordNative.stop() + } + repeat(20) { + Thread.sleep(50) + instrumentation.runOnMainSync { assertEquals("disabled", DiscordNative.poll()) } + } + instrumentation.runOnMainSync { + DiscordNative.start(1549854098801692862L) + assertEquals("ready", DiscordNative.poll()) + DiscordNative.stop() + assertEquals("disabled", DiscordNative.poll()) + } + } finally { + instrumentation.runOnMainSync { activity.finish() } + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d50564ae..ff38d69b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,14 @@ + + + + + + + + +#include +#include +#include + +namespace { +// All entry points and SDK callbacks run on Android's main thread. +std::unique_ptr client; +uint64_t generation = 0; +std::string status = "disabled"; + +std::string utf8(JNIEnv* env, jstring value) { + if (!value) return {}; + // JNI's modified UTF-8 is not suitable for emoji in track titles. + const auto length = env->GetStringLength(value); + const auto* chars = env->GetStringChars(value, nullptr); + std::string out; + for (jsize i = 0; i < length; ++i) { + uint32_t c = chars[i]; + if (c >= 0xd800 && c <= 0xdbff && i + 1 < length && + chars[i + 1] >= 0xdc00 && chars[i + 1] <= 0xdfff) { + c = 0x10000 + ((c - 0xd800) << 10) + (chars[++i] - 0xdc00); + } else if (c >= 0xd800 && c <= 0xdfff) { + c = 0xfffd; + } + if (c < 0x80) out += static_cast(c); + else if (c < 0x800) { + out += static_cast(0xc0 | (c >> 6)); + out += static_cast(0x80 | (c & 0x3f)); + } else if (c < 0x10000) { + out += static_cast(0xe0 | (c >> 12)); + out += static_cast(0x80 | ((c >> 6) & 0x3f)); + out += static_cast(0x80 | (c & 0x3f)); + } else { + out += static_cast(0xf0 | (c >> 18)); + out += static_cast(0x80 | ((c >> 12) & 0x3f)); + out += static_cast(0x80 | ((c >> 6) & 0x3f)); + out += static_cast(0x80 | (c & 0x3f)); + } + } + env->ReleaseStringChars(value, chars); + return out; +} +} + +extern "C" JNIEXPORT void JNICALL +Java_com_zarz_spotiflac_discord_DiscordNative_start(JNIEnv*, jobject, jlong appId) { + if (client) return; + ++generation; + client = std::make_unique(); + client->SetApplicationId(static_cast(appId)); + status = "ready"; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_zarz_spotiflac_discord_DiscordNative_update( + JNIEnv* env, jobject, jstring title, jstring artist, jstring album, + jstring cover, jlong start, jlong end) { + if (!client) return; + discordpp::Activity activity; + activity.SetType(discordpp::ActivityTypes::Listening); + activity.SetDetails(utf8(env, title)); + activity.SetState(utf8(env, artist)); + discordpp::ActivityTimestamps timestamps; + timestamps.SetStart(static_cast(start)); + if (end > start) timestamps.SetEnd(static_cast(end)); + activity.SetTimestamps(timestamps); + const auto image = utf8(env, cover); + if (!image.empty()) { + discordpp::ActivityAssets assets; + assets.SetLargeImage(image); + assets.SetLargeText(utf8(env, album)); + activity.SetAssets(assets); + } + const auto current = ++generation; + status = "updating"; + client->UpdateRichPresence(activity, [current](discordpp::ClientResult result) { + if (current != generation) return; + status = result.Successful() ? "sharing" : "unavailable"; + }); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_zarz_spotiflac_discord_DiscordNative_clear(JNIEnv*, jobject) { + ++generation; + if (client) client->ClearRichPresence(); + status = client ? "ready" : "disabled"; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_zarz_spotiflac_discord_DiscordNative_stop(JNIEnv*, jobject) { + ++generation; + if (client) client->ClearRichPresence(); + client.reset(); + status = "disabled"; +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_zarz_spotiflac_discord_DiscordNative_poll(JNIEnv* env, jobject) { + discordpp::RunCallbacks(); + return env->NewStringUTF(status.c_str()); +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 554cf8be..313b206b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -949,6 +949,7 @@ class MainActivity: FlutterFragmentActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + com.zarz.spotiflac.discord.DiscordPresenceBridge.attach(this, flutterEngine.dartExecutor.binaryMessenger) // Select and initialize the runtime before Flutter can dispatch a call. val selectedBackend = coreBackend sweepStaleCacheFiles() diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresenceBridge.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresenceBridge.kt new file mode 100644 index 00000000..773fcc6e --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/discord/DiscordPresenceBridge.kt @@ -0,0 +1,113 @@ +package com.zarz.spotiflac.discord + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.zarz.spotiflac.BuildConfig +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel + +internal object DiscordNative { + external fun start(appId: Long) + external fun update(title: String, artist: String, album: String, cover: String, start: Long, end: Long) + external fun clear() + external fun stop() + external fun poll(): String +} + +/** Official client RPC only. No user tokens, custom Gateway, or background login. */ +internal object DiscordPresenceBridge { + private const val APPLICATION_ID = 1549854098801692862L + private val handler = Handler(Looper.getMainLooper()) + private var loaded = false + private var enabled = false + private var status = "disabled" + private var channel: MethodChannel? = null + private val pump = object : Runnable { + override fun run() { + if (!enabled) return + val next = DiscordNative.poll() + if (next != status) { + status = next + channel?.invokeMethod("status", status) + } + if (status != "unavailable") handler.postDelayed(this, 250) + } + } + + fun attach(activity: Activity, messenger: BinaryMessenger) { + channel?.setMethodCallHandler(null) + channel = MethodChannel(messenger, "com.zarz.spotiflac/discord").also { channel -> + channel.setMethodCallHandler { call, result -> + try { + when (call.method) { + "configure" -> result.success(configure(activity, call.argument("enabled") == true)) + "update" -> { + if (enabled) { + DiscordNative.start(APPLICATION_ID) + val cover = call.argument("cover").orEmpty() + DiscordNative.update( + call.argument("title").orEmpty().take(128), + call.argument("artist").orEmpty().take(128), + call.argument("album").orEmpty().take(128), + cover.takeIf { it.startsWith("https://") && it.length <= 300 }.orEmpty(), + call.argument("start")?.toLong()?.coerceAtLeast(0) ?: 0, + call.argument("end")?.toLong()?.coerceAtLeast(0) ?: 0, + ) + handler.removeCallbacks(pump) + handler.post(pump) + } + result.success(null) + } + "clear" -> { + if (enabled) { + handler.removeCallbacks(pump) + DiscordNative.stop() + status = "ready" + channel.invokeMethod("status", status) + } + result.success(null) + } + "status" -> result.success(status) + else -> result.notImplemented() + } + } catch (error: Exception) { + result.error("discord_unavailable", "Discord presence is unavailable", null) + } catch (error: LinkageError) { + result.error("discord_unavailable", "Discord SDK could not be loaded", null) + } + } + } + if (loaded) setActivity(activity) + } + + private fun setActivity(activity: Activity) { + Class.forName("com.discord.socialsdk.DiscordSocialSdkInit") + .getMethod("setEngineActivity", Activity::class.java).invoke(null, activity) + } + + internal fun configure(activity: Activity, requested: Boolean): String { + check(Looper.myLooper() == Looper.getMainLooper()) + handler.removeCallbacks(pump) + enabled = false + if (loaded) DiscordNative.stop() + if (!requested) { + status = "disabled" + return status + } + if (!BuildConfig.HAS_DISCORD_SDK) return "sdk_unavailable".also { status = it } + if (!isDiscordInstalled(activity)) return "discord_missing".also { status = it } + if (!loaded) { + System.loadLibrary("spotiflac_discord") + loaded = true + } + setActivity(activity) + enabled = true + status = "ready" + return status + } + + private fun isDiscordInstalled(context: Context): Boolean = + context.packageManager.getLaunchIntentForPackage("com.discord") != null +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 19a5411a..2301b63b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,8 @@ { + "discordRichPresence": "Discord Rich Presence", + "discordPresenceDescription": "Share the track playing in the built-in player on Discord. Requires Discord to be installed and signed in.", + "discordPresenceMissing": "Install Discord and sign in, then turn this setting off and on again.", + "discordPresenceUnavailable": "Discord Rich Presence is unavailable in this build or on this device.", "@@locale": "en", "@@last_modified": "2026-04-28", "appName": "SpotiFLAC Mobile", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index b087c3c7..edad0b4e 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,4 +1,8 @@ { + "discordRichPresence": "Discord Rich Presence", + "discordPresenceDescription": "Bagikan lagu yang diputar di pemutar bawaan ke Discord. Discord harus terpasang dan sudah login.", + "discordPresenceMissing": "Pasang Discord dan login, lalu matikan dan nyalakan kembali pengaturan ini.", + "discordPresenceUnavailable": "Discord Rich Presence tidak tersedia pada build atau perangkat ini.", "@@locale": "id", "@@last_modified": "2026-04-28", "libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 1c4186cc..92c2c1e9 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -31,6 +31,7 @@ class AppSettings { final bool embedReplayGain; // Apply ReplayGain/R128 tags as volume normalization in the built-in player. final bool playbackNormalization; + final bool discordRichPresence; final bool isFirstLaunch; final bool checkForUpdates; final String updateChannel; @@ -138,6 +139,7 @@ class AppSettings { this.embedLyrics = true, this.embedReplayGain = false, this.playbackNormalization = false, + this.discordRichPresence = false, this.isFirstLaunch = true, this.checkForUpdates = true, this.updateChannel = 'stable', @@ -221,6 +223,7 @@ class AppSettings { bool? embedLyrics, bool? embedReplayGain, bool? playbackNormalization, + bool? discordRichPresence, bool? isFirstLaunch, bool? checkForUpdates, String? updateChannel, @@ -300,6 +303,7 @@ class AppSettings { embedReplayGain: embedReplayGain ?? this.embedReplayGain, playbackNormalization: playbackNormalization ?? this.playbackNormalization, + discordRichPresence: discordRichPresence ?? this.discordRichPresence, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, checkForUpdates: checkForUpdates ?? this.checkForUpdates, updateChannel: updateChannel ?? this.updateChannel, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index aff5b88d..444d9d96 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( embedLyrics: json['embedLyrics'] as bool? ?? true, embedReplayGain: json['embedReplayGain'] as bool? ?? false, playbackNormalization: json['playbackNormalization'] as bool? ?? false, + discordRichPresence: json['discordRichPresence'] as bool? ?? false, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true, updateChannel: json['updateChannel'] as String? ?? 'stable', @@ -117,6 +118,7 @@ Map _$AppSettingsToJson( 'embedLyrics': instance.embedLyrics, 'embedReplayGain': instance.embedReplayGain, 'playbackNormalization': instance.playbackNormalization, + 'discordRichPresence': instance.discordRichPresence, 'isFirstLaunch': instance.isFirstLaunch, 'checkForUpdates': instance.checkForUpdates, 'updateChannel': instance.updateChannel, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 6acaff66..4d370651 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -551,6 +551,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setDiscordRichPresence(bool enabled) { + state = state.copyWith(discordRichPresence: enabled); + _saveSettings(); + } + void setEmbedMetadata(bool enabled) { state = state.copyWith(embedMetadata: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index a80dd84e..51ef9bb5 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/repo_provider.dart'; @@ -79,6 +80,11 @@ class _MainShellState extends ConsumerState setPlaybackNormalizationEnabled( ref.read(settingsProvider).playbackNormalization, ); + unawaited( + DiscordPresenceService.instance.setEnabled( + ref.read(settingsProvider).discordRichPresence, + ), + ); // Deezer & co. localize artist/genre names by IP unless told the app's // language (issue #480). unawaited( @@ -631,6 +637,12 @@ class _MainShellState extends ConsumerState @override Widget build(BuildContext context) { + ref.listen(settingsProvider.select((s) => s.discordRichPresence), ( + _, + enabled, + ) { + unawaited(DiscordPresenceService.instance.setEnabled(enabled)); + }); ref.listen(settingsProvider.select((s) => s.playbackNormalization), ( _, enabled, diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 7aff8114..0f653811 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/discord_presence_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart'; @@ -910,8 +911,27 @@ class _LibrarySettingsPageState extends ConsumerState { onChanged: (v) => ref .read(settingsProvider.notifier) .setPlaybackNormalization(v), - showDivider: false, + showDivider: Platform.isAndroid, ), + if (Platform.isAndroid) + ValueListenableBuilder( + valueListenable: DiscordPresenceService.instance.status, + builder: (context, status, _) => SettingsSwitchItem( + icon: Icons.share_outlined, + title: context.l10n.discordRichPresence, + subtitle: status == 'discord_missing' + ? context.l10n.discordPresenceMissing + : status == 'sdk_unavailable' || + status == 'unavailable' + ? context.l10n.discordPresenceUnavailable + : context.l10n.discordPresenceDescription, + value: settings.discordRichPresence, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setDiscordRichPresence(value), + showDivider: false, + ), + ), ], ), ), diff --git a/lib/services/discord_presence_service.dart b/lib/services/discord_presence_service.dart new file mode 100644 index 00000000..754cf03a --- /dev/null +++ b/lib/services/discord_presence_service.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Opt-in Rich Presence, inspired by @itsmegaaa's contribution in #575/#576. +/// Playback remains independent of Discord availability. +class DiscordPresenceService { + DiscordPresenceService({ + MethodChannel channel = const MethodChannel('com.zarz.spotiflac/discord'), + }) : _channel = channel { + _channel.setMethodCallHandler((call) async { + if (call.method == 'status' && _enabled && _transportReady) { + status.value = call.arguments as String; + } + }); + } + + static final instance = DiscordPresenceService(); + final MethodChannel _channel; + final ValueNotifier status = ValueNotifier('disabled'); + StreamSubscription? _mediaSubscription; + StreamSubscription? _stateSubscription; + MediaItem? _item; + PlaybackState? _playback; + bool _enabled = false; + bool _configured = false; + bool _transportReady = false; + int _generation = 0; + String? _lastPayload; + int? _lastStart; + Future _pending = Future.value(); + + void bind(AudioHandler handler) { + unawaited(_mediaSubscription?.cancel()); + unawaited(_stateSubscription?.cancel()); + _mediaSubscription = handler.mediaItem.listen((item) { + _item = item; + _publish(); + }); + _stateSubscription = handler.playbackState.listen((state) { + _playback = state; + _publish(); + }); + } + + Future setEnabled(bool enabled) { + if (_configured && _enabled == enabled) return _pending; + _configured = true; + _enabled = enabled; + _transportReady = false; + final generation = ++_generation; + _lastPayload = null; + _lastStart = null; + _pending = _pending.then((_) async { + try { + final result = await _channel.invokeMethod('configure', { + 'enabled': enabled, + }); + if (generation != _generation) return; + status.value = result ?? 'unavailable'; + _transportReady = enabled && result == 'ready'; + if (_transportReady) _publish(); + } on PlatformException { + if (generation == _generation) status.value = 'unavailable'; + } on MissingPluginException { + if (generation == _generation) status.value = 'sdk_unavailable'; + } + }); + return _pending; + } + + void _publish() { + if (!_enabled || !_transportReady) return; + final item = _item; + final playback = _playback; + final playing = + item != null && + playback != null && + playback.playing && + playback.processingState == AudioProcessingState.ready; + final position = playback?.position ?? Duration.zero; + final now = DateTime.now().millisecondsSinceEpoch; + final start = now - position.inMilliseconds; + final payload = playing + ? { + 'title': item.title, + 'artist': item.artist ?? '', + 'album': item.album ?? '', + 'cover': item.artUri?.scheme == 'https' + ? item.artUri.toString() + : '', + 'start': start, + 'end': item.duration == null + ? 0 + : start + item.duration!.inMilliseconds, + } + : null; + // Position streams tick frequently. Only republish on track/state changes + // or a seek that shifts the playback timeline by at least two seconds. + final key = playing + ? '${item.id}|${item.title}|${item.artist}|${item.album}|${item.artUri}|${item.duration}' + : 'clear'; + if (key == _lastPayload && + (!playing || + (_lastStart != null && (start - _lastStart!).abs() < 2000))) { + return; + } + _lastPayload = key; + _lastStart = start; + final generation = _generation; + _pending = _pending.then((_) async { + if (!_enabled || generation != _generation) return; + try { + await _channel.invokeMethod( + payload == null ? 'clear' : 'update', + payload, + ); + } on PlatformException { + _lastPayload = null; + } on MissingPluginException { + _lastPayload = null; + } + }); + } + + Future dispose() async { + await _mediaSubscription?.cancel(); + await _stateSubscription?.cancel(); + await setEnabled(false); + _channel.setMethodCallHandler(null); + status.dispose(); + } +} diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 2c974767..d97f73c2 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math'; import 'package:audio_service/audio_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'; @@ -1545,6 +1546,7 @@ Future _doInitMusicPlayer() async { ), ); _handler = handler; + DiscordPresenceService.instance.bind(handler); _handlerReadyController.add(handler); return handler; } catch (_) { diff --git a/test/discord_presence_service_test.dart b/test/discord_presence_service_test.dart new file mode 100644 index 00000000..d3f2f33f --- /dev/null +++ b/test/discord_presence_service_test.dart @@ -0,0 +1,151 @@ +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'); + late DiscordPresenceService service; + late BaseAudioHandler player; + late List calls; + + Future settle() async { + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } + } + + setUp(() { + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'configure' + ? ((call.arguments as Map)['enabled'] == true + ? 'ready' + : 'disabled') + : null; + }); + player = BaseAudioHandler(); + service = DiscordPresenceService(channel: channel)..bind(player); + }); + + tearDown(() async { + await service.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + void play({String title = 'Track 🎵'}) { + player.mediaItem.add( + MediaItem( + id: 'local-track', + title: title, + artist: 'Lead & Guest', + album: 'Album', + duration: const Duration(minutes: 3), + artUri: Uri.parse('file:///private/cover.jpg'), + ), + ); + player.playbackState.add( + PlaybackState(playing: true, processingState: AudioProcessingState.ready), + ); + } + + test('opt-in persists and defaults to off', () { + expect(AppSettings.fromJson({}).discordRichPresence, isFalse); + final settings = const AppSettings().copyWith(discordRichPresence: true); + expect(AppSettings.fromJson(settings.toJson()).discordRichPresence, isTrue); + }); + + test('publishes playback only after opt-in and clears on pause', () async { + play(); + await settle(); + expect(calls, isEmpty); + await service.setEnabled(true); + await settle(); + final update = calls.singleWhere((c) => c.method == 'update'); + final data = update.arguments as Map; + expect(data['title'], 'Track 🎵'); + expect(data['artist'], 'Lead & Guest'); + expect(data['cover'], ''); + expect((data['end'] as int) - (data['start'] as int), 180000); + player.playbackState.add( + PlaybackState( + playing: false, + processingState: AudioProcessingState.ready, + ), + ); + await settle(); + expect(calls.last.method, 'clear'); + await service.setEnabled(false); + final count = calls.length; + play(title: 'Next'); + await settle(); + expect(calls.length, count); + }); + + test('late configure completion cannot republish after disabling', () async { + final ready = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return (call.arguments as Map)['enabled'] == true + ? ready.future + : 'disabled'; + }); + play(); + final enabling = service.setEnabled(true); + await settle(); + final disabling = service.setEnabled(false); + ready.complete('ready'); + await Future.wait([enabling, disabling]); + await settle(); + expect(calls.map((c) => c.method), ['configure', 'configure']); + expect(service.status.value, 'disabled'); + }); + + test('position ticks are deduplicated but seek republishes', () async { + play(); + await service.setEnabled(true); + await settle(); + final initial = calls.where((c) => c.method == 'update').length; + for (var i = 0; i < 8; i++) { + player.playbackState.add( + PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + ), + ); + } + await settle(); + expect(calls.where((c) => c.method == 'update').length, initial); + player.playbackState.add( + PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 30), + ), + ); + await settle(); + expect(calls.where((c) => c.method == 'update').length, initial + 1); + }); + + test('missing Discord does not publish or break playback', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return 'discord_missing'; + }); + play(); + await service.setEnabled(true); + await settle(); + expect(service.status.value, 'discord_missing'); + expect(calls.map((c) => c.method), ['configure']); + expect(player.playbackState.value.playing, isTrue); + }); +}