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.
- package/README.md +6 -6
- package/dist/app.js +24 -13
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +4 -6
- package/dist/browser/cdp.js +13 -17
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/index.js +3 -3
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.js +11 -8
- package/dist/browser/login.js.map +1 -1
- package/dist/browser/precaching.d.ts +1 -1
- package/dist/browser/precaching.js +23 -23
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/directv.js +4 -7
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +1 -1
- 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 +3 -3
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.js +2 -2
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.js +438 -252
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +13 -18
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +11 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/persistence.d.ts +59 -0
- package/dist/config/persistence.js +127 -0
- package/dist/config/persistence.js.map +1 -0
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/servicePacks.d.ts +46 -0
- package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
- package/dist/config/servicePacks.js.map +1 -0
- package/dist/config/services.d.ts +202 -0
- package/dist/config/services.js +758 -0
- package/dist/config/services.js.map +1 -0
- package/dist/config/sites.js +61 -61
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +89 -27
- package/dist/config/userChannels.js +400 -184
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +13 -9
- package/dist/config/userConfig.js +78 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +154 -123
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -7
- 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/hdhr/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.d.ts +2 -2
- package/dist/native/index.js +17 -27
- package/dist/native/index.js.map +1 -1
- package/dist/native/intercept.js +86 -89
- package/dist/native/intercept.js.map +1 -1
- package/dist/native/probe.js +2 -2
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -2
- package/dist/native/proxy.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +29 -51
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/auth.js +3 -3
- package/dist/routes/auth.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 +0 -8
- package/dist/routes/components.js +0 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +1 -1
- 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 +748 -420
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +21 -6
- package/dist/routes/config/channels/table.js +456 -193
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +2 -2
- package/dist/routes/config/index.js +3 -3
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/{providers.js → services.js} +47 -42
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +88 -49
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +5 -7
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/health.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.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +4 -4
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +98 -45
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +94 -55
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +620 -478
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +884 -964
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +468 -188
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +252 -207
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +57 -8
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/services.d.ts +6 -0
- package/dist/routes/{providers.js → services.js} +43 -43
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +3 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +8 -43
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.d.ts +1 -1
- package/dist/service/generators.js +143 -41
- package/dist/service/generators.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 -2
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +4 -4
- package/dist/streaming/hls.js +35 -29
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +3 -3
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/monitor.d.ts +2 -2
- package/dist/streaming/monitor.js +19 -13
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/preroll.d.ts +15 -14
- package/dist/streaming/preroll.js +32 -12
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.d.ts +2 -2
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +4 -4
- package/dist/streaming/setup.js +28 -34
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +8 -10
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -3
- package/dist/streaming/statusEmitter.js +3 -3
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +18 -11
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +9 -9
- package/dist/types/selection.d.ts +1 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +8 -16
- 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/chromeFetch.js +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.js +3 -3
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/fileLogger.js +4 -3
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +21 -4
- package/dist/utils/format.js +45 -7
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logger.js +17 -27
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/morganStream.js +2 -2
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/platform.js +3 -2
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/retry.js +2 -4
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/streamContext.d.ts +7 -0
- package/dist/utils/streamContext.js +10 -1
- package/dist/utils/streamContext.js.map +1 -1
- package/dist/utils/version.js +4 -4
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -7
- package/dist/config/providerPacks.d.ts +0 -46
- package/dist/config/providerPacks.js.map +0 -1
- package/dist/config/providers.d.ts +0 -175
- package/dist/config/providers.js +0 -678
- package/dist/config/providers.js.map +0 -1
- package/dist/routes/config/providers.js.map +0 -1
- package/dist/routes/providers.d.ts +0 -6
- package/dist/routes/providers.js.map +0 -1
- /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
|
-
"
|
|
29
|
+
" const isServiceMode = " + String(isService) + ";",
|
|
30
30
|
// Preset values for auto-filling bitrate and frame rate.
|
|
31
|
-
"
|
|
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
|
-
"
|
|
37
|
-
" if
|
|
38
|
-
" for
|
|
39
|
-
"
|
|
40
|
-
" if
|
|
41
|
-
" input.value =
|
|
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
|
-
"
|
|
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
|
-
"
|
|
64
|
+
" setPendingToast(message, type);",
|
|
51
65
|
" location.reload();",
|
|
52
66
|
" }",
|
|
53
67
|
// Interval handle for restart polling.
|
|
54
|
-
"
|
|
68
|
+
" let restartPollInterval = null;",
|
|
55
69
|
// Track whether a restart is pending (deferred due to active streams).
|
|
56
|
-
"
|
|
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
|
-
"
|
|
80
|
+
" const count = Object.keys(streamData).length;",
|
|
67
81
|
" document.getElementById('restart-stream-count').textContent = count;",
|
|
68
|
-
" if
|
|
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 =
|
|
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
|
|
96
|
+
" window.forceRestart = async () => {",
|
|
83
97
|
" pendingRestart = false;",
|
|
84
98
|
" document.getElementById('restart-dialog').style.display = 'none';",
|
|
85
99
|
" try {",
|
|
86
|
-
"
|
|
87
|
-
" if
|
|
100
|
+
" const res = await fetch('/config/restart-now', { method: 'POST' });",
|
|
101
|
+
" if(res.ok) {",
|
|
88
102
|
" waitForServerRestart();",
|
|
89
103
|
" } else {",
|
|
90
|
-
"
|
|
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
|
-
"
|
|
101
|
-
" if
|
|
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
|
-
"
|
|
111
|
-
"
|
|
124
|
+
" let attempts = 0;",
|
|
125
|
+
" const maxAttempts = 30;",
|
|
112
126
|
" showToast('Restarting server...', 'info', 0);",
|
|
113
|
-
" if
|
|
114
|
-
" restartPollInterval = setInterval(async
|
|
127
|
+
" if(restartPollInterval) { clearInterval(restartPollInterval); }",
|
|
128
|
+
" restartPollInterval = setInterval(async () => {",
|
|
115
129
|
" attempts++;",
|
|
116
130
|
" try {",
|
|
117
|
-
"
|
|
118
|
-
" if
|
|
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
|
|
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
|
-
"
|
|
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
|
|
140
|
-
"
|
|
141
|
-
" if
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
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
|
|
165
|
+
" if(upgradeBtn) upgradeBtn.style.display = 'none';",
|
|
152
166
|
" try {",
|
|
153
|
-
"
|
|
154
|
-
"
|
|
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
|
|
158
|
-
"
|
|
159
|
-
" for
|
|
160
|
-
" html += '<li>' + escapeHtmlText(
|
|
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
|
|
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 =
|
|
176
|
-
"
|
|
177
|
-
" if
|
|
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
|
|
194
|
+
" window.startUpgrade = async () => {",
|
|
181
195
|
" try {",
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
" if
|
|
196
|
+
" const infoRes = await fetch('/upgrade/info');",
|
|
197
|
+
" const info = await infoRes.json();",
|
|
198
|
+
" if(!info.upgradeable) {",
|
|
185
199
|
" closeChangelogModal();",
|
|
186
|
-
"
|
|
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
|
-
"
|
|
193
|
-
" if
|
|
194
|
-
" if
|
|
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
|
-
"
|
|
199
|
-
"
|
|
200
|
-
" if
|
|
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
|
|
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
|
|
213
|
-
"
|
|
214
|
-
" if
|
|
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
|
-
"
|
|
218
|
-
"
|
|
231
|
+
" const res = await fetch('/version/check', { method: 'POST' });",
|
|
232
|
+
" const data = await res.json();",
|
|
219
233
|
" btn.classList.remove('checking');",
|
|
220
|
-
" if
|
|
221
|
-
"
|
|
222
|
-
" if
|
|
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(
|
|
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(
|
|
247
|
+
" setTimeout(() => { btn.classList.remove('check-error'); }, 2000);",
|
|
234
248
|
" }",
|
|
235
249
|
" };",
|
|
236
250
|
// Clear all field error indicators.
|
|
237
251
|
" function clearFieldErrors() {",
|
|
238
|
-
"
|
|
239
|
-
" for
|
|
240
|
-
"
|
|
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
|
-
"
|
|
243
|
-
" for
|
|
244
|
-
"
|
|
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
|
|
250
|
-
"
|
|
251
|
-
"
|
|
252
|
-
" if
|
|
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
|
-
"
|
|
268
|
+
" const errorDiv = document.createElement('div');",
|
|
255
269
|
" errorDiv.className = 'form-error dynamic';",
|
|
256
|
-
" errorDiv.textContent =
|
|
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
|
|
277
|
+
" if(input.type === 'checkbox') {",
|
|
264
278
|
" input.checked = value === 'true';",
|
|
265
279
|
" } else {",
|
|
266
280
|
" input.value = value;",
|
|
267
281
|
" }",
|
|
268
|
-
" if
|
|
269
|
-
"
|
|
270
|
-
"
|
|
271
|
-
" if
|
|
272
|
-
"
|
|
273
|
-
" for
|
|
274
|
-
"
|
|
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
|
-
"
|
|
287
|
-
" if
|
|
288
|
-
"
|
|
289
|
-
" for
|
|
290
|
-
" if
|
|
291
|
-
"
|
|
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
|
-
"
|
|
307
|
+
" dependent.classList.add('depends-disabled');",
|
|
294
308
|
" }",
|
|
295
|
-
"
|
|
296
|
-
" for
|
|
297
|
-
"
|
|
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
|
-
"
|
|
304
|
-
"
|
|
305
|
-
"
|
|
306
|
-
" if
|
|
307
|
-
"
|
|
308
|
-
"
|
|
309
|
-
"
|
|
310
|
-
" if
|
|
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
|
|
313
|
-
"
|
|
314
|
-
" if
|
|
315
|
-
"
|
|
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
|
|
322
|
-
"
|
|
323
|
-
" if
|
|
324
|
-
"
|
|
325
|
-
"
|
|
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 = '↻';",
|
|
331
|
-
" btn.onclick =
|
|
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
|
|
338
|
-
" if
|
|
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 =
|
|
344
|
-
"
|
|
345
|
-
"
|
|
346
|
-
" if
|
|
347
|
-
"
|
|
348
|
-
" if
|
|
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 =
|
|
356
|
-
" if
|
|
357
|
-
"
|
|
358
|
-
" if
|
|
359
|
-
"
|
|
360
|
-
" for
|
|
361
|
-
"
|
|
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
|
-
"
|
|
368
|
-
" for
|
|
369
|
-
" if
|
|
370
|
-
" updateDependentFields(
|
|
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
|
-
//
|
|
376
|
-
|
|
377
|
-
"
|
|
378
|
-
"
|
|
379
|
-
"
|
|
380
|
-
"
|
|
381
|
-
"
|
|
382
|
-
"
|
|
383
|
-
"
|
|
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
|
-
"
|
|
391
|
-
"
|
|
392
|
-
"
|
|
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
|
-
"
|
|
399
|
-
"
|
|
400
|
-
"
|
|
401
|
-
"
|
|
402
|
-
"
|
|
403
|
-
"
|
|
404
|
-
"
|
|
405
|
-
"
|
|
406
|
-
"
|
|
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
|
-
" }
|
|
426
|
+
" }",
|
|
414
427
|
" }",
|
|
415
428
|
" initSections();",
|
|
416
429
|
// Reset all settings to defaults (client-side only).
|
|
417
|
-
" window.resetAllToDefaults =
|
|
418
|
-
" if
|
|
419
|
-
"
|
|
420
|
-
" if
|
|
421
|
-
"
|
|
422
|
-
" for
|
|
423
|
-
"
|
|
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
|
-
"
|
|
430
|
-
" for
|
|
431
|
-
" if
|
|
432
|
-
" updateDependentFields(
|
|
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
|
|
450
|
+
" window.submitSettingsForm = async (event) => {",
|
|
439
451
|
" event.preventDefault();",
|
|
440
452
|
" clearFieldErrors();",
|
|
441
|
-
"
|
|
442
|
-
"
|
|
443
|
-
" if
|
|
444
|
-
"
|
|
445
|
-
"
|
|
446
|
-
" for
|
|
447
|
-
"
|
|
448
|
-
"
|
|
449
|
-
"
|
|
450
|
-
"
|
|
451
|
-
" for
|
|
452
|
-
" if
|
|
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
|
|
467
|
+
" obj[parts.at(-1)] = value;",
|
|
456
468
|
" }",
|
|
457
|
-
" if
|
|
469
|
+
" if(saveBtn) saveBtn.classList.add('loading');",
|
|
458
470
|
" try {",
|
|
459
|
-
"
|
|
460
|
-
"
|
|
461
|
-
" headers: { '
|
|
462
|
-
"
|
|
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
|
-
"
|
|
465
|
-
" if
|
|
466
|
-
" if
|
|
467
|
-
" if
|
|
468
|
-
" if
|
|
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
|
|
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
|
|
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
|
|
501
|
+
" window.exportConfig = async () => {",
|
|
490
502
|
" try {",
|
|
491
|
-
"
|
|
492
|
-
"
|
|
493
|
-
"
|
|
494
|
-
"
|
|
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 =
|
|
507
|
-
"
|
|
508
|
-
" if
|
|
509
|
-
"
|
|
510
|
-
" reader.onload = async
|
|
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
|
-
"
|
|
513
|
-
" if
|
|
514
|
-
"
|
|
515
|
-
"
|
|
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
|
-
"
|
|
529
|
+
" method: 'POST'",
|
|
518
530
|
" });",
|
|
519
|
-
"
|
|
520
|
-
" if
|
|
531
|
+
" const data = await res.json();",
|
|
532
|
+
" if(res.ok && data.success) {",
|
|
521
533
|
" showToast(data.message || 'Configuration imported.', 'success');",
|
|
522
|
-
" if
|
|
523
|
-
" if
|
|
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
|
|
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
|
|
553
|
+
" window.exportChannels = async () => {",
|
|
542
554
|
" try {",
|
|
543
|
-
"
|
|
544
|
-
"
|
|
545
|
-
"
|
|
546
|
-
"
|
|
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 =
|
|
559
|
-
"
|
|
560
|
-
" if
|
|
561
|
-
"
|
|
562
|
-
" reader.onload = async
|
|
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
|
-
"
|
|
565
|
-
" if
|
|
566
|
-
"
|
|
567
|
-
"
|
|
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
|
-
"
|
|
581
|
+
" method: 'POST'",
|
|
570
582
|
" });",
|
|
571
|
-
"
|
|
572
|
-
" if
|
|
583
|
+
" const data = await res.json();",
|
|
584
|
+
" if(data.success) {",
|
|
573
585
|
" showToast((data.message || 'Channels imported successfully.') + PLAYLIST_HINT, 'success');",
|
|
574
|
-
" if
|
|
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
|
|
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 =
|
|
588
|
-
"
|
|
589
|
-
" if
|
|
590
|
-
"
|
|
591
|
-
"
|
|
592
|
-
"
|
|
593
|
-
" reader.onload = async
|
|
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
|
-
"
|
|
596
|
-
"
|
|
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
|
-
"
|
|
610
|
+
" method: 'POST'",
|
|
599
611
|
" });",
|
|
600
|
-
"
|
|
601
|
-
" if
|
|
602
|
-
"
|
|
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
|
|
605
|
-
" if
|
|
606
|
-
" if
|
|
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
|
-
"
|
|
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
|
|
624
|
+
" if(data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
|
|
612
625
|
" }",
|
|
613
|
-
" if
|
|
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
|
|
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
|
-
"
|
|
629
|
-
"
|
|
630
|
-
"
|
|
631
|
-
"
|
|
632
|
-
" if
|
|
633
|
-
" if
|
|
634
|
-
" else if
|
|
635
|
-
" else if
|
|
636
|
-
" }",
|
|
637
|
-
" if
|
|
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
|
|
645
|
-
"
|
|
646
|
-
"
|
|
647
|
-
"
|
|
648
|
-
"
|
|
649
|
-
"
|
|
650
|
-
"
|
|
651
|
-
"
|
|
652
|
-
"
|
|
653
|
-
"
|
|
654
|
-
"
|
|
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 =
|
|
687
|
-
"
|
|
688
|
-
"
|
|
689
|
-
" if
|
|
690
|
-
" if
|
|
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
|
-
"
|
|
694
|
-
" if
|
|
695
|
-
" urlInput.addEventListener('input',
|
|
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 =
|
|
701
|
-
"
|
|
702
|
-
"
|
|
703
|
-
" if
|
|
704
|
-
" if
|
|
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
|
|
707
|
-
// the advanced section (channel selector is essential for
|
|
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.
|
|
710
|
-
"
|
|
711
|
-
"
|
|
712
|
-
" for(
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
726
|
-
"
|
|
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
|
|
734
|
-
" window.hideAddForm =
|
|
735
|
-
"
|
|
736
|
-
" if
|
|
737
|
-
" if
|
|
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
|
-
"
|
|
740
|
-
" for(
|
|
741
|
-
"
|
|
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 =
|
|
792
|
-
"
|
|
793
|
-
"
|
|
794
|
-
" if
|
|
795
|
-
" if
|
|
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 =
|
|
806
|
-
"
|
|
807
|
-
" if
|
|
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 =
|
|
775
|
+
" window.startInlineEdit = (td) => {",
|
|
815
776
|
" if(td.querySelector('input.inline-edit')) return;",
|
|
816
|
-
"
|
|
817
|
-
"
|
|
818
|
-
"
|
|
819
|
-
"
|
|
820
|
-
"
|
|
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
|
-
"
|
|
789
|
+
" let saving = false;",
|
|
829
790
|
" async function save() {",
|
|
830
791
|
" if(saving) return;",
|
|
831
792
|
" saving = true;",
|
|
832
|
-
"
|
|
793
|
+
" const newValue = input.value.trim();",
|
|
833
794
|
" if(newValue === value) { cancel(); return; }",
|
|
834
|
-
"
|
|
795
|
+
" const data = { action: 'inline-edit', field: field, key: key, value: newValue };",
|
|
835
796
|
" try {",
|
|
836
|
-
"
|
|
837
|
-
"
|
|
838
|
-
" headers: { '
|
|
839
|
-
"
|
|
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
|
-
"
|
|
802
|
+
" const d = await res.json();",
|
|
842
803
|
" if(res.ok && d.success) {",
|
|
843
|
-
" if(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('
|
|
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',
|
|
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',
|
|
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
|
|
863
|
-
"
|
|
864
|
-
"
|
|
823
|
+
" window.toggleHdhr = async (checkbox) => {",
|
|
824
|
+
" const key = checkbox.getAttribute('data-key');",
|
|
825
|
+
" const value = checkbox.checked ? 'true' : 'false';",
|
|
865
826
|
" try {",
|
|
866
|
-
"
|
|
867
|
-
"
|
|
868
|
-
" headers: { '
|
|
869
|
-
"
|
|
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
|
-
"
|
|
832
|
+
" const d = await res.json();",
|
|
872
833
|
" if(res.ok && d.success) {",
|
|
873
|
-
" if(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
|
|
845
|
+
" window.submitChannelForm = async (event, action) => {",
|
|
885
846
|
" event.preventDefault();",
|
|
886
|
-
"
|
|
887
|
-
"
|
|
888
|
-
"
|
|
889
|
-
" for
|
|
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
|
-
"
|
|
892
|
-
"
|
|
893
|
-
" headers: { '
|
|
894
|
-
"
|
|
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
|
-
"
|
|
897
|
-
" if
|
|
857
|
+
" const d = await res.json();",
|
|
858
|
+
" if(res.ok && d.success) {",
|
|
898
859
|
" showToast(d.message, 'success');",
|
|
899
|
-
" if
|
|
900
|
-
" if
|
|
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
|
|
906
|
-
"
|
|
907
|
-
" for
|
|
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
|
|
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
|
|
920
|
-
" if
|
|
881
|
+
" window.deleteChannel = async (key) => {",
|
|
882
|
+
" if(!confirm('Delete channel ' + key + '?')) return;",
|
|
921
883
|
" try {",
|
|
922
|
-
"
|
|
923
|
-
"
|
|
924
|
-
" headers: { '
|
|
925
|
-
"
|
|
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
|
-
"
|
|
928
|
-
" if
|
|
889
|
+
" const d = await res.json();",
|
|
890
|
+
" if(res.ok && d.success) {",
|
|
929
891
|
" showToast(d.message, 'success');",
|
|
930
|
-
" if
|
|
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
|
|
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
|
|
902
|
+
" window.togglePredefinedChannel = async (key, enable) => {",
|
|
941
903
|
" try {",
|
|
942
|
-
"
|
|
943
|
-
"
|
|
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
|
-
"
|
|
907
|
+
" method: 'POST'",
|
|
946
908
|
" });",
|
|
947
|
-
"
|
|
948
|
-
" if
|
|
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) {
|
|
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
|
|
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
|
|
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
|
|
962
|
-
"
|
|
963
|
-
" if
|
|
964
|
-
"
|
|
965
|
-
" if
|
|
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
|
-
"
|
|
968
|
-
" for
|
|
969
|
-
" if
|
|
970
|
-
" else {
|
|
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
|
|
974
|
-
" window.
|
|
975
|
-
"
|
|
976
|
-
"
|
|
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
|
-
"
|
|
979
|
-
"
|
|
940
|
+
" const res = await fetch('/config/service', {",
|
|
941
|
+
" body: JSON.stringify({ channel: channelKey, service: serviceKey }),",
|
|
980
942
|
" headers: { 'Content-Type': 'application/json' },",
|
|
981
|
-
"
|
|
943
|
+
" method: 'POST'",
|
|
982
944
|
" });",
|
|
983
|
-
"
|
|
984
|
-
" if
|
|
985
|
-
" showToast('
|
|
986
|
-
" if
|
|
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
|
|
950
|
+
" showToast(result.error || 'Failed to update service.', 'error');",
|
|
989
951
|
" }",
|
|
990
952
|
" } catch(err) {",
|
|
991
|
-
" console.error('
|
|
992
|
-
" showToast('Failed to update
|
|
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
|
|
958
|
+
" window.bulkTogglePredefined = async (enable, scope) => {",
|
|
997
959
|
" try {",
|
|
998
|
-
"
|
|
999
|
-
"
|
|
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
|
-
"
|
|
963
|
+
" method: 'POST'",
|
|
1002
964
|
" });",
|
|
1003
|
-
"
|
|
1004
|
-
" if
|
|
1005
|
-
"
|
|
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
|
|
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
|
|
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
|
|
1019
|
-
"
|
|
1020
|
-
"
|
|
1021
|
-
"
|
|
1022
|
-
"
|
|
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
|
-
"
|
|
1025
|
-
"
|
|
1026
|
-
"
|
|
1027
|
-
"
|
|
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
|
-
"
|
|
1032
|
-
"
|
|
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
|
-
"
|
|
996
|
+
" method: 'POST'",
|
|
1035
997
|
" });",
|
|
1036
|
-
"
|
|
998
|
+
" const result = await res.json();",
|
|
1037
999
|
" if(result.success) {",
|
|
1038
1000
|
" showToast(result.message || 'Channels numbered.', 'success');",
|
|
1039
|
-
" if(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('
|
|
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
|
|
1051
|
-
"
|
|
1012
|
+
" window.bulkToggleHdhr = async () => {",
|
|
1013
|
+
" const toggle = document.getElementById('hdhr-bulk-toggle');",
|
|
1052
1014
|
" if(!toggle) return;",
|
|
1053
|
-
"
|
|
1054
|
-
"
|
|
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
|
-
"
|
|
1058
|
-
"
|
|
1019
|
+
" const res = await fetch('/config/channels/hdhr-bulk', {",
|
|
1020
|
+
" body: JSON.stringify({ enable: enable }),",
|
|
1059
1021
|
" headers: { 'Content-Type': 'application/json' },",
|
|
1060
|
-
"
|
|
1022
|
+
" method: 'POST'",
|
|
1061
1023
|
" });",
|
|
1062
|
-
"
|
|
1024
|
+
" const result = await res.json();",
|
|
1063
1025
|
" if(result.success) {",
|
|
1064
1026
|
" showToast(result.message || 'HDHR settings updated.', 'success');",
|
|
1065
|
-
" if(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
|
|
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
|
|
1076
|
-
" if
|
|
1121
|
+
" window.revertChannel = async (key) => {",
|
|
1122
|
+
" if(!confirm('Revert channel ' + key + ' to predefined defaults?')) return;",
|
|
1077
1123
|
" try {",
|
|
1078
|
-
"
|
|
1079
|
-
"
|
|
1080
|
-
" headers: { 'Content-Type': 'application/json'
|
|
1081
|
-
"
|
|
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
|
-
"
|
|
1084
|
-
" if
|
|
1129
|
+
" const d = await res.json();",
|
|
1130
|
+
" if(res.ok && d.success) {",
|
|
1085
1131
|
" showToast(d.message, 'success');",
|
|
1086
|
-
" if (d.
|
|
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
|
|
1098
|
-
" async function
|
|
1140
|
+
// Save the service filter to the server and update the UI on success.
|
|
1141
|
+
" async function saveServiceFilter(enabledTags) {",
|
|
1099
1142
|
" try {",
|
|
1100
|
-
"
|
|
1101
|
-
"
|
|
1143
|
+
" const r = await fetch('/config/service-filter', {",
|
|
1144
|
+
" body: JSON.stringify({ enabledServices: enabledTags }),",
|
|
1102
1145
|
" headers: { 'Content-Type': 'application/json' },",
|
|
1103
|
-
"
|
|
1146
|
+
" method: 'POST'",
|
|
1104
1147
|
" });",
|
|
1105
|
-
"
|
|
1106
|
-
" if
|
|
1107
|
-
"
|
|
1108
|
-
"
|
|
1148
|
+
" const result = await r.json();",
|
|
1149
|
+
" if(result.success) {",
|
|
1150
|
+
" updateServiceChips(enabledTags);",
|
|
1151
|
+
" channelTable.filter(enabledTags);",
|
|
1109
1152
|
" updateBulkAssignOptions(enabledTags);",
|
|
1110
|
-
"
|
|
1111
|
-
" if(result.patch) {
|
|
1112
|
-
" showToast('
|
|
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('
|
|
1158
|
+
" console.error('Service filter update failed.', err);",
|
|
1116
1159
|
" showToast('Failed to update filter: ' + err.message, 'error');",
|
|
1117
1160
|
" }",
|
|
1118
1161
|
" };",
|
|
1119
|
-
//
|
|
1120
|
-
"
|
|
1121
|
-
"
|
|
1122
|
-
"
|
|
1123
|
-
"
|
|
1124
|
-
|
|
1125
|
-
"
|
|
1126
|
-
"
|
|
1127
|
-
"
|
|
1128
|
-
|
|
1129
|
-
"
|
|
1130
|
-
"
|
|
1131
|
-
"
|
|
1132
|
-
|
|
1133
|
-
"
|
|
1134
|
-
"
|
|
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
|
|
1138
|
-
" window.
|
|
1139
|
-
"
|
|
1140
|
-
" if
|
|
1141
|
-
"
|
|
1142
|
-
" if
|
|
1143
|
-
// No checkbox exists for this tag — it is an orphaned entry with no corresponding
|
|
1144
|
-
"
|
|
1145
|
-
"
|
|
1146
|
-
" for
|
|
1147
|
-
"
|
|
1148
|
-
" if
|
|
1149
|
-
" }",
|
|
1150
|
-
"
|
|
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
|
|
1153
|
-
"
|
|
1154
|
-
|
|
1155
|
-
"
|
|
1156
|
-
" btn
|
|
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 + ' ▾';",
|
|
1157
1202
|
" };",
|
|
1158
|
-
// Rebuild the
|
|
1159
|
-
" function
|
|
1160
|
-
"
|
|
1161
|
-
" if
|
|
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
|
|
1164
|
-
"
|
|
1165
|
-
" for
|
|
1166
|
-
"
|
|
1167
|
-
"
|
|
1168
|
-
"
|
|
1169
|
-
"
|
|
1170
|
-
"
|
|
1171
|
-
"
|
|
1172
|
-
"
|
|
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
|
-
"
|
|
1176
|
-
" if
|
|
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
|
-
"
|
|
1226
|
+
" const chip = document.createElement('span');",
|
|
1183
1227
|
" chip.className = 'provider-chip';",
|
|
1184
1228
|
" chip.setAttribute('data-tag', tag);",
|
|
1185
|
-
"
|
|
1186
|
-
"
|
|
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=\"
|
|
1233
|
+
" '\" onclick=\"removeServiceChip(\\'' + tag + '\\')\">\\u00d7</button>';",
|
|
1190
1234
|
" container.appendChild(chip);",
|
|
1191
1235
|
" }",
|
|
1192
|
-
"
|
|
1236
|
+
" processServiceDisplays();",
|
|
1193
1237
|
" };",
|
|
1194
|
-
//
|
|
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
|
-
"
|
|
1240
|
+
" const select = document.getElementById('bulk-assign-select');",
|
|
1258
1241
|
" if(!select) return;",
|
|
1259
|
-
"
|
|
1260
|
-
" for(
|
|
1261
|
-
"
|
|
1262
|
-
"
|
|
1263
|
-
" if(show)
|
|
1264
|
-
" else
|
|
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
|
|
1268
|
-
" window.
|
|
1269
|
-
" if
|
|
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
|
-
"
|
|
1272
|
-
"
|
|
1254
|
+
" const r = await fetch('/config/service-bulk-assign', {",
|
|
1255
|
+
" body: JSON.stringify({ service: serviceTag }),",
|
|
1273
1256
|
" headers: { 'Content-Type': 'application/json' },",
|
|
1274
|
-
"
|
|
1257
|
+
" method: 'POST'",
|
|
1275
1258
|
" });",
|
|
1276
|
-
"
|
|
1277
|
-
" if
|
|
1278
|
-
"
|
|
1279
|
-
"
|
|
1280
|
-
" if
|
|
1281
|
-
"
|
|
1282
|
-
" undoAction = { label: 'Undo', onclick:
|
|
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
|
|
1286
|
-
" for
|
|
1287
|
-
"
|
|
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
|
|
1299
|
-
" async function
|
|
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
|
-
"
|
|
1302
|
-
"
|
|
1283
|
+
" const r = await fetch('/config/service-bulk-restore', {",
|
|
1284
|
+
" body: JSON.stringify({ selections: prevSelections }),",
|
|
1303
1285
|
" headers: { 'Content-Type': 'application/json' },",
|
|
1304
|
-
"
|
|
1286
|
+
" method: 'POST'",
|
|
1305
1287
|
" });",
|
|
1306
|
-
"
|
|
1307
|
-
" if
|
|
1288
|
+
" const result = await r.json();",
|
|
1289
|
+
" if(result.success) {",
|
|
1308
1290
|
" showToast('Bulk assign reverted.', 'success');",
|
|
1309
|
-
" if
|
|
1310
|
-
" for
|
|
1311
|
-
"
|
|
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',
|
|
1325
|
-
" if
|
|
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
|
|
1328
|
-
|
|
1329
|
-
"
|
|
1330
|
-
"
|
|
1331
|
-
"
|
|
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 =
|
|
1353
|
-
"
|
|
1354
|
-
"
|
|
1355
|
-
" if
|
|
1356
|
-
" if
|
|
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
|
-
"
|
|
1321
|
+
" safeStorageSet('prismcast-show-disabled-channels', 'true');",
|
|
1359
1322
|
" } else {",
|
|
1360
1323
|
" table.classList.add('hide-disabled');",
|
|
1361
|
-
"
|
|
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 =
|
|
1366
|
-
"
|
|
1367
|
-
" if
|
|
1368
|
-
"
|
|
1369
|
-
"
|
|
1370
|
-
" if
|
|
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
|
-
"
|
|
1374
|
-
"
|
|
1375
|
-
" if
|
|
1376
|
-
"
|
|
1377
|
-
" for
|
|
1378
|
-
" if
|
|
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
|
-
"
|
|
1383
|
-
"
|
|
1384
|
-
"
|
|
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
|
-
"
|
|
1349
|
+
" const qaEl = document.getElementById(qaId);",
|
|
1387
1350
|
" if(qaEl) qaEl.style.display = checkbox.checked ? '' : 'none';",
|
|
1388
|
-
"
|
|
1389
|
-
"
|
|
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
|
-
"
|
|
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' ? ' ▲' : ' ▼'); }",
|
|
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
|
-
"
|
|
1458
|
-
"
|
|
1459
|
-
" if
|
|
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
|
-
"
|
|
1463
|
-
"
|
|
1464
|
-
" if
|
|
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
|
-
"
|
|
1470
|
-
" if
|
|
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
|
-
"
|
|
1475
|
-
"
|
|
1476
|
-
"
|
|
1477
|
-
"
|
|
1478
|
-
"
|
|
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
|
-
"
|
|
1481
|
-
"
|
|
1482
|
-
" if
|
|
1483
|
-
" else if
|
|
1484
|
-
" else if
|
|
1485
|
-
"
|
|
1486
|
-
"
|
|
1487
|
-
" for
|
|
1488
|
-
" if
|
|
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
|
-
"
|
|
1492
|
-
"
|
|
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
|
|
1495
|
-
" for
|
|
1496
|
-
"
|
|
1497
|
-
" opt.value =
|
|
1498
|
-
" opt.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
|
|
1504
|
-
"
|
|
1505
|
-
"
|
|
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',
|
|
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
|
|
1521
|
-
"
|
|
1522
|
-
" (async
|
|
1425
|
+
" if(!slug) return;",
|
|
1426
|
+
" const snapshotUrl = urlInput.value;",
|
|
1427
|
+
" (async () => {",
|
|
1523
1428
|
" try {",
|
|
1524
|
-
"
|
|
1525
|
-
"
|
|
1526
|
-
" if
|
|
1527
|
-
"
|
|
1528
|
-
"
|
|
1529
|
-
" for
|
|
1530
|
-
" for
|
|
1531
|
-
" if
|
|
1532
|
-
"
|
|
1533
|
-
" o.value =
|
|
1534
|
-
" o.label =
|
|
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,
|
|
1542
|
-
" (
|
|
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
|
-
"
|
|
1545
|
-
" if (
|
|
1546
|
-
"
|
|
1547
|
-
"
|
|
1548
|
-
"
|
|
1549
|
-
"
|
|
1550
|
-
"
|
|
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
|
-
"
|
|
1558
|
-
" for(
|
|
1559
|
-
"
|
|
1560
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
1569
|
-
" if(hdhrToggle && hdhrToggle.getAttribute('data-indeterminate') === 'true') hdhrToggle.indeterminate = true;",
|
|
1570
|
-
//
|
|
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
|
-
"
|
|
1573
|
-
" if (
|
|
1574
|
-
"
|
|
1575
|
-
"
|
|
1576
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
1601
|
-
"
|
|
1602
|
-
" if
|
|
1603
|
-
" addStationInput.addEventListener('input',
|
|
1604
|
-
" addSelectorInput.addEventListener('input',
|
|
1605
|
-
" if
|
|
1606
|
-
"
|
|
1607
|
-
" if
|
|
1608
|
-
"
|
|
1609
|
-
"
|
|
1610
|
-
"
|
|
1611
|
-
"
|
|
1612
|
-
"
|
|
1613
|
-
"
|
|
1614
|
-
"
|
|
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
|
-
"
|
|
1537
|
+
" let loginStatusInterval = null;",
|
|
1621
1538
|
// Start login mode for a channel. Opens browser window and shows modal.
|
|
1622
|
-
" window.startChannelLogin = async
|
|
1539
|
+
" window.startChannelLogin = async (channel) => {",
|
|
1623
1540
|
" try {",
|
|
1624
|
-
"
|
|
1625
|
-
"
|
|
1541
|
+
" const res = await fetch('/auth/login', {",
|
|
1542
|
+
" body: JSON.stringify({ channel }),",
|
|
1626
1543
|
" headers: { 'Content-Type': 'application/json' },",
|
|
1627
|
-
"
|
|
1544
|
+
" method: 'POST'",
|
|
1628
1545
|
" });",
|
|
1629
|
-
"
|
|
1630
|
-
" if
|
|
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
|
|
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
|
-
"
|
|
1656
|
-
" if
|
|
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
|
-
"
|
|
1661
|
-
" if
|
|
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
|
-
"
|
|
1584
|
+
" let errorLogged = false;",
|
|
1585
|
+
" loginStatusInterval = setInterval(async () => {",
|
|
1667
1586
|
" try {",
|
|
1668
|
-
"
|
|
1669
|
-
"
|
|
1670
|
-
"
|
|
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
|
|
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
|
-
"
|
|
1687
|
-
" if
|
|
1688
|
-
"
|
|
1689
|
-
" for
|
|
1690
|
-
"
|
|
1691
|
-
"
|
|
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
|
-
"
|
|
1698
|
-
" for
|
|
1699
|
-
" (
|
|
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
|
-
"
|
|
1705
|
-
" if
|
|
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 =
|
|
1712
|
-
"
|
|
1713
|
-
" if
|
|
1714
|
-
"
|
|
1715
|
-
" if
|
|
1716
|
-
"
|
|
1717
|
-
" if
|
|
1718
|
-
"
|
|
1719
|
-
"
|
|
1720
|
-
" for
|
|
1721
|
-
" if
|
|
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
|
-
"
|
|
1728
|
-
"
|
|
1729
|
-
"
|
|
1730
|
-
"
|
|
1731
|
-
"
|
|
1732
|
-
" switchSubtab
|
|
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");
|