First commit on the local Gitea server, made after moving the tree off the Windows machine. Build outputs, node_modules and the AI models stay out (see .gitignore); everything the installer build needs is tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3127 lines
186 KiB
HTML
3127 lines
186 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Settings - Bible App</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||
background: #1a1a1a;
|
||
color: #e0e0e0;
|
||
padding: 20px;
|
||
line-height: 1.6;
|
||
}
|
||
.container {
|
||
max-width: 1000px;
|
||
margin: 0 auto;
|
||
/* Tall bottom spacer so every section (even the last) can scroll up to the top of a
|
||
portrait 65" screen — no bending down to reach low sections. */
|
||
padding-bottom: 90vh;
|
||
}
|
||
h1 {
|
||
color: #fff;
|
||
margin-bottom: 30px;
|
||
font-size: 2em;
|
||
}
|
||
.section {
|
||
background: #2a2a2a;
|
||
border-radius: 8px;
|
||
padding: 25px;
|
||
margin-bottom: 30px;
|
||
border: 1px solid #3a3a3a;
|
||
}
|
||
.section h2 {
|
||
color: #fff;
|
||
margin-bottom: 20px;
|
||
font-size: 1.5em;
|
||
border-bottom: 2px solid #4a4a4a;
|
||
padding-bottom: 10px;
|
||
}
|
||
/* Collapsible accordion: every section starts collapsed so the page is short to navigate.
|
||
Tap the heading to expand/collapse. */
|
||
.section h2 {
|
||
cursor: pointer;
|
||
user-select: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
transition: margin-bottom 0.12s ease;
|
||
}
|
||
.section h2::after {
|
||
content: '\25B8'; /* ▸ */
|
||
font-size: 0.7em;
|
||
color: #8a8a8a;
|
||
transition: transform 0.18s ease;
|
||
margin-left: 12px;
|
||
flex: 0 0 auto;
|
||
}
|
||
.section:not(.collapsed) h2::after { transform: rotate(90deg); }
|
||
.section.collapsed h2 { margin-bottom: 0; }
|
||
.section.collapsed .section-body { display: none; }
|
||
.section h2:hover { color: #0090ff; }
|
||
.form-group {
|
||
margin-bottom: 20px;
|
||
}
|
||
label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: #e0e0e0;
|
||
font-weight: 500;
|
||
}
|
||
input[type="text"],
|
||
input[type="number"],
|
||
input[type="password"],
|
||
textarea {
|
||
width: 100%;
|
||
padding: 10px;
|
||
background: #1f1f1f;
|
||
border: 1px solid #4a4a4a;
|
||
border-radius: 6px;
|
||
color: #e0e0e0;
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
}
|
||
input:focus,
|
||
textarea:focus {
|
||
outline: none;
|
||
border-color: #0090ff;
|
||
box-shadow: 0 0 0 2px rgba(0, 144, 255, 0.2);
|
||
}
|
||
input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
margin-right: 10px;
|
||
cursor: pointer;
|
||
accent-color: #0090ff;
|
||
}
|
||
.checkbox-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
}
|
||
.checkbox-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12px;
|
||
background: #1f1f1f;
|
||
border-radius: 6px;
|
||
border: 1px solid #3a3a3a;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.checkbox-item:hover {
|
||
border-color: #4a4a4a;
|
||
}
|
||
.checkbox-item label {
|
||
margin: 0;
|
||
cursor: pointer;
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.help-text {
|
||
font-size: 12px;
|
||
color: #999;
|
||
margin-top: 5px;
|
||
}
|
||
.btn {
|
||
background: #0090ff;
|
||
color: white;
|
||
border: none;
|
||
padding: 12px 24px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
transition: background 0.3s;
|
||
margin-right: 10px;
|
||
}
|
||
.btn:hover {
|
||
background: #0070cc;
|
||
}
|
||
.btn-secondary {
|
||
background: #4a4a4a;
|
||
}
|
||
.btn-secondary:hover {
|
||
background: #5a5a5a;
|
||
}
|
||
.status {
|
||
padding: 12px 18px;
|
||
border-radius: 8px;
|
||
display: none;
|
||
/* Pinned overlay: never part of the page flow, so sections don't jump when it appears. */
|
||
position: fixed;
|
||
top: 14px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 99999;
|
||
max-width: min(92vw, 640px);
|
||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);
|
||
margin: 0;
|
||
}
|
||
.status.success {
|
||
background: #1a3a2a;
|
||
border: 1px solid #00ff88;
|
||
color: #00ff88;
|
||
display: block;
|
||
}
|
||
.status.error {
|
||
background: #3a1a1a;
|
||
border: 1px solid #ff3333;
|
||
color: #ff3333;
|
||
display: block;
|
||
}
|
||
.status.info {
|
||
background: #1a2a3a;
|
||
border: 1px solid #0090ff;
|
||
color: #0090ff;
|
||
display: block;
|
||
}
|
||
.ip-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.ip-item {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.ip-item input {
|
||
flex: 1;
|
||
}
|
||
.btn-small {
|
||
padding: 6px 12px;
|
||
font-size: 14px;
|
||
}
|
||
.btn-danger {
|
||
background: #cc0000;
|
||
}
|
||
.btn-danger:hover {
|
||
background: #aa0000;
|
||
}
|
||
.add-btn {
|
||
margin-top: 10px;
|
||
}
|
||
.status-indicator {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
margin-right: 8px;
|
||
}
|
||
.status-indicator.active {
|
||
background: #00ff88;
|
||
box-shadow: 0 0 5px #00ff88;
|
||
}
|
||
.status-indicator.inactive {
|
||
background: #ff3333;
|
||
}
|
||
.status-indicator.warning {
|
||
background: #ffaa00;
|
||
}
|
||
.back-link {
|
||
display: inline-block;
|
||
margin-bottom: 20px;
|
||
color: #0090ff;
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
padding: 8px 16px;
|
||
border-radius: 6px;
|
||
transition: all 0.3s;
|
||
}
|
||
.back-link:hover {
|
||
text-decoration: underline;
|
||
background: #1a2a3a;
|
||
}
|
||
/* Sticky top nav so Settings <-> Content Manager is one tap from anywhere on the page —
|
||
these two get switched between constantly while loading content for a service.
|
||
z-index sits below the page's own overlays (99999) so it can never cover a dialog.
|
||
The box-shadow spreads the bar's own background 20px sideways to cover the body's
|
||
padding gutters, and 20px down into its own margin, so content never shows through. */
|
||
.page-nav {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 500;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
background: #1a1a1a;
|
||
border-bottom: 1px solid #3a3a3a;
|
||
box-shadow: 0 0 0 20px #1a1a1a;
|
||
padding: 12px 0;
|
||
margin-bottom: 20px;
|
||
}
|
||
.page-nav .back-link { margin-bottom: 0; }
|
||
.nav-switch {
|
||
display: inline-block;
|
||
text-decoration: none;
|
||
background: #0090ff;
|
||
color: #fff;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
padding: 10px 18px;
|
||
border-radius: 6px;
|
||
white-space: nowrap;
|
||
transition: background 0.2s;
|
||
}
|
||
.nav-switch:hover { background: #33a6ff; }
|
||
.nav-switch:active { background: #0074cc; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="page-nav">
|
||
<a href="/" class="back-link" id="backToAppLink">← Back to App</a>
|
||
<a href="/content-manager.html" class="nav-switch">Content Manager →</a>
|
||
</div>
|
||
<h1>Settings</h1>
|
||
|
||
<div id="statusMessage" class="status"></div>
|
||
|
||
<!-- Tab Visibility Section (moved to top for quick access — most-used setting) -->
|
||
<div class="section">
|
||
<h2>Tab Visibility</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
Control which tabs are visible in the main app window. Uncheck tabs you don't want to see during presentations.
|
||
Changes take effect immediately when you return to the app.
|
||
</p>
|
||
|
||
<div class="checkbox-group" id="tabVisibilityGroup">
|
||
<!-- Checkboxes will be populated dynamically -->
|
||
</div>
|
||
|
||
<div style="margin-top: 20px;">
|
||
<span style="color:#8cc88c; font-size:13px; align-self:center;">✓ Saves automatically</span>
|
||
<button type="button" class="btn btn-secondary" onclick="resetTabVisibility()">Reset to Default</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Service Profiles Section -->
|
||
<div class="section">
|
||
<h2>Service Profiles</h2>
|
||
<p style="color: #999; margin-bottom: 16px;">
|
||
Save a named preset for each service type (Sunday, Together Tuesday, First Tuesday…). A profile bundles
|
||
your <strong>visible tabs, default graphics/announcements/notes, Bible text size, and OSC settings</strong>,
|
||
so you can switch your whole setup in one tap.
|
||
</p>
|
||
<div id="profilesList" style="margin-bottom: 16px; color:#999;">Loading…</div>
|
||
<div class="form-group">
|
||
<label for="newProfileName">New profile name</label>
|
||
<input type="text" id="newProfileName" placeholder="e.g. Sunday AM">
|
||
</div>
|
||
<div style="margin-top: 12px;">
|
||
<button type="button" class="btn" onclick="saveCurrentAsProfile()">Save current settings as profile</button>
|
||
</div>
|
||
<p style="color:#777; margin-top: 12px; font-size: 0.9em;">
|
||
"Save current" captures the app's current tab visibility, default media, font size, and OSC settings.
|
||
Applying a profile takes effect when you return to the app (OSC address/port changes need an app restart).
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Bible Reading Section -->
|
||
<div class="section">
|
||
<h2>Bible Reading</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
Adjust the on-screen size of the scripture text. Takes effect when you return to the app.
|
||
</p>
|
||
<div class="form-group">
|
||
<label for="bibleFontSizeSlider">Text size: <span id="bibleFontSizeValue">60px</span></label>
|
||
<input type="range" min="40" max="120" step="2" value="60" id="bibleFontSizeSlider"
|
||
oninput="applyBibleFontSizePreview(this.value)" style="width:100%;">
|
||
</div>
|
||
<div style="margin-top: 20px;">
|
||
<span style="color:#8cc88c; font-size:13px; align-self:center;">✓ Saves automatically</span>
|
||
<button type="button" class="btn btn-secondary" onclick="resetBibleReadingSettings()">Reset to Default</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Red Letter Section -->
|
||
<div class="section">
|
||
<h2>Red Letter (Words of Christ)</h2>
|
||
<div class="form-group">
|
||
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; font-weight:600;">
|
||
<input type="checkbox" id="redLetterToggle" onchange="saveRedLetterEnabled()">
|
||
<span>Show the words of Christ in red</span>
|
||
</label>
|
||
<p style="color:#999; margin-top:8px;">
|
||
Colors Jesus' spoken words red in the Bible reader, using a bundled public-domain words-of-Jesus
|
||
list. Only the quoted words within a verse are reddened. Takes effect when you return to the app.
|
||
</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="redLetterOverrides">Add or correct references</label>
|
||
<p style="color:#999; margin-bottom:8px;">
|
||
One per line, e.g. <code>John 3:16</code> or <code>Matthew 5:3-12</code>. These are added to the built-in list.
|
||
</p>
|
||
<textarea id="redLetterOverrides" rows="4" placeholder="John 3:16 Matthew 5:3-12"></textarea>
|
||
</div>
|
||
<div style="margin-top: 16px;">
|
||
<span style="color:#8cc88c; font-size:13px; align-self:center;">✓ Saves automatically</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Presentation Section -->
|
||
<div class="section">
|
||
<h2>Presentation</h2>
|
||
<div class="form-group" style="margin-bottom: 26px;">
|
||
<label style="font-weight:600;">Toolbar</label>
|
||
<p style="color:#999; margin-top:8px;">
|
||
<strong>Drag the toolbar handle</strong> (top-right) out to turn it into a floating dock you can place
|
||
anywhere your hand rests on stage (it dims when idle). <strong>Throw the dock to the top</strong> of the
|
||
screen to dock it back to the rail. Use "Tools to show" below to hide tools you don't use.
|
||
</p>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom: 26px;">
|
||
<label style="font-weight:600; margin-bottom:8px;">Tools to show</label>
|
||
<p style="color:#999; margin-bottom:12px;">
|
||
Hide tools you don't use to keep the toolbar and dock uncluttered on camera. Applies to <strong>both</strong>. (A Menu button is always available in the dock so Settings stays reachable.)
|
||
</p>
|
||
<div id="toolVisibilityGroup" style="display:flex; flex-wrap:wrap; gap:16px;">
|
||
<label style="display:flex; align-items:center; gap:6px; margin:0;"><input type="checkbox" data-tool="pen" onchange="saveToolVisibility()"> Pen</label>
|
||
<label style="display:flex; align-items:center; gap:6px; margin:0;"><input type="checkbox" data-tool="highlight" onchange="saveToolVisibility()"> Highlighter</label>
|
||
<label style="display:flex; align-items:center; gap:6px; margin:0;"><input type="checkbox" data-tool="erase" onchange="saveToolVisibility()"> Eraser</label>
|
||
<label style="display:flex; align-items:center; gap:6px; margin:0;"><input type="checkbox" data-tool="laser" onchange="saveToolVisibility()"> Laser</label>
|
||
<label style="display:flex; align-items:center; gap:6px; margin:0;"><input type="checkbox" data-tool="spotlight" onchange="saveToolVisibility()"> Spotlight</label>
|
||
<label style="display:flex; align-items:center; gap:6px; margin:0;"><input type="checkbox" data-tool="undo" onchange="saveToolVisibility()"> Undo</label>
|
||
<label style="display:flex; align-items:center; gap:6px; margin:0;"><input type="checkbox" data-tool="redo" onchange="saveToolVisibility()"> Redo</label>
|
||
<label style="display:flex; align-items:center; gap:6px; margin:0;"><input type="checkbox" data-tool="clear" onchange="saveToolVisibility()"> Clear</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom: 26px;">
|
||
<label style="font-weight:600; margin-bottom:8px;">Two-finger tool selector</label>
|
||
<p style="color:#999; margin-bottom:12px;">
|
||
Tap the passage with <strong>two fingers at once</strong> and the tools fan out around the tap; tap one
|
||
and the selector is gone. It appears only where a tool can be used (not on the book / chapter pickers,
|
||
images, Ask or Prayer), and any other touch, scroll or stroke puts it away.
|
||
</p>
|
||
<label style="display:flex; align-items:center; gap:8px; margin-bottom:18px;">
|
||
<input type="checkbox" id="toolWheelEnabled" onchange="saveToolWheelSettings()"> Two-finger tap opens the tool selector
|
||
</label>
|
||
|
||
<div id="toolWheelStyleGroup" style="margin-bottom:20px;">
|
||
<label style="font-weight:600; margin-bottom:10px; display:block;">Shape</label>
|
||
<label style="display:flex; align-items:flex-start; gap:10px; margin:0 0 12px 0; cursor:pointer;">
|
||
<input type="radio" name="toolWheelStyle" value="ring" onchange="saveToolWheelSettings()" style="margin-top:3px;">
|
||
<span>
|
||
<strong>Ring</strong> — the original.
|
||
<span style="color:#999;">Five separate discs fanned out around the tap. Big and unmissable; takes about a third of the width of the screen while it is up.</span>
|
||
</span>
|
||
</label>
|
||
<label style="display:flex; align-items:flex-start; gap:10px; margin:0; cursor:pointer;">
|
||
<input type="radio" name="toolWheelStyle" value="slim" onchange="saveToolWheelSettings()" style="margin-top:3px;">
|
||
<span>
|
||
<strong>Slim dial</strong> — smaller and less in the way.
|
||
<span style="color:#999;">One small dial cut into a segment per tool, with the close button in the middle. About two thirds the width and a third of the area, so much less of the passage is covered — and much less of it is on camera if the program feed catches the moment. Same gesture, same colours, same unfold.</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div style="display:grid; grid-template-columns: 320px 1fr; gap: 0 18px; align-items:end;">
|
||
<div class="form-group" style="margin-bottom:0;">
|
||
<label for="toolSnapBackSec">Highlighter / eraser return to the pen after this many seconds</label>
|
||
<input type="number" id="toolSnapBackSec" min="0" max="600" step="1" placeholder="0 = stay on the tool" onchange="saveToolWheelSettings()">
|
||
<div class="help-text">Seconds without a touch. 0 = a tool stays until it is changed (default). With a value, "what am I on?" is always "the pen" a few seconds after highlighting or erasing.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
Transition when switching Graphics / Announcements slides. <strong>Off = instant</strong> (default).
|
||
A crossfade looks smoother, but <strong>test it on the actual stage TV first</strong> — scroll a graphic and confirm there is no thin edge line before using it live.
|
||
</p>
|
||
<div class="form-group">
|
||
<label for="crossfadeSlider">Slide crossfade: <span id="crossfadeValue">Off</span></label>
|
||
<input type="range" min="0" max="600" step="50" value="0" id="crossfadeSlider"
|
||
oninput="applyCrossfadePreview(this.value)" style="width:100%;">
|
||
</div>
|
||
<div style="margin-top: 20px;">
|
||
<span style="color:#8cc88c; font-size:13px; align-self:center;">✓ Saves automatically</span>
|
||
<button type="button" class="btn btn-secondary" onclick="resetCrossfadeSettings()">Turn Off</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Updates Section -->
|
||
<div class="section">
|
||
<h2>Updates</h2>
|
||
<p style="color: #999; margin-bottom: 16px;">
|
||
Install new versions from a folder you control — a network share, a USB drive, or a web address.
|
||
No internet or app store needed. Current version: <strong id="updCurrentVersion">…</strong>
|
||
</p>
|
||
<div class="form-group" style="margin-bottom:14px;">
|
||
<label for="updSource">Update folder or address</label>
|
||
<input type="text" id="updSource" placeholder="\\WATERS-SERVER\builds\bible-app or D:\BibleAppBuilds or http://server/bible-app"
|
||
style="width:100%; max-width:640px; padding:10px; background:#1f1f1f; color:#e0e0e0; border:1px solid #444; border-radius:6px;">
|
||
<div style="font-size:12px; color:#777; margin-top:6px;">That location needs the installer plus a small <code>latest.json</code> — the build script writes both for you.</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:16px;">
|
||
<label style="display:flex; align-items:center; gap:8px;">
|
||
<input type="checkbox" id="updAutoCheck"> Check for updates automatically when the app starts
|
||
</label>
|
||
</div>
|
||
<div style="margin-bottom:14px;">
|
||
<span style="color:#8cc88c; font-size:13px; align-self:center;">✓ Saves automatically</span>
|
||
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()">Check for Updates</button>
|
||
<button type="button" class="btn" id="updInstallBtn" style="display:none; background:#1a3a2a; border-color:#00ff88; color:#00ff88;" onclick="installUpdate()">Download & Install</button>
|
||
</div>
|
||
<div id="updResult" style="color:#4a9eff; font-size:15px; line-height:1.7;"></div>
|
||
<div id="updProgressWrap" style="display:none; margin-top:12px;">
|
||
<div style="background:#1f1f1f; border:1px solid #444; border-radius:6px; height:22px; overflow:hidden; max-width:640px;">
|
||
<div id="updProgressBar" style="height:100%; width:0%; background:#4a9eff; transition:width .3s ease;"></div>
|
||
</div>
|
||
<div id="updProgressText" style="color:#999; font-size:13px; margin-top:6px;"></div>
|
||
</div>
|
||
<p style="color:#777; margin-top:14px; font-size:13px;">
|
||
The app closes while the installer runs, then reopens. Your notes, graphics, annotations and settings are kept.
|
||
</p>
|
||
|
||
<hr style="border:0; border-top:1px solid #333; margin:22px 0;">
|
||
<h3 style="margin:0 0 8px; font-size:16px; color:#e0e0e0;">Local AI model</h3>
|
||
<p style="color:#999; margin-bottom:12px;">
|
||
The offline AI model (about 1.9 GB) ships inside the installer, so a new machine has
|
||
working AI as soon as the app is installed — nothing extra to copy over. You only need the
|
||
box below to point at a different model file by hand.
|
||
</p>
|
||
<div id="modelStatus" style="color:#4a9eff; font-size:15px; margin-bottom:12px;">Checking…</div>
|
||
<div class="form-group" style="margin-bottom:12px;">
|
||
<label for="modelSource">Model file location (leave blank to use the update folder above)</label>
|
||
<input type="text" id="modelSource" placeholder="e.g. \\WATERS-SERVER\builds\bible-app\model.gguf"
|
||
style="width:100%; max-width:640px; padding:10px; background:#1f1f1f; color:#e0e0e0; border:1px solid #444; border-radius:6px;">
|
||
</div>
|
||
<button type="button" class="btn" id="modelInstallBtn" onclick="installAiModel()">Install AI Model</button>
|
||
<div id="modelProgressWrap" style="display:none; margin-top:12px;">
|
||
<div style="background:#1f1f1f; border:1px solid #444; border-radius:6px; height:22px; overflow:hidden; max-width:640px;">
|
||
<div id="modelProgressBar" style="height:100%; width:0%; background:#8cc88c; transition:width .3s ease;"></div>
|
||
</div>
|
||
<div id="modelProgressText" style="color:#999; font-size:13px; margin-top:6px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Phone Remote Section -->
|
||
<div class="section">
|
||
<h2>Phone Remote</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
Control the app from your phone over the church Wi-Fi (the phone must be on the same network as this computer).
|
||
Turn it on, set a PIN, then open the address below on your phone. Only people on your Wi-Fi who know the PIN can control the app.
|
||
</p>
|
||
<div class="form-group" style="margin-bottom:18px;">
|
||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||
<input type="checkbox" id="remoteEnabled" onchange="saveRemoteSettings(true)"> Enable phone remote
|
||
</label>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:18px;">
|
||
<label for="remotePin">PIN (4+ digits)</label>
|
||
<input type="text" id="remotePin" inputmode="numeric" autocomplete="off" placeholder="e.g. 4827" style="max-width:220px;">
|
||
</div>
|
||
<div style="margin-bottom:16px;">
|
||
<button type="button" class="btn" onclick="saveRemoteSettings(true)">Save</button>
|
||
</div>
|
||
<div id="remoteUrlBox" style="color:#4a9eff; font-size:15px; line-height:1.9;"></div>
|
||
<p style="color:#777; margin-top:14px; font-size:13px;">
|
||
If it won't connect: confirm the phone is on the same Wi-Fi, and that Windows Firewall allows the app on port 8080.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Admin Console Section -->
|
||
<div class="section">
|
||
<h2>Admin Console (offstage)</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
A private copy of the whole app, in your own browser, for fixing and updating things
|
||
<strong>without any of it appearing on the TV</strong>. Navigate, prep tabs, upload content, change
|
||
settings — the congregation sees none of it. When something is ready you push that
|
||
<strong>one change</strong> to the TV; nothing else on the stage moves.
|
||
</p>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
This uses its own PIN, separate from the phone remote above — so an operator with the remote PIN
|
||
cannot reach the admin console.
|
||
</p>
|
||
<div class="form-group" style="margin-bottom:18px;">
|
||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||
<input type="checkbox" id="adminEnabled" onchange="saveAdminSettings(true)"> Enable admin console
|
||
</label>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:18px;">
|
||
<label for="adminPin">Admin PIN (4+ digits)</label>
|
||
<input type="text" id="adminPin" inputmode="numeric" autocomplete="off" placeholder="e.g. 9153" style="max-width:220px;">
|
||
</div>
|
||
<div style="margin-bottom:16px;">
|
||
<button type="button" class="btn" onclick="saveAdminSettings(true)">Save</button>
|
||
</div>
|
||
<div id="adminUrlBox" style="color:#4a9eff; font-size:15px; line-height:1.9;"></div>
|
||
<p style="color:#777; margin-top:14px; font-size:13px;">
|
||
Content and settings changes save to this computer, so they reach the TV like any other edit.
|
||
Tabs, passages and drawings in your private copy stay private until you push them.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- ProPresenter Sync Section -->
|
||
<div class="section">
|
||
<h2>ProPresenter Sync</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
The ProPresenter Sync tab follows whatever scripture slide is live in ProPresenter and opens that verse,
|
||
the same way a tap on a verse card would. Enter the address of the ProPresenter computer
|
||
(ProPresenter 7: Preferences → Network → Enable Network; the default port is 1025).
|
||
On the tab itself nothing extra is shown — only a small dot: grey off, amber connecting, green live, red dropped.
|
||
</p>
|
||
<div class="form-group" style="margin-bottom:18px;">
|
||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||
<input type="checkbox" id="ppsyncEnabled"> Follow ProPresenter
|
||
</label>
|
||
</div>
|
||
<div style="display:grid; grid-template-columns: minmax(200px, 320px) 140px; gap: 0 18px; align-items:end;">
|
||
<div class="form-group">
|
||
<label for="ppsyncHost">IP address / host of the ProPresenter computer</label>
|
||
<input type="text" id="ppsyncHost" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="192.168.1.100">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="ppsyncPort">Port</label>
|
||
<input type="number" id="ppsyncPort" min="1" max="65535" placeholder="1025">
|
||
</div>
|
||
</div>
|
||
<div style="display:grid; grid-template-columns: 220px 200px; gap: 0 18px; align-items:end;">
|
||
<div class="form-group">
|
||
<label for="ppsyncVersion">ProPresenter version</label>
|
||
<select id="ppsyncVersion" style="width:100%; padding:10px; background:#1f1f1f; border:1px solid #4a4a4a; border-radius:6px; color:#e0e0e0; font-size:14px; font-family:inherit;">
|
||
<option value="auto">Auto-detect</option>
|
||
<option value="pro7">Pro 7 (network API)</option>
|
||
<option value="pro6">Pro 6 (stage display)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="ppsyncReconnect">Reconnect every (ms)</label>
|
||
<input type="number" id="ppsyncReconnect" min="500" max="30000" step="100" placeholder="3000">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="max-width:320px;">
|
||
<label for="ppsyncPassword">Network password <span style="color:#999; font-weight:400;">(stage display / Pro 6 only)</span></label>
|
||
<input type="password" id="ppsyncPassword" autocomplete="off" placeholder="none">
|
||
<div class="help-text" id="ppsyncPasswordHelp"></div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:18px;">
|
||
<label style="display:flex; align-items:center; gap:8px;">
|
||
<input type="checkbox" id="ppsyncAnnouncements"> Also watch the Announcement layer
|
||
</label>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:22px; padding-top:18px; border-top:1px solid #333;">
|
||
<label style="font-weight:600; margin-bottom:8px;">Annotations</label>
|
||
<p style="color:#999; margin-bottom:12px;">
|
||
By default the sync tab shares its marks with the Bible tabs: one chapter, one set of marks, wherever that
|
||
chapter is open. Turn this on to give the sync tab a store of its own — then the <strong>same book and
|
||
chapter</strong> can be open in a Bible tab and in the sync tab with <strong>different marks on each</strong>
|
||
(teach on one, follow the slides on the other).
|
||
</p>
|
||
<label style="display:flex; align-items:center; gap:8px; margin:0;">
|
||
<input type="checkbox" id="ppsyncSeparateInk" onchange="savePPSyncSeparateInk()"> Sync tab keeps its own annotations, separate from the Bible tabs
|
||
</label>
|
||
<div class="help-text">
|
||
Nothing is ever thrown away by this switch: each side's marks stay under their own store and come straight
|
||
back when it is switched again. Takes effect when you return to the app.
|
||
</div>
|
||
</div>
|
||
<div style="margin-bottom:12px; display:flex; flex-wrap:wrap; gap:8px 0; align-items:center;">
|
||
<button type="button" class="btn btn-secondary" onclick="testPPSyncConnection()">Test Connection</button>
|
||
<button type="button" class="btn" onclick="savePPSyncSettings()">Save</button>
|
||
<button type="button" class="btn btn-secondary" id="ppsyncForgetPw" onclick="forgetPPSyncPassword()" style="display:none;">Forget saved password</button>
|
||
</div>
|
||
<div id="ppsyncTestBox" style="color:#4a9eff; font-size:14px; line-height:1.7; min-height:1.4em;"></div>
|
||
<div id="ppsyncState" style="color:#999; font-size:14px; margin-top:6px; line-height:1.7;"></div>
|
||
<p style="color:#777; margin-top:14px; font-size:13px;">
|
||
Test uses the values in the form; nothing changes until Save. Slides are read label → notes → text, and the tab
|
||
holds its last verse when a slide carries no scripture or the link drops. Turn the tab itself on or off under Tab Visibility.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Follower Screen Section -->
|
||
<div class="section">
|
||
<h2>Follower Screen</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
A second screen anywhere on the network shows exactly what this TV shows — the Bible text, graphics,
|
||
announcements and live pen strokes — without any cable from this computer. Open the address below in a
|
||
fullscreen browser on the machine that feeds ProPresenter (or the switcher). It follows this TV automatically.
|
||
</p>
|
||
<div class="form-group" style="margin-bottom:18px;">
|
||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||
<input type="checkbox" id="followerEnabled" onchange="saveFollowerSettings()"> Allow follower screens
|
||
</label>
|
||
<div class="help-text">Off = any connected follower screen goes black at once and nothing can connect.</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Open this on the follower machine</label>
|
||
<div id="followerUrlBox" style="font-family:Consolas, monospace; font-size:15px; line-height:1.9; color:#e0e0e0; background:#1f1f1f; border:1px solid #4a4a4a; border-radius:6px; padding:10px 14px;">Reading this computer's address…</div>
|
||
<div class="help-text">Computed on this computer each time this page opens — every installation shows its own address. If several are listed, use the one on the same network as the follower machine (the .local name works on most Macs).</div>
|
||
</div>
|
||
<div id="followerState" style="color:#999; font-size:14px; margin-top:6px; line-height:1.7;"></div>
|
||
</div>
|
||
|
||
<!-- Translation Visibility Section -->
|
||
<div class="section">
|
||
<h2>Translation Visibility</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
Control which Bible translations are shown in the main app picker. The current translations stay enabled by default;
|
||
newly added English translations start disabled until you turn them on here.
|
||
</p>
|
||
|
||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 18px;">
|
||
<button type="button" class="btn btn-secondary" onclick="setAllTranslationVisibility(true)">Turn All On</button>
|
||
<button type="button" class="btn btn-secondary" onclick="setAllTranslationVisibility(false)">Turn All Off</button>
|
||
<button type="button" class="btn btn-secondary" onclick="resetTranslationVisibility()">Reset to Default</button>
|
||
</div>
|
||
|
||
<div class="checkbox-group" id="translationVisibilityGroup">
|
||
<!-- Checkboxes will be populated dynamically -->
|
||
</div>
|
||
|
||
<div style="margin-top: 20px;">
|
||
<span style="color:#8cc88c; font-size:13px; align-self:center;">✓ Saves automatically</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OSC Configuration Section -->
|
||
<div class="section">
|
||
<h2>OSC (Open Sound Control) Configuration</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
Configure OSC settings for remote control of the Bible App. OSC commands allow external devices
|
||
(like Bitfocus Companion) to control the app via UDP messages.
|
||
</p>
|
||
|
||
<div class="form-group">
|
||
<label>Status</label>
|
||
<div id="oscStatus" style="padding: 10px; background: #1f1f1f; border-radius: 6px;">
|
||
<span class="status-indicator inactive"></span>
|
||
<span>Loading...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="oscAddress">Listen Address</label>
|
||
<input type="text" id="oscAddress" placeholder="127.0.0.1 or 0.0.0.0">
|
||
<div class="help-text">
|
||
IP address to listen on. Use 127.0.0.1 for localhost only, or 0.0.0.0 for all interfaces.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="oscPort">Listen Port</label>
|
||
<input type="number" id="oscPort" placeholder="3333" min="1" max="65535">
|
||
<div class="help-text">
|
||
UDP port number for OSC messages (default: 3333).
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Allowed IP Addresses</label>
|
||
<div class="ip-list" id="allowedIPs">
|
||
<!-- Dynamically populated -->
|
||
</div>
|
||
<button type="button" class="btn btn-secondary btn-small add-btn" onclick="addIPAddress()">+ Add IP Address</button>
|
||
<div class="help-text">
|
||
Specific IP addresses allowed to send OSC commands. Leave empty to allow all (when bound to loopback).
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Allowed IP Prefixes</label>
|
||
<div class="ip-list" id="allowedPrefixes">
|
||
<!-- Dynamically populated -->
|
||
</div>
|
||
<button type="button" class="btn btn-secondary btn-small add-btn" onclick="addIPPrefix()">+ Add IP Prefix</button>
|
||
<div class="help-text">
|
||
IP address prefixes allowed to send OSC commands (e.g., "10." for all 10.x.x.x addresses).
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="oscToken">Security Token (Optional)</label>
|
||
<input type="password" id="oscToken" placeholder="Leave empty to disable token requirement">
|
||
<div class="help-text">
|
||
If set, OSC commands must include this token as an argument. Leave empty to disable token requirement.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="accordancePort">Accordance OSC Port (Optional)</label>
|
||
<input type="number" id="accordancePort" placeholder="53000" min="1" max="65535">
|
||
<div class="help-text">
|
||
Port number for Accordance Bible Software OSC server (if using Accordance integration).
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 25px; padding-top: 25px; border-top: 1px solid #333;">
|
||
<h3 style="margin-bottom: 15px; color: #4a9eff;">OSC Feedback Configuration</h3>
|
||
<p style="color: #999; margin-bottom: 15px; font-size: 13px;">
|
||
Configure OSC feedback settings for Companion integration. Feedback messages are sent back to confirm command execution.
|
||
</p>
|
||
|
||
<div class="form-group">
|
||
<div class="checkbox-item">
|
||
<label>
|
||
<input type="checkbox" id="oscFeedbackEnabled" onchange="updateOSCFeedbackEnabled()">
|
||
<div>
|
||
<strong>Enable OSC Feedback</strong>
|
||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||
When enabled, the app will send OSC feedback messages after processing commands. Required for Companion button confirmation.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="oscFeedbackAddress">Feedback Address (Optional)</label>
|
||
<input type="text" id="oscFeedbackAddress" placeholder="Leave empty to use the last OSC sender">
|
||
<div class="help-text">
|
||
IP address to send feedback messages to. Leave empty to use the most recently detected OSC sender.
|
||
For the most reliable Companion setup, set this to Companion's listener IP.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="oscFeedbackPort">Feedback Port (Optional)</label>
|
||
<input type="number" id="oscFeedbackPort" placeholder="Recommended: set a fixed port (example: 18000)" min="1" max="65535">
|
||
<div class="help-text">
|
||
Port number to send OSC feedback messages to.
|
||
For reliable Companion matching, use a fixed module feedback port (for example 18000).
|
||
If left empty, the app uses the sender port when available, then the last known OSC sender port.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:25px; padding-top:25px; border-top:1px solid #333;">
|
||
<label style="font-weight:600; margin-bottom:8px;">Information line — <code style="color:#4a9eff;">/bibleapp/feedback/information</code></label>
|
||
<p style="color:#999; margin-bottom:6px; font-size:13px;">
|
||
One path, one short line, for the button you actually watch during a service. It answers the two
|
||
things you cannot see from the booth: <strong>which tab he has moved to</strong>, and whether the
|
||
<strong>splash is up over the top of it</strong>. Sent as a single OSC string whenever it changes.
|
||
</p>
|
||
<p style="color:#777; margin-bottom:14px; font-size:13px;">
|
||
Reads like <code style="color:#8cc88c;">2/5 Bible | John 3 | LIVE</code> or
|
||
<code style="color:#8cc88c;">4/5 Sync | Romans 8 | SPLASH</code>.
|
||
Tick what you want in it — pieces always appear in the order below, whatever order you tick them.
|
||
Untick everything to switch the line off. All the older <code>/feedback/state/…</code> paths keep working.
|
||
</p>
|
||
<div id="oscInformationFields" style="display:flex; flex-wrap:wrap; gap:14px 22px;">
|
||
<label style="display:flex; align-items:center; gap:7px; margin:0;"><input type="checkbox" data-osc-info="tab"> Tab <span style="color:#777;">(2/5 Bible)</span></label>
|
||
<label style="display:flex; align-items:center; gap:7px; margin:0;"><input type="checkbox" data-osc-info="passage"> Passage <span style="color:#777;">(John 3)</span></label>
|
||
<label style="display:flex; align-items:center; gap:7px; margin:0;"><input type="checkbox" data-osc-info="translation"> Translation <span style="color:#777;">(ESV)</span></label>
|
||
<label style="display:flex; align-items:center; gap:7px; margin:0;"><input type="checkbox" data-osc-info="tool"> Tool <span style="color:#777;">(Pen)</span></label>
|
||
<label style="display:flex; align-items:center; gap:7px; margin:0;"><input type="checkbox" data-osc-info="color"> Pen colour <span style="color:#777;">(#ffff00)</span></label>
|
||
<label style="display:flex; align-items:center; gap:7px; margin:0;"><input type="checkbox" data-osc-info="splash"> Splash <span style="color:#777;">(LIVE / SPLASH)</span></label>
|
||
<label style="display:flex; align-items:center; gap:7px; margin:0;"><input type="checkbox" data-osc-info="ppsync"> ProPresenter <span style="color:#777;">(PP LIVE)</span></label>
|
||
</div>
|
||
<div class="help-text" style="margin-top:12px;">
|
||
Saved with the rest of this section by <strong>Save OSC Configuration</strong> below. In Companion, add one
|
||
OSC listener on <code>/bibleapp/feedback/information</code> and put its first argument on the button.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group" style="margin-top:25px; padding-top:25px; border-top:1px solid #333;">
|
||
<label style="font-weight:600; margin-bottom:8px;">Tab button colours</label>
|
||
<p style="color:#999; margin-bottom:6px; font-size:13px;">
|
||
The information line above is <em>words</em>, which is right for button text. A Companion
|
||
<strong>colour</strong> comes from a boolean feedback instead — <code>1</code> lights the button,
|
||
<code>0</code> puts it out. This sends which tab is active that second way, so a button can go green the
|
||
moment he moves to it.
|
||
</p>
|
||
<div style="color:#777; margin-bottom:14px; font-size:13px; line-height:1.9;">
|
||
<code style="color:#8cc88c;">/bibleapp/feedback/state/tab_active/<n></code> — <code>1</code> while the <strong>n-th tab on the row</strong> is showing (1–16).<br>
|
||
<code style="color:#8cc88c;">/bibleapp/feedback/state/tab_kind/<kind></code> — <code>1</code> while a tab of that <strong>kind</strong> is showing:<br>
|
||
<span style="margin-left:18px;">bible · sync · whiteboard · notes · media · alpha · announcements · graphics · maps · prayer · ask</span>
|
||
</div>
|
||
<label style="display:flex; align-items:center; gap:8px; margin:0;">
|
||
<input type="checkbox" id="oscFeedbackTabStates"> Send per-tab on/off states for Companion button colours
|
||
</label>
|
||
<div class="help-text" style="margin-top:12px;">
|
||
Use <strong>tab_active</strong> for a row of buttons that mirrors the tab bar, and <strong>tab_kind</strong>
|
||
for a fixed button that asks “is he on the whiteboard?” — that one stays right however the
|
||
tabs are reordered. Every slot and kind is re-sent on each change, so a button can never be left lit for a
|
||
tab he has already left. Slots past the end of the row just stay dark.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" class="btn" onclick="saveOSCConfig()">Save OSC Configuration</button>
|
||
<button type="button" class="btn btn-secondary" onclick="loadOSCConfig()">Reload Configuration</button>
|
||
</div>
|
||
|
||
<!-- OSC Diagnostics -->
|
||
<div class="section">
|
||
<h2>OSC Diagnostics</h2>
|
||
<p style="color: #999; margin-bottom: 15px;">
|
||
Test OSC connectivity and check if commands are being received.
|
||
</p>
|
||
<button type="button" class="btn" onclick="testOSCCommand()">Test OSC Command (Undo)</button>
|
||
<button type="button" class="btn" onclick="testOSCFeedback()">Test OSC Feedback</button>
|
||
<button type="button" class="btn" onclick="testOSCPacket()">Test OSC Packet (Raw)</button>
|
||
<button type="button" class="btn btn-secondary" onclick="testUDPConnectivity()">Test UDP Connectivity</button>
|
||
<button type="button" class="btn btn-secondary" onclick="checkOSCStatus()">Refresh Status</button>
|
||
<button type="button" class="btn btn-secondary" onclick="checkOSCActivity()">Check Recent Activity</button>
|
||
<button type="button" class="btn btn-secondary" onclick="checkOSCFeedbackLog()">Check Feedback Log</button>
|
||
<div id="diagnosticResult" style="margin-top: 15px; padding: 10px; background: #1f1f1f; border-radius: 6px; font-family: monospace; font-size: 12px; display: none; max-height: 400px; overflow-y: auto;"></div>
|
||
</div>
|
||
|
||
<!-- Splash Screen Section -->
|
||
<div class="section">
|
||
<h2>Splash Screen</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
The TV goes to the splash screen after it has been left alone for a while and comes back on the next
|
||
touch. Set how long "a while" is here. The splash also comes up from the refresh button, the phone
|
||
remote / Companion, and (if left on) a five-finger touch. The booth <strong>follower screen never shows a
|
||
splash of its own</strong> — it only mirrors this TV.
|
||
</p>
|
||
<div style="display:grid; grid-template-columns: 320px 1fr; gap: 0 18px; align-items:end;">
|
||
<div class="form-group">
|
||
<label for="idleSplashSeconds">Go to the splash after this many seconds without a touch</label>
|
||
<input type="number" id="idleSplashSeconds" min="0" max="3600" step="5" placeholder="60" onchange="saveSplashTriggers()">
|
||
<div class="help-text">Default 60. 0 = never go to the splash on its own. Applies at once, no restart.</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:18px;">
|
||
<label style="display:flex; align-items:center; gap:8px;">
|
||
<input type="checkbox" id="fiveFingerSplash" onchange="saveSplashTriggers()"> Five-finger touch shows the splash
|
||
</label>
|
||
<div class="help-text">Turn off if a resting hand keeps bringing the splash up.</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Recent splash appearances on this TV</label>
|
||
<div id="splashHistoryBox" style="font-family:Consolas, monospace; font-size:13px; line-height:1.8; color:#bbb; background:#1f1f1f; border:1px solid #4a4a4a; border-radius:6px; padding:10px 14px; min-height:2.2em;">—</div>
|
||
<div class="help-text">Every time the splash comes up, the app writes down when and why. If it ever appears unexpectedly, the reason is here.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Default Graphics Section -->
|
||
<div class="section">
|
||
<h2>Default Graphics</h2>
|
||
<p style="color: #999; margin-bottom: 20px;">
|
||
Control whether default graphics (splash.png, Notes.png, Announcements.png) are available in the app.
|
||
When enabled, default graphics will be automatically restored if they don't exist. When disabled, these files will be hidden and deleted from your content directories.
|
||
</p>
|
||
|
||
<div class="form-group">
|
||
<div class="checkbox-item">
|
||
<label>
|
||
<input type="checkbox" id="defaultGraphicsEnabled" onchange="saveDefaultGraphicsSetting()">
|
||
<div>
|
||
<strong>Enable Default Graphics</strong>
|
||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||
When enabled, default graphics (splash.png, Notes.png, Announcements.png) are available and will be automatically restored if missing.
|
||
When disabled, these files will be hidden and deleted from your content directories.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Popup Information Visibility Section -->
|
||
<div class="section">
|
||
<h2>Word Popup Cards</h2>
|
||
<p style="color: #999; margin-bottom: 16px;">
|
||
<strong>Double-tap any word</strong> in a Bible passage to open its card — including the exact Hebrew or
|
||
Greek word behind it in that verse. Choose below exactly what appears
|
||
on the cards; Hebrew (OT) and Greek (NT) are customized separately. Changes apply to new popups immediately.
|
||
</p>
|
||
<p style="color:#666; font-size:12px; margin-bottom:16px;">
|
||
Original-language data: TAHOT & TAGNT © STEPBible.org, Tyndale House Cambridge — CC BY 4.0.
|
||
</p>
|
||
<div class="form-group" style="margin-bottom: 18px;">
|
||
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
|
||
<input type="checkbox" id="wordPopupsEnabled" onchange="saveWordPopupsEnabled()"> Enable word popup cards (double-tap a word)
|
||
</label>
|
||
</div>
|
||
|
||
<div class="checkbox-group" id="popupVisibilityGroup">
|
||
<!-- Checkboxes will be populated dynamically -->
|
||
</div>
|
||
|
||
<div style="margin-top: 20px;">
|
||
<span style="color:#8cc88c; font-size:13px; align-self:center;">✓ Saves automatically</span>
|
||
<button type="button" class="btn btn-secondary" onclick="resetPopupVisibility()">Reset to Default</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Ask Tab (AI) Settings -->
|
||
<div class="section">
|
||
<h2>Ask Tab (AI Assistant)</h2>
|
||
<p style="color: #999; margin-bottom: 18px;">
|
||
The Ask tab uses the <strong>built-in Local AI (Llama 3.2 3B, tuned for live speed)</strong> — fully offline, nothing to install.
|
||
Answers are grounded in Scripture automatically. You can also add online API providers below,
|
||
or point it at a separate <a href="https://ollama.com" style="color: #4a9eff;">Ollama</a> install (advanced).
|
||
</p>
|
||
|
||
<div class="form-group">
|
||
<label for="askOllamaModel">Ollama model (only if using a separate Ollama install)</label>
|
||
<input type="text" id="askOllamaModel" placeholder="llama3.2" style="width: 100%; max-width: 360px; padding: 10px; background: #1f1f1f; color: #e0e0e0; border: 1px solid #444; border-radius: 6px;">
|
||
<div style="font-size: 12px; color: #777; margin-top: 6px;">Not used by the built-in Local AI. Examples: <code>llama3.2</code>, <code>mistral</code>, <code>qwen2.5:3b</code></div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Active provider</label>
|
||
<select id="askActiveProvider" style="width: 100%; max-width: 360px; padding: 10px; background: #1f1f1f; color: #e0e0e0; border: 1px solid #444; border-radius: 6px;"></select>
|
||
</div>
|
||
|
||
<h3 style="color: #fff; margin: 20px 0 10px; font-size: 1.1em;">Extra API providers (optional)</h3>
|
||
<p style="color: #999; margin-bottom: 12px; font-size: 13px;">
|
||
Add OpenAI, Anthropic, or any OpenAI-compatible endpoint. Keys are stored locally on this device only.
|
||
</p>
|
||
|
||
<div id="askProvidersList" style="display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px;"></div>
|
||
|
||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;">
|
||
<button type="button" class="btn" onclick="addAskProvider()">+ Add Provider</button>
|
||
<button type="button" class="btn" onclick="saveAskSettings()">Save AI Settings</button>
|
||
<button type="button" class="btn btn-secondary" onclick="loadAskSettings()">Reload</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OSC Commands Reference -->
|
||
<div class="section">
|
||
<h2>OSC Commands Reference</h2>
|
||
<p style="color: #999; margin-bottom: 15px;">
|
||
Available OSC command addresses (send to the configured address:port):
|
||
</p>
|
||
<div style="background: #1f1f1f; padding: 15px; border-radius: 6px; font-family: monospace; font-size: 13px; line-height: 1.8; max-height: 500px; overflow-y: auto;">
|
||
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Core Actions:</strong></div>
|
||
<div style="margin-left: 10px; margin-bottom: 10px;">
|
||
<div><strong>/bibleapp/undo</strong> (or <strong>/undo</strong>) - Undo last action</div>
|
||
<div><strong>/bibleapp/redo</strong> - Redo last undone action</div>
|
||
<div><strong>/bibleapp/reset</strong> (or <strong>/reset</strong>) - Reset/reload the app</div>
|
||
<div><strong>/bibleapp/clear</strong> - Clear current tab</div>
|
||
<div><strong>/bibleapp/clear_all</strong> - Clear all tabs</div>
|
||
<div><strong>/bibleapp/splash/show</strong> (or <strong>/splash/show</strong>) - Show splash screen</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Tabs:</strong></div>
|
||
<div style="margin-left: 10px; margin-bottom: 10px;">
|
||
<div><strong>/bibleapp/new</strong> (or <strong>/bibleapp/new/tab</strong>) - Create new Bible tab</div>
|
||
<div><strong>/bibleapp/new/whiteboard</strong> - Create new whiteboard tab</div>
|
||
<div><strong>/bibleapp/new/notes</strong> - Create new notes tab</div>
|
||
<div><strong>/bibleapp/close</strong> - Close active tab</div>
|
||
<div><strong>/bibleapp/tab/left</strong> - Switch to previous tab</div>
|
||
<div><strong>/bibleapp/tab/right</strong> - Switch to next tab</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Tools:</strong></div>
|
||
<div style="margin-left: 10px; margin-bottom: 10px;">
|
||
<div><strong>/bibleapp/tool</strong> <em>pen|highlight|erase|select</em> - Switch tool</div>
|
||
<div><strong>/bibleapp/tool/pen</strong> | <strong>/bibleapp/tool/highlight</strong> | <strong>/bibleapp/tool/erase</strong> | <strong>/bibleapp/tool/select</strong></div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Pen Color:</strong></div>
|
||
<div style="margin-left: 10px;">
|
||
<div><strong>/bibleapp/pen/color</strong> <em>#ff0000</em> - Set pen color (hex)</div>
|
||
<div><strong>/bibleapp/pen/color/<color></strong> - Set pen color from route segment</div>
|
||
<div style="color: #999;">Supported names: white, red, blue, green, yellow, black, orange, purple, pink, cyan</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 10px; margin-top: 10px;"><strong style="color: #4a9eff;">Presentation & Study:</strong></div>
|
||
<div style="margin-left: 10px; margin-bottom: 10px;">
|
||
<div><strong>/bibleapp/redletter</strong> - Toggle red-letter (words of Christ)</div>
|
||
<div><strong>/bibleapp/laser</strong> - Toggle laser pointer</div>
|
||
<div><strong>/bibleapp/spotlight</strong> - Toggle spotlight</div>
|
||
<div><strong>/bibleapp/present/off</strong> - Turn off laser/spotlight</div>
|
||
</div>
|
||
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Navigation:</strong></div>
|
||
<div style="margin-left: 10px; margin-bottom: 10px;">
|
||
<div><strong>/bibleapp/goto</strong> <em>John 3:16</em> - Jump to a passage (reference as argument)</div>
|
||
<div><strong>/bibleapp/chapter/next</strong> | <strong>/bibleapp/chapter/prev</strong> - Next / previous chapter</div>
|
||
</div>
|
||
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Profiles & Toolbar:</strong></div>
|
||
<div style="margin-left: 10px;">
|
||
<div><strong>/bibleapp/profile</strong> <em>Sunday</em> - Apply a service profile (name as argument)</div>
|
||
<div><strong>/bibleapp/dock/float</strong> | <strong>/bibleapp/dock/rail</strong> - Float / dock the toolbar</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 15px; padding: 10px; background: #1a2a3a; border-radius: 6px; font-size: 12px; color: #999;">
|
||
<strong>Note:</strong> If a security token is configured, include it as an argument in your OSC messages.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OSC Feedback Paths Reference -->
|
||
<div class="section">
|
||
<h2>OSC Feedback Paths Reference</h2>
|
||
<p style="color: #999; margin-bottom: 15px;">
|
||
The app sends OSC feedback messages back to Companion (or other OSC clients) after processing commands.
|
||
Use these paths to listen for command confirmations and update button states.
|
||
</p>
|
||
<div style="background: #1f1f1f; padding: 15px; border-radius: 6px; font-family: monospace; font-size: 13px; line-height: 1.8; max-height: 500px; overflow-y: auto;">
|
||
<div style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #333;">
|
||
<strong style="color: #4a9eff;">Feedback Format:</strong>
|
||
<div style="margin-top: 5px; color: #999; font-size: 12px;">
|
||
Address: <code>/bibleapp/feedback/<command></code><br>
|
||
Args: [status (1=success, 0=failure), error_message (optional), original_command]
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 10px;"><strong style="color: #4a9eff;">Command Feedback Paths:</strong></div>
|
||
<div style="margin-left: 10px; margin-bottom: 10px;">
|
||
<div><code>/bibleapp/feedback/undo</code> - Undo command feedback</div>
|
||
<div><code>/bibleapp/feedback/redo</code> - Redo command feedback</div>
|
||
<div><code>/bibleapp/feedback/reset</code> - Reset command feedback</div>
|
||
<div><code>/bibleapp/feedback/clear</code> - Clear current tab feedback</div>
|
||
<div><code>/bibleapp/feedback/clear_all</code> - Clear all tabs feedback</div>
|
||
<div><code>/bibleapp/feedback/close</code> - Close tab feedback</div>
|
||
<div><code>/bibleapp/feedback/tab_left</code> - Previous tab feedback</div>
|
||
<div><code>/bibleapp/feedback/tab_right</code> - Next tab feedback</div>
|
||
<div><code>/bibleapp/feedback/new_bible_tab</code> - New Bible tab feedback</div>
|
||
<div><code>/bibleapp/feedback/new_whiteboard_tab</code> - New whiteboard tab feedback</div>
|
||
<div><code>/bibleapp/feedback/new_notes_tab</code> - New notes tab feedback</div>
|
||
<div><code>/bibleapp/feedback/splash_show</code> - Splash screen feedback</div>
|
||
<div><code>/bibleapp/feedback/tool_pen</code> - Pen tool selection feedback</div>
|
||
<div><code>/bibleapp/feedback/tool_highlight</code> - Highlight tool feedback</div>
|
||
<div><code>/bibleapp/feedback/tool_erase</code> - Erase tool feedback</div>
|
||
<div><code>/bibleapp/feedback/tool_select</code> - Select tool feedback</div>
|
||
<div><code>/bibleapp/feedback/pen_color/<hex6></code> - Pen color feedback (example: <code>/bibleapp/feedback/pen_color/ff3333</code>)</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 15px; margin-bottom: 10px;"><strong style="color: #00d084;">Live State Feedback Paths:</strong></div>
|
||
<div style="margin-left: 10px; margin-bottom: 10px;">
|
||
<div><code>/bibleapp/feedback/state/tool_pen</code> - Pen tool is currently active (1 active, 0 inactive)</div>
|
||
<div><code>/bibleapp/feedback/state/tool_highlight</code> - Highlighter tool active state</div>
|
||
<div><code>/bibleapp/feedback/state/tool_erase</code> - Eraser tool active state</div>
|
||
<div><code>/bibleapp/feedback/state/tool_select</code> - Select tool active state</div>
|
||
<div><code>/bibleapp/feedback/state/pen_color/<hex6></code> - Active pen color state (1 active, 0 inactive)</div>
|
||
<div><code>/bibleapp/feedback/state/splash_visible</code> - Splash visibility state (1 visible, 0 hidden)</div>
|
||
<div><code>/bibleapp/feedback/state/action_undo</code> - Undo pulse for button flash feedback</div>
|
||
<div><code>/bibleapp/feedback/state/action_redo</code> - Redo pulse for button flash feedback</div>
|
||
<div><code>/bibleapp/feedback/state/action_clear</code> - Clear current tab pulse</div>
|
||
<div><code>/bibleapp/feedback/state/action_clear_all</code> - Clear all tabs pulse</div>
|
||
<div><code>/bibleapp/feedback/state/action_splash_show</code> - Splash show pulse</div>
|
||
<div><code>/bibleapp/feedback/state/action_new_bible_tab</code> - New Bible tab pulse</div>
|
||
<div><code>/bibleapp/feedback/state/action_new_whiteboard_tab</code> - New whiteboard tab pulse</div>
|
||
<div><code>/bibleapp/feedback/state/action_new_notes_tab</code> - New notes tab pulse</div>
|
||
<div><code>/bibleapp/feedback/state/action_refresh</code> - Refresh/reset pulse</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 15px; margin-bottom: 10px;"><strong style="color: #ff6b6b;">Error Feedback Paths:</strong></div>
|
||
<div style="margin-left: 10px;">
|
||
<div><code>/bibleapp/feedback/blocked</code> - IP address was blocked</div>
|
||
<div><code>/bibleapp/feedback/unknown</code> - No matching route found</div>
|
||
<div><code>/bibleapp/feedback/tool</code> - Invalid tool command</div>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 15px; padding: 10px; background: #1a2a3a; border-radius: 6px; font-size: 12px; color: #999;">
|
||
<strong>Usage in Companion:</strong> Listen for these feedback paths to update button states.
|
||
Status <code>1</code> = success (use for button confirmation), Status <code>0</code> = failure (use for error indication).
|
||
State paths under <code>/bibleapp/feedback/state/*</code> are also sent from app UI changes (not just OSC-triggered commands) so button states stay in sync.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentConfig = null;
|
||
|
||
function showStatus(message, type = 'info') {
|
||
const statusEl = document.getElementById('statusMessage');
|
||
statusEl.style.display = ''; // clear any inline hide from a previous timeout
|
||
statusEl.className = `status ${type}`;
|
||
statusEl.textContent = message;
|
||
clearTimeout(window.__statusHideT);
|
||
window.__statusHideT = setTimeout(() => {
|
||
statusEl.style.display = 'none';
|
||
}, 4000);
|
||
}
|
||
|
||
/*
|
||
* ---- Auto-save ----
|
||
* Setting up before a live service should not require hunting for Save buttons. Controls are
|
||
* tiered by how safe a half-finished value is:
|
||
* instant — toggles, sliders, dropdowns (discrete, idempotent, harmless if repeated)
|
||
* on blur — free text, so a partly-typed value is never committed as the real setting
|
||
* manual — things where a partial value actively breaks something: the remote PIN, the
|
||
* OSC address/port/token, API keys, and the profile NAME (which would create a
|
||
* new profile per keystroke). Those keep their Save button.
|
||
*/
|
||
const __autoSaveTimers = {};
|
||
function autoSaveLater(key, fn, ms) {
|
||
clearTimeout(__autoSaveTimers[key]);
|
||
__autoSaveTimers[key] = setTimeout(() => { __autoSaveTimers[key] = 0; try { fn(); } catch (_) {} }, ms || 600);
|
||
}
|
||
// One quiet, debounced confirmation instead of a toast per checkbox.
|
||
let __savedTickT = 0;
|
||
function savedTick() {
|
||
clearTimeout(__savedTickT);
|
||
__savedTickT = setTimeout(() => showStatus('Saved', 'success'), 250);
|
||
}
|
||
// Save free text when the operator leaves the field OR presses Enter — never mid-word.
|
||
function autoSaveOnBlur(id, saveFn) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
let dirty = false;
|
||
el.addEventListener('input', () => { dirty = true; });
|
||
const commit = () => { if (!dirty) return; dirty = false; try { saveFn(); } catch (_) {} };
|
||
el.addEventListener('blur', commit);
|
||
el.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); el.blur(); } });
|
||
}
|
||
/*
|
||
* Real "Reset to Default" actions. These buttons used to just call the load* functions,
|
||
* which re-read localStorage — fine when nothing was saved until you pressed Save, but a
|
||
* no-op now that the checkboxes save themselves. Write the defaults, then re-render.
|
||
*/
|
||
function resetTabVisibility() {
|
||
try {
|
||
localStorage.setItem('tabVisibility', JSON.stringify(getDefaultTabVisibility()));
|
||
loadTabVisibility();
|
||
showStatus('Tabs reset to default.', 'success');
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); }
|
||
}
|
||
function resetPopupVisibility() {
|
||
try {
|
||
localStorage.setItem('popupVisibility', JSON.stringify(getDefaultPopupVisibility()));
|
||
loadPopupVisibility();
|
||
showStatus('Popup card fields reset to default.', 'success');
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); }
|
||
}
|
||
function resetTranslationVisibility() {
|
||
try {
|
||
localStorage.setItem('translationVisibility', JSON.stringify(getDefaultTranslationVisibility()));
|
||
loadTranslationVisibility();
|
||
showStatus('Translations reset to default.', 'success');
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); }
|
||
}
|
||
|
||
function wireAutoSave() {
|
||
// Checkbox grids are rebuilt with innerHTML on every load/reset, so listen on the
|
||
// CONTAINER (delegated) — per-checkbox listeners would be destroyed by the next rebuild.
|
||
const delegate = (containerId, saveFn) => {
|
||
const c = document.getElementById(containerId);
|
||
if (!c) return;
|
||
c.addEventListener('change', (e) => {
|
||
if (!e.target || e.target.type !== 'checkbox') return;
|
||
try { saveFn({ quiet: true }); } catch (_) { try { saveFn(); } catch (_) {} }
|
||
savedTick();
|
||
});
|
||
};
|
||
delegate('tabVisibilityGroup', (o) => saveTabVisibility(o));
|
||
delegate('popupVisibilityGroup', (o) => savePopupVisibility(o));
|
||
delegate('translationVisibilityGroup', (o) => saveTranslationVisibility(o));
|
||
|
||
// Sliders: 'change' fires on finger-up, not per pixel — right for touch, no debounce.
|
||
const onChange = (id, fn) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('change', () => { try { fn(); } catch (_) {} });
|
||
};
|
||
onChange('bibleFontSizeSlider', () => saveBibleReadingSettings());
|
||
onChange('crossfadeSlider', () => saveCrossfadeSettings());
|
||
onChange('updAutoCheck', () => saveUpdateSettings());
|
||
onChange('askActiveProvider', () => saveAskSettings());
|
||
|
||
// Free text: commit on blur / Enter only.
|
||
autoSaveOnBlur('redLetterOverrides', () => saveRedLetterOverrides());
|
||
autoSaveOnBlur('updSource', () => saveUpdateSettings());
|
||
autoSaveOnBlur('askOllamaModel', () => saveAskSettings());
|
||
}
|
||
|
||
function updateOSCStatus(status) {
|
||
const statusEl = document.getElementById('oscStatus');
|
||
const indicator = statusEl.querySelector('.status-indicator');
|
||
const text = statusEl.querySelector('span:last-child');
|
||
|
||
if (status.moduleInstalled && status.listening && status.rendererReady) {
|
||
indicator.className = 'status-indicator active';
|
||
text.textContent = 'OSC is active and ready';
|
||
} else if (status.moduleInstalled && status.listening) {
|
||
indicator.className = 'status-indicator warning';
|
||
text.textContent = 'OSC listening but renderer not ready';
|
||
} else if (status.moduleInstalled) {
|
||
indicator.className = 'status-indicator inactive';
|
||
text.textContent = 'OSC module installed but not listening';
|
||
} else {
|
||
indicator.className = 'status-indicator inactive';
|
||
text.textContent = 'OSC module not installed';
|
||
}
|
||
}
|
||
|
||
async function loadOSCStatus() {
|
||
try {
|
||
const resp = await fetch('/api/osc/status');
|
||
const data = await resp.json();
|
||
if (data.ok && data.status) {
|
||
updateOSCStatus(data.status);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load OSC status:', e);
|
||
}
|
||
}
|
||
|
||
async function loadOSCConfig() {
|
||
try {
|
||
const resp = await fetch('/api/osc/config');
|
||
const data = await resp.json();
|
||
if (data.ok && data.config) {
|
||
currentConfig = data.config;
|
||
document.getElementById('oscAddress').value = data.config.address || '127.0.0.1';
|
||
document.getElementById('oscPort').value = data.config.port || 3333;
|
||
document.getElementById('accordancePort').value = data.config.accordancePort || '';
|
||
|
||
// Load feedback settings
|
||
const feedbackEnabled = data.config.feedbackEnabled !== false; // Default to true
|
||
document.getElementById('oscFeedbackEnabled').checked = feedbackEnabled;
|
||
document.getElementById('oscFeedbackAddress').value = data.config.feedbackAddress || '';
|
||
document.getElementById('oscFeedbackPort').value = data.config.feedbackPort || '';
|
||
// Information line: an absent list is a config saved before this existed, so fall back
|
||
// to the default pair rather than showing it as switched off.
|
||
const infoFields = Array.isArray(data.config.feedbackInformation)
|
||
? data.config.feedbackInformation
|
||
: ['tab', 'passage', 'splash'];
|
||
document.querySelectorAll('#oscInformationFields input[data-osc-info]').forEach(cb => {
|
||
cb.checked = infoFields.indexOf(cb.getAttribute('data-osc-info')) !== -1;
|
||
});
|
||
// Absent in a config saved before this existed: default on, as the server does.
|
||
document.getElementById('oscFeedbackTabStates').checked = data.config.feedbackTabStates !== false;
|
||
|
||
// Load allowed IPs
|
||
const allowedIPsContainer = document.getElementById('allowedIPs');
|
||
allowedIPsContainer.innerHTML = '';
|
||
if (data.config.allow && data.config.allow.length > 0) {
|
||
data.config.allow.forEach(ip => {
|
||
addIPAddress(ip);
|
||
});
|
||
} else {
|
||
// Add one empty field
|
||
addIPAddress('');
|
||
}
|
||
|
||
// Load allowed prefixes
|
||
const allowedPrefixesContainer = document.getElementById('allowedPrefixes');
|
||
allowedPrefixesContainer.innerHTML = '';
|
||
if (data.config.allowPrefixes && data.config.allowPrefixes.length > 0) {
|
||
data.config.allowPrefixes.forEach(prefix => {
|
||
addIPPrefix(prefix);
|
||
});
|
||
} else {
|
||
// Add one empty field
|
||
addIPPrefix('');
|
||
}
|
||
|
||
// Token field (show placeholder if token exists)
|
||
const tokenInput = document.getElementById('oscToken');
|
||
if (data.hasToken) {
|
||
tokenInput.placeholder = 'Token is set (enter new value to change)';
|
||
} else {
|
||
tokenInput.placeholder = 'Leave empty to disable token requirement';
|
||
tokenInput.value = '';
|
||
}
|
||
|
||
showStatus('Configuration loaded', 'success');
|
||
} else {
|
||
showStatus('Failed to load configuration', 'error');
|
||
}
|
||
} catch (e) {
|
||
showStatus('Error loading configuration: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveOSCConfig() {
|
||
try {
|
||
const address = document.getElementById('oscAddress').value.trim();
|
||
const port = parseInt(document.getElementById('oscPort').value, 10);
|
||
const accordancePort = document.getElementById('accordancePort').value.trim();
|
||
const token = document.getElementById('oscToken').value.trim();
|
||
|
||
// Collect allowed IPs
|
||
const allowedIPs = [];
|
||
document.querySelectorAll('#allowedIPs input').forEach(input => {
|
||
const ip = input.value.trim();
|
||
if (ip) allowedIPs.push(ip);
|
||
});
|
||
|
||
// Collect allowed prefixes
|
||
const allowedPrefixes = [];
|
||
document.querySelectorAll('#allowedPrefixes input').forEach(input => {
|
||
const prefix = input.value.trim();
|
||
if (prefix) allowedPrefixes.push(prefix);
|
||
});
|
||
|
||
// Get feedback settings
|
||
const feedbackEnabled = document.getElementById('oscFeedbackEnabled').checked;
|
||
const feedbackAddress = document.getElementById('oscFeedbackAddress').value.trim();
|
||
const feedbackPort = document.getElementById('oscFeedbackPort').value.trim();
|
||
const feedbackInformation = Array.from(
|
||
document.querySelectorAll('#oscInformationFields input[data-osc-info]:checked')
|
||
).map(cb => cb.getAttribute('data-osc-info'));
|
||
const feedbackTabStates = document.getElementById('oscFeedbackTabStates').checked;
|
||
|
||
const updates = {
|
||
address: address || '127.0.0.1',
|
||
port: port || 3333
|
||
};
|
||
|
||
if (allowedIPs.length > 0) updates.allow = allowedIPs;
|
||
if (allowedPrefixes.length > 0) updates.allowPrefixes = allowedPrefixes;
|
||
if (token) updates.token = token;
|
||
if (accordancePort) updates.accordancePort = parseInt(accordancePort, 10);
|
||
|
||
// Add feedback settings
|
||
updates.feedbackEnabled = feedbackEnabled;
|
||
if (feedbackAddress) updates.feedbackAddress = feedbackAddress;
|
||
if (feedbackPort) updates.feedbackPort = parseInt(feedbackPort, 10);
|
||
else updates.feedbackPort = null;
|
||
// Always sent, including empty — an empty list is how the information line is switched off.
|
||
updates.feedbackInformation = feedbackInformation;
|
||
updates.feedbackTabStates = feedbackTabStates;
|
||
|
||
const resp = await fetch('/api/osc/config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(updates)
|
||
});
|
||
|
||
const data = await resp.json();
|
||
if (data.ok) {
|
||
showStatus(data.message || 'Configuration saved successfully. Restart the app to apply OSC changes.', 'success');
|
||
// Reload config to reflect saved state
|
||
setTimeout(loadOSCConfig, 500);
|
||
} else {
|
||
showStatus('Failed to save: ' + (data.error || 'Unknown error'), 'error');
|
||
}
|
||
} catch (e) {
|
||
showStatus('Error saving configuration: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function updateOSCFeedbackEnabled() {
|
||
const enabled = document.getElementById('oscFeedbackEnabled').checked;
|
||
const addressField = document.getElementById('oscFeedbackAddress');
|
||
const portField = document.getElementById('oscFeedbackPort');
|
||
|
||
// Enable/disable fields based on checkbox
|
||
addressField.disabled = !enabled;
|
||
portField.disabled = !enabled;
|
||
}
|
||
|
||
function addIPAddress(value = '') {
|
||
const container = document.getElementById('allowedIPs');
|
||
const item = document.createElement('div');
|
||
item.className = 'ip-item';
|
||
item.innerHTML = `
|
||
<input type="text" value="${escapeHtml(value)}" placeholder="e.g., 10.6.3.222">
|
||
<button type="button" class="btn btn-danger btn-small" onclick="this.parentElement.remove()">Remove</button>
|
||
`;
|
||
container.appendChild(item);
|
||
}
|
||
|
||
function addIPPrefix(value = '') {
|
||
const container = document.getElementById('allowedPrefixes');
|
||
const item = document.createElement('div');
|
||
item.className = 'ip-item';
|
||
item.innerHTML = `
|
||
<input type="text" value="${escapeHtml(value)}" placeholder="e.g., 10.">
|
||
<button type="button" class="btn btn-danger btn-small" onclick="this.parentElement.remove()">Remove</button>
|
||
`;
|
||
container.appendChild(item);
|
||
}
|
||
|
||
// Robust "Back to App" function that always works
|
||
function goBackToApp() {
|
||
try {
|
||
// Use replace to prevent back button issues and ensure it doesn't open new window
|
||
window.location.replace('/');
|
||
} catch (e) {
|
||
// Fallback if replace fails
|
||
try {
|
||
window.location.href = '/';
|
||
} catch (e2) {
|
||
// Last resort
|
||
window.top.location.href = '/';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Set up back link handler (works for mouse, touch, and keyboard)
|
||
function setupBackLink() {
|
||
const backLink = document.getElementById('backToAppLink');
|
||
if (backLink) {
|
||
// Remove any existing handlers to prevent duplicates
|
||
const newBackLink = backLink.cloneNode(true);
|
||
backLink.parentNode.replaceChild(newBackLink, backLink);
|
||
|
||
// Prevent default anchor behavior
|
||
newBackLink.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
goBackToApp();
|
||
return false;
|
||
}, { passive: false, capture: true });
|
||
|
||
// Touch events for touchscreen TVs
|
||
newBackLink.addEventListener('touchstart', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
goBackToApp();
|
||
return false;
|
||
}, { passive: false, capture: true });
|
||
|
||
newBackLink.addEventListener('pointerdown', (e) => {
|
||
if (e.pointerType === 'touch') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
goBackToApp();
|
||
return false;
|
||
}
|
||
}, { passive: false, capture: true });
|
||
|
||
// Also prevent default href behavior
|
||
newBackLink.setAttribute('href', 'javascript:void(0)');
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Settings <-> Content Manager shortcut. Wired the same way as the back link rather than
|
||
* left as a plain anchor: on the touchscreen TV a bare <a> tap is unreliable, which is why
|
||
* the back link carries explicit touchstart/pointerdown handlers too.
|
||
*/
|
||
function setupNavSwitch() {
|
||
const link = document.querySelector('.nav-switch');
|
||
if (!link) return;
|
||
const go = (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
try { window.location.replace('/content-manager.html'); }
|
||
catch (_) { window.location.href = '/content-manager.html'; }
|
||
return false;
|
||
};
|
||
link.addEventListener('click', go, { passive: false, capture: true });
|
||
link.addEventListener('touchstart', go, { passive: false, capture: true });
|
||
link.addEventListener('pointerdown', (e) => {
|
||
if (e.pointerType === 'touch') return go(e);
|
||
}, { passive: false, capture: true });
|
||
link.setAttribute('href', 'javascript:void(0)');
|
||
}
|
||
|
||
// Set up immediately if DOM is ready, otherwise wait
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', setupBackLink);
|
||
document.addEventListener('DOMContentLoaded', setupNavSwitch);
|
||
} else {
|
||
setupBackLink();
|
||
setupNavSwitch();
|
||
}
|
||
|
||
// ESC key to go back (set up before DOMContentLoaded for immediate availability)
|
||
// CRITICAL FIX: Defensive handling for keyboard disconnect events - prevent black screen bug
|
||
window.addEventListener('keydown', (e) => {
|
||
try {
|
||
// Defensive check: ensure event object is valid before accessing properties
|
||
if (!e || typeof e !== 'object' || typeof e.key !== 'string') return;
|
||
if (e.key === 'Escape' || e.key === 'Esc') {
|
||
// Don't trigger if user is typing in an input field
|
||
if (e.target && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
goBackToApp();
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// Silently handle any errors from malformed keyboard events (e.g., keyboard disconnect)
|
||
console.warn('[KEYBOARD] Settings key handler error (likely keyboard disconnect):', err);
|
||
}
|
||
}, { capture: true });
|
||
|
||
async function loadDefaultGraphicsSetting() {
|
||
try {
|
||
const resp = await fetch('/api/default-graphics');
|
||
const data = await resp.json();
|
||
if (data.ok) {
|
||
document.getElementById('defaultGraphicsEnabled').checked = data.enabled === true;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load default graphics setting:', e);
|
||
}
|
||
}
|
||
|
||
async function saveDefaultGraphicsSetting() {
|
||
const checkbox = document.getElementById('defaultGraphicsEnabled');
|
||
const enabled = checkbox.checked;
|
||
const statusEl = document.getElementById('statusMessage');
|
||
|
||
try {
|
||
const resp = await fetch('/api/default-graphics', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ enabled })
|
||
});
|
||
const data = await resp.json();
|
||
|
||
if (data.ok) {
|
||
if (enabled) {
|
||
const restoredMsg = data.restoredFiles && data.restoredFiles.length > 0
|
||
? ` Restored: ${data.restoredFiles.join(', ')}.`
|
||
: '';
|
||
showStatus('Default graphics enabled.' + restoredMsg, 'success');
|
||
} else {
|
||
const deletedMsg = data.deletedFiles && data.deletedFiles.length > 0
|
||
? ` Deleted: ${data.deletedFiles.join(', ')}.`
|
||
: '';
|
||
showStatus('Default graphics disabled.' + deletedMsg, 'success');
|
||
}
|
||
} else {
|
||
checkbox.checked = !enabled; // Revert checkbox
|
||
showStatus('Failed to save setting: ' + (data.error || 'Unknown error'), 'error');
|
||
}
|
||
} catch (e) {
|
||
checkbox.checked = !enabled; // Revert checkbox
|
||
showStatus('Error saving setting: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function testOSCCommand() {
|
||
const resultEl = document.getElementById('diagnosticResult');
|
||
resultEl.style.display = 'block';
|
||
resultEl.innerHTML = 'Testing OSC command...';
|
||
resultEl.style.color = '#0090ff';
|
||
|
||
try {
|
||
const resp = await fetch('/api/osc/test', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({action: 'undo', payload: {}})
|
||
});
|
||
const data = await resp.json();
|
||
if (data.ok) {
|
||
resultEl.innerHTML = '✓ Test successful! Command sent to renderer.<br>' +
|
||
'Status: ' + JSON.stringify(data, null, 2);
|
||
resultEl.style.color = '#00ff88';
|
||
} else {
|
||
resultEl.innerHTML = '✗ Test failed: ' + (data.error || 'Unknown error') + '<br>' +
|
||
'Details: ' + JSON.stringify(data, null, 2);
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
} catch (e) {
|
||
resultEl.innerHTML = '✗ Error: ' + e.message;
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
}
|
||
|
||
async function testOSCFeedback() {
|
||
const resultEl = document.getElementById('diagnosticResult');
|
||
resultEl.style.display = 'block';
|
||
resultEl.innerHTML = 'Testing OSC feedback...<br>Check console logs for detailed output.';
|
||
resultEl.style.color = '#0090ff';
|
||
|
||
try {
|
||
// Get current feedback config (use configured values or defaults)
|
||
const feedbackAddress = document.getElementById('oscFeedbackAddress').value.trim();
|
||
const feedbackPort = document.getElementById('oscFeedbackPort').value.trim();
|
||
|
||
// If not configured, use fallback default (runtime may auto-detect sender port on real commands)
|
||
const testAddress = feedbackAddress || '10.6.3.222';
|
||
const testPort = feedbackPort ? parseInt(feedbackPort, 10) : 16759;
|
||
|
||
const testData = {
|
||
command: 'undo',
|
||
success: true,
|
||
address: testAddress,
|
||
port: testPort
|
||
};
|
||
|
||
console.log('[OSC Feedback Test] Sending test feedback with:', testData);
|
||
|
||
const resp = await fetch('/api/osc/test-feedback', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(testData)
|
||
});
|
||
const data = await resp.json();
|
||
if (data.ok) {
|
||
resultEl.innerHTML = '✓ Feedback test sent!<br>' +
|
||
'Command: ' + data.command + '<br>' +
|
||
'Target: ' + data.target + '<br>' +
|
||
'Success: ' + data.success + '<br><br>' +
|
||
'Check Companion to see if feedback was received.<br><br>' +
|
||
'<div style="background: #1a2a3a; padding: 10px; border-radius: 4px; font-size: 11px; color: #ffaa00;">' +
|
||
'⚠️ <strong>Important:</strong> Check the <strong>server console</strong> (Node.js terminal) for detailed <code>[OSC Feedback]</code> logs, not the browser console.</div>';
|
||
resultEl.style.color = '#00ff88';
|
||
} else {
|
||
resultEl.innerHTML = '✗ Test failed: ' + (data.error || 'Unknown error') + '<br>' +
|
||
'Details: ' + JSON.stringify(data, null, 2);
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
} catch (e) {
|
||
resultEl.innerHTML = '✗ Error: ' + e.message;
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
}
|
||
|
||
async function checkOSCStatus() {
|
||
const resultEl = document.getElementById('diagnosticResult');
|
||
resultEl.style.display = 'block';
|
||
resultEl.innerHTML = 'Checking OSC status...';
|
||
resultEl.style.color = '#0090ff';
|
||
|
||
try {
|
||
const resp = await fetch('/api/osc/status');
|
||
const data = await resp.json();
|
||
if (data.ok && data.status) {
|
||
const status = data.status;
|
||
let statusHtml = '<strong>OSC Status:</strong><br>' +
|
||
'Module Installed: ' + (status.moduleInstalled ? '✓ Yes' : '✗ No') + '<br>' +
|
||
'Port Listening: ' + (status.listening ? '✓ Yes' : '✗ No') + '<br>' +
|
||
'Port Open: ' + (status.portOpen ? '✓ Yes' : '✗ No') + '<br>' +
|
||
'Renderer Ready: ' + (status.rendererReady ? '✓ Yes' : '✗ No') + '<br>' +
|
||
'Listening Address: ' + status.address + '<br>' +
|
||
'Listening Port: ' + status.port + '<br>' +
|
||
'Token Required: ' + (status.hasToken ? '✓ Yes' : '✗ No') + '<br>' +
|
||
'Allowed IPs: ' + (status.allowedIPs.length > 0 ? status.allowedIPs.join(', ') : 'None (all allowed if loopback)') + '<br>' +
|
||
'Config File: ' + (status.configExists ? '✓ Found' : '✗ Not found') + '<br>' +
|
||
'Config Path: ' + status.configPath;
|
||
|
||
if (status.feedbackEnabled !== undefined) {
|
||
statusHtml += '<br><br><strong>Feedback Configuration:</strong><br>' +
|
||
'Feedback Enabled: ' + (status.feedbackEnabled ? '✓ Yes' : '✗ No') + '<br>' +
|
||
'Feedback Address: ' + (status.feedbackAddress || 'auto-detect') + '<br>' +
|
||
'Feedback Port: ' + (status.feedbackPort || 'auto-detect');
|
||
if (status.cachedFeedbackAddress || status.cachedFeedbackPort) {
|
||
statusHtml += '<br>' +
|
||
'Cached Last Sender: ' + ((status.cachedFeedbackAddress || 'unknown') + ':' + (status.cachedFeedbackPort || 'unknown'));
|
||
}
|
||
}
|
||
|
||
resultEl.innerHTML = statusHtml;
|
||
resultEl.style.color = status.moduleInstalled && status.listening && status.rendererReady ? '#00ff88' : '#ffaa00';
|
||
} else {
|
||
resultEl.innerHTML = '✗ Failed to get status: ' + (data.error || 'Unknown error');
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
} catch (e) {
|
||
resultEl.innerHTML = '✗ Error: ' + e.message;
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
}
|
||
|
||
async function testOSCPacket() {
|
||
const resultEl = document.getElementById('diagnosticResult');
|
||
resultEl.style.display = 'block';
|
||
resultEl.innerHTML = 'Testing OSC packet encoding and sending...';
|
||
resultEl.style.color = '#0090ff';
|
||
|
||
try {
|
||
// Get current feedback config
|
||
const feedbackAddress = document.getElementById('oscFeedbackAddress').value.trim() || '10.6.3.222';
|
||
const feedbackPort = document.getElementById('oscFeedbackPort').value.trim() || '16759';
|
||
|
||
const testData = {
|
||
address: feedbackAddress,
|
||
port: parseInt(feedbackPort, 10),
|
||
path: '/bibleapp/feedback/undo',
|
||
value: 1
|
||
};
|
||
|
||
console.log('[OSC Packet Test] Testing OSC packet with:', testData);
|
||
|
||
const resp = await fetch('/api/osc/test-osc-packet', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(testData)
|
||
});
|
||
const data = await resp.json();
|
||
if (data.ok) {
|
||
resultEl.innerHTML = '✓ OSC packet sent!<br>' +
|
||
'Target: ' + data.target + '<br>' +
|
||
'Path: ' + data.path + '<br>' +
|
||
'Value: ' + data.value + '<br>' +
|
||
'Packet Size: ' + data.packetSize + ' bytes<br><br>' +
|
||
'This sends a properly formatted OSC packet.<br>' +
|
||
'Check Companion to see if it receives this feedback.';
|
||
resultEl.style.color = '#00ff88';
|
||
} else {
|
||
resultEl.innerHTML = '✗ Test failed: ' + (data.error || 'Unknown error') + '<br>' +
|
||
'Details: ' + JSON.stringify(data, null, 2);
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
} catch (e) {
|
||
resultEl.innerHTML = '✗ Error: ' + e.message;
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
}
|
||
|
||
async function testUDPConnectivity() {
|
||
const resultEl = document.getElementById('diagnosticResult');
|
||
resultEl.style.display = 'block';
|
||
resultEl.innerHTML = 'Testing UDP connectivity...';
|
||
resultEl.style.color = '#0090ff';
|
||
|
||
try {
|
||
// Get current feedback config
|
||
const feedbackAddress = document.getElementById('oscFeedbackAddress').value.trim() || '10.6.3.222';
|
||
const feedbackPort = document.getElementById('oscFeedbackPort').value.trim() || '16759';
|
||
|
||
const testData = {
|
||
address: feedbackAddress,
|
||
port: parseInt(feedbackPort, 10)
|
||
};
|
||
|
||
console.log('[UDP Test] Testing UDP connectivity with:', testData);
|
||
|
||
const resp = await fetch('/api/osc/test-udp', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(testData)
|
||
});
|
||
const data = await resp.json();
|
||
if (data.ok) {
|
||
resultEl.innerHTML = '✓ UDP test packet sent!<br>' +
|
||
'Target: ' + data.target + '<br>' +
|
||
'Local Port: ' + data.localPort + '<br><br>' +
|
||
'This tests if UDP packets can reach Companion.<br>' +
|
||
'If Companion receives this, UDP connectivity is working.<br>' +
|
||
'If not, check firewall settings or network connectivity.';
|
||
resultEl.style.color = '#00ff88';
|
||
} else {
|
||
resultEl.innerHTML = '✗ UDP test failed: ' + (data.error || 'Unknown error') + '<br>' +
|
||
'Details: ' + JSON.stringify(data, null, 2);
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
} catch (e) {
|
||
resultEl.innerHTML = '✗ Error: ' + e.message;
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
}
|
||
|
||
async function checkOSCFeedbackLog() {
|
||
const resultEl = document.getElementById('diagnosticResult');
|
||
resultEl.style.display = 'block';
|
||
resultEl.innerHTML = 'Loading feedback log...';
|
||
resultEl.style.color = '#0090ff';
|
||
|
||
try {
|
||
const resp = await fetch('/api/osc/feedback-log');
|
||
const data = await resp.json();
|
||
if (data.ok && data.feedback) {
|
||
if (data.feedback.length === 0) {
|
||
resultEl.innerHTML = '<strong>OSC Feedback Log:</strong><br><br>No feedback messages sent yet.<br><br>Send an OSC command from Companion to see feedback activity here.';
|
||
resultEl.style.color = '#ffaa00';
|
||
} else {
|
||
let html = `<strong>OSC Feedback Log (Last ${data.feedback.length} of ${data.total}):</strong><br><br>`;
|
||
data.feedback.reverse().forEach((entry, idx) => {
|
||
const time = new Date(entry.time).toLocaleTimeString();
|
||
const statusColor = entry.success ? '#00ff88' : '#ff3333';
|
||
const statusIcon = entry.success ? '✓' : '✗';
|
||
html += `<div style="margin-bottom: 10px; padding: 8px; background: #1a1a1a; border-left: 3px solid ${statusColor};">`;
|
||
html += `<strong>${statusIcon} ${time}</strong> - Command: <code>${entry.command}</code><br>`;
|
||
html += `Target: <code>${entry.target || 'N/A'}</code><br>`;
|
||
html += `Path: <code>${entry.path || 'N/A'}</code><br>`;
|
||
html += `Status: ${entry.status || 'N/A'} (${entry.success ? 'success' : 'failure'})<br>`;
|
||
if (entry.args && entry.args.length > 0) {
|
||
html += `Args: ${entry.args.map(a => `${a.type}:${a.value}`).join(', ')}<br>`;
|
||
}
|
||
if (entry.error) {
|
||
html += `<span style="color: #ff3333;">Error: ${entry.error}</span><br>`;
|
||
}
|
||
html += `</div>`;
|
||
});
|
||
resultEl.innerHTML = html;
|
||
resultEl.style.color = '#0090ff';
|
||
}
|
||
} else {
|
||
resultEl.innerHTML = '✗ Failed to load feedback log: ' + (data.error || 'Unknown error');
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
} catch (e) {
|
||
resultEl.innerHTML = '✗ Error: ' + e.message;
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
}
|
||
|
||
async function checkOSCActivity() {
|
||
const resultEl = document.getElementById('diagnosticResult');
|
||
resultEl.style.display = 'block';
|
||
resultEl.innerHTML = 'Checking OSC activity...';
|
||
resultEl.style.color = '#0090ff';
|
||
|
||
try {
|
||
const resp = await fetch('/api/osc/activity');
|
||
const data = await resp.json();
|
||
if (data.ok && data.activity) {
|
||
if (data.activity.length === 0) {
|
||
resultEl.innerHTML = '<strong>OSC Activity Log:</strong><br><br>No OSC messages received yet.<br><br>Send an OSC command to see activity here.<br><br><div style="background: #1a2a3a; padding: 10px; border-radius: 4px; margin-top: 10px; font-size: 11px; color: #ffaa00;"><strong>⚠️ Important:</strong> OSC feedback logs appear in the <strong>server console</strong> (Node.js terminal), not the browser console. Check the terminal/command prompt where you started the app to see <code>[OSC Feedback]</code> messages.</div>';
|
||
resultEl.style.color = '#ffaa00';
|
||
} else {
|
||
let html = `<strong>OSC Activity Log (Last ${data.activity.length} of ${data.total}):</strong><br><br>`;
|
||
html += '<div style="background: #1a2a3a; padding: 10px; border-radius: 4px; margin-bottom: 10px; font-size: 11px; color: #ffaa00;">';
|
||
html += '⚠️ <strong>Note:</strong> OSC feedback logs appear in the <strong>server console</strong> (Node.js terminal), not the browser console. Check the terminal where you started the app to see <code>[OSC Feedback]</code> messages.';
|
||
html += '</div>';
|
||
data.activity.reverse().forEach((entry, idx) => {
|
||
const time = new Date(entry.time).toLocaleTimeString();
|
||
const statusColor = entry.status === 'executed' ? '#00ff88' :
|
||
entry.status === 'matched' ? '#0090ff' :
|
||
entry.status === 'failed' ? '#ff3333' :
|
||
entry.status === 'no_match' ? '#ffaa00' : '#0090ff';
|
||
html += `<div style="margin-bottom: 10px; padding: 8px; background: #1a1a1a; border-left: 3px solid ${statusColor};">`;
|
||
html += `<strong>${time}</strong> - From: ${entry.from}<br>`;
|
||
html += `Route: <code>${entry.address || '(empty)'}</code><br>`;
|
||
if (entry.normalizedRoute) {
|
||
html += `Normalized: <code>${entry.normalizedRoute}</code><br>`;
|
||
}
|
||
if (entry.args && entry.args.length > 0) {
|
||
html += `Args: ${JSON.stringify(entry.args.map(a => a.value))}<br>`;
|
||
}
|
||
html += `Status: <strong style="color: ${statusColor}">${entry.status || 'received'}</strong><br>`;
|
||
if (entry.action) {
|
||
html += `Action: ${entry.action}<br>`;
|
||
}
|
||
if (entry.error) {
|
||
html += `Error: <span style="color: #ff3333">${entry.error}</span><br>`;
|
||
}
|
||
html += `</div>`;
|
||
});
|
||
resultEl.innerHTML = html;
|
||
resultEl.style.color = '#00ff88';
|
||
}
|
||
} else {
|
||
resultEl.innerHTML = '✗ Failed to get activity: ' + (data.error || 'Unknown error');
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
} catch (e) {
|
||
resultEl.innerHTML = '✗ Error: ' + e.message;
|
||
resultEl.style.color = '#ff3333';
|
||
}
|
||
}
|
||
|
||
// Popup Visibility Functions
|
||
const POPUP_SECTIONS = [
|
||
{ key: 'title', label: 'Title', description: 'Word title and reference (e.g., "love — John 3:16")' },
|
||
// Verse-exact HEBREW card (STEP Bible) — the exact tagged word for THIS verse
|
||
{ key: 'ot_il', label: 'HEBREW CARD (verse-exact) — master', description: 'The exact Hebrew word behind the tapped word in this verse (STEP Bible, Tyndale House). Turns the whole card on/off.' },
|
||
{ key: 'ot_il_word', label: 'Hebrew: Original Word', description: 'The Hebrew word, large' },
|
||
{ key: 'ot_il_translit', label: 'Hebrew: Transliteration', description: 'How to pronounce it (e.g., be.re.Shit)' },
|
||
{ key: 'ot_il_gloss', label: 'Hebrew: Meaning In This Verse', description: 'What the word means right here (e.g., "in beginning")' },
|
||
{ key: 'ot_il_definition', label: 'Hebrew: Definition', description: 'The Strong\'s dictionary definition of this exact word' },
|
||
{ key: 'ot_il_origin', label: 'Hebrew: Word Origin', description: 'The roots the word is built from with their meanings (e.g., Israel — "he will rule as God")' },
|
||
{ key: 'ot_il_usage', label: 'Hebrew: Usage Count', description: 'How often this word appears in Scripture and how it is translated (e.g., "2246× — god, God of...")' },
|
||
{ key: 'ot_il_lemma', label: 'Hebrew: Dictionary Form', description: 'The root/dictionary form and its core meaning' },
|
||
{ key: 'ot_il_grammar', label: 'Hebrew: Grammar (plain English)', description: 'e.g., "Verb — qal perfect, 3rd masculine singular"' },
|
||
{ key: 'ot_il_morph_code', label: 'Hebrew: Grammar Code', description: 'Scholar code (e.g., HVqp3ms)' },
|
||
{ key: 'ot_il_strongs', label: 'Hebrew: Strong\'s Number', description: 'e.g., H7225' },
|
||
// Verse-exact GREEK card (STEP Bible)
|
||
{ key: 'nt_il', label: 'GREEK CARD (verse-exact) — master', description: 'The exact Greek word behind the tapped word in this verse (STEP Bible, Tyndale House). Turns the whole card on/off.' },
|
||
{ key: 'nt_il_word', label: 'Greek: Original Word', description: 'The Greek word, large' },
|
||
{ key: 'nt_il_translit', label: 'Greek: Transliteration', description: 'How to pronounce it (e.g., ēgapēsen)' },
|
||
{ key: 'nt_il_gloss', label: 'Greek: Meaning In This Verse', description: 'What the word means right here (e.g., "loved")' },
|
||
{ key: 'nt_il_definition', label: 'Greek: Definition', description: 'The Strong\'s dictionary definition of this exact word' },
|
||
{ key: 'nt_il_origin', label: 'Greek: Word Origin', description: 'The roots the word is built from with their meanings (e.g., Laodicea = laos "people" + dike "judgment")' },
|
||
{ key: 'nt_il_usage', label: 'Greek: Usage Count', description: 'How often this word appears in Scripture and how it is translated (e.g., "175× — angel (168), messenger (7)")' },
|
||
{ key: 'nt_il_lemma', label: 'Greek: Dictionary Form', description: 'The lemma and its core meaning (e.g., agapaō — to love)' },
|
||
{ key: 'nt_il_grammar', label: 'Greek: Grammar (plain English)', description: 'e.g., "Verb — aorist active indicative, 3rd person singular"' },
|
||
{ key: 'nt_il_morph_code', label: 'Greek: Grammar Code', description: 'Scholar code (e.g., V-AAI-3S)' },
|
||
{ key: 'nt_il_strongs', label: 'Greek: Strong\'s Number', description: 'e.g., G0025' },
|
||
// OT Sections
|
||
{ key: 'ot_wlca', label: 'OT: WLCa Source Text', description: 'Old Testament Hebrew source text (WLCa)' },
|
||
{ key: 'ot_bdbt', label: 'OT: BDBT Dictionary', description: 'Old Testament Hebrew dictionary (BDBT)' },
|
||
{ key: 'ot_original', label: 'OT: Original Text', description: 'Original Hebrew word (lexeme)' },
|
||
{ key: 'ot_transliteration', label: 'OT: Transliteration', description: 'Hebrew transliteration' },
|
||
{ key: 'ot_pronunciation', label: 'OT: Pronunciation', description: 'Hebrew pronunciation guide' },
|
||
{ key: 'ot_short_definition', label: 'OT: Short Definition', description: 'Brief Hebrew definition' },
|
||
{ key: 'ot_phonetic', label: 'OT: Phonetic', description: 'Hebrew phonetic spelling' },
|
||
{ key: 'ot_bdb_definition', label: 'OT: BDB Definition', description: 'Full Brown-Driver-Briggs definition' },
|
||
// NT Sections
|
||
{ key: 'nt_ntgt', label: 'NT: NTGT Source Text', description: 'New Testament Greek source text (NTGT)' },
|
||
{ key: 'nt_strongs_greek', label: 'NT: Strong\'s Greek Dictionary', description: 'New Testament Greek dictionary (Strong\'s)' },
|
||
{ key: 'nt_original', label: 'NT: Original Text', description: 'Original Greek word (lemma)' },
|
||
{ key: 'nt_transliteration', label: 'NT: Transliteration', description: 'Greek transliteration' },
|
||
{ key: 'nt_strongs_number', label: 'NT: Strong\'s Number', description: 'Strong\'s Greek number (e.g., G25)' },
|
||
{ key: 'nt_morphology', label: 'NT: Morphology', description: 'Greek morphological information' },
|
||
{ key: 'nt_interlinear', label: 'NT: Interlinear', description: 'Interlinear translation/gloss' },
|
||
{ key: 'nt_kjv_gloss', label: 'NT: KJV Gloss', description: 'King James Version gloss/definition' },
|
||
{ key: 'nt_derivation', label: 'NT: Derivation', description: 'Word derivation/etymology' },
|
||
{ key: 'nt_strongs_definition', label: 'NT: Strong\'s Definition', description: 'Full Strong\'s Greek definition' },
|
||
{ key: 'divider', label: 'Section Dividers', description: 'Visual dividers between sections' }
|
||
];
|
||
|
||
function getDefaultPopupVisibility() {
|
||
// All sections visible by default, except those that start OFF (pastor's spec: the
|
||
// default card is word + pronunciation + Definition + Origin). Matches POPUP_DEFAULT_OFF in the app.
|
||
const DEFAULT_OFF = ['ot_il_gloss', 'nt_il_gloss', 'ot_bdbt', 'nt_strongs_greek',
|
||
'ot_il_lemma', 'nt_il_lemma', 'ot_il_grammar', 'nt_il_grammar',
|
||
'ot_il_morph_code', 'nt_il_morph_code', 'ot_il_strongs', 'nt_il_strongs',
|
||
'ot_wlca', 'nt_ntgt'];
|
||
const visibility = {};
|
||
POPUP_SECTIONS.forEach(section => {
|
||
visibility[section.key] = DEFAULT_OFF.indexOf(section.key) === -1;
|
||
});
|
||
return visibility;
|
||
}
|
||
|
||
function loadPopupVisibility() {
|
||
try {
|
||
const saved = localStorage.getItem('popupVisibility');
|
||
const visibility = saved ? JSON.parse(saved) : getDefaultPopupVisibility();
|
||
|
||
const container = document.getElementById('popupVisibilityGroup');
|
||
container.innerHTML = '';
|
||
|
||
POPUP_SECTIONS.forEach(section => {
|
||
const item = document.createElement('div');
|
||
item.className = 'checkbox-item';
|
||
const isVisible = visibility[section.key] !== false; // Default to true if not set
|
||
item.innerHTML = `
|
||
<label>
|
||
<input type="checkbox" data-key="${section.key}" ${isVisible ? 'checked' : ''}>
|
||
<div>
|
||
<strong>${section.label}</strong>
|
||
<div style="font-size: 12px; color: #999; margin-top: 4px;">${section.description}</div>
|
||
</div>
|
||
</label>
|
||
`;
|
||
container.appendChild(item);
|
||
});
|
||
} catch (e) {
|
||
console.error('Failed to load popup visibility:', e);
|
||
// Show defaults on error
|
||
const container = document.getElementById('popupVisibilityGroup');
|
||
container.innerHTML = '<div style="color: #ff3333;">Error loading settings. Using defaults.</div>';
|
||
setTimeout(() => loadPopupVisibility(), 1000);
|
||
}
|
||
}
|
||
|
||
function loadWordPopupsEnabled() {
|
||
try {
|
||
var cb = document.getElementById('wordPopupsEnabled');
|
||
if (cb) cb.checked = localStorage.getItem('wordPopupsEnabled') !== 'false';
|
||
} catch (_) {}
|
||
}
|
||
function saveWordPopupsEnabled() {
|
||
try {
|
||
var cb = document.getElementById('wordPopupsEnabled');
|
||
localStorage.setItem('wordPopupsEnabled', cb && cb.checked ? 'true' : 'false');
|
||
showStatus(cb && cb.checked
|
||
? 'Word popup cards ON — double-tap a word in the app (return to the app and reload it).'
|
||
: 'Word popup cards off.', 'success');
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); }
|
||
}
|
||
|
||
function savePopupVisibility(options = {}) {
|
||
try {
|
||
const visibility = {};
|
||
const checkboxes = document.querySelectorAll('#popupVisibilityGroup input[type="checkbox"]');
|
||
|
||
checkboxes.forEach(checkbox => {
|
||
const key = checkbox.dataset.key;
|
||
visibility[key] = checkbox.checked;
|
||
});
|
||
|
||
localStorage.setItem('popupVisibility', JSON.stringify(visibility));
|
||
if (!options.quiet) showStatus('Popup visibility settings saved. New popups will use these settings.', 'success');
|
||
|
||
// Notify the main app window if it's open
|
||
try {
|
||
if (window.opener && !window.opener.closed) {
|
||
window.opener.postMessage({ type: 'popup-visibility-updated' }, '*');
|
||
}
|
||
} catch (_) {}
|
||
} catch (e) {
|
||
showStatus('Error saving popup visibility: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Tab Visibility Functions
|
||
const TAB_TYPES = [
|
||
{ mode: 'bible', label: 'Bible Tabs', description: 'Standard Bible reading tabs' },
|
||
{ mode: 'announcements', label: 'Announcements Tab', description: 'Image-based announcements display' },
|
||
{ mode: 'graphics', label: 'Graphics Tab', description: 'Image-based graphics display' },
|
||
{ mode: 'maps', label: 'Maps & Timelines Tab', description: 'Image gallery for maps and timelines' },
|
||
{ mode: 'whiteboard', label: 'Whiteboard Tab', description: 'Blank whiteboard for drawing' },
|
||
{ mode: 'notes', label: 'Notes Tab', description: 'PDF notes with annotations' },
|
||
{ mode: 'prayer', label: 'Prayer Tab', description: 'Text editor for prayers and notes' },
|
||
{ mode: 'ask', label: 'Ask Tab (AI)', description: 'Voice-driven AI assistant (offline-capable)' },
|
||
{ mode: 'ppsync', label: 'ProPresenter Sync Tab', description: 'Follows the scripture slide that is live in ProPresenter' }
|
||
];
|
||
|
||
function getDefaultTabVisibility() {
|
||
// All tabs visible by default
|
||
const visibility = {};
|
||
TAB_TYPES.forEach(tab => {
|
||
visibility[tab.mode] = true;
|
||
});
|
||
return visibility;
|
||
}
|
||
|
||
function loadTabVisibility() {
|
||
try {
|
||
const saved = localStorage.getItem('tabVisibility');
|
||
const visibility = saved ? JSON.parse(saved) : getDefaultTabVisibility();
|
||
|
||
const container = document.getElementById('tabVisibilityGroup');
|
||
container.innerHTML = '';
|
||
|
||
TAB_TYPES.forEach(tab => {
|
||
const item = document.createElement('div');
|
||
item.className = 'checkbox-item';
|
||
const isVisible = visibility[tab.mode] !== false; // Default to true if not set
|
||
item.innerHTML = `
|
||
<label>
|
||
<input type="checkbox" data-mode="${tab.mode}" ${isVisible ? 'checked' : ''}>
|
||
<div>
|
||
<strong>${tab.label}</strong>
|
||
<div style="font-size: 12px; color: #999; margin-top: 4px;">${tab.description}</div>
|
||
</div>
|
||
</label>
|
||
`;
|
||
container.appendChild(item);
|
||
});
|
||
} catch (e) {
|
||
console.error('Failed to load tab visibility:', e);
|
||
// Show defaults on error
|
||
const container = document.getElementById('tabVisibilityGroup');
|
||
container.innerHTML = '<div style="color: #ff3333;">Error loading settings. Using defaults.</div>';
|
||
setTimeout(() => loadTabVisibility(), 1000);
|
||
}
|
||
}
|
||
|
||
function saveTabVisibility(options = {}) {
|
||
try {
|
||
const visibility = {};
|
||
const checkboxes = document.querySelectorAll('#tabVisibilityGroup input[type="checkbox"]');
|
||
|
||
checkboxes.forEach(checkbox => {
|
||
const mode = checkbox.dataset.mode;
|
||
visibility[mode] = checkbox.checked;
|
||
});
|
||
|
||
localStorage.setItem('tabVisibility', JSON.stringify(visibility));
|
||
// quiet: auto-save uses one debounced "Saved" tick instead of a toast per tap.
|
||
if (!options.quiet) showStatus('Tab visibility settings saved. Return to the app to see changes.', 'success');
|
||
|
||
// Notify the main app window if it's open
|
||
try {
|
||
if (window.opener && !window.opener.closed) {
|
||
window.opener.postMessage({ type: 'tab-visibility-updated' }, '*');
|
||
}
|
||
} catch (_) {}
|
||
} catch (e) {
|
||
showStatus('Error saving tab visibility: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Translation Visibility Functions
|
||
const DEFAULT_ENABLED_TRANSLATIONS = ['ESV', 'NIV', 'NLT', 'KJV', 'NKJV', 'MSG', 'AMP', 'NASB'];
|
||
const TRANSLATION_TYPES = [
|
||
{ code: 'ESV', label: 'ESV', description: 'English Standard Version' },
|
||
{ code: 'NIV', label: 'NIV', description: 'New International Version' },
|
||
{ code: 'NLT', label: 'NLT', description: 'New Living Translation' },
|
||
{ code: 'KJV', label: 'KJV', description: 'King James Version' },
|
||
{ code: 'NKJV', label: 'NKJV', description: 'New King James Version' },
|
||
{ code: 'MSG', label: 'MSG', description: 'The Message' },
|
||
{ code: 'AMP', label: 'AMP', description: 'Amplified Bible' },
|
||
{ code: 'NASB', label: 'NASB', description: 'New American Standard Bible' },
|
||
{ code: 'ASV', label: 'ASV', description: 'American Standard Version' },
|
||
{ code: 'AUV', label: 'AUV', description: 'An Understandable Version' },
|
||
{ code: 'BSB', label: 'BSB', description: 'Berean Standard Bible' },
|
||
{ code: 'CEB', label: 'CEB', description: 'Common English Bible' },
|
||
{ code: 'CEVD', label: 'CEVD', description: 'Contemporary English Version' },
|
||
{ code: 'CJB', label: 'CJB', description: 'Complete Jewish Bible' },
|
||
{ code: 'CSB17', label: 'CSB17', description: 'Christian Standard Bible 2017' },
|
||
{ code: 'DRB', label: 'DRB', description: 'Douay-Rheims Bible' },
|
||
{ code: 'ERV', label: 'ERV', description: 'Easy-to-Read Version' },
|
||
{ code: 'GNT', label: 'GNT', description: 'Good News Translation' },
|
||
{ code: 'GNTD', label: 'GNTD', description: 'Good News Translation with Deuterocanon' },
|
||
{ code: 'GNV', label: 'GNV', description: 'Geneva Bible' },
|
||
{ code: 'ISV', label: 'ISV', description: 'International Standard Version' },
|
||
{ code: 'LBP', label: 'LBP', description: 'The Living Bible Paraphrased' },
|
||
{ code: 'LSB', label: 'LSB', description: 'Legacy Standard Bible' },
|
||
{ code: 'LSV', label: 'LSV', description: 'Literal Standard Version' },
|
||
{ code: 'LXXE', label: 'LXXE', description: 'Brenton English Septuagint' },
|
||
{ code: 'MEV', label: 'MEV', description: 'Modern English Version' },
|
||
{ code: 'NABRE', label: 'NABRE', description: 'New American Bible Revised Edition' },
|
||
{ code: 'NET', label: 'NET', description: 'New English Translation' },
|
||
{ code: 'NIV2011', label: 'NIV2011', description: 'New International Version 2011' },
|
||
{ code: 'NJB1985', label: 'NJB1985', description: 'New Jerusalem Bible 1985' },
|
||
{ code: 'NLV', label: 'NLV', description: 'New Life Version' },
|
||
{ code: 'NRSVCE', label: 'NRSVCE', description: 'New Revised Standard Version Catholic Edition' },
|
||
{ code: 'RSV', label: 'RSV', description: 'Revised Standard Version' },
|
||
{ code: 'RSV2CE', label: 'RSV2CE', description: 'Revised Standard Version, Second Catholic Edition' },
|
||
{ code: 'SPE', label: 'SPE', description: 'Samaritan Pentateuch in English' },
|
||
{ code: 'TLV', label: 'TLV', description: 'Tree of Life Version' },
|
||
{ code: 'TS2009', label: 'TS2009', description: 'The Scriptures 2009' },
|
||
{ code: 'WEB', label: 'WEB', description: 'World English Bible' },
|
||
{ code: 'YLT', label: 'YLT', description: "Young's Literal Translation" }
|
||
];
|
||
|
||
function getDefaultTranslationVisibility() {
|
||
const enabled = new Set(DEFAULT_ENABLED_TRANSLATIONS);
|
||
const visibility = {};
|
||
TRANSLATION_TYPES.forEach(t => {
|
||
visibility[t.code] = enabled.has(t.code);
|
||
});
|
||
return visibility;
|
||
}
|
||
|
||
function loadTranslationVisibility() {
|
||
try {
|
||
const defaults = getDefaultTranslationVisibility();
|
||
const saved = localStorage.getItem('translationVisibility');
|
||
const parsed = saved ? JSON.parse(saved) : {};
|
||
const visibility = (parsed && typeof parsed === 'object') ? { ...defaults, ...parsed } : defaults;
|
||
|
||
const container = document.getElementById('translationVisibilityGroup');
|
||
container.innerHTML = '';
|
||
|
||
TRANSLATION_TYPES.forEach(t => {
|
||
const item = document.createElement('div');
|
||
item.className = 'checkbox-item';
|
||
const isVisible = visibility[t.code] === true;
|
||
const defaultTag = DEFAULT_ENABLED_TRANSLATIONS.includes(t.code)
|
||
? '<span style="font-size: 11px; color: #4a9eff; margin-left: 8px;">default on</span>'
|
||
: '<span style="font-size: 11px; color: #888; margin-left: 8px;">new/off by default</span>';
|
||
item.innerHTML = `
|
||
<label>
|
||
<input type="checkbox" data-translation="${t.code}" onchange="saveTranslationVisibility({ quiet: true })" ${isVisible ? 'checked' : ''}>
|
||
<div>
|
||
<strong>${t.label}</strong>${defaultTag}
|
||
<div style="font-size: 12px; color: #999; margin-top: 4px;">${t.description}</div>
|
||
</div>
|
||
</label>
|
||
`;
|
||
container.appendChild(item);
|
||
});
|
||
} catch (e) {
|
||
console.error('Failed to load translation visibility:', e);
|
||
const container = document.getElementById('translationVisibilityGroup');
|
||
container.innerHTML = '<div style="color: #ff3333;">Error loading translation settings. Using defaults.</div>';
|
||
setTimeout(() => loadTranslationVisibility(), 1000);
|
||
}
|
||
}
|
||
|
||
function saveTranslationVisibility(options = {}) {
|
||
try {
|
||
const visibility = {};
|
||
const checkboxes = document.querySelectorAll('#translationVisibilityGroup input[type="checkbox"]');
|
||
checkboxes.forEach(checkbox => {
|
||
visibility[checkbox.dataset.translation] = checkbox.checked;
|
||
});
|
||
|
||
localStorage.setItem('translationVisibility', JSON.stringify(visibility));
|
||
if (!options.quiet) {
|
||
showStatus('Translation visibility settings applied. Return to the app to use enabled translations.', 'success');
|
||
}
|
||
|
||
try {
|
||
if (window.opener && !window.opener.closed) {
|
||
window.opener.postMessage({ type: 'translation-visibility-updated' }, '*');
|
||
}
|
||
} catch (_) {}
|
||
} catch (e) {
|
||
showStatus('Error saving translation visibility: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function setAllTranslationVisibility(enabled) {
|
||
const checkboxes = document.querySelectorAll('#translationVisibilityGroup input[type="checkbox"]');
|
||
checkboxes.forEach(checkbox => {
|
||
checkbox.checked = enabled === true;
|
||
});
|
||
saveTranslationVisibility();
|
||
}
|
||
|
||
// ===== Ask Tab (AI) Settings =====
|
||
const ASK_PROVIDER_KEY = 'ask.activeProvider';
|
||
const ASK_KEYS_STORAGE_KEY = 'ask.providers';
|
||
const ASK_OLLAMA_MODEL_KEY = 'ask.ollamaModel';
|
||
|
||
// Working copy of user-defined providers (NOT including the built-in Ollama row).
|
||
let askProvidersDraft = [];
|
||
// Currently chosen active provider id (may be 'ollama' or a custom id).
|
||
let askActiveProviderDraft = 'ollama';
|
||
|
||
function genProviderId() {
|
||
return 'p_' + Math.random().toString(36).slice(2, 10);
|
||
}
|
||
|
||
function loadAskSettings() {
|
||
try {
|
||
askProvidersDraft = [];
|
||
const raw = localStorage.getItem(ASK_KEYS_STORAGE_KEY);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed)) {
|
||
askProvidersDraft = parsed.filter(p => p && p.id !== 'ollama');
|
||
}
|
||
}
|
||
askActiveProviderDraft = localStorage.getItem(ASK_PROVIDER_KEY) || 'ollama';
|
||
const ollamaModelInput = document.getElementById('askOllamaModel');
|
||
if (ollamaModelInput) {
|
||
ollamaModelInput.value = localStorage.getItem(ASK_OLLAMA_MODEL_KEY) || 'llama3.2';
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load Ask settings:', e);
|
||
}
|
||
renderAskSettings();
|
||
}
|
||
|
||
function renderAskSettings() {
|
||
// Active-provider <select>
|
||
const sel = document.getElementById('askActiveProvider');
|
||
if (sel) {
|
||
const options = [{ id: 'ollama', name: 'Ollama (local, offline, free)' }]
|
||
.concat(askProvidersDraft.map(p => ({ id: p.id, name: p.name || (p.provider + ' provider') })));
|
||
sel.innerHTML = options.map(o => `<option value="${o.id}" ${o.id === askActiveProviderDraft ? 'selected' : ''}>${escapeHtml(o.name)}</option>`).join('');
|
||
sel.onchange = () => { askActiveProviderDraft = sel.value; };
|
||
}
|
||
|
||
// Provider rows
|
||
const list = document.getElementById('askProvidersList');
|
||
if (!list) return;
|
||
if (!askProvidersDraft.length) {
|
||
list.innerHTML = '<div style="color: #777; font-style: italic; padding: 12px;">No extra providers configured. Click "Add Provider" to add OpenAI, Anthropic, or a custom endpoint.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = askProvidersDraft.map((p, idx) => `
|
||
<div class="ask-provider-row" data-idx="${idx}" style="background: #1f1f1f; border: 1px solid #333; border-radius: 8px; padding: 14px;">
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||
<div>
|
||
<label style="font-size: 12px; color: #aaa;">Display name</label>
|
||
<input type="text" data-field="name" value="${escapeAttr(p.name || '')}" placeholder="My OpenAI Key" style="width: 100%; padding: 8px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 5px; margin-top: 4px;">
|
||
</div>
|
||
<div>
|
||
<label style="font-size: 12px; color: #aaa;">Type</label>
|
||
<select data-field="provider" style="width: 100%; padding: 8px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 5px; margin-top: 4px;">
|
||
<option value="openai" ${p.provider === 'openai' ? 'selected' : ''}>OpenAI / OpenAI-compatible</option>
|
||
<option value="anthropic" ${p.provider === 'anthropic' ? 'selected' : ''}>Anthropic Claude</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px;">
|
||
<div>
|
||
<label style="font-size: 12px; color: #aaa;">Model</label>
|
||
<input type="text" data-field="model" value="${escapeAttr(p.model || '')}" placeholder="${p.provider === 'anthropic' ? 'claude-3-5-sonnet-latest' : 'gpt-4o-mini'}" style="width: 100%; padding: 8px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 5px; margin-top: 4px;">
|
||
</div>
|
||
<div>
|
||
<label style="font-size: 12px; color: #aaa;">Base URL (optional)</label>
|
||
<input type="text" data-field="baseUrl" value="${escapeAttr(p.baseUrl || '')}" placeholder="${p.provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com'}" style="width: 100%; padding: 8px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 5px; margin-top: 4px;">
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: 10px;">
|
||
<label style="font-size: 12px; color: #aaa;">API key</label>
|
||
<input type="password" data-field="apiKey" value="${escapeAttr(p.apiKey || '')}" placeholder="sk-..." style="width: 100%; padding: 8px; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 5px; margin-top: 4px; font-family: monospace;">
|
||
</div>
|
||
<div style="margin-top: 10px; text-align: right;">
|
||
<button type="button" class="btn btn-secondary" onclick="removeAskProvider(${idx})" style="background: #5a2a2a; border-color: #884444;">Remove</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
// Wire up live-edit listeners
|
||
list.querySelectorAll('.ask-provider-row').forEach(row => {
|
||
const idx = parseInt(row.dataset.idx, 10);
|
||
row.querySelectorAll('[data-field]').forEach(input => {
|
||
input.addEventListener('input', () => {
|
||
const field = input.dataset.field;
|
||
if (askProvidersDraft[idx]) {
|
||
askProvidersDraft[idx][field] = input.value;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function addAskProvider() {
|
||
askProvidersDraft.push({
|
||
id: genProviderId(),
|
||
name: 'New Provider',
|
||
provider: 'openai',
|
||
baseUrl: '',
|
||
apiKey: '',
|
||
model: 'gpt-4o-mini'
|
||
});
|
||
renderAskSettings();
|
||
}
|
||
|
||
function removeAskProvider(idx) {
|
||
const removed = askProvidersDraft[idx];
|
||
askProvidersDraft.splice(idx, 1);
|
||
// If the active provider was the one removed, fall back to ollama.
|
||
if (removed && removed.id === askActiveProviderDraft) {
|
||
askActiveProviderDraft = 'ollama';
|
||
}
|
||
renderAskSettings();
|
||
}
|
||
|
||
function saveAskSettings() {
|
||
try {
|
||
const ollamaModelInput = document.getElementById('askOllamaModel');
|
||
const ollamaModel = (ollamaModelInput && ollamaModelInput.value || 'llama3.2').trim() || 'llama3.2';
|
||
localStorage.setItem(ASK_OLLAMA_MODEL_KEY, ollamaModel);
|
||
localStorage.setItem(ASK_KEYS_STORAGE_KEY, JSON.stringify(askProvidersDraft));
|
||
localStorage.setItem(ASK_PROVIDER_KEY, askActiveProviderDraft || 'ollama');
|
||
showStatus('AI settings saved. Return to the app to use them.', 'success');
|
||
try {
|
||
if (window.opener && !window.opener.closed) {
|
||
window.opener.postMessage({ type: 'ask-settings-updated' }, '*');
|
||
}
|
||
} catch (_) {}
|
||
} catch (e) {
|
||
showStatus('Error saving AI settings: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"').replace(/'/g, ''');
|
||
}
|
||
function escapeAttr(s) { return escapeHtml(s); }
|
||
|
||
// ---- Bible Reading (font size) ----
|
||
const BIBLE_FONT_SIZE_KEY = 'bibleFontSize';
|
||
const BIBLE_FONT_SIZE_DEFAULT = 60;
|
||
function applyBibleFontSizePreview(px) {
|
||
const label = document.getElementById('bibleFontSizeValue');
|
||
if (label) label.textContent = px + 'px';
|
||
}
|
||
function loadBibleReadingSettings() {
|
||
try {
|
||
const slider = document.getElementById('bibleFontSizeSlider');
|
||
if (!slider) return;
|
||
let v = parseInt(localStorage.getItem(BIBLE_FONT_SIZE_KEY), 10);
|
||
if (!v || isNaN(v)) v = BIBLE_FONT_SIZE_DEFAULT;
|
||
v = Math.max(40, Math.min(120, v));
|
||
slider.value = String(v);
|
||
applyBibleFontSizePreview(v);
|
||
} catch (_) {}
|
||
}
|
||
function saveBibleReadingSettings() {
|
||
try {
|
||
const slider = document.getElementById('bibleFontSizeSlider');
|
||
let v = parseInt(slider && slider.value, 10);
|
||
if (!v || isNaN(v)) v = BIBLE_FONT_SIZE_DEFAULT;
|
||
v = Math.max(40, Math.min(120, v));
|
||
localStorage.setItem(BIBLE_FONT_SIZE_KEY, String(v));
|
||
showStatus('Bible text size saved. Return to the app to see it.', 'success');
|
||
} catch (e) {
|
||
showStatus('Error saving text size: ' + e.message, 'error');
|
||
}
|
||
}
|
||
function resetBibleReadingSettings() {
|
||
try {
|
||
localStorage.setItem(BIBLE_FONT_SIZE_KEY, String(BIBLE_FONT_SIZE_DEFAULT));
|
||
loadBibleReadingSettings();
|
||
showStatus('Bible text size reset to default (' + BIBLE_FONT_SIZE_DEFAULT + 'px).', 'success');
|
||
} catch (_) {}
|
||
}
|
||
|
||
// ---- Presentation (slide crossfade) ----
|
||
const CROSSFADE_KEY = 'carouselCrossfadeMs';
|
||
function applyCrossfadePreview(v) {
|
||
const el = document.getElementById('crossfadeValue');
|
||
const n = parseInt(v, 10) || 0;
|
||
if (el) el.textContent = n > 0 ? (n + 'ms') : 'Off';
|
||
}
|
||
function loadCrossfadeSettings() {
|
||
try {
|
||
const s = document.getElementById('crossfadeSlider');
|
||
if (!s) return;
|
||
let v = parseInt(localStorage.getItem(CROSSFADE_KEY), 10);
|
||
if (!v || isNaN(v)) v = 0;
|
||
v = Math.max(0, Math.min(600, v));
|
||
s.value = String(v);
|
||
applyCrossfadePreview(v);
|
||
} catch (_) {}
|
||
}
|
||
function saveCrossfadeSettings() {
|
||
try {
|
||
const s = document.getElementById('crossfadeSlider');
|
||
let v = parseInt(s && s.value, 10) || 0;
|
||
v = Math.max(0, Math.min(1000, v));
|
||
localStorage.setItem(CROSSFADE_KEY, String(v));
|
||
showStatus(v > 0
|
||
? 'Crossfade saved (' + v + 'ms). Test it on the stage TV — scroll a graphic and check for an edge line — before using it live.'
|
||
: 'Slide transition set to instant.', 'success');
|
||
} catch (e) {
|
||
showStatus('Error saving transition: ' + e.message, 'error');
|
||
}
|
||
}
|
||
function resetCrossfadeSettings() {
|
||
try {
|
||
localStorage.setItem(CROSSFADE_KEY, '0');
|
||
loadCrossfadeSettings();
|
||
showStatus('Slide transition turned off (instant).', 'success');
|
||
} catch (_) {}
|
||
}
|
||
|
||
// ---- Updates (local source: network share / folder / URL — no GitHub, no cloud) ----
|
||
let __updPollTimer = null;
|
||
async function loadUpdateSettings() {
|
||
try {
|
||
const j = await (await fetch('/api/update/status')).json();
|
||
if (!j || !j.ok) return;
|
||
const v = document.getElementById('updCurrentVersion'); if (v) v.textContent = j.version || '—';
|
||
const s = document.getElementById('updSource'); if (s && !s.value) s.value = j.source || '';
|
||
const a = document.getElementById('updAutoCheck'); if (a) a.checked = j.autoCheck !== false;
|
||
if (j.lastResult) {
|
||
const r = document.getElementById('updResult');
|
||
if (r && !r.innerHTML) r.textContent = j.lastResult + (j.lastCheck ? ' (last checked ' + new Date(j.lastCheck).toLocaleString() + ')' : '');
|
||
}
|
||
if (j.job && j.job.state === 'working') pollUpdateJob();
|
||
} catch (_) {}
|
||
}
|
||
async function saveUpdateSettings() {
|
||
try {
|
||
const source = (document.getElementById('updSource').value || '').trim();
|
||
const autoCheck = !!document.getElementById('updAutoCheck').checked;
|
||
const j = await (await fetch('/api/update/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source, autoCheck }) })).json();
|
||
if (j && j.ok) showStatus('Update settings saved.', 'success');
|
||
else showStatus((j && j.error) || 'Could not save update settings.', 'error');
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); }
|
||
}
|
||
async function checkForUpdates() {
|
||
const r = document.getElementById('updResult');
|
||
const btn = document.getElementById('updInstallBtn');
|
||
r.textContent = 'Checking…';
|
||
btn.style.display = 'none';
|
||
try {
|
||
await saveUpdateSettings();
|
||
const res = await fetch('/api/update/check', { method: 'POST' });
|
||
const j = await res.json();
|
||
if (!j || !j.ok) { r.innerHTML = '<span style="color:#ff8888;">' + ((j && j.error) || 'Check failed') + '</span>'; return; }
|
||
if (j.updateAvailable) {
|
||
r.innerHTML = '<strong style="color:#00ff88;">Version ' + escapeHtmlLite(j.latest) + ' is available</strong> (you have ' + escapeHtmlLite(j.current) + ')'
|
||
+ (j.notes ? '<br><span style="color:#bbb;">' + escapeHtmlLite(j.notes) + '</span>' : '');
|
||
btn.style.display = '';
|
||
} else {
|
||
r.innerHTML = '<span style="color:#8cc88c;">You are up to date (version ' + escapeHtmlLite(j.current) + ').</span>';
|
||
}
|
||
} catch (e) { r.innerHTML = '<span style="color:#ff8888;">Check failed: ' + escapeHtmlLite(e.message) + '</span>'; }
|
||
}
|
||
function escapeHtmlLite(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||
async function installUpdate() {
|
||
const btn = document.getElementById('updInstallBtn');
|
||
btn.disabled = true;
|
||
document.getElementById('updProgressWrap').style.display = '';
|
||
try {
|
||
const j = await (await fetch('/api/update/install', { method: 'POST' })).json();
|
||
if (!j || !j.ok) { showStatus((j && j.error) || 'Could not start the update.', 'error'); btn.disabled = false; return; }
|
||
pollUpdateJob();
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); btn.disabled = false; }
|
||
}
|
||
function pollUpdateJob() {
|
||
if (__updPollTimer) clearInterval(__updPollTimer);
|
||
document.getElementById('updProgressWrap').style.display = '';
|
||
__updPollTimer = setInterval(async () => {
|
||
try {
|
||
const j = await (await fetch('/api/update/status')).json();
|
||
const job = (j && j.job) || {};
|
||
const bar = document.getElementById('updProgressBar');
|
||
const txt = document.getElementById('updProgressText');
|
||
if (bar) bar.style.width = (job.pct || 0) + '%';
|
||
if (txt) txt.textContent = (job.message || '') + (job.pct ? ' ' + job.pct + '%' : '');
|
||
if (job.state === 'ready') { if (txt) txt.textContent = 'Starting installer — the app will close and reopen…'; clearInterval(__updPollTimer); __updPollTimer = null; }
|
||
if (job.state === 'error') {
|
||
clearInterval(__updPollTimer); __updPollTimer = null;
|
||
if (txt) txt.textContent = '';
|
||
showStatus('Update failed: ' + (job.message || 'unknown error'), 'error');
|
||
const b = document.getElementById('updInstallBtn'); if (b) b.disabled = false;
|
||
}
|
||
} catch (_) {}
|
||
}, 700);
|
||
}
|
||
|
||
// ---- Local AI model (installed once, separately from the app) ----
|
||
let __modelPollTimer = null;
|
||
async function loadModelStatus() {
|
||
try {
|
||
const j = await (await fetch('/api/update/model-status')).json();
|
||
const el = document.getElementById('modelStatus');
|
||
const btn = document.getElementById('modelInstallBtn');
|
||
if (!j || !j.ok || !el) return;
|
||
if (j.ready) {
|
||
const where = j.source === 'bundled' ? 'included with the app'
|
||
: (j.source === 'shared' ? 'installed for all users on this PC' : 'installed');
|
||
el.innerHTML = '<span style="color:#8cc88c;">✓ AI model ' + where + ' (' + j.sizeMB + ' MB). The Ask tab is ready.</span>';
|
||
if (btn) btn.textContent = j.source === 'bundled' ? 'Replace AI Model' : 'Reinstall AI Model';
|
||
} else if (j.sizeMB > 0) {
|
||
el.innerHTML = '<span style="color:#ffb86b;">Model looks incomplete (' + j.sizeMB + ' of ~' + j.expectedMB + ' MB). Install it again.</span>';
|
||
} else {
|
||
el.innerHTML = '<span style="color:#ffb86b;">AI model not installed yet — the Ask tab needs it (about ' + j.expectedMB + ' MB, one time).</span>';
|
||
}
|
||
if (j.job && j.job.state === 'working') pollModelJob();
|
||
} catch (_) {}
|
||
}
|
||
async function installAiModel() {
|
||
const btn = document.getElementById('modelInstallBtn');
|
||
const source = (document.getElementById('modelSource').value || '').trim();
|
||
btn.disabled = true;
|
||
document.getElementById('modelProgressWrap').style.display = '';
|
||
try {
|
||
const j = await (await fetch('/api/update/model-install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source }) })).json();
|
||
if (!j || !j.ok) { showStatus((j && j.error) || 'Could not start the model install.', 'error'); btn.disabled = false; return; }
|
||
pollModelJob();
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); btn.disabled = false; }
|
||
}
|
||
function pollModelJob() {
|
||
if (__modelPollTimer) clearInterval(__modelPollTimer);
|
||
document.getElementById('modelProgressWrap').style.display = '';
|
||
__modelPollTimer = setInterval(async () => {
|
||
try {
|
||
const j = await (await fetch('/api/update/model-status')).json();
|
||
const job = (j && j.job) || {};
|
||
const bar = document.getElementById('modelProgressBar');
|
||
const txt = document.getElementById('modelProgressText');
|
||
if (bar) bar.style.width = (job.pct || 0) + '%';
|
||
if (txt) txt.textContent = (job.message || '') + (job.pct ? ' ' + job.pct + '%' : '');
|
||
if (job.state === 'done' || job.state === 'error') {
|
||
clearInterval(__modelPollTimer); __modelPollTimer = null;
|
||
document.getElementById('modelInstallBtn').disabled = false;
|
||
if (job.state === 'error') showStatus('Model install failed: ' + (job.message || ''), 'error');
|
||
else showStatus('AI model installed. The Ask tab will be ready shortly.', 'success');
|
||
loadModelStatus();
|
||
}
|
||
} catch (_) {}
|
||
}, 800);
|
||
}
|
||
|
||
// ---- Phone remote (LAN control, PIN-gated; PIN stored server-side in remote-config.json) ----
|
||
function renderRemoteUrls(j) {
|
||
const box = document.getElementById('remoteUrlBox');
|
||
if (!box) return;
|
||
if (!j || !j.enabled) { box.innerHTML = '<span style="color:#999;">Turn it on and save to see the phone address.</span>'; return; }
|
||
const urls = (j.urls && j.urls.length) ? j.urls : [];
|
||
if (!urls.length) {
|
||
box.innerHTML = '<span style="color:#999;">On your phone open: http://[this computer\'s IP]:' + (j.port || 8080) + '/remote.html</span>';
|
||
return;
|
||
}
|
||
box.innerHTML = '<strong>On your phone, open:</strong><br>' + urls.map(function (u) { return ' ' + u; }).join('<br>');
|
||
}
|
||
async function loadRemoteSettings() {
|
||
try {
|
||
const j = await (await fetch('/api/remote/info')).json();
|
||
if (!j || !j.ok) return;
|
||
window.__remoteHasPin = !!j.hasPin;
|
||
const cb = document.getElementById('remoteEnabled');
|
||
if (cb) cb.checked = !!j.enabled;
|
||
const pinEl = document.getElementById('remotePin');
|
||
if (pinEl) pinEl.placeholder = j.hasPin ? '•••• (set — type to change)' : 'e.g. 4827';
|
||
renderRemoteUrls(j);
|
||
} catch (_) {}
|
||
}
|
||
async function saveRemoteSettings(showMsg) {
|
||
try {
|
||
const cb = document.getElementById('remoteEnabled');
|
||
const pinEl = document.getElementById('remotePin');
|
||
const enabled = !!(cb && cb.checked);
|
||
const pin = ((pinEl && pinEl.value) || '').trim();
|
||
if (pin && !/^\d{4,}$/.test(pin)) { showStatus('PIN should be at least 4 digits (numbers only).', 'error'); return; }
|
||
if (enabled && !pin && !window.__remoteHasPin) {
|
||
showStatus('Set a PIN before enabling the phone remote.', 'error');
|
||
if (cb) cb.checked = false;
|
||
return;
|
||
}
|
||
const body = { enabled: enabled };
|
||
if (pin) body.pin = pin;
|
||
const j = await (await fetch('/api/remote/config', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||
})).json();
|
||
if (j && j.ok) {
|
||
if (pinEl) pinEl.value = '';
|
||
if (showMsg) showStatus(enabled ? 'Phone remote is ON.' : 'Phone remote is off.', 'success');
|
||
loadRemoteSettings();
|
||
} else {
|
||
showStatus((j && j.error) || 'Could not save remote settings.', 'error');
|
||
}
|
||
} catch (e) { showStatus('Error saving remote settings: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// ---- ProPresenter Sync (link config lives server-side in propresenter-config.json; see propresenter-link.js) ----
|
||
let __ppsyncHasPassword = false;
|
||
function ppsyncStateLabel(st) {
|
||
if (!st) return '';
|
||
const s = st.status;
|
||
const via = st.transport === 'stage-ws' ? 'stage display protocol' : st.transport === 'pro7-stream' ? 'Pro 7 network API, live stream' : st.transport === 'pro7-poll' ? 'Pro 7 network API, polling' : '';
|
||
if (s === 'live') {
|
||
let txt = 'Status: LIVE' + (via ? ' via ' + via : '');
|
||
if (st.info && st.info.host_description) txt += ' — ' + st.info.host_description;
|
||
if (st.slide && st.slide.presentation) txt += ' · showing "' + st.slide.presentation + '"';
|
||
return txt;
|
||
}
|
||
if (s === 'connecting') return 'Status: connecting…';
|
||
if (s === 'disconnected') return 'Status: disconnected — retrying every few seconds' + (st.error ? ' (' + st.error + ')' : '');
|
||
return 'Status: off';
|
||
}
|
||
function ppsyncReadForm() {
|
||
const v = (id) => document.getElementById(id);
|
||
return {
|
||
enabled: !!v('ppsyncEnabled').checked,
|
||
host: String(v('ppsyncHost').value || '').trim(),
|
||
port: parseInt(v('ppsyncPort').value, 10),
|
||
version: v('ppsyncVersion').value,
|
||
reconnectMs: parseInt(v('ppsyncReconnect').value, 10),
|
||
watchAnnouncements: !!v('ppsyncAnnouncements').checked,
|
||
password: String(v('ppsyncPassword').value || '')
|
||
};
|
||
}
|
||
function ppsyncValidate(f, needHost) {
|
||
if (needHost && !f.host) return 'Enter the ProPresenter computer\'s IP address or host name.';
|
||
if (f.host && !/^[A-Za-z0-9.\-_:\[\]]+$/.test(f.host)) return 'That does not look like an IP address or host name.';
|
||
if (!(f.port >= 1 && f.port <= 65535)) return 'Port must be between 1 and 65535.';
|
||
if (!(f.reconnectMs >= 500 && f.reconnectMs <= 30000)) return 'Reconnect interval must be 500–30000 ms.';
|
||
return null;
|
||
}
|
||
/*
|
||
* Whether the ProPresenter Sync tab draws on the Bible tabs' chapter store or on one of its own.
|
||
* Lives in localStorage (not the link config) because it is the APP's annotation routing, read by
|
||
* app.js's getPassageKey on every stroke; the link config is only about reaching ProPresenter.
|
||
*/
|
||
const PPSYNC_SEPARATE_INK_KEY = 'ppsyncSeparateAnnotations';
|
||
function loadPPSyncSeparateInk() {
|
||
try {
|
||
const cb = document.getElementById('ppsyncSeparateInk');
|
||
if (cb) cb.checked = localStorage.getItem(PPSYNC_SEPARATE_INK_KEY) === '1';
|
||
} catch (_) {}
|
||
}
|
||
function savePPSyncSeparateInk() {
|
||
try {
|
||
const on = !!document.getElementById('ppsyncSeparateInk').checked;
|
||
localStorage.setItem(PPSYNC_SEPARATE_INK_KEY, on ? '1' : '0');
|
||
showStatus(on
|
||
? 'The sync tab now keeps its own annotations. Open the same chapter in a Bible tab and in the sync tab and each keeps its own marks.'
|
||
: 'The sync tab shares annotations with the Bible tabs again — one chapter, one set of marks.', 'success');
|
||
} catch (e) { showStatus('Could not save that: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function loadPPSyncSettings() {
|
||
loadPPSyncSeparateInk();
|
||
try {
|
||
const j = await (await fetch('/api/propresenter/config')).json();
|
||
if (!j || !j.ok) return;
|
||
const c = j.config || {};
|
||
__ppsyncHasPassword = !!c.hasPassword;
|
||
document.getElementById('ppsyncEnabled').checked = !!c.enabled;
|
||
document.getElementById('ppsyncHost').value = c.host || '';
|
||
document.getElementById('ppsyncPort').value = c.port || 1025;
|
||
document.getElementById('ppsyncVersion').value = c.version || 'auto';
|
||
document.getElementById('ppsyncReconnect').value = c.reconnectMs || 3000;
|
||
document.getElementById('ppsyncAnnouncements').checked = c.watchAnnouncements !== false;
|
||
const pw = document.getElementById('ppsyncPassword');
|
||
pw.value = '';
|
||
pw.placeholder = c.hasPassword ? '•••• (saved — type to change)' : 'none';
|
||
document.getElementById('ppsyncForgetPw').style.display = c.hasPassword ? '' : 'none';
|
||
document.getElementById('ppsyncPasswordHelp').textContent = j.secureStore
|
||
? 'Stored encrypted by Windows for this user account. Only needed for the stage display protocol; Pro 7\'s network API has no password.'
|
||
: (c.hasPassword ? 'Kept in memory only in this run (no OS credential store available).' : 'Only needed for the stage display protocol; Pro 7\'s network API has no password.');
|
||
refreshPPSyncState();
|
||
} catch (_) {}
|
||
}
|
||
async function refreshPPSyncState() {
|
||
try {
|
||
const j = await (await fetch('/api/propresenter/state')).json();
|
||
const el = document.getElementById('ppsyncState');
|
||
if (el && j && j.ok) el.textContent = ppsyncStateLabel(j.state);
|
||
} catch (_) {}
|
||
}
|
||
setInterval(() => { try { if (document.getElementById('ppsyncState')) refreshPPSyncState(); } catch (_) {} }, 3000);
|
||
async function testPPSyncConnection() {
|
||
const f = ppsyncReadForm();
|
||
const bad = ppsyncValidate(f, true);
|
||
if (bad) { showStatus(bad, 'error'); return; }
|
||
const box = document.getElementById('ppsyncTestBox');
|
||
box.style.color = '#999'; box.textContent = 'Testing ' + f.host + ':' + f.port + '…';
|
||
try {
|
||
const body = { host: f.host, port: f.port, version: f.version };
|
||
if (f.password) body.password = f.password;
|
||
const j = await (await fetch('/api/propresenter/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
|
||
const r = (j && j.result) || {};
|
||
if (j && j.ok && r.ok) { box.style.color = '#8cc88c'; box.textContent = '✓ ' + (r.message || 'Connected.'); }
|
||
else { box.style.color = '#ff6b6b'; box.textContent = '✗ ' + ((j && !j.ok && j.error) || r.error || ('No answer from ' + f.host + ':' + f.port)); }
|
||
} catch (e) { box.style.color = '#ff6b6b'; box.textContent = '✗ Test failed: ' + e.message; }
|
||
}
|
||
async function savePPSyncSettings() {
|
||
const f = ppsyncReadForm();
|
||
const bad = ppsyncValidate(f, f.enabled);
|
||
if (bad) { showStatus(bad, 'error'); return; }
|
||
try {
|
||
const body = { enabled: f.enabled, host: f.host, port: f.port, version: f.version, reconnectMs: f.reconnectMs, watchAnnouncements: f.watchAnnouncements };
|
||
if (f.password) body.password = f.password;
|
||
const j = await (await fetch('/api/propresenter/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
|
||
if (j && j.ok) {
|
||
if (j.note) console.warn('[PPSYNC]', j.note);
|
||
showStatus(f.enabled ? 'ProPresenter Sync is ON — the tab now follows ProPresenter.' : 'ProPresenter Sync is off.', 'success');
|
||
loadPPSyncSettings();
|
||
} else {
|
||
showStatus((j && j.error) || 'Could not save ProPresenter settings.', 'error');
|
||
}
|
||
} catch (e) { showStatus('Error saving ProPresenter settings: ' + e.message, 'error'); }
|
||
}
|
||
async function forgetPPSyncPassword() {
|
||
try {
|
||
const j = await (await fetch('/api/propresenter/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: '' }) })).json();
|
||
if (j && j.ok) { showStatus('Saved password forgotten.', 'success'); loadPPSyncSettings(); }
|
||
else showStatus((j && j.error) || 'Could not update the password.', 'error');
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// ---- Splash Screen triggers + history (same-origin localStorage; the TV re-reads on every arm) ----
|
||
function loadSplashTriggers() {
|
||
try {
|
||
try { localStorage.removeItem('idleSplashMinutes'); } catch (_) {} // 1.4.30's short-lived minutes key
|
||
const raw = localStorage.getItem('idleSplashSeconds');
|
||
const sec = (raw === null || raw === '') ? 60 : parseFloat(raw);
|
||
document.getElementById('idleSplashSeconds').value = (isFinite(sec) && sec >= 0) ? String(sec) : '60';
|
||
document.getElementById('fiveFingerSplash').checked = localStorage.getItem('fiveFingerSplash') !== '0';
|
||
} catch (_) {}
|
||
renderSplashHistory();
|
||
}
|
||
function saveSplashTriggers() {
|
||
try {
|
||
const el = document.getElementById('idleSplashSeconds');
|
||
const raw = String(el.value || '').trim();
|
||
const sec = raw === '' ? 60 : parseFloat(raw);
|
||
if (!isFinite(sec) || sec < 0 || sec > 3600) { showStatus('Seconds must be 0–3600.', 'error'); return; }
|
||
localStorage.setItem('idleSplashSeconds', String(sec));
|
||
localStorage.setItem('fiveFingerSplash', document.getElementById('fiveFingerSplash').checked ? '1' : '0');
|
||
if (raw === '') el.value = '60';
|
||
showStatus(sec > 0
|
||
? ('The TV will go to the splash after ' + sec + ' second' + (sec === 1 ? '' : 's') + ' without a touch.')
|
||
: 'The TV will not go to the splash on its own (refresh, remote and five fingers still show it).', 'success');
|
||
} catch (e) { showStatus('Could not save splash settings: ' + e.message, 'error'); }
|
||
}
|
||
function renderSplashHistory() {
|
||
const box = document.getElementById('splashHistoryBox');
|
||
if (!box) return;
|
||
let list = [];
|
||
try { list = JSON.parse(localStorage.getItem('splashHistory') || '[]'); } catch (_) { list = []; }
|
||
if (!list.length) { box.textContent = 'No splash has been shown on this TV yet (since this version).'; return; }
|
||
box.innerHTML = list.slice(-8).reverse().map(e => {
|
||
const d = new Date(e.at);
|
||
const when = d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||
return when + ' — ' + String(e.reason || 'unknown').replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||
}).join('<br>');
|
||
}
|
||
try { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', loadSplashTriggers); else loadSplashTriggers(); } catch (_) {}
|
||
window.addEventListener('storage', (ev) => { try { if (ev && ev.key === 'splashHistory') renderSplashHistory(); } catch (_) {} });
|
||
|
||
// ---- Follower screen (booth mirror → ProPresenter / ATEM) ----
|
||
function renderFollowerUrls(urls) {
|
||
const box = document.getElementById('followerUrlBox');
|
||
if (!box) return;
|
||
if (!urls || !urls.length) { box.textContent = 'No network address found — is this computer connected to the network?'; return; }
|
||
box.innerHTML = urls.map(u => u.replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c]))).join('<br>');
|
||
}
|
||
async function loadFollowerSettings() {
|
||
try {
|
||
const j = await (await fetch('/api/follower/config', { cache: 'no-store' })).json();
|
||
if (!j || !j.ok) return;
|
||
const cb = document.getElementById('followerEnabled');
|
||
if (cb) cb.checked = j.config && j.config.enabled !== false;
|
||
renderFollowerUrls(j.urls);
|
||
refreshFollowerState();
|
||
} catch (_) {}
|
||
}
|
||
async function saveFollowerSettings() {
|
||
const cb = document.getElementById('followerEnabled');
|
||
const enabled = !!(cb && cb.checked);
|
||
try {
|
||
const j = await (await fetch('/api/follower/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) })).json();
|
||
if (j && j.ok) {
|
||
showStatus(enabled ? 'Follower screens may connect.' : 'Follower screens are off — any connected screen has gone black.', 'success');
|
||
renderFollowerUrls(j.urls);
|
||
refreshFollowerState();
|
||
} else showStatus((j && j.error) || 'Could not save the follower setting.', 'error');
|
||
} catch (e) { showStatus('Error saving the follower setting: ' + e.message, 'error'); }
|
||
}
|
||
async function refreshFollowerState() {
|
||
try {
|
||
const j = await (await fetch('/api/follower/status', { cache: 'no-store' })).json();
|
||
const el = document.getElementById('followerState');
|
||
if (!el || !j) return;
|
||
if (j.enabled === false) { el.textContent = 'Status: off'; return; }
|
||
const n = j.followers || 0;
|
||
const src = j.publisher ? (j.publisherIsStage ? 'this TV is publishing' : 'a local copy is publishing') : 'this TV is not publishing yet';
|
||
el.textContent = 'Status: ' + (n ? (n + ' follower screen' + (n === 1 ? '' : 's') + ' connected') : 'no follower screen connected') + ' — ' + src + '.';
|
||
} catch (_) {}
|
||
}
|
||
setInterval(() => { try { if (document.getElementById('followerState')) refreshFollowerState(); } catch (_) {} }, 3000);
|
||
try { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', loadFollowerSettings); else loadFollowerSettings(); } catch (_) {}
|
||
|
||
// ---- Admin console (offstage private copy + per-item push to the TV) ----
|
||
// Shares remote-config.json and /api/remote/info, but its own PIN and its own endpoints.
|
||
function renderAdminUrls(j) {
|
||
const box = document.getElementById('adminUrlBox');
|
||
if (!box) return;
|
||
const urls = (j.adminUrls && j.adminUrls.length) ? j.adminUrls : [];
|
||
if (!urls.length) {
|
||
box.innerHTML = '<span style="color:#999;">Open: http://[this computer\'s IP]:' + (j.port || 8080) + '/admin.html</span>';
|
||
return;
|
||
}
|
||
box.innerHTML = '<strong>Open the admin console at:</strong><br>' + urls.map(function (u) { return ' ' + u; }).join('<br>');
|
||
}
|
||
async function loadAdminSettings() {
|
||
try {
|
||
const j = await (await fetch('/api/remote/info')).json();
|
||
if (!j || !j.ok) return;
|
||
window.__adminHasPin = !!j.adminHasPin;
|
||
const cb = document.getElementById('adminEnabled');
|
||
if (cb) cb.checked = !!j.adminEnabled;
|
||
const pinEl = document.getElementById('adminPin');
|
||
if (pinEl) pinEl.placeholder = j.adminHasPin ? '•••• (set — type to change)' : 'e.g. 9153';
|
||
renderAdminUrls(j);
|
||
} catch (_) {}
|
||
}
|
||
async function saveAdminSettings(showMsg) {
|
||
try {
|
||
const cb = document.getElementById('adminEnabled');
|
||
const pinEl = document.getElementById('adminPin');
|
||
const enabled = !!(cb && cb.checked);
|
||
const pin = ((pinEl && pinEl.value) || '').trim();
|
||
if (pin && !/^\d{4,}$/.test(pin)) { showStatus('Admin PIN should be at least 4 digits (numbers only).', 'error'); return; }
|
||
if (enabled && !pin && !window.__adminHasPin) {
|
||
showStatus('Set an admin PIN before enabling the admin console.', 'error');
|
||
if (cb) cb.checked = false;
|
||
return;
|
||
}
|
||
// Guard against one PIN opening both surfaces — the whole point of a separate
|
||
// credential is that the operator's remote PIN cannot reach admin.
|
||
const remotePinEl = document.getElementById('remotePin');
|
||
if (pin && remotePinEl && remotePinEl.value.trim() && pin === remotePinEl.value.trim()) {
|
||
showStatus('Use a different PIN for admin than the phone remote.', 'error');
|
||
return;
|
||
}
|
||
const body = { enabled: enabled };
|
||
if (pin) body.pin = pin;
|
||
const j = await (await fetch('/api/admin/config', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||
})).json();
|
||
if (j && j.ok) {
|
||
if (pinEl) pinEl.value = '';
|
||
if (showMsg) showStatus(enabled ? 'Admin console is ON.' : 'Admin console is off.', 'success');
|
||
loadAdminSettings();
|
||
} else {
|
||
showStatus((j && j.error) || 'Could not save admin settings.', 'error');
|
||
}
|
||
} catch (e) { showStatus('Error saving admin settings: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// ---- Tool visibility (which tools show in the toolbar + dock) ----
|
||
const TOOL_VIS_KEY = 'toolVisibility';
|
||
const TOOL_VIS_KEYS = ['pen', 'highlight', 'erase', 'laser', 'spotlight', 'undo', 'redo', 'clear'];
|
||
function loadToolVisibility() {
|
||
try {
|
||
var cfg = {};
|
||
TOOL_VIS_KEYS.forEach(function (k) { cfg[k] = true; });
|
||
var s = JSON.parse(localStorage.getItem(TOOL_VIS_KEY) || 'null');
|
||
if (s && typeof s === 'object') TOOL_VIS_KEYS.forEach(function (k) { if (k in s) cfg[k] = !!s[k]; });
|
||
document.querySelectorAll('#toolVisibilityGroup input[data-tool]').forEach(function (cb) {
|
||
cb.checked = cfg[cb.getAttribute('data-tool')] !== false;
|
||
});
|
||
} catch (_) {}
|
||
}
|
||
function saveToolVisibility() {
|
||
try {
|
||
var cfg = {};
|
||
document.querySelectorAll('#toolVisibilityGroup input[data-tool]').forEach(function (cb) {
|
||
cfg[cb.getAttribute('data-tool')] = cb.checked;
|
||
});
|
||
localStorage.setItem(TOOL_VIS_KEY, JSON.stringify(cfg));
|
||
showStatus('Tool visibility saved. Return to the app to apply.', 'success');
|
||
} catch (e) { showStatus('Error saving tool visibility: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// ---- Two-finger tool selector (tool-wheel.js reads these on every gesture; no restart) ----
|
||
function loadToolWheelSettings() {
|
||
try {
|
||
document.getElementById('toolWheelEnabled').checked = localStorage.getItem('toolWheelEnabled') !== '0';
|
||
// Anything that is not 'slim' is the ring, so an older install stays on the shape it had.
|
||
var shape = localStorage.getItem('toolWheelStyle') === 'slim' ? 'slim' : 'ring';
|
||
var radio = document.querySelector('#toolWheelStyleGroup input[value="' + shape + '"]');
|
||
if (radio) radio.checked = true;
|
||
var s = parseFloat(localStorage.getItem('toolSnapBackSec'));
|
||
document.getElementById('toolSnapBackSec').value = (isFinite(s) && s > 0) ? String(s) : '';
|
||
} catch (_) {}
|
||
}
|
||
function saveToolWheelSettings() {
|
||
try {
|
||
var on = document.getElementById('toolWheelEnabled').checked;
|
||
var picked = document.querySelector('#toolWheelStyleGroup input[name="toolWheelStyle"]:checked');
|
||
var shape = (picked && picked.value === 'slim') ? 'slim' : 'ring';
|
||
var raw = String(document.getElementById('toolSnapBackSec').value || '').trim();
|
||
var s = parseFloat(raw);
|
||
if (raw && (!isFinite(s) || s < 0 || s > 600)) { showStatus('Return-to-pen seconds must be 0–600.', 'error'); return; }
|
||
localStorage.setItem('toolWheelEnabled', on ? '1' : '0');
|
||
// tool-wheel.js reads this on every gesture, so the next two-finger tap already uses it.
|
||
localStorage.setItem('toolWheelStyle', shape);
|
||
localStorage.setItem('toolSnapBackSec', (raw && s > 0) ? String(s) : '0');
|
||
showStatus((on ? ('Two-finger tool selector is on, using the ' + (shape === 'slim' ? 'slim dial' : 'ring') + '. ') : 'Two-finger tool selector is off. ') +
|
||
((raw && s > 0) ? ('Highlighter and eraser return to the pen after ' + s + ' s without a touch.') : 'Tools stay selected until changed.'), 'success');
|
||
} catch (e) { showStatus('Could not save tool selector settings: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// ---- Red letter (words of Christ) ----
|
||
const RED_LETTER_ENABLED_KEY = 'redLetterEnabled';
|
||
const RED_LETTER_OVERRIDES_KEY = 'redLetterOverrides';
|
||
function loadRedLetterSettings() {
|
||
try {
|
||
var cb = document.getElementById('redLetterToggle');
|
||
if (cb) cb.checked = (localStorage.getItem(RED_LETTER_ENABLED_KEY) === 'true');
|
||
var ta = document.getElementById('redLetterOverrides');
|
||
if (ta) ta.value = localStorage.getItem(RED_LETTER_OVERRIDES_KEY) || '';
|
||
} catch (_) {}
|
||
}
|
||
function saveRedLetterEnabled() {
|
||
try {
|
||
var cb = document.getElementById('redLetterToggle');
|
||
var on = !!(cb && cb.checked);
|
||
localStorage.setItem(RED_LETTER_ENABLED_KEY, on ? 'true' : 'false');
|
||
showStatus(on ? 'Red letter enabled. Return to the app to see it.' : 'Red letter disabled.', 'success');
|
||
} catch (e) { showStatus('Error saving red-letter setting: ' + e.message, 'error'); }
|
||
}
|
||
function saveRedLetterOverrides() {
|
||
try {
|
||
var ta = document.getElementById('redLetterOverrides');
|
||
localStorage.setItem(RED_LETTER_OVERRIDES_KEY, (ta && ta.value) || '');
|
||
showStatus('References saved. Return to the app to apply.', 'success');
|
||
} catch (e) { showStatus('Error saving references: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// ---- Collapsible sections (accordion) ----
|
||
// Wrap each section's content in a .section-body and collapse all on entry so the
|
||
// long settings page is quick to navigate. Runs on 'load' (after all population).
|
||
function initSettingsAccordion() {
|
||
try {
|
||
document.querySelectorAll('.section').forEach(function(section) {
|
||
var h2 = section.querySelector(':scope > h2');
|
||
if (!h2) return;
|
||
if (!section.querySelector(':scope > .section-body')) {
|
||
var body = document.createElement('div');
|
||
body.className = 'section-body';
|
||
var node = h2.nextSibling;
|
||
while (node) {
|
||
var next = node.nextSibling;
|
||
body.appendChild(node);
|
||
node = next;
|
||
}
|
||
section.appendChild(body);
|
||
}
|
||
section.classList.add('collapsed');
|
||
h2.setAttribute('role', 'button');
|
||
h2.setAttribute('tabindex', '0');
|
||
h2.addEventListener('click', function() { section.classList.toggle('collapsed'); });
|
||
h2.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); section.classList.toggle('collapsed'); }
|
||
});
|
||
});
|
||
// Group every OSC section at the bottom so the common settings come first.
|
||
var container = document.querySelector('.container');
|
||
if (container) {
|
||
document.querySelectorAll('.section').forEach(function(section) {
|
||
var h = section.querySelector('h2');
|
||
if (h && /OSC/i.test(h.textContent)) container.appendChild(section);
|
||
});
|
||
}
|
||
} catch (e) { console.warn('[settings] accordion init failed', e); }
|
||
}
|
||
|
||
// ---- Service Profiles ----
|
||
async function loadProfiles() {
|
||
var el = document.getElementById('profilesList');
|
||
if (!el) return;
|
||
try {
|
||
var r = await fetch('/api/profiles/list');
|
||
var j = await r.json();
|
||
var profs = (j && j.profiles) || [];
|
||
el.innerHTML = '';
|
||
if (!profs.length) { el.innerHTML = '<p style="color:#777;">No profiles yet. Configure the app the way you want, then save it as a profile below.</p>'; return; }
|
||
profs.forEach(function (p) {
|
||
var row = document.createElement('div');
|
||
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px solid #333;';
|
||
var name = document.createElement('span'); name.style.fontWeight = '600'; name.textContent = p.name;
|
||
var btns = document.createElement('span');
|
||
var apply = document.createElement('button'); apply.type = 'button'; apply.className = 'btn'; apply.textContent = 'Apply'; apply.onclick = function () { applyProfile(p.name); };
|
||
var del = document.createElement('button'); del.type = 'button'; del.className = 'btn btn-secondary'; del.style.marginLeft = '8px'; del.textContent = 'Delete'; del.onclick = function () { deleteProfile(p.name); };
|
||
btns.appendChild(apply); btns.appendChild(del);
|
||
row.appendChild(name); row.appendChild(btns);
|
||
el.appendChild(row);
|
||
});
|
||
} catch (e) { el.innerHTML = '<p style="color:#c66;">Could not load profiles.</p>'; }
|
||
}
|
||
async function saveCurrentAsProfile() {
|
||
try {
|
||
var nameEl = document.getElementById('newProfileName');
|
||
var name = (nameEl && nameEl.value || '').trim();
|
||
if (!name) { showStatus('Enter a profile name first.', 'error'); return; }
|
||
var tabs = null; try { tabs = JSON.parse(localStorage.getItem('tabVisibility') || 'null'); } catch (_) {}
|
||
var fontSize = parseInt(localStorage.getItem('bibleFontSize'), 10) || 60;
|
||
async function curDefault(url) { try { var r = await fetch(url); var j = await r.json(); return j.currentDefault || ''; } catch (_) { return ''; } }
|
||
var defGraphics = await curDefault('/api/graphics/list');
|
||
var defAnn = await curDefault('/api/announcements/list');
|
||
var defNotes = await curDefault('/api/notes/list');
|
||
var defMaps = await curDefault('/api/maps/list');
|
||
var defSplash = await curDefault('/api/splash/list');
|
||
// Full look-and-feel bundle: everything that defines a service type.
|
||
var settings = {};
|
||
try {
|
||
// Keep this list in step with the settings that auto-save, or a profile silently
|
||
// fails to carry them.
|
||
['tabVisibility', 'bibleFontSize', 'toolVisibility', 'carouselCrossfadeMs',
|
||
'redLetterEnabled', 'redLetterOverrides', 'popupVisibility',
|
||
'translationVisibility', 'wordPopupsEnabled', 'spotlightHalfPx',
|
||
'userLaserColor', 'toolWheelEnabled', 'toolWheelStyle', 'toolSnapBackSec',
|
||
'idleSplashSeconds', 'fiveFingerSplash', 'ppsyncSeparateAnnotations'].forEach(function (k) {
|
||
var v = localStorage.getItem(k);
|
||
if (v !== null) settings[k] = v;
|
||
});
|
||
} catch (_) {}
|
||
var osc = {};
|
||
try { var r = await fetch('/api/osc/config'); var j = await r.json(); if (j && j.config) { var c = j.config; osc = { address: c.address, port: c.port, allow: c.allow, allowPrefixes: c.allowPrefixes, feedbackEnabled: c.feedbackEnabled, feedbackAddress: c.feedbackAddress, feedbackPort: c.feedbackPort, feedbackInformation: c.feedbackInformation, feedbackTabStates: c.feedbackTabStates }; } } catch (_) {}
|
||
var profile = { name: name, tabs: tabs || undefined, bibleFontSize: fontSize, settings: settings, defaultGraphics: defGraphics, defaultAnnouncements: defAnn, defaultNotes: defNotes, defaultMaps: defMaps, defaultSplash: defSplash, osc: osc };
|
||
var rr = await fetch('/api/profiles/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile) });
|
||
var jj = await rr.json();
|
||
if (jj && jj.ok) { showStatus('Profile "' + name + '" saved.', 'success'); if (nameEl) nameEl.value = ''; loadProfiles(); }
|
||
else showStatus('Could not save profile: ' + ((jj && jj.error) || 'error'), 'error');
|
||
} catch (e) { showStatus('Error saving profile: ' + e.message, 'error'); }
|
||
}
|
||
async function applyProfile(name) {
|
||
try {
|
||
var r = await fetch('/api/profiles/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name }) });
|
||
var j = await r.json();
|
||
if (!j || !j.ok || !j.profile) { showStatus('Could not apply profile: ' + ((j && j.error) || 'error'), 'error'); return; }
|
||
var p = j.profile;
|
||
try { if (p.tabs) localStorage.setItem('tabVisibility', JSON.stringify(p.tabs)); } catch (_) {}
|
||
try { if (p.bibleFontSize) localStorage.setItem('bibleFontSize', String(p.bibleFontSize)); } catch (_) {}
|
||
try { if (p.settings && typeof p.settings === 'object') Object.keys(p.settings).forEach(function (k) { var v = p.settings[k]; if (v !== null && v !== undefined) localStorage.setItem(k, String(v)); }); } catch (_) {}
|
||
showStatus('Profile "' + name + '" applied. Return to the app to see it (OSC address/port changes need an app restart).', 'success');
|
||
} catch (e) { showStatus('Error applying profile: ' + e.message, 'error'); }
|
||
}
|
||
async function deleteProfile(name) {
|
||
try {
|
||
var r = await fetch('/api/profiles/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name }) });
|
||
var j = await r.json();
|
||
if (j && j.ok) { showStatus('Profile "' + name + '" deleted.', 'success'); loadProfiles(); }
|
||
} catch (e) { showStatus('Error: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// Load config on page load
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
loadOSCConfig();
|
||
loadOSCStatus();
|
||
loadPopupVisibility();
|
||
loadWordPopupsEnabled();
|
||
loadTabVisibility();
|
||
loadTranslationVisibility();
|
||
loadDefaultGraphicsSetting();
|
||
loadAskSettings();
|
||
loadBibleReadingSettings();
|
||
loadCrossfadeSettings();
|
||
loadToolVisibility();
|
||
loadToolWheelSettings();
|
||
loadRedLetterSettings();
|
||
loadRemoteSettings();
|
||
loadAdminSettings();
|
||
loadPPSyncSettings();
|
||
loadUpdateSettings();
|
||
loadModelStatus();
|
||
loadProfiles();
|
||
// Auto-save LAST, so every loader has already populated the controls — otherwise the
|
||
// first change event could persist a half-initialised form.
|
||
wireAutoSave();
|
||
// Refresh status every 5 seconds
|
||
setInterval(loadOSCStatus, 5000);
|
||
});
|
||
// Accordion wraps content after all sections are populated
|
||
window.addEventListener('load', initSettingsAccordion);
|
||
</script>
|
||
</body>
|
||
</html>
|