Enhance display customization and remove PHP server
Major improvements to display configuration and server architecture: **Display Enhancements:** - Add URL parameters for display customization (timestamps, maxlines, fontsize, fontfamily) - Fix max lines enforcement to prevent scroll bars in OBS - Apply font family and size settings to both local and sync displays - Remove auto-scroll, enforce overflow:hidden for clean OBS integration **Node.js Server:** - Add timestamps toggle: timestamps=true/false - Add max lines limit: maxlines=50 - Add font configuration: fontsize=16, fontfamily=Arial - Update index page with URL parameters documentation - Improve display URLs in room generation **Local Web Server:** - Add max_lines, font_family, font_size configuration - Respect settings from GUI configuration - Apply changes immediately without restart **Architecture:** - Remove PHP server implementation (Node.js recommended) - Update all documentation to reference Node.js server - Update default config URLs to Node.js endpoints - Clean up 1700+ lines of PHP code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
60
CLAUDE.md
60
CLAUDE.md
@@ -10,7 +10,7 @@ Local Transcription is a desktop application for real-time speech-to-text transc
|
|||||||
- Standalone desktop GUI (PySide6/Qt)
|
- Standalone desktop GUI (PySide6/Qt)
|
||||||
- Local transcription with CPU/GPU support
|
- Local transcription with CPU/GPU support
|
||||||
- Built-in web server for OBS browser source integration
|
- Built-in web server for OBS browser source integration
|
||||||
- Optional PHP-based multi-user server for syncing transcriptions across users
|
- Optional Node.js-based multi-user server for syncing transcriptions across users
|
||||||
- Noise suppression and Voice Activity Detection (VAD)
|
- Noise suppression and Voice Activity Detection (VAD)
|
||||||
- Cross-platform builds (Linux/Windows) with PyInstaller
|
- Cross-platform builds (Linux/Windows) with PyInstaller
|
||||||
|
|
||||||
@@ -29,12 +29,12 @@ local-transcription/
|
|||||||
│ ├── main_window_qt.py # Main application window (PySide6)
|
│ ├── main_window_qt.py # Main application window (PySide6)
|
||||||
│ ├── settings_dialog_qt.py # Settings dialog (PySide6)
|
│ ├── settings_dialog_qt.py # Settings dialog (PySide6)
|
||||||
│ └── transcription_display_qt.py # Display widget
|
│ └── transcription_display_qt.py # Display widget
|
||||||
├── server/ # Web display server
|
├── server/ # Web display servers
|
||||||
│ ├── web_display.py # FastAPI server for OBS browser source
|
│ ├── web_display.py # FastAPI server for OBS browser source (local)
|
||||||
│ └── php/ # Optional multi-user PHP server
|
│ └── nodejs/ # Optional multi-user Node.js server
|
||||||
│ ├── server.php # Multi-user sync server
|
│ ├── server.js # Multi-user sync server with WebSocket
|
||||||
│ ├── display.php # Multi-user web display
|
│ ├── package.json # Node.js dependencies
|
||||||
│ └── README.md # PHP server documentation
|
│ └── README.md # Server deployment documentation
|
||||||
├── config/ # Example configuration files
|
├── config/ # Example configuration files
|
||||||
│ └── default_config.yaml # Default settings template
|
│ └── default_config.yaml # Default settings template
|
||||||
├── main.py # GUI application entry point
|
├── main.py # GUI application entry point
|
||||||
@@ -128,28 +128,20 @@ uv run python -m uvicorn server.web_display:app --reload
|
|||||||
- Used for OBS browser source integration
|
- Used for OBS browser source integration
|
||||||
- Single-user (displays only local transcriptions)
|
- Single-user (displays only local transcriptions)
|
||||||
|
|
||||||
**Multi-User Servers** (Optional - for syncing across multiple users)
|
**Multi-User Server** (Optional - for syncing across multiple users)
|
||||||
|
|
||||||
Three options available:
|
**Node.js WebSocket Server** ([server/nodejs/](server/nodejs/)) - **RECOMMENDED**
|
||||||
|
|
||||||
1. **PHP with Polling** ([server/php/display-polling.php](server/php/display-polling.php)) - **RECOMMENDED for PHP**
|
|
||||||
- Works on ANY shared hosting (no buffering issues)
|
|
||||||
- Uses HTTP polling instead of SSE
|
|
||||||
- 1-2 second latency, very reliable
|
|
||||||
- File-based storage, no database needed
|
|
||||||
|
|
||||||
2. **Node.js WebSocket Server** ([server/nodejs/](server/nodejs/)) - **BEST PERFORMANCE**
|
|
||||||
- Real-time WebSocket support (< 100ms latency)
|
- Real-time WebSocket support (< 100ms latency)
|
||||||
- Handles 100+ concurrent users
|
- Handles 100+ concurrent users
|
||||||
- Requires VPS/cloud hosting (Railway, Heroku, DigitalOcean)
|
- Easy deployment to VPS/cloud hosting (Railway, Heroku, DigitalOcean, or any VPS)
|
||||||
- Much better than PHP for real-time applications
|
- Configurable display options via URL parameters:
|
||||||
|
- `timestamps=true/false` - Show/hide timestamps
|
||||||
|
- `maxlines=50` - Maximum visible lines (prevents scroll bars in OBS)
|
||||||
|
- `fontsize=16` - Font size in pixels
|
||||||
|
- `fontfamily=Arial` - Font family
|
||||||
|
- `fade=10` - Seconds before text fades (0 = never)
|
||||||
|
|
||||||
3. **PHP with SSE** ([server/php/display.php](server/php/display.php)) - **NOT RECOMMENDED**
|
See [server/nodejs/README.md](server/nodejs/README.md) for deployment instructions
|
||||||
- Has buffering issues on most shared hosting
|
|
||||||
- PHP-FPM incompatibility
|
|
||||||
- Use polling or Node.js instead
|
|
||||||
|
|
||||||
See [server/COMPARISON.md](server/COMPARISON.md) and [server/QUICK_FIX.md](server/QUICK_FIX.md) for details
|
|
||||||
|
|
||||||
### Configuration System
|
### Configuration System
|
||||||
|
|
||||||
@@ -281,19 +273,17 @@ See [server/COMPARISON.md](server/COMPARISON.md) and [server/QUICK_FIX.md](serve
|
|||||||
3. URL: `http://localhost:8080`
|
3. URL: `http://localhost:8080`
|
||||||
4. Set dimensions (e.g., 1920x300)
|
4. Set dimensions (e.g., 1920x300)
|
||||||
|
|
||||||
### Multi-User Display (PHP Server - Polling)
|
|
||||||
1. Deploy PHP server to web hosting
|
|
||||||
2. Each user enables "Server Sync" in settings
|
|
||||||
3. Enter same room name and passphrase
|
|
||||||
4. In OBS: Add "Browser" source
|
|
||||||
5. URL: `https://your-domain.com/transcription/display-polling.php?room=ROOM&fade=10`
|
|
||||||
|
|
||||||
### Multi-User Display (Node.js Server)
|
### Multi-User Display (Node.js Server)
|
||||||
1. Deploy Node.js server (see [server/nodejs/README.md](server/nodejs/README.md))
|
1. Deploy Node.js server (see [server/nodejs/README.md](server/nodejs/README.md))
|
||||||
2. Each user configures Server URL: `http://your-server:3000/api/send`
|
2. Each user configures Server URL: `http://your-server:3000/api/send`
|
||||||
3. Enter same room name and passphrase
|
3. Enter same room name and passphrase
|
||||||
4. In OBS: Add "Browser" source
|
4. In OBS: Add "Browser" source
|
||||||
5. URL: `http://your-server:3000/display?room=ROOM&fade=10`
|
5. URL: `http://your-server:3000/display?room=ROOM&fade=10×tamps=true&maxlines=50&fontsize=16`
|
||||||
|
6. Customize URL parameters as needed:
|
||||||
|
- `timestamps=false` - Hide timestamps
|
||||||
|
- `maxlines=30` - Show max 30 lines (prevents scroll bars)
|
||||||
|
- `fontsize=18` - Larger font
|
||||||
|
- `fontfamily=Courier` - Different font
|
||||||
|
|
||||||
## Performance Optimization
|
## Performance Optimization
|
||||||
|
|
||||||
@@ -314,7 +304,7 @@ See [server/COMPARISON.md](server/COMPARISON.md) and [server/QUICK_FIX.md](serve
|
|||||||
- ✅ **Phase 1**: Standalone desktop application (complete)
|
- ✅ **Phase 1**: Standalone desktop application (complete)
|
||||||
- ✅ **Web Server**: Local OBS integration (complete)
|
- ✅ **Web Server**: Local OBS integration (complete)
|
||||||
- ✅ **Builds**: PyInstaller executables (complete)
|
- ✅ **Builds**: PyInstaller executables (complete)
|
||||||
- 🚧 **Phase 2**: Multi-user PHP server (functional, optional)
|
- ✅ **Phase 2**: Multi-user Node.js server (complete, optional)
|
||||||
- ⏸️ **Phase 3+**: Advanced features (see [NEXT_STEPS.md](NEXT_STEPS.md))
|
- ⏸️ **Phase 3+**: Advanced features (see [NEXT_STEPS.md](NEXT_STEPS.md))
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
@@ -323,4 +313,4 @@ See [server/COMPARISON.md](server/COMPARISON.md) and [server/QUICK_FIX.md](serve
|
|||||||
- [BUILD.md](BUILD.md) - Detailed build instructions
|
- [BUILD.md](BUILD.md) - Detailed build instructions
|
||||||
- [INSTALL.md](INSTALL.md) - Installation guide
|
- [INSTALL.md](INSTALL.md) - Installation guide
|
||||||
- [NEXT_STEPS.md](NEXT_STEPS.md) - Future enhancements
|
- [NEXT_STEPS.md](NEXT_STEPS.md) - Future enhancements
|
||||||
- [server/php/README.md](server/php/README.md) - PHP server setup
|
- [server/nodejs/README.md](server/nodejs/README.md) - Node.js server setup and deployment
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ processing:
|
|||||||
|
|
||||||
server_sync:
|
server_sync:
|
||||||
enabled: false
|
enabled: false
|
||||||
url: "http://localhost/transcription/server.php"
|
url: "http://localhost:3000/api/send"
|
||||||
room: "default"
|
room: "default"
|
||||||
passphrase: ""
|
passphrase: ""
|
||||||
|
|
||||||
|
|||||||
@@ -273,6 +273,9 @@ class MainWindow(QMainWindow):
|
|||||||
port = self.config.get('web_server.port', 8080)
|
port = self.config.get('web_server.port', 8080)
|
||||||
show_timestamps = self.config.get('display.show_timestamps', True)
|
show_timestamps = self.config.get('display.show_timestamps', True)
|
||||||
fade_after_seconds = self.config.get('display.fade_after_seconds', 10)
|
fade_after_seconds = self.config.get('display.fade_after_seconds', 10)
|
||||||
|
max_lines = self.config.get('display.max_lines', 50)
|
||||||
|
font_family = self.config.get('display.font_family', 'Arial')
|
||||||
|
font_size = self.config.get('display.font_size', 16)
|
||||||
|
|
||||||
# Try up to 5 ports if the default is in use
|
# Try up to 5 ports if the default is in use
|
||||||
ports_to_try = [port] + [port + i for i in range(1, 5)]
|
ports_to_try = [port] + [port + i for i in range(1, 5)]
|
||||||
@@ -284,7 +287,10 @@ class MainWindow(QMainWindow):
|
|||||||
host=host,
|
host=host,
|
||||||
port=try_port,
|
port=try_port,
|
||||||
show_timestamps=show_timestamps,
|
show_timestamps=show_timestamps,
|
||||||
fade_after_seconds=fade_after_seconds
|
fade_after_seconds=fade_after_seconds,
|
||||||
|
max_lines=max_lines,
|
||||||
|
font_family=font_family,
|
||||||
|
font_size=font_size
|
||||||
)
|
)
|
||||||
self.web_server_thread = WebServerThread(self.web_server)
|
self.web_server_thread = WebServerThread(self.web_server)
|
||||||
self.web_server_thread.start()
|
self.web_server_thread.start()
|
||||||
@@ -530,6 +536,9 @@ class MainWindow(QMainWindow):
|
|||||||
if self.web_server:
|
if self.web_server:
|
||||||
self.web_server.show_timestamps = show_timestamps
|
self.web_server.show_timestamps = show_timestamps
|
||||||
self.web_server.fade_after_seconds = self.config.get('display.fade_after_seconds', 10)
|
self.web_server.fade_after_seconds = self.config.get('display.fade_after_seconds', 10)
|
||||||
|
self.web_server.max_lines = self.config.get('display.max_lines', 50)
|
||||||
|
self.web_server.font_family = self.config.get('display.font_family', 'Arial')
|
||||||
|
self.web_server.font_size = self.config.get('display.font_size', 16)
|
||||||
|
|
||||||
# Restart server sync if it was running and settings changed
|
# Restart server sync if it was running and settings changed
|
||||||
if self.is_transcribing and self.server_sync_client:
|
if self.is_transcribing and self.server_sync_client:
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class SettingsDialog(QDialog):
|
|||||||
server_layout.addRow("Enable Server Sync:", self.server_enabled_check)
|
server_layout.addRow("Enable Server Sync:", self.server_enabled_check)
|
||||||
|
|
||||||
self.server_url_input = QLineEdit()
|
self.server_url_input = QLineEdit()
|
||||||
self.server_url_input.setPlaceholderText("http://example.com/transcription/server.php")
|
self.server_url_input.setPlaceholderText("http://your-server:3000/api/send")
|
||||||
server_layout.addRow("Server URL:", self.server_url_input)
|
server_layout.addRow("Server URL:", self.server_url_input)
|
||||||
|
|
||||||
self.server_room_input = QLineEdit()
|
self.server_room_input = QLineEdit()
|
||||||
|
|||||||
@@ -411,6 +411,19 @@ app.get('/', (req, res) => {
|
|||||||
<p style="margin-top: 15px; font-size: 0.9em; color: #666;">
|
<p style="margin-top: 15px; font-size: 0.9em; color: #666;">
|
||||||
Add a Browser source in OBS and paste this URL. Set width to 1920 and height to 200-400px.
|
Add a Browser source in OBS and paste this URL. Set width to 1920 and height to 200-400px.
|
||||||
</p>
|
</p>
|
||||||
|
<details style="margin-top: 15px;">
|
||||||
|
<summary style="cursor: pointer; font-weight: bold; color: #667eea;">⚙️ URL Parameters (Optional)</summary>
|
||||||
|
<ul style="margin-top: 10px; font-size: 0.9em; color: #666;">
|
||||||
|
<li><code>fade=10</code> - Seconds before text fades (0 = never fade)</li>
|
||||||
|
<li><code>timestamps=true</code> - Show/hide timestamps (true/false)</li>
|
||||||
|
<li><code>maxlines=50</code> - Max lines visible at once (prevents scroll bars)</li>
|
||||||
|
<li><code>fontsize=16</code> - Font size in pixels</li>
|
||||||
|
<li><code>fontfamily=Arial</code> - Font family (Arial, Courier, etc.)</li>
|
||||||
|
</ul>
|
||||||
|
<p style="font-size: 0.85em; color: #888; margin-top: 10px;">
|
||||||
|
Example: <code>?room=myroom&fade=15×tamps=false&maxlines=30&fontsize=18</code>
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,7 +541,7 @@ app.get('/', (req, res) => {
|
|||||||
|
|
||||||
// Build URLs
|
// Build URLs
|
||||||
const serverUrl = \`http://\${window.location.host}/api/send\`;
|
const serverUrl = \`http://\${window.location.host}/api/send\`;
|
||||||
const displayUrl = \`http://\${window.location.host}/display?room=\${encodeURIComponent(room)}&fade=10×tamps=true\`;
|
const displayUrl = \`http://\${window.location.host}/display?room=\${encodeURIComponent(room)}&fade=10×tamps=true&maxlines=50&fontsize=16&fontfamily=Arial\`;
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
document.getElementById('serverUrl').textContent = serverUrl;
|
document.getElementById('serverUrl').textContent = serverUrl;
|
||||||
@@ -636,7 +649,7 @@ app.get('/api/list', async (req, res) => {
|
|||||||
|
|
||||||
// Serve display page
|
// Serve display page
|
||||||
app.get('/display', (req, res) => {
|
app.get('/display', (req, res) => {
|
||||||
const { room = 'default', fade = '10', timestamps = 'true' } = req.query;
|
const { room = 'default', fade = '10', timestamps = 'true', maxlines = '50', fontsize = '16', fontfamily = 'Arial' } = req.query;
|
||||||
|
|
||||||
res.send(`
|
res.send(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -649,12 +662,13 @@ app.get('/display', (req, res) => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-family: Arial, sans-serif;
|
font-family: ${fontfamily}, sans-serif;
|
||||||
|
font-size: ${fontsize}px;
|
||||||
color: white;
|
color: white;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
#transcriptions {
|
#transcriptions {
|
||||||
max-height: 100vh;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
.transcription {
|
.transcription {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
@@ -705,7 +719,8 @@ app.get('/display', (req, res) => {
|
|||||||
<script>
|
<script>
|
||||||
const room = "${room}";
|
const room = "${room}";
|
||||||
const fadeAfter = ${fade};
|
const fadeAfter = ${fade};
|
||||||
const showTimestamps = ${timestamps};
|
const showTimestamps = ${timestamps === 'true' || timestamps === '1'};
|
||||||
|
const maxLines = ${maxlines};
|
||||||
const container = document.getElementById('transcriptions');
|
const container = document.getElementById('transcriptions');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const userColors = new Map();
|
const userColors = new Map();
|
||||||
@@ -737,7 +752,6 @@ app.get('/display', (req, res) => {
|
|||||||
|
|
||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
|
|
||||||
if (fadeAfter > 0) {
|
if (fadeAfter > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -746,7 +760,8 @@ app.get('/display', (req, res) => {
|
|||||||
}, fadeAfter * 1000);
|
}, fadeAfter * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
while (container.children.length > 100) {
|
// Enforce max lines limit
|
||||||
|
while (container.children.length > maxLines) {
|
||||||
container.removeChild(container.firstChild);
|
container.removeChild(container.firstChild);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# Security settings for Multi-User Transcription Server
|
|
||||||
# Compatible with both mod_php and php-fpm
|
|
||||||
|
|
||||||
# IMPORTANT: For Server-Sent Events (SSE) to work with php-fpm, you MUST configure
|
|
||||||
# Apache's ProxyTimeout in your virtual host configuration. See apache-sse-config.conf
|
|
||||||
# for the required settings. Without this, Apache will timeout SSE connections after
|
|
||||||
# 30-60 seconds with a 504 Gateway Timeout error.
|
|
||||||
|
|
||||||
# Deny access to config file directly (if accessed via URL)
|
|
||||||
<Files "config.php">
|
|
||||||
Require all denied
|
|
||||||
</Files>
|
|
||||||
|
|
||||||
# Deny access to .json data files
|
|
||||||
<FilesMatch "\.json$">
|
|
||||||
Require all denied
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
# Disable directory listing
|
|
||||||
Options -Indexes
|
|
||||||
|
|
||||||
# Enable compression
|
|
||||||
<IfModule mod_deflate.c>
|
|
||||||
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/json
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
# Set MIME types
|
|
||||||
AddType application/json .json
|
|
||||||
AddType text/event-stream .php
|
|
||||||
|
|
||||||
# NOTE: PHP settings (display_errors, upload limits) must be configured in:
|
|
||||||
# - For php-fpm: /etc/php/X.X/fpm/pool.d/www.conf or php.ini
|
|
||||||
# - For mod_php: Can use php_flag/php_value directives here
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# Multi-User Transcription Server (PHP)
|
|
||||||
|
|
||||||
A simple PHP server that allows multiple Local Transcription clients to merge their captions into a single stream. Perfect for multiple streamers playing together who want synchronized captions.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- ✅ Room-based isolation (multiple groups can use the same server)
|
|
||||||
- ✅ Passphrase authentication per room
|
|
||||||
- ✅ Real-time streaming via Server-Sent Events (SSE)
|
|
||||||
- ✅ Different colors for each user
|
|
||||||
- ✅ Auto-fade transcriptions
|
|
||||||
- ✅ Works on standard PHP hosting (no special requirements)
|
|
||||||
- ✅ File-based storage (no database needed)
|
|
||||||
- ✅ Automatic cleanup of old rooms
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- PHP 7.4 or higher
|
|
||||||
- Web server (Apache/Nginx)
|
|
||||||
- Writable data directory
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### 1. Upload Files
|
|
||||||
|
|
||||||
Upload these files to your web server:
|
|
||||||
```
|
|
||||||
your-domain.com/
|
|
||||||
└── transcription/
|
|
||||||
├── server.php
|
|
||||||
├── display.php
|
|
||||||
├── config.php
|
|
||||||
├── .htaccess
|
|
||||||
└── data/ (will be created automatically)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Set Permissions
|
|
||||||
|
|
||||||
Make sure the PHP process can write to the directory:
|
|
||||||
```bash
|
|
||||||
chmod 755 server.php display.php config.php
|
|
||||||
chmod 755 .
|
|
||||||
```
|
|
||||||
|
|
||||||
The `data/` directory will be created automatically with proper permissions.
|
|
||||||
|
|
||||||
### 3. Test Installation
|
|
||||||
|
|
||||||
Visit: `https://your-domain.com/transcription/server.php`
|
|
||||||
|
|
||||||
You should see:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"service": "Local Transcription Multi-User Server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### For Streamers (Desktop App)
|
|
||||||
|
|
||||||
1. Open the Local Transcription app
|
|
||||||
2. Go to Settings
|
|
||||||
3. Enable "Server Sync"
|
|
||||||
4. Enter:
|
|
||||||
- **Server URL**: `https://your-domain.com/transcription/server.php`
|
|
||||||
- **Room Name**: Choose a unique name (e.g., "gaming-session-123")
|
|
||||||
- **Passphrase**: A shared secret for your group (e.g., "mysecretpass")
|
|
||||||
5. Start transcription
|
|
||||||
|
|
||||||
### For OBS (Browser Source)
|
|
||||||
|
|
||||||
1. Add a "Browser" source in OBS
|
|
||||||
2. Set URL to:
|
|
||||||
```
|
|
||||||
https://your-domain.com/transcription/display.php?room=ROOM&passphrase=PASS&fade=10×tamps=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace:
|
|
||||||
- `ROOM` = Your room name
|
|
||||||
- `PASS` = Your passphrase
|
|
||||||
- `fade=10` = Seconds before text fades (0 = never)
|
|
||||||
- `timestamps=true` = Show timestamps (false to hide)
|
|
||||||
|
|
||||||
3. Set width/height as desired (e.g., 1920x300)
|
|
||||||
4. Check "Shutdown source when not visible" (optional)
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Send Transcription
|
|
||||||
```http
|
|
||||||
POST /server.php?action=send
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"room": "my-room",
|
|
||||||
"passphrase": "my-secret",
|
|
||||||
"user_name": "Alice",
|
|
||||||
"text": "Hello everyone!",
|
|
||||||
"timestamp": "12:34:56"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stream Transcriptions (SSE)
|
|
||||||
```http
|
|
||||||
GET /server.php?action=stream&room=my-room&passphrase=my-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns Server-Sent Events stream with new transcriptions.
|
|
||||||
|
|
||||||
### List Recent Transcriptions
|
|
||||||
```http
|
|
||||||
GET /server.php?action=list&room=my-room&passphrase=my-secret
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns JSON array of recent transcriptions.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Edit `config.php` to customize:
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Session lifetime (seconds)
|
|
||||||
define('SESSION_LIFETIME', 3600);
|
|
||||||
|
|
||||||
// Max transcriptions stored per room
|
|
||||||
define('MAX_TRANSCRIPTIONS_PER_ROOM', 100);
|
|
||||||
|
|
||||||
// Storage directory
|
|
||||||
define('STORAGE_DIR', __DIR__ . '/data');
|
|
||||||
|
|
||||||
// Enable CORS
|
|
||||||
define('ENABLE_CORS', true);
|
|
||||||
|
|
||||||
// Cleanup threshold (seconds)
|
|
||||||
define('CLEANUP_THRESHOLD', 7200);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
### Passphrases
|
|
||||||
- Each room is protected by a passphrase
|
|
||||||
- Passphrases are hashed using PHP's `password_hash()`
|
|
||||||
- The first person to create a room sets its passphrase
|
|
||||||
- All subsequent users must use the same passphrase
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
1. Use strong passphrases (e.g., `MyStream2024!SecurePass`)
|
|
||||||
2. Don't share passphrases publicly
|
|
||||||
3. Use unique room names (e.g., include date/time)
|
|
||||||
4. Enable HTTPS on your server
|
|
||||||
5. Regularly update PHP
|
|
||||||
|
|
||||||
### Data Storage
|
|
||||||
- Room data is stored in `data/room_HASH.json`
|
|
||||||
- Files are automatically cleaned up after 2 hours of inactivity
|
|
||||||
- No personally identifiable information is logged
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Invalid passphrase" error
|
|
||||||
- Make sure all clients use the exact same passphrase
|
|
||||||
- Passphrases are case-sensitive
|
|
||||||
- First user to join creates the room and sets the passphrase
|
|
||||||
|
|
||||||
### Transcriptions not appearing
|
|
||||||
- Check browser console for errors
|
|
||||||
- Verify Server-Sent Events (SSE) is supported
|
|
||||||
- Check that the room name and passphrase match
|
|
||||||
|
|
||||||
### "Permission denied" on data directory
|
|
||||||
```bash
|
|
||||||
chmod 755 /path/to/transcription
|
|
||||||
# Data directory will be created automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server disconnects frequently
|
|
||||||
- Increase PHP's `max_execution_time` for SSE:
|
|
||||||
```php
|
|
||||||
set_time_limit(0);
|
|
||||||
```
|
|
||||||
- Check server timeout settings (Apache/Nginx)
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Multiple Rooms on Same Server
|
|
||||||
Each room is completely isolated. Example:
|
|
||||||
|
|
||||||
- Room "podcast-team-1" with passphrase "secret1"
|
|
||||||
- Room "gaming-squad-2" with passphrase "secret2"
|
|
||||||
|
|
||||||
They don't interfere with each other.
|
|
||||||
|
|
||||||
### Customizing Display
|
|
||||||
|
|
||||||
Add URL parameters to `display.php`:
|
|
||||||
- `?fade=20` - Fade after 20 seconds
|
|
||||||
- `?fade=0` - Never fade
|
|
||||||
- `?timestamps=false` - Hide timestamps
|
|
||||||
- `?font=Arial` - Change font (future feature)
|
|
||||||
|
|
||||||
### Using with Shared Hosting
|
|
||||||
|
|
||||||
This works on most shared hosting providers:
|
|
||||||
- No database required
|
|
||||||
- No special PHP extensions needed
|
|
||||||
- Uses standard PHP file operations
|
|
||||||
- Compatible with Apache .htaccess
|
|
||||||
|
|
||||||
### Upgrading to Redis/MySQL
|
|
||||||
|
|
||||||
For high-traffic scenarios, replace file storage in `server.php`:
|
|
||||||
|
|
||||||
```php
|
|
||||||
// Instead of file_put_contents()
|
|
||||||
// Use Redis:
|
|
||||||
$redis->set("room:$room", json_encode($roomData));
|
|
||||||
|
|
||||||
// Or MySQL:
|
|
||||||
$pdo->prepare("INSERT INTO rooms ...")->execute(...);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **Tested**: 10 concurrent clients per room
|
|
||||||
- **Latency**: < 2 seconds
|
|
||||||
- **Storage**: ~1KB per transcription
|
|
||||||
- **Bandwidth**: Minimal (text-only)
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
- File-based storage (not suitable for very high traffic)
|
|
||||||
- Server-Sent Events may not work with some proxies
|
|
||||||
- Rooms expire after 2 hours of inactivity
|
|
||||||
- No user management or admin panel (by design)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Part of the Local Transcription project.
|
|
||||||
Generated with Claude Code.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check this README
|
|
||||||
2. Review server logs
|
|
||||||
3. Test with browser's Network tab
|
|
||||||
4. Create an issue on GitHub
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Apache configuration snippet for Server-Sent Events (SSE) support
|
|
||||||
# Add this to your Apache virtual host configuration
|
|
||||||
#
|
|
||||||
# Location: Typically in /etc/apache2/sites-available/your-site.conf
|
|
||||||
# or wherever your virtual host is configured
|
|
||||||
|
|
||||||
# Increase timeout for SSE connections (6 hours to match HAProxy)
|
|
||||||
# This prevents Apache from killing long-running SSE connections
|
|
||||||
ProxyTimeout 21600
|
|
||||||
|
|
||||||
# Alternative: Set timeout specifically for SSE endpoints
|
|
||||||
# Uncomment and adjust the path if you want to be more specific
|
|
||||||
# <LocationMatch "^/transcribe/server\.php">
|
|
||||||
# ProxyTimeout 21600
|
|
||||||
# </LocationMatch>
|
|
||||||
|
|
||||||
# Note: After adding this to your virtual host config, reload Apache:
|
|
||||||
# sudo systemctl reload apache2
|
|
||||||
# or
|
|
||||||
# sudo service apache2 reload
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Multi-User Transcription Server - Configuration
|
|
||||||
*
|
|
||||||
* Simple PHP server for merging transcriptions from multiple clients
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Session configuration
|
|
||||||
define('SESSION_LIFETIME', 3600); // 1 hour
|
|
||||||
define('MAX_TRANSCRIPTIONS_PER_ROOM', 100);
|
|
||||||
|
|
||||||
// Storage directory (must be writable by PHP)
|
|
||||||
define('STORAGE_DIR', __DIR__ . '/data');
|
|
||||||
|
|
||||||
// Enable CORS for cross-origin requests (if needed)
|
|
||||||
define('ENABLE_CORS', true);
|
|
||||||
|
|
||||||
// Cleanup old sessions older than this (seconds)
|
|
||||||
define('CLEANUP_THRESHOLD', 7200); // 2 hours
|
|
||||||
|
|
||||||
// Initialize storage directory
|
|
||||||
if (!file_exists(STORAGE_DIR)) {
|
|
||||||
mkdir(STORAGE_DIR, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Security headers
|
|
||||||
header('X-Content-Type-Options: nosniff');
|
|
||||||
header('X-Frame-Options: SAMEORIGIN');
|
|
||||||
|
|
||||||
// CORS headers (if enabled)
|
|
||||||
if (ENABLE_CORS) {
|
|
||||||
header('Access-Control-Allow-Origin: *');
|
|
||||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
|
||||||
header('Access-Control-Allow-Headers: Content-Type');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle preflight requests
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
||||||
http_response_code(200);
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
// Block direct access to data directory
|
|
||||||
http_response_code(403);
|
|
||||||
header('Content-Type: text/plain');
|
|
||||||
die('Access Denied');
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Server Diagnostic - Multi-User Transcription</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 40px auto;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
.test {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 15px 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
border-left: 4px solid #ccc;
|
|
||||||
}
|
|
||||||
.test.pass { border-color: #4CAF50; }
|
|
||||||
.test.fail { border-color: #f44336; }
|
|
||||||
.test.warn { border-color: #ff9800; }
|
|
||||||
.test h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.test pre {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.status { font-weight: bold; }
|
|
||||||
.pass .status { color: #4CAF50; }
|
|
||||||
.fail .status { color: #f44336; }
|
|
||||||
.warn .status { color: #ff9800; }
|
|
||||||
h1 { color: #333; }
|
|
||||||
.info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>🔧 Server Diagnostic</h1>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
require_once 'config.php';
|
|
||||||
|
|
||||||
// Test 1: PHP Version
|
|
||||||
$php_version = phpversion();
|
|
||||||
$php_ok = version_compare($php_version, '7.4.0', '>=');
|
|
||||||
?>
|
|
||||||
<div class="test <?php echo $php_ok ? 'pass' : 'fail'; ?>">
|
|
||||||
<h3>PHP Version</h3>
|
|
||||||
<p class="status"><?php echo $php_ok ? '✓ PASS' : '✗ FAIL'; ?></p>
|
|
||||||
<p>Version: <strong><?php echo $php_version; ?></strong></p>
|
|
||||||
<?php if (!$php_ok): ?>
|
|
||||||
<p>Minimum required: PHP 7.4.0</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Test 2: Server API
|
|
||||||
$server_api = php_sapi_name();
|
|
||||||
$is_fpm = strpos($server_api, 'fpm') !== false;
|
|
||||||
?>
|
|
||||||
<div class="test pass">
|
|
||||||
<h3>Server API</h3>
|
|
||||||
<p class="status">ℹ INFO</p>
|
|
||||||
<p>SAPI: <strong><?php echo $server_api; ?></strong></p>
|
|
||||||
<p>Type: <strong><?php echo $is_fpm ? 'PHP-FPM' : 'Other'; ?></strong></p>
|
|
||||||
<?php if ($is_fpm): ?>
|
|
||||||
<p class="info">✓ Running under PHP-FPM. Note: php_flag/php_value directives in .htaccess won't work. Configure PHP settings in php.ini or FPM pool config.</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Test 3: Storage Directory
|
|
||||||
$storage_exists = file_exists(STORAGE_DIR);
|
|
||||||
$storage_writable = $storage_exists && is_writable(STORAGE_DIR);
|
|
||||||
$storage_ok = $storage_exists && $storage_writable;
|
|
||||||
?>
|
|
||||||
<div class="test <?php echo $storage_ok ? 'pass' : 'fail'; ?>">
|
|
||||||
<h3>Storage Directory</h3>
|
|
||||||
<p class="status"><?php echo $storage_ok ? '✓ PASS' : '✗ FAIL'; ?></p>
|
|
||||||
<p>Path: <code><?php echo STORAGE_DIR; ?></code></p>
|
|
||||||
<p>Exists: <?php echo $storage_exists ? 'Yes' : 'No'; ?></p>
|
|
||||||
<p>Writable: <?php echo $storage_writable ? 'Yes' : 'No'; ?></p>
|
|
||||||
<?php if (!$storage_ok): ?>
|
|
||||||
<p><strong>Fix:</strong> Run: <code>mkdir -p <?php echo STORAGE_DIR; ?> && chmod 755 <?php echo STORAGE_DIR; ?></code></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Test 4: Required PHP Extensions
|
|
||||||
$required_extensions = ['json', 'mbstring'];
|
|
||||||
$missing_extensions = [];
|
|
||||||
foreach ($required_extensions as $ext) {
|
|
||||||
if (!extension_loaded($ext)) {
|
|
||||||
$missing_extensions[] = $ext;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$extensions_ok = empty($missing_extensions);
|
|
||||||
?>
|
|
||||||
<div class="test <?php echo $extensions_ok ? 'pass' : 'fail'; ?>">
|
|
||||||
<h3>PHP Extensions</h3>
|
|
||||||
<p class="status"><?php echo $extensions_ok ? '✓ PASS' : '✗ FAIL'; ?></p>
|
|
||||||
<?php if ($extensions_ok): ?>
|
|
||||||
<p>All required extensions loaded</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<p>Missing extensions: <strong><?php echo implode(', ', $missing_extensions); ?></strong></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Test 5: Server Info
|
|
||||||
?>
|
|
||||||
<div class="test pass">
|
|
||||||
<h3>Server Information</h3>
|
|
||||||
<p class="status">ℹ INFO</p>
|
|
||||||
<p>Server: <strong><?php echo $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown'; ?></strong></p>
|
|
||||||
<p>Document Root: <code><?php echo $_SERVER['DOCUMENT_ROOT'] ?? 'Unknown'; ?></code></p>
|
|
||||||
<p>Script Path: <code><?php echo __DIR__; ?></code></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Test 6: API Endpoint Test
|
|
||||||
?>
|
|
||||||
<div class="test pass">
|
|
||||||
<h3>API Endpoint Test</h3>
|
|
||||||
<p class="status">ℹ INFO</p>
|
|
||||||
<p>Info endpoint: <a href="server.php?action=info" target="_blank">server.php?action=info</a></p>
|
|
||||||
<p>Test this endpoint to verify the API is accessible</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
// Test 7: Server-Sent Events Test
|
|
||||||
?>
|
|
||||||
<div class="test pass">
|
|
||||||
<h3>Server-Sent Events (SSE) Test</h3>
|
|
||||||
<p class="status">Testing...</p>
|
|
||||||
<div id="sse-status">Connecting to SSE endpoint...</div>
|
|
||||||
<div id="sse-log" style="background: #f5f5f5; padding: 10px; margin-top: 10px; border-radius: 3px; max-height: 200px; overflow-y: auto;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<h3>Common Issues & Solutions</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Disconnected Status:</strong> Check that server.php is accessible and SSE test above succeeds</li>
|
|
||||||
<li><strong>403 Forbidden:</strong> Check file permissions (644 for files, 755 for directories)</li>
|
|
||||||
<li><strong>500 Internal Server Error:</strong> Check PHP error logs for details</li>
|
|
||||||
<li><strong>CORS Errors:</strong> Ensure ENABLE_CORS is true in config.php</li>
|
|
||||||
<li><strong>PHP-FPM Issues:</strong> Check that PHP-FPM is running and configured correctly</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Test SSE connection
|
|
||||||
const sseLog = document.getElementById('sse-log');
|
|
||||||
const sseStatus = document.getElementById('sse-status');
|
|
||||||
|
|
||||||
function log(message) {
|
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
sseLog.innerHTML += `[${time}] ${message}<br>`;
|
|
||||||
sseLog.scrollTop = sseLog.scrollHeight;
|
|
||||||
console.log(`[SSE Test] ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create test room
|
|
||||||
const testRoom = 'diagnostic-test-' + Date.now();
|
|
||||||
const url = `server.php?action=stream&room=${encodeURIComponent(testRoom)}`;
|
|
||||||
|
|
||||||
console.group('SSE Connection Test');
|
|
||||||
console.log('Test Room:', testRoom);
|
|
||||||
console.log('Full URL:', url);
|
|
||||||
console.log('Current Location:', window.location.href);
|
|
||||||
console.log('Base URL:', window.location.origin + window.location.pathname.replace(/\/[^\/]*$/, '/'));
|
|
||||||
|
|
||||||
log(`Attempting to connect to: ${url}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const eventSource = new EventSource(url);
|
|
||||||
|
|
||||||
console.log('EventSource object created:', eventSource);
|
|
||||||
console.log('EventSource readyState:', eventSource.readyState, '(0=CONNECTING, 1=OPEN, 2=CLOSED)');
|
|
||||||
console.log('EventSource url:', eventSource.url);
|
|
||||||
|
|
||||||
eventSource.onopen = (event) => {
|
|
||||||
console.log('EventSource.onopen fired:', event);
|
|
||||||
console.log('ReadyState after open:', eventSource.readyState);
|
|
||||||
sseStatus.innerHTML = '<span style="color: #4CAF50; font-weight: bold;">✓ SSE Connected</span>';
|
|
||||||
log('✓ Connection established');
|
|
||||||
log('✓ Server-Sent Events are working');
|
|
||||||
|
|
||||||
// Close after successful connection
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('Closing EventSource after successful test');
|
|
||||||
eventSource.close();
|
|
||||||
log('Connection test complete - closing');
|
|
||||||
console.groupEnd();
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
console.log('EventSource.onmessage:', event);
|
|
||||||
console.log('Message data:', event.data);
|
|
||||||
console.log('Event ID:', event.lastEventId);
|
|
||||||
log('Received message: ' + event.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
|
||||||
console.error('EventSource.onerror fired:', error);
|
|
||||||
console.error('ReadyState on error:', eventSource.readyState);
|
|
||||||
console.error('EventSource URL:', eventSource.url);
|
|
||||||
|
|
||||||
// Try to get more info from the error
|
|
||||||
console.error('Error type:', error.type);
|
|
||||||
console.error('Error target:', error.target);
|
|
||||||
console.error('Error currentTarget:', error.currentTarget);
|
|
||||||
|
|
||||||
// Check if it's a network error
|
|
||||||
if (eventSource.readyState === EventSource.CLOSED) {
|
|
||||||
console.error('Connection is CLOSED - this usually indicates a network error or server rejection');
|
|
||||||
} else if (eventSource.readyState === EventSource.CONNECTING) {
|
|
||||||
console.warn('Still CONNECTING - may be retrying');
|
|
||||||
}
|
|
||||||
|
|
||||||
sseStatus.innerHTML = '<span style="color: #f44336; font-weight: bold;">✗ SSE Failed</span>';
|
|
||||||
log('✗ Connection error occurred');
|
|
||||||
log('ReadyState: ' + eventSource.readyState + ' (0=CONNECTING, 1=OPEN, 2=CLOSED)');
|
|
||||||
log('Check browser console for detailed error information');
|
|
||||||
|
|
||||||
eventSource.close();
|
|
||||||
console.groupEnd();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Monitor readyState changes
|
|
||||||
let lastReadyState = eventSource.readyState;
|
|
||||||
const stateMonitor = setInterval(() => {
|
|
||||||
if (eventSource.readyState !== lastReadyState) {
|
|
||||||
console.log('ReadyState changed:', lastReadyState, '→', eventSource.readyState);
|
|
||||||
lastReadyState = eventSource.readyState;
|
|
||||||
}
|
|
||||||
if (eventSource.readyState === EventSource.CLOSED) {
|
|
||||||
clearInterval(stateMonitor);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Exception creating EventSource:', error);
|
|
||||||
console.error('Error stack:', error.stack);
|
|
||||||
sseStatus.innerHTML = '<span style="color: #f44336; font-weight: bold;">✗ SSE Error</span>';
|
|
||||||
log('✗ Exception: ' + error.message);
|
|
||||||
console.groupEnd();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Multi-User Transcription Display (Polling)</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;
|
|
||||||
/* Color set dynamically via inline style */
|
|
||||||
}
|
|
||||||
.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; }
|
|
||||||
#status.polling { color: #FFC107; }
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="status" class="polling">🟡 Polling...</div>
|
|
||||||
<div id="transcriptions"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Get URL parameters
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const room = urlParams.get('room') || 'default';
|
|
||||||
const fadeAfter = parseInt(urlParams.get('fade') || '10');
|
|
||||||
const showTimestamps = urlParams.get('timestamps') !== 'false';
|
|
||||||
const pollInterval = parseInt(urlParams.get('poll') || '1000'); // Poll every 1 second
|
|
||||||
|
|
||||||
const container = document.getElementById('transcriptions');
|
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
const userColors = new Map(); // Map user names to HSL colors
|
|
||||||
let colorIndex = 0;
|
|
||||||
let lastCount = 0; // Track how many transcriptions we've seen
|
|
||||||
let consecutiveErrors = 0;
|
|
||||||
let isPolling = false;
|
|
||||||
|
|
||||||
// Generate distinct color for each user using golden ratio
|
|
||||||
function getUserColor(userName) {
|
|
||||||
if (!userColors.has(userName)) {
|
|
||||||
// Use golden ratio for evenly distributed hues
|
|
||||||
const goldenRatio = 0.618033988749895;
|
|
||||||
const hue = (colorIndex * goldenRatio * 360) % 360;
|
|
||||||
// High saturation and medium lightness for vibrant, readable colors
|
|
||||||
const color = `hsl(${hue}, 85%, 65%)`;
|
|
||||||
userColors.set(userName, color);
|
|
||||||
colorIndex++;
|
|
||||||
}
|
|
||||||
return userColors.get(userName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTranscription(data) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'transcription';
|
|
||||||
|
|
||||||
// Get user color (generates new color if first time)
|
|
||||||
const userColor = getUserColor(data.user_name);
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
if (showTimestamps && data.timestamp) {
|
|
||||||
html += `<span class="timestamp">[${data.timestamp}]</span>`;
|
|
||||||
}
|
|
||||||
if (data.user_name) {
|
|
||||||
html += `<span class="user" style="color: ${userColor}">${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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for new transcriptions
|
|
||||||
async function poll() {
|
|
||||||
if (isPolling) return; // Prevent concurrent polls
|
|
||||||
isPolling = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `server.php?action=list&room=${encodeURIComponent(room)}&t=${Date.now()}`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
cache: 'no-cache',
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'no-cache'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.transcriptions) {
|
|
||||||
const currentCount = data.transcriptions.length;
|
|
||||||
|
|
||||||
// Only show new transcriptions
|
|
||||||
if (currentCount > lastCount) {
|
|
||||||
const newTranscriptions = data.transcriptions.slice(lastCount);
|
|
||||||
newTranscriptions.forEach(addTranscription);
|
|
||||||
lastCount = currentCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
statusEl.textContent = `🟢 Connected (${currentCount})`;
|
|
||||||
statusEl.className = 'connected';
|
|
||||||
consecutiveErrors = 0;
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = '🟡 Waiting for data...';
|
|
||||||
statusEl.className = 'polling';
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Polling error:', error);
|
|
||||||
consecutiveErrors++;
|
|
||||||
|
|
||||||
if (consecutiveErrors < 5) {
|
|
||||||
statusEl.textContent = `🟡 Retrying... (${consecutiveErrors})`;
|
|
||||||
statusEl.className = 'polling';
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = '🔴 Connection failed';
|
|
||||||
statusEl.className = 'disconnected';
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isPolling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load initial transcriptions
|
|
||||||
async function loadInitial() {
|
|
||||||
try {
|
|
||||||
const url = `server.php?action=list&room=${encodeURIComponent(room)}&t=${Date.now()}`;
|
|
||||||
const response = await fetch(url, { cache: 'no-cache' });
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.transcriptions && data.transcriptions.length > 0) {
|
|
||||||
// Show last 20 transcriptions
|
|
||||||
const recent = data.transcriptions.slice(-20);
|
|
||||||
recent.forEach(addTranscription);
|
|
||||||
lastCount = data.transcriptions.length;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading initial transcriptions:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start polling
|
|
||||||
async function start() {
|
|
||||||
statusEl.textContent = '🟡 Loading...';
|
|
||||||
statusEl.className = 'polling';
|
|
||||||
|
|
||||||
await loadInitial();
|
|
||||||
|
|
||||||
// Start regular polling
|
|
||||||
setInterval(poll, pollInterval);
|
|
||||||
poll(); // First poll immediately
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start when page loads
|
|
||||||
start();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
<!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;
|
|
||||||
/* Color set dynamically via inline style */
|
|
||||||
}
|
|
||||||
.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 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 HSL colors
|
|
||||||
let colorIndex = 0;
|
|
||||||
|
|
||||||
// Generate distinct color for each user using golden ratio
|
|
||||||
function getUserColor(userName) {
|
|
||||||
if (!userColors.has(userName)) {
|
|
||||||
// Use golden ratio for evenly distributed hues
|
|
||||||
const goldenRatio = 0.618033988749895;
|
|
||||||
const hue = (colorIndex * goldenRatio * 360) % 360;
|
|
||||||
// High saturation and medium lightness for vibrant, readable colors
|
|
||||||
const color = `hsl(${hue}, 85%, 65%)`;
|
|
||||||
userColors.set(userName, color);
|
|
||||||
colorIndex++;
|
|
||||||
}
|
|
||||||
return userColors.get(userName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to Server-Sent Events
|
|
||||||
function connect() {
|
|
||||||
const url = `server.php?action=stream&room=${encodeURIComponent(room)}`;
|
|
||||||
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';
|
|
||||||
|
|
||||||
// Get user color (generates new color if first time)
|
|
||||||
const userColor = getUserColor(data.user_name);
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
if (showTimestamps && data.timestamp) {
|
|
||||||
html += `<span class="timestamp">[${data.timestamp}]</span>`;
|
|
||||||
}
|
|
||||||
if (data.user_name) {
|
|
||||||
html += `<span class="user" style="color: ${userColor}">${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)}`;
|
|
||||||
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>
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Multi-User Transcription Server</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 40px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
font-size: 2.5em;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.header p {
|
|
||||||
font-size: 1.2em;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 40px;
|
|
||||||
}
|
|
||||||
.section {
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
.section h2 {
|
|
||||||
color: #667eea;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 1.8em;
|
|
||||||
}
|
|
||||||
.section p {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.generator {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.button {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 15px 30px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
.urls {
|
|
||||||
display: none;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
.urls.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.url-box {
|
|
||||||
background: white;
|
|
||||||
border: 2px solid #667eea;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.url-box h3 {
|
|
||||||
color: #667eea;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 1.3em;
|
|
||||||
}
|
|
||||||
.url-box .label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.url-box .value {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
word-break: break-all;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.url-box .value:hover {
|
|
||||||
background: #e9ecef;
|
|
||||||
}
|
|
||||||
.copy-btn {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9em;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
.copy-btn:hover {
|
|
||||||
background: #218838;
|
|
||||||
}
|
|
||||||
.copy-btn.copied {
|
|
||||||
background: #5cb85c;
|
|
||||||
}
|
|
||||||
.steps {
|
|
||||||
counter-reset: step;
|
|
||||||
}
|
|
||||||
.step {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-left: 50px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.step::before {
|
|
||||||
counter-increment: step;
|
|
||||||
content: counter(step);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
width: 35px;
|
|
||||||
height: 35px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.features {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.feature {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border-left: 4px solid #667eea;
|
|
||||||
}
|
|
||||||
.feature h3 {
|
|
||||||
color: #667eea;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.note {
|
|
||||||
background: #fff3cd;
|
|
||||||
border-left: 4px solid #ffc107;
|
|
||||||
padding: 15px;
|
|
||||||
margin-top: 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.note strong {
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🎙️ Multi-User Transcription Server</h1>
|
|
||||||
<p>Merge captions from multiple streamers into a single OBS display</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<!-- What is this -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>What is this?</h2>
|
|
||||||
<p>This server allows multiple streamers using the Local Transcription app to merge their real-time captions into a single stream. Perfect for collaborative streams, podcasts, or gaming sessions with multiple commentators.</p>
|
|
||||||
|
|
||||||
<div class="features">
|
|
||||||
<div class="feature">
|
|
||||||
<h3>🔒 Secure</h3>
|
|
||||||
<p>Room-based isolation with passphrase authentication</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<h3>🎨 Colorful</h3>
|
|
||||||
<p>Each user gets a unique color (supports 20+ users)</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<h3>⚡ Real-time</h3>
|
|
||||||
<p>Low-latency streaming via Server-Sent Events</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<h3>🌐 Universal</h3>
|
|
||||||
<p>Works on any standard PHP hosting</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Generate URLs -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Get Started</h2>
|
|
||||||
<p>Click the button below to generate a unique room with random credentials:</p>
|
|
||||||
|
|
||||||
<div class="generator">
|
|
||||||
<button class="button" onclick="generateUrls()">🎲 Generate New Room</button>
|
|
||||||
|
|
||||||
<div class="urls" id="urls">
|
|
||||||
<div class="url-box">
|
|
||||||
<h3>📱 For Desktop App Users</h3>
|
|
||||||
<div class="label">Room Name:</div>
|
|
||||||
<div class="value" id="room" onclick="copyToClipboard('room')">-</div>
|
|
||||||
<button class="copy-btn" onclick="copyToClipboard('room')">Copy Room</button>
|
|
||||||
|
|
||||||
<div class="label" style="margin-top: 15px;">Passphrase:</div>
|
|
||||||
<div class="value" id="passphrase" onclick="copyToClipboard('passphrase')">-</div>
|
|
||||||
<button class="copy-btn" onclick="copyToClipboard('passphrase')">Copy Passphrase</button>
|
|
||||||
|
|
||||||
<div class="label" style="margin-top: 15px;">Server URL:</div>
|
|
||||||
<div class="value" id="serverUrl" onclick="copyToClipboard('serverUrl')">-</div>
|
|
||||||
<button class="copy-btn" onclick="copyToClipboard('serverUrl')">Copy URL</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="url-box">
|
|
||||||
<h3>📺 For OBS Browser Source</h3>
|
|
||||||
<div class="label">Display URL:</div>
|
|
||||||
<div class="value" id="displayUrl" onclick="copyToClipboard('displayUrl')">-</div>
|
|
||||||
<button class="copy-btn" onclick="copyToClipboard('displayUrl')">Copy Display URL</button>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<strong>Note:</strong> The display URL does not contain the passphrase for security. Only users with the passphrase can send transcriptions.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- How to use -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>How to Use</h2>
|
|
||||||
<div class="steps">
|
|
||||||
<div class="step">
|
|
||||||
<h3>Generate Room Credentials</h3>
|
|
||||||
<p>Click "Generate New Room" above to create a unique room with a random name and passphrase. Share these with your streaming team.</p>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<h3>Configure Desktop App</h3>
|
|
||||||
<p>In the Local Transcription app, go to Settings → Server Sync and enter:</p>
|
|
||||||
<ul style="margin-left: 20px; margin-top: 10px;">
|
|
||||||
<li>Enable Server Sync: ✓</li>
|
|
||||||
<li>Server URL: (from above)</li>
|
|
||||||
<li>Room Name: (from above)</li>
|
|
||||||
<li>Passphrase: (from above)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<h3>Add to OBS</h3>
|
|
||||||
<p>In OBS, add a Browser source and paste the Display URL. Set width to 1920 and height to your preference (e.g., 200-400px).</p>
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<h3>Start Streaming!</h3>
|
|
||||||
<p>All team members start transcription in their apps. Captions from everyone appear merged in OBS with different colors per person.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FAQ -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Frequently Asked Questions</h2>
|
|
||||||
|
|
||||||
<h3 style="color: #667eea; margin-top: 20px;">How many users can join one room?</h3>
|
|
||||||
<p>Technically unlimited, but we've tested up to 20 users successfully. Each user gets a unique color.</p>
|
|
||||||
|
|
||||||
<h3 style="color: #667eea; margin-top: 20px;">Is my passphrase secure?</h3>
|
|
||||||
<p>Yes! Passphrases are hashed using PHP's password_hash() function. They're never stored in plain text.</p>
|
|
||||||
|
|
||||||
<h3 style="color: #667eea; margin-top: 20px;">How long does a room stay active?</h3>
|
|
||||||
<p>Rooms are automatically cleaned up after 2 hours of inactivity to save server resources.</p>
|
|
||||||
|
|
||||||
<h3 style="color: #667eea; margin-top: 20px;">Can I use custom room names?</h3>
|
|
||||||
<p>Yes! You can use any room name you want instead of the randomly generated one. Just make sure all team members use the exact same name.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function generateUrls() {
|
|
||||||
// Generate random room name
|
|
||||||
const adjectives = ['swift', 'bright', 'cosmic', 'electric', 'turbo', 'mega', 'ultra', 'super', 'hyper', 'alpha'];
|
|
||||||
const nouns = ['phoenix', 'dragon', 'tiger', 'falcon', 'comet', 'storm', 'blaze', 'thunder', 'frost', 'nebula'];
|
|
||||||
const randomNum = Math.floor(Math.random() * 10000);
|
|
||||||
const room = `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${nouns[Math.floor(Math.random() * nouns.length)]}-${randomNum}`;
|
|
||||||
|
|
||||||
// Generate random passphrase (URL-safe)
|
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
||||||
let passphrase = '';
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
passphrase += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current URL base
|
|
||||||
const baseUrl = window.location.href.replace(/index\.html$/, '');
|
|
||||||
|
|
||||||
// Generate URLs
|
|
||||||
const serverUrl = `${baseUrl}server.php`;
|
|
||||||
const displayUrl = `${baseUrl}display.php?room=${encodeURIComponent(room)}&fade=10×tamps=true`;
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
document.getElementById('room').textContent = room;
|
|
||||||
document.getElementById('passphrase').textContent = passphrase;
|
|
||||||
document.getElementById('serverUrl').textContent = serverUrl;
|
|
||||||
document.getElementById('displayUrl').textContent = displayUrl;
|
|
||||||
|
|
||||||
// Show URLs section
|
|
||||||
document.getElementById('urls').classList.add('active');
|
|
||||||
|
|
||||||
// Scroll to URLs
|
|
||||||
document.getElementById('urls').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(elementId) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
const text = element.textContent;
|
|
||||||
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
|
||||||
// Find the copy button associated with this element
|
|
||||||
const copyBtn = element.nextElementSibling;
|
|
||||||
if (copyBtn && copyBtn.classList.contains('copy-btn')) {
|
|
||||||
const originalText = copyBtn.textContent;
|
|
||||||
copyBtn.textContent = '✓ Copied!';
|
|
||||||
copyBtn.classList.add('copied');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
copyBtn.textContent = originalText;
|
|
||||||
copyBtn.classList.remove('copied');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Multi-User Transcription Server - API Endpoint
|
|
||||||
*
|
|
||||||
* Endpoints:
|
|
||||||
* - POST /server.php?action=send - Send a transcription
|
|
||||||
* - GET /server.php?action=stream - Stream transcriptions via SSE
|
|
||||||
* - GET /server.php?action=list - List recent transcriptions
|
|
||||||
*/
|
|
||||||
|
|
||||||
require_once 'config.php';
|
|
||||||
|
|
||||||
// Get action
|
|
||||||
$action = $_GET['action'] ?? 'info';
|
|
||||||
|
|
||||||
// Route to appropriate handler
|
|
||||||
switch ($action) {
|
|
||||||
case 'send':
|
|
||||||
handleSend();
|
|
||||||
break;
|
|
||||||
case 'stream':
|
|
||||||
handleStream();
|
|
||||||
break;
|
|
||||||
case 'list':
|
|
||||||
handleList();
|
|
||||||
break;
|
|
||||||
case 'info':
|
|
||||||
handleInfo();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
sendError('Invalid action', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle sending a transcription
|
|
||||||
*/
|
|
||||||
function handleSend() {
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
sendError('Method not allowed', 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get JSON body
|
|
||||||
$input = file_get_contents('php://input');
|
|
||||||
$data = json_decode($input, true);
|
|
||||||
|
|
||||||
if (!$data) {
|
|
||||||
sendError('Invalid JSON', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
$required = ['room', 'passphrase', 'user_name', 'text'];
|
|
||||||
foreach ($required as $field) {
|
|
||||||
if (empty($data[$field])) {
|
|
||||||
sendError("Missing required field: $field", 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify passphrase
|
|
||||||
$room = sanitize($data['room']);
|
|
||||||
$passphrase = $data['passphrase'];
|
|
||||||
|
|
||||||
if (!verifyPassphrase($room, $passphrase)) {
|
|
||||||
sendError('Invalid passphrase', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create transcription entry
|
|
||||||
$transcription = [
|
|
||||||
'user_name' => sanitize($data['user_name']),
|
|
||||||
'text' => sanitize($data['text']),
|
|
||||||
'timestamp' => $data['timestamp'] ?? date('H:i:s'),
|
|
||||||
'created_at' => time()
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add to room
|
|
||||||
addTranscription($room, $transcription);
|
|
||||||
|
|
||||||
// Cleanup old sessions
|
|
||||||
cleanupOldSessions();
|
|
||||||
|
|
||||||
// Success response
|
|
||||||
sendJson(['status' => 'ok', 'message' => 'Transcription added']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle streaming transcriptions via Server-Sent Events
|
|
||||||
* Note: Passphrase is optional for streaming (read-only access)
|
|
||||||
*/
|
|
||||||
function handleStream() {
|
|
||||||
// Get parameters
|
|
||||||
$room = sanitize($_GET['room'] ?? '');
|
|
||||||
|
|
||||||
if (empty($room)) {
|
|
||||||
sendError('Missing room name', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set SSE headers
|
|
||||||
header('Content-Type: text/event-stream');
|
|
||||||
header('Cache-Control: no-cache');
|
|
||||||
header('X-Accel-Buffering: no'); // Disable nginx buffering
|
|
||||||
|
|
||||||
// Passphrase is optional for streaming (read-only)
|
|
||||||
// If room doesn't exist yet, we'll keep the connection open and wait for it
|
|
||||||
|
|
||||||
// Track last known count
|
|
||||||
$lastCount = 0;
|
|
||||||
|
|
||||||
// Stream loop
|
|
||||||
while (true) {
|
|
||||||
$transcriptions = getTranscriptions($room);
|
|
||||||
$currentCount = count($transcriptions);
|
|
||||||
|
|
||||||
// If new transcriptions, send them
|
|
||||||
if ($currentCount > $lastCount) {
|
|
||||||
$newTranscriptions = array_slice($transcriptions, $lastCount);
|
|
||||||
foreach ($newTranscriptions as $trans) {
|
|
||||||
echo "data: " . json_encode($trans) . "\n\n";
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
$lastCount = $currentCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send keepalive comment every 1 second (keeps SSE connection alive)
|
|
||||||
echo ": keepalive\n\n";
|
|
||||||
flush();
|
|
||||||
|
|
||||||
// Check if client disconnected
|
|
||||||
if (connection_aborted()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before next check
|
|
||||||
sleep(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle listing recent transcriptions
|
|
||||||
* Note: Passphrase is optional for listing (read-only access)
|
|
||||||
*/
|
|
||||||
function handleList() {
|
|
||||||
$room = sanitize($_GET['room'] ?? '');
|
|
||||||
|
|
||||||
if (empty($room)) {
|
|
||||||
sendError('Missing room name', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Passphrase is optional for read-only access
|
|
||||||
// If room doesn't exist, return empty array
|
|
||||||
$transcriptions = getTranscriptions($room);
|
|
||||||
sendJson(['transcriptions' => $transcriptions]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle info request
|
|
||||||
*/
|
|
||||||
function handleInfo() {
|
|
||||||
sendJson([
|
|
||||||
'service' => 'Local Transcription Multi-User Server',
|
|
||||||
'version' => '1.0.0',
|
|
||||||
'endpoints' => [
|
|
||||||
'POST ?action=send' => 'Send a transcription',
|
|
||||||
'GET ?action=stream' => 'Stream transcriptions (SSE)',
|
|
||||||
'GET ?action=list' => 'List recent transcriptions'
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify passphrase for a room
|
|
||||||
*/
|
|
||||||
function verifyPassphrase($room, $passphrase) {
|
|
||||||
$file = getRoomFile($room);
|
|
||||||
|
|
||||||
// If room doesn't exist, create it with this passphrase
|
|
||||||
if (!file_exists($file)) {
|
|
||||||
$roomData = [
|
|
||||||
'passphrase_hash' => password_hash($passphrase, PASSWORD_DEFAULT),
|
|
||||||
'created_at' => time(),
|
|
||||||
'transcriptions' => []
|
|
||||||
];
|
|
||||||
file_put_contents($file, json_encode($roomData));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify passphrase
|
|
||||||
$roomData = json_decode(file_get_contents($file), true);
|
|
||||||
return password_verify($passphrase, $roomData['passphrase_hash']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add transcription to room
|
|
||||||
*/
|
|
||||||
function addTranscription($room, $transcription) {
|
|
||||||
$file = getRoomFile($room);
|
|
||||||
$roomData = json_decode(file_get_contents($file), true);
|
|
||||||
|
|
||||||
// Add transcription
|
|
||||||
$roomData['transcriptions'][] = $transcription;
|
|
||||||
|
|
||||||
// Limit to max transcriptions
|
|
||||||
if (count($roomData['transcriptions']) > MAX_TRANSCRIPTIONS_PER_ROOM) {
|
|
||||||
$roomData['transcriptions'] = array_slice(
|
|
||||||
$roomData['transcriptions'],
|
|
||||||
-MAX_TRANSCRIPTIONS_PER_ROOM
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last activity
|
|
||||||
$roomData['last_activity'] = time();
|
|
||||||
|
|
||||||
// Save
|
|
||||||
file_put_contents($file, json_encode($roomData));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get transcriptions for a room
|
|
||||||
*/
|
|
||||||
function getTranscriptions($room) {
|
|
||||||
$file = getRoomFile($room);
|
|
||||||
if (!file_exists($file)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$roomData = json_decode(file_get_contents($file), true);
|
|
||||||
return $roomData['transcriptions'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get room data file path
|
|
||||||
*/
|
|
||||||
function getRoomFile($room) {
|
|
||||||
return STORAGE_DIR . '/room_' . md5($room) . '.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if room exists
|
|
||||||
*/
|
|
||||||
function roomExists($room) {
|
|
||||||
return file_exists(getRoomFile($room));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup old sessions
|
|
||||||
*/
|
|
||||||
function cleanupOldSessions() {
|
|
||||||
$files = glob(STORAGE_DIR . '/room_*.json');
|
|
||||||
$now = time();
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$data = json_decode(file_get_contents($file), true);
|
|
||||||
$lastActivity = $data['last_activity'] ?? $data['created_at'];
|
|
||||||
|
|
||||||
if ($now - $lastActivity > CLEANUP_THRESHOLD) {
|
|
||||||
unlink($file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize input
|
|
||||||
*/
|
|
||||||
function sanitize($input) {
|
|
||||||
return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send JSON response
|
|
||||||
*/
|
|
||||||
function sendJson($data, $code = 200) {
|
|
||||||
http_response_code($code);
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode($data);
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send error response
|
|
||||||
*/
|
|
||||||
function sendError($message, $code = 400) {
|
|
||||||
sendJson(['error' => $message], $code);
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
|||||||
class TranscriptionWebServer:
|
class TranscriptionWebServer:
|
||||||
"""Web server for displaying transcriptions."""
|
"""Web server for displaying transcriptions."""
|
||||||
|
|
||||||
def __init__(self, host: str = "127.0.0.1", port: int = 8080, show_timestamps: bool = True, fade_after_seconds: int = 10):
|
def __init__(self, host: str = "127.0.0.1", port: int = 8080, show_timestamps: bool = True, fade_after_seconds: int = 10, max_lines: int = 50, font_family: str = "Arial", font_size: int = 16):
|
||||||
"""
|
"""
|
||||||
Initialize web server.
|
Initialize web server.
|
||||||
|
|
||||||
@@ -20,11 +20,17 @@ class TranscriptionWebServer:
|
|||||||
port: Server port
|
port: Server port
|
||||||
show_timestamps: Whether to show timestamps in transcriptions
|
show_timestamps: Whether to show timestamps in transcriptions
|
||||||
fade_after_seconds: Time in seconds before transcriptions fade out (0 = never fade)
|
fade_after_seconds: Time in seconds before transcriptions fade out (0 = never fade)
|
||||||
|
max_lines: Maximum number of lines to display at once
|
||||||
|
font_family: Font family for display
|
||||||
|
font_size: Font size in pixels
|
||||||
"""
|
"""
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.show_timestamps = show_timestamps
|
self.show_timestamps = show_timestamps
|
||||||
self.fade_after_seconds = fade_after_seconds
|
self.fade_after_seconds = fade_after_seconds
|
||||||
|
self.max_lines = max_lines
|
||||||
|
self.font_family = font_family
|
||||||
|
self.font_size = font_size
|
||||||
self.app = FastAPI()
|
self.app = FastAPI()
|
||||||
self.active_connections: List[WebSocket] = []
|
self.active_connections: List[WebSocket] = []
|
||||||
self.transcriptions = [] # Store recent transcriptions
|
self.transcriptions = [] # Store recent transcriptions
|
||||||
@@ -70,12 +76,13 @@ class TranscriptionWebServer:
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-family: Arial, sans-serif;
|
font-family: {self.font_family}, sans-serif;
|
||||||
|
font-size: {self.font_size}px;
|
||||||
color: white;
|
color: white;
|
||||||
|
overflow: hidden;
|
||||||
}}
|
}}
|
||||||
#transcriptions {{
|
#transcriptions {{
|
||||||
max-height: 100vh;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
|
||||||
}}
|
}}
|
||||||
.transcription {{
|
.transcription {{
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
@@ -120,6 +127,7 @@ class TranscriptionWebServer:
|
|||||||
const container = document.getElementById('transcriptions');
|
const container = document.getElementById('transcriptions');
|
||||||
const ws = new WebSocket(`ws://${{window.location.host}}/ws`);
|
const ws = new WebSocket(`ws://${{window.location.host}}/ws`);
|
||||||
const fadeAfterSeconds = {self.fade_after_seconds};
|
const fadeAfterSeconds = {self.fade_after_seconds};
|
||||||
|
const maxLines = {self.max_lines};
|
||||||
|
|
||||||
ws.onmessage = (event) => {{
|
ws.onmessage = (event) => {{
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
@@ -154,9 +162,6 @@ class TranscriptionWebServer:
|
|||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
|
|
||||||
// Set up fade-out if enabled
|
// Set up fade-out if enabled
|
||||||
if (fadeAfterSeconds > 0) {{
|
if (fadeAfterSeconds > 0) {{
|
||||||
setTimeout(() => {{
|
setTimeout(() => {{
|
||||||
@@ -172,8 +177,8 @@ class TranscriptionWebServer:
|
|||||||
}}, fadeAfterSeconds * 1000);
|
}}, fadeAfterSeconds * 1000);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Limit to 50 transcriptions (fallback)
|
// Enforce max lines limit
|
||||||
while (container.children.length > 50) {{
|
while (container.children.length > maxLines) {{
|
||||||
container.removeChild(container.firstChild);
|
container.removeChild(container.firstChild);
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user