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; }