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