A real-time, remote-controlled multi-display dashboard for endurance racing events like the Nurburgring 24h.
Run it on an Ubuntu thin client, control it from your tablet, and show YouTube onboard streams and live timing on big screens in the paddock.
- Full-screen kiosk view designed for large monitors
- Renders YouTube streams and web pages as iframes in a configurable grid
- Unlimited displays — start with one, add more from the admin panel. Each monitor loads
/?display=N - Updates instantly via WebSocket when the layout changes — no manual refresh needed
- Splash screen on boot with automatic fade to layout
- Idle screen (logo) when no content is assigned
- Screen wake lock prevents the display from sleeping
- Auto-hides cursor after 2 seconds
- Tablet-friendly control interface — dark mode, touch-optimized, works on any device in the same network
- Source Management — add YouTube streams (just paste the video ID) or any web page URL. Edit or delete sources on the fly
- Dynamic Displays — add or remove displays from the admin UI. Each display shows its URL (
/?display=N) for easy kiosk setup - Layout Presets — one-click arrangements:
- Full — single source fills the entire screen
- 2H — two sources side by side (50/50)
- 2V — two sources stacked top/bottom
- 2x2 — four equal quadrants
- 2+1 — two on top, one full-width below
- 1+2 — one full-width on top, two below
- Race — big main video + two small below + timing sidebar
- Focus — one large left, two small stacked on the right
- 3Col — three equal columns
- Auto Layout — automatically picks the best preset based on the number of sources
- Multiple Layouts — create, save, and switch between different layout setups (e.g. "Qualifying", "Race Day", "Night Setup")
- Go Live — one button push activates the layout on all connected displays in real-time
- Configurable Display Resolution — set the exact width and height per display. The admin grid preview matches the real aspect ratio
- Bluetooth Speaker Management — scan, pair, connect, and combine multiple Bluetooth speakers for audio output (see below)
- Keyboard shortcut —
Ctrl+Sto save the current layout - Responsive — works on mobile phones and tablets
- Manage Bluetooth speakers directly from the admin panel sidebar
- Scan for nearby Bluetooth devices
- Pair & Connect speakers with one tap
- Combine multiple speakers — routes audio to all connected BT speakers simultaneously using PipeWire/PulseAudio
module-combine-sink - Graceful degradation — if Bluetooth isn't available (e.g. running in Docker without BT access), the section shows a friendly "not available" message instead of breaking
- All data (sources, layouts, theme, display config) is stored in a single JSON file (
data/db.json) - Survives reboots and power outages — no database server needed
- The server announces itself as
rrv.localon the local network via mDNS - No IP address needed — just open
http://rrv.local/adminfrom any device in the same network - Custom hostname via
MDNS_NAME=mynameenvironment variable (becomesmyname.local)
- Auto-detects all connected monitors via
xrandr - Opens a Chromium app window on each monitor with the correct display URL
- Forces X11 (disables Wayland) for reliable window positioning
- GNOME dark mode for clean dark title bars
- Hides GNOME dock/panel for a clean look
- Disables screen blanking and power management
- Auto-login via GDM
- Custom RingRaceViewer Plymouth boot logo (replaces the Ubuntu logo on startup)
- Skip with
--no-bootlogoflag during install
- Docker Compose includes Nginx Proxy Manager for easy SSL setup
- Point your domain's DNS A record to the server, then configure the proxy host and request a free Let's Encrypt certificate — all through a web UI on port 81
curl -sSL https://raw.githubusercontent.com/stikkx/RingRaceViewer/main/scripts/install.sh | sudo bashInstalls Node.js 24 (via nvm), clones the repo, creates a systemd service, sets up kiosk mode, and installs the boot logo. After install:
- Dashboard:
http://rrv.local/?display=1 - Admin Panel:
http://rrv.local/admin
Flags:
--no-kiosk— skip kiosk mode setup--no-bootlogo— skip boot logo install
git clone https://github.com/stikkx/RingRaceViewer.git
cd RingRaceViewer
npm install
node server.jsdocker compose up -d- Dashboard:
http://rrv.local - Admin Panel:
http://rrv.local/admin - Nginx Proxy Manager:
http://localhost:81(default login:admin@example.com/changeme)
docker compose -f docker-compose.yml -f docker-compose.bluetooth.yml up -dGives the container access to the host's Bluetooth adapter and audio system. Requires bluez and PipeWire/PulseAudio running on the host.
sudo systemctl start ringraceviewer # start
sudo systemctl stop ringraceviewer # stop
sudo systemctl restart ringraceviewer # restart
sudo journalctl -u ringraceviewer -f # live logsUpdate to latest version:
cd /opt/ringraceviewer && sudo git pull && sudo npm install && sudo systemctl restart ringraceviewer- Make sure your domain (e.g.
race.yourdomain.com) has a DNS A record pointing to your server's public IP - Start the stack:
docker compose up -d - Open
http://<server-ip>:81and log in (default:admin@example.com/changeme) - Go to Proxy Hosts -> Add Proxy Host:
- Domain:
race.yourdomain.com - Forward Hostname:
app - Forward Port:
80
- Domain:
- Go to the SSL tab -> check Request a new SSL Certificate -> enable Force SSL
- Save — done. Your dashboard is now available over HTTPS
RingRaceViewer/
├── server.js # Express + Socket.io + REST API
├── bluetooth.js # Bluetooth speaker management module
├── package.json
├── Dockerfile
├── docker-compose.yml # App + Nginx Proxy Manager
├── docker-compose.bluetooth.yml # Override for Bluetooth-in-Docker
├── .env.example
├── .gitignore
├── data/ # JSON database (auto-created)
│ └── db.json
├── public/
│ ├── index.html # Dashboard (kiosk display)
│ ├── admin.html # Admin panel (tablet control)
│ ├── assets/ # Logo and uploaded files
│ │ └── logo.svg
│ ├── css/
│ │ └── style.css # Full design system (CSS custom properties)
│ └── js/
│ ├── dashboard.js # Dashboard logic + WebSocket client
│ ├── admin.js # Admin panel logic (pure CSS Grid)
│ └── bluetooth.js # Bluetooth speaker UI module
└── scripts/
├── install.sh # One-line Ubuntu install script
├── install-bootlogo.sh # Plymouth boot logo installer
├── kiosk-setup.sh # Standalone kiosk mode setup
└── plymouth/ # Plymouth theme files
├── ringraceviewer.plymouth
└── ringraceviewer.script
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/sources |
List all sources |
POST |
/api/sources |
Create a source ({ type, title, videoId/url }) |
PUT |
/api/sources/:id |
Update a source |
DELETE |
/api/sources/:id |
Delete a source |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/layouts |
List all layouts + active layout ID |
GET |
/api/layouts/:id |
Get a single layout |
POST |
/api/layouts |
Create a layout ({ name }) |
PUT |
/api/layouts/:id |
Update a layout (including widgets) |
DELETE |
/api/layouts/:id |
Delete a layout |
POST |
/api/layouts/:id/activate |
Set as the active (live) layout |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/displays |
Get all display configurations |
POST |
/api/displays |
Add a new display ({ width, height }) |
PUT |
/api/displays/:num |
Update display resolution ({ width, height }) |
DELETE |
/api/displays/:num |
Remove a display |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/theme |
Get current theme |
PUT |
/api/theme |
Update theme colors |
POST |
/api/logo |
Upload a logo image (binary body, Content-Type: image/*) |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/audio |
Get current audio source ID |
PUT |
/api/audio |
Set audio source ({ sourceId } or { sourceId: null } to mute) |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/messages |
Broadcast a popup to all displays ({ text, duration }) |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/timing/status |
Connection status |
POST |
/api/timing/connect |
Connect to timing WebSocket ({ url }) |
POST |
/api/timing/disconnect |
Disconnect from timing |
POST |
/api/timing/test |
Run test with sample N24h data |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/kiosk/resync |
Re-detect monitors and restart Chromium kiosk |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/bluetooth/status |
Overall BT status, paired devices, sinks |
POST |
/api/bluetooth/scan |
Scan for nearby devices ({ duration }) |
POST |
/api/bluetooth/pair |
Pair a device ({ mac }) |
POST |
/api/bluetooth/connect |
Connect a paired device ({ mac }) |
POST |
/api/bluetooth/disconnect |
Disconnect a device ({ mac }) |
POST |
/api/bluetooth/remove |
Unpair/remove a device ({ mac }) |
GET |
/api/bluetooth/sinks |
List Bluetooth audio sinks |
POST |
/api/bluetooth/combine |
Combine all BT sinks into one output |
DELETE |
/api/bluetooth/combine |
Remove the combined sink |
POST |
/api/bluetooth/volume |
Set volume ({ sink, volume }) |
| Event | Direction | Description |
|---|---|---|
layout:activated |
Server -> Client | Active layout changed |
layout:updated |
Server -> Client | Current active layout was modified |
layouts:changed |
Server -> Client | Layout list changed |
sources:changed |
Server -> Client | Source list changed |
theme:changed |
Server -> Client | Theme updated |
displays:changed |
Server -> Client | Display config changed |
audio:changed |
Server -> Client | Audio source changed |
message:broadcast |
Server -> Client | Popup notification |
timing:update |
Server -> Client | Live timing leaderboard data |
timing:status |
Server -> Client | Timing connection status |
bluetooth:changed |
Server -> Client | Bluetooth state changed |
- Backend: Node.js 24, Express, Socket.io
- Frontend: Vanilla HTML/CSS/JS, CSS Grid
- Storage: JSON file (zero-dependency)
- Bluetooth: bluetoothctl (bluez), pactl (PipeWire/PulseAudio)
- SSL: Nginx Proxy Manager + Let's Encrypt
- Containerization: Docker, Docker Compose
- YouTube Video IDs change frequently during 24h races — the admin panel lets you update them in seconds from your phone
- Save multiple layouts — pre-build setups for qualifying, race start, night stint, etc.
- Dark mode by default — easy on the eyes during night stints in the paddock
- Ctrl+S saves the layout from the admin panel
- Use the Race preset for a main view + timing sidebar
- The dashboard auto-reconnects if the server restarts
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install 24
nvm use 24git clone https://github.com/stikkx/RingRaceViewer.git
cd RingRaceViewer
npm install
node server.jsOpen http://localhost (port 80) or set a custom port with PORT=3000 node server.js.
npm run devdocker build -t rrv . && docker run --rm -p 3000:80 -v ./data:/app/data rrvOpen http://localhost:3000/admin.
# Check if Bluetooth adapter is available
bluetoothctl show
# Start Bluetooth service if needed
sudo systemctl start bluetooth
# Test API endpoints
curl http://localhost/api/bluetooth/status
curl -X POST http://localhost/api/bluetooth/scan -H "Content-Type: application/json" -d '{"duration":5}'If Bluetooth is not available, the admin panel shows "Bluetooth not available" gracefully.
Open the admin panel, scroll to Live Timing, and click Test. This sends sample N24h race data through the notification pipeline:
- 2s — Initial leaderboard loaded
- 2s — New fastest lap popup
- 5s — Lead change popup
- 8s — Best sector popup
Watch the dashboard (/?display=1) for popup notifications.
- Add a YouTube source and assign it to a display
- Click the speaker icon on the source card in the admin sidebar
- The dashboard unmutes that stream (all others stay muted)
- Click again to mute
# List all sources
curl http://localhost/api/sources
# List all layouts
curl http://localhost/api/layouts
# List displays
curl http://localhost/api/displays
# Current audio source
curl http://localhost/api/audio
# Send a message to all displays
curl -X POST http://localhost/api/messages \
-H "Content-Type: application/json" \
-d '{"text":"Hello from the paddock!","duration":30}'
# Resync kiosk monitors
curl -X POST http://localhost/api/kiosk/resync# Server logs (systemd)
sudo journalctl -u ringraceviewer -f
# Docker logs
docker logs -f ringraceviewer-appIf RingRaceViewer made your race weekend better, consider buying me a cold one:
Cheers and happy racing!