Files
bible-app/settings.html
T

3127 lines
186 KiB
HTML
Raw Normal View History

2026-09-24 13:05:20 -04:00
<!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&#10;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> &mdash; 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> &mdash; 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 &mdash; 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> &mdash; 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 &amp; 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&nbsp;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 &mdash; 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 &mdash; <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 &mdash; 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/&hellip;</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 &mdash; <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/&lt;n&gt;</code> &mdash; <code>1</code> while the <strong>n-th tab on the row</strong> is showing (1&ndash;16).<br>
<code style="color:#8cc88c;">/bibleapp/feedback/state/tab_kind/&lt;kind&gt;</code> &mdash; <code>1</code> while a tab of that <strong>kind</strong> is showing:<br>
<span style="margin-left:18px;">bible &middot; sync &middot; whiteboard &middot; notes &middot; media &middot; alpha &middot; announcements &middot; graphics &middot; maps &middot; prayer &middot; 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 &ldquo;is he on the whiteboard?&rdquo; &mdash; 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 &amp; 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/&lt;color&gt;</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 &amp; 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 &amp; 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/&lt;command&gt;</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/&lt;hex6&gt;</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/&lt;hex6&gt;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');
}
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 '&nbsp;&nbsp;' + 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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 '&nbsp;&nbsp;' + 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>