mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
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:
1 parent
b2ea07c41e
commit
f0ed293d48
17 files changed
+662
-1
No files matched your search
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (_) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user