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