Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions desktop/src/main/kotlin/ireader/desktop/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<ireader.domain.preferences.prefs.AppPreferences>()
if (appPrefs.discordRichPresenceEnabled().get()) {
koinApp.koin.getOrNull<ireader.domain.services.discord.DiscordStatePublisher>()
koinApp.koin.getOrNull<ireader.domain.services.discord.TTSActivityBridge>()
}
} catch (_: Exception) {
// A missing/failed RPC publisher must never break app startup.
}

//Dispatchers.setMain(StandardTestDispatcher())
application {
val state = rememberWindowState()
Expand Down
74 changes: 74 additions & 0 deletions docs/DISCORD_RICH_PRESENCE.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package ireader.domain.services.discord

internal actual fun nowMillis(): Long = System.currentTimeMillis()
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ val DomainServices = module {
// Download state - lightweight, can be singleton
single<DownloadStateHolder> { 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> { ireader.domain.services.discord.ActivityStateHolder() }

// Preferences - lightweight
single { ireader.domain.preferences.prefs.PlayerPreferences(get()) }
single { ireader.domain.preferences.prefs.DownloadPreferences(get()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,16 @@ class AppPreferences(
fun showUpdateDialog(): Preference<Boolean> {
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<Boolean> {
return preferenceStore.getBoolean("discord_rich_presence_enabled", true)
}
}
Original file line number Diff line number Diff line change
@@ -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>(ActivityState.Idle)
val activity: StateFlow<ActivityState> = _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
17 changes: 16 additions & 1 deletion domain/src/desktopMain/kotlin/ireader/domain/di/DomainModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
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> {
ireader.domain.services.discord.TTSActivityBridge(
ttsController = get(),
holder = get(),
).apply { start() }
}

// TTS Download Notification Helper
single<ireader.domain.services.tts_service.TTSDownloadNotificationHelper> {
ireader.domain.services.tts_service.TTSDownloadNotificationHelper(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package ireader.domain.services.discord

internal actual fun nowMillis(): Long = System.currentTimeMillis()
Loading