prismcast 1.6.0 → 1.8.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.
- package/dist/app.js +9 -8
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.js +1 -1
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +10 -34
- package/dist/browser/channelSelection.js +19 -96
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +19 -0
- package/dist/browser/display.js +16 -11
- package/dist/browser/display.js.map +1 -1
- package/dist/browser/index.d.ts +9 -46
- package/dist/browser/index.js +136 -191
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +65 -0
- package/dist/browser/login.js +196 -0
- package/dist/browser/login.js.map +1 -0
- package/dist/browser/precaching.js +12 -0
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/comcastPolymer.d.ts +23 -0
- package/dist/browser/tuning/comcastPolymer.js +655 -0
- package/dist/browser/tuning/comcastPolymer.js.map +1 -0
- package/dist/browser/tuning/cox.d.ts +2 -0
- package/dist/browser/tuning/cox.js +14 -0
- package/dist/browser/tuning/cox.js.map +1 -0
- package/dist/browser/tuning/directv.js +1 -1
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +2 -2
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +1 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +1 -1
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +46 -0
- package/dist/browser/tuning/shared.js +100 -0
- package/dist/browser/tuning/shared.js.map +1 -0
- package/dist/browser/tuning/sling.js +1 -1
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.js +1 -1
- package/dist/browser/tuning/spectrum.js.map +1 -1
- package/dist/browser/tuning/thumbnailRow.js +1 -1
- package/dist/browser/tuning/thumbnailRow.js.map +1 -1
- package/dist/browser/tuning/tileClick.js +1 -1
- package/dist/browser/tuning/tileClick.js.map +1 -1
- package/dist/browser/tuning/xfinity.js +10 -603
- package/dist/browser/tuning/xfinity.js.map +1 -1
- package/dist/browser/tuning/youtubeTv.js +1 -1
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +18 -0
- package/dist/browser/video.js +69 -107
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +4 -2
- package/dist/channels/index.js +2261 -800
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.js +30 -17
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +37 -63
- package/dist/config/index.js.map +1 -1
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/providerPacks.js +4 -1
- package/dist/config/providerPacks.js.map +1 -1
- package/dist/config/providers.d.ts +12 -5
- package/dist/config/providers.js +133 -104
- package/dist/config/providers.js.map +1 -1
- package/dist/config/sites.js +15 -9
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +108 -7
- package/dist/config/userChannels.js +374 -44
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +3 -1
- package/dist/config/userConfig.js +31 -19
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.js +27 -39
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -4
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/index.js +9 -15
- package/dist/index.js.map +1 -1
- package/dist/native/index.d.ts +3 -0
- package/dist/native/index.js +16 -25
- package/dist/native/index.js.map +1 -1
- package/dist/native/probe.d.ts +4 -3
- package/dist/native/probe.js +36 -12
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -0
- package/dist/native/proxy.js +369 -326
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +26 -48
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/channels.js +5 -11
- package/dist/routes/channels.js.map +1 -1
- package/dist/routes/components.d.ts +43 -8
- package/dist/routes/components.js +122 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +2 -2
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +749 -71
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +93 -2
- package/dist/routes/config/channels/table.js +594 -104
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +1 -1
- package/dist/routes/config/index.js +1 -1
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/providers.d.ts +6 -6
- package/dist/routes/config/providers.js +76 -102
- package/dist/routes/config/providers.js.map +1 -1
- package/dist/routes/config/settings.js +57 -36
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/icons.d.ts +16 -0
- package/dist/routes/icons.js +44 -0
- package/dist/routes/icons.js.map +1 -0
- package/dist/routes/index.d.ts +0 -16
- package/dist/routes/index.js +0 -17
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +68 -27
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/providers.js +106 -3
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/root/content.js +96 -40
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +2 -1
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +955 -310
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +695 -569
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/index.d.ts +1 -0
- package/dist/routes/root/scripts/index.js +1 -0
- package/dist/routes/root/scripts/index.js.map +1 -1
- package/dist/routes/root/scripts/shared.d.ts +6 -0
- package/dist/routes/root/scripts/shared.js +437 -0
- package/dist/routes/root/scripts/shared.js.map +1 -0
- package/dist/routes/root/scripts/status.js +33 -24
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +157 -11
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +1 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +4 -35
- package/dist/service/commands.js.map +1 -1
- package/dist/streaming/codec.d.ts +20 -0
- package/dist/streaming/codec.js +68 -0
- package/dist/streaming/codec.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +2 -0
- package/dist/streaming/fmp4Segmenter.js +7 -4
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.js +274 -188
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +9 -3
- package/dist/streaming/hlsResume.js +15 -8
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +1 -2
- package/dist/streaming/hlsSegments.js +48 -46
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/lifecycle.js +8 -12
- package/dist/streaming/lifecycle.js.map +1 -1
- package/dist/streaming/monitor.js +625 -585
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mpegts.js +1 -1
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/preroll.d.ts +41 -20
- package/dist/streaming/preroll.js +157 -76
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/recovery.d.ts +0 -6
- package/dist/streaming/recovery.js +0 -8
- package/dist/streaming/recovery.js.map +1 -1
- package/dist/streaming/registry.d.ts +36 -9
- package/dist/streaming/registry.js +6 -9
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +3 -3
- package/dist/streaming/setup.js +38 -22
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +7 -6
- package/dist/streaming/showInfo.js +156 -36
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +14 -2
- package/dist/streaming/statusEmitter.js +22 -4
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +34 -0
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +3 -2
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +8 -3
- package/dist/types/selection.d.ts +1 -0
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +7 -15
- package/dist/types/streaming.js +5 -1
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -16
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/utils/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +0 -5
- package/dist/utils/debugFilter.js +5 -11
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +12 -0
- package/dist/utils/delay.js +20 -0
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/evaluate.js +6 -11
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.d.ts +13 -5
- package/dist/utils/ffmpeg.js +32 -11
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/format.d.ts +20 -3
- package/dist/utils/format.js +32 -6
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logEmitter.js +1 -1
- package/dist/utils/logEmitter.js.map +1 -1
- package/dist/utils/retry.d.ts +15 -9
- package/dist/utils/retry.js +8 -26
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/timing.d.ts +5 -0
- package/dist/utils/timing.js +13 -0
- package/dist/utils/timing.js.map +1 -0
- package/dist/utils/version.js +2 -2
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -5
|
@@ -43,42 +43,6 @@ export function generateConfigSubtabScript() {
|
|
|
43
43
|
" }",
|
|
44
44
|
" }",
|
|
45
45
|
" }",
|
|
46
|
-
// Show a toast notification. Auto-dismiss durations: success/info = 5s, warning = 8s, error = no auto-dismiss. Optional action: { label, onclick } appends an
|
|
47
|
-
// inline button between the message text and the close button.
|
|
48
|
-
" function showToast(message, type, duration, action) {",
|
|
49
|
-
" var container = document.getElementById('toast-container');",
|
|
50
|
-
" if (!container) return;",
|
|
51
|
-
" var toast = document.createElement('div');",
|
|
52
|
-
" toast.className = 'toast ' + (type || 'info');",
|
|
53
|
-
" toast.textContent = message;",
|
|
54
|
-
" toast.setAttribute('role', (type === 'error' || type === 'warning') ? 'alert' : 'status');",
|
|
55
|
-
" if (action && action.label) {",
|
|
56
|
-
" var actionBtn = document.createElement('button');",
|
|
57
|
-
" actionBtn.type = 'button';",
|
|
58
|
-
" actionBtn.className = 'toast-action';",
|
|
59
|
-
" actionBtn.textContent = action.label;",
|
|
60
|
-
" actionBtn.onclick = function() { if (action.onclick) action.onclick(); dismissToast(toast); };",
|
|
61
|
-
" toast.appendChild(actionBtn);",
|
|
62
|
-
" }",
|
|
63
|
-
" var closeBtn = document.createElement('button');",
|
|
64
|
-
" closeBtn.type = 'button';",
|
|
65
|
-
" closeBtn.className = 'toast-close';",
|
|
66
|
-
" closeBtn.textContent = '\\u00d7';",
|
|
67
|
-
" closeBtn.setAttribute('aria-label', 'Dismiss');",
|
|
68
|
-
" closeBtn.onclick = function() { dismissToast(toast); };",
|
|
69
|
-
" toast.appendChild(closeBtn);",
|
|
70
|
-
" container.appendChild(toast);",
|
|
71
|
-
" var ms = duration !== undefined ? duration : type === 'error' ? 0 : type === 'warning' ? 8000 : 5000;",
|
|
72
|
-
" if (ms > 0) { setTimeout(function() { dismissToast(toast); }, ms); }",
|
|
73
|
-
" }",
|
|
74
|
-
// Expose showToast globally so other script blocks (e.g., channels-subtab IIFE) can use the toast system.
|
|
75
|
-
" window.showToast = showToast;",
|
|
76
|
-
// Dismiss a toast with slide-out animation.
|
|
77
|
-
" function dismissToast(toast) {",
|
|
78
|
-
" if (toast.classList.contains('toast-exit')) return;",
|
|
79
|
-
" toast.classList.add('toast-exit');",
|
|
80
|
-
" toast.addEventListener('animationend', function() { if (toast.parentNode) toast.parentNode.removeChild(toast); });",
|
|
81
|
-
" }",
|
|
82
46
|
// Hint appended to success toasts when a channel operation changes M3U playlist content that Channels DVR consumes.
|
|
83
47
|
" var PLAYLIST_HINT = ' Reload the playlist in Channels DVR to see this change.';",
|
|
84
48
|
// Queue a toast to appear after the next page reload.
|
|
@@ -115,34 +79,31 @@ export function generateConfigSubtabScript() {
|
|
|
115
79
|
" showToast('Restart cancelled. Changes will apply on next restart.', 'info');",
|
|
116
80
|
" };",
|
|
117
81
|
// Force immediate restart despite active streams.
|
|
118
|
-
" window.forceRestart = function() {",
|
|
82
|
+
" window.forceRestart = async function() {",
|
|
119
83
|
" pendingRestart = false;",
|
|
120
84
|
" document.getElementById('restart-dialog').style.display = 'none';",
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
" showToast('Failed to restart: ' + err.message, 'error');",
|
|
133
|
-
" });",
|
|
85
|
+
" try {",
|
|
86
|
+
" var res = await fetch('/config/restart-now', { method: 'POST' });",
|
|
87
|
+
" if (res.ok) {",
|
|
88
|
+
" waitForServerRestart();",
|
|
89
|
+
" } else {",
|
|
90
|
+
" var data = await res.json();",
|
|
91
|
+
" throw new Error(data.message || 'Restart failed');",
|
|
92
|
+
" }",
|
|
93
|
+
" } catch(err) {",
|
|
94
|
+
" showToast('Failed to restart: ' + err.message, 'error');",
|
|
95
|
+
" }",
|
|
134
96
|
" };",
|
|
135
97
|
// Trigger restart (called when streams reach 0).
|
|
136
|
-
" function triggerRestart() {",
|
|
137
|
-
"
|
|
138
|
-
"
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
" .
|
|
144
|
-
"
|
|
145
|
-
" });",
|
|
98
|
+
" async function triggerRestart() {",
|
|
99
|
+
" try {",
|
|
100
|
+
" var res = await fetch('/config/restart-now', { method: 'POST' });",
|
|
101
|
+
" if (res.ok) {",
|
|
102
|
+
" waitForServerRestart();",
|
|
103
|
+
" }",
|
|
104
|
+
" } catch(err) {",
|
|
105
|
+
" showToast('Failed to trigger restart. Please restart manually.', 'error');",
|
|
106
|
+
" }",
|
|
146
107
|
" }",
|
|
147
108
|
// Wait for server restart by polling /health, then reload.
|
|
148
109
|
" function waitForServerRestart() {",
|
|
@@ -150,23 +111,22 @@ export function generateConfigSubtabScript() {
|
|
|
150
111
|
" var maxAttempts = 30;",
|
|
151
112
|
" showToast('Restarting server...', 'info', 0);",
|
|
152
113
|
" if (restartPollInterval) { clearInterval(restartPollInterval); }",
|
|
153
|
-
" restartPollInterval = setInterval(function() {",
|
|
114
|
+
" restartPollInterval = setInterval(async function() {",
|
|
154
115
|
" attempts++;",
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
"
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
"
|
|
169
|
-
" });",
|
|
116
|
+
" try {",
|
|
117
|
+
" var res = await fetch('/health');",
|
|
118
|
+
" if (res.ok) {",
|
|
119
|
+
" clearInterval(restartPollInterval);",
|
|
120
|
+
" restartPollInterval = null;",
|
|
121
|
+
" showToastAfterReload('Server restarted.', 'success');",
|
|
122
|
+
" }",
|
|
123
|
+
" } catch(err) {",
|
|
124
|
+
" if (attempts >= maxAttempts) {",
|
|
125
|
+
" clearInterval(restartPollInterval);",
|
|
126
|
+
" restartPollInterval = null;",
|
|
127
|
+
" showToast('Server did not restart within 30 seconds. Please check the server manually.', 'error');",
|
|
128
|
+
" }",
|
|
129
|
+
" }",
|
|
170
130
|
" }, 1000);",
|
|
171
131
|
" }",
|
|
172
132
|
// Escape HTML entities in text for safe display.
|
|
@@ -176,7 +136,7 @@ export function generateConfigSubtabScript() {
|
|
|
176
136
|
" return div.innerHTML;",
|
|
177
137
|
" }",
|
|
178
138
|
// Open the changelog modal and fetch content dynamically. Also checks whether an upgrade button should be shown.
|
|
179
|
-
" window.openChangelogModal = function() {",
|
|
139
|
+
" window.openChangelogModal = async function() {",
|
|
180
140
|
" var modal = document.getElementById('changelog-modal');",
|
|
181
141
|
" if (!modal) return;",
|
|
182
142
|
" var title = modal.querySelector('.changelog-title');",
|
|
@@ -189,28 +149,27 @@ export function generateConfigSubtabScript() {
|
|
|
189
149
|
" content.style.display = 'none';",
|
|
190
150
|
" error.style.display = 'none';",
|
|
191
151
|
" if (upgradeBtn) upgradeBtn.style.display = 'none';",
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
" .
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
"
|
|
201
|
-
" }",
|
|
202
|
-
" html += '</ul>';",
|
|
203
|
-
" content.innerHTML = html;",
|
|
204
|
-
" content.style.display = 'block';",
|
|
205
|
-
" } else {",
|
|
206
|
-
" error.style.display = 'block';",
|
|
152
|
+
" try {",
|
|
153
|
+
" var res = await fetch('/version/changelog');",
|
|
154
|
+
" var data = await res.json();",
|
|
155
|
+
" loading.style.display = 'none';",
|
|
156
|
+
" 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>';",
|
|
207
161
|
" }",
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
"
|
|
211
|
-
"
|
|
162
|
+
" html += '</ul>';",
|
|
163
|
+
" content.innerHTML = html;",
|
|
164
|
+
" content.style.display = 'block';",
|
|
165
|
+
" } else {",
|
|
212
166
|
" error.style.display = 'block';",
|
|
213
|
-
" }
|
|
167
|
+
" }",
|
|
168
|
+
" if (data.updateAvailable && upgradeBtn) { upgradeBtn.style.display = ''; }",
|
|
169
|
+
" } catch(err) {",
|
|
170
|
+
" loading.style.display = 'none';",
|
|
171
|
+
" error.style.display = 'block';",
|
|
172
|
+
" }",
|
|
214
173
|
" };",
|
|
215
174
|
// Close the changelog modal.
|
|
216
175
|
" window.closeChangelogModal = function() {",
|
|
@@ -218,65 +177,61 @@ export function generateConfigSubtabScript() {
|
|
|
218
177
|
" if (modal) { modal.style.display = 'none'; }",
|
|
219
178
|
" };",
|
|
220
179
|
// Start the upgrade process from the changelog modal. Fetches upgrade info, confirms with the user if there are active streams, then executes the upgrade.
|
|
221
|
-
" window.startUpgrade = function() {",
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
" .
|
|
225
|
-
"
|
|
226
|
-
" closeChangelogModal();",
|
|
227
|
-
" var msg = info.method === 'docker'",
|
|
228
|
-
" ? 'Docker containers cannot be upgraded in-place. Pull the latest image and recreate the container.'",
|
|
229
|
-
" : 'Manual upgrade required: ' + info.upgradeCommand;",
|
|
230
|
-
" showToast(msg, 'info', 8000);",
|
|
231
|
-
" return;",
|
|
232
|
-
" }",
|
|
233
|
-
" var streamCount = Object.keys(streamData).length;",
|
|
234
|
-
" if (streamCount > 0) {",
|
|
235
|
-
" if (!confirm('There are ' + streamCount + ' active stream(s). Upgrading will interrupt them. Continue?')) return;",
|
|
236
|
-
" }",
|
|
180
|
+
" window.startUpgrade = async function() {",
|
|
181
|
+
" try {",
|
|
182
|
+
" var infoRes = await fetch('/upgrade/info');",
|
|
183
|
+
" var info = await infoRes.json();",
|
|
184
|
+
" if (!info.upgradeable) {",
|
|
237
185
|
" closeChangelogModal();",
|
|
238
|
-
"
|
|
239
|
-
"
|
|
240
|
-
"
|
|
241
|
-
"
|
|
242
|
-
"
|
|
243
|
-
"
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"
|
|
248
|
-
"
|
|
249
|
-
"
|
|
250
|
-
" })",
|
|
251
|
-
" .
|
|
252
|
-
"
|
|
253
|
-
"
|
|
186
|
+
" var msg = info.method === 'docker'",
|
|
187
|
+
" ? 'Docker containers cannot be upgraded in-place. Pull the latest image and recreate the container.'",
|
|
188
|
+
" : 'Manual upgrade required: ' + info.upgradeCommand;",
|
|
189
|
+
" showToast(msg, 'info', 8000);",
|
|
190
|
+
" return;",
|
|
191
|
+
" }",
|
|
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;",
|
|
195
|
+
" }",
|
|
196
|
+
" closeChangelogModal();",
|
|
197
|
+
" 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) {",
|
|
201
|
+
" waitForServerRestart();",
|
|
202
|
+
" } else if (result.success) {",
|
|
203
|
+
" showToast('Upgrade complete. Please restart PrismCast manually.', 'success', 8000);",
|
|
204
|
+
" } else {",
|
|
205
|
+
" showToast('Upgrade failed: ' + result.message, 'error');",
|
|
206
|
+
" }",
|
|
207
|
+
" } catch(err) {",
|
|
208
|
+
" showToast('Upgrade failed: ' + err.message, 'error');",
|
|
209
|
+
" }",
|
|
254
210
|
" };",
|
|
255
211
|
// Check for updates manually. Updates the version link in-place when a new version is found.
|
|
256
|
-
" window.checkForUpdates = function() {",
|
|
212
|
+
" window.checkForUpdates = async function() {",
|
|
257
213
|
" var btn = document.querySelector('.version-check');",
|
|
258
214
|
" if (!btn || btn.classList.contains('checking')) return;",
|
|
259
215
|
" btn.classList.add('checking');",
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
" .
|
|
263
|
-
"
|
|
264
|
-
"
|
|
265
|
-
"
|
|
266
|
-
"
|
|
267
|
-
"
|
|
268
|
-
"
|
|
269
|
-
" }",
|
|
270
|
-
" } else {",
|
|
271
|
-
" btn.classList.add('up-to-date');",
|
|
272
|
-
" setTimeout(function() { btn.classList.remove('up-to-date'); }, 2000);",
|
|
216
|
+
" try {",
|
|
217
|
+
" var res = await fetch('/version/check', { method: 'POST' });",
|
|
218
|
+
" var data = await res.json();",
|
|
219
|
+
" 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')) {",
|
|
223
|
+
" link.textContent = 'v' + data.currentVersion + ' \\u2192 v' + data.latestVersion;",
|
|
224
|
+
" link.classList.add('version-update');",
|
|
273
225
|
" }",
|
|
274
|
-
" }
|
|
275
|
-
"
|
|
276
|
-
" btn.classList.remove('
|
|
277
|
-
"
|
|
278
|
-
"
|
|
279
|
-
"
|
|
226
|
+
" } else {",
|
|
227
|
+
" btn.classList.add('up-to-date');",
|
|
228
|
+
" setTimeout(function() { btn.classList.remove('up-to-date'); }, 2000);",
|
|
229
|
+
" }",
|
|
230
|
+
" } catch(err) {",
|
|
231
|
+
" btn.classList.remove('checking');",
|
|
232
|
+
" btn.classList.add('check-error');",
|
|
233
|
+
" setTimeout(function() { btn.classList.remove('check-error'); }, 2000);",
|
|
234
|
+
" }",
|
|
280
235
|
" };",
|
|
281
236
|
// Clear all field error indicators.
|
|
282
237
|
" function clearFieldErrors() {",
|
|
@@ -480,7 +435,7 @@ export function generateConfigSubtabScript() {
|
|
|
480
435
|
" showToast('All settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
|
|
481
436
|
" };",
|
|
482
437
|
// Submit settings form via AJAX.
|
|
483
|
-
" window.submitSettingsForm = function(event) {",
|
|
438
|
+
" window.submitSettingsForm = async function(event) {",
|
|
484
439
|
" event.preventDefault();",
|
|
485
440
|
" clearFieldErrors();",
|
|
486
441
|
" var form = document.getElementById('settings-form');",
|
|
@@ -500,132 +455,129 @@ export function generateConfigSubtabScript() {
|
|
|
500
455
|
" obj[parts[parts.length - 1]] = value;",
|
|
501
456
|
" }",
|
|
502
457
|
" if (saveBtn) saveBtn.classList.add('loading');",
|
|
503
|
-
"
|
|
504
|
-
"
|
|
505
|
-
"
|
|
506
|
-
"
|
|
507
|
-
"
|
|
508
|
-
"
|
|
509
|
-
"
|
|
458
|
+
" try {",
|
|
459
|
+
" var res = await fetch('/config', {",
|
|
460
|
+
" method: 'POST',",
|
|
461
|
+
" headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
|
|
462
|
+
" body: JSON.stringify(config)",
|
|
463
|
+
" });",
|
|
464
|
+
" var data = await res.json();",
|
|
510
465
|
" if (saveBtn) saveBtn.classList.remove('loading');",
|
|
511
|
-
" if (
|
|
512
|
-
" if (
|
|
513
|
-
" if (
|
|
514
|
-
" showPendingRestartDialog(
|
|
466
|
+
" if (res.ok && data.success) {",
|
|
467
|
+
" if (data.willRestart) {",
|
|
468
|
+
" if (data.deferred) {",
|
|
469
|
+
" showPendingRestartDialog(data.activeStreams);",
|
|
515
470
|
" } else {",
|
|
516
471
|
" waitForServerRestart();",
|
|
517
472
|
" }",
|
|
518
473
|
" } else {",
|
|
519
|
-
" showToast(
|
|
474
|
+
" showToast(data.message || 'Configuration saved.', 'info');",
|
|
520
475
|
" }",
|
|
521
|
-
" } else if (
|
|
522
|
-
" displayFieldErrors(
|
|
476
|
+
" } else if (data.errors) {",
|
|
477
|
+
" displayFieldErrors(data.errors);",
|
|
523
478
|
" showToast('Please correct the errors below.', 'error');",
|
|
524
479
|
" } else {",
|
|
525
|
-
" showToast(
|
|
480
|
+
" showToast(data.message || 'Failed to save configuration.', 'error');",
|
|
526
481
|
" }",
|
|
527
|
-
" })",
|
|
528
|
-
" .catch(function(err) {",
|
|
482
|
+
" } catch(err) {",
|
|
529
483
|
" if (saveBtn) saveBtn.classList.remove('loading');",
|
|
530
484
|
" showToast('Failed to save configuration: ' + err.message, 'error');",
|
|
531
|
-
" }
|
|
485
|
+
" }",
|
|
532
486
|
" return false;",
|
|
533
487
|
" };",
|
|
534
488
|
// Export configuration as JSON download.
|
|
535
|
-
" window.exportConfig = function() {",
|
|
536
|
-
"
|
|
537
|
-
"
|
|
538
|
-
" .
|
|
539
|
-
"
|
|
540
|
-
"
|
|
541
|
-
"
|
|
542
|
-
"
|
|
543
|
-
"
|
|
544
|
-
"
|
|
545
|
-
"
|
|
546
|
-
"
|
|
547
|
-
"
|
|
548
|
-
"
|
|
489
|
+
" window.exportConfig = async function() {",
|
|
490
|
+
" 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');",
|
|
495
|
+
" a.href = url;",
|
|
496
|
+
" a.download = 'prismcast-config.json';",
|
|
497
|
+
" document.body.appendChild(a);",
|
|
498
|
+
" a.click();",
|
|
499
|
+
" document.body.removeChild(a);",
|
|
500
|
+
" window.URL.revokeObjectURL(url);",
|
|
501
|
+
" } catch(err) {",
|
|
502
|
+
" showToast('Failed to export configuration: ' + err.message, 'error');",
|
|
503
|
+
" }",
|
|
549
504
|
" };",
|
|
550
505
|
// Import configuration from file.
|
|
551
506
|
" window.importConfig = function(fileInput) {",
|
|
552
507
|
" var file = fileInput.files[0];",
|
|
553
508
|
" if (!file) return;",
|
|
554
509
|
" var reader = new FileReader();",
|
|
555
|
-
" reader.onload = function(e) {",
|
|
510
|
+
" reader.onload = async function(e) {",
|
|
556
511
|
" try {",
|
|
557
512
|
" var config = JSON.parse(e.target.result);",
|
|
558
513
|
" if (confirm('Import this configuration? The server may restart to apply changes.')) {",
|
|
559
|
-
" fetch('/config/import', {",
|
|
514
|
+
" var res = await fetch('/config/import', {",
|
|
560
515
|
" method: 'POST',",
|
|
561
516
|
" headers: { 'Content-Type': 'application/json' },",
|
|
562
517
|
" body: JSON.stringify(config)",
|
|
563
|
-
" })",
|
|
564
|
-
"
|
|
565
|
-
" .
|
|
566
|
-
"
|
|
567
|
-
"
|
|
568
|
-
" if (
|
|
569
|
-
"
|
|
570
|
-
"
|
|
571
|
-
"
|
|
572
|
-
" waitForServerRestart();",
|
|
573
|
-
" }",
|
|
518
|
+
" });",
|
|
519
|
+
" var data = await res.json();",
|
|
520
|
+
" if (res.ok && data.success) {",
|
|
521
|
+
" showToast(data.message || 'Configuration imported.', 'success');",
|
|
522
|
+
" if (data.willRestart) {",
|
|
523
|
+
" if (data.deferred) {",
|
|
524
|
+
" showPendingRestartDialog(data.activeStreams);",
|
|
525
|
+
" } else {",
|
|
526
|
+
" waitForServerRestart();",
|
|
574
527
|
" }",
|
|
575
|
-
" } else {",
|
|
576
|
-
" throw new Error(result.data.message || result.data.error || 'Import failed');",
|
|
577
528
|
" }",
|
|
578
|
-
" }
|
|
579
|
-
"
|
|
529
|
+
" } else {",
|
|
530
|
+
" throw new Error(data.message || data.error || 'Import failed');",
|
|
531
|
+
" }",
|
|
580
532
|
" }",
|
|
581
533
|
" } catch (err) {",
|
|
582
|
-
" showToast('
|
|
534
|
+
" showToast('Failed to import configuration: ' + err.message, 'error');",
|
|
583
535
|
" }",
|
|
584
536
|
" fileInput.value = '';",
|
|
585
537
|
" };",
|
|
586
538
|
" reader.readAsText(file);",
|
|
587
539
|
" };",
|
|
588
540
|
// Export channels as JSON download.
|
|
589
|
-
" window.exportChannels = function() {",
|
|
590
|
-
"
|
|
591
|
-
"
|
|
592
|
-
" .
|
|
593
|
-
"
|
|
594
|
-
"
|
|
595
|
-
"
|
|
596
|
-
"
|
|
597
|
-
"
|
|
598
|
-
"
|
|
599
|
-
"
|
|
600
|
-
"
|
|
601
|
-
"
|
|
602
|
-
"
|
|
541
|
+
" window.exportChannels = async function() {",
|
|
542
|
+
" 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');",
|
|
547
|
+
" a.href = url;",
|
|
548
|
+
" a.download = 'prismcast-channels.json';",
|
|
549
|
+
" document.body.appendChild(a);",
|
|
550
|
+
" a.click();",
|
|
551
|
+
" document.body.removeChild(a);",
|
|
552
|
+
" window.URL.revokeObjectURL(url);",
|
|
553
|
+
" } catch(err) {",
|
|
554
|
+
" showToast('Failed to export channels: ' + err.message, 'error');",
|
|
555
|
+
" }",
|
|
603
556
|
" };",
|
|
604
557
|
// Import channels from file.
|
|
605
558
|
" window.importChannels = function(fileInput) {",
|
|
606
559
|
" var file = fileInput.files[0];",
|
|
607
560
|
" if (!file) return;",
|
|
608
561
|
" var reader = new FileReader();",
|
|
609
|
-
" reader.onload = function(e) {",
|
|
562
|
+
" reader.onload = async function(e) {",
|
|
610
563
|
" try {",
|
|
611
564
|
" var channels = JSON.parse(e.target.result);",
|
|
612
565
|
" if (confirm('Import these channels? This will replace all existing user channels.')) {",
|
|
613
|
-
" fetch('/config/channels/import', {",
|
|
566
|
+
" var res = await fetch('/config/channels/import', {",
|
|
614
567
|
" method: 'POST',",
|
|
615
568
|
" headers: { 'Content-Type': 'application/json' },",
|
|
616
569
|
" body: JSON.stringify(channels)",
|
|
617
|
-
" })",
|
|
618
|
-
" .
|
|
619
|
-
"
|
|
620
|
-
"
|
|
621
|
-
"
|
|
622
|
-
"
|
|
623
|
-
"
|
|
624
|
-
" }
|
|
625
|
-
" .catch(function(err) { showToast('Failed to import channels: ' + err.message, 'error'); });",
|
|
570
|
+
" });",
|
|
571
|
+
" var data = await res.json();",
|
|
572
|
+
" if (data.success) {",
|
|
573
|
+
" showToast((data.message || 'Channels imported successfully.') + PLAYLIST_HINT, 'success');",
|
|
574
|
+
" if (data.patch) { applyChannelPatch(data.patch); }",
|
|
575
|
+
" } else {",
|
|
576
|
+
" showToast('Failed to import channels: ' + (data.error || 'Unknown error.'), 'error');",
|
|
577
|
+
" }",
|
|
626
578
|
" }",
|
|
627
579
|
" } catch (err) {",
|
|
628
|
-
" showToast('
|
|
580
|
+
" showToast('Failed to import channels: ' + err.message, 'error');",
|
|
629
581
|
" }",
|
|
630
582
|
" fileInput.value = '';",
|
|
631
583
|
" };",
|
|
@@ -638,14 +590,14 @@ export function generateConfigSubtabScript() {
|
|
|
638
590
|
" var replaceCheckbox = document.getElementById('m3u-replace-duplicates');",
|
|
639
591
|
" var conflictMode = (replaceCheckbox && replaceCheckbox.checked) ? 'replace' : 'skip';",
|
|
640
592
|
" var reader = new FileReader();",
|
|
641
|
-
" reader.onload = function(e) {",
|
|
642
|
-
"
|
|
643
|
-
"
|
|
644
|
-
"
|
|
645
|
-
"
|
|
646
|
-
"
|
|
647
|
-
"
|
|
648
|
-
"
|
|
593
|
+
" reader.onload = async function(e) {",
|
|
594
|
+
" try {",
|
|
595
|
+
" var res = await fetch('/config/channels/import-m3u', {",
|
|
596
|
+
" method: 'POST',",
|
|
597
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
598
|
+
" body: JSON.stringify({ content: e.target.result, conflictMode: conflictMode })",
|
|
599
|
+
" });",
|
|
600
|
+
" var data = await res.json();",
|
|
649
601
|
" if (data.success) {",
|
|
650
602
|
" var msg = 'M3U Import Complete\\n\\n';",
|
|
651
603
|
" msg += '\\u2713 ' + data.imported + ' channel(s) imported\\n';",
|
|
@@ -658,13 +610,15 @@ export function generateConfigSubtabScript() {
|
|
|
658
610
|
" }",
|
|
659
611
|
" if (data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
|
|
660
612
|
" }",
|
|
661
|
-
" if (data.
|
|
662
|
-
"
|
|
613
|
+
" if (data.patch) { applyChannelPatch(data.patch); }",
|
|
614
|
+
" showToast(msg + ((data.imported > 0 || data.replaced > 0) ? PLAYLIST_HINT : ''), 'success');",
|
|
663
615
|
" } else {",
|
|
664
616
|
" showToast('M3U import failed: ' + (data.error || 'Unknown error'), 'error');",
|
|
665
617
|
" }",
|
|
666
|
-
" })",
|
|
667
|
-
"
|
|
618
|
+
" } catch(err) {",
|
|
619
|
+
" console.error('M3U import error:', err);",
|
|
620
|
+
" showToast('Failed to import M3U: ' + err.message, 'error');",
|
|
621
|
+
" }",
|
|
668
622
|
" fileInput.value = '';",
|
|
669
623
|
" };",
|
|
670
624
|
" reader.readAsText(file);",
|
|
@@ -749,13 +703,44 @@ export function generateConfigSubtabScript() {
|
|
|
749
703
|
" if (displayRow) displayRow.style.display = '';",
|
|
750
704
|
" if (editRow) editRow.style.display = 'none';",
|
|
751
705
|
" };",
|
|
752
|
-
//
|
|
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
|
|
708
|
+
// 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');",
|
|
713
|
+
" if(!wasActive) btn.classList.add('active');",
|
|
714
|
+
" var urlInput = document.getElementById('add-url');",
|
|
715
|
+
" if(urlInput) {",
|
|
716
|
+
" urlInput.value = wasActive ? '' : (btn.getAttribute('data-url') || '');",
|
|
717
|
+
" urlInput.dispatchEvent(new Event('input', { bubbles: true }));",
|
|
718
|
+
" }",
|
|
719
|
+
" var profileSelect = document.getElementById('add-profile');",
|
|
720
|
+
" if(profileSelect) {",
|
|
721
|
+
" if(!wasActive) { profileSelect.value = ''; profileSelect.disabled = true; }",
|
|
722
|
+
" else { profileSelect.disabled = false; }",
|
|
723
|
+
" }",
|
|
724
|
+
" if(!wasActive) {",
|
|
725
|
+
" var advanced = document.getElementById('add-advanced');",
|
|
726
|
+
" var toggle = advanced ? advanced.previousElementSibling : null;",
|
|
727
|
+
" if(advanced && !advanced.classList.contains('show')) {",
|
|
728
|
+
" advanced.classList.add('show');",
|
|
729
|
+
" if(toggle) toggle.textContent = 'Hide Advanced Options';",
|
|
730
|
+
" }",
|
|
731
|
+
" }",
|
|
732
|
+
" };",
|
|
733
|
+
// Hide the add form and reset all fields and provider pill selection.
|
|
753
734
|
" window.hideAddForm = function() {",
|
|
754
735
|
" var addForm = document.getElementById('add-channel-form');",
|
|
755
|
-
" var addBtn = document.getElementById('add-channel-btn');",
|
|
756
736
|
" if (addForm) addForm.style.display = 'none';",
|
|
757
|
-
" if (
|
|
758
|
-
"
|
|
737
|
+
" if (addForm) {",
|
|
738
|
+
" 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');",
|
|
742
|
+
" if(profileSelect) profileSelect.disabled = false;",
|
|
743
|
+
" }",
|
|
759
744
|
" };",
|
|
760
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).
|
|
761
746
|
// Uses the current table sort field and direction for correct insertion order.
|
|
@@ -793,7 +778,7 @@ export function generateConfigSubtabScript() {
|
|
|
793
778
|
" tbody.appendChild(newDisplayRow);",
|
|
794
779
|
" if (newEditRow) tbody.appendChild(newEditRow);",
|
|
795
780
|
" }",
|
|
796
|
-
"
|
|
781
|
+
" processChannelLogos();",
|
|
797
782
|
" };",
|
|
798
783
|
// Remove channel rows from the table.
|
|
799
784
|
" window.removeChannelRow = function(key) {",
|
|
@@ -801,7 +786,6 @@ export function generateConfigSubtabScript() {
|
|
|
801
786
|
" var editRow = document.getElementById('edit-row-' + key);",
|
|
802
787
|
" if (displayRow) displayRow.remove();",
|
|
803
788
|
" if (editRow) editRow.remove();",
|
|
804
|
-
" updateDisabledCount();",
|
|
805
789
|
" };",
|
|
806
790
|
// Advanced fields toggle for channel forms.
|
|
807
791
|
" window.toggleAdvanced = function(prefix) {",
|
|
@@ -824,84 +808,153 @@ export function generateConfigSubtabScript() {
|
|
|
824
808
|
" ref.style.display = ref.style.display === 'none' ? 'block' : 'none';",
|
|
825
809
|
" }",
|
|
826
810
|
" };",
|
|
811
|
+
// 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
|
+
// 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
|
+
// current value so the handler is generic across both fields.
|
|
814
|
+
" window.startInlineEdit = function(td) {",
|
|
815
|
+
" 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');",
|
|
821
|
+
" input.type = (field === 'channelNumber') ? 'number' : 'text';",
|
|
822
|
+
" input.className = 'inline-edit';",
|
|
823
|
+
" input.value = value;",
|
|
824
|
+
" td.innerHTML = '';",
|
|
825
|
+
" td.appendChild(input);",
|
|
826
|
+
" input.focus();",
|
|
827
|
+
" input.select();",
|
|
828
|
+
" var saving = false;",
|
|
829
|
+
" async function save() {",
|
|
830
|
+
" if(saving) return;",
|
|
831
|
+
" saving = true;",
|
|
832
|
+
" var newValue = input.value.trim();",
|
|
833
|
+
" if(newValue === value) { cancel(); return; }",
|
|
834
|
+
" var data = { action: 'inline-edit', field: field, key: key, value: newValue };",
|
|
835
|
+
" 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)",
|
|
840
|
+
" });",
|
|
841
|
+
" var d = await res.json();",
|
|
842
|
+
" if(res.ok && d.success) {",
|
|
843
|
+
" if(d.patch) { applyChannelPatch(d.patch); }",
|
|
844
|
+
" } else {",
|
|
845
|
+
" td.innerHTML = original;",
|
|
846
|
+
" showToast(d.message || 'Failed to save.', 'error');",
|
|
847
|
+
" }",
|
|
848
|
+
" } catch(err) {",
|
|
849
|
+
" console.error('Inline edit error:', err);",
|
|
850
|
+
" td.innerHTML = original;",
|
|
851
|
+
" showToast('Failed to save: ' + err.message, 'error');",
|
|
852
|
+
" }",
|
|
853
|
+
" }",
|
|
854
|
+
" function cancel() { td.innerHTML = original; }",
|
|
855
|
+
" input.addEventListener('keydown', function(e) {",
|
|
856
|
+
" if(e.key === 'Enter') { e.preventDefault(); save(); }",
|
|
857
|
+
" else if(e.key === 'Escape') { cancel(); }",
|
|
858
|
+
" });",
|
|
859
|
+
" input.addEventListener('blur', function() { if(!saving) save(); });",
|
|
860
|
+
" };",
|
|
861
|
+
// 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';",
|
|
865
|
+
" 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 })",
|
|
870
|
+
" });",
|
|
871
|
+
" var d = await res.json();",
|
|
872
|
+
" if(res.ok && d.success) {",
|
|
873
|
+
" if(d.patch) { applyChannelPatch(d.patch); }",
|
|
874
|
+
" } else {",
|
|
875
|
+
" checkbox.checked = !checkbox.checked;",
|
|
876
|
+
" showToast(d.message || 'Failed to update HDHR setting.', 'error');",
|
|
877
|
+
" }",
|
|
878
|
+
" } catch(err) {",
|
|
879
|
+
" checkbox.checked = !checkbox.checked;",
|
|
880
|
+
" showToast('Failed to update HDHR setting: ' + err.message, 'error');",
|
|
881
|
+
" }",
|
|
882
|
+
" };",
|
|
827
883
|
// Submit channel form via AJAX (add or edit).
|
|
828
|
-
" window.submitChannelForm = function(event, action) {",
|
|
884
|
+
" window.submitChannelForm = async function(event, action) {",
|
|
829
885
|
" event.preventDefault();",
|
|
830
886
|
" var form = event.target;",
|
|
831
887
|
" var formData = new FormData(form);",
|
|
832
888
|
" var data = {};",
|
|
833
889
|
" for (var pair of formData.entries()) { data[pair[0]] = pair[1]; }",
|
|
834
|
-
"
|
|
835
|
-
"
|
|
836
|
-
"
|
|
837
|
-
"
|
|
838
|
-
"
|
|
839
|
-
"
|
|
840
|
-
"
|
|
841
|
-
" if (
|
|
842
|
-
" showToast(
|
|
843
|
-
" if (
|
|
844
|
-
" insertChannelRow(result.data.html, result.data.key);",
|
|
845
|
-
" refilterChannelRows();",
|
|
846
|
-
" }",
|
|
890
|
+
" 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)",
|
|
895
|
+
" });",
|
|
896
|
+
" var d = await res.json();",
|
|
897
|
+
" if (res.ok && d.success) {",
|
|
898
|
+
" showToast(d.message, 'success');",
|
|
899
|
+
" if (d.patch) { applyChannelPatch(d.patch); }",
|
|
847
900
|
" if (action === 'add') {",
|
|
848
901
|
" hideAddForm();",
|
|
849
902
|
" } else {",
|
|
850
|
-
" hideEditForm(
|
|
903
|
+
" hideEditForm(d.key);",
|
|
851
904
|
" }",
|
|
852
|
-
" } else if (
|
|
905
|
+
" } else if (d.errors) {",
|
|
853
906
|
" var errorMsgs = [];",
|
|
854
|
-
" for (var field in
|
|
907
|
+
" for (var field in d.errors) { errorMsgs.push(field + ': ' + d.errors[field]); }",
|
|
855
908
|
" showToast('Validation errors: ' + errorMsgs.join(', '), 'error');",
|
|
856
909
|
" } else {",
|
|
857
|
-
" showToast(
|
|
910
|
+
" showToast(d.message || 'Failed to save channel.', 'error');",
|
|
858
911
|
" }",
|
|
859
|
-
" })",
|
|
860
|
-
"
|
|
912
|
+
" } catch(err) {",
|
|
913
|
+
" console.error('Channel save error:', err);",
|
|
914
|
+
" showToast('Failed to save channel: ' + err.message, 'error');",
|
|
915
|
+
" }",
|
|
861
916
|
" return false;",
|
|
862
917
|
" };",
|
|
863
918
|
// Delete channel via AJAX.
|
|
864
|
-
" window.deleteChannel = function(key) {",
|
|
919
|
+
" window.deleteChannel = async function(key) {",
|
|
865
920
|
" if (!confirm('Delete channel ' + key + '?')) return;",
|
|
866
|
-
"
|
|
867
|
-
"
|
|
868
|
-
"
|
|
869
|
-
"
|
|
870
|
-
"
|
|
871
|
-
"
|
|
872
|
-
"
|
|
873
|
-
" if (
|
|
874
|
-
" showToast(
|
|
875
|
-
" if (
|
|
876
|
-
" insertChannelRow(result.data.html, result.data.key || key);",
|
|
877
|
-
" refilterChannelRows();",
|
|
878
|
-
" } else {",
|
|
879
|
-
" removeChannelRow(result.data.key || key);",
|
|
880
|
-
" }",
|
|
921
|
+
" 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 })",
|
|
926
|
+
" });",
|
|
927
|
+
" var d = await res.json();",
|
|
928
|
+
" if (res.ok && d.success) {",
|
|
929
|
+
" showToast(d.message, 'success');",
|
|
930
|
+
" if (d.patch) { applyChannelPatch(d.patch); }",
|
|
881
931
|
" } else {",
|
|
882
|
-
" showToast(
|
|
932
|
+
" showToast(d.message || 'Failed to delete channel.', 'error');",
|
|
883
933
|
" }",
|
|
884
|
-
" })",
|
|
885
|
-
"
|
|
934
|
+
" } catch(err) {",
|
|
935
|
+
" console.error('Channel delete error:', err);",
|
|
936
|
+
" showToast('Failed to delete channel: ' + err.message, 'error');",
|
|
937
|
+
" }",
|
|
886
938
|
" };",
|
|
887
939
|
// Toggle a single predefined channel's enabled/disabled state.
|
|
888
|
-
" window.togglePredefinedChannel = function(key, enable) {",
|
|
889
|
-
"
|
|
890
|
-
"
|
|
891
|
-
"
|
|
892
|
-
"
|
|
893
|
-
"
|
|
894
|
-
"
|
|
895
|
-
"
|
|
940
|
+
" window.togglePredefinedChannel = async function(key, enable) {",
|
|
941
|
+
" try {",
|
|
942
|
+
" var res = await fetch('/config/channels/toggle-predefined', {",
|
|
943
|
+
" method: 'POST',",
|
|
944
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
945
|
+
" body: JSON.stringify({ key: key, enabled: enable })",
|
|
946
|
+
" });",
|
|
947
|
+
" var result = await res.json();",
|
|
896
948
|
" if (result.success) {",
|
|
897
949
|
" showToast('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
|
|
898
|
-
"
|
|
899
|
-
" if(result.counts) { updateScopeToggles(result.counts); }",
|
|
950
|
+
" if(result.patch) { applyChannelPatch(result.patch); }",
|
|
900
951
|
" } else {",
|
|
901
952
|
" showToast(result.error || 'Failed to toggle channel.', 'error');",
|
|
902
953
|
" }",
|
|
903
|
-
" })",
|
|
904
|
-
"
|
|
954
|
+
" } catch(err) {",
|
|
955
|
+
" console.error('Channel toggle error:', err);",
|
|
956
|
+
" showToast('Failed to toggle channel: ' + err.message, 'error');",
|
|
957
|
+
" }",
|
|
905
958
|
" };",
|
|
906
959
|
// Update a channel row's provider selection in-place. Syncs the HTML selected attribute so filterChannelRows() restore logic works correctly. We iterate
|
|
907
960
|
// _allOptions (if present) rather than querySelectorAll because filtered-out options are removed from the DOM but still tracked in the array.
|
|
@@ -918,186 +971,228 @@ export function generateConfigSubtabScript() {
|
|
|
918
971
|
" }",
|
|
919
972
|
" }",
|
|
920
973
|
// Update provider selection for a multi-provider channel.
|
|
921
|
-
" window.updateProviderSelection = function(selectElement) {",
|
|
974
|
+
" window.updateProviderSelection = async function(selectElement) {",
|
|
922
975
|
" var channelKey = selectElement.getAttribute('data-channel');",
|
|
923
976
|
" var providerKey = selectElement.value;",
|
|
924
|
-
"
|
|
925
|
-
"
|
|
926
|
-
"
|
|
927
|
-
"
|
|
928
|
-
"
|
|
929
|
-
"
|
|
930
|
-
"
|
|
977
|
+
" try {",
|
|
978
|
+
" var res = await fetch('/config/provider', {",
|
|
979
|
+
" method: 'POST',",
|
|
980
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
981
|
+
" body: JSON.stringify({ channel: channelKey, provider: providerKey })",
|
|
982
|
+
" });",
|
|
983
|
+
" var result = await res.json();",
|
|
931
984
|
" if (result.success) {",
|
|
932
985
|
" showToast('Provider updated. New streams will use the selected provider.', 'success');",
|
|
933
|
-
" if (result.
|
|
986
|
+
" if (result.patch) { applyChannelPatch(result.patch); }",
|
|
934
987
|
" } else {",
|
|
935
988
|
" showToast(result.error || 'Failed to update provider.', 'error');",
|
|
936
989
|
" }",
|
|
937
|
-
" })",
|
|
938
|
-
"
|
|
990
|
+
" } catch(err) {",
|
|
991
|
+
" console.error('Provider update error:', err);",
|
|
992
|
+
" showToast('Failed to update provider: ' + err.message, 'error');",
|
|
993
|
+
" }",
|
|
939
994
|
" };",
|
|
940
995
|
// Bulk toggle predefined channels by scope (all, pacific, east).
|
|
941
|
-
" window.bulkTogglePredefined = function(enable, scope) {",
|
|
942
|
-
"
|
|
943
|
-
"
|
|
944
|
-
"
|
|
945
|
-
"
|
|
946
|
-
"
|
|
947
|
-
"
|
|
948
|
-
"
|
|
996
|
+
" window.bulkTogglePredefined = async function(enable, scope) {",
|
|
997
|
+
" try {",
|
|
998
|
+
" var res = await fetch('/config/channels/bulk-toggle-predefined', {",
|
|
999
|
+
" method: 'POST',",
|
|
1000
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1001
|
+
" body: JSON.stringify({ enabled: enable, scope: scope })",
|
|
1002
|
+
" });",
|
|
1003
|
+
" var result = await res.json();",
|
|
949
1004
|
" if (result.success) {",
|
|
950
1005
|
" var labels = { all: 'All predefined', pacific: 'Pacific predefined', east: 'East predefined' };",
|
|
951
1006
|
" showToast((labels[scope] || 'Predefined') + ' channels ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
|
|
952
|
-
" if (result.
|
|
953
|
-
" for (var i = 0; i < result.keys.length; i++) { setRowDisabledState(result.keys[i], !enable); }",
|
|
954
|
-
" }",
|
|
955
|
-
" updateDisabledCount();",
|
|
956
|
-
" if(result.counts) { updateScopeToggles(result.counts); }",
|
|
1007
|
+
" if (result.patch) { applyChannelPatch(result.patch); }",
|
|
957
1008
|
" } else {",
|
|
958
1009
|
" showToast(result.error || 'Failed to toggle channels.', 'error');",
|
|
959
1010
|
" }",
|
|
960
|
-
" })",
|
|
961
|
-
"
|
|
1011
|
+
" } catch(err) {",
|
|
1012
|
+
" console.error('Bulk toggle error:', err);",
|
|
1013
|
+
" showToast('Failed to toggle channels: ' + err.message, 'error');",
|
|
1014
|
+
" }",
|
|
962
1015
|
" };",
|
|
963
|
-
//
|
|
964
|
-
|
|
965
|
-
"
|
|
966
|
-
"
|
|
967
|
-
"
|
|
968
|
-
"
|
|
969
|
-
"
|
|
970
|
-
"
|
|
971
|
-
"
|
|
972
|
-
"
|
|
973
|
-
"
|
|
1016
|
+
// 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
|
+
// 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);",
|
|
1023
|
+
" 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?' " +
|
|
1028
|
+
": 'Assign channel numbers starting from ' + start + ' to all visible channels in the current sort order? This will overwrite existing numbers.';",
|
|
1029
|
+
" if(!confirm(msg)) return;",
|
|
1030
|
+
" try {",
|
|
1031
|
+
" var res = await fetch('/config/channels/auto-number', {",
|
|
1032
|
+
" method: 'POST',",
|
|
1033
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1034
|
+
" body: JSON.stringify({ start: start, sortField: sortField, sortDirection: sortDir })",
|
|
1035
|
+
" });",
|
|
1036
|
+
" var result = await res.json();",
|
|
1037
|
+
" if(result.success) {",
|
|
1038
|
+
" showToast(result.message || 'Channels numbered.', 'success');",
|
|
1039
|
+
" if(result.patch) { applyChannelPatch(result.patch); }",
|
|
1040
|
+
" } else {",
|
|
1041
|
+
" showToast(result.error || 'Failed to auto-number channels.', 'error');",
|
|
974
1042
|
" }",
|
|
975
|
-
"
|
|
1043
|
+
" } catch(err) {",
|
|
1044
|
+
" console.error('Auto-number error:', err);",
|
|
1045
|
+
" showToast('Failed to auto-number channels: ' + err.message, 'error');",
|
|
976
1046
|
" }",
|
|
977
|
-
" }",
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
"
|
|
983
|
-
|
|
984
|
-
"
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
"
|
|
988
|
-
"
|
|
989
|
-
"
|
|
990
|
-
"
|
|
991
|
-
"
|
|
992
|
-
"
|
|
993
|
-
"
|
|
994
|
-
"
|
|
995
|
-
"
|
|
996
|
-
" if (result.ok && result.data.success) {",
|
|
997
|
-
" showToast(result.data.message, 'success');",
|
|
998
|
-
" if (result.data.html) {",
|
|
999
|
-
" insertChannelRow(result.data.html, result.data.key || key);",
|
|
1000
|
-
" refilterChannelRows();",
|
|
1001
|
-
" }",
|
|
1047
|
+
" };",
|
|
1048
|
+
// 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
|
+
// unchecked or indeterminate it enables all.
|
|
1050
|
+
" window.bulkToggleHdhr = async function() {",
|
|
1051
|
+
" var toggle = document.getElementById('hdhr-bulk-toggle');",
|
|
1052
|
+
" 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?';",
|
|
1055
|
+
" if(!confirm(msg)) return;",
|
|
1056
|
+
" try {",
|
|
1057
|
+
" var res = await fetch('/config/channels/hdhr-bulk', {",
|
|
1058
|
+
" method: 'POST',",
|
|
1059
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1060
|
+
" body: JSON.stringify({ enable: enable })",
|
|
1061
|
+
" });",
|
|
1062
|
+
" var result = await res.json();",
|
|
1063
|
+
" if(result.success) {",
|
|
1064
|
+
" showToast(result.message || 'HDHR settings updated.', 'success');",
|
|
1065
|
+
" if(result.patch) { applyChannelPatch(result.patch); }",
|
|
1002
1066
|
" } else {",
|
|
1003
|
-
" showToast(result.
|
|
1067
|
+
" showToast(result.error || 'Failed to update HDHR settings.', 'error');",
|
|
1004
1068
|
" }",
|
|
1005
|
-
" })",
|
|
1006
|
-
"
|
|
1069
|
+
" } catch(err) {",
|
|
1070
|
+
" console.error('HDHR bulk toggle error:', err);",
|
|
1071
|
+
" showToast('Failed to update HDHR settings: ' + err.message, 'error');",
|
|
1072
|
+
" }",
|
|
1007
1073
|
" };",
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
"
|
|
1012
|
-
"
|
|
1013
|
-
"
|
|
1014
|
-
"
|
|
1015
|
-
"
|
|
1016
|
-
"
|
|
1017
|
-
|
|
1018
|
-
" var
|
|
1019
|
-
" if
|
|
1020
|
-
"
|
|
1021
|
-
"
|
|
1022
|
-
"
|
|
1023
|
-
" }",
|
|
1024
|
-
// Swap disable icon to enable icon.
|
|
1025
|
-
" var disableBtn = btnGroup.querySelector('.btn-icon-disable');",
|
|
1026
|
-
" if (disableBtn) {",
|
|
1027
|
-
" disableBtn.className = 'btn-icon btn-icon-enable';",
|
|
1028
|
-
" disableBtn.title = 'Enable';",
|
|
1029
|
-
" disableBtn.setAttribute('aria-label', 'Enable');",
|
|
1030
|
-
" disableBtn.innerHTML = ICON_ENABLE_SVG;",
|
|
1031
|
-
" disableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', true)\");",
|
|
1032
|
-
" }",
|
|
1033
|
-
" } else {",
|
|
1034
|
-
" row.classList.remove('channel-disabled');",
|
|
1035
|
-
// Swap enable icon to disable icon.
|
|
1036
|
-
" var enableBtn = btnGroup.querySelector('.btn-icon-enable');",
|
|
1037
|
-
" if (enableBtn) {",
|
|
1038
|
-
// Replace placeholder with login icon button.
|
|
1039
|
-
" var placeholder = btnGroup.querySelector('.btn-icon-placeholder');",
|
|
1040
|
-
" if (placeholder) {",
|
|
1041
|
-
" var newLoginBtn = document.createElement('button');",
|
|
1042
|
-
" newLoginBtn.type = 'button';",
|
|
1043
|
-
" newLoginBtn.className = 'btn-icon btn-icon-login';",
|
|
1044
|
-
" newLoginBtn.title = 'Login';",
|
|
1045
|
-
" newLoginBtn.setAttribute('aria-label', 'Login');",
|
|
1046
|
-
" newLoginBtn.innerHTML = ICON_LOGIN_SVG;",
|
|
1047
|
-
" newLoginBtn.setAttribute('onclick', \"startChannelLogin('\" + key + \"')\");",
|
|
1048
|
-
" placeholder.replaceWith(newLoginBtn);",
|
|
1049
|
-
" }",
|
|
1050
|
-
" enableBtn.className = 'btn-icon btn-icon-disable';",
|
|
1051
|
-
" enableBtn.title = 'Disable';",
|
|
1052
|
-
" enableBtn.setAttribute('aria-label', 'Disable');",
|
|
1053
|
-
" enableBtn.innerHTML = ICON_DISABLE_SVG;",
|
|
1054
|
-
" enableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', false)\");",
|
|
1074
|
+
// 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
|
|
1075
|
+
// Quick Actions calls this on click — from checked (all have tag) it removes; from unchecked or indeterminate it adds. Uses applyTagResponse (defined in
|
|
1076
|
+
// channels.ts, exposed on window) to update the tag manager modal, filter dropdown, and channel table in one call.
|
|
1077
|
+
" window.bulkToggleTag = async function(tag, add) {",
|
|
1078
|
+
" try {",
|
|
1079
|
+
" var res = await fetch('/config/channels/bulk-tags', {",
|
|
1080
|
+
" method: 'POST',",
|
|
1081
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1082
|
+
" body: JSON.stringify({ action: add ? 'add' : 'remove', tag: tag })",
|
|
1083
|
+
" });",
|
|
1084
|
+
" var result = await res.json();",
|
|
1085
|
+
" if(result.success) {",
|
|
1086
|
+
" if(window.applyTagResponse) { window.applyTagResponse(result, result.message || 'Tags updated.'); }",
|
|
1087
|
+
" } else {",
|
|
1088
|
+
" showToast(result.error || 'Failed to update tags.', 'error');",
|
|
1055
1089
|
" }",
|
|
1090
|
+
" } catch(err) {",
|
|
1091
|
+
" console.error('Bulk tag error:', err);",
|
|
1092
|
+
" showToast('Failed to update tags: ' + err.message, 'error');",
|
|
1056
1093
|
" }",
|
|
1094
|
+
" };",
|
|
1095
|
+
// Inline tag edit portal — uses toggleDropdown with lifecycle hooks for batch-save behavior. The before-close hook saves pending changes whenever the
|
|
1096
|
+
// dropdown closes (click-outside, scroll, resize, or next toggle). The onOpen hook populates checkbox state from the cell's data-tags attribute.
|
|
1097
|
+
" var activeTagDropdown = null;",
|
|
1098
|
+
" var tagPortal = null;",
|
|
1099
|
+
" function getTagPortal() {",
|
|
1100
|
+
" if(!tagPortal) { tagPortal = document.getElementById('inline-tag-portal'); }",
|
|
1101
|
+
" return tagPortal;",
|
|
1057
1102
|
" }",
|
|
1058
|
-
|
|
1059
|
-
"
|
|
1060
|
-
"
|
|
1061
|
-
"
|
|
1103
|
+
" function saveTagDropdownIfChanged() {",
|
|
1104
|
+
" if(!activeTagDropdown) return;",
|
|
1105
|
+
" var portal = getTagPortal();",
|
|
1106
|
+
" if(!portal) return;",
|
|
1107
|
+
" var original = activeTagDropdown.original;",
|
|
1108
|
+
" var key = activeTagDropdown.key;",
|
|
1109
|
+
" var checkboxes = portal.querySelectorAll('.inline-tag-checkbox');",
|
|
1110
|
+
" var tags = [];",
|
|
1111
|
+
" for(var i = 0; i < checkboxes.length; i++) {",
|
|
1112
|
+
" if(checkboxes[i].checked) tags.push(checkboxes[i].getAttribute('data-tag'));",
|
|
1113
|
+
" }",
|
|
1114
|
+
" var newValue = tags.sort().join(',');",
|
|
1115
|
+
" activeTagDropdown = null;",
|
|
1116
|
+
" if(newValue === original) return;",
|
|
1117
|
+
" fetch('/config/channels', {",
|
|
1118
|
+
" method: 'POST',",
|
|
1119
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1120
|
+
" body: JSON.stringify({ action: 'inline-edit', field: 'tags', key: key, value: newValue })",
|
|
1121
|
+
" }).then(function(res) { return res.json(); }).then(function(d) {",
|
|
1122
|
+
" if(d.success && d.patch) { applyChannelPatch(d.patch); }",
|
|
1123
|
+
" else if(!d.success) { showToast(d.message || 'Failed to update tags.', 'error'); }",
|
|
1124
|
+
" }).catch(function(err) {",
|
|
1125
|
+
" showToast('Failed to update tags: ' + err.message, 'error');",
|
|
1126
|
+
" });",
|
|
1127
|
+
" }",
|
|
1128
|
+
// Register the batch-save hook so all close paths (click-outside, scroll, resize, next toggle) trigger save.
|
|
1129
|
+
" closeDropdowns.addHook(saveTagDropdownIfChanged);",
|
|
1130
|
+
" window.toggleInlineTagDropdown = function(td) {",
|
|
1131
|
+
" var portal = getTagPortal();",
|
|
1132
|
+
" if(!portal) return;",
|
|
1133
|
+
" var isOpen = activeTagDropdown && (activeTagDropdown.td === td);",
|
|
1134
|
+
" closeDropdowns();",
|
|
1135
|
+
" if(isOpen) return;",
|
|
1136
|
+
// Populate checkbox state from the cell's data-tags attribute, then toggle the dropdown with the shared portal positioning.
|
|
1137
|
+
" var currentTags = (td.getAttribute('data-tags') || '').split(',').filter(function(t) { return t.length > 0; });",
|
|
1138
|
+
" var tagSet = {};",
|
|
1139
|
+
" for(var i = 0; i < currentTags.length; i++) tagSet[currentTags[i]] = true;",
|
|
1140
|
+
" var checkboxes = portal.querySelectorAll('.inline-tag-checkbox');",
|
|
1141
|
+
" for(var j = 0; j < checkboxes.length; j++) {",
|
|
1142
|
+
" checkboxes[j].checked = !!tagSet[checkboxes[j].getAttribute('data-tag')];",
|
|
1143
|
+
" }",
|
|
1144
|
+
" toggleDropdown(td, { menu: portal });",
|
|
1145
|
+
" activeTagDropdown = { key: td.getAttribute('data-key'), original: currentTags.sort().join(','), td: td };",
|
|
1062
1146
|
" };",
|
|
1063
|
-
//
|
|
1064
|
-
" function
|
|
1065
|
-
" var
|
|
1066
|
-
" var
|
|
1067
|
-
" var
|
|
1068
|
-
" var
|
|
1069
|
-
"
|
|
1070
|
-
"
|
|
1071
|
-
|
|
1072
|
-
"
|
|
1073
|
-
"
|
|
1074
|
-
"
|
|
1075
|
-
"
|
|
1076
|
-
"
|
|
1077
|
-
"
|
|
1078
|
-
"
|
|
1079
|
-
"
|
|
1080
|
-
"
|
|
1147
|
+
// Collect checked tag checkboxes into the hidden tags input for form submission. Called when any tag checkbox changes in the channel edit form.
|
|
1148
|
+
" window.updateTagsHidden = function(formRow) {",
|
|
1149
|
+
" var checkboxes = formRow.querySelectorAll('.tag-checkbox:checked');",
|
|
1150
|
+
" var tags = [];",
|
|
1151
|
+
" for(var i = 0; i < checkboxes.length; i++) tags.push(checkboxes[i].getAttribute('data-tag'));",
|
|
1152
|
+
" var hidden = formRow.querySelector('input[type=\"hidden\"][name=\"tags\"]');",
|
|
1153
|
+
" if(hidden) hidden.value = tags.sort().join(', ');",
|
|
1154
|
+
" };",
|
|
1155
|
+
// Revert a channel override back to predefined defaults.
|
|
1156
|
+
" window.revertChannel = async function(key) {",
|
|
1157
|
+
" if(!confirm('Revert channel ' + key + ' to predefined defaults?')) return;",
|
|
1158
|
+
" try {",
|
|
1159
|
+
" var res = await fetch('/config/channels', {",
|
|
1160
|
+
" method: 'POST',",
|
|
1161
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1162
|
+
" body: JSON.stringify({ action: 'revert', key: key })",
|
|
1163
|
+
" });",
|
|
1164
|
+
" var d = await res.json();",
|
|
1165
|
+
" if(res.ok && d.success) {",
|
|
1166
|
+
" showToast(d.message, 'success');",
|
|
1167
|
+
" if(d.patch) { applyChannelPatch(d.patch); }",
|
|
1168
|
+
" } else {",
|
|
1169
|
+
" showToast(d.message || 'Failed to revert channel.', 'error');",
|
|
1170
|
+
" }",
|
|
1171
|
+
" } catch(err) {",
|
|
1172
|
+
" showToast('Failed to revert channel: ' + err.message, 'error');",
|
|
1173
|
+
" }",
|
|
1081
1174
|
" };",
|
|
1082
1175
|
// Save the provider filter to the server and update the UI on success.
|
|
1083
|
-
" function saveProviderFilter(enabledTags) {",
|
|
1084
|
-
"
|
|
1085
|
-
"
|
|
1086
|
-
"
|
|
1087
|
-
"
|
|
1088
|
-
"
|
|
1089
|
-
"
|
|
1090
|
-
"
|
|
1176
|
+
" async function saveProviderFilter(enabledTags) {",
|
|
1177
|
+
" try {",
|
|
1178
|
+
" var r = await fetch('/config/provider-filter', {",
|
|
1179
|
+
" method: 'POST',",
|
|
1180
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1181
|
+
" body: JSON.stringify({ enabledProviders: enabledTags })",
|
|
1182
|
+
" });",
|
|
1183
|
+
" var result = await r.json();",
|
|
1091
1184
|
" if (result.success) {",
|
|
1092
1185
|
" updateProviderChips(enabledTags);",
|
|
1093
1186
|
" filterChannelRows(enabledTags);",
|
|
1094
1187
|
" updateBulkAssignOptions(enabledTags);",
|
|
1095
1188
|
" updateProviderFilterButton(enabledTags);",
|
|
1096
|
-
"
|
|
1189
|
+
" if(result.patch) { applyChannelPatch(result.patch); }",
|
|
1097
1190
|
" showToast('Provider filter updated.' + PLAYLIST_HINT, 'success');",
|
|
1098
1191
|
" }",
|
|
1099
|
-
" })",
|
|
1100
|
-
"
|
|
1192
|
+
" } catch(err) {",
|
|
1193
|
+
" console.error('Provider filter error:', err);",
|
|
1194
|
+
" showToast('Failed to update filter: ' + err.message, 'error');",
|
|
1195
|
+
" }",
|
|
1101
1196
|
" };",
|
|
1102
1197
|
// Provider filter: toggle a provider tag on/off.
|
|
1103
1198
|
" window.toggleProviderTag = function(checkbox) {",
|
|
@@ -1132,11 +1227,14 @@ export function generateConfigSubtabScript() {
|
|
|
1132
1227
|
" }",
|
|
1133
1228
|
" saveProviderFilter(enabledTags);",
|
|
1134
1229
|
" };",
|
|
1135
|
-
// Update the provider filter button text.
|
|
1230
|
+
// Update the provider filter button text. The filter icon SVG is inlined here because this runs client-side where the server's icon module is not available.
|
|
1231
|
+
" var filterIcon = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
|
|
1232
|
+
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1 2h14l-5 6v5l-4 2V8z\"/></svg>';",
|
|
1136
1233
|
" function updateProviderFilterButton(enabledTags) {",
|
|
1137
1234
|
" var btn = document.getElementById('provider-filter-btn');",
|
|
1138
|
-
" if
|
|
1139
|
-
"
|
|
1235
|
+
" if(!btn) return;",
|
|
1236
|
+
" var label = (enabledTags.length > 0) ? 'Filtered' : 'All Providers';",
|
|
1237
|
+
" btn.innerHTML = filterIcon + ' ' + label + ' ▾';",
|
|
1140
1238
|
" };",
|
|
1141
1239
|
// Rebuild the provider chips from the enabled tags.
|
|
1142
1240
|
" function updateProviderChips(enabledTags) {",
|
|
@@ -1149,17 +1247,30 @@ export function generateConfigSubtabScript() {
|
|
|
1149
1247
|
" var tag = enabledTags[i];",
|
|
1150
1248
|
" if (tag === 'direct') continue;",
|
|
1151
1249
|
" var label = tag;",
|
|
1250
|
+
" var chipDomain = '';",
|
|
1251
|
+
" var chipIconUrl = '';",
|
|
1152
1252
|
" if (menu) {",
|
|
1153
1253
|
" var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
|
|
1154
|
-
" if (cb && cb.parentElement)
|
|
1254
|
+
" if (cb && cb.parentElement) {",
|
|
1255
|
+
" label = cb.parentElement.textContent.trim();",
|
|
1256
|
+
" var pd = cb.parentElement.querySelector('.provider-display');",
|
|
1257
|
+
" if (pd) {",
|
|
1258
|
+
" chipDomain = pd.getAttribute('data-domain') || '';",
|
|
1259
|
+
" chipIconUrl = pd.getAttribute('data-icon-url') || '';",
|
|
1260
|
+
" }",
|
|
1261
|
+
" }",
|
|
1155
1262
|
" }",
|
|
1156
1263
|
" var chip = document.createElement('span');",
|
|
1157
1264
|
" chip.className = 'provider-chip';",
|
|
1158
1265
|
" chip.setAttribute('data-tag', tag);",
|
|
1159
|
-
"
|
|
1266
|
+
" var domainAttr = chipDomain ? ' data-domain=\"' + chipDomain + '\"' : '';",
|
|
1267
|
+
" var iconAttr = chipIconUrl ? ' data-icon-url=\"' + chipIconUrl + '\"' : '';",
|
|
1268
|
+
" chip.innerHTML = '<span class=\"provider-display\"' + domainAttr + iconAttr + ' data-sm>' + label + '</span>' +",
|
|
1269
|
+
" '<button type=\"button\" class=\"chip-close\" aria-label=\"Remove ' + label +",
|
|
1160
1270
|
" '\" onclick=\"removeProviderChip(\\'' + tag + '\\')\">\\u00d7</button>';",
|
|
1161
1271
|
" container.appendChild(chip);",
|
|
1162
1272
|
" }",
|
|
1273
|
+
" processProviderDisplays();",
|
|
1163
1274
|
" };",
|
|
1164
1275
|
// Filter channel rows based on enabled provider tags. Toggles the channel-unavailable class on each row and updates Source column content. Filtered-out options
|
|
1165
1276
|
// 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
|
|
@@ -1222,25 +1333,28 @@ export function generateConfigSubtabScript() {
|
|
|
1222
1333
|
" if (allChecked) { enabledTags = []; }",
|
|
1223
1334
|
" if (enabledTags.length > 0) { filterChannelRows(enabledTags); }",
|
|
1224
1335
|
" }",
|
|
1225
|
-
// Update bulk assign
|
|
1336
|
+
// 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.
|
|
1226
1337
|
" function updateBulkAssignOptions(enabledTags) {",
|
|
1227
|
-
" var
|
|
1228
|
-
"
|
|
1229
|
-
"
|
|
1338
|
+
" var select = document.getElementById('bulk-assign-select');",
|
|
1339
|
+
" if(!select) return;",
|
|
1340
|
+
" var options = select.querySelectorAll('option[data-provider-tag]');",
|
|
1341
|
+
" for(var i = 0; i < options.length; i++) {",
|
|
1342
|
+
" var tag = options[i].getAttribute('data-provider-tag');",
|
|
1230
1343
|
" var show = (enabledTags.length === 0) || (tag === 'direct') || (enabledTags.indexOf(tag) !== -1);",
|
|
1231
|
-
"
|
|
1344
|
+
" if(show) options[i].removeAttribute('hidden');",
|
|
1345
|
+
" else options[i].setAttribute('hidden', '');",
|
|
1232
1346
|
" }",
|
|
1233
1347
|
" };",
|
|
1234
1348
|
// Bulk assign all channels to a specific provider. Updates all dropdowns and profile cells in-place.
|
|
1235
|
-
" window.bulkAssignProvider = function(providerTag) {",
|
|
1349
|
+
" window.bulkAssignProvider = async function(providerTag) {",
|
|
1236
1350
|
" if (!providerTag) return;",
|
|
1237
|
-
"
|
|
1238
|
-
"
|
|
1239
|
-
"
|
|
1240
|
-
"
|
|
1241
|
-
"
|
|
1242
|
-
"
|
|
1243
|
-
"
|
|
1351
|
+
" try {",
|
|
1352
|
+
" var r = await fetch('/config/provider-bulk-assign', {",
|
|
1353
|
+
" method: 'POST',",
|
|
1354
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1355
|
+
" body: JSON.stringify({ provider: providerTag })",
|
|
1356
|
+
" });",
|
|
1357
|
+
" var result = await r.json();",
|
|
1244
1358
|
" if (result.success) {",
|
|
1245
1359
|
" var msg = result.affected + ' of ' + result.total + ' channel(s) updated.';",
|
|
1246
1360
|
" var undoAction = null;",
|
|
@@ -1258,18 +1372,19 @@ export function generateConfigSubtabScript() {
|
|
|
1258
1372
|
" } else {",
|
|
1259
1373
|
" showToast(result.error || 'Failed to assign.', 'error');",
|
|
1260
1374
|
" }",
|
|
1261
|
-
" })",
|
|
1262
|
-
"
|
|
1375
|
+
" } catch(err) {",
|
|
1376
|
+
" showToast('Failed to assign: ' + err.message, 'error');",
|
|
1377
|
+
" }",
|
|
1263
1378
|
" };",
|
|
1264
1379
|
// Restore previous provider selections (undo bulk assign). Sends the previousSelections map to the server and updates the UI with the restored selections.
|
|
1265
|
-
" function restoreBulkProviders(prevSelections) {",
|
|
1266
|
-
"
|
|
1267
|
-
"
|
|
1268
|
-
"
|
|
1269
|
-
"
|
|
1270
|
-
"
|
|
1271
|
-
"
|
|
1272
|
-
"
|
|
1380
|
+
" async function restoreBulkProviders(prevSelections) {",
|
|
1381
|
+
" try {",
|
|
1382
|
+
" var r = await fetch('/config/provider-bulk-restore', {",
|
|
1383
|
+
" method: 'POST',",
|
|
1384
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1385
|
+
" body: JSON.stringify({ selections: prevSelections })",
|
|
1386
|
+
" });",
|
|
1387
|
+
" var result = await r.json();",
|
|
1273
1388
|
" if (result.success) {",
|
|
1274
1389
|
" showToast('Bulk assign reverted.', 'success');",
|
|
1275
1390
|
" if (result.selections) {",
|
|
@@ -1281,69 +1396,20 @@ export function generateConfigSubtabScript() {
|
|
|
1281
1396
|
" } else {",
|
|
1282
1397
|
" showToast(result.error || 'Failed to revert.', 'error');",
|
|
1283
1398
|
" }",
|
|
1284
|
-
" })",
|
|
1285
|
-
"
|
|
1399
|
+
" } catch(err) {",
|
|
1400
|
+
" showToast('Failed to revert: ' + err.message, 'error');",
|
|
1401
|
+
" }",
|
|
1286
1402
|
" }",
|
|
1287
|
-
// Close all open dropdown menus and remove the scroll and resize listeners.
|
|
1288
|
-
" function closeDropdowns() {",
|
|
1289
|
-
" var menus = document.querySelectorAll('.dropdown-menu.show');",
|
|
1290
|
-
" for (var i = 0; i < menus.length; i++) menus[i].classList.remove('show');",
|
|
1291
|
-
" window.removeEventListener('scroll', closeDropdowns, true);",
|
|
1292
|
-
" window.removeEventListener('resize', closeDropdowns);",
|
|
1293
|
-
" };",
|
|
1294
|
-
" window.closeDropdowns = closeDropdowns;",
|
|
1295
|
-
// Toggle a dropdown menu open or closed. On first call for a given button, the menu is detached from its .dropdown parent and portaled to <body> with
|
|
1296
|
-
// position: fixed. This allows the menu to escape overflow: auto containers like the channel table wrapper. On every open, the menu is positioned relative
|
|
1297
|
-
// to the button's bounding rect, with edge-of-viewport clamping and above-button flip when it would extend below the viewport.
|
|
1298
|
-
" window.toggleDropdown = function(btn) {",
|
|
1299
|
-
" var menu = btn._portalMenu;",
|
|
1300
|
-
" var isOpen = menu && menu.classList.contains('show');",
|
|
1301
|
-
" closeDropdowns();",
|
|
1302
|
-
" if (isOpen) return;",
|
|
1303
|
-
" if (!menu) {",
|
|
1304
|
-
" menu = btn.nextElementSibling;",
|
|
1305
|
-
" if (!menu) return;",
|
|
1306
|
-
" btn._portalMenu = menu;",
|
|
1307
|
-
" document.body.appendChild(menu);",
|
|
1308
|
-
" menu.style.position = 'fixed';",
|
|
1309
|
-
" menu.style.marginTop = '0';",
|
|
1310
|
-
" }",
|
|
1311
|
-
" menu.classList.add('show');",
|
|
1312
|
-
" var rect = btn.getBoundingClientRect();",
|
|
1313
|
-
" var top = rect.bottom + 2;",
|
|
1314
|
-
" var left = rect.left;",
|
|
1315
|
-
" if (left + menu.offsetWidth > window.innerWidth - 4) left = rect.right - menu.offsetWidth;",
|
|
1316
|
-
" if (left < 4) left = 4;",
|
|
1317
|
-
" if (top + menu.offsetHeight > window.innerHeight - 4) top = rect.top - menu.offsetHeight - 2;",
|
|
1318
|
-
" menu.style.top = top + 'px';",
|
|
1319
|
-
" menu.style.left = left + 'px';",
|
|
1320
|
-
" window.addEventListener('scroll', closeDropdowns, true);",
|
|
1321
|
-
" window.addEventListener('resize', closeDropdowns);",
|
|
1322
|
-
" };",
|
|
1323
1403
|
// Close dropdowns when clicking outside. After portaling, menus live on <body> outside .dropdown containers, so we also check .dropdown-menu to prevent
|
|
1324
1404
|
// clicks on menu items (like column picker checkboxes) from triggering close.
|
|
1325
1405
|
" document.addEventListener('click', function(e) {",
|
|
1326
1406
|
" if (!e.target.closest('.dropdown') && !e.target.closest('.dropdown-menu')) closeDropdowns();",
|
|
1327
1407
|
" });",
|
|
1328
|
-
// Copy a stream URL to the clipboard
|
|
1329
|
-
// available (secure contexts), falling back to execCommand for plain HTTP access via IP address.
|
|
1408
|
+
// Copy a stream URL to the clipboard. The type parameter selects HLS or MPEG-TS format. Delegates to the shared copyToClipboard utility.
|
|
1330
1409
|
" window.copyStreamUrl = function(type, key) {",
|
|
1331
1410
|
" closeDropdowns();",
|
|
1332
1411
|
" var url = (type === 'hls') ? (location.origin + '/hls/' + key + '/stream.m3u8') : (location.origin + '/stream/' + key);",
|
|
1333
|
-
"
|
|
1334
|
-
" navigator.clipboard.writeText(url).then(function() { showToast('Stream URL copied to clipboard.', 'success'); })",
|
|
1335
|
-
" .catch(function() { showToast('Failed to copy URL.', 'error'); });",
|
|
1336
|
-
" } else {",
|
|
1337
|
-
" var ta = document.createElement('textarea');",
|
|
1338
|
-
" ta.value = url;",
|
|
1339
|
-
" ta.style.position = 'fixed';",
|
|
1340
|
-
" ta.style.opacity = '0';",
|
|
1341
|
-
" document.body.appendChild(ta);",
|
|
1342
|
-
" ta.select();",
|
|
1343
|
-
" try { document.execCommand('copy'); showToast('Stream URL copied to clipboard.', 'success'); }",
|
|
1344
|
-
" catch(e) { showToast('Failed to copy URL.', 'error'); }",
|
|
1345
|
-
" document.body.removeChild(ta);",
|
|
1346
|
-
" }",
|
|
1412
|
+
" copyToClipboard(url, 'Stream URL copied to clipboard.');",
|
|
1347
1413
|
" };",
|
|
1348
1414
|
// Toggle visibility of disabled predefined channels and persist preference.
|
|
1349
1415
|
" window.toggleDisabledVisibility = function() {",
|
|
@@ -1375,18 +1441,36 @@ export function generateConfigSubtabScript() {
|
|
|
1375
1441
|
" if (cbs[i].checked) cols.push(cbs[i].getAttribute('data-col-field'));",
|
|
1376
1442
|
" }",
|
|
1377
1443
|
" }",
|
|
1444
|
+
// Show or hide Quick Action sections gated on column visibility.
|
|
1445
|
+
" var colField = checkbox.getAttribute('data-col-field');",
|
|
1446
|
+
" var qaMap = { channelNumber: 'quick-action-autonumber', hdhrEnabled: 'quick-action-hdhr' };",
|
|
1447
|
+
" var qaId = qaMap[colField];",
|
|
1448
|
+
" if(qaId) {",
|
|
1449
|
+
" var qaEl = document.getElementById(qaId);",
|
|
1450
|
+
" if(qaEl) qaEl.style.display = checkbox.checked ? '' : 'none';",
|
|
1451
|
+
" var divider = document.getElementById('quick-action-divider');",
|
|
1452
|
+
" var anyVisible = document.getElementById('quick-action-autonumber')?.style.display !== 'none' ||",
|
|
1453
|
+
" document.getElementById('quick-action-hdhr')?.style.display !== 'none';",
|
|
1454
|
+
" if(divider) divider.style.display = anyVisible ? '' : 'none';",
|
|
1455
|
+
" }",
|
|
1378
1456
|
" fetch('/config/channels/display-prefs', {",
|
|
1379
1457
|
" method: 'POST', headers: { 'Content-Type': 'application/json' },",
|
|
1380
1458
|
" body: JSON.stringify({ visibleColumns: cols })",
|
|
1381
1459
|
" }).catch(function() {});",
|
|
1382
1460
|
" };",
|
|
1383
|
-
// Read the server-stamped sort value from the data-sort-value attribute.
|
|
1461
|
+
// Read the server-stamped sort value from the data-sort-value attribute. The column index is derived from the header row's data-sort-field attributes rather
|
|
1462
|
+
// than a hardcoded map — new columns are automatically supported without updating client-side code.
|
|
1463
|
+
" var colIndexCache = null;",
|
|
1384
1464
|
" function getSortValue(row, field) {",
|
|
1385
|
-
"
|
|
1386
|
-
"
|
|
1387
|
-
"
|
|
1465
|
+
" if(!colIndexCache) {",
|
|
1466
|
+
" colIndexCache = {};",
|
|
1467
|
+
" var ths = document.querySelectorAll('.channel-table th[data-sort-field]');",
|
|
1468
|
+
" for(var i = 0; i < ths.length; i++) { colIndexCache[ths[i].getAttribute('data-sort-field')] = i; }",
|
|
1469
|
+
" }",
|
|
1470
|
+
" var idx = colIndexCache[field];",
|
|
1471
|
+
" if(idx === undefined) return '';",
|
|
1388
1472
|
" var cell = row.children[idx];",
|
|
1389
|
-
" if
|
|
1473
|
+
" if(!cell) return '';",
|
|
1390
1474
|
" return cell.getAttribute('data-sort-value') || '';",
|
|
1391
1475
|
" }",
|
|
1392
1476
|
// Sort the channel table by the specified field. Toggles direction if the same field is clicked again.
|
|
@@ -1421,14 +1505,16 @@ export function generateConfigSubtabScript() {
|
|
|
1421
1505
|
" tbody.appendChild(pairs[j].displayRow);",
|
|
1422
1506
|
" if (pairs[j].editRow) tbody.appendChild(pairs[j].editRow);",
|
|
1423
1507
|
" }",
|
|
1424
|
-
// Update header indicators.
|
|
1508
|
+
// Update header sort indicators. Targets .sort-label within each <th> so other children (like the tag filter dropdown) are untouched.
|
|
1425
1509
|
" var headers = table.querySelectorAll('th.sortable');",
|
|
1426
|
-
" for
|
|
1510
|
+
" for(var h = 0; h < headers.length; h++) {",
|
|
1427
1511
|
" var th = headers[h];",
|
|
1428
1512
|
" var hField = th.getAttribute('data-sort-field');",
|
|
1429
|
-
" var
|
|
1430
|
-
" if
|
|
1431
|
-
"
|
|
1513
|
+
" var sortLabel = th.querySelector('.sort-label');",
|
|
1514
|
+
" if(!sortLabel) continue;",
|
|
1515
|
+
" var label = sortLabel.textContent.replace(/[\\u25B2\\u25BC]/g, '').trim();",
|
|
1516
|
+
" if(hField === field) { sortLabel.innerHTML = label + (dir === 'asc' ? ' ▲' : ' ▼'); }",
|
|
1517
|
+
" else { sortLabel.textContent = label; }",
|
|
1432
1518
|
" }",
|
|
1433
1519
|
// Persist sort preference.
|
|
1434
1520
|
" fetch('/config/channels/display-prefs', {",
|
|
@@ -1504,9 +1590,10 @@ export function generateConfigSubtabScript() {
|
|
|
1504
1590
|
" }",
|
|
1505
1591
|
" if (!slug) return;",
|
|
1506
1592
|
" var snapshotUrl = urlInput.value;",
|
|
1507
|
-
"
|
|
1508
|
-
"
|
|
1509
|
-
"
|
|
1593
|
+
" (async function() {",
|
|
1594
|
+
" try {",
|
|
1595
|
+
" var r = await fetch('/providers/' + slug + '/channels');",
|
|
1596
|
+
" var channels = r.ok ? await r.json() : null;",
|
|
1510
1597
|
" if (!channels || urlInput.value !== snapshotUrl) return;",
|
|
1511
1598
|
" var existing = {};",
|
|
1512
1599
|
" var opts = datalist.querySelectorAll('option');",
|
|
@@ -1519,8 +1606,8 @@ export function generateConfigSubtabScript() {
|
|
|
1519
1606
|
" datalist.appendChild(o);",
|
|
1520
1607
|
" }",
|
|
1521
1608
|
" }",
|
|
1522
|
-
" })",
|
|
1523
|
-
"
|
|
1609
|
+
" } catch(err) {}",
|
|
1610
|
+
" })();",
|
|
1524
1611
|
" };",
|
|
1525
1612
|
// Initialize disabled channel toggle, provider filter, URL input listeners, and pending toast on page load.
|
|
1526
1613
|
" (function() {",
|
|
@@ -1536,17 +1623,24 @@ export function generateConfigSubtabScript() {
|
|
|
1536
1623
|
" if (table) table.classList.remove('hide-disabled');",
|
|
1537
1624
|
" if (checkbox) checkbox.checked = true;",
|
|
1538
1625
|
" }",
|
|
1539
|
-
// Set indeterminate state on scope toggle checkboxes. HTML has no indeterminate attribute — it must be set via JS on page load.
|
|
1540
|
-
|
|
1541
|
-
"
|
|
1542
|
-
"
|
|
1543
|
-
" var
|
|
1544
|
-
" var
|
|
1545
|
-
"
|
|
1546
|
-
"
|
|
1547
|
-
"
|
|
1626
|
+
// 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
|
|
1627
|
+
// are read from data attributes on the quick-action-count span, not from the display text — this decouples checkbox state from the text format.
|
|
1628
|
+
" var scopeSpans = document.querySelectorAll('.quick-action-count[data-enabled][data-total]');",
|
|
1629
|
+
" for(var si = 0; si < scopeSpans.length; si++) {",
|
|
1630
|
+
" var span = scopeSpans[si];",
|
|
1631
|
+
" var enabled = parseInt(span.getAttribute('data-enabled'), 10);",
|
|
1632
|
+
" var total = parseInt(span.getAttribute('data-total'), 10);",
|
|
1633
|
+
" if((total > 0) && (enabled > 0) && (enabled < total)) {",
|
|
1634
|
+
" var cb = document.querySelector('.scope-toggle[data-scope=\"' + span.getAttribute('data-scope') + '\"]');",
|
|
1635
|
+
" if(cb) cb.indeterminate = true;",
|
|
1548
1636
|
" }",
|
|
1549
1637
|
" }",
|
|
1638
|
+
// Set indeterminate state on the HDHR bulk toggle if data-indeterminate is present.
|
|
1639
|
+
" var hdhrToggle = document.getElementById('hdhr-bulk-toggle');",
|
|
1640
|
+
" if(hdhrToggle && hdhrToggle.getAttribute('data-indeterminate') === 'true') hdhrToggle.indeterminate = true;",
|
|
1641
|
+
// 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.
|
|
1642
|
+
" var tagToggles = document.querySelectorAll('.tag-bulk-toggle[data-indeterminate=\"true\"]');",
|
|
1643
|
+
" for(var ti = 0; ti < tagToggles.length; ti++) tagToggles[ti].indeterminate = true;",
|
|
1550
1644
|
// 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
|
|
1551
1645
|
// option elements. This initial pass removes those options from the DOM to enforce the filter.
|
|
1552
1646
|
" var menu = document.querySelector('.provider-dropdown-menu');",
|
|
@@ -1562,21 +1656,51 @@ export function generateConfigSubtabScript() {
|
|
|
1562
1656
|
" }",
|
|
1563
1657
|
" var addUrlInput = document.getElementById('add-url');",
|
|
1564
1658
|
" if (addUrlInput) {",
|
|
1565
|
-
" addUrlInput.addEventListener('input', function() {
|
|
1659
|
+
" addUrlInput.addEventListener('input', function() {",
|
|
1660
|
+
" updateSelectorSuggestions('add-url', 'add-selectorList');",
|
|
1661
|
+
// 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
|
|
1662
|
+
// 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.
|
|
1663
|
+
" var activePill = document.querySelector('.provider-pill.active');",
|
|
1664
|
+
" if(activePill && activePill.getAttribute('data-url') !== addUrlInput.value) {",
|
|
1665
|
+
" activePill.classList.remove('active');",
|
|
1666
|
+
" var profileSelect = document.getElementById('add-profile');",
|
|
1667
|
+
" if(profileSelect) profileSelect.disabled = false;",
|
|
1668
|
+
" }",
|
|
1669
|
+
" });",
|
|
1566
1670
|
" updateSelectorSuggestions('add-url', 'add-selectorList');",
|
|
1567
1671
|
" }",
|
|
1672
|
+
// Auto-fill station ID when the channelSelector matches a known entry with a stationId. Reads from the same channelSelectorsByDomain data used by the
|
|
1673
|
+
// datalist. The data-auto attribute tracks whether the current value was auto-filled — user edits clear it, preventing overwrites of intentional input.
|
|
1674
|
+
" var addSelectorInput = document.getElementById('add-channelSelector');",
|
|
1675
|
+
" var addStationInput = document.getElementById('add-stationId');",
|
|
1676
|
+
" if (addSelectorInput && addStationInput) {",
|
|
1677
|
+
" addStationInput.addEventListener('input', function() { addStationInput.removeAttribute('data-auto'); });",
|
|
1678
|
+
" addSelectorInput.addEventListener('input', function() {",
|
|
1679
|
+
" if (addStationInput.value && !addStationInput.hasAttribute('data-auto')) return;",
|
|
1680
|
+
" var urlInput = document.getElementById('add-url');",
|
|
1681
|
+
" if (!urlInput || !urlInput.value) return;",
|
|
1682
|
+
" try {",
|
|
1683
|
+
" var hostname = new URL(urlInput.value).hostname;",
|
|
1684
|
+
" var entries = (typeof channelSelectorsByDomain !== 'undefined') ? channelSelectorsByDomain[hostname] : null;",
|
|
1685
|
+
" if (!entries) return;",
|
|
1686
|
+
" var match = entries.find(function(e) { return e.value === addSelectorInput.value; });",
|
|
1687
|
+
" if (match && match.stationId) { addStationInput.value = match.stationId; addStationInput.setAttribute('data-auto', '1'); }",
|
|
1688
|
+
" else if (addStationInput.hasAttribute('data-auto')) { addStationInput.value = ''; addStationInput.removeAttribute('data-auto'); }",
|
|
1689
|
+
" } catch(e) {}",
|
|
1690
|
+
" });",
|
|
1691
|
+
" }",
|
|
1568
1692
|
" })();",
|
|
1569
1693
|
// Login modal state tracking.
|
|
1570
1694
|
" var loginStatusInterval = null;",
|
|
1571
1695
|
// Start login mode for a channel. Opens browser window and shows modal.
|
|
1572
|
-
" window.startChannelLogin = function(channel) {",
|
|
1573
|
-
"
|
|
1574
|
-
"
|
|
1575
|
-
"
|
|
1576
|
-
"
|
|
1577
|
-
"
|
|
1578
|
-
"
|
|
1579
|
-
"
|
|
1696
|
+
" window.startChannelLogin = async function(channel) {",
|
|
1697
|
+
" try {",
|
|
1698
|
+
" var res = await fetch('/auth/login', {",
|
|
1699
|
+
" method: 'POST',",
|
|
1700
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1701
|
+
" body: JSON.stringify({ channel: channel })",
|
|
1702
|
+
" });",
|
|
1703
|
+
" var result = await res.json();",
|
|
1580
1704
|
" if (result.success) {",
|
|
1581
1705
|
" showToast('Browser window opened. Complete authentication.', 'info');",
|
|
1582
1706
|
" showLoginModal();",
|
|
@@ -1584,18 +1708,21 @@ export function generateConfigSubtabScript() {
|
|
|
1584
1708
|
" } else {",
|
|
1585
1709
|
" showToast(result.error || 'Failed to start login.', 'error');",
|
|
1586
1710
|
" }",
|
|
1587
|
-
" })",
|
|
1588
|
-
"
|
|
1711
|
+
" } catch(err) {",
|
|
1712
|
+
" showToast('Failed to start login: ' + err.message, 'error');",
|
|
1713
|
+
" }",
|
|
1589
1714
|
" };",
|
|
1590
1715
|
// End login mode. Closes browser tab and hides modal.
|
|
1591
|
-
" window.endLogin = function() {",
|
|
1716
|
+
" window.endLogin = async function() {",
|
|
1592
1717
|
" stopLoginStatusPolling();",
|
|
1593
|
-
"
|
|
1594
|
-
"
|
|
1718
|
+
" try {",
|
|
1719
|
+
" await fetch('/auth/done', { method: 'POST' });",
|
|
1595
1720
|
" hideLoginModal();",
|
|
1596
1721
|
" showToast('Authentication complete.', 'success');",
|
|
1597
|
-
" })",
|
|
1598
|
-
"
|
|
1722
|
+
" } catch(err) {",
|
|
1723
|
+
" showToast('Error ending login: ' + err.message, 'error');",
|
|
1724
|
+
" hideLoginModal();",
|
|
1725
|
+
" }",
|
|
1599
1726
|
" };",
|
|
1600
1727
|
// Show the login modal.
|
|
1601
1728
|
" function showLoginModal() {",
|
|
@@ -1610,17 +1737,16 @@ export function generateConfigSubtabScript() {
|
|
|
1610
1737
|
// Start polling login status to detect when tab is closed externally.
|
|
1611
1738
|
" function startLoginStatusPolling() {",
|
|
1612
1739
|
" stopLoginStatusPolling();",
|
|
1613
|
-
" loginStatusInterval = setInterval(function() {",
|
|
1614
|
-
"
|
|
1615
|
-
"
|
|
1616
|
-
"
|
|
1740
|
+
" loginStatusInterval = setInterval(async function() {",
|
|
1741
|
+
" try {",
|
|
1742
|
+
" var res = await fetch('/auth/status');",
|
|
1743
|
+
" var status = await res.json();",
|
|
1617
1744
|
" if (!status.active) {",
|
|
1618
1745
|
" stopLoginStatusPolling();",
|
|
1619
1746
|
" hideLoginModal();",
|
|
1620
1747
|
" showToast('Login session ended.', 'info');",
|
|
1621
1748
|
" }",
|
|
1622
|
-
" })",
|
|
1623
|
-
" .catch(function() { });",
|
|
1749
|
+
" } catch(err) {}",
|
|
1624
1750
|
" }, 1000);",
|
|
1625
1751
|
" }",
|
|
1626
1752
|
// Stop polling login status.
|