diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4ca68a1e..0e58113f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,7 +2,7 @@ name: Android CI/CD
on:
push:
- branches: [ main, master, develop ]
+ branches: [ main, master, develop, agent/discord-rich-presence ]
tags:
- 'v*'
pull_request:
@@ -19,6 +19,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up JDK 17
uses: actions/setup-java@v4
@@ -49,6 +51,16 @@ jobs:
echo "Release keystore secret is not available. Skipping decoding."
fi
+ - name: Run unit tests
+ run: >
+ ./gradlew :app:testGithubNightlyUnitTest :app:testFossReleaseUnitTest
+ --parallel --build-cache --no-daemon --stacktrace
+
+ - name: Lint GitHub nightly
+ run: >
+ ./gradlew :app:lintGithubNightly
+ --build-cache --no-daemon --stacktrace
+
# Build all variants in a single Gradle invocation so tasks share the
# configuration phase, classpath resolution and incremental build cache.
# --parallel — lets Gradle run independent subproject tasks concurrently.
@@ -63,25 +75,35 @@ jobs:
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ - name: Verify Discord nightly APK
+ run: |
+ test -f app/build/outputs/apk/github/nightly/app-github-nightly.apk
+ sha256sum app/build/outputs/apk/github/nightly/app-github-nightly.apk | tee app/build/outputs/apk/github/nightly/app-github-nightly.apk.sha256
+
- name: Upload Release APK
uses: actions/upload-artifact@v4
with:
name: flow-release-apk
- path: app/build/outputs/apk/github/release/app-github-release.apk
+ path: app/build/outputs/apk/github/release/*.apk
+ if-no-files-found: error
retention-days: 14
- name: Upload Nightly APK
uses: actions/upload-artifact@v4
with:
name: flow-nightly-apk
- path: app/build/outputs/apk/github/nightly/app-github-nightly.apk
+ path: |
+ app/build/outputs/apk/github/nightly/app-github-nightly.apk
+ app/build/outputs/apk/github/nightly/app-github-nightly.apk.sha256
+ if-no-files-found: error
retention-days: 14
- name: Upload FOSS Release APK
uses: actions/upload-artifact@v4
with:
name: flow-foss-release-apk
- path: app/build/outputs/apk/foss/release/app-foss-release.apk
+ path: app/build/outputs/apk/foss/release/*.apk
+ if-no-files-found: error
retention-days: 14
release:
@@ -104,7 +126,7 @@ jobs:
- name: Download Nightly APK
uses: actions/download-artifact@v4
with:
- name: flow-nightly-apk
+ name: flow-discord-rpc-nightly-apk
path: dist/
- name: Download FOSS Release APK
@@ -124,7 +146,7 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Create Release
- uses: softprops/action-gh-release@v1
+ uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.VERSION }}
name: Flow ${{ steps.get_version.outputs.VERSION }}
@@ -265,4 +287,4 @@ jobs:
draft: false
prerelease: false
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 64ac8990..44a512a7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -47,6 +47,7 @@ android {
dimension = "version"
isDefault = true
buildConfigField("Boolean", "UPDATER_ENABLED", "true")
+ buildConfigField("String", "DISCORD_APPLICATION_ID", "\"1526515771021328514\"")
}
create("foss") {
dimension = "version"
diff --git a/app/src/foss/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt b/app/src/foss/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt
new file mode 100644
index 00000000..e11fab57
--- /dev/null
+++ b/app/src/foss/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt
@@ -0,0 +1,12 @@
+package io.github.aedev.flow.discord
+
+import android.content.Context
+import okhttp3.OkHttpClient
+
+class DiscordPlatformTransportFactory : DiscordPresenceTransportFactory {
+ override fun create(
+ context: Context,
+ okHttpClient: OkHttpClient,
+ tokenStore: DiscordTokenStore,
+ ): DiscordPresenceTransport = NoOpDiscordPresenceTransport()
+}
diff --git a/app/src/github/AndroidManifest.xml b/app/src/github/AndroidManifest.xml
new file mode 100644
index 00000000..49c491c0
--- /dev/null
+++ b/app/src/github/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt
new file mode 100644
index 00000000..7db26314
--- /dev/null
+++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginActivity.kt
@@ -0,0 +1,83 @@
+package io.github.aedev.flow.discord
+
+import android.annotation.SuppressLint
+import android.graphics.Color
+import android.os.Bundle
+import android.view.WindowManager
+import android.webkit.CookieManager
+import android.webkit.WebStorage
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.ComponentActivity
+
+class DiscordLoginActivity : ComponentActivity() {
+ private var completed = false
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+
+ val webView = WebView(this).apply {
+ setBackgroundColor(Color.TRANSPARENT)
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ settings.userAgentString = settings.userAgentString.replace("; wv", "")
+ webViewClient = object : WebViewClient() {
+ @Deprecated("Deprecated in Java")
+ override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
+ requestTokenWhenAuthenticated(view, url)
+ return false
+ }
+
+ override fun onPageFinished(view: WebView, url: String) {
+ super.onPageFinished(view, url)
+ requestTokenWhenAuthenticated(view, url)
+ }
+ }
+ loadUrl(DISCORD_LOGIN_URL)
+ }
+ setContentView(webView)
+ }
+
+ override fun onDestroy() {
+ if (!completed && isFinishing) {
+ DiscordLoginBroker.fail("Discord connection was cancelled.")
+ }
+ super.onDestroy()
+ }
+
+ private fun requestTokenWhenAuthenticated(webView: WebView, url: String) {
+ if (!completed && DiscordLoginTokenExtractor.isAuthenticatedAppUrl(url)) {
+ readToken(webView)
+ }
+ }
+
+ private fun readToken(webView: WebView) {
+ webView.evaluateJavascript(TOKEN_SCRIPT) { encodedResult ->
+ val token = DiscordLoginTokenExtractor.decodeJavascriptValue(encodedResult)
+
+ if (token != null && !completed) {
+ completed = true
+ DiscordLoginBroker.complete(token)
+ webView.clearHistory()
+ webView.clearCache(true)
+ WebStorage.getInstance().deleteAllData()
+ CookieManager.getInstance().removeAllCookies(null)
+ finish()
+ }
+ }
+ }
+
+ private companion object {
+ const val DISCORD_LOGIN_URL = "https://discord.com/login"
+ const val TOKEN_SCRIPT = "(function(){" +
+ "var frame=document.createElement('iframe');" +
+ "frame.style.display='none';" +
+ "document.body.appendChild(frame);" +
+ "var token=frame.contentWindow.localStorage.getItem('token');" +
+ "frame.remove();" +
+ "return token;" +
+ "})()"
+ }
+}
diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginBroker.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginBroker.kt
new file mode 100644
index 00000000..b8398bf8
--- /dev/null
+++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginBroker.kt
@@ -0,0 +1,25 @@
+package io.github.aedev.flow.discord
+
+import kotlinx.coroutines.CompletableDeferred
+
+internal object DiscordLoginBroker {
+ private var pending: CompletableDeferred>? = null
+
+ @Synchronized
+ fun begin(): CompletableDeferred>? {
+ if (pending?.isActive == true) return null
+ return CompletableDeferred>().also { pending = it }
+ }
+
+ @Synchronized
+ fun complete(token: String) {
+ pending?.complete(Result.success(token))
+ pending = null
+ }
+
+ @Synchronized
+ fun fail(message: String) {
+ pending?.complete(Result.failure(IllegalStateException(message)))
+ pending = null
+ }
+}
diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt
new file mode 100644
index 00000000..a508abcc
--- /dev/null
+++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordLoginTokenExtractor.kt
@@ -0,0 +1,23 @@
+package io.github.aedev.flow.discord
+
+import java.net.URI
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.jsonPrimitive
+
+internal object DiscordLoginTokenExtractor {
+ fun isAuthenticatedAppUrl(url: String): Boolean = runCatching {
+ val uri = URI(url)
+ uri.scheme.equals("https", ignoreCase = true) &&
+ uri.host.equals("discord.com", ignoreCase = true) &&
+ uri.path.trimEnd('/') == "/app"
+ }.getOrDefault(false)
+
+ fun decodeJavascriptValue(value: String): String? = runCatching {
+ val element = Json.parseToJsonElement(value)
+ if (element is JsonNull) null else element.jsonPrimitive.content
+ }.getOrNull()
+ ?.trim()
+ ?.removeSurrounding("\"")
+ ?.takeIf { it.isNotEmpty() && it != "null" }
+}
diff --git a/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt b/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt
new file mode 100644
index 00000000..986263b2
--- /dev/null
+++ b/app/src/github/java/io/github/aedev/flow/discord/DiscordPlatformTransportFactory.kt
@@ -0,0 +1,17 @@
+package io.github.aedev.flow.discord
+
+import android.content.Context
+import io.github.aedev.flow.BuildConfig
+import okhttp3.OkHttpClient
+
+class DiscordPlatformTransportFactory : DiscordPresenceTransportFactory {
+ override fun create(
+ context: Context,
+ okHttpClient: OkHttpClient,
+ tokenStore: DiscordTokenStore,
+ ): DiscordPresenceTransport = KizzyDiscordPresenceTransport(
+ client = okHttpClient,
+ tokenStore = tokenStore,
+ applicationId = BuildConfig.DISCORD_APPLICATION_ID,
+ )
+}
diff --git a/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt b/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt
new file mode 100644
index 00000000..7191d08f
--- /dev/null
+++ b/app/src/github/java/io/github/aedev/flow/discord/KizzyDiscordPresenceTransport.kt
@@ -0,0 +1,285 @@
+package io.github.aedev.flow.discord
+
+import android.app.Activity
+import android.content.Intent
+import java.lang.ref.WeakReference
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * GitHub-flavor Discord Gateway adapter inspired by Kizzy's Android RPC approach.
+ * This uses a Discord user session and is not an official Discord integration.
+ */
+class KizzyDiscordPresenceTransport(
+ private val client: OkHttpClient,
+ private val tokenStore: DiscordTokenStore,
+ private val applicationId: String,
+) : DiscordPresenceTransport {
+ override val isAvailable: Boolean = applicationId.isNotBlank()
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val connectionMutex = Mutex()
+ private val imageCache = ConcurrentHashMap()
+ private var activityReference = WeakReference(null)
+ private var socket: WebSocket? = null
+ private var heartbeatJob: Job? = null
+ private var readySignal: CompletableDeferred? = null
+ private var currentToken: String? = null
+ @Volatile private var sequence: Int? = null
+ @Volatile private var generation = 0
+
+ private val _connectionState = MutableStateFlow(DiscordConnectionState.DISCONNECTED)
+ override val connectionState: StateFlow = _connectionState.asStateFlow()
+
+ private val _linkedAccountName = MutableStateFlow(null)
+ override val linkedAccountName: StateFlow = _linkedAccountName.asStateFlow()
+
+ private val _lastError = MutableStateFlow(null)
+ override val lastError: StateFlow = _lastError.asStateFlow()
+
+ override fun attachActivity(activity: Activity?) {
+ activityReference = WeakReference(activity)
+ }
+
+ override suspend fun link(): DiscordLinkResult {
+ val activity = activityReference.get()
+ ?: return fail("Open Flow before connecting Discord.")
+ val result = DiscordLoginBroker.begin()
+ ?: return fail("A Discord connection is already in progress.")
+
+ _connectionState.value = DiscordConnectionState.LINKING
+ _lastError.value = null
+ activity.startActivity(Intent(activity, DiscordLoginActivity::class.java))
+
+ val token = runCatching {
+ withTimeout(LOGIN_TIMEOUT_MS) { result.await().getOrThrow() }
+ }.getOrElse { error ->
+ return fail(error.message ?: "Discord connection did not complete.")
+ }
+
+ return connect(
+ DiscordAuthTokens(
+ accessToken = token,
+ refreshToken = "",
+ expiresAtEpochSeconds = Long.MAX_VALUE,
+ ),
+ )
+ }
+
+ override suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult {
+ connectionMutex.lock()
+ return try {
+ connectLocked(tokens)
+ } finally {
+ connectionMutex.unlock()
+ }
+ }
+
+ private suspend fun connectLocked(tokens: DiscordAuthTokens): DiscordLinkResult {
+ if (tokens.accessToken.isBlank()) return fail("Discord returned an empty session token.")
+ if (applicationId.isBlank()) return fail("Discord application ID is missing from this build.")
+ if (
+ _connectionState.value == DiscordConnectionState.CONNECTED &&
+ currentToken == tokens.accessToken
+ ) {
+ return DiscordLinkResult.Success
+ }
+
+ closeSocket()
+ val connectionGeneration = ++generation
+ currentToken = tokens.accessToken
+ sequence = null
+ _connectionState.value = DiscordConnectionState.CONNECTING
+ _lastError.value = null
+ val signal = CompletableDeferred()
+ readySignal = signal
+
+ val request = Request.Builder().url(KizzyGatewayProtocol.GATEWAY_URL).build()
+ socket = client.newWebSocket(request, gatewayListener(connectionGeneration, tokens))
+
+ return runCatching {
+ withTimeout(CONNECTION_TIMEOUT_MS) { signal.await() }
+ }.getOrElse {
+ closeSocket()
+ fail("Discord Gateway connection timed out.")
+ }
+ }
+
+ override suspend fun update(payload: DiscordPresencePayload): Boolean {
+ if (!ensureConnected()) return false
+ val resolvedImage = resolveExternalImage(payload.largeImage)
+ return socket?.send(
+ KizzyGatewayProtocol.presence(payload, applicationId, resolvedImage),
+ ) == true
+ }
+
+ override suspend fun clear(): Boolean {
+ if (_connectionState.value != DiscordConnectionState.CONNECTED) return true
+ return socket?.send(KizzyGatewayProtocol.presence(null, applicationId, null)) == true
+ }
+
+ override suspend fun unlink(): Boolean {
+ clear()
+ closeSocket()
+ tokenStore.clear()
+ currentToken = null
+ _linkedAccountName.value = null
+ _lastError.value = null
+ _connectionState.value = DiscordConnectionState.DISCONNECTED
+ return true
+ }
+
+ override fun close() {
+ if (_connectionState.value == DiscordConnectionState.CONNECTED) {
+ socket?.send(KizzyGatewayProtocol.presence(null, applicationId, null))
+ }
+ closeSocket()
+ scope.cancel()
+ }
+
+ private fun gatewayListener(
+ connectionGeneration: Int,
+ tokens: DiscordAuthTokens,
+ ) = object : WebSocketListener() {
+ override fun onMessage(webSocket: WebSocket, text: String) {
+ if (connectionGeneration != generation) return
+ val payload = runCatching { JSONObject(text) }.getOrNull() ?: return
+ if (payload.has("s") && !payload.isNull("s")) sequence = payload.optInt("s")
+
+ when (payload.optInt("op", -1)) {
+ 0 -> if (payload.optString("t") == "READY") {
+ val user = payload.optJSONObject("d")?.optJSONObject("user")
+ val accountName = user?.optString("global_name")
+ ?.takeIf(String::isNotBlank)
+ ?: user?.optString("username")?.takeIf(String::isNotBlank)
+ ?: "Discord user"
+ _linkedAccountName.value = accountName
+ _connectionState.value = DiscordConnectionState.CONNECTED
+ _lastError.value = null
+ tokenStore.save(tokens)
+ readySignal?.complete(DiscordLinkResult.Success)
+ }
+
+ 1 -> webSocket.send(KizzyGatewayProtocol.heartbeat(sequence))
+ 7 -> failFromCallback("Discord requested a reconnect.")
+ 9 -> failFromCallback("Discord rejected the session. Reconnect your account.")
+ 10 -> {
+ val interval = payload.optJSONObject("d")?.optLong("heartbeat_interval") ?: 0L
+ startHeartbeat(webSocket, interval, connectionGeneration)
+ webSocket.send(KizzyGatewayProtocol.identify(tokens.accessToken))
+ }
+ }
+ }
+
+ override fun onFailure(webSocket: WebSocket, throwable: Throwable, response: Response?) {
+ if (connectionGeneration != generation) return
+ failFromCallback("Discord Gateway connection failed.")
+ }
+
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ if (connectionGeneration != generation) return
+ heartbeatJob?.cancel()
+ if (_connectionState.value == DiscordConnectionState.CONNECTED) {
+ _connectionState.value = DiscordConnectionState.DISCONNECTED
+ }
+ }
+ }
+
+ private fun startHeartbeat(webSocket: WebSocket, intervalMs: Long, connectionGeneration: Int) {
+ heartbeatJob?.cancel()
+ if (intervalMs <= 0) return
+ heartbeatJob = scope.launch {
+ while (isActive && connectionGeneration == generation) {
+ delay(intervalMs)
+ webSocket.send(KizzyGatewayProtocol.heartbeat(sequence))
+ }
+ }
+ }
+
+ private suspend fun ensureConnected(): Boolean {
+ if (_connectionState.value == DiscordConnectionState.CONNECTED) return true
+ val saved = tokenStore.load() ?: return false
+ return connect(saved) == DiscordLinkResult.Success
+ }
+
+ private suspend fun resolveExternalImage(imageUrl: String): String? {
+ if (!imageUrl.startsWith("https://")) return null
+ imageCache[imageUrl]?.let { return it }
+ val token = currentToken ?: return null
+
+ return withContext(Dispatchers.IO) {
+ runCatching {
+ val body = JSONObject()
+ .put("urls", JSONArray().put(imageUrl))
+ .toString()
+ .toRequestBody(JSON_MEDIA_TYPE)
+ val request = Request.Builder()
+ .url("https://discord.com/api/v9/applications/$applicationId/external-assets")
+ .header("Authorization", token)
+ .post(body)
+ .build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use null
+ val path = JSONArray(response.body?.string().orEmpty())
+ .optJSONObject(0)
+ ?.optString("external_asset_path")
+ ?.takeIf(String::isNotBlank)
+ path?.let { "mp:$it" }
+ }
+ }.getOrNull()?.also { imageCache[imageUrl] = it }
+ }
+ }
+
+ private fun closeSocket() {
+ generation++
+ heartbeatJob?.cancel()
+ heartbeatJob = null
+ readySignal?.cancel()
+ readySignal = null
+ socket?.close(1000, "Flow Discord presence closed")
+ socket = null
+ sequence = null
+ }
+
+ private fun fail(message: String): DiscordLinkResult.Failure {
+ _connectionState.value = DiscordConnectionState.ERROR
+ _lastError.value = message
+ return DiscordLinkResult.Failure(message)
+ }
+
+ private fun failFromCallback(message: String) {
+ val failure = fail(message)
+ readySignal?.complete(failure)
+ closeSocket()
+ }
+
+ private companion object {
+ val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
+ val LOGIN_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(5)
+ val CONNECTION_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(20)
+ }
+}
diff --git a/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt b/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt
new file mode 100644
index 00000000..b5be3ac7
--- /dev/null
+++ b/app/src/github/java/io/github/aedev/flow/discord/KizzyGatewayProtocol.kt
@@ -0,0 +1,88 @@
+package io.github.aedev.flow.discord
+
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+
+internal object KizzyGatewayProtocol {
+ const val GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
+
+ fun identify(token: String): String = buildJsonObject {
+ put("op", 2)
+ put(
+ "d",
+ buildJsonObject {
+ put("token", token)
+ put("capabilities", 65)
+ put("compress", false)
+ put("large_threshold", 100)
+ put(
+ "properties",
+ buildJsonObject {
+ put("os", "Android")
+ put("browser", "Discord Android")
+ put("device", "Flow")
+ },
+ )
+ },
+ )
+ }.toString()
+
+ fun heartbeat(sequence: Int?): String = buildJsonObject {
+ put("op", 1)
+ if (sequence == null) put("d", JsonNull) else put("d", sequence)
+ }.toString()
+
+ fun presence(
+ payload: DiscordPresencePayload?,
+ applicationId: String,
+ resolvedImage: String?,
+ ): String {
+ val activities = buildJsonArray {
+ if (payload != null) {
+ add(
+ buildJsonObject {
+ put("name", "Flow")
+ put("type", if (payload.type == DiscordActivityType.LISTENING) 2 else 3)
+ put("details", payload.details)
+ put("state", payload.state)
+ put("application_id", applicationId)
+
+ if (payload.startTimestampSeconds != null || payload.endTimestampSeconds != null) {
+ put(
+ "timestamps",
+ buildJsonObject {
+ payload.startTimestampSeconds?.let { put("start", it * 1_000L) }
+ payload.endTimestampSeconds?.let { put("end", it * 1_000L) }
+ },
+ )
+ }
+ if (!resolvedImage.isNullOrBlank()) {
+ put(
+ "assets",
+ buildJsonObject {
+ put("large_image", resolvedImage)
+ put("large_text", payload.largeImageText)
+ },
+ )
+ }
+ },
+ )
+ }
+ }
+
+ return buildJsonObject {
+ put("op", 3)
+ put(
+ "d",
+ buildJsonObject {
+ put("since", 0)
+ put("activities", activities)
+ put("status", "online")
+ put("afk", false)
+ },
+ )
+ }.toString()
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ebc2d062..e97ca2d9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,9 @@
+
+
+
@@ -52,6 +55,7 @@
+
+
+
+
+
+
+
"IDLE"
@@ -96,7 +103,6 @@ class MainActivity : ComponentActivity() {
return "interactive=${powerManager?.isInteractive} lifecycle=${lifecycle.currentState} " +
"pip=$isInPictureInPictureMode pendingAutoPip=$pendingAutoPip " +
"bgPref=$cachedBackgroundPlayEnabled shortsBgPref=$cachedShortsBackgroundPlay " +
- "explicitBg=${GlobalPlayerState.isExplicitBackgroundPlaybackActive.value} " +
"video=${playerState.currentVideoId} exo=${videoPlaybackStateName(player?.playbackState)} " +
"pwr=${player?.playWhenReady} playing=${player?.isPlaying} buffering=${playerState.isBuffering} " +
"pos=${player?.currentPosition}/${player?.duration} idx=${player?.currentMediaItemIndex} count=${player?.mediaItemCount}"
@@ -116,6 +122,7 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
super.onCreate(savedInstanceState)
+ DiscordPresenceRuntime.attachActivity(this)
window.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
@@ -185,6 +192,14 @@ class MainActivity : ComponentActivity() {
var showSplash by remember { mutableStateOf(true) }
val context = LocalContext.current
+ val configuration = LocalConfiguration.current
+ val uiPreferences = remember { AppUiModePreferences(applicationContext) }
+ val appUiMode by uiPreferences.mode.collectAsState(initial = AppUiMode.AUTOMATIC)
+ val deviceFormFactor = remember(configuration.uiMode, context) {
+ DeviceFormFactorDetector.detect(context)
+ }
+ val appUiRoot = appUiMode.resolve(deviceFormFactor)
+ SideEffect { cachedAppUiRoot = appUiRoot }
// Check for a crash that happened last session.
// If found, show the CrashReporterScreen instead of the normal UI.
@@ -331,56 +346,64 @@ class MainActivity : ComponentActivity() {
val isDeeplinkShort by this@MainActivity.isDeeplinkShort
val openMusicPlayerRequest by this@MainActivity.openMusicPlayerRequest
- FlowApp(
- currentTheme = themeMode,
- themeVariant = themeVariant,
- customThemePalettes = customThemePalettes,
- systemLightThemeMode = systemLightThemeMode,
- systemDarkThemeMode = systemDarkThemeMode,
- systemDarkThemeVariant = systemDarkThemeVariant,
- onThemeChange = { newTheme ->
- themeMode = newTheme
- scope.launch {
- dataManager.setThemeMode(newTheme)
- }
- },
- onThemeVariantChange = { variant ->
- themeVariant = variant
- scope.launch {
- dataManager.setThemeVariant(variant)
- }
- },
- onCustomThemePalettesChange = { palettes ->
- customThemePalettes = palettes
- scope.launch {
- dataManager.setCustomThemePalettes(palettes)
- }
- },
- onSystemLightThemeChange = { newTheme ->
- systemLightThemeMode = newTheme
- scope.launch {
- dataManager.setSystemLightThemeMode(newTheme)
- }
- },
- onSystemDarkThemeChange = { newTheme ->
- systemDarkThemeMode = newTheme
- scope.launch {
- dataManager.setSystemDarkThemeMode(newTheme)
- }
- },
- onSystemDarkThemeVariantChange = { variant ->
- systemDarkThemeVariant = variant
- scope.launch {
- dataManager.setSystemDarkThemeVariant(variant)
+ if (appUiRoot == AppUiRoot.TV) {
+ FlowTvApp(
+ deeplinkVideoId = deeplinkVideoId,
+ isShort = isDeeplinkShort,
+ onDeeplinkConsumed = { consumeDeeplink() },
+ )
+ } else {
+ FlowApp(
+ currentTheme = themeMode,
+ themeVariant = themeVariant,
+ customThemePalettes = customThemePalettes,
+ systemLightThemeMode = systemLightThemeMode,
+ systemDarkThemeMode = systemDarkThemeMode,
+ systemDarkThemeVariant = systemDarkThemeVariant,
+ onThemeChange = { newTheme ->
+ themeMode = newTheme
+ scope.launch {
+ dataManager.setThemeMode(newTheme)
+ }
+ },
+ onThemeVariantChange = { variant ->
+ themeVariant = variant
+ scope.launch {
+ dataManager.setThemeVariant(variant)
+ }
+ },
+ onCustomThemePalettesChange = { palettes ->
+ customThemePalettes = palettes
+ scope.launch {
+ dataManager.setCustomThemePalettes(palettes)
+ }
+ },
+ onSystemLightThemeChange = { newTheme ->
+ systemLightThemeMode = newTheme
+ scope.launch {
+ dataManager.setSystemLightThemeMode(newTheme)
+ }
+ },
+ onSystemDarkThemeChange = { newTheme ->
+ systemDarkThemeMode = newTheme
+ scope.launch {
+ dataManager.setSystemDarkThemeMode(newTheme)
+ }
+ },
+ onSystemDarkThemeVariantChange = { variant ->
+ systemDarkThemeVariant = variant
+ scope.launch {
+ dataManager.setSystemDarkThemeVariant(variant)
+ }
+ },
+ deeplinkVideoId = deeplinkVideoId,
+ isShort = isDeeplinkShort,
+ openMusicPlayerRequest = openMusicPlayerRequest,
+ onDeeplinkConsumed = {
+ consumeDeeplink()
}
- },
- deeplinkVideoId = deeplinkVideoId,
- isShort = isDeeplinkShort,
- openMusicPlayerRequest = openMusicPlayerRequest,
- onDeeplinkConsumed = {
- consumeDeeplink()
- }
- )
+ )
+ }
// 2. THE SPLASH SCREEN (Z-Index Top)
if (showSplash) {
@@ -397,16 +420,13 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
videoLifecycleLog("onDestroy")
+ DiscordPresenceRuntime.detachActivity(this)
val playerManager = io.github.aedev.flow.player.EnhancedPlayerManager.getInstance()
val playerState = playerManager.playerState.value
- val hasActiveVideo =
- playerState.currentVideoId != null &&
+ val shouldKeepBackgroundPlayback =
+ cachedBackgroundPlayEnabled &&
+ playerState.currentVideoId != null &&
(playerState.playWhenReady || playerState.isPlaying || playerState.isBuffering)
- val shouldKeepBackgroundPlayback = BackgroundPlaybackPolicy.shouldKeepPlaybackInBackground(
- backgroundPlaybackPreferenceEnabled = cachedBackgroundPlayEnabled,
- explicitBackgroundPlaybackActive = GlobalPlayerState.isExplicitBackgroundPlaybackActive.value,
- hasActiveVideo = hasActiveVideo
- )
if (shouldKeepBackgroundPlayback) {
handOffVideoPlaybackToBackground()
@@ -593,7 +613,9 @@ class MainActivity : ComponentActivity() {
)
videoLifecycleLog("onStop")
if (!isInPictureInPictureMode && !PictureInPictureHelper.isPopupActive) {
- requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ if (cachedAppUiRoot == AppUiRoot.MOBILE) {
+ requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ }
if (!cachedShortsBackgroundPlay) {
io.github.aedev.flow.player.shorts.ShortsPlayerPool.getInstance().pauseAll()
}
@@ -618,12 +640,8 @@ class MainActivity : ComponentActivity() {
override fun onUserLeaveHint() {
super.onUserLeaveHint()
- val explicitBackgroundPlaybackActive =
- GlobalPlayerState.isExplicitBackgroundPlaybackActive.value
- FlowCrashHandler.recordPhase(
- "activity",
- "onUserLeaveHint autoPip=$cachedAutoPipEnabled explicitBackground=$explicitBackgroundPlaybackActive"
- )
+ if (cachedAppUiRoot == AppUiRoot.TV) return
+ FlowCrashHandler.recordPhase("activity", "onUserLeaveHint autoPip=$cachedAutoPipEnabled")
videoLifecycleLog("onUserLeaveHint")
// Only enter PiP mode if video is playing and has progressed
// We use the EnhancedPlayerManager directly to get the immediate state
@@ -637,12 +655,7 @@ class MainActivity : ComponentActivity() {
val isMusicPlaying = musicManager.playerState.value.isPlaying
// Only enter PiP for video, not for music (which uses background service)
- val shouldEnterAutoPip = BackgroundPlaybackPolicy.shouldEnterAutoPip(
- autoPipEnabled = cachedAutoPipEnabled,
- isVideoPlaying = isVideoPlaying,
- explicitBackgroundPlaybackActive = explicitBackgroundPlaybackActive
- )
- if (shouldEnterAutoPip && !isMusicPlaying) {
+ if (isVideoPlaying && !isMusicPlaying && cachedAutoPipEnabled) {
enterPlayerPictureInPictureMode(
aspectRatioWidth = 16,
aspectRatioHeight = 9,
@@ -666,6 +679,7 @@ class MainActivity : ComponentActivity() {
isPlaying: Boolean = true,
openSettingsOnDenied: Boolean = false
): Boolean {
+ if (cachedAppUiRoot == AppUiRoot.TV) return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
if (!PictureInPictureHelper.isPipAllowed(this)) {
if (openSettingsOnDenied) {
@@ -719,13 +733,7 @@ class MainActivity : ComponentActivity() {
if (!hasActiveVideo) return
- val shouldKeepBackgroundPlayback = BackgroundPlaybackPolicy.shouldKeepPlaybackInBackground(
- backgroundPlaybackPreferenceEnabled = cachedBackgroundPlayEnabled,
- explicitBackgroundPlaybackActive = GlobalPlayerState.isExplicitBackgroundPlaybackActive.value,
- hasActiveVideo = hasActiveVideo
- )
-
- if (shouldKeepBackgroundPlayback) {
+ if (cachedBackgroundPlayEnabled) {
videoLifecycleLog("handleBackgroundPlaybackOnStop handoff")
handOffVideoPlaybackToBackground()
} else {
diff --git a/app/src/main/java/io/github/aedev/flow/TvActivity.kt b/app/src/main/java/io/github/aedev/flow/TvActivity.kt
new file mode 100644
index 00000000..e2351b38
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/TvActivity.kt
@@ -0,0 +1,18 @@
+package io.github.aedev.flow
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+
+/** Leanback launcher bridge that keeps [MainActivity] as Flow's single runtime host. */
+class TvActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val forwardedIntent = Intent(intent).apply {
+ setClass(this@TvActivity, MainActivity::class.java)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
+ startActivity(forwardedIntent)
+ finish()
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt
new file mode 100644
index 00000000..eaa6500f
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt
@@ -0,0 +1,31 @@
+package io.github.aedev.flow.data.local
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import io.github.aedev.flow.platform.AppUiMode
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.appUiModeDataStore: DataStore by safePreferencesDataStore(name = "app_ui_mode")
+
+/** Stores the interface override independently from playback preferences. */
+class AppUiModePreferences(context: Context) {
+ private val appContext = context.applicationContext
+
+ val mode: Flow = appContext.appUiModeDataStore.data.map { preferences ->
+ AppUiMode.fromStorage(preferences[MODE])
+ }
+
+ suspend fun setMode(mode: AppUiMode) {
+ appContext.appUiModeDataStore.edit { preferences ->
+ preferences[MODE] = mode.name
+ }
+ }
+
+ private companion object {
+ val MODE = stringPreferencesKey("mode")
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordConnectionActions.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordConnectionActions.kt
new file mode 100644
index 00000000..7ca32f64
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordConnectionActions.kt
@@ -0,0 +1,19 @@
+package io.github.aedev.flow.discord
+
+internal suspend fun retryDiscordConnection(
+ transport: DiscordPresenceTransport,
+ loadTokens: () -> DiscordAuthTokens?,
+): DiscordLinkResult = loadTokens()?.let { tokens ->
+ transport.connect(tokens)
+} ?: transport.link()
+
+internal suspend fun unlinkDiscordConnection(
+ transport: DiscordPresenceTransport,
+ disablePreference: suspend () -> Unit,
+ clearAccountLabel: suspend () -> Unit,
+): Boolean {
+ disablePreference()
+ val result = transport.unlink()
+ clearAccountLabel()
+ return result
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSelector.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSelector.kt
new file mode 100644
index 00000000..70ecf919
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSelector.kt
@@ -0,0 +1,11 @@
+package io.github.aedev.flow.discord
+
+class DiscordPlaybackSelector {
+ fun select(
+ short: PlaybackSnapshot?,
+ video: PlaybackSnapshot?,
+ music: PlaybackSnapshot?,
+ ): PlaybackSnapshot? = sequenceOf(short, video, music)
+ .filterNotNull()
+ .firstOrNull { snapshot -> snapshot.isPlaying && snapshot.mediaId.isNotBlank() }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt
new file mode 100644
index 00000000..2c895306
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPlaybackSource.kt
@@ -0,0 +1,121 @@
+package io.github.aedev.flow.discord
+
+import androidx.annotation.OptIn
+import androidx.media3.common.util.UnstableApi
+import io.github.aedev.flow.player.EnhancedMusicPlayerManager
+import io.github.aedev.flow.player.EnhancedPlayerManager
+import io.github.aedev.flow.player.GlobalPlayerState
+import io.github.aedev.flow.player.shorts.ShortsPlayerPool
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.isActive
+
+@OptIn(UnstableApi::class)
+class DiscordPlaybackSource(
+ private val videoManager: EnhancedPlayerManager = EnhancedPlayerManager.getInstance(),
+ private val shortsPool: ShortsPlayerPool = ShortsPlayerPool.getInstance(),
+ private val selector: DiscordPlaybackSelector = DiscordPlaybackSelector(),
+) {
+ val playback: Flow = discordPlaybackSnapshotFlow(
+ signals = merge(
+ GlobalPlayerState.currentVideo.map { Unit },
+ videoManager.playerState.map { Unit },
+ EnhancedMusicPlayerManager.currentTrack.map { Unit },
+ EnhancedMusicPlayerManager.playerState.map { Unit },
+ shortsPool.currentVideo.map { Unit },
+ ticker(),
+ ),
+ snapshotDispatcher = Dispatchers.Main.immediate,
+ readSnapshot = {
+ selector.select(
+ short = shortSnapshot(),
+ video = videoSnapshot(),
+ music = musicSnapshot(),
+ )
+ },
+ )
+
+ private fun shortSnapshot(): PlaybackSnapshot? {
+ val short = shortsPool.currentVideo.value ?: return null
+ if (shortsPool.currentVideoId.value != short.id) return null
+ return PlaybackSnapshot(
+ kind = PlaybackKind.SHORT,
+ mediaId = short.id,
+ title = short.title,
+ subtitle = short.channelName,
+ artworkUrl = short.thumbnailUrl,
+ positionMs = shortsPool.playbackPosition(),
+ durationMs = shortsPool.playbackDuration(),
+ isPlaying = shortsPool.isPlaying(),
+ isLive = false,
+ )
+ }
+
+ private fun videoSnapshot(): PlaybackSnapshot? {
+ val video = GlobalPlayerState.currentVideo.value ?: return null
+ val state = videoManager.playerState.value
+ if (state.currentVideoId != video.id) return null
+ val isLive = state.isLive || video.isLive
+ return PlaybackSnapshot(
+ kind = when {
+ isLive -> PlaybackKind.LIVE
+ video.isShort -> PlaybackKind.SHORT
+ else -> PlaybackKind.VIDEO
+ },
+ mediaId = video.id,
+ title = video.title,
+ subtitle = video.channelName,
+ artworkUrl = video.thumbnailUrl,
+ positionMs = videoManager.getCurrentPosition(),
+ durationMs = if (isLive) 0L else videoManager.getDuration().coerceAtLeast(0L),
+ isPlaying = state.isPlaying,
+ isLive = isLive,
+ )
+ }
+
+ private fun musicSnapshot(): PlaybackSnapshot? {
+ val track = EnhancedMusicPlayerManager.currentTrack.value ?: return null
+ val state = EnhancedMusicPlayerManager.playerState.value
+ return PlaybackSnapshot(
+ kind = PlaybackKind.MUSIC,
+ mediaId = track.videoId,
+ title = track.title,
+ subtitle = track.artist,
+ artworkUrl = track.highResThumbnailUrl,
+ positionMs = EnhancedMusicPlayerManager.getCurrentPosition(),
+ durationMs = EnhancedMusicPlayerManager.getDuration().coerceAtLeast(0L),
+ isPlaying = state.isPlaying,
+ isLive = false,
+ )
+ }
+
+ private fun ticker(): Flow = flow {
+ while (currentCoroutineContext().isActive) {
+ emit(Unit)
+ delay(POSITION_SAMPLE_INTERVAL_MS)
+ }
+ }
+
+ private companion object {
+ const val POSITION_SAMPLE_INTERVAL_MS = 5_000L
+ }
+}
+
+internal fun discordPlaybackSnapshotFlow(
+ signals: Flow,
+ snapshotDispatcher: CoroutineDispatcher,
+ readSnapshot: () -> PlaybackSnapshot?,
+): Flow = signals
+ .map { readSnapshot() }
+ // Media3 players are owned by the main application thread. The presence runtime
+ // collects on IO for its gateway calls, so keep every snapshot read upstream on main.
+ .flowOn(snapshotDispatcher)
+ .distinctUntilChanged()
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPreferences.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPreferences.kt
new file mode 100644
index 00000000..d22a9277
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPreferences.kt
@@ -0,0 +1,49 @@
+package io.github.aedev.flow.discord
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import io.github.aedev.flow.data.local.safePreferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.discordDataStore: DataStore by safePreferencesDataStore(
+ name = "discord_presence",
+)
+
+class DiscordPreferences(
+ private val dataStore: DataStore,
+) {
+ constructor(context: Context) : this(context.applicationContext.discordDataStore)
+
+ val enabled: Flow = dataStore.data.map { preferences ->
+ preferences[ENABLED] ?: false
+ }
+
+ val linkedAccountLabel: Flow = dataStore.data.map { preferences ->
+ preferences[LINKED_ACCOUNT_LABEL]?.takeIf(String::isNotBlank)
+ }
+
+ suspend fun setEnabled(enabled: Boolean) {
+ dataStore.edit { preferences -> preferences[ENABLED] = enabled }
+ }
+
+ suspend fun setLinkedAccountLabel(label: String?) {
+ dataStore.edit { preferences ->
+ val normalized = label?.trim()?.takeIf(String::isNotBlank)
+ if (normalized == null) {
+ preferences.remove(LINKED_ACCOUNT_LABEL)
+ } else {
+ preferences[LINKED_ACCOUNT_LABEL] = normalized
+ }
+ }
+ }
+
+ private companion object {
+ val ENABLED = booleanPreferencesKey("enabled")
+ val LINKED_ACCOUNT_LABEL = stringPreferencesKey("linked_account_label")
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt
new file mode 100644
index 00000000..25a63c36
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceCoordinator.kt
@@ -0,0 +1,51 @@
+package io.github.aedev.flow.discord
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.combine
+
+class DiscordPresenceCoordinator(
+ private val enabled: Flow,
+ private val playback: Flow,
+ private val transport: DiscordPresenceTransport,
+ private val mapper: DiscordPresenceMapper = DiscordPresenceMapper(),
+ private val policy: DiscordPresencePolicy = DiscordPresencePolicy(),
+ private val nowEpochSeconds: () -> Long = { System.currentTimeMillis() / 1_000L },
+ private val nowElapsedMs: () -> Long,
+) {
+ private var lastSent: SentPresence? = null
+
+ suspend fun run() {
+ combine(enabled, playback) { isEnabled, snapshot -> isEnabled to snapshot }
+ .collect { (isEnabled, snapshot) ->
+ if (!isEnabled || snapshot == null) {
+ clearPresence()
+ return@collect
+ }
+
+ val candidate = mapper.map(snapshot, nowEpochSeconds())
+ if (candidate == null) {
+ clearPresence()
+ return@collect
+ }
+
+ when (val decision = policy.decide(lastSent, candidate, nowElapsedMs())) {
+ is DiscordPresenceDecision.Send -> {
+ if (transport.update(decision.payload)) {
+ lastSent = SentPresence(
+ payload = decision.payload,
+ sentAtElapsedMs = nowElapsedMs(),
+ )
+ }
+ }
+
+ DiscordPresenceDecision.Skip -> Unit
+ }
+ }
+ }
+
+ private suspend fun clearPresence() {
+ transport.clear()
+ lastSent = null
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceMapper.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceMapper.kt
new file mode 100644
index 00000000..bc50f285
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceMapper.kt
@@ -0,0 +1,63 @@
+package io.github.aedev.flow.discord
+
+class DiscordPresenceMapper {
+ fun map(
+ snapshot: PlaybackSnapshot,
+ nowEpochSeconds: Long,
+ ): DiscordPresencePayload? {
+ if (!snapshot.isPlaying || snapshot.mediaId.isBlank()) return null
+
+ val details = normalize(snapshot.title, fallback = "Playing in Flow")
+ val subtitle = normalize(snapshot.subtitle, fallback = "Flow")
+ val durationMs = snapshot.durationMs.coerceAtLeast(0L)
+ val positionMs = snapshot.positionMs.coerceAtLeast(0L).let { position ->
+ if (durationMs > 0L) position.coerceAtMost(durationMs) else position
+ }
+ val hasTimestamps = !snapshot.isLive && durationMs > 0L
+
+ return DiscordPresencePayload(
+ type = when (snapshot.kind) {
+ PlaybackKind.MUSIC -> DiscordActivityType.LISTENING
+ PlaybackKind.VIDEO,
+ PlaybackKind.SHORT,
+ PlaybackKind.LIVE,
+ -> DiscordActivityType.WATCHING
+ },
+ mediaId = snapshot.mediaId,
+ details = details,
+ state = if (snapshot.kind == PlaybackKind.MUSIC) {
+ subtitle
+ } else {
+ normalize("by $subtitle", fallback = "Flow")
+ },
+ largeImage = snapshot.artworkUrl
+ .trim()
+ .takeIf { it.startsWith("https://") }
+ ?: FALLBACK_IMAGE_KEY,
+ largeImageText = details,
+ startTimestampSeconds = if (hasTimestamps) {
+ nowEpochSeconds - positionMs / 1_000L
+ } else {
+ null
+ },
+ endTimestampSeconds = if (hasTimestamps) {
+ nowEpochSeconds + (durationMs - positionMs) / 1_000L
+ } else {
+ null
+ },
+ )
+ }
+
+ private fun normalize(value: String, fallback: String): String =
+ value
+ .trim()
+ .ifBlank { fallback }
+ .take(MAX_TEXT_LENGTH)
+ .padEnd(MIN_TEXT_LENGTH, ' ')
+
+ private companion object {
+ const val MIN_TEXT_LENGTH = 2
+ const val MAX_TEXT_LENGTH = 128
+ const val FALLBACK_IMAGE_KEY = "flow_logo"
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceModels.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceModels.kt
new file mode 100644
index 00000000..268a7dea
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceModels.kt
@@ -0,0 +1,80 @@
+package io.github.aedev.flow.discord
+
+enum class PlaybackKind {
+ VIDEO,
+ SHORT,
+ LIVE,
+ MUSIC,
+}
+
+enum class DiscordActivityType {
+ WATCHING,
+ LISTENING,
+}
+
+data class PlaybackSnapshot(
+ val kind: PlaybackKind,
+ val mediaId: String,
+ val title: String,
+ val subtitle: String,
+ val artworkUrl: String,
+ val positionMs: Long,
+ val durationMs: Long,
+ val isPlaying: Boolean,
+ val isLive: Boolean,
+)
+
+data class DiscordPresencePayload(
+ val type: DiscordActivityType,
+ val mediaId: String,
+ val details: String,
+ val state: String,
+ val largeImage: String,
+ val largeImageText: String,
+ val startTimestampSeconds: Long?,
+ val endTimestampSeconds: Long?,
+)
+
+data class SentPresence(
+ val payload: DiscordPresencePayload,
+ val sentAtElapsedMs: Long,
+)
+
+sealed interface DiscordPresenceDecision {
+ data class Send(val payload: DiscordPresencePayload) : DiscordPresenceDecision
+ data object Skip : DiscordPresenceDecision
+}
+
+enum class DiscordConnectionState {
+ UNAVAILABLE,
+ DISCONNECTED,
+ LINKING,
+ CONNECTING,
+ CONNECTED,
+ ERROR,
+}
+
+data class DiscordPresenceStatus(
+ val isAvailable: Boolean = false,
+ val isEnabled: Boolean = false,
+ val connectionState: DiscordConnectionState = DiscordConnectionState.UNAVAILABLE,
+ val linkedAccountLabel: String? = null,
+ val errorMessage: String? = null,
+)
+
+data class DiscordAuthTokens(
+ val accessToken: String,
+ val refreshToken: String,
+ val expiresAtEpochSeconds: Long,
+)
+
+sealed interface DiscordLinkResult {
+ data object Success : DiscordLinkResult
+ data class Failure(val message: String) : DiscordLinkResult
+}
+
+sealed interface DiscordTransportResult {
+ data object Success : DiscordTransportResult
+ data object Unavailable : DiscordTransportResult
+ data class Failure(val message: String) : DiscordTransportResult
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresencePolicy.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresencePolicy.kt
new file mode 100644
index 00000000..25b0506b
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresencePolicy.kt
@@ -0,0 +1,63 @@
+package io.github.aedev.flow.discord
+
+import kotlin.math.abs
+
+class DiscordPresencePolicy(
+ private val minimumUpdateIntervalMs: Long = 5_000L,
+ private val seekDriftThresholdMs: Long = 10_000L,
+) {
+ fun decide(
+ previous: SentPresence?,
+ candidate: DiscordPresencePayload,
+ nowElapsedMs: Long,
+ ): DiscordPresenceDecision {
+ if (previous == null) return DiscordPresenceDecision.Send(candidate)
+
+ if (
+ previous.payload.mediaId != candidate.mediaId ||
+ previous.payload.type != candidate.type
+ ) {
+ return DiscordPresenceDecision.Send(candidate)
+ }
+
+ val previousWithoutTimestamps = previous.payload.withoutTimestamps()
+ val candidateWithoutTimestamps = candidate.withoutTimestamps()
+ val intervalElapsed = nowElapsedMs - previous.sentAtElapsedMs >= minimumUpdateIntervalMs
+
+ if (previousWithoutTimestamps != candidateWithoutTimestamps) {
+ return if (intervalElapsed) {
+ DiscordPresenceDecision.Send(candidate)
+ } else {
+ DiscordPresenceDecision.Skip
+ }
+ }
+
+ val meaningfulSeek = maxOf(
+ timestampDriftMs(
+ previous.payload.startTimestampSeconds,
+ candidate.startTimestampSeconds,
+ ),
+ timestampDriftMs(
+ previous.payload.endTimestampSeconds,
+ candidate.endTimestampSeconds,
+ ),
+ ) >= seekDriftThresholdMs
+
+ return if (meaningfulSeek && intervalElapsed) {
+ DiscordPresenceDecision.Send(candidate)
+ } else {
+ DiscordPresenceDecision.Skip
+ }
+ }
+
+ private fun DiscordPresencePayload.withoutTimestamps(): DiscordPresencePayload = copy(
+ startTimestampSeconds = null,
+ endTimestampSeconds = null,
+ )
+
+ private fun timestampDriftMs(left: Long?, right: Long?): Long = when {
+ left == null && right == null -> 0L
+ left == null || right == null -> Long.MAX_VALUE
+ else -> abs(left - right) * 1_000L
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt
new file mode 100644
index 00000000..aeb7827a
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceRuntime.kt
@@ -0,0 +1,163 @@
+package io.github.aedev.flow.discord
+
+import android.app.Activity
+import android.content.Context
+import android.os.SystemClock
+import java.lang.ref.WeakReference
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import okhttp3.OkHttpClient
+
+object DiscordPresenceRuntime {
+ private var scope: CoroutineScope? = null
+ private var preferences: DiscordPreferences? = null
+ private var tokenStore: DiscordTokenStore? = null
+ private var transport: DiscordPresenceTransport? = null
+ private var attachedActivity = WeakReference(null)
+
+ private val unavailableState = DiscordSettingsState(
+ isAvailable = false,
+ isEnabled = false,
+ canEnable = false,
+ connectionState = DiscordConnectionState.UNAVAILABLE,
+ summary = DiscordSettingsSummary.UNAVAILABLE,
+ accountName = null,
+ errorMessage = "Discord Rich Presence has not initialized.",
+ )
+ private val _settingsState = MutableStateFlow(unavailableState)
+ val settingsState: StateFlow = _settingsState
+
+ @Synchronized
+ fun initialize(context: Context, okHttpClient: OkHttpClient) {
+ if (scope != null) return
+ val applicationContext = context.applicationContext
+ val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ val runtimePreferences = DiscordPreferences(applicationContext)
+ val runtimeTokenStore = DiscordTokenStore(applicationContext)
+ val runtimeTransport = DiscordPlatformTransportFactory().create(
+ context = applicationContext,
+ okHttpClient = okHttpClient,
+ tokenStore = runtimeTokenStore,
+ )
+
+ scope = runtimeScope
+ preferences = runtimePreferences
+ tokenStore = runtimeTokenStore
+ transport = runtimeTransport
+
+ val state = combine(
+ runtimePreferences.enabled,
+ runtimePreferences.linkedAccountLabel,
+ runtimeTransport.connectionState,
+ runtimeTransport.linkedAccountName,
+ runtimeTransport.lastError,
+ ) { enabled, savedAccount, connection, liveAccount, error ->
+ deriveDiscordSettingsState(
+ preferenceEnabled = enabled,
+ transportAvailable = runtimeTransport.isAvailable,
+ connectionState = connection,
+ accountName = liveAccount ?: savedAccount,
+ errorMessage = error,
+ )
+ }.stateIn(runtimeScope, SharingStarted.Eagerly, unavailableState)
+
+ runtimeScope.launch { state.collect { _settingsState.value = it } }
+ runtimeScope.launch {
+ runtimeTransport.linkedAccountName.collect { accountName ->
+ if (!accountName.isNullOrBlank()) {
+ runtimePreferences.setLinkedAccountLabel(accountName)
+ }
+ }
+ }
+ runtimeScope.launch {
+ runtimePreferences.enabled.collect { enabled ->
+ if (!enabled) {
+ runtimeTransport.clear()
+ } else {
+ runtimeTokenStore.load()?.let { runtimeTransport.connect(it) }
+ }
+ }
+ }
+ runtimeScope.launch {
+ DiscordPresenceCoordinator(
+ enabled = runtimePreferences.enabled,
+ playback = DiscordPlaybackSource().playback,
+ transport = runtimeTransport,
+ nowElapsedMs = SystemClock::elapsedRealtime,
+ ).run()
+ }
+ }
+
+ fun attachActivity(activity: Activity?) {
+ attachedActivity = WeakReference(activity)
+ transport?.attachActivity(activity)
+ }
+
+ fun detachActivity(activity: Activity) {
+ if (attachedActivity.get() === activity) {
+ attachedActivity.clear()
+ transport?.attachActivity(null)
+ }
+ }
+
+ suspend fun setEnabled(enabled: Boolean) {
+ val currentTransport = transport ?: return
+ val currentPreferences = preferences ?: return
+ if (!currentTransport.isAvailable) {
+ currentPreferences.setEnabled(false)
+ return
+ }
+
+ currentPreferences.setEnabled(enabled)
+ if (!enabled) {
+ currentTransport.clear()
+ }
+ }
+
+ suspend fun connectAccount(): DiscordLinkResult {
+ val currentTransport = transport
+ ?: return DiscordLinkResult.Failure("Discord Rich Presence has not initialized.")
+ val result = currentTransport.link()
+ if (result == DiscordLinkResult.Success) {
+ currentTransport.linkedAccountName.value?.let { accountName ->
+ preferences?.setLinkedAccountLabel(accountName)
+ }
+ }
+ return result
+ }
+
+ suspend fun retry(): DiscordLinkResult {
+ val currentTransport = transport
+ ?: return DiscordLinkResult.Failure("Discord Rich Presence has not initialized.")
+ return retryDiscordConnection(currentTransport) { tokenStore?.load() }
+ }
+
+ suspend fun unlink(): Boolean {
+ val currentTransport = transport ?: return false
+ return unlinkDiscordConnection(
+ transport = currentTransport,
+ disablePreference = { preferences?.setEnabled(false) },
+ clearAccountLabel = { preferences?.setLinkedAccountLabel(null) },
+ )
+ }
+
+ fun shutdown() {
+ val currentTransport = transport
+ currentTransport?.close()
+ scope?.cancel()
+ scope = null
+ preferences = null
+ tokenStore = null
+ transport = null
+ attachedActivity.clear()
+ _settingsState.value = unavailableState
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt
new file mode 100644
index 00000000..c9f12c20
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransport.kt
@@ -0,0 +1,19 @@
+package io.github.aedev.flow.discord
+
+import android.app.Activity
+import kotlinx.coroutines.flow.StateFlow
+
+interface DiscordPresenceTransport {
+ val isAvailable: Boolean
+ val connectionState: StateFlow
+ val linkedAccountName: StateFlow
+ val lastError: StateFlow
+
+ fun attachActivity(activity: Activity?)
+ suspend fun link(): DiscordLinkResult
+ suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult
+ suspend fun update(payload: DiscordPresencePayload): Boolean
+ suspend fun clear(): Boolean
+ suspend fun unlink(): Boolean
+ fun close()
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransportFactory.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransportFactory.kt
new file mode 100644
index 00000000..ccb29571
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordPresenceTransportFactory.kt
@@ -0,0 +1,12 @@
+package io.github.aedev.flow.discord
+
+import android.content.Context
+import okhttp3.OkHttpClient
+
+interface DiscordPresenceTransportFactory {
+ fun create(
+ context: Context,
+ okHttpClient: OkHttpClient,
+ tokenStore: DiscordTokenStore,
+ ): DiscordPresenceTransport
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt
new file mode 100644
index 00000000..b6824451
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordSettingsState.kt
@@ -0,0 +1,47 @@
+package io.github.aedev.flow.discord
+
+enum class DiscordSettingsSummary {
+ OFF,
+ NOT_CONNECTED,
+ CONNECTED,
+ UNAVAILABLE,
+ ERROR,
+}
+
+data class DiscordSettingsState(
+ val isAvailable: Boolean,
+ val isEnabled: Boolean,
+ val canEnable: Boolean,
+ val connectionState: DiscordConnectionState,
+ val summary: DiscordSettingsSummary,
+ val accountName: String?,
+ val errorMessage: String?,
+)
+
+fun deriveDiscordSettingsState(
+ preferenceEnabled: Boolean,
+ transportAvailable: Boolean,
+ connectionState: DiscordConnectionState,
+ accountName: String?,
+ errorMessage: String?,
+): DiscordSettingsState {
+ val available = transportAvailable && connectionState != DiscordConnectionState.UNAVAILABLE
+ val enabled = preferenceEnabled && available
+ val summary = when {
+ !available -> DiscordSettingsSummary.UNAVAILABLE
+ connectionState == DiscordConnectionState.ERROR -> DiscordSettingsSummary.ERROR
+ !enabled -> DiscordSettingsSummary.OFF
+ connectionState == DiscordConnectionState.CONNECTED -> DiscordSettingsSummary.CONNECTED
+ else -> DiscordSettingsSummary.NOT_CONNECTED
+ }
+
+ return DiscordSettingsState(
+ isAvailable = available,
+ isEnabled = enabled,
+ canEnable = available,
+ connectionState = connectionState,
+ summary = summary,
+ accountName = accountName?.takeIf(String::isNotBlank),
+ errorMessage = errorMessage?.takeIf(String::isNotBlank),
+ )
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt b/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt
new file mode 100644
index 00000000..3326dce3
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/DiscordTokenStore.kt
@@ -0,0 +1,126 @@
+package io.github.aedev.flow.discord
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.AtomicFile
+import android.util.Base64
+import org.json.JSONObject
+import java.io.File
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+class DiscordTokenStore(context: Context) {
+ private val storageFile = AtomicFile(
+ File(context.applicationContext.noBackupFilesDir, TOKEN_FILE_NAME),
+ )
+
+ @Synchronized
+ fun save(tokens: DiscordAuthTokens) {
+ val plaintext = JSONObject()
+ .put("access_token", tokens.accessToken)
+ .put("refresh_token", tokens.refreshToken)
+ .put("expires_at", tokens.expiresAtEpochSeconds)
+ .toString()
+ .toByteArray(Charsets.UTF_8)
+
+ val cipher = Cipher.getInstance(TRANSFORMATION).apply {
+ init(Cipher.ENCRYPT_MODE, getOrCreateKey())
+ }
+ val payload = JSONObject()
+ .put("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
+ .put(
+ "ciphertext",
+ Base64.encodeToString(cipher.doFinal(plaintext), Base64.NO_WRAP),
+ )
+ .toString()
+ .toByteArray(Charsets.UTF_8)
+
+ val output = storageFile.startWrite()
+ try {
+ output.write(payload)
+ output.flush()
+ storageFile.finishWrite(output)
+ } catch (error: Throwable) {
+ storageFile.failWrite(output)
+ throw error
+ }
+ }
+
+ @Synchronized
+ fun load(): DiscordAuthTokens? {
+ if (!storageFile.baseFile.exists()) return null
+
+ return runCatching {
+ val payload = JSONObject(
+ storageFile.openRead().bufferedReader(Charsets.UTF_8).use { it.readText() },
+ )
+ val cipher = Cipher.getInstance(TRANSFORMATION).apply {
+ init(
+ Cipher.DECRYPT_MODE,
+ getOrCreateKey(),
+ GCMParameterSpec(
+ GCM_TAG_LENGTH_BITS,
+ Base64.decode(payload.getString("iv"), Base64.NO_WRAP),
+ ),
+ )
+ }
+ val plaintext = cipher.doFinal(
+ Base64.decode(payload.getString("ciphertext"), Base64.NO_WRAP),
+ )
+ val tokens = JSONObject(String(plaintext, Charsets.UTF_8))
+ DiscordAuthTokens(
+ accessToken = tokens.getString("access_token"),
+ refreshToken = tokens.getString("refresh_token"),
+ expiresAtEpochSeconds = tokens.getLong("expires_at"),
+ )
+ }.getOrElse {
+ clear()
+ null
+ }
+ }
+
+ @Synchronized
+ fun clear() {
+ storageFile.delete()
+ runCatching {
+ KeyStore.getInstance(ANDROID_KEYSTORE).apply {
+ load(null)
+ deleteEntry(KEY_ALIAS)
+ }
+ }
+ }
+
+ private fun getOrCreateKey(): SecretKey {
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
+ (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
+
+ return KeyGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_AES,
+ ANDROID_KEYSTORE,
+ ).run {
+ init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setRandomizedEncryptionRequired(true)
+ .build(),
+ )
+ generateKey()
+ }
+ }
+
+ private companion object {
+ const val TOKEN_FILE_NAME = "discord_tokens.enc"
+ const val KEY_ALIAS = "flow_discord_tokens_v1"
+ const val ANDROID_KEYSTORE = "AndroidKeyStore"
+ const val TRANSFORMATION = "AES/GCM/NoPadding"
+ const val GCM_TAG_LENGTH_BITS = 128
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt b/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt
new file mode 100644
index 00000000..a4e93e10
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/discord/NoOpDiscordPresenceTransport.kt
@@ -0,0 +1,37 @@
+package io.github.aedev.flow.discord
+
+import android.app.Activity
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class NoOpDiscordPresenceTransport(
+ private val unavailableMessage: String = "Discord Rich Presence is unavailable in this build.",
+) : DiscordPresenceTransport {
+ override val isAvailable: Boolean = false
+
+ private val _connectionState = MutableStateFlow(DiscordConnectionState.UNAVAILABLE)
+ override val connectionState: StateFlow = _connectionState.asStateFlow()
+
+ private val _linkedAccountName = MutableStateFlow(null)
+ override val linkedAccountName: StateFlow = _linkedAccountName.asStateFlow()
+
+ private val _lastError = MutableStateFlow(unavailableMessage)
+ override val lastError: StateFlow = _lastError.asStateFlow()
+
+ override fun attachActivity(activity: Activity?) = Unit
+
+ override suspend fun link(): DiscordLinkResult =
+ DiscordLinkResult.Failure(unavailableMessage)
+
+ override suspend fun connect(tokens: DiscordAuthTokens): DiscordLinkResult =
+ DiscordLinkResult.Failure(unavailableMessage)
+
+ override suspend fun update(payload: DiscordPresencePayload): Boolean = false
+
+ override suspend fun clear(): Boolean = false
+
+ override suspend fun unlink(): Boolean = false
+
+ override fun close() = Unit
+}
diff --git a/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt b/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt
new file mode 100644
index 00000000..0b0337d5
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt
@@ -0,0 +1,25 @@
+package io.github.aedev.flow.platform
+
+/** User-selected interface mode for the shared Flow APK. */
+enum class AppUiMode {
+ AUTOMATIC,
+ MOBILE,
+ TV;
+
+ fun resolve(deviceFormFactor: DeviceFormFactor): AppUiRoot = when (this) {
+ AUTOMATIC -> if (deviceFormFactor == DeviceFormFactor.TV) AppUiRoot.TV else AppUiRoot.MOBILE
+ MOBILE -> AppUiRoot.MOBILE
+ TV -> AppUiRoot.TV
+ }
+
+ companion object {
+ fun fromStorage(value: String?): AppUiMode =
+ entries.firstOrNull { it.name == value } ?: AUTOMATIC
+ }
+}
+
+/** The UI root rendered by [io.github.aedev.flow.MainActivity]. */
+enum class AppUiRoot {
+ MOBILE,
+ TV,
+}
diff --git a/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt b/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt
new file mode 100644
index 00000000..9532a3b2
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt
@@ -0,0 +1,22 @@
+package io.github.aedev.flow.platform
+
+import android.app.UiModeManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.res.Configuration
+
+/** Runtime device categories relevant to Flow's interface selection. */
+enum class DeviceFormFactor {
+ MOBILE,
+ TV,
+}
+
+/** Detects television devices without making touchscreen availability mandatory. */
+object DeviceFormFactorDetector {
+ fun detect(context: Context): DeviceFormFactor {
+ val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
+ val isTelevisionMode = uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
+ val hasLeanback = context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
+ return if (isTelevisionMode || hasLeanback) DeviceFormFactor.TV else DeviceFormFactor.MOBILE
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt b/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt
index 3e8c04c8..61c9b78e 100644
--- a/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt
+++ b/app/src/main/java/io/github/aedev/flow/player/shorts/ShortsPlayerPool.kt
@@ -20,6 +20,7 @@ import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.upstream.DefaultAllocator
import io.github.aedev.flow.data.local.PlayerPreferences
+import io.github.aedev.flow.data.model.ShortVideo
import io.github.aedev.flow.player.analytics.PlaybackAnalyticsLogger
import io.github.aedev.flow.player.config.PlayerConfig
import io.github.aedev.flow.player.datasource.YouTubeHttpDataSource
@@ -95,6 +96,19 @@ class ShortsPlayerPool private constructor() {
private val _currentVideoId = MutableStateFlow(null)
val currentVideoId: StateFlow = _currentVideoId.asStateFlow()
+ private val _currentVideo = MutableStateFlow(null)
+ val currentVideo: StateFlow = _currentVideo.asStateFlow()
+
+ fun setCurrentVideo(video: ShortVideo?) {
+ _currentVideo.value = video
+ }
+
+ fun playbackPosition(): Long = findActivePlayer()?.currentPosition ?: 0L
+
+ fun playbackDuration(): Long = findActivePlayer()?.duration?.coerceAtLeast(0L) ?: 0L
+
+ fun isPlaying(): Boolean = findActivePlayer()?.isPlaying == true
+
// INITIALIZATION
fun initialize(context: Context) {
if (isInitialized) return
@@ -498,6 +512,7 @@ class ShortsPlayerPool private constructor() {
}
isInitialized = false
_currentVideoId.value = null
+ _currentVideo.value = null
}
fun isReady(): Boolean = isInitialized && players[0] != null
diff --git a/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt b/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt
index 52e016a1..15b63c98 100644
--- a/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt
+++ b/app/src/main/java/io/github/aedev/flow/service/VideoPlayerService.kt
@@ -84,6 +84,9 @@ class VideoPlayerService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
+ // Android starts this service with startForegroundService when a Media3 controller connects.
+ // Promote before any player/session initialization so Android 16 cannot hit its FGS deadline.
+ promoteToForeground()
FlowCrashHandler.recordPhase("video-service", "onCreate")
val notificationProvider = DefaultMediaNotificationProvider.Builder(this)
.setChannelId(MEDIA_CHANNEL_ID)
@@ -135,9 +138,17 @@ class VideoPlayerService : MediaSessionService() {
ACTION_HIDE_POPUP -> popupPlayerWindow?.dismiss()
}
- if (EnhancedPlayerManager.getInstance().getVideoMediaSession() == null) {
+ val startPlan = videoServiceForegroundStartupPlan(
+ hasMediaSession = EnhancedPlayerManager.getInstance().getVideoMediaSession() != null,
+ )
+ if (startPlan.promoteImmediately && !promoteToForeground()) {
+ serviceLog("Foreground promotion failed — stopping before system deadline")
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+
+ if (startPlan.stopAfterPromotion) {
serviceLog("No media session available — placeholder then stop")
- promoteToForeground()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf(startId)
return START_NOT_STICKY
@@ -147,7 +158,7 @@ class VideoPlayerService : MediaSessionService() {
return START_STICKY
}
- private fun promoteToForeground() {
+ private fun promoteToForeground(): Boolean {
try {
ensureFallbackNotificationChannel()
val notification = NotificationCompat.Builder(this, FALLBACK_CHANNEL_ID)
@@ -159,8 +170,15 @@ class VideoPlayerService : MediaSessionService() {
this, FALLBACK_NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
)
+ FlowCrashHandler.recordPhase("video-service", "foreground-started")
+ return true
} catch (e: Exception) {
Log.e(TAG, "Failed to promote service to foreground", e)
+ FlowCrashHandler.recordPhase(
+ "video-service",
+ "foreground-start-failed ${e.javaClass.simpleName}",
+ )
+ return false
}
}
diff --git a/app/src/main/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicy.kt b/app/src/main/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicy.kt
new file mode 100644
index 00000000..5903c482
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/service/VideoServiceForegroundStartupPolicy.kt
@@ -0,0 +1,13 @@
+package io.github.aedev.flow.service
+
+internal data class VideoServiceForegroundStartupPlan(
+ val promoteImmediately: Boolean,
+ val stopAfterPromotion: Boolean,
+)
+
+internal fun videoServiceForegroundStartupPlan(
+ hasMediaSession: Boolean,
+): VideoServiceForegroundStartupPlan = VideoServiceForegroundStartupPlan(
+ promoteImmediately = true,
+ stopAfterPromotion = !hasMediaSession,
+)
diff --git a/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt b/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt
index aa75ae73..dd8ebd4c 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/FlowNavigation.kt
@@ -326,7 +326,16 @@ fun NavGraphBuilder.flowAppGraph(
onNavigateToAutoBackup = { navController.navigate("settings/auto_backup") },
onNavigateToSyncDevices = { navController.navigate("settings/sync_devices") },
onNavigateToExport = { navController.navigate("settings/export") },
- onNavigateToSponsorBlockSettings = { navController.navigate("settings/sponsorblock") }
+ onNavigateToSponsorBlockSettings = { navController.navigate("settings/sponsorblock") },
+ onNavigateToDiscordSettings = { navController.navigate("settings/discord") }
+ )
+ }
+
+ composable("settings/discord") {
+ currentRoute.value = "settings/discord"
+ showBottomNav.value = false
+ io.github.aedev.flow.ui.screens.settings.DiscordSettingsScreen(
+ onNavigateBack = { navController.popBackStack() }
)
}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt
new file mode 100644
index 00000000..55baa037
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/DiscordSettingsScreen.kt
@@ -0,0 +1,206 @@
+package io.github.aedev.flow.ui.screens.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.outlined.Link
+import androidx.compose.material.icons.outlined.LinkOff
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.Security
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.github.aedev.flow.R
+import io.github.aedev.flow.discord.DiscordConnectionState
+import io.github.aedev.flow.discord.DiscordPresenceRuntime
+import io.github.aedev.flow.discord.DiscordSettingsState
+import io.github.aedev.flow.discord.DiscordSettingsSummary
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DiscordSettingsScreen(onNavigateBack: () -> Unit) {
+ val state by DiscordPresenceRuntime.settingsState.collectAsStateWithLifecycle()
+ val scope = rememberCoroutineScope()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.discord_presence_title)) },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.discord_presence_enable),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ text = statusText(state),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(
+ checked = state.isEnabled,
+ enabled = state.canEnable,
+ onCheckedChange = { enabled ->
+ scope.launch { DiscordPresenceRuntime.setEnabled(enabled) }
+ },
+ )
+ }
+ }
+
+ state.accountName?.let { accountName ->
+ Text(
+ text = stringResource(R.string.discord_presence_connected_account, accountName),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+
+ state.errorMessage?.takeIf {
+ state.summary == DiscordSettingsSummary.ERROR || !state.isAvailable
+ }?.let { error ->
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ if (state.isAvailable) {
+ if (state.accountName == null) {
+ Button(
+ onClick = { scope.launch { DiscordPresenceRuntime.connectAccount() } },
+ enabled = state.connectionState !in setOf(
+ DiscordConnectionState.LINKING,
+ DiscordConnectionState.CONNECTING,
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(Icons.Outlined.Link, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.discord_presence_connect))
+ }
+ } else {
+ OutlinedButton(
+ onClick = { scope.launch { DiscordPresenceRuntime.unlink() } },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(Icons.Outlined.LinkOff, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.discord_presence_disconnect))
+ }
+ }
+
+ if (state.summary == DiscordSettingsSummary.ERROR) {
+ OutlinedButton(
+ onClick = { scope.launch { DiscordPresenceRuntime.retry() } },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(Icons.Outlined.Refresh, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.discord_presence_retry))
+ }
+ }
+ }
+
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.45f),
+ ),
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Outlined.Security, contentDescription = null)
+ Text(
+ text = stringResource(R.string.discord_presence_privacy_title),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.discord_presence_privacy_body),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.discord_presence_gateway_warning),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun discordSettingsSummaryText(state: DiscordSettingsState): String = when (state.summary) {
+ DiscordSettingsSummary.OFF -> stringResource(R.string.discord_presence_off)
+ DiscordSettingsSummary.NOT_CONNECTED -> stringResource(R.string.discord_presence_not_connected)
+ DiscordSettingsSummary.CONNECTED -> state.accountName?.let { account ->
+ stringResource(R.string.discord_presence_connected_as, account)
+ } ?: stringResource(R.string.discord_presence_connected)
+ DiscordSettingsSummary.UNAVAILABLE -> stringResource(R.string.discord_presence_unavailable)
+ DiscordSettingsSummary.ERROR -> stringResource(R.string.discord_presence_connection_error)
+}
+
+@Composable
+private fun statusText(state: DiscordSettingsState): String = when (state.connectionState) {
+ DiscordConnectionState.LINKING -> stringResource(R.string.discord_presence_linking)
+ DiscordConnectionState.CONNECTING -> stringResource(R.string.discord_presence_connecting)
+ else -> discordSettingsSummaryText(state)
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt
new file mode 100644
index 00000000..14cccc92
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt
@@ -0,0 +1,71 @@
+package io.github.aedev.flow.ui.screens.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.github.aedev.flow.R
+import io.github.aedev.flow.platform.AppUiMode
+
+@Composable
+fun InterfaceModeDialog(
+ selected: AppUiMode,
+ onSelected: (AppUiMode) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.interface_mode_title)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ AppUiMode.entries.forEach { mode ->
+ val title = when (mode) {
+ AppUiMode.AUTOMATIC -> stringResource(R.string.interface_mode_automatic)
+ AppUiMode.MOBILE -> stringResource(R.string.interface_mode_mobile)
+ AppUiMode.TV -> stringResource(R.string.interface_mode_tv)
+ }
+ val summary = when (mode) {
+ AppUiMode.AUTOMATIC -> stringResource(R.string.interface_mode_automatic_summary)
+ AppUiMode.MOBILE -> stringResource(R.string.interface_mode_mobile_summary)
+ AppUiMode.TV -> stringResource(R.string.interface_mode_tv_summary)
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelected(mode) }
+ .padding(vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ RadioButton(selected = selected == mode, onClick = null)
+ Column {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ Text(
+ text = summary,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.close))
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt
index d20c58e5..c2fa9d84 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt
@@ -44,6 +44,8 @@ import io.github.aedev.flow.player.DeepFlowManager
import io.github.aedev.flow.ui.theme.ThemeMode
import io.github.aedev.flow.ui.theme.extendedColors
import io.github.aedev.flow.data.local.PlayerPreferences
+import io.github.aedev.flow.data.local.AppUiModePreferences
+import io.github.aedev.flow.platform.AppUiMode
import io.github.aedev.flow.utils.AppLanguageManager
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
@@ -60,6 +62,8 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.ui.text.input.ImeAction
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.github.aedev.flow.discord.DiscordPresenceRuntime
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -90,11 +94,15 @@ fun SettingsScreen(
onNavigateToSyncDevices: () -> Unit,
onNavigateToExport: () -> Unit,
onNavigateToSponsorBlockSettings: () -> Unit,
+ onNavigateToDiscordSettings: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val playerPreferences = remember { PlayerPreferences(context) }
+ val appUiModePreferences = remember { AppUiModePreferences(context) }
+ val appUiMode by appUiModePreferences.mode.collectAsStateWithLifecycle(initialValue = AppUiMode.AUTOMATIC)
+ var showInterfaceModeDialog by remember { mutableStateOf(false) }
val backupRepo = remember { io.github.aedev.flow.data.local.BackupRepository(context) }
// Brain State
@@ -116,6 +124,18 @@ fun SettingsScreen(
// Player preferences states
val currentRegion by playerPreferences.trendingRegion.collectAsState(initial = "US")
val currentAppLanguage by playerPreferences.appLanguage.collectAsState(initial = AppLanguageManager.SYSTEM_DEFAULT)
+ val discordSettingsState by DiscordPresenceRuntime.settingsState.collectAsStateWithLifecycle()
+ val discordSettingsSummary = discordSettingsSummaryText(discordSettingsState)
+
+ if (showInterfaceModeDialog) {
+ InterfaceModeDialog(
+ selected = appUiMode,
+ onSelected = { mode ->
+ coroutineScope.launch { appUiModePreferences.setMode(mode) }
+ },
+ onDismiss = { showInterfaceModeDialog = false },
+ )
+ }
// Deep Flow state
val deepFlowActive by playerPreferences.deepFlowActive.collectAsState(initial = false)
@@ -228,6 +248,7 @@ fun SettingsScreen(
val allSettingsEntries = listOf(
SettingSearchEntry(Icons.Outlined.Psychology, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.flow_control_center), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.neural_interest_map_subtitle), secFlowEngine, onNavigateToPersonality),
SettingSearchEntry(Icons.Outlined.Palette, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_theme), "", secAppearance, onNavigateToAppearance),
+ SettingSearchEntry(Icons.Outlined.Tv, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_interface_mode), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_interface_mode_subtitle), secAppearance) { showInterfaceModeDialog = true },
SettingSearchEntry(Icons.Outlined.Language, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_app_language), currentAppLanguageLabel, secAppearance) { showAppLanguageDialog = true },
SettingSearchEntry(Icons.Outlined.AppShortcut, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_app_icon), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_app_icon_subtitle), secAppearance, onNavigateToAppIconPicker),
SettingSearchEntry(Icons.Outlined.Tune, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_player_appearance), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_player_appearance_subtitle), secAppearance, onNavigateToPlayerAppearance),
@@ -235,6 +256,7 @@ fun SettingsScreen(
SettingSearchEntry(Icons.Outlined.Schedule, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_datetime), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_datetime_subtitle), secAppearance, onNavigateToDateTimeSettings),
SettingSearchEntry(Icons.Outlined.FilterAlt, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_content_prefs), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_content_prefs_subtitle), secContentPlayback, onNavigateToUserPreferences),
SettingSearchEntry(Icons.Outlined.PlayCircle, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_player), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_player_subtitle), secContentPlayback, onNavigateToPlayerSettings),
+ SettingSearchEntry(Icons.Outlined.Share, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.discord_presence_title), discordSettingsSummary, secContentPlayback, onNavigateToDiscordSettings),
SettingSearchEntry(Icons.Outlined.Public, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_proxy), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_proxy_subtitle), secContentPlayback, onNavigateToProxySettings),
SettingSearchEntry(io.github.aedev.flow.R.drawable.ic_block, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.sb_settings_title), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.sb_settings_subtitle), secContentPlayback, onNavigateToSponsorBlockSettings),
SettingSearchEntry(Icons.Outlined.HighQuality, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_quality), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_quality_subtitle), secContentPlayback, onNavigateToVideoQuality),
@@ -712,6 +734,13 @@ item {
onClick = onNavigateToAppearance
)
HorizontalDivider(Modifier.padding(start = 56.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
+ SettingsItem(
+ icon = Icons.Outlined.Tv,
+ title = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_interface_mode),
+ subtitle = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_interface_mode_subtitle),
+ onClick = { showInterfaceModeDialog = true }
+ )
+ HorizontalDivider(Modifier.padding(start = 56.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
SettingsItem(
icon = Icons.Outlined.Language,
title = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_app_language),
@@ -770,6 +799,13 @@ item {
onClick = onNavigateToPlayerSettings
)
HorizontalDivider(Modifier.padding(start = 56.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
+ SettingsItem(
+ icon = Icons.Outlined.Share,
+ title = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.discord_presence_title),
+ subtitle = discordSettingsSummary,
+ onClick = onNavigateToDiscordSettings
+ )
+ HorizontalDivider(Modifier.padding(start = 56.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
SettingsItem(
icon = Icons.Outlined.Public,
title = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_proxy),
diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt
index f5e89385..7f7b6484 100644
--- a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt
+++ b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt
@@ -194,6 +194,7 @@ fun ShortsScreen(
val settled = pagerState.settledPage
val playerPool = ShortsPlayerPool.getInstance()
playerPool.initialize(context)
+ playerPool.setCurrentVideo(uiState.shorts.getOrNull(settled))
suspend fun prepareShort(index: Int, short: ShortVideo, shouldPlay: Boolean) {
try {
diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt
new file mode 100644
index 00000000..261dd718
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt
@@ -0,0 +1,116 @@
+package io.github.aedev.flow.ui.tv
+
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.media3.common.util.UnstableApi
+import io.github.aedev.flow.data.model.Video
+import io.github.aedev.flow.player.GlobalPlayerState
+import io.github.aedev.flow.ui.screens.home.HomeViewModel
+import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModel
+import io.github.aedev.flow.ui.screens.search.SearchViewModel
+import io.github.aedev.flow.ui.screens.subscriptions.SubscriptionsViewModel
+import io.github.aedev.flow.ui.tv.components.TvNavigationRail
+import io.github.aedev.flow.ui.tv.navigation.TvDestination
+import io.github.aedev.flow.ui.tv.screens.TvHomeScreen
+import io.github.aedev.flow.ui.tv.screens.TvLibraryScreen
+import io.github.aedev.flow.ui.tv.screens.TvPlayerScreen
+import io.github.aedev.flow.ui.tv.screens.TvSearchScreen
+import io.github.aedev.flow.ui.tv.screens.TvSettingsScreen
+import io.github.aedev.flow.ui.tv.screens.TvSubscriptionsScreen
+
+@androidx.annotation.OptIn(UnstableApi::class)
+@Composable
+fun FlowTvApp(
+ deeplinkVideoId: String? = null,
+ isShort: Boolean = false,
+ onDeeplinkConsumed: () -> Unit = {},
+) {
+ val context = LocalContext.current
+ val activity = context as ComponentActivity
+ val homeViewModel: HomeViewModel = hiltViewModel(activity)
+ val playerViewModel: VideoPlayerViewModel = hiltViewModel(activity)
+ val subscriptionsViewModel: SubscriptionsViewModel = viewModel(viewModelStoreOwner = activity)
+ val searchViewModel: SearchViewModel = viewModel(viewModelStoreOwner = activity)
+ var destination by remember { mutableStateOf(TvDestination.HOME) }
+ val activeVideo by GlobalPlayerState.currentVideo.collectAsStateWithLifecycle()
+
+ fun play(video: Video) {
+ GlobalPlayerState.setCurrentVideo(video)
+ playerViewModel.playVideo(video)
+ }
+
+ LaunchedEffect(deeplinkVideoId) {
+ val videoId = deeplinkVideoId ?: return@LaunchedEffect
+ play(
+ Video(
+ id = videoId,
+ title = "",
+ channelName = "",
+ channelId = "",
+ thumbnailUrl = "",
+ duration = 0,
+ viewCount = 0L,
+ uploadDate = "",
+ isShort = isShort,
+ )
+ )
+ onDeeplinkConsumed()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Row(modifier = Modifier.fillMaxSize()) {
+ TvNavigationRail(
+ selected = destination,
+ onSelected = { destination = it },
+ )
+ when (destination) {
+ TvDestination.HOME -> TvHomeScreen(
+ viewModel = homeViewModel,
+ onVideoClick = ::play,
+ modifier = Modifier.weight(1f),
+ )
+ TvDestination.SUBSCRIPTIONS -> TvSubscriptionsScreen(
+ viewModel = subscriptionsViewModel,
+ onVideoClick = ::play,
+ modifier = Modifier.weight(1f),
+ )
+ TvDestination.SEARCH -> TvSearchScreen(
+ viewModel = searchViewModel,
+ onVideoClick = ::play,
+ modifier = Modifier.weight(1f),
+ )
+ TvDestination.LIBRARY -> TvLibraryScreen(
+ onVideoClick = ::play,
+ modifier = Modifier.weight(1f),
+ )
+ TvDestination.SETTINGS -> TvSettingsScreen(
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+
+ activeVideo?.let { video ->
+ TvPlayerScreen(
+ video = video,
+ viewModel = playerViewModel,
+ onClose = {
+ playerViewModel.clearVideo()
+ GlobalPlayerState.setCurrentVideo(null)
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt
new file mode 100644
index 00000000..18708bbe
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt
@@ -0,0 +1,64 @@
+package io.github.aedev.flow.ui.tv.components
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.dp
+
+/** Material 3 card with a visible ten-foot focus state. */
+@Composable
+fun TvFocusableCard(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ var focused by remember { mutableStateOf(false) }
+ val scale by animateFloatAsState(
+ targetValue = if (focused) 1.05f else 1f,
+ label = "tvCardScale",
+ )
+
+ Card(
+ onClick = onClick,
+ modifier = modifier
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
+ .onFocusChanged { focused = it.isFocused },
+ colors = CardDefaults.cardColors(
+ containerColor = if (focused) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainer
+ },
+ contentColor = if (focused) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ ),
+ border = if (focused) {
+ BorderStroke(2.dp, MaterialTheme.colorScheme.outline)
+ } else {
+ null
+ },
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = if (focused) 8.dp else 1.dp,
+ ),
+ ) {
+ Box(content = content)
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt
new file mode 100644
index 00000000..b84da6a3
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt
@@ -0,0 +1,78 @@
+package io.github.aedev.flow.ui.tv.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.github.aedev.flow.ui.tv.navigation.TvDestination
+
+@Composable
+fun TvNavigationRail(
+ selected: TvDestination,
+ onSelected: (TvDestination) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier
+ .width(236.dp)
+ .fillMaxHeight(),
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ Column(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(io.github.aedev.flow.R.string.app_name),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ TvDestination.primary.forEach { destination ->
+ TvFocusableCard(
+ onClick = { onSelected(destination) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = destination.icon,
+ contentDescription = null,
+ tint = if (destination == selected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ Spacer(modifier = Modifier.width(14.dp))
+ Text(
+ text = stringResource(destination.labelRes),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = if (destination == selected) FontWeight.Bold else FontWeight.Medium,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt
new file mode 100644
index 00000000..869cafad
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt
@@ -0,0 +1,51 @@
+package io.github.aedev.flow.ui.tv.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun TvLoadingState(modifier: Modifier = Modifier) {
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+}
+
+@Composable
+fun TvMessageState(
+ title: String,
+ message: String? = null,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ )
+ if (!message.isNullOrBlank()) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt
new file mode 100644
index 00000000..ad7d9f5e
--- /dev/null
+++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt
@@ -0,0 +1,114 @@
+package io.github.aedev.flow.ui.tv.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import io.github.aedev.flow.data.model.Video
+
+@Composable
+fun TvVideoCard(
+ video: Video,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ TvFocusableCard(
+ onClick = onClick,
+ modifier = modifier.width(280.dp),
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ ) {
+ AsyncImage(
+ model = video.thumbnailUrl,
+ contentDescription = video.title,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ if (video.duration > 0) {
+ Text(
+ text = formatTvDuration(video.duration),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.inverseOnSurface,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(8.dp)
+ .background(
+ color = MaterialTheme.colorScheme.inverseSurface,
+ shape = MaterialTheme.shapes.extraSmall,
+ )
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+ }
+ }
+ Column(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = video.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = video.channelName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun TvVideoRow(
+ videos: List