diff --git a/desktop/src/main/kotlin/ireader/desktop/Main.kt b/desktop/src/main/kotlin/ireader/desktop/Main.kt index 197c99c37..e4345aa64 100644 --- a/desktop/src/main/kotlin/ireader/desktop/Main.kt +++ b/desktop/src/main/kotlin/ireader/desktop/Main.kt @@ -136,6 +136,20 @@ fun main() { // Silently ignore auto-sync service errors } + // Discord Rich Presence: eagerly start the state publisher + TTS bridge so they + // begin observing activity and writing ~/.cache/IReader/discord_state.json, which + // the standalone ireader-discord bridge reads and pushes as RPC. Koin singles are + // lazy, so without this nothing instantiates them. Gated on a user preference. + try { + val appPrefs = koinApp.koin.get() + if (appPrefs.discordRichPresenceEnabled().get()) { + koinApp.koin.getOrNull() + koinApp.koin.getOrNull() + } + } catch (_: Exception) { + // A missing/failed RPC publisher must never break app startup. + } + //Dispatchers.setMain(StandardTestDispatcher()) application { val state = rememberWindowState() diff --git a/docs/DISCORD_RICH_PRESENCE.md b/docs/DISCORD_RICH_PRESENCE.md new file mode 100644 index 000000000..2bbbb0588 --- /dev/null +++ b/docs/DISCORD_RICH_PRESENCE.md @@ -0,0 +1,74 @@ +# Discord Rich Presence (desktop) + +IReader can publish your current activity — which tab you're browsing, the book +you're reading, or an active TTS session — to Discord as Rich Presence. + +## Design: file-IPC, no SDK in the app + +IReader itself has **no Discord dependency**. It only writes a small JSON state file; +a separate, standalone bridge process does the actual Discord RPC. This keeps the app +free of any Discord SDK and lets the presence layer be swapped or disabled without +touching the app. + +``` + IReader (desktop) ireader-discord bridge (separate process) + ┌────────────────────┐ writes ┌─────────────────────────────────────┐ + │ ActivityStateHolder │ ────────────▶ │ polls ~/.cache/IReader/ │ + │ DiscordStatePublisher│ JSON file │ discord_state.json → Discord IPC RPC │ + │ TTSActivityBridge │ │ (holds the Discord application ID) │ + └────────────────────┘ └─────────────────────────────────────┘ +``` + +The **Discord application / client ID and all RPC logic live in the bridge**, not in +IReader. Without the bridge running, IReader simply writes a file nobody reads. + +## State file + +- Path: `~/.cache/IReader/discord_state.json` +- Written atomically (`*.tmp` + rename) so the poller never reads a partial file. +- Writes are debounced ~300 ms; a 20 s heartbeat re-writes the current activity so a + motionless reading session doesn't look "stale" to the bridge. +- On app shutdown / idle the file is **deleted** — the bridge treats a missing file as + "clear presence" (`rpc.clear()`). The bridge should also treat a file older than + ~30 s as "IReader closed". + +### JSON schema + +```jsonc +{ + "mode": "browsing" | "perusing" | "reading" | "tts" | "idle", + "tab": "Library", // browsing only + "subTab": null, // browsing, optional + "book": "Book Title", // perusing / reading / tts + "author": "Author", + "cover": "https://…", // raw cover URL (bridge may re-host) + "bookUrl": "https://…", // source URL (book.key) for a Discord button + "chapter": "Chapter 12", // reading / tts + "chapterIndex": 12, // 1-based + "chapterCount": 40, + "startedAt": 1717459200000 // epoch millis; presence "elapsed" start +} +``` + +`idle` is never published as presence — it's the signal to clear. State precedence is +**TTS > Reading**; Browsing/Perusing are last-writer-wins (navigation is sequential). + +## Enabling / disabling + +Controlled by `AppPreferences.discordRichPresenceEnabled()` (default **on**). The +publisher + TTS bridge are eager-initialised at desktop startup only when this is set; +toggling it takes effect on the next app start. (Settings UI toggle: TODO — the pref +and the startup gate exist; the screen control is a small follow-on.) + +## The companion bridge + +The RPC bridge is a separate project (not in this repository). It is the component that +owns the Discord application ID, opens the Discord IPC socket, maps the JSON `mode` to +presence assets/buttons, and calls `rpc.clear()` when the file disappears. Ship/install +it separately, or replace the file consumer with any process that understands the +schema above. + +> Maintainer note: because the app side is only a state-file writer, this feature does +> nothing visible on its own. If a self-contained experience is preferred, the RPC +> could instead be implemented in-app (Kotlin Discord IPC over the local socket); the +> file-IPC approach was chosen to keep IReader SDK-free. diff --git a/domain/src/androidMain/kotlin/ireader/domain/services/discord/ActivityStateHolderAndroid.kt b/domain/src/androidMain/kotlin/ireader/domain/services/discord/ActivityStateHolderAndroid.kt new file mode 100644 index 000000000..4ed5314a2 --- /dev/null +++ b/domain/src/androidMain/kotlin/ireader/domain/services/discord/ActivityStateHolderAndroid.kt @@ -0,0 +1,3 @@ +package ireader.domain.services.discord + +internal actual fun nowMillis(): Long = System.currentTimeMillis() diff --git a/domain/src/commonMain/kotlin/ireader/domain/di/DomainModules.kt b/domain/src/commonMain/kotlin/ireader/domain/di/DomainModules.kt index 300a6fd24..137513b46 100644 --- a/domain/src/commonMain/kotlin/ireader/domain/di/DomainModules.kt +++ b/domain/src/commonMain/kotlin/ireader/domain/di/DomainModules.kt @@ -55,6 +55,11 @@ val DomainServices = module { // Download state - lightweight, can be singleton single { DownloadStateHolder() } + // Activity state holder — current reading activity (browsing / perusing / + // reading / TTS / idle) for the Discord Rich Presence pipeline. Desktop wires a + // publisher that observes this; platforms without a publisher ignore it. + single { ireader.domain.services.discord.ActivityStateHolder() } + // Preferences - lightweight single { ireader.domain.preferences.prefs.PlayerPreferences(get()) } single { ireader.domain.preferences.prefs.DownloadPreferences(get()) } diff --git a/domain/src/commonMain/kotlin/ireader/domain/preferences/prefs/AppPreferences.kt b/domain/src/commonMain/kotlin/ireader/domain/preferences/prefs/AppPreferences.kt index 918579a21..ff562cb06 100644 --- a/domain/src/commonMain/kotlin/ireader/domain/preferences/prefs/AppPreferences.kt +++ b/domain/src/commonMain/kotlin/ireader/domain/preferences/prefs/AppPreferences.kt @@ -280,4 +280,16 @@ class AppPreferences( fun showUpdateDialog(): Preference { return preferenceStore.getBoolean("show_update_dialog", true) } + + // ==================== Discord Rich Presence ==================== + + /** + * Whether to publish reading/TTS activity to ~/.cache/IReader/discord_state.json + * for the companion ireader-discord bridge to push as Discord Rich Presence. + * Editable from Settings. Takes effect on next app start (the publisher is + * eager-initialised at startup). + */ + fun discordRichPresenceEnabled(): Preference { + return preferenceStore.getBoolean("discord_rich_presence_enabled", true) + } } diff --git a/domain/src/commonMain/kotlin/ireader/domain/services/discord/ActivityStateHolder.kt b/domain/src/commonMain/kotlin/ireader/domain/services/discord/ActivityStateHolder.kt new file mode 100644 index 000000000..b2935e05e --- /dev/null +++ b/domain/src/commonMain/kotlin/ireader/domain/services/discord/ActivityStateHolder.kt @@ -0,0 +1,198 @@ +package ireader.domain.services.discord + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Cross-platform singleton tracking the user's current activity for the Discord Rich + * Presence pipeline. Hook sites (main tabs, book detail, reader, TTS) push updates + * here; platform publishers observe and forward. Platforms without a publisher ignore + * the flow entirely. + * + * Five activity states: + * - [ActivityState.Browsing] — in a main tab (Library / Updates / History / …) + * - [ActivityState.Perusing] — on a book detail page (cover + title) + * - [ActivityState.Reading] — in the reader actively reading a chapter + * - [ActivityState.ListeningTTS] — TTS playback active + * - [ActivityState.Idle] — emitted only at app shutdown; the publisher treats + * this as "delete the state file" so Discord's presence clears entirely. + * + * Precedence: TTS > Reading. Browsing and Perusing don't self-gate; the most recent + * setter wins because navigation is inherently sequential. + */ +class ActivityStateHolder { + private val _activity = MutableStateFlow(ActivityState.Idle) + val activity: StateFlow = _activity.asStateFlow() + + // Precedence flag. When true, `setReading` becomes a no-op so the reader's + // frequent state ticks (font-size changes, scroll, etc.) don't clobber the + // TTS activity. Cleared when TTS stops or is released. + private var ttsLocked: Boolean = false + + // Cache of the last Browsing state. When transient screens (reader, book + // detail) unmount, they call `restoreBrowsing()` so the presence flips back + // to the library/tab the user was on — this avoids a dead gap where no one + // is publishing (the main screen was already composed and its LaunchedEffect + // won't re-fire on back-navigation). + private var lastBrowsing: ActivityState.Browsing? = null + + fun setBrowsing(tab: String, subTab: String?) { + if (ttsLocked) return + val current = _activity.value + if (current is ActivityState.Browsing && current.tab == tab && current.subTab == subTab) { + return // no-op — avoids spurious StateFlow emissions on recomposition + } + val startedAt = if (current is ActivityState.Browsing) current.startedAt else nowMillis() + val newState = ActivityState.Browsing(tab = tab, subTab = subTab, startedAt = startedAt) + lastBrowsing = newState + _activity.value = newState + } + + /** + * Restore the last known Browsing state. Called from transient screens' + * onCleared/onDispose hooks (reader VM, book detail VM) when they pop back to + * a main tab; the main tab's composable won't re-fire its LaunchedEffect + * because it never actually unmounted. + */ + fun restoreBrowsing() { + if (ttsLocked) return + val restore = lastBrowsing ?: ActivityState.Browsing(tab = "Library", subTab = null, startedAt = nowMillis()) + _activity.value = restore + } + + fun setPerusing( + bookTitle: String, + author: String?, + coverUrl: String?, + bookUrl: String?, + ) { + if (ttsLocked) return + val current = _activity.value + if (current is ActivityState.Perusing && current.bookTitle == bookTitle) return + val startedAt = if (current is ActivityState.Perusing && current.bookTitle == bookTitle) { + current.startedAt + } else { + nowMillis() + } + _activity.value = ActivityState.Perusing( + bookTitle = bookTitle, + author = author, + coverUrl = coverUrl, + bookUrl = bookUrl, + startedAt = startedAt, + ) + } + + fun setReading( + bookTitle: String, + author: String?, + coverUrl: String?, + bookUrl: String?, + chapterName: String, + chapterIndex: Int, + chapterCount: Int, + ) { + if (ttsLocked) return + val current = _activity.value + val startedAt = if (current is ActivityState.Reading && current.bookTitle == bookTitle) { + current.startedAt + } else { + nowMillis() + } + _activity.value = ActivityState.Reading( + bookTitle = bookTitle, + author = author, + coverUrl = coverUrl, + bookUrl = bookUrl, + chapterName = chapterName, + chapterIndex = chapterIndex, + chapterCount = chapterCount, + startedAt = startedAt, + ) + } + + fun setListeningTTS( + bookTitle: String, + author: String?, + coverUrl: String?, + bookUrl: String?, + chapterName: String, + chapterIndex: Int, + chapterCount: Int, + ) { + ttsLocked = true + val current = _activity.value + val startedAt = if (current is ActivityState.ListeningTTS && current.bookTitle == bookTitle) { + current.startedAt + } else { + nowMillis() + } + _activity.value = ActivityState.ListeningTTS( + bookTitle = bookTitle, + author = author, + coverUrl = coverUrl, + bookUrl = bookUrl, + chapterName = chapterName, + chapterIndex = chapterIndex, + chapterCount = chapterCount, + startedAt = startedAt, + ) + } + + /** Called when TTS stops. Releases the TTS lock so reader/browse activity can publish again. */ + fun releaseTTS() { + ttsLocked = false + } + + /** Emitted only at app shutdown so the publisher wipes the state file (clears presence entirely). */ + fun setIdle() { + ttsLocked = false + if (_activity.value != ActivityState.Idle) { + _activity.value = ActivityState.Idle + } + } +} + +sealed interface ActivityState { + /** Publisher sentinel: delete the state file, Python bridge will `rpc.clear()`. */ + data object Idle : ActivityState + + data class Browsing( + val tab: String, + val subTab: String?, + val startedAt: Long, + ) : ActivityState + + data class Perusing( + val bookTitle: String, + val author: String?, + val coverUrl: String?, + val bookUrl: String?, + val startedAt: Long, + ) : ActivityState + + data class Reading( + val bookTitle: String, + val author: String?, + val coverUrl: String?, + val bookUrl: String?, + val chapterName: String, + val chapterIndex: Int, + val chapterCount: Int, + val startedAt: Long, + ) : ActivityState + + data class ListeningTTS( + val bookTitle: String, + val author: String?, + val coverUrl: String?, + val bookUrl: String?, + val chapterName: String, + val chapterIndex: Int, + val chapterCount: Int, + val startedAt: Long, + ) : ActivityState +} + +internal expect fun nowMillis(): Long diff --git a/domain/src/desktopMain/kotlin/ireader/domain/di/DomainModule.kt b/domain/src/desktopMain/kotlin/ireader/domain/di/DomainModule.kt index 7a38ec2f1..4296b3595 100644 --- a/domain/src/desktopMain/kotlin/ireader/domain/di/DomainModule.kt +++ b/domain/src/desktopMain/kotlin/ireader/domain/di/DomainModule.kt @@ -219,7 +219,22 @@ actual val DomainModule: Module = module { initialize() } } - + + // Discord Rich Presence state writer — observes ActivityStateHolder and writes + // ~/.cache/IReader/discord_state.json for the standalone ireader-discord bridge. + single { + ireader.domain.services.discord.DiscordStatePublisher(holder = get()).apply { start() } + } + + // TTS -> Rich Presence bridge. Publishes "Listening" activity while v2 TTS + // playback is active (subscribes to the real TTSController.state). + single { + ireader.domain.services.discord.TTSActivityBridge( + ttsController = get(), + holder = get(), + ).apply { start() } + } + // TTS Download Notification Helper single { ireader.domain.services.tts_service.TTSDownloadNotificationHelper( diff --git a/domain/src/desktopMain/kotlin/ireader/domain/services/discord/ActivityStateHolderDesktop.kt b/domain/src/desktopMain/kotlin/ireader/domain/services/discord/ActivityStateHolderDesktop.kt new file mode 100644 index 000000000..4ed5314a2 --- /dev/null +++ b/domain/src/desktopMain/kotlin/ireader/domain/services/discord/ActivityStateHolderDesktop.kt @@ -0,0 +1,3 @@ +package ireader.domain.services.discord + +internal actual fun nowMillis(): Long = System.currentTimeMillis() diff --git a/domain/src/desktopMain/kotlin/ireader/domain/services/discord/DiscordStatePublisher.kt b/domain/src/desktopMain/kotlin/ireader/domain/services/discord/DiscordStatePublisher.kt new file mode 100644 index 000000000..cdb2f19a9 --- /dev/null +++ b/domain/src/desktopMain/kotlin/ireader/domain/services/discord/DiscordStatePublisher.kt @@ -0,0 +1,184 @@ +package ireader.domain.services.discord + +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Writes the user's current activity to `~/.cache/IReader/discord_state.json` so the + * standalone `ireader-discord` Python service can pick it up and push Rich Presence + * to Discord. File-based IPC deliberately keeps IReader itself free of any Discord + * SDK dependency — the bridge process handles all RPC. + * + * JSON schema: + * ``` + * { + * "mode": "browsing" | "perusing" | "reading" | "tts", + * "tab": string?, // browsing only (e.g. "Library") + * "subTab": string?, // browsing, optional (e.g. "Popular") + * "book": string?, // perusing/reading/tts + * "author": string?, + * "cover": string?, // raw URL; Python will rehost through catbox + * "bookUrl": string?, // source URL (book.key) — used for Discord button + * "chapter": string?, // reading/tts + * "chapterIndex": int?, // 1-based + * "chapterCount": int?, + * "startedAt": long // epoch millis (presence "elapsed" counter start) + * } + * ``` + * + * When [ActivityState.Idle] arrives (app shutdown), the file is **deleted** — that's + * the signal to Python to call `rpc.clear()` so Discord shows nothing at all. A stale + * file (no writes for > 30s) is also treated as "IReader closed" by the bridge. + * + * Writes are debounced (300 ms) so rapid state changes during navigation don't thrash + * the filesystem. Atomic rename prevents the Python poller from ever reading a + * half-written file. + */ +class DiscordStatePublisher( + private val holder: ActivityStateHolder, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val stateFile: File = run { + val cacheDir = File(System.getProperty("user.home"), ".cache/IReader") + cacheDir.mkdirs() + File(cacheDir, "discord_state.json") + } + private val tmpFile = File(stateFile.parentFile, stateFile.name + ".tmp") + + private var writer: Job? = null + private var heartbeat: Job? = null + + fun start() { + if (writer?.isActive == true) return + writer = scope.launch { + // StateFlow already conflates — if two updates arrive before the collector + // wakes, only the newest is delivered. collectLatest then gives us debouncing: + // a new emission cancels the delay and this block re-enters with the newer + // value, so we only write at most ~3× per second during rapid changes. + holder.activity.collectLatest { activity -> + delay(300) + writeState(activity) + } + } + // Heartbeat: re-write the current activity every 20s while the user is + // doing anything (reading, listening, browsing, perusing). Without this, + // a motionless reader session triggers no state changes — the Python + // bridge then treats the state file as stale (>30s) and clears the + // presence mid-read. The heartbeat is skipped on Idle so a truly closed + // IReader session still goes stale and the presence drops. + heartbeat = scope.launch { + while (true) { + delay(20_000) + val current = holder.activity.value + if (current !is ActivityState.Idle) { + writeState(current) + } + } + } + } + + fun stop() { + writer?.cancel() + heartbeat?.cancel() + writer = null + heartbeat = null + scope.launch { writeState(ActivityState.Idle) } + } + + private suspend fun writeState(activity: ActivityState) = withContext(Dispatchers.IO) { + try { + if (activity is ActivityState.Idle) { + // Signal "IReader closed" by deleting the file outright — Python + // treats file-missing as `rpc.clear()` rather than publishing any + // synthetic "idle" presence. + stateFile.delete() + tmpFile.delete() + return@withContext + } + val json = activity.toJson() + tmpFile.writeText(json) + // Atomic rename so readers never see a partial file. + if (!tmpFile.renameTo(stateFile)) { + stateFile.writeText(json) + tmpFile.delete() + } + } catch (_: Exception) { + // Never let publisher failure bubble up into the UI. + } + } +} + +private fun ActivityState.toJson(): String { + val b = StringBuilder() + b.append('{') + when (this) { + ActivityState.Idle -> { + b.append("\"mode\":\"idle\"") + } + is ActivityState.Browsing -> { + b.append("\"mode\":\"browsing\"") + b.append(",\"tab\":").appendJsonString(tab) + b.append(",\"subTab\":").appendJsonString(subTab) + b.append(",\"startedAt\":").append(startedAt) + } + is ActivityState.Perusing -> { + b.append("\"mode\":\"perusing\"") + b.append(",\"book\":").appendJsonString(bookTitle) + b.append(",\"author\":").appendJsonString(author) + b.append(",\"cover\":").appendJsonString(coverUrl) + b.append(",\"bookUrl\":").appendJsonString(bookUrl) + b.append(",\"startedAt\":").append(startedAt) + } + is ActivityState.Reading -> { + b.append("\"mode\":\"reading\"") + b.append(",\"book\":").appendJsonString(bookTitle) + b.append(",\"author\":").appendJsonString(author) + b.append(",\"cover\":").appendJsonString(coverUrl) + b.append(",\"bookUrl\":").appendJsonString(bookUrl) + b.append(",\"chapter\":").appendJsonString(chapterName) + b.append(",\"chapterIndex\":").append(chapterIndex) + b.append(",\"chapterCount\":").append(chapterCount) + b.append(",\"startedAt\":").append(startedAt) + } + is ActivityState.ListeningTTS -> { + b.append("\"mode\":\"tts\"") + b.append(",\"book\":").appendJsonString(bookTitle) + b.append(",\"author\":").appendJsonString(author) + b.append(",\"cover\":").appendJsonString(coverUrl) + b.append(",\"bookUrl\":").appendJsonString(bookUrl) + b.append(",\"chapter\":").appendJsonString(chapterName) + b.append(",\"chapterIndex\":").append(chapterIndex) + b.append(",\"chapterCount\":").append(chapterCount) + b.append(",\"startedAt\":").append(startedAt) + } + } + b.append('}') + return b.toString() +} + +private fun StringBuilder.appendJsonString(value: String?) { + if (value == null) { + append("null") + return + } + append('"') + for (c in value) { + when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + in '\u0000'..'\u001F' -> append("\\u%04x".format(c.code)) + else -> append(c) + } + } + append('"') +} diff --git a/domain/src/desktopMain/kotlin/ireader/domain/services/discord/TTSActivityBridge.kt b/domain/src/desktopMain/kotlin/ireader/domain/services/discord/TTSActivityBridge.kt new file mode 100644 index 000000000..2f0c84588 --- /dev/null +++ b/domain/src/desktopMain/kotlin/ireader/domain/services/discord/TTSActivityBridge.kt @@ -0,0 +1,68 @@ +package ireader.domain.services.discord + +import ireader.domain.services.tts_service.v2.TTSController +import ireader.domain.services.tts_service.v2.TTSState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.launch + +/** + * Wires the v2 [TTSController]'s state → [ActivityStateHolder] so Discord Rich + * Presence reflects TTS playback. The **legacy** `DesktopTTSService` isn't used + * by the current reader/TTS pipeline — its state stays idle even while v2 + * playback is running — so this bridge has to observe `TTSController.state` + * directly. + * + * Presence rules: + * - `playbackState == PLAYING` with book+chapter → `setListeningTTS` + * - any non-playing transition → `releaseTTS` so the reader (or the tab + * browsing state) can take over publishing again. + * + * Started eagerly at app init from [ireader.domain.di.DomainModule]. + */ +class TTSActivityBridge( + private val ttsController: TTSController, + private val holder: ActivityStateHolder, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var job: Job? = null + + fun start() { + if (job?.isActive == true) return + job = scope.launch { + ttsController.state + // Narrow: only re-fire when fields that affect the presence change. + .distinctUntilChangedBy { Key(it.isPlaying, it.book?.id, it.chapter?.id) } + .collect { snap -> apply(snap) } + } + } + + private fun apply(s: TTSState) { + val book = s.book + val chapter = s.chapter + if (s.isPlaying && book != null && chapter != null) { + holder.setListeningTTS( + bookTitle = book.title, + author = book.author.takeIf { it.isNotBlank() }, + coverUrl = book.cover.takeIf { it.isNotBlank() }, + bookUrl = book.key.takeIf { + it.startsWith("http://") || it.startsWith("https://") + }, + chapterName = chapter.name, + // v2 TTSState doesn't carry chapter index/count — we don't have + // access to the chapters list here, so publish 1/1 as a + // placeholder. The reader VM fills these fields when the user + // returns from TTS to the reader screen. + chapterIndex = 1, + chapterCount = 1, + ) + } else if (!s.isPlaying) { + holder.releaseTTS() + } + } + + private data class Key(val playing: Boolean, val bookId: Long?, val chapterId: Long?) +} diff --git a/presentation/src/commonMain/kotlin/ireader/presentation/core/MainStarterScreen.kt b/presentation/src/commonMain/kotlin/ireader/presentation/core/MainStarterScreen.kt index fecb357e6..06451bb44 100644 --- a/presentation/src/commonMain/kotlin/ireader/presentation/core/MainStarterScreen.kt +++ b/presentation/src/commonMain/kotlin/ireader/presentation/core/MainStarterScreen.kt @@ -135,7 +135,18 @@ object MainStarterScreen { currentTabIndex = pending } } - + + // Discord Rich Presence: publish which main tab is visible. The holder's setter + // is idempotent so recomposition doesn't thrash; the key fires on first + // composition and on every real tab change (covers deep-link initial tab too). + val activityHolder = org.koin.compose.koinInject() + LaunchedEffect(currentTabIndex) { + activityHolder.setBrowsing( + tab = ireader.presentation.core.ui.AppTab.fromIndex(currentTabIndex).displayName, + subTab = null, + ) + } + // Track visited tabs - start EMPTY to defer all tab initialization // This improves startup time by not loading Library data until after first frame var visitedTabs by rememberSaveable { mutableStateOf(emptySet()) } diff --git a/presentation/src/commonMain/kotlin/ireader/presentation/core/ui/AppTab.kt b/presentation/src/commonMain/kotlin/ireader/presentation/core/ui/AppTab.kt index 5efebc122..1ef983f65 100644 --- a/presentation/src/commonMain/kotlin/ireader/presentation/core/ui/AppTab.kt +++ b/presentation/src/commonMain/kotlin/ireader/presentation/core/ui/AppTab.kt @@ -9,7 +9,13 @@ import androidx.compose.ui.graphics.painter.Painter */ sealed class AppTab( val index: Int, - val route: String + val route: String, + /** + * Stable, locale-independent English name for external integrations + * (Discord Rich Presence, logs) that run outside a Composable scope and + * can't read [title]. The user-facing label still flows through [title]. + */ + val displayName: String, ) { abstract val title: String @Composable get @@ -20,7 +26,7 @@ sealed class AppTab( @Composable abstract fun Content() - data object Library : AppTab(0, "tab_library") { + data object Library : AppTab(0, "tab_library", "Library") { override val title: String @Composable get() = LibraryScreenSpec.getTitle() @@ -33,7 +39,7 @@ sealed class AppTab( } } - data object Updates : AppTab(1, "tab_updates") { + data object Updates : AppTab(1, "tab_updates", "Updates") { override val title: String @Composable get() = UpdateScreenSpec.getTitle() @@ -46,7 +52,7 @@ sealed class AppTab( } } - data object History : AppTab(2, "tab_history") { + data object History : AppTab(2, "tab_history", "History") { override val title: String @Composable get() = HistoryScreenSpec.getTitle() @@ -59,7 +65,7 @@ sealed class AppTab( } } - data object Extensions : AppTab(3, "tab_extensions") { + data object Extensions : AppTab(3, "tab_extensions", "Extensions") { override val title: String @Composable get() = ExtensionScreenSpec.getTitle() @@ -72,7 +78,7 @@ sealed class AppTab( } } - data object More : AppTab(4, "tab_more") { + data object More : AppTab(4, "tab_more", "More") { override val title: String @Composable get() = MoreScreenSpec.getTitle()