From 33319a102d73c81dd4d559ba54f30d4843bf17ad Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 29 Mar 2026 15:28:01 -0700 Subject: [PATCH] feat(discord): add configurable presence style presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded "Mining and crafting (Anki cards)" meme message with a preset system. New `discordPresence.presenceStyle` option supports four presets: "default" (clean bilingual), "meme" (the OG Minecraft joke), "japanese" (fully JP), and "minimal". The default preset shows "Sentence Mining" with 日本語学習中 as the small image tooltip. Existing users can set presenceStyle to "meme" to keep the old behavior. --- config.example.jsonc | 1 + docs-site/configuration.md | 32 ++++--- docs-site/public/config.example.jsonc | 1 + .../definitions/defaults-integrations.ts | 1 + .../definitions/options-integrations.ts | 7 ++ src/core/services/discord-presence.test.ts | 48 +++++++++- src/core/services/discord-presence.ts | 91 ++++++++++++++----- src/types/config.ts | 1 + src/types/integrations.ts | 3 + 9 files changed, 148 insertions(+), 37 deletions(-) diff --git a/config.example.jsonc b/config.example.jsonc index 2f5c223..de4e143 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -498,6 +498,7 @@ // ========================================== "discordPresence": { "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false + "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". "updateIntervalMs": 3000, // Minimum interval between presence payload updates. "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index d06654d..399bc63 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1197,30 +1197,38 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine { "discordPresence": { "enabled": true, + "presenceStyle": "default", "updateIntervalMs": 3000, "debounceMs": 750 } } ``` -| Option | Values | Description | -| ------------------ | --------------- | ---------------------------------------------------------- | -| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) | -| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | -| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | +| Option | Values | Description | +| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) | +| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | +| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | +| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | Setup steps: 1. Set `discordPresence.enabled` to `true`. -2. Restart SubMiner. +2. Optionally set `discordPresence.presenceStyle` to choose a card text preset. +3. Restart SubMiner. -SubMiner uses a fixed official activity card style for all users: +#### Presence style presets -- Details: current media title while playing (fallback: `Mining and crafting (Anki cards)` when idle/disconnected) -- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`) -- Large image key/text: `subminer-logo` / `SubMiner` -- Small image key/text: `study` / `Sentence Mining` -- No activity button by default +While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images. + +| Preset | Idle details | Small image text | Vibe | +| ------------ | ----------------------------------- | ------------------ | --------------------------------------- | +| **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair | +| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke | +| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese | +| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay | + +All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default. Troubleshooting: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 2f5c223..de4e143 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -498,6 +498,7 @@ // ========================================== "discordPresence": { "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false + "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". "updateIntervalMs": 3000, // Minimum interval between presence payload updates. "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 9d63dfe..af38b21 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -129,6 +129,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, discordPresence: { enabled: false, + presenceStyle: 'default' as const, updateIntervalMs: 3_000, debounceMs: 750, }, diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index d6c8815..5e60605 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -323,6 +323,13 @@ export function buildIntegrationConfigOptionRegistry( defaultValue: defaultConfig.discordPresence.enabled, description: 'Enable optional Discord Rich Presence updates.', }, + { + path: 'discordPresence.presenceStyle', + kind: 'string', + defaultValue: defaultConfig.discordPresence.presenceStyle, + description: + 'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".', + }, { path: 'discordPresence.updateIntervalMs', kind: 'number', diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts index f14ffae..68745fe 100644 --- a/src/core/services/discord-presence.test.ts +++ b/src/core/services/discord-presence.test.ts @@ -10,6 +10,7 @@ import { const baseConfig = { enabled: true, + presenceStyle: 'default' as const, updateIntervalMs: 10_000, debounceMs: 200, } as const; @@ -27,24 +28,67 @@ const baseSnapshot: DiscordPresenceSnapshot = { sessionStartedAtMs: BASE_SESSION_STARTED_AT_MS, }; -test('buildDiscordPresenceActivity maps polished payload fields', () => { +test('buildDiscordPresenceActivity maps polished payload fields (default style)', () => { const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot); assert.equal(payload.details, 'Sousou no Frieren E01'); assert.equal(payload.state, 'Playing 01:35 / 24:10'); assert.equal(payload.largeImageKey, 'subminer-logo'); assert.equal(payload.smallImageKey, 'study'); + assert.equal(payload.smallImageText, '日本語学習中'); assert.equal(payload.buttons, undefined); assert.equal(payload.startTimestamp, Math.floor(BASE_SESSION_STARTED_AT_MS / 1000)); }); -test('buildDiscordPresenceActivity falls back to idle when disconnected', () => { +test('buildDiscordPresenceActivity falls back to idle with default style', () => { const payload = buildDiscordPresenceActivity(baseConfig, { ...baseSnapshot, connected: false, mediaPath: null, }); assert.equal(payload.state, 'Idle'); + assert.equal(payload.details, 'Sentence Mining'); +}); + +test('buildDiscordPresenceActivity uses meme style fallback', () => { + const memeConfig = { ...baseConfig, presenceStyle: 'meme' as const }; + const payload = buildDiscordPresenceActivity(memeConfig, { + ...baseSnapshot, + connected: false, + mediaPath: null, + }); assert.equal(payload.details, 'Mining and crafting (Anki cards)'); + assert.equal(payload.smallImageText, 'Sentence Mining'); +}); + +test('buildDiscordPresenceActivity uses japanese style', () => { + const jpConfig = { ...baseConfig, presenceStyle: 'japanese' as const }; + const payload = buildDiscordPresenceActivity(jpConfig, { + ...baseSnapshot, + connected: false, + mediaPath: null, + }); + assert.equal(payload.details, '文の採掘中'); + assert.equal(payload.smallImageText, 'イマージョン学習'); +}); + +test('buildDiscordPresenceActivity uses minimal style', () => { + const minConfig = { ...baseConfig, presenceStyle: 'minimal' as const }; + const payload = buildDiscordPresenceActivity(minConfig, { + ...baseSnapshot, + connected: false, + mediaPath: null, + }); + assert.equal(payload.details, 'SubMiner'); + assert.equal(payload.smallImageKey, undefined); + assert.equal(payload.smallImageText, undefined); +}); + +test('buildDiscordPresenceActivity shows media title regardless of style', () => { + for (const presenceStyle of ['default', 'meme', 'japanese', 'minimal'] as const) { + const payload = buildDiscordPresenceActivity({ ...baseConfig, presenceStyle }, baseSnapshot); + assert.equal(payload.details, 'Sousou no Frieren E01'); + assert.equal(payload.state, 'Playing 01:35 / 24:10'); + } }); test('service deduplicates identical updates and sends changed timeline', async () => { diff --git a/src/core/services/discord-presence.ts b/src/core/services/discord-presence.ts index 5876a34..420a8e9 100644 --- a/src/core/services/discord-presence.ts +++ b/src/core/services/discord-presence.ts @@ -1,3 +1,4 @@ +import type { DiscordPresenceStylePreset } from '../../types/integrations'; import type { ResolvedConfig } from '../../types'; export interface DiscordPresenceSnapshot { @@ -33,15 +34,58 @@ type DiscordClient = { type TimeoutLike = ReturnType; -const DISCORD_PRESENCE_STYLE = { - fallbackDetails: 'Mining and crafting (Anki cards)', - largeImageKey: 'subminer-logo', - largeImageText: 'SubMiner', - smallImageKey: 'study', - smallImageText: 'Sentence Mining', - buttonLabel: '', - buttonUrl: '', -} as const; +interface PresenceStyleDefinition { + fallbackDetails: string; + largeImageKey: string; + largeImageText: string; + smallImageKey: string; + smallImageText: string; + buttonLabel: string; + buttonUrl: string; +} + +const PRESENCE_STYLES: Record = { + default: { + fallbackDetails: 'Sentence Mining', + largeImageKey: 'subminer-logo', + largeImageText: 'SubMiner', + smallImageKey: 'study', + smallImageText: '日本語学習中', + buttonLabel: '', + buttonUrl: '', + }, + meme: { + fallbackDetails: 'Mining and crafting (Anki cards)', + largeImageKey: 'subminer-logo', + largeImageText: 'SubMiner', + smallImageKey: 'study', + smallImageText: 'Sentence Mining', + buttonLabel: '', + buttonUrl: '', + }, + japanese: { + fallbackDetails: '文の採掘中', + largeImageKey: 'subminer-logo', + largeImageText: 'SubMiner', + smallImageKey: 'study', + smallImageText: 'イマージョン学習', + buttonLabel: '', + buttonUrl: '', + }, + minimal: { + fallbackDetails: 'SubMiner', + largeImageKey: 'subminer-logo', + largeImageText: 'SubMiner', + smallImageKey: '', + smallImageText: '', + buttonLabel: '', + buttonUrl: '', + }, +}; + +function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition { + return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default; +} function trimField(value: string, maxLength = 128): string { if (value.length <= maxLength) return value; @@ -79,15 +123,16 @@ function formatClock(totalSeconds: number | null | undefined): string { } export function buildDiscordPresenceActivity( - _config: DiscordPresenceConfig, + config: DiscordPresenceConfig, snapshot: DiscordPresenceSnapshot, ): DiscordActivityPayload { + const style = resolvePresenceStyle(config.presenceStyle); const status = buildStatus(snapshot); const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media'); const details = snapshot.connected && snapshot.mediaPath ? trimField(title) - : DISCORD_PRESENCE_STYLE.fallbackDetails; + : style.fallbackDetails; const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`; const state = snapshot.connected && snapshot.mediaPath @@ -100,26 +145,26 @@ export function buildDiscordPresenceActivity( startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000), }; - if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) { - activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim(); + if (style.largeImageKey.trim().length > 0) { + activity.largeImageKey = style.largeImageKey.trim(); } - if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) { - activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim()); + if (style.largeImageText.trim().length > 0) { + activity.largeImageText = trimField(style.largeImageText.trim()); } - if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) { - activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim(); + if (style.smallImageKey.trim().length > 0) { + activity.smallImageKey = style.smallImageKey.trim(); } - if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) { - activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim()); + if (style.smallImageText.trim().length > 0) { + activity.smallImageText = trimField(style.smallImageText.trim()); } if ( - DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 && - /^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim()) + style.buttonLabel.trim().length > 0 && + /^https?:\/\//.test(style.buttonUrl.trim()) ) { activity.buttons = [ { - label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32), - url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(), + label: trimField(style.buttonLabel.trim(), 32), + url: style.buttonUrl.trim(), }, ]; } diff --git a/src/types/config.ts b/src/types/config.ts index 527bff5..fd44f4e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -273,6 +273,7 @@ export interface ResolvedConfig { }; discordPresence: { enabled: boolean; + presenceStyle: import('./integrations').DiscordPresenceStylePreset; updateIntervalMs: number; debounceMs: number; }; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index fab1d2c..37cfe31 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -101,8 +101,11 @@ export interface JellyfinConfig { transcodeVideoCodec?: string; } +export type DiscordPresenceStylePreset = 'default' | 'meme' | 'japanese' | 'minimal'; + export interface DiscordPresenceConfig { enabled?: boolean; + presenceStyle?: DiscordPresenceStylePreset; updateIntervalMs?: number; debounceMs?: number; }