1348 lines
52 KiB
HTML
1348 lines
52 KiB
HTML
|
|
<!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>
|