Add user-configurable colors for transcription display

- Add color settings (user_color, text_color, background_color) to config
- Add color picker buttons in Settings dialog with alpha support for backgrounds
- Update local web display to use configurable colors
- Send per-user colors with transcriptions to multi-user server
- Update Node.js server to apply per-user colors on display page
- Improve server landing page: replace tech details with display options reference
- Bump version to 1.3.2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 20:59:13 -08:00
parent ff067b3368
commit 89819f5d1b
7 changed files with 322 additions and 41 deletions

View File

@@ -19,7 +19,10 @@ class ServerSyncClient:
font_source: str = "None",
websafe_font: Optional[str] = None,
google_font: Optional[str] = None,
custom_font_file: Optional[str] = None):
custom_font_file: Optional[str] = None,
user_color: str = "#4CAF50",
text_color: str = "#FFFFFF",
background_color: str = "#000000B3"):
"""
Initialize server sync client.
@@ -33,6 +36,9 @@ class ServerSyncClient:
websafe_font: Web-safe font name (e.g., "Arial", "Times New Roman")
google_font: Google Font name (e.g., "Roboto", "Open Sans")
custom_font_file: Path to a custom font file for this speaker
user_color: User name color (hex format)
text_color: Text color (hex format)
background_color: Background color (hex format with optional alpha)
"""
self.url = url
self.room = room
@@ -43,6 +49,9 @@ class ServerSyncClient:
self.websafe_font = websafe_font
self.google_font = google_font
self.custom_font_file = custom_font_file
self.user_color = user_color
self.text_color = text_color
self.background_color = background_color
# Font info to send with transcriptions
self.font_family: Optional[str] = None
@@ -303,7 +312,11 @@ class ServerSyncClient:
'user_name': self.user_name,
'text': trans_data['text'],
'timestamp': trans_data['timestamp'],
'is_preview': trans_data.get('is_preview', False)
'is_preview': trans_data.get('is_preview', False),
# Always include user's color settings
'user_color': self.user_color,
'text_color': self.text_color,
'background_color': self.background_color
}
# Add font info if user has a custom font configured

View File

@@ -59,6 +59,10 @@ display:
font_size: 12
theme: "dark"
fade_after_seconds: 10 # Time before transcriptions fade out (0 = never fade)
# Color settings (used for both local display and server sync)
user_color: "#4CAF50" # User's name color (default green)
text_color: "#FFFFFF" # Text/font color (default white)
background_color: "#000000B3" # Background color with alpha (default semi-transparent black)
web_server:
port: 8080

View File

@@ -373,6 +373,11 @@ class MainWindow(QMainWindow):
websafe_font = self.config.get('display.websafe_font', 'Arial')
google_font = self.config.get('display.google_font', 'Roboto')
# Color settings
user_color = self.config.get('display.user_color', '#4CAF50')
text_color = self.config.get('display.text_color', '#FFFFFF')
background_color = self.config.get('display.background_color', '#000000B3')
# Try up to 5 ports if the default is in use
ports_to_try = [port] + [port + i for i in range(1, 5)]
server_started = False
@@ -390,7 +395,10 @@ class MainWindow(QMainWindow):
fonts_dir=fonts_dir,
font_source=font_source,
websafe_font=websafe_font,
google_font=google_font
google_font=google_font,
user_color=user_color,
text_color=text_color,
background_color=background_color
)
self.web_server_thread = WebServerThread(self.web_server)
self.web_server_thread.start()
@@ -643,6 +651,10 @@ class MainWindow(QMainWindow):
self.web_server.font_source = self.config.get('display.font_source', 'System Font')
self.web_server.websafe_font = self.config.get('display.websafe_font', 'Arial')
self.web_server.google_font = self.config.get('display.google_font', 'Roboto')
# Update color settings
self.web_server.user_color = self.config.get('display.user_color', '#4CAF50')
self.web_server.text_color = self.config.get('display.text_color', '#FFFFFF')
self.web_server.background_color = self.config.get('display.background_color', '#000000B3')
# Update sync link visibility based on server sync settings
self._update_sync_link()
@@ -728,6 +740,11 @@ class MainWindow(QMainWindow):
google_font = self.config.get('display.google_font', '')
custom_font_file = self.config.get('display.custom_font_file', '')
# Color settings
user_color = self.config.get('display.user_color', '#4CAF50')
text_color = self.config.get('display.text_color', '#FFFFFF')
background_color = self.config.get('display.background_color', '#000000B3')
if not url:
print("Server sync enabled but no URL configured")
return
@@ -743,7 +760,10 @@ class MainWindow(QMainWindow):
font_source=font_source,
websafe_font=websafe_font if websafe_font else None,
google_font=google_font if google_font else None,
custom_font_file=custom_font_file if custom_font_file else None
custom_font_file=custom_font_file if custom_font_file else None,
user_color=user_color,
text_color=text_color,
background_color=background_color
)
self.server_sync_client.start()

View File

@@ -4,10 +4,10 @@ from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QLineEdit, QComboBox, QCheckBox, QSlider,
QPushButton, QMessageBox, QGroupBox, QScrollArea, QWidget,
QFileDialog
QFileDialog, QColorDialog
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QScreen, QFontDatabase
from PySide6.QtGui import QScreen, QFontDatabase, QColor
from typing import Callable, List, Tuple
@@ -388,6 +388,53 @@ class SettingsDialog(QDialog):
)
display_layout.addRow("Fade After (seconds):", self.fade_seconds_input)
# Color settings
color_label = QLabel("Color Settings")
color_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
display_layout.addRow("", color_label)
# User name color picker
user_color_layout = QHBoxLayout()
self.user_color_button = QPushButton()
self.user_color_button.setFixedSize(100, 30)
self.user_color_button.setCursor(Qt.PointingHandCursor)
self.user_color_button.setToolTip("Click to change user name color")
self.user_color_button.clicked.connect(self._pick_user_color)
user_color_layout.addWidget(self.user_color_button)
self.user_color_hex = QLabel("#4CAF50")
self.user_color_hex.setStyleSheet("font-family: monospace;")
user_color_layout.addWidget(self.user_color_hex)
user_color_layout.addStretch()
display_layout.addRow("User Name Color:", user_color_layout)
# Text color picker
text_color_layout = QHBoxLayout()
self.text_color_button = QPushButton()
self.text_color_button.setFixedSize(100, 30)
self.text_color_button.setCursor(Qt.PointingHandCursor)
self.text_color_button.setToolTip("Click to change text color")
self.text_color_button.clicked.connect(self._pick_text_color)
text_color_layout.addWidget(self.text_color_button)
self.text_color_hex = QLabel("#FFFFFF")
self.text_color_hex.setStyleSheet("font-family: monospace;")
text_color_layout.addWidget(self.text_color_hex)
text_color_layout.addStretch()
display_layout.addRow("Text Color:", text_color_layout)
# Background color picker
bg_color_layout = QHBoxLayout()
self.bg_color_button = QPushButton()
self.bg_color_button.setFixedSize(100, 30)
self.bg_color_button.setCursor(Qt.PointingHandCursor)
self.bg_color_button.setToolTip("Click to change background color (with transparency)")
self.bg_color_button.clicked.connect(self._pick_background_color)
bg_color_layout.addWidget(self.bg_color_button)
self.bg_color_hex = QLabel("#000000B3")
self.bg_color_hex.setStyleSheet("font-family: monospace;")
bg_color_layout.addWidget(self.bg_color_hex)
bg_color_layout.addStretch()
display_layout.addRow("Background Color:", bg_color_layout)
display_group.setLayout(display_layout)
content_layout.addWidget(display_group)
@@ -577,6 +624,64 @@ class SettingsDialog(QDialog):
if file_path:
self.display_custom_font_input.setText(file_path)
def _update_color_button_style(self, button: QPushButton, color_hex: str):
"""Update a color button's background to show the selected color."""
# Handle colors with alpha (8-char hex)
if len(color_hex) == 9: # #RRGGBBAA
# For display, we just show the color (alpha is visible through the button style)
rgb = color_hex[:7]
alpha_hex = color_hex[7:9]
alpha = int(alpha_hex, 16) / 255
button.setStyleSheet(
f"background-color: {rgb}; "
f"border: 2px solid #888; "
f"border-radius: 4px; "
f"opacity: {alpha};"
)
else:
button.setStyleSheet(
f"background-color: {color_hex}; "
f"border: 2px solid #888; "
f"border-radius: 4px;"
)
def _pick_user_color(self):
"""Open color dialog for user name color."""
current_color = QColor(self.user_color_hex.text())
color = QColorDialog.getColor(current_color, self, "Select User Name Color")
if color.isValid():
hex_color = color.name()
self.user_color_hex.setText(hex_color)
self._update_color_button_style(self.user_color_button, hex_color)
def _pick_text_color(self):
"""Open color dialog for text color."""
current_color = QColor(self.text_color_hex.text())
color = QColorDialog.getColor(current_color, self, "Select Text Color")
if color.isValid():
hex_color = color.name()
self.text_color_hex.setText(hex_color)
self._update_color_button_style(self.text_color_button, hex_color)
def _pick_background_color(self):
"""Open color dialog for background color (with alpha support)."""
current_hex = self.bg_color_hex.text()
current_color = QColor(current_hex[:7] if len(current_hex) > 7 else current_hex)
if len(current_hex) == 9:
current_color.setAlpha(int(current_hex[7:9], 16))
color = QColorDialog.getColor(
current_color,
self,
"Select Background Color",
QColorDialog.ShowAlphaChannel
)
if color.isValid():
# Include alpha in hex format: #RRGGBBAA
hex_color = f"{color.name()}{color.alpha():02X}"
self.bg_color_hex.setText(hex_color)
self._update_color_button_style(self.bg_color_button, hex_color)
def _load_current_settings(self):
"""Load current settings from config."""
# User settings
@@ -649,6 +754,19 @@ class SettingsDialog(QDialog):
self.font_size_input.setText(str(self.config.get('display.font_size', 12)))
self.fade_seconds_input.setText(str(self.config.get('display.fade_after_seconds', 10)))
# Color settings
user_color = self.config.get('display.user_color', '#4CAF50')
self.user_color_hex.setText(user_color)
self._update_color_button_style(self.user_color_button, user_color)
text_color = self.config.get('display.text_color', '#FFFFFF')
self.text_color_hex.setText(text_color)
self._update_color_button_style(self.text_color_button, text_color)
bg_color = self.config.get('display.background_color', '#000000B3')
self.bg_color_hex.setText(bg_color)
self._update_color_button_style(self.bg_color_button, bg_color)
# Server sync settings
self.server_enabled_check.setChecked(self.config.get('server_sync.enabled', False))
self.server_url_input.setText(self.config.get('server_sync.url', ''))
@@ -716,6 +834,11 @@ class SettingsDialog(QDialog):
fade_seconds = int(self.fade_seconds_input.text())
self.config.set('display.fade_after_seconds', fade_seconds)
# Color settings
self.config.set('display.user_color', self.user_color_hex.text())
self.config.set('display.text_color', self.text_color_hex.text())
self.config.set('display.background_color', self.bg_color_hex.text())
# Server sync settings
self.config.set('server_sync.enabled', self.server_enabled_check.isChecked())
self.config.set('server_sync.url', self.server_url_input.text())

View File

@@ -374,13 +374,29 @@ app.get('/', (req, res) => {
overflow-x: auto;
margin-top: 10px;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 30px;
border-radius: 10px;
cursor: pointer;
font-weight: bold;
transition: transform 0.2s, box-shadow 0.2s;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎤 Local Transcription</h1>
<p>Multi-User Server (Node.js)</p>
<p>Multi-User Transcription Server</p>
<div class="status">🟢 Server Running</div>
</div>
@@ -504,25 +520,59 @@ app.get('/', (req, res) => {
</div>
<div class="card">
<h2> Server Information</h2>
<div class="stats">
<div class="stat">
<div class="stat-value">Node.js</div>
<div class="stat-label">Runtime</div>
</div>
<div class="stat">
<div class="stat-value">v1.0.0</div>
<div class="stat-label">Version</div>
</div>
<div class="stat">
<div class="stat-value">&lt;100ms</div>
<div class="stat-label">Latency</div>
</div>
<div class="stat">
<div class="stat-value">WebSocket</div>
<div class="stat-label">Protocol</div>
</div>
</div>
<h2>📺 Display Options Reference</h2>
<p>When creating a display URL for OBS, you can customize it with these URL parameters:</p>
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
<tr style="background: #f5f5f5;">
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;">Parameter</th>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;">Description</th>
<th style="text-align: left; padding: 10px; border-bottom: 2px solid #ddd;">Default</th>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>room</code></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Room name (required)</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">-</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>fade</code></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Seconds before text fades (0 = never)</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">10</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>timestamps</code></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Show timestamps (true/false)</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">true</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>maxlines</code></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Maximum visible lines</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">50</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>fontsize</code></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Font size in pixels</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">16</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>fontsource</code></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Font source: websafe, google, or custom</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">websafe</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>websafefont</code></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Web-safe font name (Arial, Courier New, etc.)</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Arial</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;"><code>googlefont</code></td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Google Font name (Roboto, Open Sans, etc.)</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Roboto</td>
</tr>
</table>
<p style="margin-top: 15px; font-size: 0.9em; color: #666;">
<strong>Note:</strong> Per-user colors and fonts set in the desktop app will override these defaults.
Each user can customize their name color, text color, and background color in Settings.
</p>
</div>
</div>
@@ -602,7 +652,8 @@ app.get('/', (req, res) => {
app.post('/api/send', async (req, res) => {
const requestStart = Date.now();
try {
const { room, passphrase, user_name, text, timestamp, is_preview, font_family, font_type } = req.body;
const { room, passphrase, user_name, text, timestamp, is_preview, font_family, font_type,
user_color, text_color, background_color } = req.body;
if (!room || !passphrase || !user_name || !text) {
return res.status(400).json({ error: 'Missing required fields' });
@@ -624,7 +675,11 @@ app.post('/api/send', async (req, res) => {
created_at: Date.now(),
is_preview: is_preview || false,
font_family: font_family || null, // Per-speaker font name
font_type: font_type || null // Font type: "websafe", "google", or "custom"
font_type: font_type || null, // Font type: "websafe", "google", or "custom"
// Per-user color settings
user_color: user_color || null, // User name color (e.g., "#4CAF50")
text_color: text_color || null, // Text color (e.g., "#FFFFFF")
background_color: background_color || null // Background color (e.g., "#000000B3")
};
const addStart = Date.now();
@@ -931,15 +986,36 @@ app.get('/display', (req, res) => {
return userColors.get(userName);
}
// Helper to convert hex color with alpha to rgba
function hexToRgba(hex) {
if (!hex) return null;
hex = hex.replace('#', '');
if (hex.length === 8) { // RRGGBBAA
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const a = parseInt(hex.substring(6, 8), 16) / 255;
return \`rgba(\${r}, \${g}, \${b}, \${a.toFixed(2)})\`;
} else if (hex.length === 6) { // RRGGBB
return '#' + hex;
}
return '#' + hex;
}
function addTranscription(data) {
const isPreview = data.is_preview || false;
const userName = data.user_name || '';
const fontFamily = data.font_family || null; // Per-speaker font name
const fontType = data.font_type || null; // "websafe", "google", or "custom"
// Per-user color settings
const userColorSetting = data.user_color || null; // User name color
const textColorSetting = data.text_color || null; // Text color
const bgColorSetting = data.background_color || null; // Background color
// Debug: Log received font info
if (fontFamily) {
console.log('Received transcription with font:', fontFamily, '(' + fontType + ')');
// Debug: Log received font/color info
if (fontFamily || userColorSetting) {
console.log('Received transcription with font:', fontFamily, '(' + fontType + ')',
'colors:', userColorSetting, textColorSetting, bgColorSetting);
}
// Load Google Font if needed
@@ -950,6 +1026,12 @@ app.get('/display', (req, res) => {
// Build font style string if font is set
// Use single quotes for font name to avoid conflict with style="" double quotes
const fontStyle = fontFamily ? \`font-family: '\${fontFamily}', sans-serif;\` : '';
// Build text color style
const textStyle = textColorSetting ? \`color: \${textColorSetting};\` : '';
// Combine font and text color for text span
const combinedTextStyle = fontStyle + textStyle;
// Build background style for transcription div
const bgStyle = bgColorSetting ? \`background: \${hexToRgba(bgColorSetting)};\` : '';
// If this is a final transcription, remove any existing preview from this user
if (!isPreview && userPreviews.has(userName)) {
@@ -960,12 +1042,14 @@ app.get('/display', (req, res) => {
userPreviews.delete(userName);
}
// Determine user name color: use custom color if provided, otherwise auto-generated
const userColor = userColorSetting || getUserColor(userName);
// If this is a preview, update existing preview or create new one
if (isPreview && userPreviews.has(userName)) {
const previewEl = userPreviews.get(userName);
if (previewEl && previewEl.parentNode) {
// Update existing preview
const userColor = getUserColor(userName);
let html = '';
if (showTimestamps && data.timestamp) {
html += \`<span class="timestamp">[\${data.timestamp}]</span>\`;
@@ -974,16 +1058,23 @@ app.get('/display', (req, res) => {
html += \`<span class="user" style="color: \${userColor}">\${userName}:</span>\`;
}
html += \`<span class="preview-indicator">[...]</span>\`;
html += \`<span class="text" style="\${fontStyle}">\${data.text}</span>\`;
html += \`<span class="text" style="\${combinedTextStyle}">\${data.text}</span>\`;
previewEl.innerHTML = html;
// Update background color if provided
if (bgStyle) {
previewEl.style.background = hexToRgba(bgColorSetting);
}
return;
}
}
const div = document.createElement('div');
div.className = isPreview ? 'transcription preview' : 'transcription';
// Apply per-user background color if provided
if (bgStyle) {
div.style.background = hexToRgba(bgColorSetting);
}
const userColor = getUserColor(userName);
let html = '';
if (showTimestamps && data.timestamp) {
html += \`<span class="timestamp">[\${data.timestamp}]</span>\`;
@@ -994,7 +1085,7 @@ app.get('/display', (req, res) => {
if (isPreview) {
html += \`<span class="preview-indicator">[...]</span>\`;
}
html += \`<span class="text" style="\${fontStyle}">\${data.text}</span>\`;
html += \`<span class="text" style="\${combinedTextStyle}">\${data.text}</span>\`;
div.innerHTML = html;
container.appendChild(div);

View File

@@ -16,7 +16,9 @@ class TranscriptionWebServer:
fade_after_seconds: int = 10, max_lines: int = 50, font_family: str = "Arial",
font_size: int = 16, fonts_dir: Optional[Path] = None,
font_source: str = "System Font", websafe_font: str = "Arial",
google_font: str = "Roboto"):
google_font: str = "Roboto",
user_color: str = "#4CAF50", text_color: str = "#FFFFFF",
background_color: str = "#000000B3"):
"""
Initialize web server.
@@ -32,6 +34,9 @@ class TranscriptionWebServer:
font_source: Font source type ("System Font", "Web-Safe", "Google Font")
websafe_font: Web-safe font name
google_font: Google Font name
user_color: User name color (hex format)
text_color: Text color (hex format)
background_color: Background color (hex format with optional alpha, e.g., #RRGGBBAA)
"""
self.host = host
self.port = port
@@ -44,6 +49,9 @@ class TranscriptionWebServer:
self.font_source = font_source
self.websafe_font = websafe_font
self.google_font = google_font
self.user_color = user_color
self.text_color = text_color
self.background_color = background_color
self.app = FastAPI()
self.active_connections: List[WebSocket] = []
self.transcriptions = [] # Store recent transcriptions
@@ -138,6 +146,25 @@ class TranscriptionWebServer:
return f'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family={font_name}&display=swap">'
return ""
def _hex_to_rgba(self, hex_color: str) -> str:
"""Convert hex color (optionally with alpha) to CSS rgba() format."""
# Remove # if present
hex_color = hex_color.lstrip('#')
if len(hex_color) == 8: # RRGGBBAA
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
a = int(hex_color[6:8], 16) / 255
return f"rgba({r}, {g}, {b}, {a:.2f})"
elif len(hex_color) == 6: # RRGGBB
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return f"rgb({r}, {g}, {b})"
else:
return hex_color # Return as-is if format is unknown
def _get_html(self) -> str:
"""Generate HTML for transcription display."""
# Generate custom font CSS
@@ -145,6 +172,9 @@ class TranscriptionWebServer:
google_font_link = self._get_google_font_link()
effective_font = self._get_effective_font()
# Convert background color to rgba for CSS
bg_color_css = self._hex_to_rgba(self.background_color)
return f"""
<!DOCTYPE html>
<html>
@@ -168,7 +198,7 @@ class TranscriptionWebServer:
.transcription {{
margin: 10px 0;
padding: 10px;
background: rgba(0, 0, 0, 0.7);
background: {bg_color_css};
border-radius: 5px;
animation: slideIn 0.3s ease-out;
transition: opacity 1s ease-out;
@@ -182,12 +212,12 @@ class TranscriptionWebServer:
margin-right: 10px;
}}
.user {{
color: #4CAF50;
color: {self.user_color};
font-weight: bold;
margin-right: 10px;
}}
.text {{
color: white;
color: {self.text_color};
}}
.transcription.preview {{
font-style: italic;

View File

@@ -1,7 +1,7 @@
"""Version information for Local Transcription."""
__version__ = "1.2.4"
__version_info__ = (1, 2, 4)
__version__ = "1.3.1"
__version_info__ = (1, 3, 1)
# Version history:
# 1.0.0 - Initial release with: