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.
This commit is contained in:
zarzet committed 2026-09-17 02:12:24 +07:00
1 parent b2ea07c41e
commit f0ed293d48
17 files changed
+662 -1

No files matched your search

+32
View File
@@ -15,6 +15,16 @@ if (keystorePropertiesFile.exists()) {
} }
val rustBackendDir = rootProject.file("../rust_backend") 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<Copy>("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") val rustAndroidAbis = providers.environmentVariable("SPOTIFLAC_RUST_ANDROID_ABIS")
.orElse("arm64-v8a,armeabi-v7a") .orElse("arm64-v8a,armeabi-v7a")
.get() .get()
@@ -34,12 +44,16 @@ android {
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
prefab = discordSdkAar != null
} }
sourceSets.getByName("main") { sourceSets.getByName("main") {
java.srcDir("src/rust/kotlin") java.srcDir("src/rust/kotlin")
java.srcDir(rustBackendDir.resolve("target/bindings/kotlin")) java.srcDir(rustBackendDir.resolve("target/bindings/kotlin"))
jniLibs.srcDir(rustBackendDir.resolve("target/android/jniLibs")) jniLibs.srcDir(rustBackendDir.resolve("target/android/jniLibs"))
if (discordNotices != null) {
assets.srcDir(layout.buildDirectory.dir("generated/discordAssets").get().asFile)
}
} }
compileOptions { compileOptions {
@@ -67,6 +81,8 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.zarz.spotiflac" applicationId = "com.zarz.spotiflac"
buildConfigField("boolean", "HAS_DISCORD_SDK", (discordSdkAar != null).toString())
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = 37 targetSdk = 37
versionCode = flutter.versionCode 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 { buildTypes {
getByName("debug") { getByName("debug") {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
@@ -144,12 +169,17 @@ val buildRustBackend = tasks.register<Exec>("buildRustBackend") {
outputs.dir(rustBackendDir.resolve("target/android/jniLibs")) outputs.dir(rustBackendDir.resolve("target/android/jniLibs"))
} }
tasks.named("preBuild").configure { dependsOn(buildRustBackend) } tasks.named("preBuild").configure { dependsOn(buildRustBackend) }
if (discordNotices != null) tasks.named("preBuild").configure { dependsOn(discordNotices) }
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies { dependencies {
if (discordSdkAar != null) {
implementation(files(discordSdkAar))
implementation("androidx.browser:browser:1.9.0")
}
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
implementation("net.java.dev.jna:jna:5.17.0@aar") implementation("net.java.dev.jna:jna:5.17.0@aar")
@@ -162,4 +192,6 @@ dependencies {
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1") compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
} }
@@ -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() }
}
}
}
+9
View File
@@ -1,7 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.zarz.spotiflac"> package="com.zarz.spotiflac">
<!-- Permissions --> <!-- Permissions -->
<!-- Presence never records audio or uses the SDK voice service. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" />
<uses-permission android:name="android.permission.BLUETOOTH" tools:node="remove" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" tools:node="remove" />
<queries><package android:name="com.discord" /></queries>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -29,6 +36,8 @@
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locale_config"> android:localeConfig="@xml/locale_config">
<service android:name="com.discord.socialsdk.ForegroundService" tools:node="remove" />
<activity android:name="com.discord.socialsdk.AuthenticationActivity" tools:node="remove" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
+8
View File
@@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.22.1)
project(spotiflac_discord LANGUAGES CXX)
find_package(discord_partner_sdk REQUIRED CONFIG)
add_library(spotiflac_discord SHARED discord_presence.cpp)
target_compile_features(spotiflac_discord PRIVATE cxx_std_20)
target_link_libraries(spotiflac_discord discord_partner_sdk::discord_partner_sdk)
target_link_options(spotiflac_discord PRIVATE "-Wl,-z,max-page-size=16384")
@@ -0,0 +1,105 @@
// Rich Presence initiative: @itsmegaaa, SpotiFLAC-Mobile #575 / #576.
// Uses the official Social SDK transport instead of a custom user Gateway.
#define DISCORDPP_IMPLEMENTATION
#include <discordpp.h>
#include <jni.h>
#include <memory>
#include <string>
namespace {
// All entry points and SDK callbacks run on Android's main thread.
std::unique_ptr<discordpp::Client> 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<char>(c);
else if (c < 0x800) {
out += static_cast<char>(0xc0 | (c >> 6));
out += static_cast<char>(0x80 | (c & 0x3f));
} else if (c < 0x10000) {
out += static_cast<char>(0xe0 | (c >> 12));
out += static_cast<char>(0x80 | ((c >> 6) & 0x3f));
out += static_cast<char>(0x80 | (c & 0x3f));
} else {
out += static_cast<char>(0xf0 | (c >> 18));
out += static_cast<char>(0x80 | ((c >> 12) & 0x3f));
out += static_cast<char>(0x80 | ((c >> 6) & 0x3f));
out += static_cast<char>(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<discordpp::Client>();
client->SetApplicationId(static_cast<uint64_t>(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<uint64_t>(start));
if (end > start) timestamps.SetEnd(static_cast<uint64_t>(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());
}
@@ -949,6 +949,7 @@ class MainActivity: FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(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. // Select and initialize the runtime before Flutter can dispatch a call.
val selectedBackend = coreBackend val selectedBackend = coreBackend
sweepStaleCacheFiles() sweepStaleCacheFiles()
@@ -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<Boolean>("enabled") == true))
"update" -> {
if (enabled) {
DiscordNative.start(APPLICATION_ID)
val cover = call.argument<String>("cover").orEmpty()
DiscordNative.update(
call.argument<String>("title").orEmpty().take(128),
call.argument<String>("artist").orEmpty().take(128),
call.argument<String>("album").orEmpty().take(128),
cover.takeIf { it.startsWith("https://") && it.length <= 300 }.orEmpty(),
call.argument<Number>("start")?.toLong()?.coerceAtLeast(0) ?: 0,
call.argument<Number>("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
}
+4
View File
@@ -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", "@@locale": "en",
"@@last_modified": "2026-04-28", "@@last_modified": "2026-04-28",
"appName": "SpotiFLAC Mobile", "appName": "SpotiFLAC Mobile",
+4
View File
@@ -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", "@@locale": "id",
"@@last_modified": "2026-04-28", "@@last_modified": "2026-04-28",
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", "libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
+4
View File
@@ -31,6 +31,7 @@ class AppSettings {
final bool embedReplayGain; final bool embedReplayGain;
// Apply ReplayGain/R128 tags as volume normalization in the built-in player. // Apply ReplayGain/R128 tags as volume normalization in the built-in player.
final bool playbackNormalization; final bool playbackNormalization;
final bool discordRichPresence;
final bool isFirstLaunch; final bool isFirstLaunch;
final bool checkForUpdates; final bool checkForUpdates;
final String updateChannel; final String updateChannel;
@@ -138,6 +139,7 @@ class AppSettings {
this.embedLyrics = true, this.embedLyrics = true,
this.embedReplayGain = false, this.embedReplayGain = false,
this.playbackNormalization = false, this.playbackNormalization = false,
this.discordRichPresence = false,
this.isFirstLaunch = true, this.isFirstLaunch = true,
this.checkForUpdates = true, this.checkForUpdates = true,
this.updateChannel = 'stable', this.updateChannel = 'stable',
@@ -221,6 +223,7 @@ class AppSettings {
bool? embedLyrics, bool? embedLyrics,
bool? embedReplayGain, bool? embedReplayGain,
bool? playbackNormalization, bool? playbackNormalization,
bool? discordRichPresence,
bool? isFirstLaunch, bool? isFirstLaunch,
bool? checkForUpdates, bool? checkForUpdates,
String? updateChannel, String? updateChannel,
@@ -300,6 +303,7 @@ class AppSettings {
embedReplayGain: embedReplayGain ?? this.embedReplayGain, embedReplayGain: embedReplayGain ?? this.embedReplayGain,
playbackNormalization: playbackNormalization:
playbackNormalization ?? this.playbackNormalization, playbackNormalization ?? this.playbackNormalization,
discordRichPresence: discordRichPresence ?? this.discordRichPresence,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
checkForUpdates: checkForUpdates ?? this.checkForUpdates, checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel, updateChannel: updateChannel ?? this.updateChannel,
+2
View File
@@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
embedLyrics: json['embedLyrics'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true,
embedReplayGain: json['embedReplayGain'] as bool? ?? false, embedReplayGain: json['embedReplayGain'] as bool? ?? false,
playbackNormalization: json['playbackNormalization'] as bool? ?? false, playbackNormalization: json['playbackNormalization'] as bool? ?? false,
discordRichPresence: json['discordRichPresence'] as bool? ?? false,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable', updateChannel: json['updateChannel'] as String? ?? 'stable',
@@ -117,6 +118,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'embedLyrics': instance.embedLyrics, 'embedLyrics': instance.embedLyrics,
'embedReplayGain': instance.embedReplayGain, 'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization, 'playbackNormalization': instance.playbackNormalization,
'discordRichPresence': instance.discordRichPresence,
'isFirstLaunch': instance.isFirstLaunch, 'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates, 'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel, 'updateChannel': instance.updateChannel,
+5
View File
@@ -551,6 +551,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setDiscordRichPresence(bool enabled) {
state = state.copyWith(discordRichPresence: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) { void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled); state = state.copyWith(embedMetadata: enabled);
_saveSettings(); _saveSettings();
+12
View File
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.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/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/repo_provider.dart'; import 'package:spotiflac_android/providers/repo_provider.dart';
@@ -79,6 +80,11 @@ class _MainShellState extends ConsumerState<MainShell>
setPlaybackNormalizationEnabled( setPlaybackNormalizationEnabled(
ref.read(settingsProvider).playbackNormalization, 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 // Deezer & co. localize artist/genre names by IP unless told the app's
// language (issue #480). // language (issue #480).
unawaited( unawaited(
@@ -631,6 +637,12 @@ class _MainShellState extends ConsumerState<MainShell>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.listen(settingsProvider.select((s) => s.discordRichPresence), (
_,
enabled,
) {
unawaited(DiscordPresenceService.instance.setEnabled(enabled));
});
ref.listen(settingsProvider.select((s) => s.playbackNormalization), ( ref.listen(settingsProvider.select((s) => s.playbackNormalization), (
_, _,
enabled, enabled,
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/library_database.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/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart'; import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart';
@@ -910,8 +911,27 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
onChanged: (v) => ref onChanged: (v) => ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setPlaybackNormalization(v), .setPlaybackNormalization(v),
showDivider: false, showDivider: Platform.isAndroid,
), ),
if (Platform.isAndroid)
ValueListenableBuilder<String>(
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,
),
),
], ],
), ),
), ),
+135
View File
@@ -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<String> status = ValueNotifier('disabled');
StreamSubscription<MediaItem?>? _mediaSubscription;
StreamSubscription<PlaybackState>? _stateSubscription;
MediaItem? _item;
PlaybackState? _playback;
bool _enabled = false;
bool _configured = false;
bool _transportReady = false;
int _generation = 0;
String? _lastPayload;
int? _lastStart;
Future<void> _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<void> 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<String>('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
? <String, Object?>{
'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<void>(
payload == null ? 'clear' : 'update',
payload,
);
} on PlatformException {
_lastPayload = null;
} on MissingPluginException {
_lastPayload = null;
}
});
}
Future<void> dispose() async {
await _mediaSubscription?.cancel();
await _stateSubscription?.cancel();
await setEnabled(false);
_channel.setMethodCallHandler(null);
status.dispose();
}
}
+2
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:audio_session/audio_session.dart' import 'package:audio_session/audio_session.dart'
show AudioSession, AudioSessionConfiguration, AudioInterruptionType; show AudioSession, AudioSessionConfiguration, AudioInterruptionType;
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
@@ -1545,6 +1546,7 @@ Future<MusicPlayerHandler> _doInitMusicPlayer() async {
), ),
); );
_handler = handler; _handler = handler;
DiscordPresenceService.instance.bind(handler);
_handlerReadyController.add(handler); _handlerReadyController.add(handler);
return handler; return handler;
} catch (_) { } catch (_) {
+151
View File
@@ -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<MethodCall> calls;
Future<void> settle() async {
for (var i = 0; i < 5; i++) {
await Future<void>.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<String>();
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);
});
}