mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 06:12:06 -07:00
feat(discord): add configurable presence style presets
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.
This commit is contained in:
@@ -498,6 +498,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"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.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|||||||
@@ -1197,6 +1197,7 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
|
|||||||
{
|
{
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"presenceStyle": "default",
|
||||||
"updateIntervalMs": 3000,
|
"updateIntervalMs": 3000,
|
||||||
"debounceMs": 750
|
"debounceMs": 750
|
||||||
}
|
}
|
||||||
@@ -1204,23 +1205,30 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------ | --------------- | ---------------------------------------------------------- |
|
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
|
| `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 |
|
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
|
||||||
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
|
||||||
|
|
||||||
Setup steps:
|
Setup steps:
|
||||||
|
|
||||||
1. Set `discordPresence.enabled` to `true`.
|
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)
|
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.
|
||||||
- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`)
|
|
||||||
- Large image key/text: `subminer-logo` / `SubMiner`
|
| Preset | Idle details | Small image text | Vibe |
|
||||||
- Small image key/text: `study` / `Sentence Mining`
|
| ------------ | ----------------------------------- | ------------------ | --------------------------------------- |
|
||||||
- No activity button by default
|
| **`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:
|
Troubleshooting:
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
"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.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
discordPresence: {
|
discordPresence: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
presenceStyle: 'default' as const,
|
||||||
updateIntervalMs: 3_000,
|
updateIntervalMs: 3_000,
|
||||||
debounceMs: 750,
|
debounceMs: 750,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -323,6 +323,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.discordPresence.enabled,
|
defaultValue: defaultConfig.discordPresence.enabled,
|
||||||
description: 'Enable optional Discord Rich Presence updates.',
|
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',
|
path: 'discordPresence.updateIntervalMs',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
|
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
presenceStyle: 'default' as const,
|
||||||
updateIntervalMs: 10_000,
|
updateIntervalMs: 10_000,
|
||||||
debounceMs: 200,
|
debounceMs: 200,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -27,24 +28,67 @@ const baseSnapshot: DiscordPresenceSnapshot = {
|
|||||||
sessionStartedAtMs: BASE_SESSION_STARTED_AT_MS,
|
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);
|
const payload = buildDiscordPresenceActivity(baseConfig, baseSnapshot);
|
||||||
assert.equal(payload.details, 'Sousou no Frieren E01');
|
assert.equal(payload.details, 'Sousou no Frieren E01');
|
||||||
assert.equal(payload.state, 'Playing 01:35 / 24:10');
|
assert.equal(payload.state, 'Playing 01:35 / 24:10');
|
||||||
assert.equal(payload.largeImageKey, 'subminer-logo');
|
assert.equal(payload.largeImageKey, 'subminer-logo');
|
||||||
assert.equal(payload.smallImageKey, 'study');
|
assert.equal(payload.smallImageKey, 'study');
|
||||||
|
assert.equal(payload.smallImageText, '日本語学習中');
|
||||||
assert.equal(payload.buttons, undefined);
|
assert.equal(payload.buttons, undefined);
|
||||||
assert.equal(payload.startTimestamp, Math.floor(BASE_SESSION_STARTED_AT_MS / 1000));
|
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, {
|
const payload = buildDiscordPresenceActivity(baseConfig, {
|
||||||
...baseSnapshot,
|
...baseSnapshot,
|
||||||
connected: false,
|
connected: false,
|
||||||
mediaPath: null,
|
mediaPath: null,
|
||||||
});
|
});
|
||||||
assert.equal(payload.state, 'Idle');
|
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.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 () => {
|
test('service deduplicates identical updates and sends changed timeline', async () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DiscordPresenceStylePreset } from '../../types/integrations';
|
||||||
import type { ResolvedConfig } from '../../types';
|
import type { ResolvedConfig } from '../../types';
|
||||||
|
|
||||||
export interface DiscordPresenceSnapshot {
|
export interface DiscordPresenceSnapshot {
|
||||||
@@ -33,7 +34,27 @@ type DiscordClient = {
|
|||||||
|
|
||||||
type TimeoutLike = ReturnType<typeof setTimeout>;
|
type TimeoutLike = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const DISCORD_PRESENCE_STYLE = {
|
interface PresenceStyleDefinition {
|
||||||
|
fallbackDetails: string;
|
||||||
|
largeImageKey: string;
|
||||||
|
largeImageText: string;
|
||||||
|
smallImageKey: string;
|
||||||
|
smallImageText: string;
|
||||||
|
buttonLabel: string;
|
||||||
|
buttonUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinition> = {
|
||||||
|
default: {
|
||||||
|
fallbackDetails: 'Sentence Mining',
|
||||||
|
largeImageKey: 'subminer-logo',
|
||||||
|
largeImageText: 'SubMiner',
|
||||||
|
smallImageKey: 'study',
|
||||||
|
smallImageText: '日本語学習中',
|
||||||
|
buttonLabel: '',
|
||||||
|
buttonUrl: '',
|
||||||
|
},
|
||||||
|
meme: {
|
||||||
fallbackDetails: 'Mining and crafting (Anki cards)',
|
fallbackDetails: 'Mining and crafting (Anki cards)',
|
||||||
largeImageKey: 'subminer-logo',
|
largeImageKey: 'subminer-logo',
|
||||||
largeImageText: 'SubMiner',
|
largeImageText: 'SubMiner',
|
||||||
@@ -41,7 +62,30 @@ const DISCORD_PRESENCE_STYLE = {
|
|||||||
smallImageText: 'Sentence Mining',
|
smallImageText: 'Sentence Mining',
|
||||||
buttonLabel: '',
|
buttonLabel: '',
|
||||||
buttonUrl: '',
|
buttonUrl: '',
|
||||||
} as const;
|
},
|
||||||
|
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 {
|
function trimField(value: string, maxLength = 128): string {
|
||||||
if (value.length <= maxLength) return value;
|
if (value.length <= maxLength) return value;
|
||||||
@@ -79,15 +123,16 @@ function formatClock(totalSeconds: number | null | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildDiscordPresenceActivity(
|
export function buildDiscordPresenceActivity(
|
||||||
_config: DiscordPresenceConfig,
|
config: DiscordPresenceConfig,
|
||||||
snapshot: DiscordPresenceSnapshot,
|
snapshot: DiscordPresenceSnapshot,
|
||||||
): DiscordActivityPayload {
|
): DiscordActivityPayload {
|
||||||
|
const style = resolvePresenceStyle(config.presenceStyle);
|
||||||
const status = buildStatus(snapshot);
|
const status = buildStatus(snapshot);
|
||||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||||
const details =
|
const details =
|
||||||
snapshot.connected && snapshot.mediaPath
|
snapshot.connected && snapshot.mediaPath
|
||||||
? trimField(title)
|
? trimField(title)
|
||||||
: DISCORD_PRESENCE_STYLE.fallbackDetails;
|
: style.fallbackDetails;
|
||||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||||
const state =
|
const state =
|
||||||
snapshot.connected && snapshot.mediaPath
|
snapshot.connected && snapshot.mediaPath
|
||||||
@@ -100,26 +145,26 @@ export function buildDiscordPresenceActivity(
|
|||||||
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
|
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) {
|
if (style.largeImageKey.trim().length > 0) {
|
||||||
activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim();
|
activity.largeImageKey = style.largeImageKey.trim();
|
||||||
}
|
}
|
||||||
if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) {
|
if (style.largeImageText.trim().length > 0) {
|
||||||
activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim());
|
activity.largeImageText = trimField(style.largeImageText.trim());
|
||||||
}
|
}
|
||||||
if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) {
|
if (style.smallImageKey.trim().length > 0) {
|
||||||
activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim();
|
activity.smallImageKey = style.smallImageKey.trim();
|
||||||
}
|
}
|
||||||
if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) {
|
if (style.smallImageText.trim().length > 0) {
|
||||||
activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim());
|
activity.smallImageText = trimField(style.smallImageText.trim());
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 &&
|
style.buttonLabel.trim().length > 0 &&
|
||||||
/^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim())
|
/^https?:\/\//.test(style.buttonUrl.trim())
|
||||||
) {
|
) {
|
||||||
activity.buttons = [
|
activity.buttons = [
|
||||||
{
|
{
|
||||||
label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32),
|
label: trimField(style.buttonLabel.trim(), 32),
|
||||||
url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(),
|
url: style.buttonUrl.trim(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export interface ResolvedConfig {
|
|||||||
};
|
};
|
||||||
discordPresence: {
|
discordPresence: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
presenceStyle: import('./integrations').DiscordPresenceStylePreset;
|
||||||
updateIntervalMs: number;
|
updateIntervalMs: number;
|
||||||
debounceMs: number;
|
debounceMs: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,8 +101,11 @@ export interface JellyfinConfig {
|
|||||||
transcodeVideoCodec?: string;
|
transcodeVideoCodec?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DiscordPresenceStylePreset = 'default' | 'meme' | 'japanese' | 'minimal';
|
||||||
|
|
||||||
export interface DiscordPresenceConfig {
|
export interface DiscordPresenceConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
presenceStyle?: DiscordPresenceStylePreset;
|
||||||
updateIntervalMs?: number;
|
updateIntervalMs?: number;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user