From 81d24665a13dc89313c402d6be344342dddce2b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Nov 2025 08:37:34 +0000 Subject: [PATCH 1/3] feat: add standalone Discord Rich Presence controller app Add a new Electron-based app for custom Discord Rich Presence control. This follows the same architecture as the existing discord plugin in ytb-music but provides a standalone UI for manual status configuration. Features: - Full control over all Rich Presence fields (details, state, images) - Support for all activity types (Playing, Listening, Watching, etc.) - Configurable timestamps (elapsed/remaining) - Up to 2 custom buttons with URLs - Auto-reconnect functionality - Clean dark theme UI matching Discord's style --- rp-app/.gitignore | 4 + rp-app/README.md | 88 +++++++++ rp-app/package.json | 18 ++ rp-app/src/main/constants.js | 8 + rp-app/src/main/discord-service.js | 292 +++++++++++++++++++++++++++++ rp-app/src/main/index.js | 101 ++++++++++ rp-app/src/main/timer-manager.js | 43 +++++ rp-app/src/preload.js | 13 ++ rp-app/src/renderer/index.html | 150 +++++++++++++++ rp-app/src/renderer/renderer.js | 167 +++++++++++++++++ rp-app/src/renderer/styles.css | 271 ++++++++++++++++++++++++++ 11 files changed, 1155 insertions(+) create mode 100644 rp-app/.gitignore create mode 100644 rp-app/README.md create mode 100644 rp-app/package.json create mode 100644 rp-app/src/main/constants.js create mode 100644 rp-app/src/main/discord-service.js create mode 100644 rp-app/src/main/index.js create mode 100644 rp-app/src/main/timer-manager.js create mode 100644 rp-app/src/preload.js create mode 100644 rp-app/src/renderer/index.html create mode 100644 rp-app/src/renderer/renderer.js create mode 100644 rp-app/src/renderer/styles.css diff --git a/rp-app/.gitignore b/rp-app/.gitignore new file mode 100644 index 0000000000..94510244f1 --- /dev/null +++ b/rp-app/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +*.log diff --git a/rp-app/README.md b/rp-app/README.md new file mode 100644 index 0000000000..daf64a9fbc --- /dev/null +++ b/rp-app/README.md @@ -0,0 +1,88 @@ +# Discord Rich Presence Controller + +A simple Electron app to set custom Discord Rich Presence status. + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Run the app: + ```bash + npm start + ``` + +## Usage + +1. **Get a Discord Application ID**: + - Go to [Discord Developer Portal](https://discord.com/developers/applications) + - Create a new application (or use an existing one) + - Copy the "Application ID" from the General Information page + +2. **Set up Rich Presence Assets** (optional): + - In your Discord application, go to "Rich Presence" → "Art Assets" + - Upload images you want to use for large/small icons + - Note the asset names for use in the app + +3. **Connect and Customize**: + - Paste your Application ID in the app + - Click "Connect" + - Fill in the fields you want to display + - Click "Update Presence" + +## Fields + +| Field | Description | Max Length | +|-------|-------------|------------| +| Details | First line of text | 128 chars | +| State | Second line of text | 128 chars | +| Large Image | Asset name or image URL | - | +| Large Image Text | Hover text for large image | 128 chars | +| Small Image | Asset name or image URL | - | +| Small Image Text | Hover text for small image | 128 chars | +| Button 1/2 Label | Button text | 32 chars | +| Button 1/2 URL | Button link | Valid URL | + +## Activity Types + +- **Playing** - "Playing {details}" +- **Streaming** - "Streaming {details}" +- **Listening** - "Listening to {details}" +- **Watching** - "Watching {details}" +- **Competing** - "Competing in {details}" + +## Timestamps + +- **Elapsed**: Shows "XX:XX elapsed" +- **Remaining**: Shows "XX:XX left" (requires duration) + +## Notes + +- Discord must be running for Rich Presence to work +- Buttons are only visible to other users (not yourself) +- Image URLs must be publicly accessible HTTPS URLs +- Changes may take a few seconds to appear in Discord + +## Architecture + +This app follows the same Discord RPC implementation pattern as [YouTube Music Desktop](https://github.com/pear-devs/pear-desktop): + +``` +src/ +├── main/ +│ ├── index.js # Electron main process +│ ├── discord-service.js # Discord RPC service +│ ├── timer-manager.js # Timer management +│ └── constants.js # Constants +├── renderer/ +│ ├── index.html # UI +│ ├── styles.css # Styles +│ └── renderer.js # UI logic +└── preload.js # IPC bridge +``` + +## License + +MIT diff --git a/rp-app/package.json b/rp-app/package.json new file mode 100644 index 0000000000..e0010b8ffc --- /dev/null +++ b/rp-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "rp", + "version": "1.0.0", + "description": "Custom Discord Rich Presence Controller", + "main": "src/main/index.js", + "scripts": { + "start": "electron .", + "dev": "electron ." + }, + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "^33.0.0" + }, + "dependencies": { + "@xhayper/discord-rpc": "^1.3.0" + } +} diff --git a/rp-app/src/main/constants.js b/rp-app/src/main/constants.js new file mode 100644 index 0000000000..646024fbdf --- /dev/null +++ b/rp-app/src/main/constants.js @@ -0,0 +1,8 @@ +/** + * Enum for keys used in TimerManager. + */ +const TimerKey = { + DiscordConnectRetry: 'discordConnectRetry', +}; + +module.exports = { TimerKey }; diff --git a/rp-app/src/main/discord-service.js b/rp-app/src/main/discord-service.js new file mode 100644 index 0000000000..39e1abdad0 --- /dev/null +++ b/rp-app/src/main/discord-service.js @@ -0,0 +1,292 @@ +const { Client: DiscordClient } = require('@xhayper/discord-rpc'); +const { TimerManager } = require('./timer-manager'); +const { TimerKey } = require('./constants'); + +/** + * Discord Rich Presence Service + * Handles connection and activity updates to Discord + */ +class DiscordService { + constructor() { + this.rpc = null; + this.clientId = null; + this.ready = false; + this.autoReconnect = true; + this.timerManager = new TimerManager(); + this.currentActivity = null; + this.onStatusChange = null; + } + + /** + * Initialize the Discord RPC client with a client ID + * @param {string} clientId - Discord Application ID + */ + init(clientId) { + if (this.rpc) { + this.disconnect(); + } + + this.clientId = clientId; + this.rpc = new DiscordClient({ clientId }); + + this.rpc.on('connected', () => { + console.log('[Discord] Connected'); + this._notifyStatus('connected'); + }); + + this.rpc.on('ready', () => { + this.ready = true; + console.log('[Discord] Ready'); + this._notifyStatus('ready'); + + // If we have a pending activity, set it now + if (this.currentActivity) { + this.updateActivity(this.currentActivity); + } + }); + + this.rpc.on('disconnected', () => { + this.ready = false; + console.log('[Discord] Disconnected'); + this._notifyStatus('disconnected'); + + if (this.autoReconnect) { + this._connectRecursive(); + } + }); + } + + /** + * Notify status change to callback + * @param {string} status + */ + _notifyStatus(status) { + if (this.onStatusChange) { + this.onStatusChange(status); + } + } + + /** + * Attempts to connect to Discord RPC after a delay + */ + _connectWithRetry() { + return new Promise((resolve, reject) => { + this.timerManager.set( + TimerKey.DiscordConnectRetry, + () => { + if (!this.autoReconnect || (this.rpc && this.rpc.isConnected)) { + this.timerManager.clear(TimerKey.DiscordConnectRetry); + if (this.rpc && this.rpc.isConnected) resolve(); + else reject(new Error('Auto-reconnect disabled or already connected.')); + return; + } + + this.rpc + .login() + .then(() => { + this.timerManager.clear(TimerKey.DiscordConnectRetry); + resolve(); + }) + .catch(() => { + this._connectRecursive(); + }); + }, + 5000 + ); + }); + } + + /** + * Recursively attempts to connect + */ + _connectRecursive() { + if (!this.autoReconnect || (this.rpc && this.rpc.isConnected)) { + this.timerManager.clear(TimerKey.DiscordConnectRetry); + return; + } + this._connectWithRetry(); + } + + /** + * Connect to Discord + */ + connect() { + if (!this.rpc) { + throw new Error('Discord client not initialized. Call init() first.'); + } + + if (this.rpc.isConnected) { + console.log('[Discord] Already connected'); + return; + } + + this.autoReconnect = true; + this.timerManager.clear(TimerKey.DiscordConnectRetry); + + this.rpc.login().catch((err) => { + console.error('[Discord] Connection failed:', err.message); + this._notifyStatus('error'); + + if (this.autoReconnect) { + this._connectRecursive(); + } + }); + } + + /** + * Disconnect from Discord + */ + disconnect() { + this.autoReconnect = false; + this.timerManager.clear(TimerKey.DiscordConnectRetry); + + if (this.rpc && this.rpc.isConnected) { + try { + this.rpc.destroy(); + } catch (e) { + // Ignored + } + } + + this.ready = false; + this.currentActivity = null; + this._notifyStatus('disconnected'); + } + + /** + * Update Discord Rich Presence activity + * @param {Object} activity - Activity object + */ + updateActivity(activity) { + this.currentActivity = activity; + + if (!this.rpc || !this.ready) { + console.log('[Discord] Not ready, activity cached for later'); + return; + } + + // Build the activity payload + const payload = this._buildActivityPayload(activity); + + this.rpc.user + ?.setActivity(payload) + .then(() => { + console.log('[Discord] Activity updated'); + this._notifyStatus('activity_updated'); + }) + .catch((err) => { + console.error('[Discord] Failed to set activity:', err.message); + }); + } + + /** + * Build Discord activity payload from user input + * @param {Object} activity + */ + _buildActivityPayload(activity) { + const payload = {}; + + // Activity type (Playing, Listening, Watching, Competing) + if (activity.type !== undefined) { + payload.type = activity.type; + } + + // Details (first line) + if (activity.details && activity.details.trim()) { + payload.details = this._truncate(activity.details, 128); + } + + // State (second line) + if (activity.state && activity.state.trim()) { + payload.state = this._truncate(activity.state, 128); + } + + // Large image + if (activity.largeImageKey && activity.largeImageKey.trim()) { + payload.largeImageKey = activity.largeImageKey; + } + if (activity.largeImageText && activity.largeImageText.trim()) { + payload.largeImageText = this._truncate(activity.largeImageText, 128); + } + + // Small image + if (activity.smallImageKey && activity.smallImageKey.trim()) { + payload.smallImageKey = activity.smallImageKey; + } + if (activity.smallImageText && activity.smallImageText.trim()) { + payload.smallImageText = this._truncate(activity.smallImageText, 128); + } + + // Timestamps + if (activity.useTimestamp) { + if (activity.timestampMode === 'elapsed') { + payload.startTimestamp = Math.floor(Date.now() / 1000); + } else if (activity.timestampMode === 'remaining' && activity.endTime) { + payload.startTimestamp = Math.floor(Date.now() / 1000); + payload.endTimestamp = Math.floor((Date.now() + activity.endTime * 1000) / 1000); + } + } + + // Buttons (max 2) + const buttons = []; + if (activity.button1Label && activity.button1Url) { + buttons.push({ + label: this._truncate(activity.button1Label, 32), + url: activity.button1Url, + }); + } + if (activity.button2Label && activity.button2Url) { + buttons.push({ + label: this._truncate(activity.button2Label, 32), + url: activity.button2Url, + }); + } + if (buttons.length > 0) { + payload.buttons = buttons; + } + + return payload; + } + + /** + * Truncate string to max length + */ + _truncate(str, maxLength) { + if (!str) return str; + if (str.length <= maxLength) return str; + return str.substring(0, maxLength - 3) + '...'; + } + + /** + * Clear Discord activity + */ + clearActivity() { + this.currentActivity = null; + + if (this.rpc && this.ready) { + this.rpc.user?.clearActivity(); + console.log('[Discord] Activity cleared'); + this._notifyStatus('activity_cleared'); + } + } + + /** + * Check if connected + */ + isConnected() { + return this.rpc && this.rpc.isConnected && this.ready; + } + + /** + * Cleanup + */ + cleanup() { + this.disconnect(); + this.timerManager.clearAll(); + } +} + +// Singleton instance +const discordService = new DiscordService(); + +module.exports = { discordService, DiscordService }; diff --git a/rp-app/src/main/index.js b/rp-app/src/main/index.js new file mode 100644 index 0000000000..677335ac10 --- /dev/null +++ b/rp-app/src/main/index.js @@ -0,0 +1,101 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const { discordService } = require('./discord-service'); + +let mainWindow = null; + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 500, + height: 750, + resizable: true, + webPreferences: { + preload: path.join(__dirname, '..', 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + backgroundColor: '#1a1a2e', + title: 'Discord Rich Presence', + }); + + mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); + + // Set up status change callback + discordService.onStatusChange = (status) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('discord:status', status); + } + }; +} + +// IPC Handlers +ipcMain.handle('discord:init', (_, clientId) => { + try { + discordService.init(clientId); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:connect', () => { + try { + discordService.connect(); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:disconnect', () => { + try { + discordService.disconnect(); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:updateActivity', (_, activity) => { + try { + discordService.updateActivity(activity); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:clearActivity', () => { + try { + discordService.clearActivity(); + return { success: true }; + } catch (err) { + return { success: false, error: err.message }; + } +}); + +ipcMain.handle('discord:isConnected', () => { + return discordService.isConnected(); +}); + +// App lifecycle +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + discordService.cleanup(); + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('before-quit', () => { + discordService.cleanup(); +}); diff --git a/rp-app/src/main/timer-manager.js b/rp-app/src/main/timer-manager.js new file mode 100644 index 0000000000..58ffb156a8 --- /dev/null +++ b/rp-app/src/main/timer-manager.js @@ -0,0 +1,43 @@ +/** + * Manages NodeJS Timers, ensuring only one timer exists per key. + */ +class TimerManager { + constructor() { + this.timers = new Map(); + } + + /** + * Sets a timer for a given key, clearing any existing timer with the same key. + * @param {string} key - The unique key for the timer. + * @param {Function} fn - The function to execute after the delay. + * @param {number} delay - The delay in milliseconds. + */ + set(key, fn, delay) { + this.clear(key); + this.timers.set(key, setTimeout(fn, delay)); + } + + /** + * Clears the timer associated with the given key. + * @param {string} key - The key of the timer to clear. + */ + clear(key) { + const timer = this.timers.get(key); + if (timer) { + clearTimeout(timer); + this.timers.delete(key); + } + } + + /** + * Clears all managed timers. + */ + clearAll() { + for (const timer of this.timers.values()) { + clearTimeout(timer); + } + this.timers.clear(); + } +} + +module.exports = { TimerManager }; diff --git a/rp-app/src/preload.js b/rp-app/src/preload.js new file mode 100644 index 0000000000..931c17677c --- /dev/null +++ b/rp-app/src/preload.js @@ -0,0 +1,13 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('discord', { + init: (clientId) => ipcRenderer.invoke('discord:init', clientId), + connect: () => ipcRenderer.invoke('discord:connect'), + disconnect: () => ipcRenderer.invoke('discord:disconnect'), + updateActivity: (activity) => ipcRenderer.invoke('discord:updateActivity', activity), + clearActivity: () => ipcRenderer.invoke('discord:clearActivity'), + isConnected: () => ipcRenderer.invoke('discord:isConnected'), + onStatus: (callback) => { + ipcRenderer.on('discord:status', (_, status) => callback(status)); + }, +}); diff --git a/rp-app/src/renderer/index.html b/rp-app/src/renderer/index.html new file mode 100644 index 0000000000..c297ce69ef --- /dev/null +++ b/rp-app/src/renderer/index.html @@ -0,0 +1,150 @@ + + + + + + + Discord Rich Presence + + + +
+
+

Discord Rich Presence

+
+ + Disconnected +
+
+ +
+ +
+

Discord Application

+
+ + + Get it from Discord Developer Portal +
+
+ + +
+
+ + +
+

Activity Type

+
+ +
+
+ + +
+

Text

+
+ + +
+
+ + +
+
+ + +
+

Images

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+

Timestamp

+
+ +
+ + +
+ + +
+

Buttons

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+ + + + diff --git a/rp-app/src/renderer/renderer.js b/rp-app/src/renderer/renderer.js new file mode 100644 index 0000000000..b9122fc75d --- /dev/null +++ b/rp-app/src/renderer/renderer.js @@ -0,0 +1,167 @@ +// DOM Elements +const clientIdInput = document.getElementById('clientId'); +const connectBtn = document.getElementById('connectBtn'); +const disconnectBtn = document.getElementById('disconnectBtn'); +const updateBtn = document.getElementById('updateBtn'); +const clearBtn = document.getElementById('clearBtn'); +const statusDot = document.querySelector('.status-dot'); +const statusText = document.querySelector('.status-text'); + +// Activity fields +const activityType = document.getElementById('activityType'); +const details = document.getElementById('details'); +const state = document.getElementById('state'); +const largeImageKey = document.getElementById('largeImageKey'); +const largeImageText = document.getElementById('largeImageText'); +const smallImageKey = document.getElementById('smallImageKey'); +const smallImageText = document.getElementById('smallImageText'); +const useTimestamp = document.getElementById('useTimestamp'); +const timestampOptions = document.getElementById('timestampOptions'); +const endTimeGroup = document.getElementById('endTimeGroup'); +const endTime = document.getElementById('endTime'); +const button1Label = document.getElementById('button1Label'); +const button1Url = document.getElementById('button1Url'); +const button2Label = document.getElementById('button2Label'); +const button2Url = document.getElementById('button2Url'); + +let isConnected = false; + +// Status update handler +function updateStatus(status) { + statusDot.className = 'status-dot'; + + switch (status) { + case 'connected': + case 'ready': + statusDot.classList.add('connected'); + statusText.textContent = 'Connected'; + isConnected = true; + break; + case 'disconnected': + statusDot.classList.add('disconnected'); + statusText.textContent = 'Disconnected'; + isConnected = false; + break; + case 'error': + statusDot.classList.add('connecting'); + statusText.textContent = 'Reconnecting...'; + break; + case 'activity_updated': + statusText.textContent = 'Connected - Active'; + break; + case 'activity_cleared': + statusText.textContent = 'Connected'; + break; + } + + updateButtonStates(); +} + +// Update button states based on connection +function updateButtonStates() { + connectBtn.disabled = isConnected || !clientIdInput.value.trim(); + disconnectBtn.disabled = !isConnected; + updateBtn.disabled = !isConnected; + clearBtn.disabled = !isConnected; +} + +// Connect to Discord +async function connect() { + const clientId = clientIdInput.value.trim(); + if (!clientId) { + alert('Please enter a Discord Application ID'); + return; + } + + statusDot.className = 'status-dot connecting'; + statusText.textContent = 'Connecting...'; + connectBtn.disabled = true; + + const initResult = await window.discord.init(clientId); + if (!initResult.success) { + alert('Failed to initialize: ' + initResult.error); + updateStatus('disconnected'); + return; + } + + const connectResult = await window.discord.connect(); + if (!connectResult.success) { + alert('Failed to connect: ' + connectResult.error); + updateStatus('disconnected'); + } +} + +// Disconnect from Discord +async function disconnect() { + await window.discord.disconnect(); + updateStatus('disconnected'); +} + +// Get activity data from form +function getActivityData() { + const timestampMode = document.querySelector('input[name="timestampMode"]:checked')?.value; + + return { + type: parseInt(activityType.value), + details: details.value, + state: state.value, + largeImageKey: largeImageKey.value, + largeImageText: largeImageText.value, + smallImageKey: smallImageKey.value, + smallImageText: smallImageText.value, + useTimestamp: useTimestamp.checked, + timestampMode: timestampMode, + endTime: parseInt(endTime.value) || 0, + button1Label: button1Label.value, + button1Url: button1Url.value, + button2Label: button2Label.value, + button2Url: button2Url.value, + }; +} + +// Update presence +async function updatePresence() { + const activity = getActivityData(); + const result = await window.discord.updateActivity(activity); + if (!result.success) { + alert('Failed to update presence: ' + result.error); + } +} + +// Clear presence +async function clearPresence() { + const result = await window.discord.clearActivity(); + if (!result.success) { + alert('Failed to clear presence: ' + result.error); + } +} + +// Toggle timestamp options visibility +function toggleTimestampOptions() { + timestampOptions.style.display = useTimestamp.checked ? 'block' : 'none'; + updateEndTimeVisibility(); +} + +// Toggle end time visibility based on timestamp mode +function updateEndTimeVisibility() { + const timestampMode = document.querySelector('input[name="timestampMode"]:checked')?.value; + endTimeGroup.style.display = (useTimestamp.checked && timestampMode === 'remaining') ? 'block' : 'none'; +} + +// Event listeners +connectBtn.addEventListener('click', connect); +disconnectBtn.addEventListener('click', disconnect); +updateBtn.addEventListener('click', updatePresence); +clearBtn.addEventListener('click', clearPresence); +clientIdInput.addEventListener('input', updateButtonStates); +useTimestamp.addEventListener('change', toggleTimestampOptions); + +document.querySelectorAll('input[name="timestampMode"]').forEach(radio => { + radio.addEventListener('change', updateEndTimeVisibility); +}); + +// Listen for status updates from main process +window.discord.onStatus(updateStatus); + +// Initial button state +updateButtonStates(); diff --git a/rp-app/src/renderer/styles.css b/rp-app/src/renderer/styles.css new file mode 100644 index 0000000000..a58ba42315 --- /dev/null +++ b/rp-app/src/renderer/styles.css @@ -0,0 +1,271 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-tertiary: #0f3460; + --accent: #5865f2; + --accent-hover: #4752c4; + --success: #3ba55c; + --success-hover: #2d7d46; + --warning: #faa61a; + --warning-hover: #d4900f; + --danger: #ed4245; + --text-primary: #ffffff; + --text-secondary: #b9bbbe; + --text-muted: #72767d; + --border: #40444b; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; +} + +.container { + max-width: 100%; + padding: 20px; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +h1 { + font-size: 1.5rem; + font-weight: 600; +} + +h2 { + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.status { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bg-secondary); + border-radius: 16px; + font-size: 0.875rem; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.status-dot.disconnected { + background-color: var(--text-muted); +} + +.status-dot.connecting { + background-color: var(--warning); + animation: pulse 1s infinite; +} + +.status-dot.connected { + background-color: var(--success); +} + +.status-dot.error { + background-color: var(--danger); +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.section { + background: var(--bg-secondary); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.form-group { + margin-bottom: 12px; +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +label { + display: block; + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 6px; +} + +input[type="text"], +input[type="number"], +select { + width: 100%; + padding: 10px 12px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.875rem; + transition: border-color 0.2s; +} + +input[type="text"]:focus, +input[type="number"]:focus, +select:focus { + outline: none; + border-color: var(--accent); +} + +input::placeholder { + color: var(--text-muted); +} + +select { + cursor: pointer; +} + +small { + display: block; + margin-top: 6px; + font-size: 0.75rem; + color: var(--text-muted); +} + +small a { + color: var(--accent); + text-decoration: none; +} + +small a:hover { + text-decoration: underline; +} + +.button-group { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, opacity 0.2s; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--accent); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--accent-hover); +} + +.btn-secondary { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-secondary:hover:not(:disabled) { + background-color: var(--border); +} + +.btn-success { + background-color: var(--success); + color: white; + flex: 1; +} + +.btn-success:hover:not(:disabled) { + background-color: var(--success-hover); +} + +.btn-warning { + background-color: var(--warning); + color: black; +} + +.btn-warning:hover:not(:disabled) { + background-color: var(--warning-hover); +} + +.actions { + display: flex; + gap: 12px; +} + +.checkbox-label, +.radio-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.875rem; + color: var(--text-primary); +} + +.checkbox-label input, +.radio-label input { + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +.radio-group { + display: flex; + gap: 20px; + margin-top: 8px; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} From 1bdfac588e029aa098aaf49e99d704da1db1b236 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Nov 2025 08:49:58 +0000 Subject: [PATCH 2/3] fix(rp-app): add statusDisplayType to control Discord status text Add the statusDisplayType field that controls what Discord shows in the status line (e.g., "Listening to X"). Options: - 0: App Name (from Developer Portal) - 1: State field (Line 2) - 2: Details field (Line 1) - default This matches how ytb-music handles the Discord status display. --- rp-app/src/main/discord-service.js | 6 ++++++ rp-app/src/renderer/index.html | 9 +++++++++ rp-app/src/renderer/renderer.js | 2 ++ 3 files changed, 17 insertions(+) diff --git a/rp-app/src/main/discord-service.js b/rp-app/src/main/discord-service.js index 39e1abdad0..8a7a714e33 100644 --- a/rp-app/src/main/discord-service.js +++ b/rp-app/src/main/discord-service.js @@ -191,6 +191,12 @@ class DiscordService { payload.type = activity.type; } + // Status display type - controls what shows in "Listening to X" / "Playing X" + // 0 = App Name, 1 = State field, 2 = Details field + if (activity.statusDisplayType !== undefined) { + payload.statusDisplayType = activity.statusDisplayType; + } + // Details (first line) if (activity.details && activity.details.trim()) { payload.details = this._truncate(activity.details, 128); diff --git a/rp-app/src/renderer/index.html b/rp-app/src/renderer/index.html index c297ce69ef..29f2417fc6 100644 --- a/rp-app/src/renderer/index.html +++ b/rp-app/src/renderer/index.html @@ -44,6 +44,15 @@

Activity Type

+
+ + + Controls what appears after "Playing" / "Listening to" in your status +
diff --git a/rp-app/src/renderer/renderer.js b/rp-app/src/renderer/renderer.js index b9122fc75d..18e9ab6426 100644 --- a/rp-app/src/renderer/renderer.js +++ b/rp-app/src/renderer/renderer.js @@ -9,6 +9,7 @@ const statusText = document.querySelector('.status-text'); // Activity fields const activityType = document.getElementById('activityType'); +const statusDisplayType = document.getElementById('statusDisplayType'); const details = document.getElementById('details'); const state = document.getElementById('state'); const largeImageKey = document.getElementById('largeImageKey'); @@ -103,6 +104,7 @@ function getActivityData() { return { type: parseInt(activityType.value), + statusDisplayType: parseInt(statusDisplayType.value), details: details.value, state: state.value, largeImageKey: largeImageKey.value, From def2523872c7d79f4febe853e6da3e0503747103 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Nov 2025 09:05:06 +0000 Subject: [PATCH 3/3] feat(rp-app): add detailsUrl, stateUrl and min 2-char padding - Add detailsUrl and stateUrl fields to make text lines clickable - Add minimum 2-character padding using Hangul filler (same as ytb-music) - Update UI with URL input fields for details and state - Discord rejects text fields shorter than 2 characters, padding prevents this --- rp-app/src/main/discord-service.js | 33 ++++++++++++++++++++++++------ rp-app/src/renderer/index.html | 25 ++++++++++++++++------ rp-app/src/renderer/renderer.js | 4 ++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/rp-app/src/main/discord-service.js b/rp-app/src/main/discord-service.js index 8a7a714e33..078e204efd 100644 --- a/rp-app/src/main/discord-service.js +++ b/rp-app/src/main/discord-service.js @@ -197,14 +197,22 @@ class DiscordService { payload.statusDisplayType = activity.statusDisplayType; } - // Details (first line) + // Details (first line) - min 2 chars required if (activity.details && activity.details.trim()) { - payload.details = this._truncate(activity.details, 128); + payload.details = this._padToMinLength(this._truncate(activity.details, 128)); + } + // Details URL (makes details clickable) + if (activity.detailsUrl && activity.detailsUrl.trim()) { + payload.detailsUrl = activity.detailsUrl; } - // State (second line) + // State (second line) - min 2 chars required if (activity.state && activity.state.trim()) { - payload.state = this._truncate(activity.state, 128); + payload.state = this._padToMinLength(this._truncate(activity.state, 128)); + } + // State URL (makes state clickable) + if (activity.stateUrl && activity.stateUrl.trim()) { + payload.stateUrl = activity.stateUrl; } // Large image @@ -212,7 +220,7 @@ class DiscordService { payload.largeImageKey = activity.largeImageKey; } if (activity.largeImageText && activity.largeImageText.trim()) { - payload.largeImageText = this._truncate(activity.largeImageText, 128); + payload.largeImageText = this._padToMinLength(this._truncate(activity.largeImageText, 128)); } // Small image @@ -220,7 +228,7 @@ class DiscordService { payload.smallImageKey = activity.smallImageKey; } if (activity.smallImageText && activity.smallImageText.trim()) { - payload.smallImageText = this._truncate(activity.smallImageText, 128); + payload.smallImageText = this._padToMinLength(this._truncate(activity.smallImageText, 128)); } // Timestamps @@ -263,6 +271,19 @@ class DiscordService { return str.substring(0, maxLength - 3) + '...'; } + /** + * Pad string to minimum length (Discord requires min 2 chars) + * Uses Unicode Hangul filler character (invisible) + */ + _padToMinLength(str, minLength = 2) { + if (!str) return str; + const FILLER = '\u3164'; // Hangul filler (invisible) + if (str.length > 0 && str.length < minLength) { + return str + FILLER.repeat(minLength - str.length); + } + return str; + } + /** * Clear Discord activity */ diff --git a/rp-app/src/renderer/index.html b/rp-app/src/renderer/index.html index 29f2417fc6..9f07dc50a7 100644 --- a/rp-app/src/renderer/index.html +++ b/rp-app/src/renderer/index.html @@ -58,14 +58,27 @@

Activity Type

Text

-
- - +
+
+ + +
+
+ + +
-
- - +
+
+ + +
+
+ + +
+ Text fields must be at least 2 characters. URLs make the text clickable.
diff --git a/rp-app/src/renderer/renderer.js b/rp-app/src/renderer/renderer.js index 18e9ab6426..0f0f48b72f 100644 --- a/rp-app/src/renderer/renderer.js +++ b/rp-app/src/renderer/renderer.js @@ -11,7 +11,9 @@ const statusText = document.querySelector('.status-text'); const activityType = document.getElementById('activityType'); const statusDisplayType = document.getElementById('statusDisplayType'); const details = document.getElementById('details'); +const detailsUrl = document.getElementById('detailsUrl'); const state = document.getElementById('state'); +const stateUrl = document.getElementById('stateUrl'); const largeImageKey = document.getElementById('largeImageKey'); const largeImageText = document.getElementById('largeImageText'); const smallImageKey = document.getElementById('smallImageKey'); @@ -106,7 +108,9 @@ function getActivityData() { type: parseInt(activityType.value), statusDisplayType: parseInt(statusDisplayType.value), details: details.value, + detailsUrl: detailsUrl.value, state: state.value, + stateUrl: stateUrl.value, largeImageKey: largeImageKey.value, largeImageText: largeImageText.value, smallImageKey: smallImageKey.value,