prismcast 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/app.js +18 -1
  2. package/dist/app.js.map +1 -1
  3. package/dist/browser/cdp.d.ts +2 -1
  4. package/dist/browser/cdp.js +44 -23
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +45 -1
  7. package/dist/browser/channelSelection.js +164 -27
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/display.d.ts +0 -4
  10. package/dist/browser/display.js +0 -7
  11. package/dist/browser/display.js.map +1 -1
  12. package/dist/browser/index.d.ts +5 -0
  13. package/dist/browser/index.js +14 -0
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/precaching.d.ts +5 -0
  16. package/dist/browser/precaching.js +112 -0
  17. package/dist/browser/precaching.js.map +1 -0
  18. package/dist/browser/tuning/directv.d.ts +2 -0
  19. package/dist/browser/tuning/directv.js +723 -0
  20. package/dist/browser/tuning/directv.js.map +1 -0
  21. package/dist/browser/tuning/fox.d.ts +2 -2
  22. package/dist/browser/tuning/fox.js +141 -22
  23. package/dist/browser/tuning/fox.js.map +1 -1
  24. package/dist/browser/tuning/hbo.d.ts +2 -2
  25. package/dist/browser/tuning/hbo.js +110 -41
  26. package/dist/browser/tuning/hbo.js.map +1 -1
  27. package/dist/browser/tuning/hulu.d.ts +2 -2
  28. package/dist/browser/tuning/hulu.js +442 -168
  29. package/dist/browser/tuning/hulu.js.map +1 -1
  30. package/dist/browser/tuning/sling.d.ts +2 -2
  31. package/dist/browser/tuning/sling.js +200 -56
  32. package/dist/browser/tuning/sling.js.map +1 -1
  33. package/dist/browser/tuning/youtubeTv.d.ts +2 -2
  34. package/dist/browser/tuning/youtubeTv.js +243 -57
  35. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  36. package/dist/browser/video.d.ts +1 -1
  37. package/dist/browser/video.js +4 -2
  38. package/dist/browser/video.js.map +1 -1
  39. package/dist/channels/index.js +280 -34
  40. package/dist/channels/index.js.map +1 -1
  41. package/dist/config/health.d.ts +78 -0
  42. package/dist/config/health.js +202 -0
  43. package/dist/config/health.js.map +1 -0
  44. package/dist/config/paths.d.ts +10 -0
  45. package/dist/config/paths.js +14 -0
  46. package/dist/config/paths.js.map +1 -1
  47. package/dist/config/profiles.d.ts +6 -4
  48. package/dist/config/profiles.js +65 -14
  49. package/dist/config/profiles.js.map +1 -1
  50. package/dist/config/providerPacks.d.ts +46 -0
  51. package/dist/config/providerPacks.js +200 -0
  52. package/dist/config/providerPacks.js.map +1 -0
  53. package/dist/config/providers.d.ts +36 -3
  54. package/dist/config/providers.js +168 -8
  55. package/dist/config/providers.js.map +1 -1
  56. package/dist/config/sites.d.ts +1 -12
  57. package/dist/config/sites.js +38 -5
  58. package/dist/config/sites.js.map +1 -1
  59. package/dist/config/userChannels.d.ts +12 -0
  60. package/dist/config/userChannels.js +81 -14
  61. package/dist/config/userChannels.js.map +1 -1
  62. package/dist/config/userConfig.d.ts +5 -1
  63. package/dist/config/userConfig.js +58 -6
  64. package/dist/config/userConfig.js.map +1 -1
  65. package/dist/config/userProfiles.d.ts +81 -0
  66. package/dist/config/userProfiles.js +424 -0
  67. package/dist/config/userProfiles.js.map +1 -0
  68. package/dist/routes/config/channels/index.d.ts +3 -0
  69. package/dist/routes/config/channels/index.js +3 -0
  70. package/dist/routes/config/channels/index.js.map +1 -0
  71. package/dist/routes/config/channels/routes.d.ts +6 -0
  72. package/dist/routes/config/channels/routes.js +797 -0
  73. package/dist/routes/config/channels/routes.js.map +1 -0
  74. package/dist/routes/config/channels/table.d.ts +46 -0
  75. package/dist/routes/config/channels/table.js +811 -0
  76. package/dist/routes/config/channels/table.js.map +1 -0
  77. package/dist/routes/config/index.d.ts +37 -0
  78. package/dist/routes/config/index.js +81 -0
  79. package/dist/routes/config/index.js.map +1 -0
  80. package/dist/routes/config/providers.d.ts +18 -0
  81. package/dist/routes/config/providers.js +573 -0
  82. package/dist/routes/config/providers.js.map +1 -0
  83. package/dist/routes/config/settings.d.ts +39 -0
  84. package/dist/routes/config/settings.js +820 -0
  85. package/dist/routes/config/settings.js.map +1 -0
  86. package/dist/routes/index.d.ts +3 -2
  87. package/dist/routes/index.js +7 -4
  88. package/dist/routes/index.js.map +1 -1
  89. package/dist/routes/playlist.d.ts +5 -1
  90. package/dist/routes/playlist.js +32 -7
  91. package/dist/routes/playlist.js.map +1 -1
  92. package/dist/routes/providers.d.ts +6 -0
  93. package/dist/routes/providers.js +166 -0
  94. package/dist/routes/providers.js.map +1 -0
  95. package/dist/routes/root/content.d.ts +32 -0
  96. package/dist/routes/root/content.js +857 -0
  97. package/dist/routes/root/content.js.map +1 -0
  98. package/dist/routes/root/index.js +197 -0
  99. package/dist/routes/root/index.js.map +1 -0
  100. package/dist/routes/root/scripts/channels.d.ts +1 -0
  101. package/dist/routes/root/scripts/channels.js +745 -0
  102. package/dist/routes/root/scripts/channels.js.map +1 -0
  103. package/dist/routes/root/scripts/config.d.ts +1 -0
  104. package/dist/routes/root/scripts/config.js +1662 -0
  105. package/dist/routes/root/scripts/config.js.map +1 -0
  106. package/dist/routes/root/scripts/index.d.ts +3 -0
  107. package/dist/routes/root/scripts/index.js +8 -0
  108. package/dist/routes/root/scripts/index.js.map +1 -0
  109. package/dist/routes/root/scripts/status.d.ts +1 -0
  110. package/dist/routes/root/scripts/status.js +436 -0
  111. package/dist/routes/root/scripts/status.js.map +1 -0
  112. package/dist/routes/root/styles.d.ts +5 -0
  113. package/dist/routes/root/styles.js +447 -0
  114. package/dist/routes/root/styles.js.map +1 -0
  115. package/dist/routes/ui.js +8 -3
  116. package/dist/routes/ui.js.map +1 -1
  117. package/dist/service/commands.d.ts +5 -3
  118. package/dist/service/commands.js +117 -33
  119. package/dist/service/commands.js.map +1 -1
  120. package/dist/service/generators.d.ts +27 -0
  121. package/dist/service/generators.js +97 -6
  122. package/dist/service/generators.js.map +1 -1
  123. package/dist/streaming/hls.js +18 -1
  124. package/dist/streaming/hls.js.map +1 -1
  125. package/dist/streaming/lifecycle.js +1 -1
  126. package/dist/streaming/lifecycle.js.map +1 -1
  127. package/dist/streaming/monitor.d.ts +1 -38
  128. package/dist/streaming/monitor.js +2 -289
  129. package/dist/streaming/monitor.js.map +1 -1
  130. package/dist/streaming/recovery.d.ts +143 -0
  131. package/dist/streaming/recovery.js +286 -0
  132. package/dist/streaming/recovery.js.map +1 -0
  133. package/dist/streaming/registry.d.ts +1 -1
  134. package/dist/streaming/setup.d.ts +1 -1
  135. package/dist/streaming/setup.js +3 -1
  136. package/dist/streaming/setup.js.map +1 -1
  137. package/dist/streaming/statusEmitter.d.ts +4 -2
  138. package/dist/streaming/statusEmitter.js +5 -0
  139. package/dist/streaming/statusEmitter.js.map +1 -1
  140. package/dist/types/channels.d.ts +68 -0
  141. package/dist/types/channels.js +2 -0
  142. package/dist/types/channels.js.map +1 -0
  143. package/dist/types/config.d.ts +127 -0
  144. package/dist/types/config.js +2 -0
  145. package/dist/types/config.js.map +1 -0
  146. package/dist/types/index.d.ts +7 -429
  147. package/dist/types/index.js +1 -5
  148. package/dist/types/index.js.map +1 -1
  149. package/dist/types/profiles.d.ts +141 -0
  150. package/dist/types/profiles.js +2 -0
  151. package/dist/types/profiles.js.map +1 -0
  152. package/dist/types/selection.d.ts +101 -0
  153. package/dist/types/selection.js +6 -0
  154. package/dist/types/selection.js.map +1 -0
  155. package/dist/types/shared.d.ts +7 -0
  156. package/dist/types/shared.js +6 -0
  157. package/dist/types/shared.js.map +1 -0
  158. package/dist/types/streaming.d.ts +96 -0
  159. package/dist/types/streaming.js +2 -0
  160. package/dist/types/streaming.js.map +1 -0
  161. package/dist/utils/debugFilter.js +3 -1
  162. package/dist/utils/debugFilter.js.map +1 -1
  163. package/dist/utils/format.d.ts +7 -0
  164. package/dist/utils/format.js +22 -0
  165. package/dist/utils/format.js.map +1 -1
  166. package/dist/utils/index.d.ts +1 -0
  167. package/dist/utils/index.js +1 -0
  168. package/dist/utils/index.js.map +1 -1
  169. package/dist/utils/sanitize.d.ts +14 -0
  170. package/dist/utils/sanitize.js +37 -0
  171. package/dist/utils/sanitize.js.map +1 -0
  172. package/package.json +5 -3
  173. package/dist/routes/config.d.ts +0 -76
  174. package/dist/routes/config.js +0 -2187
  175. package/dist/routes/config.js.map +0 -1
  176. package/dist/routes/root.js +0 -3158
  177. package/dist/routes/root.js.map +0 -1
  178. /package/dist/routes/{root.d.ts → root/index.d.ts} +0 -0
@@ -0,0 +1,1662 @@
1
+ /* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * config.ts: Client-side JavaScript generator for the PrismCast configuration subtab.
4
+ */
5
+ import { VIDEO_QUALITY_PRESETS } from "../../../config/presets.js";
6
+ import { isRunningAsService } from "../../../utils/index.js";
7
+ export function generateConfigSubtabScript() {
8
+ // Build preset data for auto-filling bitrate and frame rate when preset changes. Viewport is derived server-side and not included here.
9
+ const presetBlocks = [];
10
+ for (const preset of VIDEO_QUALITY_PRESETS) {
11
+ // Only include bitrate and frame rate, not viewport (viewport is derived from preset server-side).
12
+ const bitrateValue = preset.values["streaming.videoBitsPerSecond"];
13
+ const frameRateValue = preset.values["streaming.frameRate"];
14
+ // Convert bitrate from bps to Mbps for display.
15
+ const block = [
16
+ " '" + preset.id + "': {",
17
+ " 'streaming-videoBitsPerSecond': " + String(bitrateValue / 1000000) + ",",
18
+ " 'streaming-frameRate': " + String(frameRateValue),
19
+ " }"
20
+ ].join("\n");
21
+ presetBlocks.push(block);
22
+ }
23
+ // Pass service status to JavaScript for conditional messaging.
24
+ const isService = isRunningAsService();
25
+ return [
26
+ "<script>",
27
+ "(function() {",
28
+ // Service mode flag for conditional UI behavior.
29
+ " var isServiceMode = " + String(isService) + ";",
30
+ // Preset values for auto-filling bitrate and frame rate.
31
+ " var presetValues = {",
32
+ presetBlocks.join(",\n"),
33
+ " };",
34
+ // When quality preset changes, auto-fill bitrate and frame rate with preset values.
35
+ " function onPresetChange(presetId) {",
36
+ " var values = presetValues[presetId];",
37
+ " if (!values) return;",
38
+ " for (var inputId in values) {",
39
+ " var input = document.getElementById(inputId);",
40
+ " if (input) {",
41
+ " input.value = values[inputId];",
42
+ " input.dispatchEvent(new Event('input', { bubbles: true }));",
43
+ " }",
44
+ " }",
45
+ " }",
46
+ // Show a toast notification. Auto-dismiss durations: success/info = 5s, warning = 8s, error = no auto-dismiss. Optional action: { label, onclick } appends an
47
+ // inline button between the message text and the close button.
48
+ " function showToast(message, type, duration, action) {",
49
+ " var container = document.getElementById('toast-container');",
50
+ " if (!container) return;",
51
+ " var toast = document.createElement('div');",
52
+ " toast.className = 'toast ' + (type || 'info');",
53
+ " toast.textContent = message;",
54
+ " toast.setAttribute('role', (type === 'error' || type === 'warning') ? 'alert' : 'status');",
55
+ " if (action && action.label) {",
56
+ " var actionBtn = document.createElement('button');",
57
+ " actionBtn.type = 'button';",
58
+ " actionBtn.className = 'toast-action';",
59
+ " actionBtn.textContent = action.label;",
60
+ " actionBtn.onclick = function() { if (action.onclick) action.onclick(); dismissToast(toast); };",
61
+ " toast.appendChild(actionBtn);",
62
+ " }",
63
+ " var closeBtn = document.createElement('button');",
64
+ " closeBtn.type = 'button';",
65
+ " closeBtn.className = 'toast-close';",
66
+ " closeBtn.textContent = '\\u00d7';",
67
+ " closeBtn.setAttribute('aria-label', 'Dismiss');",
68
+ " closeBtn.onclick = function() { dismissToast(toast); };",
69
+ " toast.appendChild(closeBtn);",
70
+ " container.appendChild(toast);",
71
+ " var ms = duration !== undefined ? duration : type === 'error' ? 0 : type === 'warning' ? 8000 : 5000;",
72
+ " if (ms > 0) { setTimeout(function() { dismissToast(toast); }, ms); }",
73
+ " }",
74
+ // Expose showToast globally so other script blocks (e.g., channels-subtab IIFE) can use the toast system.
75
+ " window.showToast = showToast;",
76
+ // Dismiss a toast with slide-out animation.
77
+ " function dismissToast(toast) {",
78
+ " if (toast.classList.contains('toast-exit')) return;",
79
+ " toast.classList.add('toast-exit');",
80
+ " toast.addEventListener('animationend', function() { if (toast.parentNode) toast.parentNode.removeChild(toast); });",
81
+ " }",
82
+ // Hint appended to success toasts when a channel operation changes M3U playlist content that Channels DVR consumes.
83
+ " var PLAYLIST_HINT = ' Reload the playlist in Channels DVR to see this change.';",
84
+ // Queue a toast to appear after the next page reload.
85
+ " function showToastAfterReload(message, type) {",
86
+ " sessionStorage.setItem('pendingToast', JSON.stringify({ message: message, type: type || 'success' }));",
87
+ " location.reload();",
88
+ " }",
89
+ // Interval handle for restart polling.
90
+ " var restartPollInterval = null;",
91
+ // Track whether a restart is pending (deferred due to active streams).
92
+ " var pendingRestart = false;",
93
+ // Show the pending restart dialog when streams are active.
94
+ " function showPendingRestartDialog(streamCount) {",
95
+ " pendingRestart = true;",
96
+ " document.getElementById('restart-stream-count').textContent = streamCount;",
97
+ " document.getElementById('restart-dialog').style.display = 'flex';",
98
+ " updateRestartDialogStatus();",
99
+ " }",
100
+ // Update the restart dialog when stream count changes. Exposed on window so the status script's streamRemoved handler can trigger it across script blocks.
101
+ " function updateRestartDialogStatus() {",
102
+ " var count = Object.keys(streamData).length;",
103
+ " document.getElementById('restart-stream-count').textContent = count;",
104
+ " if (count === 0 && pendingRestart) {",
105
+ " pendingRestart = false;",
106
+ " document.getElementById('restart-dialog').style.display = 'none';",
107
+ " triggerRestart();",
108
+ " }",
109
+ " }",
110
+ " window.updateRestartDialogStatus = updateRestartDialogStatus;",
111
+ // Cancel the pending restart.
112
+ " window.cancelPendingRestart = function() {",
113
+ " pendingRestart = false;",
114
+ " document.getElementById('restart-dialog').style.display = 'none';",
115
+ " showToast('Restart cancelled. Changes will apply on next restart.', 'info');",
116
+ " };",
117
+ // Force immediate restart despite active streams.
118
+ " window.forceRestart = function() {",
119
+ " pendingRestart = false;",
120
+ " document.getElementById('restart-dialog').style.display = 'none';",
121
+ " fetch('/config/restart-now', { method: 'POST' })",
122
+ " .then(function(response) {",
123
+ " if (response.ok) {",
124
+ " waitForServerRestart();",
125
+ " } else {",
126
+ " return response.json().then(function(data) {",
127
+ " throw new Error(data.message || 'Restart failed');",
128
+ " });",
129
+ " }",
130
+ " })",
131
+ " .catch(function(err) {",
132
+ " showToast('Failed to restart: ' + err.message, 'error');",
133
+ " });",
134
+ " };",
135
+ // Trigger restart (called when streams reach 0).
136
+ " function triggerRestart() {",
137
+ " fetch('/config/restart-now', { method: 'POST' })",
138
+ " .then(function(response) {",
139
+ " if (response.ok) {",
140
+ " waitForServerRestart();",
141
+ " }",
142
+ " })",
143
+ " .catch(function() {",
144
+ " showToast('Failed to trigger restart. Please restart manually.', 'error');",
145
+ " });",
146
+ " }",
147
+ // Wait for server restart by polling /health, then reload.
148
+ " function waitForServerRestart() {",
149
+ " var attempts = 0;",
150
+ " var maxAttempts = 30;",
151
+ " showToast('Restarting server...', 'info', 0);",
152
+ " if (restartPollInterval) { clearInterval(restartPollInterval); }",
153
+ " restartPollInterval = setInterval(function() {",
154
+ " attempts++;",
155
+ " fetch('/health')",
156
+ " .then(function(response) {",
157
+ " if (response.ok) {",
158
+ " clearInterval(restartPollInterval);",
159
+ " restartPollInterval = null;",
160
+ " showToastAfterReload('Server restarted.', 'success');",
161
+ " }",
162
+ " })",
163
+ " .catch(function() {",
164
+ " if (attempts >= maxAttempts) {",
165
+ " clearInterval(restartPollInterval);",
166
+ " restartPollInterval = null;",
167
+ " showToast('Server did not restart within 30 seconds. Please check the server manually.', 'error');",
168
+ " }",
169
+ " });",
170
+ " }, 1000);",
171
+ " }",
172
+ // Escape HTML entities in text for safe display.
173
+ " function escapeHtmlText(text) {",
174
+ " var div = document.createElement('div');",
175
+ " div.textContent = text;",
176
+ " return div.innerHTML;",
177
+ " }",
178
+ // Open the changelog modal and fetch content dynamically. Also checks whether an upgrade button should be shown.
179
+ " window.openChangelogModal = function() {",
180
+ " var modal = document.getElementById('changelog-modal');",
181
+ " if (!modal) return;",
182
+ " var title = modal.querySelector('.changelog-title');",
183
+ " var loading = modal.querySelector('.changelog-loading');",
184
+ " var content = modal.querySelector('.changelog-content');",
185
+ " var error = modal.querySelector('.changelog-error');",
186
+ " var upgradeBtn = document.getElementById('changelog-upgrade-btn');",
187
+ " modal.style.display = 'flex';",
188
+ " loading.style.display = 'block';",
189
+ " content.style.display = 'none';",
190
+ " error.style.display = 'none';",
191
+ " if (upgradeBtn) upgradeBtn.style.display = 'none';",
192
+ " fetch('/version/changelog')",
193
+ " .then(function(res) { return res.json(); })",
194
+ " .then(function(data) {",
195
+ " loading.style.display = 'none';",
196
+ " title.textContent = \"What's new in v\" + data.displayVersion;",
197
+ " if (data.items && data.items.length > 0) {",
198
+ " var html = '<ul class=\"changelog-list\">';",
199
+ " for (var i = 0; i < data.items.length; i++) {",
200
+ " html += '<li>' + escapeHtmlText(data.items[i]) + '</li>';",
201
+ " }",
202
+ " html += '</ul>';",
203
+ " content.innerHTML = html;",
204
+ " content.style.display = 'block';",
205
+ " } else {",
206
+ " error.style.display = 'block';",
207
+ " }",
208
+ " if (data.updateAvailable && upgradeBtn) { upgradeBtn.style.display = ''; }",
209
+ " })",
210
+ " .catch(function() {",
211
+ " loading.style.display = 'none';",
212
+ " error.style.display = 'block';",
213
+ " });",
214
+ " };",
215
+ // Close the changelog modal.
216
+ " window.closeChangelogModal = function() {",
217
+ " var modal = document.getElementById('changelog-modal');",
218
+ " if (modal) { modal.style.display = 'none'; }",
219
+ " };",
220
+ // Start the upgrade process from the changelog modal. Fetches upgrade info, confirms with the user if there are active streams, then executes the upgrade.
221
+ " window.startUpgrade = function() {",
222
+ " fetch('/upgrade/info')",
223
+ " .then(function(res) { return res.json(); })",
224
+ " .then(function(info) {",
225
+ " if (!info.upgradeable) {",
226
+ " closeChangelogModal();",
227
+ " var msg = info.method === 'docker'",
228
+ " ? 'Docker containers cannot be upgraded in-place. Pull the latest image and recreate the container.'",
229
+ " : 'Manual upgrade required: ' + info.upgradeCommand;",
230
+ " showToast(msg, 'info', 8000);",
231
+ " return;",
232
+ " }",
233
+ " var streamCount = Object.keys(streamData).length;",
234
+ " if (streamCount > 0) {",
235
+ " if (!confirm('There are ' + streamCount + ' active stream(s). Upgrading will interrupt them. Continue?')) return;",
236
+ " }",
237
+ " closeChangelogModal();",
238
+ " showToast('Upgrading PrismCast...', 'info', 0);",
239
+ " return fetch('/upgrade', { method: 'POST' })",
240
+ " .then(function(res) { return res.json(); })",
241
+ " .then(function(result) {",
242
+ " if (result.success && result.willRestart) {",
243
+ " waitForServerRestart();",
244
+ " } else if (result.success) {",
245
+ " showToast('Upgrade complete. Please restart PrismCast manually.', 'success', 8000);",
246
+ " } else {",
247
+ " showToast('Upgrade failed: ' + result.message, 'error');",
248
+ " }",
249
+ " });",
250
+ " })",
251
+ " .catch(function(err) {",
252
+ " showToast('Upgrade failed: ' + err.message, 'error');",
253
+ " });",
254
+ " };",
255
+ // Check for updates manually. Updates the version link in-place when a new version is found.
256
+ " window.checkForUpdates = function() {",
257
+ " var btn = document.querySelector('.version-check');",
258
+ " if (!btn || btn.classList.contains('checking')) return;",
259
+ " btn.classList.add('checking');",
260
+ " fetch('/version/check', { method: 'POST' })",
261
+ " .then(function(res) { return res.json(); })",
262
+ " .then(function(data) {",
263
+ " btn.classList.remove('checking');",
264
+ " if (data.updateAvailable && data.latestVersion) {",
265
+ " var link = document.querySelector('.version-container .version');",
266
+ " if (link && !link.classList.contains('version-update')) {",
267
+ " link.textContent = 'v' + data.currentVersion + ' \\u2192 v' + data.latestVersion;",
268
+ " link.classList.add('version-update');",
269
+ " }",
270
+ " } else {",
271
+ " btn.classList.add('up-to-date');",
272
+ " setTimeout(function() { btn.classList.remove('up-to-date'); }, 2000);",
273
+ " }",
274
+ " })",
275
+ " .catch(function() {",
276
+ " btn.classList.remove('checking');",
277
+ " btn.classList.add('check-error');",
278
+ " setTimeout(function() { btn.classList.remove('check-error'); }, 2000);",
279
+ " });",
280
+ " };",
281
+ // Clear all field error indicators.
282
+ " function clearFieldErrors() {",
283
+ " var errorInputs = document.querySelectorAll('.form-input.error, .form-select.error');",
284
+ " for (var i = 0; i < errorInputs.length; i++) {",
285
+ " errorInputs[i].classList.remove('error');",
286
+ " }",
287
+ " var errorMsgs = document.querySelectorAll('.form-error.dynamic');",
288
+ " for (var j = 0; j < errorMsgs.length; j++) {",
289
+ " errorMsgs[j].remove();",
290
+ " }",
291
+ " }",
292
+ // Display field-level errors from server response.
293
+ " function displayFieldErrors(errors) {",
294
+ " for (var path in errors) {",
295
+ " var inputId = path.replace(/\\./g, '-');",
296
+ " var input = document.getElementById(inputId);",
297
+ " if (input) {",
298
+ " input.classList.add('error');",
299
+ " var errorDiv = document.createElement('div');",
300
+ " errorDiv.className = 'form-error dynamic';",
301
+ " errorDiv.textContent = errors[path];",
302
+ " input.closest('.form-group').appendChild(errorDiv);",
303
+ " }",
304
+ " }",
305
+ " }",
306
+ // Set a form input's value, handling checkbox and standard input types uniformly.
307
+ " function setInputValue(input, value) {",
308
+ " if (input.type === 'checkbox') {",
309
+ " input.checked = value === 'true';",
310
+ " } else {",
311
+ " input.value = value;",
312
+ " }",
313
+ " if (input.hasAttribute('data-checkbox-list')) {",
314
+ " var values = JSON.parse(value || '[]');",
315
+ " var grid = input.closest('.form-group').querySelector('.checkbox-list-grid');",
316
+ " if (grid) {",
317
+ " var cbs = grid.querySelectorAll('input[type=\"checkbox\"]');",
318
+ " for (var k = 0; k < cbs.length; k++) {",
319
+ " cbs[k].checked = values.indexOf(cbs[k].value) !== -1;",
320
+ " }",
321
+ " }",
322
+ " }",
323
+ " }",
324
+ // Get a form input's current value as a string, handling checkbox and standard input types uniformly.
325
+ " function getInputValue(input) {",
326
+ " return input.type === 'checkbox' ? String(input.checked) : input.value;",
327
+ " }",
328
+ // Toggle dependent fields when a boolean checkbox changes. Fields with data-depends-on are visually greyed out and removed from the tab order when the
329
+ // referenced checkbox is unchecked. This function is defined at the top level so that both event handlers and reset functions can call it.
330
+ " function updateDependentFields(checkboxId) {",
331
+ " var checkbox = document.getElementById(checkboxId);",
332
+ " if (!checkbox) return;",
333
+ " var dependents = document.querySelectorAll('[data-depends-on=\"' + checkboxId + '\"]');",
334
+ " for (var i = 0; i < dependents.length; i++) {",
335
+ " if (checkbox.checked) {",
336
+ " dependents[i].classList.remove('depends-disabled');",
337
+ " } else {",
338
+ " dependents[i].classList.add('depends-disabled');",
339
+ " }",
340
+ " var depInputs = dependents[i].querySelectorAll('input:not([type=\"hidden\"]), select');",
341
+ " for (var j = 0; j < depInputs.length; j++) {",
342
+ " depInputs[j].tabIndex = checkbox.checked ? 0 : -1;",
343
+ " }",
344
+ " }",
345
+ " }",
346
+ // Update modified indicators for a single input.
347
+ " function updateModifiedIndicator(input) {",
348
+ " var defaultVal = input.getAttribute('data-default');",
349
+ " var currentVal = getInputValue(input);",
350
+ " var formGroup = input.closest('.form-group');",
351
+ " if (!formGroup) return;",
352
+ " var isModified = currentVal !== defaultVal;",
353
+ " var dot = formGroup.querySelector('.modified-dot');",
354
+ " var resetBtn = formGroup.querySelector('.btn-reset');",
355
+ " if (isModified) {",
356
+ " formGroup.classList.add('modified');",
357
+ " if (!dot) {",
358
+ " var label = formGroup.querySelector('.form-label');",
359
+ " if (label) {",
360
+ " var newDot = document.createElement('span');",
361
+ " newDot.className = 'modified-dot';",
362
+ " newDot.title = 'Modified from default';",
363
+ " label.insertBefore(newDot, label.firstChild);",
364
+ " }",
365
+ " }",
366
+ " if (!resetBtn) {",
367
+ " var row = formGroup.querySelector('.form-row');",
368
+ " if (row) {",
369
+ " var path = input.getAttribute('name');",
370
+ " var btn = document.createElement('button');",
371
+ " btn.type = 'button';",
372
+ " btn.className = 'btn-reset';",
373
+ " btn.title = 'Reset to default';",
374
+ " btn.setAttribute('aria-label', 'Reset to default');",
375
+ " btn.innerHTML = '&#8635;';",
376
+ " btn.onclick = function() { resetSetting(path); };",
377
+ " row.appendChild(btn);",
378
+ " }",
379
+ " }",
380
+ " } else {",
381
+ " formGroup.classList.remove('modified');",
382
+ " if (dot) dot.remove();",
383
+ " if (resetBtn) resetBtn.remove();",
384
+ " }",
385
+ " }",
386
+ // Reset a single setting to its default value (client-side only). Dispatches both input and change events to match browser behavior: input for validation and
387
+ // modified indicator updates, change for cascade handlers (e.g., preset dropdown updating bitrate and frame rate fields).
388
+ " window.resetSetting = function(path) {",
389
+ " var inputId = path.replace(/\\./g, '-');",
390
+ " var input = document.getElementById(inputId);",
391
+ " if (!input) return;",
392
+ " var defaultVal = input.getAttribute('data-default');",
393
+ " if (defaultVal !== null) {",
394
+ " setInputValue(input, defaultVal);",
395
+ " input.dispatchEvent(new Event('input', { bubbles: true }));",
396
+ " input.dispatchEvent(new Event('change', { bubbles: true }));",
397
+ " }",
398
+ " };",
399
+ // Reset all settings in a tab to defaults (client-side only). Works for both settings and advanced tabs.
400
+ " window.resetTabToDefaults = function(tabId) {",
401
+ " if (!confirm('Reset all settings in this tab to defaults?')) return;",
402
+ " var panel = document.getElementById('subtab-' + tabId);",
403
+ " if (!panel) return;",
404
+ " var inputs = panel.querySelectorAll('input[data-default], select[data-default]');",
405
+ " for (var i = 0; i < inputs.length; i++) {",
406
+ " var input = inputs[i];",
407
+ " if (!input.disabled) {",
408
+ " setInputValue(input, input.getAttribute('data-default'));",
409
+ " updateModifiedIndicator(input);",
410
+ " }",
411
+ " }",
412
+ " var cbInputs = panel.querySelectorAll('input[type=\"checkbox\"][data-default]');",
413
+ " for (var j = 0; j < cbInputs.length; j++) {",
414
+ " if (!cbInputs[j].disabled) {",
415
+ " updateDependentFields(cbInputs[j].id);",
416
+ " }",
417
+ " }",
418
+ " showToast('Settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
419
+ " };",
420
+ // Toggle collapsible section in Advanced tab.
421
+ " window.toggleSection = function(sectionId) {",
422
+ " var section = document.querySelector('.advanced-section[data-section=\"' + sectionId + '\"]');",
423
+ " if (!section) return;",
424
+ " var header = section.querySelector('.section-header');",
425
+ " var content = section.querySelector('.section-content');",
426
+ " if (!header || !content) return;",
427
+ " var isExpanded = content.classList.contains('expanded');",
428
+ " if (isExpanded) {",
429
+ " content.classList.remove('expanded');",
430
+ " header.classList.remove('expanded');",
431
+ " } else {",
432
+ " content.classList.add('expanded');",
433
+ " header.classList.add('expanded');",
434
+ " }",
435
+ " try {",
436
+ " var expanded = JSON.parse(localStorage.getItem('prismcast-advanced-sections') || '{}');",
437
+ " expanded[sectionId] = !isExpanded;",
438
+ " localStorage.setItem('prismcast-advanced-sections', JSON.stringify(expanded));",
439
+ " } catch(e) {}",
440
+ " };",
441
+ // Initialize section expansion state from localStorage.
442
+ " function initSections() {",
443
+ " try {",
444
+ " var expanded = JSON.parse(localStorage.getItem('prismcast-advanced-sections') || '{}');",
445
+ " for (var sectionId in expanded) {",
446
+ " if (expanded[sectionId]) {",
447
+ " var section = document.querySelector('.advanced-section[data-section=\"' + sectionId + '\"]');",
448
+ " if (section) {",
449
+ " var header = section.querySelector('.section-header');",
450
+ " var content = section.querySelector('.section-content');",
451
+ " if (header && content) {",
452
+ " header.classList.add('expanded');",
453
+ " content.classList.add('expanded');",
454
+ " }",
455
+ " }",
456
+ " }",
457
+ " }",
458
+ " } catch(e) {}",
459
+ " }",
460
+ " initSections();",
461
+ // Reset all settings to defaults (client-side only).
462
+ " window.resetAllToDefaults = function() {",
463
+ " if (!confirm('Reset ALL settings to defaults? Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' after to apply.')) return;",
464
+ " var form = document.getElementById('settings-form');",
465
+ " if (!form) return;",
466
+ " var inputs = form.querySelectorAll('input[data-default], select[data-default]');",
467
+ " for (var i = 0; i < inputs.length; i++) {",
468
+ " var input = inputs[i];",
469
+ " if (!input.disabled) {",
470
+ " setInputValue(input, input.getAttribute('data-default'));",
471
+ " updateModifiedIndicator(input);",
472
+ " }",
473
+ " }",
474
+ " var cbInputs = form.querySelectorAll('input[type=\"checkbox\"][data-default]');",
475
+ " for (var j = 0; j < cbInputs.length; j++) {",
476
+ " if (!cbInputs[j].disabled) {",
477
+ " updateDependentFields(cbInputs[j].id);",
478
+ " }",
479
+ " }",
480
+ " showToast('All settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
481
+ " };",
482
+ // Submit settings form via AJAX.
483
+ " window.submitSettingsForm = function(event) {",
484
+ " event.preventDefault();",
485
+ " clearFieldErrors();",
486
+ " var form = document.getElementById('settings-form');",
487
+ " var saveBtn = document.getElementById('save-btn');",
488
+ " if (!form) return false;",
489
+ " var formData = new FormData(form);",
490
+ " var config = {};",
491
+ " for (var pair of formData.entries()) {",
492
+ " var path = pair[0];",
493
+ " var value = pair[1];",
494
+ " var parts = path.split('.');",
495
+ " var obj = config;",
496
+ " for (var i = 0; i < parts.length - 1; i++) {",
497
+ " if (!obj[parts[i]]) obj[parts[i]] = {};",
498
+ " obj = obj[parts[i]];",
499
+ " }",
500
+ " obj[parts[parts.length - 1]] = value;",
501
+ " }",
502
+ " if (saveBtn) saveBtn.classList.add('loading');",
503
+ " fetch('/config', {",
504
+ " method: 'POST',",
505
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
506
+ " body: JSON.stringify(config)",
507
+ " })",
508
+ " .then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); })",
509
+ " .then(function(result) {",
510
+ " if (saveBtn) saveBtn.classList.remove('loading');",
511
+ " if (result.ok && result.data.success) {",
512
+ " if (result.data.willRestart) {",
513
+ " if (result.data.deferred) {",
514
+ " showPendingRestartDialog(result.data.activeStreams);",
515
+ " } else {",
516
+ " waitForServerRestart();",
517
+ " }",
518
+ " } else {",
519
+ " showToast(result.data.message || 'Configuration saved.', 'info');",
520
+ " }",
521
+ " } else if (result.data.errors) {",
522
+ " displayFieldErrors(result.data.errors);",
523
+ " showToast('Please correct the errors below.', 'error');",
524
+ " } else {",
525
+ " showToast(result.data.message || 'Failed to save configuration.', 'error');",
526
+ " }",
527
+ " })",
528
+ " .catch(function(err) {",
529
+ " if (saveBtn) saveBtn.classList.remove('loading');",
530
+ " showToast('Failed to save configuration: ' + err.message, 'error');",
531
+ " });",
532
+ " return false;",
533
+ " };",
534
+ // Export configuration as JSON download.
535
+ " window.exportConfig = function() {",
536
+ " fetch('/config/export')",
537
+ " .then(function(response) { return response.blob(); })",
538
+ " .then(function(blob) {",
539
+ " var url = window.URL.createObjectURL(blob);",
540
+ " var a = document.createElement('a');",
541
+ " a.href = url;",
542
+ " a.download = 'prismcast-config.json';",
543
+ " document.body.appendChild(a);",
544
+ " a.click();",
545
+ " document.body.removeChild(a);",
546
+ " window.URL.revokeObjectURL(url);",
547
+ " })",
548
+ " .catch(function(err) { showToast('Failed to export configuration: ' + err.message, 'error'); });",
549
+ " };",
550
+ // Import configuration from file.
551
+ " window.importConfig = function(fileInput) {",
552
+ " var file = fileInput.files[0];",
553
+ " if (!file) return;",
554
+ " var reader = new FileReader();",
555
+ " reader.onload = function(e) {",
556
+ " try {",
557
+ " var config = JSON.parse(e.target.result);",
558
+ " if (confirm('Import this configuration? The server may restart to apply changes.')) {",
559
+ " fetch('/config/import', {",
560
+ " method: 'POST',",
561
+ " headers: { 'Content-Type': 'application/json' },",
562
+ " body: JSON.stringify(config)",
563
+ " })",
564
+ " .then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); })",
565
+ " .then(function(result) {",
566
+ " if (result.ok && result.data.success) {",
567
+ " showToast(result.data.message || 'Configuration imported.', 'success');",
568
+ " if (result.data.willRestart) {",
569
+ " if (result.data.deferred) {",
570
+ " showPendingRestartDialog(result.data.activeStreams);",
571
+ " } else {",
572
+ " waitForServerRestart();",
573
+ " }",
574
+ " }",
575
+ " } else {",
576
+ " throw new Error(result.data.message || result.data.error || 'Import failed');",
577
+ " }",
578
+ " })",
579
+ " .catch(function(err) { showToast('Failed to import configuration: ' + err.message, 'error'); });",
580
+ " }",
581
+ " } catch (err) {",
582
+ " showToast('Invalid JSON file: ' + err.message, 'error');",
583
+ " }",
584
+ " fileInput.value = '';",
585
+ " };",
586
+ " reader.readAsText(file);",
587
+ " };",
588
+ // Export channels as JSON download.
589
+ " window.exportChannels = function() {",
590
+ " fetch('/config/channels/export')",
591
+ " .then(function(response) { return response.blob(); })",
592
+ " .then(function(blob) {",
593
+ " var url = window.URL.createObjectURL(blob);",
594
+ " var a = document.createElement('a');",
595
+ " a.href = url;",
596
+ " a.download = 'prismcast-channels.json';",
597
+ " document.body.appendChild(a);",
598
+ " a.click();",
599
+ " document.body.removeChild(a);",
600
+ " window.URL.revokeObjectURL(url);",
601
+ " })",
602
+ " .catch(function(err) { showToast('Failed to export channels: ' + err.message, 'error'); });",
603
+ " };",
604
+ // Import channels from file.
605
+ " window.importChannels = function(fileInput) {",
606
+ " var file = fileInput.files[0];",
607
+ " if (!file) return;",
608
+ " var reader = new FileReader();",
609
+ " reader.onload = function(e) {",
610
+ " try {",
611
+ " var channels = JSON.parse(e.target.result);",
612
+ " if (confirm('Import these channels? This will replace all existing user channels.')) {",
613
+ " fetch('/config/channels/import', {",
614
+ " method: 'POST',",
615
+ " headers: { 'Content-Type': 'application/json' },",
616
+ " body: JSON.stringify(channels)",
617
+ " })",
618
+ " .then(function(response) {",
619
+ " if (response.ok) {",
620
+ " showToastAfterReload('Channels imported successfully.' + PLAYLIST_HINT, 'success');",
621
+ " } else {",
622
+ " return response.text().then(function(text) { throw new Error(text); });",
623
+ " }",
624
+ " })",
625
+ " .catch(function(err) { showToast('Failed to import channels: ' + err.message, 'error'); });",
626
+ " }",
627
+ " } catch (err) {",
628
+ " showToast('Invalid JSON file: ' + err.message, 'error');",
629
+ " }",
630
+ " fileInput.value = '';",
631
+ " };",
632
+ " reader.readAsText(file);",
633
+ " };",
634
+ // Import channels from M3U playlist file.
635
+ " window.importM3U = function(fileInput) {",
636
+ " var file = fileInput.files[0];",
637
+ " if (!file) return;",
638
+ " var replaceCheckbox = document.getElementById('m3u-replace-duplicates');",
639
+ " var conflictMode = (replaceCheckbox && replaceCheckbox.checked) ? 'replace' : 'skip';",
640
+ " var reader = new FileReader();",
641
+ " reader.onload = function(e) {",
642
+ " fetch('/config/channels/import-m3u', {",
643
+ " method: 'POST',",
644
+ " headers: { 'Content-Type': 'application/json' },",
645
+ " body: JSON.stringify({ content: e.target.result, conflictMode: conflictMode })",
646
+ " })",
647
+ " .then(function(response) { return response.json(); })",
648
+ " .then(function(data) {",
649
+ " if (data.success) {",
650
+ " var msg = 'M3U Import Complete\\n\\n';",
651
+ " msg += '\\u2713 ' + data.imported + ' channel(s) imported\\n';",
652
+ " if (data.replaced > 0) msg += '\\u21BB ' + data.replaced + ' channel(s) replaced\\n';",
653
+ " if (data.skipped > 0) msg += '\\u25CB ' + data.skipped + ' duplicate(s) skipped\\n';",
654
+ " if (data.errors && data.errors.length > 0) {",
655
+ " msg += '\\n! ' + data.errors.length + ' error(s):\\n';",
656
+ " for (var i = 0; i < Math.min(data.errors.length, 5); i++) {",
657
+ " msg += ' - ' + data.errors[i] + '\\n';",
658
+ " }",
659
+ " if (data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
660
+ " }",
661
+ " if (data.imported > 0 || data.replaced > 0) { showToastAfterReload(msg + PLAYLIST_HINT, 'success'); }",
662
+ " else { showToast(msg, 'success'); }",
663
+ " } else {",
664
+ " showToast('M3U import failed: ' + (data.error || 'Unknown error'), 'error');",
665
+ " }",
666
+ " })",
667
+ " .catch(function(err) { showToast('Failed to import M3U: ' + err.message, 'error'); });",
668
+ " fileInput.value = '';",
669
+ " };",
670
+ " reader.readAsText(file);",
671
+ " };",
672
+ // Client-side validation for numeric inputs.
673
+ " function validateInput(input) {",
674
+ " var min = input.min !== '' ? Number(input.min) : null;",
675
+ " var max = input.max !== '' ? Number(input.max) : null;",
676
+ " var value = Number(input.value);",
677
+ " var isValid = true;",
678
+ " if (input.type === 'number') {",
679
+ " if (isNaN(value)) { isValid = false; }",
680
+ " else if (min !== null && value < min) { isValid = false; }",
681
+ " else if (max !== null && value > max) { isValid = false; }",
682
+ " }",
683
+ " if (isValid) {",
684
+ " input.classList.remove('error');",
685
+ " } else {",
686
+ " input.classList.add('error');",
687
+ " }",
688
+ " return isValid;",
689
+ " }",
690
+ // Subtab switching function.
691
+ " function switchSubtab(subtab, updateUrl) {",
692
+ " var btns = document.querySelectorAll('.subtab-btn');",
693
+ " var panels = document.querySelectorAll('.subtab-panel');",
694
+ " var settingsButtons = document.getElementById('settings-buttons');",
695
+ " for (var i = 0; i < btns.length; i++) {",
696
+ " btns[i].classList.remove('active');",
697
+ " btns[i].setAttribute('aria-selected', 'false');",
698
+ " if (btns[i].getAttribute('data-subtab') === subtab) {",
699
+ " btns[i].classList.add('active');",
700
+ " btns[i].setAttribute('aria-selected', 'true');",
701
+ " }",
702
+ " }",
703
+ " for (var j = 0; j < panels.length; j++) {",
704
+ " panels[j].classList.remove('active');",
705
+ " if (panels[j].id === 'subtab-' + subtab) {",
706
+ " panels[j].classList.add('active');",
707
+ " }",
708
+ " }",
709
+ // Show/hide settings buttons based on subtab (hidden on backup subtab).
710
+ " if (settingsButtons) {",
711
+ " settingsButtons.style.display = (subtab === 'backup') ? 'none' : 'flex';",
712
+ " }",
713
+ // Update localStorage and URL hash.
714
+ " try { localStorage.setItem('prismcast-config-subtab', subtab); } catch(e) {}",
715
+ " if (updateUrl !== false) {",
716
+ " var newHash = '#config/' + subtab;",
717
+ " if (window.location.hash !== newHash) {",
718
+ " window.location.hash = newHash;",
719
+ " }",
720
+ " }",
721
+ " }",
722
+ // Expose for main tab script to use.
723
+ " window.switchConfigSubtab = switchSubtab;",
724
+ // Attach click handlers to subtab buttons.
725
+ " var subtabBtns = document.querySelectorAll('.subtab-btn');",
726
+ " for (var i = 0; i < subtabBtns.length; i++) {",
727
+ " subtabBtns[i].addEventListener('click', function() {",
728
+ " switchSubtab(this.getAttribute('data-subtab'));",
729
+ " });",
730
+ " }",
731
+ // Channel edit form show/hide functions.
732
+ " window.showEditForm = function(key) {",
733
+ " var displayRow = document.getElementById('display-row-' + key);",
734
+ " var editRow = document.getElementById('edit-row-' + key);",
735
+ " if (displayRow) displayRow.style.display = 'none';",
736
+ " if (editRow) {",
737
+ " editRow.style.display = '';",
738
+ " updateSelectorSuggestions('edit-url-' + key, 'edit-' + key + '-selectorList');",
739
+ " var urlInput = document.getElementById('edit-url-' + key);",
740
+ " if (urlInput && !urlInput.dataset.selectorBound) {",
741
+ " urlInput.addEventListener('input', function() { updateSelectorSuggestions('edit-url-' + key, 'edit-' + key + '-selectorList'); });",
742
+ " urlInput.dataset.selectorBound = 'true';",
743
+ " }",
744
+ " }",
745
+ " };",
746
+ " window.hideEditForm = function(key) {",
747
+ " var displayRow = document.getElementById('display-row-' + key);",
748
+ " var editRow = document.getElementById('edit-row-' + key);",
749
+ " if (displayRow) displayRow.style.display = '';",
750
+ " if (editRow) editRow.style.display = 'none';",
751
+ " };",
752
+ // Hide add form and show the Add Channel button.
753
+ " window.hideAddForm = function() {",
754
+ " var addForm = document.getElementById('add-channel-form');",
755
+ " var addBtn = document.getElementById('add-channel-btn');",
756
+ " if (addForm) addForm.style.display = 'none';",
757
+ " if (addBtn) addBtn.style.display = 'inline-block';",
758
+ " if (addForm) addForm.querySelector('form').reset();",
759
+ " };",
760
+ // Insert or replace channel rows in the table. Always removes existing rows with the same key first (handles both edits and overrides of builtin channels).
761
+ // Uses the current table sort field and direction for correct insertion order.
762
+ " window.insertChannelRow = function(html, key) {",
763
+ " var tbody = document.querySelector('.channel-table tbody');",
764
+ " var table = document.querySelector('.channel-table');",
765
+ " if (!tbody || !html || !table) return;",
766
+ // Remove any existing rows with this key (edit or override of builtin).
767
+ " var oldDisplay = document.getElementById('display-row-' + key);",
768
+ " var oldEdit = document.getElementById('edit-row-' + key);",
769
+ " if (oldEdit) oldEdit.remove();",
770
+ " if (oldDisplay) oldDisplay.remove();",
771
+ // Create temporary container to parse HTML.
772
+ " var temp = document.createElement('tbody');",
773
+ " temp.innerHTML = html.displayRow + (html.editRow || '');",
774
+ " var newDisplayRow = temp.firstElementChild;",
775
+ " var newEditRow = temp.children[1] || null;",
776
+ // Find insertion point using current sort field and direction.
777
+ " var sortField = table.getAttribute('data-sort-field') || 'name';",
778
+ " var sortDir = table.getAttribute('data-sort-dir') || 'asc';",
779
+ " var newVal = getSortValue(newDisplayRow, sortField);",
780
+ " var rows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
781
+ " var inserted = false;",
782
+ " for (var i = 0; i < rows.length; i++) {",
783
+ " var rowVal = getSortValue(rows[i], sortField);",
784
+ " var cmp = (sortDir === 'asc') ? (newVal < rowVal) : (newVal > rowVal);",
785
+ " if (cmp) {",
786
+ " tbody.insertBefore(newDisplayRow, rows[i]);",
787
+ " if (newEditRow) tbody.insertBefore(newEditRow, rows[i]);",
788
+ " inserted = true;",
789
+ " break;",
790
+ " }",
791
+ " }",
792
+ " if (!inserted) {",
793
+ " tbody.appendChild(newDisplayRow);",
794
+ " if (newEditRow) tbody.appendChild(newEditRow);",
795
+ " }",
796
+ " updateDisabledCount();",
797
+ " };",
798
+ // Remove channel rows from the table.
799
+ " window.removeChannelRow = function(key) {",
800
+ " var displayRow = document.getElementById('display-row-' + key);",
801
+ " var editRow = document.getElementById('edit-row-' + key);",
802
+ " if (displayRow) displayRow.remove();",
803
+ " if (editRow) editRow.remove();",
804
+ " updateDisabledCount();",
805
+ " };",
806
+ // Advanced fields toggle for channel forms.
807
+ " window.toggleAdvanced = function(prefix) {",
808
+ " var fields = document.getElementById(prefix + '-advanced');",
809
+ " var toggle = document.getElementById(prefix + '-toggle');",
810
+ " if (fields && toggle) {",
811
+ " if (fields.classList.contains('show')) {",
812
+ " fields.classList.remove('show');",
813
+ " toggle.textContent = '\\u25B6 Show Advanced Options';",
814
+ " } else {",
815
+ " fields.classList.add('show');",
816
+ " toggle.textContent = '\\u25BC Hide Advanced Options';",
817
+ " }",
818
+ " }",
819
+ " };",
820
+ // Profile reference toggle.
821
+ " window.toggleProfileReference = function() {",
822
+ " var ref = document.getElementById('profile-reference');",
823
+ " if (ref) {",
824
+ " ref.style.display = ref.style.display === 'none' ? 'block' : 'none';",
825
+ " }",
826
+ " };",
827
+ // Submit channel form via AJAX (add or edit).
828
+ " window.submitChannelForm = function(event, action) {",
829
+ " event.preventDefault();",
830
+ " var form = event.target;",
831
+ " var formData = new FormData(form);",
832
+ " var data = {};",
833
+ " for (var pair of formData.entries()) { data[pair[0]] = pair[1]; }",
834
+ " fetch('/config/channels', {",
835
+ " method: 'POST',",
836
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
837
+ " body: JSON.stringify(data)",
838
+ " })",
839
+ " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
840
+ " .then(function(result) {",
841
+ " if (result.ok && result.data.success) {",
842
+ " showToast(result.data.message, 'success');",
843
+ " if (result.data.html) {",
844
+ " insertChannelRow(result.data.html, result.data.key);",
845
+ " refilterChannelRows();",
846
+ " }",
847
+ " if (action === 'add') {",
848
+ " hideAddForm();",
849
+ " } else {",
850
+ " hideEditForm(result.data.key);",
851
+ " }",
852
+ " } else if (result.data.errors) {",
853
+ " var errorMsgs = [];",
854
+ " for (var field in result.data.errors) { errorMsgs.push(field + ': ' + result.data.errors[field]); }",
855
+ " showToast('Validation errors: ' + errorMsgs.join(', '), 'error');",
856
+ " } else {",
857
+ " showToast(result.data.message || 'Failed to save channel.', 'error');",
858
+ " }",
859
+ " })",
860
+ " .catch(function(err) { showToast('Failed to save channel: ' + err.message, 'error'); });",
861
+ " return false;",
862
+ " };",
863
+ // Delete channel via AJAX.
864
+ " window.deleteChannel = function(key) {",
865
+ " if (!confirm('Delete channel ' + key + '?')) return;",
866
+ " fetch('/config/channels', {",
867
+ " method: 'POST',",
868
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
869
+ " body: JSON.stringify({ action: 'delete', key: key })",
870
+ " })",
871
+ " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
872
+ " .then(function(result) {",
873
+ " if (result.ok && result.data.success) {",
874
+ " showToast(result.data.message, 'success');",
875
+ " if (result.data.html) {",
876
+ " insertChannelRow(result.data.html, result.data.key || key);",
877
+ " refilterChannelRows();",
878
+ " } else {",
879
+ " removeChannelRow(result.data.key || key);",
880
+ " }",
881
+ " } else {",
882
+ " showToast(result.data.message || 'Failed to delete channel.', 'error');",
883
+ " }",
884
+ " })",
885
+ " .catch(function(err) { showToast('Failed to delete channel: ' + err.message, 'error'); });",
886
+ " };",
887
+ // Toggle a single predefined channel's enabled/disabled state.
888
+ " window.togglePredefinedChannel = function(key, enable) {",
889
+ " fetch('/config/channels/toggle-predefined', {",
890
+ " method: 'POST',",
891
+ " headers: { 'Content-Type': 'application/json' },",
892
+ " body: JSON.stringify({ key: key, enabled: enable })",
893
+ " })",
894
+ " .then(function(response) { return response.json(); })",
895
+ " .then(function(result) {",
896
+ " if (result.success) {",
897
+ " showToast('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
898
+ " updateChannelRowDisabledState(key, !enable);",
899
+ " } else {",
900
+ " showToast(result.error || 'Failed to toggle channel.', 'error');",
901
+ " }",
902
+ " })",
903
+ " .catch(function(err) { showToast('Failed to toggle channel: ' + err.message, 'error'); });",
904
+ " };",
905
+ // Update a channel row's provider selection in-place. Syncs the HTML selected attribute so filterChannelRows() restore logic works correctly. We iterate
906
+ // _allOptions (if present) rather than querySelectorAll because filtered-out options are removed from the DOM but still tracked in the array.
907
+ " function updateChannelProviderUI(channelKey, variant) {",
908
+ " var row = document.getElementById('display-row-' + channelKey);",
909
+ " if (!row) return;",
910
+ " var sel = row.querySelector('.provider-select');",
911
+ " if (!sel) return;",
912
+ " sel.value = variant;",
913
+ " var allOpts = sel._allOptions || Array.prototype.slice.call(sel.querySelectorAll('option'));",
914
+ " for (var oi = 0; oi < allOpts.length; oi++) {",
915
+ " if (allOpts[oi].value === variant) { allOpts[oi].setAttribute('selected', ''); }",
916
+ " else { allOpts[oi].removeAttribute('selected'); }",
917
+ " }",
918
+ " }",
919
+ // Update provider selection for a multi-provider channel.
920
+ " window.updateProviderSelection = function(selectElement) {",
921
+ " var channelKey = selectElement.getAttribute('data-channel');",
922
+ " var providerKey = selectElement.value;",
923
+ " fetch('/config/provider', {",
924
+ " method: 'POST',",
925
+ " headers: { 'Content-Type': 'application/json' },",
926
+ " body: JSON.stringify({ channel: channelKey, provider: providerKey })",
927
+ " })",
928
+ " .then(function(response) { return response.json(); })",
929
+ " .then(function(result) {",
930
+ " if (result.success) {",
931
+ " showToast('Provider updated. New streams will use the selected provider.', 'success');",
932
+ " if (result.html) { insertChannelRow(result.html, channelKey); refilterChannelRows(); }",
933
+ " } else {",
934
+ " showToast(result.error || 'Failed to update provider.', 'error');",
935
+ " }",
936
+ " })",
937
+ " .catch(function(err) { showToast('Failed to update provider: ' + err.message, 'error'); });",
938
+ " };",
939
+ // Bulk toggle predefined channels by scope (all, pacific, east).
940
+ " window.bulkTogglePredefined = function(enable, scope) {",
941
+ " fetch('/config/channels/bulk-toggle-predefined', {",
942
+ " method: 'POST',",
943
+ " headers: { 'Content-Type': 'application/json' },",
944
+ " body: JSON.stringify({ enabled: enable, scope: scope })",
945
+ " })",
946
+ " .then(function(response) { return response.json(); })",
947
+ " .then(function(result) {",
948
+ " if (result.success) {",
949
+ " var labels = { all: 'All predefined', pacific: 'Pacific predefined', east: 'East predefined' };",
950
+ " showToast((labels[scope] || 'Predefined') + ' channels ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
951
+ " if (result.keys) {",
952
+ " for (var i = 0; i < result.keys.length; i++) { setRowDisabledState(result.keys[i], !enable); }",
953
+ " }",
954
+ " updateDisabledCount();",
955
+ " } else {",
956
+ " showToast(result.error || 'Failed to toggle channels.', 'error');",
957
+ " }",
958
+ " })",
959
+ " .catch(function(err) { showToast('Failed to toggle channels: ' + err.message, 'error'); });",
960
+ " };",
961
+ // SVG icon strings for dynamic DOM manipulation when toggling button states.
962
+ " var ICON_LOGIN_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
963
+ "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M6.5 2H3.5a1 1 0 00-1 1v10a1 1 0 001 1h3\"/><path d=\"M10.5 11l3-3-3-3\"/>" +
964
+ "<path d=\"M13.5 8H6.5\"/></svg>';",
965
+ " var ICON_ENABLE_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
966
+ "stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 8l2 2 3.5-4\"/></svg>';",
967
+ " var ICON_DISABLE_SVG = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
968
+ "stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 5.5l5 5\"/></svg>';",
969
+ // Revert a channel override back to predefined defaults.
970
+ " window.revertChannel = function(key) {",
971
+ " if (!confirm('Revert channel ' + key + ' to predefined defaults?')) return;",
972
+ " fetch('/config/channels', {",
973
+ " method: 'POST',",
974
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
975
+ " body: JSON.stringify({ action: 'revert', key: key })",
976
+ " })",
977
+ " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
978
+ " .then(function(result) {",
979
+ " if (result.ok && result.data.success) {",
980
+ " showToast(result.data.message, 'success');",
981
+ " if (result.data.html) {",
982
+ " insertChannelRow(result.data.html, result.data.key || key);",
983
+ " refilterChannelRows();",
984
+ " }",
985
+ " } else {",
986
+ " showToast(result.data.message || 'Failed to revert channel.', 'error');",
987
+ " }",
988
+ " })",
989
+ " .catch(function(err) { showToast('Failed to revert channel: ' + err.message, 'error'); });",
990
+ " };",
991
+ // Set a channel row's disabled state without triggering count updates. Uses icon buttons. Used by bulkTogglePredefined for efficient bulk updates.
992
+ " function setRowDisabledState(key, disabled) {",
993
+ " var row = document.getElementById('display-row-' + key);",
994
+ " if (!row) return;",
995
+ " var btnGroup = row.querySelector('.btn-group');",
996
+ " if (!btnGroup) return;",
997
+ " if (disabled) {",
998
+ " row.classList.add('channel-disabled');",
999
+ " row.classList.remove('user-channel');",
1000
+ // Replace login icon with placeholder.
1001
+ " var loginBtn = btnGroup.querySelector('.btn-icon-login');",
1002
+ " if (loginBtn) {",
1003
+ " var placeholder = document.createElement('span');",
1004
+ " placeholder.className = 'btn-icon-placeholder';",
1005
+ " loginBtn.replaceWith(placeholder);",
1006
+ " }",
1007
+ // Swap disable icon to enable icon.
1008
+ " var disableBtn = btnGroup.querySelector('.btn-icon-disable');",
1009
+ " if (disableBtn) {",
1010
+ " disableBtn.className = 'btn-icon btn-icon-enable';",
1011
+ " disableBtn.title = 'Enable';",
1012
+ " disableBtn.setAttribute('aria-label', 'Enable');",
1013
+ " disableBtn.innerHTML = ICON_ENABLE_SVG;",
1014
+ " disableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', true)\");",
1015
+ " }",
1016
+ " } else {",
1017
+ " row.classList.remove('channel-disabled');",
1018
+ // Swap enable icon to disable icon.
1019
+ " var enableBtn = btnGroup.querySelector('.btn-icon-enable');",
1020
+ " if (enableBtn) {",
1021
+ // Replace placeholder with login icon button.
1022
+ " var placeholder = btnGroup.querySelector('.btn-icon-placeholder');",
1023
+ " if (placeholder) {",
1024
+ " var newLoginBtn = document.createElement('button');",
1025
+ " newLoginBtn.type = 'button';",
1026
+ " newLoginBtn.className = 'btn-icon btn-icon-login';",
1027
+ " newLoginBtn.title = 'Login';",
1028
+ " newLoginBtn.setAttribute('aria-label', 'Login');",
1029
+ " newLoginBtn.innerHTML = ICON_LOGIN_SVG;",
1030
+ " newLoginBtn.setAttribute('onclick', \"startChannelLogin('\" + key + \"')\");",
1031
+ " placeholder.replaceWith(newLoginBtn);",
1032
+ " }",
1033
+ " enableBtn.className = 'btn-icon btn-icon-disable';",
1034
+ " enableBtn.title = 'Disable';",
1035
+ " enableBtn.setAttribute('aria-label', 'Disable');",
1036
+ " enableBtn.innerHTML = ICON_DISABLE_SVG;",
1037
+ " enableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', false)\");",
1038
+ " }",
1039
+ " }",
1040
+ " }",
1041
+ // Update a single channel row's disabled state and refresh counts. Used by individual togglePredefinedChannel.
1042
+ " function updateChannelRowDisabledState(key, disabled) {",
1043
+ " setRowDisabledState(key, disabled);",
1044
+ " updateDisabledCount();",
1045
+ " };",
1046
+ // Update the disabled channel count shown in the toolbar toggle label. Uses a union selector to avoid double-counting rows that are both disabled and
1047
+ // provider-unavailable.
1048
+ " function updateDisabledCount() {",
1049
+ " var countEl = document.getElementById('disabled-count');",
1050
+ " if (!countEl) return;",
1051
+ " var hiddenRows = document.querySelectorAll('tr.channel-disabled:not(.user-channel), tr.channel-unavailable');",
1052
+ " countEl.textContent = String(hiddenRows.length);",
1053
+ " };",
1054
+ // Save the provider filter to the server and update the UI on success.
1055
+ " function saveProviderFilter(enabledTags) {",
1056
+ " fetch('/config/provider-filter', {",
1057
+ " method: 'POST',",
1058
+ " headers: { 'Content-Type': 'application/json' },",
1059
+ " body: JSON.stringify({ enabledProviders: enabledTags })",
1060
+ " })",
1061
+ " .then(function(r) { return r.json(); })",
1062
+ " .then(function(result) {",
1063
+ " if (result.success) {",
1064
+ " updateProviderChips(enabledTags);",
1065
+ " filterChannelRows(enabledTags);",
1066
+ " updateBulkAssignOptions(enabledTags);",
1067
+ " updateProviderFilterButton(enabledTags);",
1068
+ " updateDisabledCount();",
1069
+ " showToast('Provider filter updated.' + PLAYLIST_HINT, 'success');",
1070
+ " }",
1071
+ " })",
1072
+ " .catch(function(err) { showToast('Failed to update filter: ' + err.message, 'error'); });",
1073
+ " };",
1074
+ // Provider filter: toggle a provider tag on/off.
1075
+ " window.toggleProviderTag = function(checkbox) {",
1076
+ " var menu = checkbox.closest('.provider-dropdown-menu');",
1077
+ " if (!menu) return;",
1078
+ " var checkboxes = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
1079
+ " var enabledTags = [];",
1080
+ " for (var i = 0; i < checkboxes.length; i++) {",
1081
+ " if (checkboxes[i].checked) enabledTags.push(checkboxes[i].getAttribute('data-tag'));",
1082
+ " }",
1083
+ // If all checkboxes are checked, clear the filter (empty array = no filter).
1084
+ " var allCheckboxes = menu.querySelectorAll('input[type=\"checkbox\"]');",
1085
+ " var allChecked = true;",
1086
+ " for (var j = 0; j < allCheckboxes.length; j++) {",
1087
+ " if (!allCheckboxes[j].checked && !allCheckboxes[j].disabled) { allChecked = false; break; }",
1088
+ " }",
1089
+ " if (allChecked) enabledTags = [];",
1090
+ " saveProviderFilter(enabledTags);",
1091
+ " };",
1092
+ // Remove a provider chip (uncheck the tag and update). Orphaned tags (no checkbox in the dropdown) are removed by rebuilding the list from the remaining chips.
1093
+ " window.removeProviderChip = function(tag) {",
1094
+ " var menu = document.querySelector('.provider-dropdown-menu');",
1095
+ " if (!menu) return;",
1096
+ " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1097
+ " if (cb) { cb.checked = false; toggleProviderTag(cb); return; }",
1098
+ // No checkbox exists for this tag — it is an orphaned entry with no corresponding provider. Build the enabled list from the remaining chips and POST directly.
1099
+ " var chips = document.querySelectorAll('#provider-chips .provider-chip[data-tag]');",
1100
+ " var enabledTags = [];",
1101
+ " for (var i = 0; i < chips.length; i++) {",
1102
+ " var ct = chips[i].getAttribute('data-tag');",
1103
+ " if (ct && ct !== tag) enabledTags.push(ct);",
1104
+ " }",
1105
+ " saveProviderFilter(enabledTags);",
1106
+ " };",
1107
+ // Update the provider filter button text.
1108
+ " function updateProviderFilterButton(enabledTags) {",
1109
+ " var btn = document.getElementById('provider-filter-btn');",
1110
+ " if (!btn) return;",
1111
+ " btn.innerHTML = (enabledTags.length > 0) ? 'Filtered &#9662;' : 'All Providers &#9662;';",
1112
+ " };",
1113
+ // Rebuild the provider chips from the enabled tags.
1114
+ " function updateProviderChips(enabledTags) {",
1115
+ " var container = document.getElementById('provider-chips');",
1116
+ " if (!container) return;",
1117
+ " container.innerHTML = '';",
1118
+ " if (enabledTags.length === 0) return;",
1119
+ " var menu = document.querySelector('.provider-dropdown-menu');",
1120
+ " for (var i = 0; i < enabledTags.length; i++) {",
1121
+ " var tag = enabledTags[i];",
1122
+ " if (tag === 'direct') continue;",
1123
+ " var label = tag;",
1124
+ " if (menu) {",
1125
+ " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1126
+ " if (cb && cb.parentElement) label = cb.parentElement.textContent.trim();",
1127
+ " }",
1128
+ " var chip = document.createElement('span');",
1129
+ " chip.className = 'provider-chip';",
1130
+ " chip.setAttribute('data-tag', tag);",
1131
+ " chip.innerHTML = label + '<button type=\"button\" class=\"chip-close\" aria-label=\"Remove ' + label +",
1132
+ " '\" onclick=\"removeProviderChip(\\'' + tag + '\\')\">\\u00d7</button>';",
1133
+ " container.appendChild(chip);",
1134
+ " }",
1135
+ " };",
1136
+ // Filter channel rows based on enabled provider tags. Toggles the channel-unavailable class on each row and updates Source column content. Filtered-out options
1137
+ // are removed from the DOM entirely rather than hidden — Safari ignores both the hidden attribute and display:none on option elements because they are rendered by
1138
+ // the OS native widget. All options (visible and removed) are stored in a _allOptions array on each select for reinsertion when the filter changes. Selection
1139
+ // restore priority: (1) the saved choice (HTML selected attribute, kept in sync by updateProviderSelection), (2) the previous visual selection, (3) first option.
1140
+ " function filterChannelRows(enabledTags) {",
1141
+ " var rows = document.querySelectorAll('tr[data-provider-tags]');",
1142
+ " for (var i = 0; i < rows.length; i++) {",
1143
+ " var tags = rows[i].getAttribute('data-provider-tags').split(',');",
1144
+ " var available = true;",
1145
+ " if (enabledTags.length > 0) {",
1146
+ " available = false;",
1147
+ " for (var j = 0; j < tags.length; j++) {",
1148
+ " if (tags[j] === 'direct' || enabledTags.indexOf(tags[j]) !== -1) { available = true; break; }",
1149
+ " }",
1150
+ " }",
1151
+ " if (available) { rows[i].classList.remove('channel-unavailable'); }",
1152
+ " else { rows[i].classList.add('channel-unavailable'); }",
1153
+ // Update Source column elements: toggle between the no-provider label and the provider content (select or static name).
1154
+ " var label = rows[i].querySelector('.no-provider-label');",
1155
+ " var sel = rows[i].querySelector('.provider-select');",
1156
+ " var name = rows[i].querySelector('.provider-name');",
1157
+ " if (label) label.style.display = available ? 'none' : '';",
1158
+ " if (name) name.style.display = available ? '' : 'none';",
1159
+ " if (sel) {",
1160
+ " sel.style.display = available ? '' : 'none';",
1161
+ // On first call, snapshot all options (including server-hidden ones) into a persistent array.
1162
+ " if (!sel._allOptions) { sel._allOptions = Array.prototype.slice.call(sel.querySelectorAll('option')); }",
1163
+ " var prevValue = sel.value;",
1164
+ " sel.innerHTML = '';",
1165
+ " var serverDefault = null;",
1166
+ " var prevExists = false;",
1167
+ " for (var k = 0; k < sel._allOptions.length; k++) {",
1168
+ " var opt = sel._allOptions[k];",
1169
+ " var oTag = opt.getAttribute('data-provider-tag');",
1170
+ " var show = (enabledTags.length === 0) || oTag === 'direct' || enabledTags.indexOf(oTag) !== -1;",
1171
+ " if (show) {",
1172
+ " sel.appendChild(opt);",
1173
+ " if (opt.hasAttribute('selected')) serverDefault = opt;",
1174
+ " if (opt.value === prevValue) prevExists = true;",
1175
+ " }",
1176
+ " }",
1177
+ " if (serverDefault) { sel.value = serverDefault.value; }",
1178
+ " else if (prevExists) { sel.value = prevValue; }",
1179
+ " else if (sel.options.length > 0) { sel.selectedIndex = 0; }",
1180
+ " }",
1181
+ " }",
1182
+ " };",
1183
+ // Re-run the provider filter on all channel rows using the current checkbox state. Called after insertChannelRow replaces a row (which loses the filter state).
1184
+ " function refilterChannelRows() {",
1185
+ " var menu = document.querySelector('.provider-dropdown-menu');",
1186
+ " if (!menu) return;",
1187
+ " var cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
1188
+ " var enabledTags = [];",
1189
+ " var allChecked = true;",
1190
+ " for (var i = 0; i < cbs.length; i++) {",
1191
+ " if (cbs[i].checked) { enabledTags.push(cbs[i].getAttribute('data-tag')); }",
1192
+ " else { allChecked = false; }",
1193
+ " }",
1194
+ " if (allChecked) { enabledTags = []; }",
1195
+ " if (enabledTags.length > 0) { filterChannelRows(enabledTags); }",
1196
+ " }",
1197
+ // Update bulk assign dropdown to only show enabled providers. Uses DOM removal like filterChannelRows because Safari ignores hidden/display:none on option
1198
+ // elements. The snapshot filters by truthy .value to exclude the "Choose provider..." placeholder (value="") so it is never removed from the DOM.
1199
+ " function updateBulkAssignOptions(enabledTags) {",
1200
+ " var select = document.getElementById('bulk-assign');",
1201
+ " if (!select) return;",
1202
+ " if (!select._allOptions) {",
1203
+ " select._allOptions = [];",
1204
+ " var all = select.querySelectorAll('option');",
1205
+ " for (var a = 0; a < all.length; a++) { if (all[a].value) select._allOptions.push(all[a]); }",
1206
+ " }",
1207
+ " for (var i = 0; i < select._allOptions.length; i++) {",
1208
+ " var opt = select._allOptions[i];",
1209
+ " if (opt.parentNode === select) { select.removeChild(opt); }",
1210
+ " }",
1211
+ " for (var j = 0; j < select._allOptions.length; j++) {",
1212
+ " var opt2 = select._allOptions[j];",
1213
+ " if (enabledTags.length === 0 || opt2.value === 'direct' || enabledTags.indexOf(opt2.value) !== -1) {",
1214
+ " select.appendChild(opt2);",
1215
+ " }",
1216
+ " }",
1217
+ " select.value = '';",
1218
+ " };",
1219
+ // Bulk assign all channels to a specific provider. Updates all dropdowns and profile cells in-place.
1220
+ " window.bulkAssignProvider = function(selectEl) {",
1221
+ " var providerTag = selectEl.value;",
1222
+ " if (!providerTag) return;",
1223
+ " selectEl.value = '';",
1224
+ " fetch('/config/provider-bulk-assign', {",
1225
+ " method: 'POST',",
1226
+ " headers: { 'Content-Type': 'application/json' },",
1227
+ " body: JSON.stringify({ provider: providerTag })",
1228
+ " })",
1229
+ " .then(function(r) { return r.json(); })",
1230
+ " .then(function(result) {",
1231
+ " if (result.success) {",
1232
+ " var msg = result.affected + ' of ' + result.total + ' channel(s) updated.';",
1233
+ " var undoAction = null;",
1234
+ " if (result.affected > 0 && result.previousSelections) {",
1235
+ " var prevSelections = result.previousSelections;",
1236
+ " undoAction = { label: 'Undo', onclick: function() { restoreBulkProviders(prevSelections); } };",
1237
+ " }",
1238
+ " showToast(msg, 'success', undoAction ? 10000 : undefined, undoAction);",
1239
+ " if (result.selections) {",
1240
+ " for (var key in result.selections) {",
1241
+ " var sel = result.selections[key];",
1242
+ " updateChannelProviderUI(key, sel.variant);",
1243
+ " }",
1244
+ " }",
1245
+ " } else {",
1246
+ " showToast(result.error || 'Failed to assign.', 'error');",
1247
+ " }",
1248
+ " })",
1249
+ " .catch(function(err) { showToast('Failed to assign: ' + err.message, 'error'); });",
1250
+ " };",
1251
+ // Restore previous provider selections (undo bulk assign). Sends the previousSelections map to the server and updates the UI with the restored selections.
1252
+ " function restoreBulkProviders(prevSelections) {",
1253
+ " fetch('/config/provider-bulk-restore', {",
1254
+ " method: 'POST',",
1255
+ " headers: { 'Content-Type': 'application/json' },",
1256
+ " body: JSON.stringify({ selections: prevSelections })",
1257
+ " })",
1258
+ " .then(function(r) { return r.json(); })",
1259
+ " .then(function(result) {",
1260
+ " if (result.success) {",
1261
+ " showToast('Bulk assign reverted.', 'success');",
1262
+ " if (result.selections) {",
1263
+ " for (var key in result.selections) {",
1264
+ " var sel = result.selections[key];",
1265
+ " updateChannelProviderUI(key, sel.variant);",
1266
+ " }",
1267
+ " }",
1268
+ " } else {",
1269
+ " showToast(result.error || 'Failed to revert.', 'error');",
1270
+ " }",
1271
+ " })",
1272
+ " .catch(function(err) { showToast('Failed to revert: ' + err.message, 'error'); });",
1273
+ " }",
1274
+ // Close all open dropdown menus and remove the scroll and resize listeners.
1275
+ " function closeDropdowns() {",
1276
+ " var menus = document.querySelectorAll('.dropdown-menu.show');",
1277
+ " for (var i = 0; i < menus.length; i++) menus[i].classList.remove('show');",
1278
+ " window.removeEventListener('scroll', closeDropdowns, true);",
1279
+ " window.removeEventListener('resize', closeDropdowns);",
1280
+ " };",
1281
+ " window.closeDropdowns = closeDropdowns;",
1282
+ // Toggle a dropdown menu open or closed. On first call for a given button, the menu is detached from its .dropdown parent and portaled to <body> with
1283
+ // position: fixed. This allows the menu to escape overflow: auto containers like the channel table wrapper. On every open, the menu is positioned relative
1284
+ // to the button's bounding rect, with edge-of-viewport clamping and above-button flip when it would extend below the viewport.
1285
+ " window.toggleDropdown = function(btn) {",
1286
+ " var menu = btn._portalMenu;",
1287
+ " var isOpen = menu && menu.classList.contains('show');",
1288
+ " closeDropdowns();",
1289
+ " if (isOpen) return;",
1290
+ " if (!menu) {",
1291
+ " menu = btn.nextElementSibling;",
1292
+ " if (!menu) return;",
1293
+ " btn._portalMenu = menu;",
1294
+ " document.body.appendChild(menu);",
1295
+ " menu.style.position = 'fixed';",
1296
+ " menu.style.marginTop = '0';",
1297
+ " }",
1298
+ " menu.classList.add('show');",
1299
+ " var rect = btn.getBoundingClientRect();",
1300
+ " var top = rect.bottom + 2;",
1301
+ " var left = rect.left;",
1302
+ " if (left + menu.offsetWidth > window.innerWidth - 4) left = rect.right - menu.offsetWidth;",
1303
+ " if (left < 4) left = 4;",
1304
+ " if (top + menu.offsetHeight > window.innerHeight - 4) top = rect.top - menu.offsetHeight - 2;",
1305
+ " menu.style.top = top + 'px';",
1306
+ " menu.style.left = left + 'px';",
1307
+ " window.addEventListener('scroll', closeDropdowns, true);",
1308
+ " window.addEventListener('resize', closeDropdowns);",
1309
+ " };",
1310
+ // Close dropdowns when clicking outside. After portaling, menus live on <body> outside .dropdown containers, so we also check .dropdown-menu to prevent
1311
+ // clicks on menu items (like column picker checkboxes) from triggering close.
1312
+ " document.addEventListener('click', function(e) {",
1313
+ " if (!e.target.closest('.dropdown') && !e.target.closest('.dropdown-menu')) closeDropdowns();",
1314
+ " });",
1315
+ // Copy a stream URL to the clipboard and show a toast notification. The type parameter selects HLS or MPEG-TS format. Uses the modern Clipboard API when
1316
+ // available (secure contexts), falling back to execCommand for plain HTTP access via IP address.
1317
+ " window.copyStreamUrl = function(type, key) {",
1318
+ " closeDropdowns();",
1319
+ " var url = (type === 'hls') ? (location.origin + '/hls/' + key + '/stream.m3u8') : (location.origin + '/stream/' + key);",
1320
+ " if (navigator.clipboard && navigator.clipboard.writeText) {",
1321
+ " navigator.clipboard.writeText(url).then(function() { showToast('Stream URL copied to clipboard.', 'success'); })",
1322
+ " .catch(function() { showToast('Failed to copy URL.', 'error'); });",
1323
+ " } else {",
1324
+ " var ta = document.createElement('textarea');",
1325
+ " ta.value = url;",
1326
+ " ta.style.position = 'fixed';",
1327
+ " ta.style.opacity = '0';",
1328
+ " document.body.appendChild(ta);",
1329
+ " ta.select();",
1330
+ " try { document.execCommand('copy'); showToast('Stream URL copied to clipboard.', 'success'); }",
1331
+ " catch(e) { showToast('Failed to copy URL.', 'error'); }",
1332
+ " document.body.removeChild(ta);",
1333
+ " }",
1334
+ " };",
1335
+ // Toggle visibility of disabled predefined channels and persist preference.
1336
+ " window.toggleDisabledVisibility = function() {",
1337
+ " var table = document.querySelector('.channel-table');",
1338
+ " var checkbox = document.getElementById('show-disabled-toggle');",
1339
+ " if (!table || !checkbox) return;",
1340
+ " if (checkbox.checked) {",
1341
+ " table.classList.remove('hide-disabled');",
1342
+ " localStorage.setItem('prismcast-show-disabled-channels', 'true');",
1343
+ " } else {",
1344
+ " table.classList.add('hide-disabled');",
1345
+ " localStorage.removeItem('prismcast-show-disabled-channels');",
1346
+ " }",
1347
+ " };",
1348
+ // Toggle visibility of an optional column and persist via background POST.
1349
+ " window.toggleColumn = function(checkbox) {",
1350
+ " var table = document.querySelector('.channel-table');",
1351
+ " if (!table || !checkbox) return;",
1352
+ " var colClass = checkbox.getAttribute('data-col-class');",
1353
+ " var hideClass = 'hide-' + colClass;",
1354
+ " if (checkbox.checked) { table.classList.remove(hideClass); }",
1355
+ " else { table.classList.add(hideClass); }",
1356
+ // Collect all currently checked columns from the picker menu.
1357
+ " var menu = document.querySelector('.column-picker-menu');",
1358
+ " var cols = [];",
1359
+ " if (menu) {",
1360
+ " var cbs = menu.querySelectorAll('input[type=\"checkbox\"]');",
1361
+ " for (var i = 0; i < cbs.length; i++) {",
1362
+ " if (cbs[i].checked) cols.push(cbs[i].getAttribute('data-col-field'));",
1363
+ " }",
1364
+ " }",
1365
+ " fetch('/config/channels/display-prefs', {",
1366
+ " method: 'POST', headers: { 'Content-Type': 'application/json' },",
1367
+ " body: JSON.stringify({ visibleColumns: cols })",
1368
+ " }).catch(function() {});",
1369
+ " };",
1370
+ // Read the server-stamped sort value from the data-sort-value attribute.
1371
+ " function getSortValue(row, field) {",
1372
+ " var colIndex = { key: 0, name: 1, provider: 2, channelNumber: 3, stationId: 4, profile: 5, channelSelector: 6 };",
1373
+ " var idx = colIndex[field];",
1374
+ " if (idx === undefined) return '';",
1375
+ " var cell = row.children[idx];",
1376
+ " if (!cell) return '';",
1377
+ " return cell.getAttribute('data-sort-value') || '';",
1378
+ " }",
1379
+ // Sort the channel table by the specified field. Toggles direction if the same field is clicked again.
1380
+ " window.sortChannelTable = function(field) {",
1381
+ " var table = document.querySelector('.channel-table');",
1382
+ " if (!table) return;",
1383
+ " var currentField = table.getAttribute('data-sort-field');",
1384
+ " var currentDir = table.getAttribute('data-sort-dir') || 'asc';",
1385
+ " var dir = (field === currentField) ? (currentDir === 'asc' ? 'desc' : 'asc') : 'asc';",
1386
+ " table.setAttribute('data-sort-field', field);",
1387
+ " table.setAttribute('data-sort-dir', dir);",
1388
+ // Collect display rows and their paired edit rows.
1389
+ " var tbody = table.querySelector('tbody');",
1390
+ " var displayRows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
1391
+ " var pairs = [];",
1392
+ " for (var i = 0; i < displayRows.length; i++) {",
1393
+ " var dr = displayRows[i];",
1394
+ " var key = dr.id.replace('display-row-', '');",
1395
+ " var er = document.getElementById('edit-row-' + key);",
1396
+ " var name = dr.children[1] ? dr.children[1].textContent.trim().toLowerCase() : '';",
1397
+ " pairs.push({ displayRow: dr, editRow: er, val: getSortValue(dr, field), name: name });",
1398
+ " }",
1399
+ // Sort pairs. The channel name is used as a secondary sort key to stabilize rows with identical primary values. The secondary sort is always ascending
1400
+ // regardless of the primary direction so that rows within each group maintain a consistent alphabetical order.
1401
+ " pairs.sort(function(a, b) {",
1402
+ " var cmp = (dir === 'asc') ? a.val.localeCompare(b.val) : b.val.localeCompare(a.val);",
1403
+ " if (cmp !== 0) return cmp;",
1404
+ " return a.name.localeCompare(b.name);",
1405
+ " });",
1406
+ // Re-append in sorted order.
1407
+ " for (var j = 0; j < pairs.length; j++) {",
1408
+ " tbody.appendChild(pairs[j].displayRow);",
1409
+ " if (pairs[j].editRow) tbody.appendChild(pairs[j].editRow);",
1410
+ " }",
1411
+ // Update header indicators.
1412
+ " var headers = table.querySelectorAll('th.sortable');",
1413
+ " for (var h = 0; h < headers.length; h++) {",
1414
+ " var th = headers[h];",
1415
+ " var hField = th.getAttribute('data-sort-field');",
1416
+ " var label = th.textContent.replace(/[\\u25B2\\u25BC]/g, '').trim();",
1417
+ " if (hField === field) { th.innerHTML = label + (dir === 'asc' ? ' &#9650;' : ' &#9660;'); }",
1418
+ " else { th.textContent = label; }",
1419
+ " }",
1420
+ // Persist sort preference.
1421
+ " fetch('/config/channels/display-prefs', {",
1422
+ " method: 'POST', headers: { 'Content-Type': 'application/json' },",
1423
+ " body: JSON.stringify({ sortField: field, sortDirection: dir })",
1424
+ " }).catch(function() {});",
1425
+ " };",
1426
+ // Populate channel selector datalist based on the URL field value. First populates from server-rendered data (predefined channels and cached provider
1427
+ // discovery), then asynchronously fetches from the provider discovery endpoint to pick up any channels not yet in the static data.
1428
+ " function updateSelectorSuggestions(urlInputId, datalistId) {",
1429
+ " var urlInput = document.getElementById(urlInputId);",
1430
+ " var datalist = document.getElementById(datalistId);",
1431
+ " if (!urlInput || !datalist) return;",
1432
+ " datalist.innerHTML = '';",
1433
+ // Find or create the URL hint element. Placed after the URL input's parent form-row so it sits below the field.
1434
+ " var hintId = urlInputId + '-url-hint';",
1435
+ " var hintEl = document.getElementById(hintId);",
1436
+ " if (!hintEl) {",
1437
+ " hintEl = document.createElement('div');",
1438
+ " hintEl.id = hintId;",
1439
+ " hintEl.className = 'hint';",
1440
+ " hintEl.style.marginBottom = '0';",
1441
+ " var formRow = urlInput.closest('.form-row');",
1442
+ " if (formRow) { formRow.parentNode.insertBefore(hintEl, formRow.nextSibling); }",
1443
+ " }",
1444
+ " hintEl.innerHTML = '';",
1445
+ " hintEl.style.display = 'none';",
1446
+ " var hostname;",
1447
+ " var raw = urlInput.value;",
1448
+ " if (raw && !raw.startsWith('http://') && !raw.startsWith('https://')) { raw = 'https://' + raw; }",
1449
+ " try { hostname = new URL(raw).hostname; } catch (e) { return; }",
1450
+ " var alt = hostname.startsWith('www.') ? hostname.substring(4) : 'www.' + hostname;",
1451
+ // Resolve the provider hostname. Try exact match first, then www-variant, then base domain match (e.g., sling.com matches watch.sling.com).
1452
+ " var exactMatch = (typeof channelSelectorsByDomain !== 'undefined') && channelSelectorsByDomain[hostname];",
1453
+ " var resolvedHostname = null;",
1454
+ " if ((typeof providerByDomain !== 'undefined') && providerByDomain[hostname]) { resolvedHostname = hostname; }",
1455
+ " else if ((typeof providerByDomain !== 'undefined') && providerByDomain[alt]) { resolvedHostname = alt; }",
1456
+ " else if (typeof providerByDomain !== 'undefined') {",
1457
+ " var suffix = '.' + hostname;",
1458
+ " var domainKeys = Object.keys(providerByDomain);",
1459
+ " for (var d = 0; d < domainKeys.length; d++) {",
1460
+ " if (domainKeys[d].endsWith(suffix)) { resolvedHostname = domainKeys[d]; break; }",
1461
+ " }",
1462
+ " }",
1463
+ " var slug = resolvedHostname ? providerByDomain[resolvedHostname] : null;",
1464
+ " var entries = exactMatch || ((typeof channelSelectorsByDomain !== 'undefined')",
1465
+ " ? (channelSelectorsByDomain[alt] || (resolvedHostname ? channelSelectorsByDomain[resolvedHostname] : null)) : null);",
1466
+ " if (entries) {",
1467
+ " for (var i = 0; i < entries.length; i++) {",
1468
+ " var opt = document.createElement('option');",
1469
+ " opt.value = entries[i].value;",
1470
+ " opt.label = entries[i].label;",
1471
+ " datalist.appendChild(opt);",
1472
+ " }",
1473
+ " }",
1474
+ // Show a URL hint when the hostname didn't match exactly and we found a provider via fallback.
1475
+ " if (!exactMatch && slug && (typeof providerGuideUrl !== 'undefined') && providerGuideUrl[slug]) {",
1476
+ " var suggestedUrl = providerGuideUrl[slug];",
1477
+ " var link = document.createElement('a');",
1478
+ " link.href = '#';",
1479
+ " link.textContent = suggestedUrl;",
1480
+ " link.addEventListener('click', function(e) {",
1481
+ " e.preventDefault();",
1482
+ " urlInput.value = suggestedUrl;",
1483
+ " hintEl.innerHTML = '';",
1484
+ " hintEl.style.display = 'none';",
1485
+ " updateSelectorSuggestions(urlInputId, datalistId);",
1486
+ " });",
1487
+ " hintEl.appendChild(document.createTextNode('Did you mean '));",
1488
+ " hintEl.appendChild(link);",
1489
+ " hintEl.appendChild(document.createTextNode('?'));",
1490
+ " hintEl.style.display = '';",
1491
+ " }",
1492
+ " if (!slug) return;",
1493
+ " var snapshotUrl = urlInput.value;",
1494
+ " fetch('/providers/' + slug + '/channels')",
1495
+ " .then(function(r) { return r.ok ? r.json() : null; })",
1496
+ " .then(function(channels) {",
1497
+ " if (!channels || urlInput.value !== snapshotUrl) return;",
1498
+ " var existing = {};",
1499
+ " var opts = datalist.querySelectorAll('option');",
1500
+ " for (var j = 0; j < opts.length; j++) { existing[opts[j].value] = true; }",
1501
+ " for (var k = 0; k < channels.length; k++) {",
1502
+ " if (!existing[channels[k].channelSelector]) {",
1503
+ " var o = document.createElement('option');",
1504
+ " o.value = channels[k].channelSelector;",
1505
+ " o.label = channels[k].name;",
1506
+ " datalist.appendChild(o);",
1507
+ " }",
1508
+ " }",
1509
+ " })",
1510
+ " .catch(function() {});",
1511
+ " };",
1512
+ // Initialize disabled channel toggle, provider filter, URL input listeners, and pending toast on page load.
1513
+ " (function() {",
1514
+ // Show any toast queued by showToastAfterReload() before the last page reload.
1515
+ " var pending = sessionStorage.getItem('pendingToast');",
1516
+ " if (pending) {",
1517
+ " sessionStorage.removeItem('pendingToast');",
1518
+ " try { var pt = JSON.parse(pending); showToast(pt.message, pt.type); } catch (e) {}",
1519
+ " }",
1520
+ " if (localStorage.getItem('prismcast-show-disabled-channels') === 'true') {",
1521
+ " var table = document.querySelector('.channel-table');",
1522
+ " var checkbox = document.getElementById('show-disabled-toggle');",
1523
+ " if (table) table.classList.remove('hide-disabled');",
1524
+ " if (checkbox) checkbox.checked = true;",
1525
+ " }",
1526
+ // Run filterChannelRows on page load when a provider filter is active. The server renders filtered options with the hidden attribute, but Safari ignores it on
1527
+ // option elements. This initial pass removes those options from the DOM to enforce the filter.
1528
+ " var menu = document.querySelector('.provider-dropdown-menu');",
1529
+ " if (menu) {",
1530
+ " var cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
1531
+ " var tags = [];",
1532
+ " var allChecked = true;",
1533
+ " for (var ci = 0; ci < cbs.length; ci++) {",
1534
+ " if (cbs[ci].checked) { tags.push(cbs[ci].getAttribute('data-tag')); }",
1535
+ " else { allChecked = false; }",
1536
+ " }",
1537
+ " if (!allChecked) { filterChannelRows(tags); updateBulkAssignOptions(tags); }",
1538
+ " }",
1539
+ " var addUrlInput = document.getElementById('add-url');",
1540
+ " if (addUrlInput) {",
1541
+ " addUrlInput.addEventListener('input', function() { updateSelectorSuggestions('add-url', 'add-selectorList'); });",
1542
+ " updateSelectorSuggestions('add-url', 'add-selectorList');",
1543
+ " }",
1544
+ " })();",
1545
+ // Login modal state tracking.
1546
+ " var loginStatusInterval = null;",
1547
+ // Start login mode for a channel. Opens browser window and shows modal.
1548
+ " window.startChannelLogin = function(channel) {",
1549
+ " fetch('/auth/login', {",
1550
+ " method: 'POST',",
1551
+ " headers: { 'Content-Type': 'application/json' },",
1552
+ " body: JSON.stringify({ channel: channel })",
1553
+ " })",
1554
+ " .then(function(response) { return response.json(); })",
1555
+ " .then(function(result) {",
1556
+ " if (result.success) {",
1557
+ " showToast('Browser window opened. Complete authentication.', 'info');",
1558
+ " showLoginModal();",
1559
+ " startLoginStatusPolling();",
1560
+ " } else {",
1561
+ " showToast(result.error || 'Failed to start login.', 'error');",
1562
+ " }",
1563
+ " })",
1564
+ " .catch(function(err) { showToast('Failed to start login: ' + err.message, 'error'); });",
1565
+ " };",
1566
+ // End login mode. Closes browser tab and hides modal.
1567
+ " window.endLogin = function() {",
1568
+ " stopLoginStatusPolling();",
1569
+ " fetch('/auth/done', { method: 'POST' })",
1570
+ " .then(function() {",
1571
+ " hideLoginModal();",
1572
+ " showToast('Authentication complete.', 'success');",
1573
+ " })",
1574
+ " .catch(function(err) { showToast('Error ending login: ' + err.message, 'error'); hideLoginModal(); });",
1575
+ " };",
1576
+ // Show the login modal.
1577
+ " function showLoginModal() {",
1578
+ " var modal = document.getElementById('login-modal');",
1579
+ " if (modal) modal.style.display = 'flex';",
1580
+ " }",
1581
+ // Hide the login modal.
1582
+ " function hideLoginModal() {",
1583
+ " var modal = document.getElementById('login-modal');",
1584
+ " if (modal) modal.style.display = 'none';",
1585
+ " }",
1586
+ // Start polling login status to detect when tab is closed externally.
1587
+ " function startLoginStatusPolling() {",
1588
+ " stopLoginStatusPolling();",
1589
+ " loginStatusInterval = setInterval(function() {",
1590
+ " fetch('/auth/status')",
1591
+ " .then(function(response) { return response.json(); })",
1592
+ " .then(function(status) {",
1593
+ " if (!status.active) {",
1594
+ " stopLoginStatusPolling();",
1595
+ " hideLoginModal();",
1596
+ " showToast('Login session ended.', 'info');",
1597
+ " }",
1598
+ " })",
1599
+ " .catch(function() { });",
1600
+ " }, 1000);",
1601
+ " }",
1602
+ // Stop polling login status.
1603
+ " function stopLoginStatusPolling() {",
1604
+ " if (loginStatusInterval) {",
1605
+ " clearInterval(loginStatusInterval);",
1606
+ " loginStatusInterval = null;",
1607
+ " }",
1608
+ " }",
1609
+ // Attach validation and modified indicator handlers to form inputs.
1610
+ " var form = document.getElementById('settings-form');",
1611
+ " if (form) {",
1612
+ " var inputs = form.querySelectorAll('input, select');",
1613
+ " for (var k = 0; k < inputs.length; k++) {",
1614
+ " var eventType = inputs[k].type === 'checkbox' ? 'change' : 'input';",
1615
+ " inputs[k].addEventListener(eventType, function() {",
1616
+ " validateInput(this);",
1617
+ " updateModifiedIndicator(this);",
1618
+ " });",
1619
+ " }",
1620
+ // Wire up all checkboxes that have dependent fields. The updateDependentFields function is defined at the top level alongside the other helpers.
1621
+ " var checkboxes = form.querySelectorAll('input[type=\"checkbox\"]');",
1622
+ " for (var c = 0; c < checkboxes.length; c++) {",
1623
+ " (function(cb) {",
1624
+ " cb.addEventListener('change', function() { updateDependentFields(cb.id); });",
1625
+ " })(checkboxes[c]);",
1626
+ " }",
1627
+ // Attach preset change handler to auto-fill bitrate and frame rate.
1628
+ " var presetSelect = document.getElementById('streaming-qualityPreset');",
1629
+ " if (presetSelect) {",
1630
+ " presetSelect.addEventListener('change', function() { onPresetChange(this.value); });",
1631
+ " }",
1632
+ " }",
1633
+ // Checkbox list: collect checked values into the hidden input and update the modified indicator. Called by onchange on individual checkboxes within a
1634
+ // checkbox-list-grid. The hidden input holds the JSON-encoded array and participates in the standard form submission.
1635
+ " window.updateCheckboxList = function(checkbox) {",
1636
+ " var group = checkbox.closest('.form-group');",
1637
+ " if (!group) return;",
1638
+ " var hidden = group.querySelector('[data-checkbox-list]');",
1639
+ " if (!hidden) return;",
1640
+ " var grid = group.querySelector('.checkbox-list-grid');",
1641
+ " if (!grid) return;",
1642
+ " var cbs = grid.querySelectorAll('input[type=\"checkbox\"]');",
1643
+ " var values = [];",
1644
+ " for (var i = 0; i < cbs.length; i++) {",
1645
+ " if (cbs[i].checked) values.push(cbs[i].value);",
1646
+ " }",
1647
+ " hidden.value = JSON.stringify(values);",
1648
+ " updateModifiedIndicator(hidden);",
1649
+ " };",
1650
+ // Initialize subtab on load: hash > localStorage > default.
1651
+ " var initialSubtab = window.initialHashSubtab;",
1652
+ " if (!initialSubtab) {",
1653
+ " try { initialSubtab = localStorage.getItem('prismcast-config-subtab'); } catch(e) {}",
1654
+ " }",
1655
+ " if (initialSubtab && document.querySelector('.subtab-btn[data-subtab=\"' + initialSubtab + '\"]')) {",
1656
+ " switchSubtab(initialSubtab, false);",
1657
+ " }",
1658
+ "})();",
1659
+ "</script>"
1660
+ ].join("\n");
1661
+ }
1662
+ //# sourceMappingURL=config.js.map