Files
OBS-overlay/optimized-controls.html

1348 lines
52 KiB
HTML
Raw Normal View History

2026-02-07 12:24:54 -05:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Animated Stream Code Display</title>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: transparent;
font-family: 'Arial', sans-serif;
overflow: hidden;
}
#container {
text-align: center;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
}
#header {
position: absolute;
width: 100%;
text-align: center;
transform: translateY(-220px);
color: #f35dcb;
font-size: 80px;
font-weight: bold;
text-shadow: 3px 3px 8px rgba(0, 0, 0, 0.8);
letter-spacing: 2px;
opacity: 0;
transition: opacity 0.5s ease;
}
#footer {
position: absolute;
width: 100%;
text-align: center;
transform: translateY(160px);
color: #f35dcb;
font-size: 80px;
font-weight: bold;
text-shadow: 3px 3px 8px rgba(0, 0, 0, 0.8);
letter-spacing: 2px;
opacity: 0;
transition: opacity 0.5s ease;
}
.code-part {
font-size: 160px;
font-weight: bold;
color: #fdf935;
text-shadow: 4px 4px 12px rgba(0, 0, 0, 0.7);
letter-spacing: 8px;
position: absolute;
width: 100%;
text-align: center;
opacity: 0;
transition: opacity 0.5s ease;
}
#code-part1 {
transform: translateY(-100px);
}
#code-part2 {
transform: translateY(40px);
}
/* Redesigned controls */
#controls {
display: none;
position: absolute;
bottom: 50px;
right: 10px;
width: 280px; /* Narrower width */
padding: 10px;
background-color: rgba(0, 0, 0, 0.8);
border-radius: 8px;
z-index: 100;
max-height: 80vh;
overflow-y: auto;
}
.control-section {
margin-bottom: 5px;
border-bottom: 1px solid #555;
}
.section-header {
color: white;
background-color: rgba(0, 0, 0, 0.5);
padding: 5px;
margin: 0;
cursor: pointer;
font-size: 14px;
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-content {
padding: 5px 0;
display: none;
}
.control-group {
margin-bottom: 8px;
display: flex;
flex-direction: column;
}
.control-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
label {
color: white;
font-size: 12px;
margin-right: 5px;
flex: 1;
}
input[type="text"] {
font-size: 14px;
padding: 3px;
text-align: center;
flex: 1;
text-transform: uppercase;
}
input[type="color"] {
width: 30px;
height: 20px;
vertical-align: middle;
}
input[type="number"] {
width: 50px;
font-size: 12px;
padding: 3px;
}
input[type="range"] {
width: 100px;
}
button {
font-size: 14px;
padding: 6px 12px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 5px;
width: 100%;
}
button:hover {
background-color: #45a049;
}
.action-buttons {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.action-buttons button {
width: 48%;
}
#show-controls-btn {
position: absolute;
bottom: 10px;
right: 10px;
background-color: rgba(0, 0, 0, 0.3);
color: #ffffff;
font-size: 12px;
padding: 4px 8px;
z-index: 100;
width: auto;
margin-top: 0;
}
#toggle-display-btn {
position: absolute;
bottom: 10px;
right: 120px;
background-color: rgba(0, 0, 0, 0.3);
color: #ffffff;
font-size: 12px;
padding: 4px 8px;
z-index: 100;
width: auto;
margin-top: 0;
}
/* Toggle indicator */
.toggle-indicator:after {
content: "▼";
margin-left: 5px;
font-size: 10px;
}
.toggle-indicator.active:after {
content: "▲";
}
/* Wide text fields */
.wide-field {
width: 100%;
}
#controls.bottom-position {
bottom: auto;
top: 80vh; /* Position below the footer */
left: 50%;
transform: translateX(-50%);
width: 80%;
max-width: 600px;
}
/* Style for active section */
.section-header.active {
background-color: rgba(79, 79, 79, 0.7);
}
/* Connection status indicator */
.status-row {
display: flex;
align-items: center;
margin-bottom: 5px;
gap: 6px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.disconnected {
background-color: #888;
}
.status-dot.connecting {
background-color: #f0ad4e;
animation: pulse 1s infinite;
}
.status-dot.connected {
background-color: #4CAF50;
}
.status-dot.error {
background-color: #d9534f;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
color: #ccc;
font-size: 11px;
}
input[type="password"] {
font-size: 12px;
padding: 3px;
flex: 1;
width: 100%;
}
#ws-connect-btn {
background-color: #337ab7;
}
#ws-connect-btn:hover {
background-color: #286090;
}
#ws-disconnect-btn {
background-color: #d9534f;
}
#ws-disconnect-btn:hover {
background-color: #c9302c;
}
/* Auto/Manual mode toggle */
.mode-toggle {
display: flex;
align-items: center;
gap: 8px;
}
.mode-toggle-switch {
position: relative;
width: 36px;
height: 18px;
flex-shrink: 0;
}
.mode-toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.mode-toggle-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #555;
border-radius: 18px;
transition: background-color 0.2s;
}
.mode-toggle-slider:before {
content: "";
position: absolute;
height: 14px;
width: 14px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 50%;
transition: transform 0.2s;
}
.mode-toggle-switch input:checked + .mode-toggle-slider {
background-color: #4CAF50;
}
.mode-toggle-switch input:checked + .mode-toggle-slider:before {
transform: translateX(18px);
}
.mode-label {
color: #ccc;
font-size: 12px;
}
</style>
</head>
<body>
<div id="container">
<div id="header">ROOM CODE:</div>
<div id="code-part1" class="code-part"></div>
<div id="code-part2" class="code-part"></div>
<div id="footer">Enter @ jackbox.tv</div>
<!-- Add audio element -->
<audio id="theme-sound" preload="auto" loop>
<source src="https://feed.falsefinish.club/HSO/Audio/Room%20Code%20Theme.mp3" type="audio/mp3">
Your browser does not support the audio element.
</audio>
<div id="controls">
<!-- Code Settings Section -->
<div class="control-section">
<div class="section-header">
<span>Code Settings</span>
<span class="toggle-indicator active"></span>
</div>
<div class="section-content" style="display: block;">
<div class="control-group">
<div class="control-row">
<label>First Line:</label>
<input type="text" id="code1-input" maxlength="2" value="" style="width: 40px;">
<input type="color" id="color1-input" value="#fdf935">
</div>
<div class="control-row">
<label>Offset:</label>
<input type="number" id="offset1-input" value="-100" step="10">
</div>
</div>
<div class="control-group">
<div class="control-row">
<label>Second Line:</label>
<input type="text" id="code2-input" maxlength="2" value="" style="width: 40px;">
<input type="color" id="color2-input" value="#fdf935">
</div>
<div class="control-row">
<label>Offset:</label>
<input type="number" id="offset2-input" value="40" step="10">
</div>
</div>
<div class="control-row">
<label>Font Size:</label>
<input type="number" id="size-input" value="160" min="10" max="200" step="1">
</div>
<div class="control-row">
<label>Animation Cycle (sec):</label>
<input type="number" id="cycle-input" value="300" min="30" max="600" step="10">
</div>
</div>
</div>
<!-- Header Settings Section -->
<div class="control-section">
<div class="section-header">
<span>Header Settings</span>
<span class="toggle-indicator"></span>
</div>
<div class="section-content">
<div class="control-row">
<label>Text:</label>
<input type="text" id="header-text-input" value="ROOM CODE:" class="wide-field">
</div>
<div class="control-row">
<label>Color:</label>
<input type="color" id="header-color-input" value="#f35dcb">
</div>
<div class="control-row">
<label>Size:</label>
<input type="number" id="header-size-input" value="80" min="12" max="100" step="1">
</div>
<div class="control-row">
<label>Position:</label>
<input type="number" id="header-offset-input" value="-220" step="10">
</div>
</div>
</div>
<!-- Footer Settings Section -->
<div class="control-section">
<div class="section-header">
<span>Footer Settings</span>
<span class="toggle-indicator"></span>
</div>
<div class="section-content">
<div class="control-row">
<label>Text:</label>
<input type="text" id="footer-text-input" value="Enter @ jackbox.tv" class="wide-field">
</div>
<div class="control-row">
<label>Color:</label>
<input type="color" id="footer-color-input" value="#f35dcb">
</div>
<div class="control-row">
<label>Size:</label>
<input type="number" id="footer-size-input" value="80" min="12" max="100" step="1">
</div>
<div class="control-row">
<label>Position:</label>
<input type="number" id="footer-offset-input" value="160" step="10">
</div>
</div>
</div>
<!-- Timing Settings Section -->
<div class="control-section">
<div class="section-header">
<span>Timing Settings</span>
<span class="toggle-indicator"></span>
</div>
<div class="section-content">
<div class="control-group">
<div class="control-row">
<label>Header Appear (sec):</label>
<input type="number" id="header-appear-delay" value="10" min="0" max="30">
</div>
<div class="control-row">
<label>Duration:</label>
<input type="number" id="header-appear-duration" value="10" min="1" max="20">
</div>
</div>
<div class="control-group">
<div class="control-row">
<label>Header Hide (sec):</label>
<input type="number" id="header-hide-time" value="240" min="10" max="600">
</div>
<div class="control-row">
<label>Duration:</label>
<input type="number" id="header-hide-duration" value="60" min="1" max="120">
</div>
</div>
<div class="control-group">
<div class="control-row">
<label>Line 1 Appear (sec):</label>
<input type="number" id="line1-appear-delay" value="20" min="0" max="30">
</div>
<div class="control-row">
<label>Duration:</label>
<input type="number" id="line1-appear-duration" value="30" min="1" max="120">
</div>
</div>
<div class="control-group">
<div class="control-row">
<label>Line 1 Hide (sec):</label>
<input type="number" id="line1-hide-time" value="240" min="10" max="600">
</div>
<div class="control-row">
<label>Duration:</label>
<input type="number" id="line1-hide-duration" value="60" min="1" max="120">
</div>
</div>
<div class="control-group">
<div class="control-row">
<label>Line 2 Appear (sec):</label>
<input type="number" id="line2-appear-delay" value="40" min="0" max="300">
</div>
<div class="control-row">
<label>Duration:</label>
<input type="number" id="line2-appear-duration" value="60" min="1" max="120">
</div>
</div>
<div class="control-group">
<div class="control-row">
<label>Line 2 Hide (sec):</label>
<input type="number" id="line2-hide-time" value="240" min="10" max="600">
</div>
<div class="control-row">
<label>Duration:</label>
<input type="number" id="line2-hide-duration" value="60" min="1" max="120">
</div>
</div>
</div>
</div>
<!-- Audio Settings Section -->
<div class="control-section">
<div class="section-header">
<span>Audio Settings</span>
<span class="toggle-indicator"></span>
</div>
<div class="section-content">
<div class="control-row">
<label>Sound Effect:</label>
<input type="checkbox" id="sound-enabled" checked>
</div>
<div class="control-row">
<label>Volume:</label>
<input type="range" id="volume-slider" min="0" max="1" step="0.1" value="0.5">
<span id="volume-value">50%</span>
</div>
<div class="control-row">
<label>Sound URL:</label>
</div>
<div class="control-row">
<input type="text" id="sound-url-input" value="https://feed.falsefinish.club/HSO/Audio/Room%20Code%20Theme.mp3" class="wide-field">
</div>
<div class="control-row">
<button id="test-sound-btn">Test Sound</button>
</div>
</div>
</div>
<!-- Connection Settings Section -->
<div class="control-section">
<div class="section-header">
<span>Connection Settings</span>
<span class="toggle-indicator"></span>
</div>
<div class="section-content">
<div class="status-row">
<span class="status-dot disconnected" id="ws-status-dot"></span>
<span class="status-text" id="ws-status-text">Disconnected</span>
</div>
<div class="control-row">
<label>API URL:</label>
</div>
<div class="control-row">
<input type="text" id="api-url-input" placeholder="https://your-api-url" class="wide-field" style="text-transform: none;">
</div>
<div class="control-row">
<label>API Key:</label>
</div>
<div class="control-row">
<input type="password" id="api-key-input" placeholder="Enter API key">
</div>
<div class="control-row">
<button id="ws-connect-btn">Connect</button>
</div>
<div class="control-row" style="display: none;" id="ws-disconnect-row">
<button id="ws-disconnect-btn">Disconnect</button>
</div>
<div class="control-row" style="margin-top: 5px;">
<label>Update Mode:</label>
<div class="mode-toggle">
<span class="mode-label">Manual</span>
<label class="mode-toggle-switch">
<input type="checkbox" id="auto-mode-toggle" checked>
<span class="mode-toggle-slider"></span>
</label>
<span class="mode-label">Auto</span>
</div>
</div>
</div>
</div>
<!-- Position toggle -->
<div class="control-row" style="margin-top: 5px;">
<label>Position Controls Below:</label>
<input type="checkbox" id="position-toggle">
</div>
<!-- Action buttons -->
<div class="action-buttons">
<button id="update-btn">Update</button>
<button id="preview-btn">Preview</button>
</div>
</div>
</div>
<button id="toggle-display-btn">Hide Display</button>
<button id="show-controls-btn">Show Controls</button>
<script>
// Get elements
const header = document.getElementById('header');
const footer = document.getElementById('footer');
const codePart1 = document.getElementById('code-part1');
const codePart2 = document.getElementById('code-part2');
const code1Input = document.getElementById('code1-input');
const code2Input = document.getElementById('code2-input');
const color1Input = document.getElementById('color1-input');
const color2Input = document.getElementById('color2-input');
const offset1Input = document.getElementById('offset1-input');
const offset2Input = document.getElementById('offset2-input');
const sizeInput = document.getElementById('size-input');
const cycleInput = document.getElementById('cycle-input');
const headerTextInput = document.getElementById('header-text-input');
const headerColorInput = document.getElementById('header-color-input');
const headerSizeInput = document.getElementById('header-size-input');
const headerOffsetInput = document.getElementById('header-offset-input');
const headerAppearDelay = document.getElementById('header-appear-delay');
const headerAppearDuration = document.getElementById('header-appear-duration');
const headerHideTime = document.getElementById('header-hide-time');
const headerHideDuration = document.getElementById('header-hide-duration');
const footerTextInput = document.getElementById('footer-text-input');
const footerColorInput = document.getElementById('footer-color-input');
const footerSizeInput = document.getElementById('footer-size-input');
const footerOffsetInput = document.getElementById('footer-offset-input');
const updateBtn = document.getElementById('update-btn');
const previewBtn = document.getElementById('preview-btn');
const showControlsBtn = document.getElementById('show-controls-btn');
const controls = document.getElementById('controls');
const positionToggle = document.getElementById('position-toggle');
// Audio elements
const themeSound = document.getElementById('theme-sound');
const soundEnabled = document.getElementById('sound-enabled');
const volumeSlider = document.getElementById('volume-slider');
const volumeValue = document.getElementById('volume-value');
const soundUrlInput = document.getElementById('sound-url-input');
const testSoundBtn = document.getElementById('test-sound-btn');
// Animation timing inputs
const line1AppearDelay = document.getElementById('line1-appear-delay');
const line1AppearDuration = document.getElementById('line1-appear-duration');
const line1HideTime = document.getElementById('line1-hide-time');
const line1HideDuration = document.getElementById('line1-hide-duration');
const line2AppearDelay = document.getElementById('line2-appear-delay');
const line2AppearDuration = document.getElementById('line2-appear-duration');
const line2HideTime = document.getElementById('line2-hide-time');
const line2HideDuration = document.getElementById('line2-hide-duration');
// Section headers
const sectionHeaders = document.querySelectorAll('.section-header');
// Animation timers
let animationTimers = [];
// Initialize on load (don't start animation until a room code is set)
window.addEventListener('load', () => {
// Apply default settings (styles, positions, etc.)
applySettings();
// Set initial volume display
updateVolumeDisplay();
// Setup collapsible sections
setupCollapsibleSections();
});
// Setup collapsible sections
function setupCollapsibleSections() {
sectionHeaders.forEach(header => {
header.addEventListener('click', () => {
const content = header.nextElementSibling;
const indicator = header.querySelector('.toggle-indicator');
// Toggle content visibility
if (content.style.display === 'block') {
content.style.display = 'none';
indicator.classList.remove('active');
header.classList.remove('active');
} else {
content.style.display = 'block';
indicator.classList.add('active');
header.classList.add('active');
}
});
});
}
// Toggle control position
positionToggle.addEventListener('change', () => {
if (positionToggle.checked) {
controls.classList.add('bottom-position');
} else {
controls.classList.remove('bottom-position');
}
});
// Update volume display
function updateVolumeDisplay() {
volumeValue.textContent = Math.round(volumeSlider.value * 100) + '%';
themeSound.volume = volumeSlider.value;
}
// Update settings function
function applySettings() {
// Update code text and styling
codePart1.textContent = code1Input.value.toUpperCase();
codePart2.textContent = code2Input.value.toUpperCase();
codePart1.style.color = color1Input.value;
codePart2.style.color = color2Input.value;
codePart1.style.transform = `translateY(${offset1Input.value}px)`;
codePart2.style.transform = `translateY(${offset2Input.value}px)`;
codePart1.style.fontSize = `${sizeInput.value}px`;
codePart2.style.fontSize = `${sizeInput.value}px`;
// Update header text and styling
header.textContent = headerTextInput.value;
header.style.color = headerColorInput.value;
header.style.fontSize = `${headerSizeInput.value}px`;
header.style.transform = `translateY(${headerOffsetInput.value}px)`;
// Update footer text and styling
footer.textContent = footerTextInput.value;
footer.style.color = footerColorInput.value;
footer.style.fontSize = `${footerSizeInput.value}px`;
footer.style.transform = `translateY(${footerOffsetInput.value}px)`;
// Update sound URL
themeSound.src = soundUrlInput.value;
themeSound.load();
}
// Reset and start animation
function startAnimation() {
// Clear any existing animation timers
animationTimers.forEach(timer => clearTimeout(timer));
animationTimers = [];
// Reset opacity for all elements
header.style.opacity = 0;
footer.style.opacity = 0;
codePart1.style.opacity = 0;
codePart2.style.opacity = 0;
const cycleDuration = parseInt(cycleInput.value) * 1000; // Convert to milliseconds
// Play sound if enabled
if (soundEnabled.checked) {
// Reset and play the audio
themeSound.currentTime = 0;
themeSound.play().catch(error => {
console.log("Audio playback failed:", error);
});
} else {
themeSound.pause();
}
// Header and Footer appear together
const headerAppearDelayMs = parseInt(headerAppearDelay.value) * 1000;
const headerAppearDurationMs = parseInt(headerAppearDuration.value) * 1000;
animationTimers.push(setTimeout(() => {
header.style.transition = `opacity ${headerAppearDurationMs/1000}s ease-out`;
header.style.opacity = 1;
// Make the footer fade in at the same time as the header
footer.style.transition = `opacity ${headerAppearDurationMs/1000}s ease-out`;
footer.style.opacity = 1;
}, headerAppearDelayMs));
// Line 1 appear
const line1AppearDelayMs = parseInt(line1AppearDelay.value) * 1000;
const line1AppearDurationMs = parseInt(line1AppearDuration.value) * 1000;
animationTimers.push(setTimeout(() => {
codePart1.style.transition = `opacity ${line1AppearDurationMs/1000}s ease-out`;
codePart1.style.opacity = 1;
}, line1AppearDelayMs));
// Line 1 hide
const line1HideTimeMs = parseInt(line1HideTime.value) * 1000;
const line1HideDurationMs = parseInt(line1HideDuration.value) * 1000;
animationTimers.push(setTimeout(() => {
codePart1.style.transition = `opacity ${line1HideDurationMs/1000}s ease-out`;
codePart1.style.opacity = 0;
}, line1HideTimeMs));
// Line 2 appear
const line2AppearDelayMs = parseInt(line2AppearDelay.value) * 1000;
const line2AppearDurationMs = parseInt(line2AppearDuration.value) * 1000;
animationTimers.push(setTimeout(() => {
codePart2.style.transition = `opacity ${line2AppearDurationMs/1000}s ease-out`;
codePart2.style.opacity = 1;
}, line2AppearDelayMs));
// Line 2 hide
const line2HideTimeMs = parseInt(line2HideTime.value) * 1000;
const line2HideDurationMs = parseInt(line2HideDuration.value) * 1000;
animationTimers.push(setTimeout(() => {
codePart2.style.transition = `opacity ${line2HideDurationMs/1000}s ease-out`;
codePart2.style.opacity = 0;
}, line2HideTimeMs));
// Header and Footer hide together
const headerHideTimeMs = parseInt(headerHideTime.value) * 1000;
const headerHideDurationMs = parseInt(headerHideDuration.value) * 1000;
animationTimers.push(setTimeout(() => {
header.style.transition = `opacity ${headerHideDurationMs/1000}s ease-out`;
header.style.opacity = 0;
// Make the footer fade out at the same time as the header
footer.style.transition = `opacity ${headerHideDurationMs/1000}s ease-out`;
footer.style.opacity = 0;
// Also fade out the sound near the end of the cycle
if (soundEnabled.checked) {
const fadeAudio = setInterval(() => {
if (themeSound.volume > 0.1) {
themeSound.volume -= 0.1;
} else {
clearInterval(fadeAudio);
themeSound.pause();
}
}, headerHideDurationMs / 10);
}
}, headerHideTimeMs));
// Restart animation after cycle completes
animationTimers.push(setTimeout(() => {
startAnimation();
}, cycleDuration));
}
// Preview animation
function previewAnimation() {
applySettings();
startAnimation();
}
// Display visibility state
let displayHidden = false;
const toggleDisplayBtn = document.getElementById('toggle-display-btn');
// Hide everything on screen (cancel animation, fade out, stop audio)
function hideDisplay() {
// Cancel all pending animation timers
animationTimers.forEach(timer => clearTimeout(timer));
animationTimers = [];
// Quick fade out all display elements
const fadeTime = '0.3s';
header.style.transition = `opacity ${fadeTime} ease-out`;
footer.style.transition = `opacity ${fadeTime} ease-out`;
codePart1.style.transition = `opacity ${fadeTime} ease-out`;
codePart2.style.transition = `opacity ${fadeTime} ease-out`;
header.style.opacity = 0;
footer.style.opacity = 0;
codePart1.style.opacity = 0;
codePart2.style.opacity = 0;
// Stop audio
themeSound.pause();
themeSound.currentTime = 0;
displayHidden = true;
toggleDisplayBtn.textContent = 'Show Display';
console.log('[Display] Hidden');
}
// Show display again (re-apply settings and restart animation)
function showDisplay() {
displayHidden = false;
toggleDisplayBtn.textContent = 'Hide Display';
applySettings();
startAnimation();
console.log('[Display] Shown');
}
// Test sound function
function testSound() {
// Update sound URL
themeSound.src = soundUrlInput.value;
themeSound.volume = volumeSlider.value;
themeSound.currentTime = 0;
themeSound.loop = false; // Don't loop for testing
// Play sound
themeSound.play().catch(error => {
console.log("Audio test playback failed:", error);
alert("Audio playback failed. Check the URL or browser autoplay permissions.");
});
// Reset loop setting after 5 seconds
setTimeout(() => {
themeSound.pause();
themeSound.loop = true;
}, 5000);
}
// Event listeners
updateBtn.addEventListener('click', () => {
applySettings();
startAnimation();
});
previewBtn.addEventListener('click', previewAnimation);
// Volume slider event
volumeSlider.addEventListener('input', updateVolumeDisplay);
// Test sound button
testSoundBtn.addEventListener('click', testSound);
// Toggle controls visibility
showControlsBtn.addEventListener('click', () => {
if (controls.style.display === 'block') {
controls.style.display = 'none';
showControlsBtn.textContent = 'Show Controls';
} else {
controls.style.display = 'block';
showControlsBtn.textContent = 'Hide Controls';
}
});
// Toggle display visibility (hide/show everything on screen)
toggleDisplayBtn.addEventListener('click', () => {
if (displayHidden) {
showDisplay();
} else {
hideDisplay();
}
});
// Auto-hide controls after 20 seconds of inactivity
let inactivityTimer;
function resetInactivityTimer() {
clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(() => {
controls.style.display = 'none';
showControlsBtn.textContent = 'Show Controls';
}, 20000);
}
document.addEventListener('mousemove', resetInactivityTimer);
document.addEventListener('keypress', resetInactivityTimer);
document.addEventListener('click', resetInactivityTimer);
// Initialize timer
resetInactivityTimer();
// ============================================================
// WebSocket Connection to Jackbox Game Picker API
// ============================================================
// Connection elements
const apiUrlInput = document.getElementById('api-url-input');
const apiKeyInput = document.getElementById('api-key-input');
const wsConnectBtn = document.getElementById('ws-connect-btn');
const wsDisconnectBtn = document.getElementById('ws-disconnect-btn');
const wsDisconnectRow = document.getElementById('ws-disconnect-row');
const wsStatusDot = document.getElementById('ws-status-dot');
const wsStatusText = document.getElementById('ws-status-text');
const autoModeToggle = document.getElementById('auto-mode-toggle');
// WebSocket state
let ws = null;
let jwtToken = null;
let heartbeatInterval = null;
let reconnectTimeout = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let intentionalDisconnect = false;
// Load saved settings from localStorage
const savedApiUrl = localStorage.getItem('jackbox-api-url');
if (savedApiUrl) {
apiUrlInput.value = savedApiUrl;
}
const savedApiKey = localStorage.getItem('jackbox-api-key');
if (savedApiKey) {
apiKeyInput.value = savedApiKey;
}
// Auto-connect on load if both URL and key are saved
if (savedApiUrl && savedApiKey) {
console.log('[WS] Saved credentials found, auto-connecting...');
// Small delay to let the page finish initializing
setTimeout(() => {
authenticateAndConnect();
}, 500);
}
// Update connection status indicator
function setConnectionStatus(state, message) {
wsStatusDot.className = 'status-dot ' + state;
wsStatusText.textContent = message || state;
if (state === 'connected') {
wsConnectBtn.style.display = 'none';
wsDisconnectRow.style.display = 'flex';
apiUrlInput.disabled = true;
apiKeyInput.disabled = true;
} else {
wsConnectBtn.style.display = 'block';
wsDisconnectRow.style.display = 'none';
apiUrlInput.disabled = false;
apiKeyInput.disabled = false;
}
}
// Authenticate with the API and get JWT, then connect WebSocket
async function authenticateAndConnect() {
const apiUrl = apiUrlInput.value.trim().replace(/\/+$/, '');
const apiKey = apiKeyInput.value.trim();
if (!apiUrl) {
setConnectionStatus('error', 'API URL is required');
return;
}
if (!apiKey) {
setConnectionStatus('error', 'API Key is required');
return;
}
// Save settings to localStorage
localStorage.setItem('jackbox-api-url', apiUrl);
localStorage.setItem('jackbox-api-key', apiKey);
setConnectionStatus('connecting', 'Authenticating...');
intentionalDisconnect = false;
try {
const response = await fetch(apiUrl + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: apiKey })
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
setConnectionStatus('error', 'Auth failed: ' + (errData.error || response.statusText));
return;
}
const data = await response.json();
jwtToken = data.token;
if (!jwtToken) {
setConnectionStatus('error', 'No token in auth response');
return;
}
setConnectionStatus('connecting', 'Connecting WebSocket...');
connectWebSocket(apiUrl);
} catch (err) {
console.error('Auth error:', err);
setConnectionStatus('error', 'Auth error: ' + err.message);
}
}
// Open WebSocket connection
function connectWebSocket(apiUrl) {
const wsUrl = apiUrl.replace(/^http/, 'ws') + '/api/sessions/live';
try {
ws = new WebSocket(wsUrl);
} catch (err) {
setConnectionStatus('error', 'WebSocket error: ' + err.message);
return;
}
ws.addEventListener('open', () => {
console.log('[WS] Connected, authenticating...');
setConnectionStatus('connecting', 'Authenticating via WS...');
reconnectDelay = 1000; // Reset backoff on successful connection
// Send auth message
ws.send(JSON.stringify({ type: 'auth', token: jwtToken }));
});
ws.addEventListener('message', (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch (e) {
console.error('[WS] Failed to parse message:', event.data);
return;
}
handleWebSocketMessage(message);
});
ws.addEventListener('close', (event) => {
console.log('[WS] Disconnected, code:', event.code);
stopHeartbeat();
if (!intentionalDisconnect) {
setConnectionStatus('connecting', 'Reconnecting in ' + Math.round(reconnectDelay / 1000) + 's...');
scheduleReconnect();
} else {
setConnectionStatus('disconnected', 'Disconnected');
}
});
ws.addEventListener('error', (err) => {
console.error('[WS] Error:', err);
});
}
// Handle incoming WebSocket messages
function handleWebSocketMessage(message) {
switch (message.type) {
case 'auth_success':
console.log('[WS] Authenticated successfully');
setConnectionStatus('connected', 'Connected');
startHeartbeat();
fetchActiveSessionAndSubscribe();
break;
case 'auth_error':
console.error('[WS] Auth error:', message.message);
setConnectionStatus('error', 'WS auth failed: ' + message.message);
intentionalDisconnect = true; // Don't reconnect on auth failure
if (ws) ws.close();
break;
case 'session.started':
console.log('[WS] Session started:', message.data.session.id);
// Auto-subscribe to the new session
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: message.data.session.id
}));
}
setConnectionStatus('connected', 'Connected (session ' + message.data.session.id + ')');
break;
case 'subscribed':
console.log('[WS] Subscribed to session:', message.sessionId);
setConnectionStatus('connected', 'Connected (session ' + message.sessionId + ')');
break;
case 'game.added':
console.log('[WS] Game added:', message.data.game.title, 'code:', message.data.game.room_code);
handleGameAdded(message.data);
break;
case 'session.ended':
console.log('[WS] Session ended:', message.data.session.id);
setConnectionStatus('connected', 'Connected (no active session)');
break;
case 'game.started':
console.log('[WS] Game started, room locked:', message.data.roomCode);
hideDisplay();
break;
case 'audience.joined':
console.log('[WS] Audience joined room:', message.data.roomCode);
hideDisplay();
break;
case 'pong':
// Heartbeat acknowledged
break;
case 'error':
console.error('[WS] Server error:', message.message);
break;
default:
console.log('[WS] Unhandled message type:', message.type);
}
}
// Handle a game.added event: split the room code and update fields
function handleGameAdded(data) {
const roomCode = data.game.room_code;
if (!roomCode) {
console.log('[WS] Game added but no room_code provided');
return;
}
// Split the code into two halves
const midpoint = Math.ceil(roomCode.length / 2);
const firstHalf = roomCode.substring(0, midpoint).toUpperCase();
const secondHalf = roomCode.substring(midpoint).toUpperCase();
// Always update the input fields
code1Input.value = firstHalf;
code2Input.value = secondHalf;
console.log('[WS] Room code set: ' + firstHalf + ' / ' + secondHalf);
if (autoModeToggle.checked) {
// Auto mode: apply settings and start the animation immediately
console.log('[WS] Auto mode: triggering animation');
applySettings();
startAnimation();
} else {
// Manual mode: fields are updated, operator must press Update
console.log('[WS] Manual mode: fields updated, waiting for operator');
}
}
// Check for an already-active session on connect and subscribe to it
async function fetchActiveSessionAndSubscribe() {
const apiUrl = apiUrlInput.value.trim().replace(/\/+$/, '');
if (!apiUrl || !jwtToken) return;
try {
const response = await fetch(apiUrl + '/api/sessions/active', {
headers: { 'Authorization': 'Bearer ' + jwtToken }
});
if (response.ok) {
const session = await response.json();
if (session && session.id) {
console.log('[WS] Found active session:', session.id, '-- subscribing');
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: session.id
}));
}
} else {
console.log('[WS] No active session found, waiting for session.started');
}
} else {
console.log('[WS] Could not fetch active session:', response.status);
}
} catch (err) {
console.error('[WS] Error fetching active session:', err);
}
}
// Heartbeat to keep WebSocket alive
function startHeartbeat() {
stopHeartbeat();
heartbeatInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
}
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
}
// Auto-reconnect with exponential backoff
function scheduleReconnect() {
if (reconnectTimeout) clearTimeout(reconnectTimeout);
reconnectTimeout = setTimeout(() => {
const apiUrl = apiUrlInput.value.trim().replace(/\/+$/, '');
if (apiUrl && jwtToken && !intentionalDisconnect) {
console.log('[WS] Attempting reconnect...');
setConnectionStatus('connecting', 'Reconnecting...');
connectWebSocket(apiUrl);
}
// Exponential backoff
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
}, reconnectDelay);
}
// Disconnect WebSocket
function disconnectWebSocket() {
intentionalDisconnect = true;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
stopHeartbeat();
if (ws) {
ws.close();
ws = null;
}
jwtToken = null;
setConnectionStatus('disconnected', 'Disconnected');
}
// Event listeners for connection buttons
wsConnectBtn.addEventListener('click', authenticateAndConnect);
wsDisconnectBtn.addEventListener('click', disconnectWebSocket);
// Save settings on change
apiUrlInput.addEventListener('change', () => {
const url = apiUrlInput.value.trim();
if (url) {
localStorage.setItem('jackbox-api-url', url);
}
});
apiKeyInput.addEventListener('change', () => {
localStorage.setItem('jackbox-api-key', apiKeyInput.value.trim());
});
</script>
</body>
</html>