Add multi-user server sync (PHP server + client)

Phase 2 implementation: Multiple streamers can now merge their captions
into a single stream using a PHP server.

PHP Server (server/php/):
- server.php: API endpoint for sending/streaming transcriptions
- display.php: Web page for viewing merged captions in OBS
- config.php: Server configuration
- .htaccess: Security settings
- README.md: Comprehensive deployment guide

Features:
- Room-based isolation (multiple groups on same server)
- Passphrase authentication per room
- Real-time streaming via Server-Sent Events (SSE)
- Different colors for each user
- File-based storage (no database required)
- Auto-cleanup of old rooms
- Works on standard PHP hosting

Client-Side:
- client/server_sync.py: HTTP client for sending to PHP server
- Settings dialog updated with server sync options
- Config updated with server_sync section

Server Configuration:
- URL: Server endpoint (e.g., http://example.com/transcription/server.php)
- Room: Unique room name for your group
- Passphrase: Shared secret for authentication

OBS Integration:
Display URL format:
http://example.com/transcription/display.php?room=ROOM&passphrase=PASS&fade=10&timestamps=true

NOTE: Main window integration pending (client sends transcriptions)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-26 10:09:12 -08:00
parent aa8c294fdc
commit 9c3a0d7678
8 changed files with 982 additions and 2 deletions

180
server/php/display.php Normal file
View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html>
<head>
<title>Multi-User Transcription Display</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
margin: 0;
padding: 20px;
background: transparent;
font-family: Arial, sans-serif;
color: white;
}
#transcriptions {
max-height: 100vh;
overflow-y: auto;
}
.transcription {
margin: 10px 0;
padding: 10px;
background: rgba(0, 0, 0, 0.7);
border-radius: 5px;
animation: slideIn 0.3s ease-out;
transition: opacity 1s ease-out;
}
.transcription.fading {
opacity: 0;
}
.timestamp {
color: #888;
font-size: 0.9em;
margin-right: 10px;
}
.user {
font-weight: bold;
margin-right: 10px;
}
/* Different colors for different users */
.user-0 { color: #4CAF50; }
.user-1 { color: #2196F3; }
.user-2 { color: #FF9800; }
.user-3 { color: #E91E63; }
.user-4 { color: #9C27B0; }
.user-5 { color: #00BCD4; }
.text {
color: white;
}
#status {
position: fixed;
top: 10px;
right: 10px;
padding: 10px;
background: rgba(0, 0, 0, 0.8);
border-radius: 5px;
font-size: 0.9em;
}
#status.connected { color: #4CAF50; }
#status.disconnected { color: #f44336; }
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<div id="status" class="disconnected"> Connecting...</div>
<div id="transcriptions"></div>
<script>
// Get URL parameters
const urlParams = new URLSearchParams(window.location.search);
const room = urlParams.get('room') || 'default';
const passphrase = urlParams.get('passphrase') || '';
const fadeAfter = parseInt(urlParams.get('fade') || '10');
const showTimestamps = urlParams.get('timestamps') !== 'false';
const container = document.getElementById('transcriptions');
const statusEl = document.getElementById('status');
const userColors = new Map(); // Map user names to color indices
let colorIndex = 0;
// Connect to Server-Sent Events
function connect() {
const url = `server.php?action=stream&room=${encodeURIComponent(room)}&passphrase=${encodeURIComponent(passphrase)}`;
const eventSource = new EventSource(url);
eventSource.onopen = () => {
statusEl.textContent = '🟢 Connected';
statusEl.className = 'connected';
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
addTranscription(data);
};
eventSource.onerror = () => {
statusEl.textContent = '🔴 Disconnected';
statusEl.className = 'disconnected';
eventSource.close();
// Reconnect after 3 seconds
setTimeout(connect, 3000);
};
}
function addTranscription(data) {
const div = document.createElement('div');
div.className = 'transcription';
// Assign color to user
if (!userColors.has(data.user_name)) {
userColors.set(data.user_name, colorIndex % 6);
colorIndex++;
}
const userColorClass = `user-${userColors.get(data.user_name)}`;
let html = '';
if (showTimestamps && data.timestamp) {
html += `<span class="timestamp">[${data.timestamp}]</span>`;
}
if (data.user_name) {
html += `<span class="user ${userColorClass}">${data.user_name}:</span>`;
}
html += `<span class="text">${data.text}</span>`;
div.innerHTML = html;
container.appendChild(div);
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
// Set up fade-out if enabled
if (fadeAfter > 0) {
setTimeout(() => {
div.classList.add('fading');
setTimeout(() => {
if (div.parentNode === container) {
container.removeChild(div);
}
}, 1000);
}, fadeAfter * 1000);
}
// Limit to 100 transcriptions
while (container.children.length > 100) {
container.removeChild(container.firstChild);
}
}
// Load recent transcriptions on startup
async function loadRecent() {
try {
const url = `server.php?action=list&room=${encodeURIComponent(room)}&passphrase=${encodeURIComponent(passphrase)}`;
const response = await fetch(url);
const data = await response.json();
if (data.transcriptions) {
// Show last 20 transcriptions
data.transcriptions.slice(-20).forEach(addTranscription);
}
} catch (error) {
console.error('Error loading recent transcriptions:', error);
}
}
// Start
loadRecent().then(() => {
connect();
});
</script>
</body>
</html>