prismcast 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +26 -1
  2. package/dist/app.js +29 -2
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.js +1 -1
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +14 -4
  7. package/dist/browser/channelSelection.js +48 -704
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/index.d.ts +18 -4
  10. package/dist/browser/index.js +205 -39
  11. package/dist/browser/index.js.map +1 -1
  12. package/dist/browser/tuning/fox.d.ts +19 -0
  13. package/dist/browser/tuning/fox.js +61 -0
  14. package/dist/browser/tuning/fox.js.map +1 -0
  15. package/dist/browser/tuning/hbo.d.ts +22 -0
  16. package/dist/browser/tuning/hbo.js +182 -0
  17. package/dist/browser/tuning/hbo.js.map +1 -0
  18. package/dist/browser/tuning/hulu.d.ts +30 -0
  19. package/dist/browser/tuning/hulu.js +514 -0
  20. package/dist/browser/tuning/hulu.js.map +1 -0
  21. package/dist/browser/tuning/sling.d.ts +22 -0
  22. package/dist/browser/tuning/sling.js +287 -0
  23. package/dist/browser/tuning/sling.js.map +1 -0
  24. package/dist/browser/tuning/thumbnailRow.d.ts +18 -0
  25. package/dist/browser/tuning/thumbnailRow.js +107 -0
  26. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  27. package/dist/browser/tuning/tileClick.d.ts +18 -0
  28. package/dist/browser/tuning/tileClick.js +102 -0
  29. package/dist/browser/tuning/tileClick.js.map +1 -0
  30. package/dist/browser/tuning/youtubeTv.d.ts +17 -0
  31. package/dist/browser/tuning/youtubeTv.js +99 -0
  32. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  33. package/dist/browser/video.d.ts +2 -2
  34. package/dist/browser/video.js +18 -8
  35. package/dist/browser/video.js.map +1 -1
  36. package/dist/channels/index.js +131 -12
  37. package/dist/channels/index.js.map +1 -1
  38. package/dist/config/profiles.d.ts +5 -5
  39. package/dist/config/profiles.js +35 -11
  40. package/dist/config/profiles.js.map +1 -1
  41. package/dist/config/providers.d.ts +48 -4
  42. package/dist/config/providers.js +182 -22
  43. package/dist/config/providers.js.map +1 -1
  44. package/dist/config/sites.d.ts +10 -0
  45. package/dist/config/sites.js +96 -17
  46. package/dist/config/sites.js.map +1 -1
  47. package/dist/config/userChannels.js +21 -3
  48. package/dist/config/userChannels.js.map +1 -1
  49. package/dist/config/userConfig.d.ts +2 -1
  50. package/dist/config/userConfig.js +12 -4
  51. package/dist/config/userConfig.js.map +1 -1
  52. package/dist/index.js +10 -4
  53. package/dist/index.js.map +1 -1
  54. package/dist/routes/auth.js +6 -0
  55. package/dist/routes/auth.js.map +1 -1
  56. package/dist/routes/config.d.ts +5 -0
  57. package/dist/routes/config.js +273 -35
  58. package/dist/routes/config.js.map +1 -1
  59. package/dist/routes/debug.d.ts +6 -0
  60. package/dist/routes/debug.js +274 -0
  61. package/dist/routes/debug.js.map +1 -0
  62. package/dist/routes/index.d.ts +1 -0
  63. package/dist/routes/index.js +3 -0
  64. package/dist/routes/index.js.map +1 -1
  65. package/dist/routes/logs.js +17 -5
  66. package/dist/routes/logs.js.map +1 -1
  67. package/dist/routes/root.js +507 -128
  68. package/dist/routes/root.js.map +1 -1
  69. package/dist/routes/theme.js +7 -0
  70. package/dist/routes/theme.js.map +1 -1
  71. package/dist/service/commands.js +8 -8
  72. package/dist/service/commands.js.map +1 -1
  73. package/dist/service/generators.js +22 -16
  74. package/dist/service/generators.js.map +1 -1
  75. package/dist/streaming/fmp4Segmenter.d.ts +7 -0
  76. package/dist/streaming/fmp4Segmenter.js +124 -15
  77. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  78. package/dist/streaming/hls.d.ts +74 -6
  79. package/dist/streaming/hls.js +212 -130
  80. package/dist/streaming/hls.js.map +1 -1
  81. package/dist/streaming/hlsSegments.js +11 -4
  82. package/dist/streaming/hlsSegments.js.map +1 -1
  83. package/dist/streaming/lifecycle.js +1 -1
  84. package/dist/streaming/lifecycle.js.map +1 -1
  85. package/dist/streaming/monitor.d.ts +1 -1
  86. package/dist/streaming/monitor.js +202 -53
  87. package/dist/streaming/monitor.js.map +1 -1
  88. package/dist/streaming/mp4Parser.d.ts +29 -1
  89. package/dist/streaming/mp4Parser.js +243 -18
  90. package/dist/streaming/mp4Parser.js.map +1 -1
  91. package/dist/streaming/mpegts.d.ts +5 -2
  92. package/dist/streaming/mpegts.js +122 -29
  93. package/dist/streaming/mpegts.js.map +1 -1
  94. package/dist/streaming/registry.d.ts +1 -1
  95. package/dist/streaming/registry.js.map +1 -1
  96. package/dist/streaming/setup.d.ts +13 -1
  97. package/dist/streaming/setup.js +136 -36
  98. package/dist/streaming/setup.js.map +1 -1
  99. package/dist/streaming/showInfo.js +9 -9
  100. package/dist/streaming/showInfo.js.map +1 -1
  101. package/dist/streaming/statusEmitter.js +5 -6
  102. package/dist/streaming/statusEmitter.js.map +1 -1
  103. package/dist/types/index.d.ts +16 -3
  104. package/dist/types/index.js +5 -1
  105. package/dist/types/index.js.map +1 -1
  106. package/dist/utils/debugFilter.d.ts +38 -0
  107. package/dist/utils/debugFilter.js +157 -0
  108. package/dist/utils/debugFilter.js.map +1 -0
  109. package/dist/utils/evaluate.js.map +1 -1
  110. package/dist/utils/ffmpeg.d.ts +5 -0
  111. package/dist/utils/ffmpeg.js +13 -2
  112. package/dist/utils/ffmpeg.js.map +1 -1
  113. package/dist/utils/fileLogger.d.ts +2 -1
  114. package/dist/utils/fileLogger.js +4 -2
  115. package/dist/utils/fileLogger.js.map +1 -1
  116. package/dist/utils/index.d.ts +1 -0
  117. package/dist/utils/index.js +1 -0
  118. package/dist/utils/index.js.map +1 -1
  119. package/dist/utils/logEmitter.d.ts +1 -0
  120. package/dist/utils/logEmitter.js.map +1 -1
  121. package/dist/utils/logger.d.ts +9 -8
  122. package/dist/utils/logger.js +52 -28
  123. package/dist/utils/logger.js.map +1 -1
  124. package/dist/utils/retry.d.ts +1 -1
  125. package/dist/utils/retry.js +3 -3
  126. package/dist/utils/retry.js.map +1 -1
  127. package/dist/utils/version.js +4 -9
  128. package/dist/utils/version.js.map +1 -1
  129. package/package.json +6 -6
@@ -22,7 +22,10 @@ function generateHeaderStatusHtml() {
22
22
  return [
23
23
  "<div id=\"system-status\" class=\"header-status\">",
24
24
  "<span id=\"system-health\"><span class=\"status-dot\" style=\"color: var(--text-muted);\">&#9679;</span> Connecting...</span>",
25
- "<span id=\"stream-count\">-</span>",
25
+ "<div class=\"dropdown stream-popover\">",
26
+ "<span id=\"stream-count\" onclick=\"toggleStreamPopover()\">-</span>",
27
+ "<div class=\"dropdown-menu\" id=\"stream-popover-menu\"></div>",
28
+ "</div>",
26
29
  "</div>"
27
30
  ].join("\n");
28
31
  }
@@ -104,6 +107,8 @@ function generateStatusScript() {
104
107
  "var streamData = {};",
105
108
  "var systemData = null;",
106
109
  "var expandedStreams = {};",
110
+ "var healthColorVars = { healthy: 'var(--stream-healthy)', buffering: 'var(--stream-buffering)', recovering: 'var(--stream-recovering)', ",
111
+ " stalled: 'var(--stream-stalled)', error: 'var(--stream-error)' };",
107
112
  // Format duration in human readable format.
108
113
  "function formatDuration(seconds) {",
109
114
  " if (seconds < 60) return seconds + 's';",
@@ -144,6 +149,16 @@ function generateStatusScript() {
144
149
  " return url;",
145
150
  " }",
146
151
  "}",
152
+ // Build channel display HTML with an optional logo image. When a logo URL is available, renders an img element with an onerror fallback that hides the
153
+ // image and reveals a text span. The logoClass and textClass parameters allow callers to apply context-specific sizing.
154
+ "function channelDisplayHtml(logoUrl, name, logoClass, textClass) {",
155
+ " if(logoUrl) {",
156
+ " return '<img src=\"' + logoUrl + '\" class=\"' + logoClass + '\" alt=\"' + name + '\" title=\"' + name + '\" ' +",
157
+ " 'onerror=\"this.style.display=\\'none\\';this.nextElementSibling.style.display=\\'inline\\'\">' +",
158
+ " '<span class=\"' + textClass + '\" style=\"display:none\">' + name + '</span>';",
159
+ " }",
160
+ " return '<span class=\"' + textClass + '\">' + name + '</span>';",
161
+ "}",
147
162
  // Get row background color based on health status. Uses CSS variables for theme support.
148
163
  "function getRowTint(health) {",
149
164
  " var tints = {",
@@ -158,8 +173,6 @@ function generateStatusScript() {
158
173
  // Get health badge HTML using CSS variables for theme-aware colors. NOTE: Escalation level semantics defined in monitor.ts.
159
174
  // L1=play/unmute, L2=seek, L3=source reload, L4=page navigation.
160
175
  "function getHealthBadge(health, level) {",
161
- " var colorVars = { healthy: 'var(--stream-healthy)', buffering: 'var(--stream-buffering)', recovering: 'var(--stream-recovering)', ",
162
- " stalled: 'var(--stream-stalled)', error: 'var(--stream-error)' };",
163
176
  " var label = '';",
164
177
  " if (health === 'healthy') { label = 'Healthy'; }",
165
178
  " else if (health === 'buffering') { label = 'Buffering'; }",
@@ -173,7 +186,7 @@ function generateStatusScript() {
173
186
  " else { label = 'Recovering'; }",
174
187
  " }",
175
188
  " else { label = health; }",
176
- " return '<span class=\"status-dot\" style=\"color: ' + (colorVars[health] || 'var(--text-muted)') + ';\">&#9679;</span> ' +",
189
+ " return '<span class=\"status-dot\" style=\"color: ' + (healthColorVars[health] || 'var(--text-muted)') + ';\">&#9679;</span> ' +",
177
190
  " '<span style=\"color: var(--text-secondary);\">' + label + '</span>';",
178
191
  "}",
179
192
  // Update system status display in header. Shows system health (green dot when connected, red with label when not) and stream count.
@@ -190,10 +203,57 @@ function generateStatusScript() {
190
203
  " var limit = systemData.streams.limit;",
191
204
  " if(active === 0) {",
192
205
  " streamEl.textContent = '0 streams';",
206
+ " streamEl.classList.remove('clickable');",
207
+ " var popMenu = document.getElementById('stream-popover-menu');",
208
+ " if(popMenu) popMenu.classList.remove('show');",
193
209
  " } else {",
194
210
  " streamEl.textContent = active + '/' + limit + ' streams';",
211
+ " streamEl.classList.add('clickable');",
212
+ " }",
213
+ "}",
214
+ // Build the popover content from streamData. Populates the given menu element with one row per active stream.
215
+ "function buildStreamPopoverContent(menu) {",
216
+ " var ids = Object.keys(streamData);",
217
+ " var html = '';",
218
+ " var now = Date.now();",
219
+ " for(var i = 0; i < ids.length; i++) {",
220
+ " var s = streamData[ids[i]];",
221
+ " var color = healthColorVars[s.health] || 'var(--text-muted)';",
222
+ " var name = s.channel || s.providerName || getDomain(s.url);",
223
+ " var dur = Math.floor((now - new Date(s.startTime).getTime()) / 1000);",
224
+ " var titleAttr = s.showName ? ' title=\"' + s.showName + '\"' : '';",
225
+ " html += '<div class=\"stream-popover-row\"' + titleAttr + '>';",
226
+ " html += '<span class=\"status-dot\" style=\"color: ' + color + ';\">&#9679;</span>';",
227
+ " html += channelDisplayHtml(s.logoUrl, name, 'stream-popover-logo', 'stream-popover-channel');",
228
+ " html += '<span class=\"stream-popover-duration\">' + formatDuration(dur) + '</span>';",
229
+ " html += '</div>';",
230
+ " }",
231
+ " menu.innerHTML = html;",
232
+ "}",
233
+ // Update an already-open stream popover with current data. Called from SSE handlers and the duration interval.
234
+ "function updateStreamPopover() {",
235
+ " var menu = document.getElementById('stream-popover-menu');",
236
+ " if(!menu || !menu.classList.contains('show')) return;",
237
+ " var ids = Object.keys(streamData);",
238
+ " if(ids.length === 0) {",
239
+ " menu.classList.remove('show');",
240
+ " return;",
195
241
  " }",
242
+ " buildStreamPopoverContent(menu);",
196
243
  "}",
244
+ // Toggle the stream popover open or closed.
245
+ "window.toggleStreamPopover = function() {",
246
+ " var ids = Object.keys(streamData);",
247
+ " if(ids.length === 0) return;",
248
+ " var menu = document.getElementById('stream-popover-menu');",
249
+ " if(!menu) return;",
250
+ " var isOpen = menu.classList.contains('show');",
251
+ " closeDropdowns();",
252
+ " if(!isOpen) {",
253
+ " buildStreamPopoverContent(menu);",
254
+ " menu.classList.add('show');",
255
+ " }",
256
+ "};",
197
257
  // Format last issue for display.
198
258
  "function formatLastIssue(s) {",
199
259
  " if (!s.lastIssueType || !s.lastIssueTime) { return 'None'; }",
@@ -239,11 +299,7 @@ function generateStatusScript() {
239
299
  " var chevron = isExpanded ? '&#9660;' : '&#9654;';",
240
300
  " var rowTint = getRowTint(s.health);",
241
301
  " var channelText = s.channel || s.providerName || getDomain(s.url);",
242
- " var channelDisplay = s.logoUrl",
243
- " ? '<img src=\"' + s.logoUrl + '\" class=\"channel-logo\" alt=\"' + channelText + '\" title=\"' + channelText + '\" ' +",
244
- " 'onerror=\"this.style.display=\\'none\\';this.nextElementSibling.style.display=\\'inline\\'\">' +",
245
- " '<span class=\"channel-text\" style=\"display:none\">' + channelText + '</span>'",
246
- " : '<span class=\"channel-text\">' + channelText + '</span>';",
302
+ " var channelDisplay = channelDisplayHtml(s.logoUrl, channelText, 'channel-logo', 'channel-text');",
247
303
  " html += '<tr class=\"stream-row\" data-id=\"' + id + '\" onclick=\"toggleStreamDetails(' + id + ')\" style=\"background-color: ' + rowTint + ';\">';",
248
304
  " html += '<td class=\"chevron\">' + chevron + '</td>';",
249
305
  " var durationSpan = '<span class=\"stream-duration\" id=\"duration-' + id + '\">\\u00b7 ' + formatDuration(s.duration) + '</span>';",
@@ -294,6 +350,7 @@ function generateStatusScript() {
294
350
  " var el = document.getElementById('duration-' + id);",
295
351
  " if (el) el.textContent = '\\u00b7 ' + formatDuration(durationSec);",
296
352
  " }",
353
+ " updateStreamPopover();",
297
354
  "}",
298
355
  // Connect to SSE stream for status updates.
299
356
  "(function() {",
@@ -307,17 +364,20 @@ function generateStatusScript() {
307
364
  " }",
308
365
  " updateSystemStatus();",
309
366
  " renderStreamsTable();",
367
+ " updateStreamPopover();",
310
368
  " });",
311
369
  " statusEventSource.addEventListener('streamAdded', function(e) {",
312
370
  " var s = JSON.parse(e.data);",
313
371
  " streamData[s.id] = s;",
314
372
  " renderStreamsTable();",
373
+ " updateStreamPopover();",
315
374
  " });",
316
375
  " statusEventSource.addEventListener('streamRemoved', function(e) {",
317
376
  " var data = JSON.parse(e.data);",
318
377
  " delete streamData[data.id];",
319
378
  " delete expandedStreams[data.id];",
320
379
  " renderStreamsTable();",
380
+ " updateStreamPopover();",
321
381
  " if (typeof pendingRestart !== 'undefined' && pendingRestart) {",
322
382
  " updateRestartDialogStatus();",
323
383
  " }",
@@ -327,6 +387,7 @@ function generateStatusScript() {
327
387
  " if (streamData[s.id]) {",
328
388
  " streamData[s.id] = s;",
329
389
  " renderStreamsTable();",
390
+ " updateStreamPopover();",
330
391
  " }",
331
392
  " });",
332
393
  " statusEventSource.addEventListener('systemStatusChanged', function(e) {",
@@ -494,7 +555,7 @@ function generateApiReferenceContent() {
494
555
  "</tr>",
495
556
  "<tr>",
496
557
  "<td class=\"endpoint\"><code>DELETE /streams/:id</code></td>",
497
- "<td>Terminate a specific stream by its numeric ID. Returns 204 on success, 404 if not found.</td>",
558
+ "<td>Terminate a specific stream by its numeric ID. Returns 200 on success, 404 if not found.</td>",
498
559
  "</tr>",
499
560
  "</table>",
500
561
  "</div>",
@@ -562,6 +623,24 @@ function generateApiReferenceContent() {
562
623
  "<td class=\"endpoint\"><code>POST /config/channels/toggle-all-predefined</code></td>",
563
624
  "<td>Enable or disable all predefined channels. Body: <code>{ \"enabled\": true }</code></td>",
564
625
  "</tr>",
626
+ "<tr>",
627
+ "<td class=\"endpoint\"><code>POST /config/provider</code></td>",
628
+ "<td>Update provider selection for a multi-provider channel. Body: <code>{ \"channel\": \"nbc\", \"provider\": \"nbc-hulu\" }</code></td>",
629
+ "</tr>",
630
+ "<tr>",
631
+ "<td class=\"endpoint\"><code>POST /config/provider-filter</code></td>",
632
+ "<td>Set enabled provider tags. Body: <code>{ \"enabledProviders\": [\"hulu\", \"yttv\"] }</code>. Empty array disables filter.</td>",
633
+ "</tr>",
634
+ "<tr>",
635
+ "<td class=\"endpoint\"><code>POST /config/provider-bulk-assign</code></td>",
636
+ "<td>Assign a provider to all multi-provider channels. Body: <code>{ \"provider\": \"hulu\" }</code>. " +
637
+ "Returns <code>{ affected, previousSelections, selections }</code></td>",
638
+ "</tr>",
639
+ "<tr>",
640
+ "<td class=\"endpoint\"><code>POST /config/provider-bulk-restore</code></td>",
641
+ "<td>Restore previous provider selections (undo bulk assign). Body: <code>{ \"selections\": { \"nbc\": \"nbc-hulu\", \"fox\": null } }</code>. " +
642
+ "A <code>null</code> value restores the channel to its default provider.</td>",
643
+ "</tr>",
565
644
  "</table>",
566
645
  "</div>",
567
646
  // Diagnostics endpoints.
@@ -696,7 +775,12 @@ function generateLogsContent() {
696
775
  " var cls = 'log-entry';",
697
776
  " if(entry.level === 'error') { cls += ' log-error'; }",
698
777
  " else if(entry.level === 'warn') { cls += ' log-warn'; }",
699
- " var levelBadge = entry.level !== 'info' ? '[' + entry.level.toUpperCase() + '] ' : '';",
778
+ " else if(entry.level === 'debug') { cls += ' log-debug'; }",
779
+ " var levelBadge = '';",
780
+ " if(entry.level !== 'info') {",
781
+ " var tag = entry.categoryTag ? entry.level.toUpperCase() + ':' + entry.categoryTag : entry.level.toUpperCase();",
782
+ " levelBadge = '[' + escapeHtml(tag) + '] ';",
783
+ " }",
700
784
  " return '<div class=\"' + cls + '\">[' + escapeHtml(entry.timestamp) + '] ' + levelBadge + escapeHtml(entry.message) + '</div>';",
701
785
  "}",
702
786
  // Append a single log entry (for SSE streaming).
@@ -811,8 +895,6 @@ function generateBackupPanel() {
811
895
  function generateConfigContent() {
812
896
  const tabs = getUITabs();
813
897
  const lines = [];
814
- // Status message area for AJAX feedback.
815
- lines.push("<div id=\"config-status\" class=\"config-status\" style=\"display: none;\"></div>");
816
898
  // Environment variable warning if applicable.
817
899
  if (hasEnvOverrides()) {
818
900
  lines.push("<div class=\"warning\">");
@@ -901,30 +983,43 @@ function generateConfigSubtabScript() {
901
983
  " }",
902
984
  " }",
903
985
  " }",
904
- // Timeout handle for auto-dismissing success messages.
905
- " var statusTimeout = null;",
906
- // Show status message. Success messages auto-dismiss after 5 seconds.
907
- " function showStatus(message, type) {",
908
- " var status = document.getElementById('config-status');",
909
- " if (!status) return;",
910
- " if (statusTimeout) { clearTimeout(statusTimeout); statusTimeout = null; }",
911
- " status.textContent = message;",
912
- " status.className = 'config-status ' + (type || 'info');",
913
- " status.style.opacity = '1';",
914
- " status.style.display = 'block';",
915
- " status.scrollIntoView({ behavior: 'smooth', block: 'nearest' });",
916
- " if (type === 'success') {",
917
- " statusTimeout = setTimeout(function() { hideStatus(); }, 10000);",
986
+ // Show a toast notification. Auto-dismiss durations: success/info = 5s, warning = 8s, error = no auto-dismiss. Optional action: { label, onclick } appends an
987
+ // inline button between the message text and the close button.
988
+ " function showToast(message, type, duration, action) {",
989
+ " var container = document.getElementById('toast-container');",
990
+ " if (!container) return;",
991
+ " var toast = document.createElement('div');",
992
+ " toast.className = 'toast ' + (type || 'info');",
993
+ " toast.textContent = message;",
994
+ " toast.setAttribute('role', (type === 'error' || type === 'warning') ? 'alert' : 'status');",
995
+ " if (action && action.label) {",
996
+ " var actionBtn = document.createElement('button');",
997
+ " actionBtn.type = 'button';",
998
+ " actionBtn.className = 'toast-action';",
999
+ " actionBtn.textContent = action.label;",
1000
+ " actionBtn.onclick = function() { if (action.onclick) action.onclick(); dismissToast(toast); };",
1001
+ " toast.appendChild(actionBtn);",
918
1002
  " }",
1003
+ " var closeBtn = document.createElement('button');",
1004
+ " closeBtn.type = 'button';",
1005
+ " closeBtn.className = 'toast-close';",
1006
+ " closeBtn.textContent = '\\u00d7';",
1007
+ " closeBtn.onclick = function() { dismissToast(toast); };",
1008
+ " toast.appendChild(closeBtn);",
1009
+ " container.appendChild(toast);",
1010
+ " var ms = duration !== undefined ? duration : type === 'error' ? 0 : type === 'warning' ? 8000 : 5000;",
1011
+ " if (ms > 0) { setTimeout(function() { dismissToast(toast); }, ms); }",
919
1012
  " }",
920
- // Hide status message with fade-out effect.
921
- " function hideStatus() {",
922
- " var status = document.getElementById('config-status');",
923
- " if (statusTimeout) { clearTimeout(statusTimeout); statusTimeout = null; }",
924
- " if (status) {",
925
- " status.style.opacity = '0';",
926
- " setTimeout(function() { status.style.display = 'none'; }, 300);",
927
- " }",
1013
+ // Dismiss a toast with slide-out animation.
1014
+ " function dismissToast(toast) {",
1015
+ " if (toast.classList.contains('toast-exit')) return;",
1016
+ " toast.classList.add('toast-exit');",
1017
+ " toast.addEventListener('animationend', function() { if (toast.parentNode) toast.parentNode.removeChild(toast); });",
1018
+ " }",
1019
+ // Queue a toast to appear after the next page reload.
1020
+ " function showToastAfterReload(message, type) {",
1021
+ " sessionStorage.setItem('pendingToast', JSON.stringify({ message: message, type: type || 'success' }));",
1022
+ " location.reload();",
928
1023
  " }",
929
1024
  // Interval handle for restart polling.
930
1025
  " var restartPollInterval = null;",
@@ -951,7 +1046,7 @@ function generateConfigSubtabScript() {
951
1046
  " window.cancelPendingRestart = function() {",
952
1047
  " pendingRestart = false;",
953
1048
  " document.getElementById('restart-dialog').style.display = 'none';",
954
- " showStatus('Restart cancelled. Changes will apply on next restart.', 'info');",
1049
+ " showToast('Restart cancelled. Changes will apply on next restart.', 'info');",
955
1050
  " };",
956
1051
  // Force immediate restart despite active streams.
957
1052
  " window.forceRestart = function() {",
@@ -968,7 +1063,7 @@ function generateConfigSubtabScript() {
968
1063
  " }",
969
1064
  " })",
970
1065
  " .catch(function(err) {",
971
- " showStatus('Failed to restart: ' + err.message, 'error');",
1066
+ " showToast('Failed to restart: ' + err.message, 'error');",
972
1067
  " });",
973
1068
  " };",
974
1069
  // Trigger restart (called when streams reach 0).
@@ -980,14 +1075,14 @@ function generateConfigSubtabScript() {
980
1075
  " }",
981
1076
  " })",
982
1077
  " .catch(function() {",
983
- " showStatus('Failed to trigger restart. Please restart manually.', 'error');",
1078
+ " showToast('Failed to trigger restart. Please restart manually.', 'error');",
984
1079
  " });",
985
1080
  " }",
986
1081
  // Wait for server restart by polling /health, then reload.
987
1082
  " function waitForServerRestart() {",
988
1083
  " var attempts = 0;",
989
1084
  " var maxAttempts = 30;",
990
- " showStatus('Restarting server...', 'info');",
1085
+ " showToast('Restarting server...', 'info', 0);",
991
1086
  " if (restartPollInterval) { clearInterval(restartPollInterval); }",
992
1087
  " restartPollInterval = setInterval(function() {",
993
1088
  " attempts++;",
@@ -996,17 +1091,14 @@ function generateConfigSubtabScript() {
996
1091
  " if (response.ok) {",
997
1092
  " clearInterval(restartPollInterval);",
998
1093
  " restartPollInterval = null;",
999
- " showStatus('Server restarted. Reloading...', 'success');",
1000
- " setTimeout(function() { window.location.reload(); }, 500);",
1094
+ " showToastAfterReload('Server restarted.', 'success');",
1001
1095
  " }",
1002
1096
  " })",
1003
1097
  " .catch(function() {",
1004
1098
  " if (attempts >= maxAttempts) {",
1005
1099
  " clearInterval(restartPollInterval);",
1006
1100
  " restartPollInterval = null;",
1007
- " showStatus('Server did not restart within 30 seconds. Please check the server manually.', 'error');",
1008
- " } else {",
1009
- " showStatus('Restarting server... (' + attempts + 's)', 'info');",
1101
+ " showToast('Server did not restart within 30 seconds. Please check the server manually.', 'error');",
1010
1102
  " }",
1011
1103
  " });",
1012
1104
  " }, 1000);",
@@ -1056,7 +1148,7 @@ function generateConfigSubtabScript() {
1056
1148
  " var modal = document.getElementById('changelog-modal');",
1057
1149
  " if (modal) { modal.style.display = 'none'; }",
1058
1150
  " };",
1059
- // Check for updates manually.
1151
+ // Check for updates manually. Updates the version link in-place when a new version is found.
1060
1152
  " window.checkForUpdates = function() {",
1061
1153
  " var btn = document.querySelector('.version-check');",
1062
1154
  " if (!btn || btn.classList.contains('checking')) return;",
@@ -1065,10 +1157,12 @@ function generateConfigSubtabScript() {
1065
1157
  " .then(function(res) { return res.json(); })",
1066
1158
  " .then(function(data) {",
1067
1159
  " btn.classList.remove('checking');",
1068
- " if (data.updateAvailable) {",
1069
- // Only reload if update wasn't already visible (need to fetch changelog modal).
1070
- " var alreadyShowing = document.querySelector('.version-update');",
1071
- " if (!alreadyShowing) { location.reload(); }",
1160
+ " if (data.updateAvailable && data.latestVersion) {",
1161
+ " var link = document.querySelector('.version-container .version');",
1162
+ " if (link && !link.classList.contains('version-update')) {",
1163
+ " link.textContent = 'v' + data.currentVersion + ' \\u2192 v' + data.latestVersion;",
1164
+ " link.classList.add('version-update');",
1165
+ " }",
1072
1166
  " } else {",
1073
1167
  " btn.classList.add('up-to-date');",
1074
1168
  " setTimeout(function() { btn.classList.remove('up-to-date'); }, 2000);",
@@ -1206,7 +1300,7 @@ function generateConfigSubtabScript() {
1206
1300
  " updateDependentFields(cbInputs[j].id);",
1207
1301
  " }",
1208
1302
  " }",
1209
- " showStatus('Settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
1303
+ " showToast('Settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
1210
1304
  " };",
1211
1305
  // Toggle collapsible section in Advanced tab.
1212
1306
  " window.toggleSection = function(sectionId) {",
@@ -1268,12 +1362,11 @@ function generateConfigSubtabScript() {
1268
1362
  " updateDependentFields(cbInputs[j].id);",
1269
1363
  " }",
1270
1364
  " }",
1271
- " showStatus('All settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
1365
+ " showToast('All settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
1272
1366
  " };",
1273
1367
  // Submit settings form via AJAX.
1274
1368
  " window.submitSettingsForm = function(event) {",
1275
1369
  " event.preventDefault();",
1276
- " hideStatus();",
1277
1370
  " clearFieldErrors();",
1278
1371
  " var form = document.getElementById('settings-form');",
1279
1372
  " var saveBtn = document.getElementById('save-btn');",
@@ -1308,18 +1401,18 @@ function generateConfigSubtabScript() {
1308
1401
  " waitForServerRestart();",
1309
1402
  " }",
1310
1403
  " } else {",
1311
- " showStatus(result.data.message || 'Configuration saved.', 'info');",
1404
+ " showToast(result.data.message || 'Configuration saved.', 'info');",
1312
1405
  " }",
1313
1406
  " } else if (result.data.errors) {",
1314
1407
  " displayFieldErrors(result.data.errors);",
1315
- " showStatus('Please correct the errors below.', 'error');",
1408
+ " showToast('Please correct the errors below.', 'error');",
1316
1409
  " } else {",
1317
- " showStatus(result.data.message || 'Failed to save configuration.', 'error');",
1410
+ " showToast(result.data.message || 'Failed to save configuration.', 'error');",
1318
1411
  " }",
1319
1412
  " })",
1320
1413
  " .catch(function(err) {",
1321
1414
  " if (saveBtn) saveBtn.classList.remove('loading');",
1322
- " showStatus('Failed to save configuration: ' + err.message, 'error');",
1415
+ " showToast('Failed to save configuration: ' + err.message, 'error');",
1323
1416
  " });",
1324
1417
  " return false;",
1325
1418
  " };",
@@ -1337,7 +1430,7 @@ function generateConfigSubtabScript() {
1337
1430
  " document.body.removeChild(a);",
1338
1431
  " window.URL.revokeObjectURL(url);",
1339
1432
  " })",
1340
- " .catch(function(err) { alert('Failed to export configuration: ' + err.message); });",
1433
+ " .catch(function(err) { showToast('Failed to export configuration: ' + err.message, 'error'); });",
1341
1434
  " };",
1342
1435
  // Import configuration from file.
1343
1436
  " window.importConfig = function(fileInput) {",
@@ -1356,7 +1449,7 @@ function generateConfigSubtabScript() {
1356
1449
  " .then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); })",
1357
1450
  " .then(function(result) {",
1358
1451
  " if (result.ok && result.data.success) {",
1359
- " alert(result.data.message || 'Configuration imported.');",
1452
+ " showToast(result.data.message || 'Configuration imported.', 'success');",
1360
1453
  " if (result.data.willRestart) {",
1361
1454
  " if (result.data.deferred) {",
1362
1455
  " showPendingRestartDialog(result.data.activeStreams);",
@@ -1368,10 +1461,10 @@ function generateConfigSubtabScript() {
1368
1461
  " throw new Error(result.data.message || result.data.error || 'Import failed');",
1369
1462
  " }",
1370
1463
  " })",
1371
- " .catch(function(err) { alert('Failed to import configuration: ' + err.message); });",
1464
+ " .catch(function(err) { showToast('Failed to import configuration: ' + err.message, 'error'); });",
1372
1465
  " }",
1373
1466
  " } catch (err) {",
1374
- " alert('Invalid JSON file: ' + err.message);",
1467
+ " showToast('Invalid JSON file: ' + err.message, 'error');",
1375
1468
  " }",
1376
1469
  " fileInput.value = '';",
1377
1470
  " };",
@@ -1391,7 +1484,7 @@ function generateConfigSubtabScript() {
1391
1484
  " document.body.removeChild(a);",
1392
1485
  " window.URL.revokeObjectURL(url);",
1393
1486
  " })",
1394
- " .catch(function(err) { alert('Failed to export channels: ' + err.message); });",
1487
+ " .catch(function(err) { showToast('Failed to export channels: ' + err.message, 'error'); });",
1395
1488
  " };",
1396
1489
  // Import channels from file.
1397
1490
  " window.importChannels = function(fileInput) {",
@@ -1409,16 +1502,15 @@ function generateConfigSubtabScript() {
1409
1502
  " })",
1410
1503
  " .then(function(response) {",
1411
1504
  " if (response.ok) {",
1412
- " alert('Channels imported successfully.');",
1413
- " window.location.reload();",
1505
+ " showToastAfterReload('Channels imported successfully.', 'success');",
1414
1506
  " } else {",
1415
1507
  " return response.text().then(function(text) { throw new Error(text); });",
1416
1508
  " }",
1417
1509
  " })",
1418
- " .catch(function(err) { alert('Failed to import channels: ' + err.message); });",
1510
+ " .catch(function(err) { showToast('Failed to import channels: ' + err.message, 'error'); });",
1419
1511
  " }",
1420
1512
  " } catch (err) {",
1421
- " alert('Invalid JSON file: ' + err.message);",
1513
+ " showToast('Invalid JSON file: ' + err.message, 'error');",
1422
1514
  " }",
1423
1515
  " fileInput.value = '';",
1424
1516
  " };",
@@ -1451,13 +1543,13 @@ function generateConfigSubtabScript() {
1451
1543
  " }",
1452
1544
  " if (data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
1453
1545
  " }",
1454
- " alert(msg);",
1455
- " if (data.imported > 0 || data.replaced > 0) window.location.reload();",
1546
+ " if (data.imported > 0 || data.replaced > 0) { showToastAfterReload(msg, 'success'); }",
1547
+ " else { showToast(msg, 'success'); }",
1456
1548
  " } else {",
1457
- " alert('M3U import failed: ' + (data.error || 'Unknown error'));",
1549
+ " showToast('M3U import failed: ' + (data.error || 'Unknown error'), 'error');",
1458
1550
  " }",
1459
1551
  " })",
1460
- " .catch(function(err) { alert('Failed to import M3U: ' + err.message); });",
1552
+ " .catch(function(err) { showToast('Failed to import M3U: ' + err.message, 'error'); });",
1461
1553
  " fileInput.value = '';",
1462
1554
  " };",
1463
1555
  " reader.readAsText(file);",
@@ -1618,7 +1710,6 @@ function generateConfigSubtabScript() {
1618
1710
  " var formData = new FormData(form);",
1619
1711
  " var data = {};",
1620
1712
  " for (var pair of formData.entries()) { data[pair[0]] = pair[1]; }",
1621
- " showStatus('Saving channel...', 'info');",
1622
1713
  " fetch('/config/channels', {",
1623
1714
  " method: 'POST',",
1624
1715
  " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
@@ -1627,7 +1718,7 @@ function generateConfigSubtabScript() {
1627
1718
  " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
1628
1719
  " .then(function(result) {",
1629
1720
  " if (result.ok && result.data.success) {",
1630
- " showStatus(result.data.message, 'success');",
1721
+ " showToast(result.data.message, 'success');",
1631
1722
  " if (result.data.html) {",
1632
1723
  " insertChannelRow(result.data.html, result.data.key);",
1633
1724
  " if (action === 'add') {",
@@ -1636,23 +1727,22 @@ function generateConfigSubtabScript() {
1636
1727
  " hideEditForm(result.data.key);",
1637
1728
  " }",
1638
1729
  " } else {",
1639
- " window.location.reload();",
1730
+ " showToast('Channel saved. Reload to see changes.', 'info');",
1640
1731
  " }",
1641
1732
  " } else if (result.data.errors) {",
1642
1733
  " var errorMsgs = [];",
1643
1734
  " for (var field in result.data.errors) { errorMsgs.push(field + ': ' + result.data.errors[field]); }",
1644
- " showStatus('Validation errors: ' + errorMsgs.join(', '), 'error');",
1735
+ " showToast('Validation errors: ' + errorMsgs.join(', '), 'error');",
1645
1736
  " } else {",
1646
- " showStatus(result.data.message || 'Failed to save channel.', 'error');",
1737
+ " showToast(result.data.message || 'Failed to save channel.', 'error');",
1647
1738
  " }",
1648
1739
  " })",
1649
- " .catch(function(err) { showStatus('Failed to save channel: ' + err.message, 'error'); });",
1740
+ " .catch(function(err) { showToast('Failed to save channel: ' + err.message, 'error'); });",
1650
1741
  " return false;",
1651
1742
  " };",
1652
1743
  // Delete channel via AJAX.
1653
1744
  " window.deleteChannel = function(key) {",
1654
1745
  " if (!confirm('Delete channel ' + key + '?')) return;",
1655
- " showStatus('Deleting channel...', 'info');",
1656
1746
  " fetch('/config/channels', {",
1657
1747
  " method: 'POST',",
1658
1748
  " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
@@ -1661,21 +1751,20 @@ function generateConfigSubtabScript() {
1661
1751
  " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
1662
1752
  " .then(function(result) {",
1663
1753
  " if (result.ok && result.data.success) {",
1664
- " showStatus(result.data.message, 'success');",
1754
+ " showToast(result.data.message, 'success');",
1665
1755
  " if (result.data.html) {",
1666
1756
  " insertChannelRow(result.data.html, result.data.key || key);",
1667
1757
  " } else {",
1668
1758
  " removeChannelRow(result.data.key || key);",
1669
1759
  " }",
1670
1760
  " } else {",
1671
- " showStatus(result.data.message || 'Failed to delete channel.', 'error');",
1761
+ " showToast(result.data.message || 'Failed to delete channel.', 'error');",
1672
1762
  " }",
1673
1763
  " })",
1674
- " .catch(function(err) { showStatus('Failed to delete channel: ' + err.message, 'error'); });",
1764
+ " .catch(function(err) { showToast('Failed to delete channel: ' + err.message, 'error'); });",
1675
1765
  " };",
1676
1766
  // Toggle a single predefined channel's enabled/disabled state.
1677
1767
  " window.togglePredefinedChannel = function(key, enable) {",
1678
- " showStatus((enable ? 'Enabling' : 'Disabling') + ' channel...', 'info');",
1679
1768
  " fetch('/config/channels/toggle-predefined', {",
1680
1769
  " method: 'POST',",
1681
1770
  " headers: { 'Content-Type': 'application/json' },",
@@ -1684,19 +1773,38 @@ function generateConfigSubtabScript() {
1684
1773
  " .then(function(response) { return response.json(); })",
1685
1774
  " .then(function(result) {",
1686
1775
  " if (result.success) {",
1687
- " showStatus('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.', 'success');",
1776
+ " showToast('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.', 'success');",
1688
1777
  " updateChannelRowDisabledState(key, !enable);",
1689
1778
  " } else {",
1690
- " showStatus(result.error || 'Failed to toggle channel.', 'error');",
1779
+ " showToast(result.error || 'Failed to toggle channel.', 'error');",
1691
1780
  " }",
1692
1781
  " })",
1693
- " .catch(function(err) { showStatus('Failed to toggle channel: ' + err.message, 'error'); });",
1782
+ " .catch(function(err) { showToast('Failed to toggle channel: ' + err.message, 'error'); });",
1694
1783
  " };",
1784
+ // Update a channel row's provider selection and profile cell in-place. Syncs the HTML selected attribute so filterChannelRows() restore logic works correctly.
1785
+ // We iterate _allOptions (if present) rather than querySelectorAll because filtered-out options are removed from the DOM but still tracked in the array.
1786
+ " function updateChannelProviderUI(channelKey, variant, profile) {",
1787
+ " var row = document.getElementById('display-row-' + channelKey);",
1788
+ " if (!row) return;",
1789
+ " var sel = row.querySelector('.provider-select');",
1790
+ " if (sel) {",
1791
+ " sel.value = variant;",
1792
+ " var allOpts = sel._allOptions || Array.prototype.slice.call(sel.querySelectorAll('option'));",
1793
+ " for (var oi = 0; oi < allOpts.length; oi++) {",
1794
+ " if (allOpts[oi].value === variant) { allOpts[oi].setAttribute('selected', ''); }",
1795
+ " else { allOpts[oi].removeAttribute('selected'); }",
1796
+ " }",
1797
+ " }",
1798
+ " var profileCell = row.cells[3];",
1799
+ " if (profileCell) {",
1800
+ " if (profile) { profileCell.textContent = profile; }",
1801
+ " else { profileCell.innerHTML = '<em>auto</em>'; }",
1802
+ " }",
1803
+ " }",
1695
1804
  // Update provider selection for a multi-provider channel.
1696
1805
  " window.updateProviderSelection = function(selectElement) {",
1697
1806
  " var channelKey = selectElement.getAttribute('data-channel');",
1698
1807
  " var providerKey = selectElement.value;",
1699
- " showStatus('Updating provider...', 'info');",
1700
1808
  " fetch('/config/provider', {",
1701
1809
  " method: 'POST',",
1702
1810
  " headers: { 'Content-Type': 'application/json' },",
@@ -1705,25 +1813,16 @@ function generateConfigSubtabScript() {
1705
1813
  " .then(function(response) { return response.json(); })",
1706
1814
  " .then(function(result) {",
1707
1815
  " if (result.success) {",
1708
- " showStatus('Provider updated. New streams will use the selected provider.', 'success');",
1709
- " var row = document.getElementById('display-row-' + channelKey);",
1710
- " if (row) {",
1711
- " var profileCell = row.cells[3];",
1712
- " if (result.profile) {",
1713
- " profileCell.textContent = result.profile;",
1714
- " } else {",
1715
- " profileCell.innerHTML = '<em>auto</em>';",
1716
- " }",
1717
- " }",
1816
+ " showToast('Provider updated. New streams will use the selected provider.', 'success');",
1817
+ " updateChannelProviderUI(channelKey, providerKey, result.profile);",
1718
1818
  " } else {",
1719
- " showStatus(result.error || 'Failed to update provider.', 'error');",
1819
+ " showToast(result.error || 'Failed to update provider.', 'error');",
1720
1820
  " }",
1721
1821
  " })",
1722
- " .catch(function(err) { showStatus('Failed to update provider: ' + err.message, 'error'); });",
1822
+ " .catch(function(err) { showToast('Failed to update provider: ' + err.message, 'error'); });",
1723
1823
  " };",
1724
1824
  // Toggle all predefined channels' enabled/disabled state.
1725
1825
  " window.toggleAllPredefined = function(enable) {",
1726
- " showStatus((enable ? 'Enabling' : 'Disabling') + ' all predefined channels...', 'info');",
1727
1826
  " fetch('/config/channels/toggle-all-predefined', {",
1728
1827
  " method: 'POST',",
1729
1828
  " headers: { 'Content-Type': 'application/json' },",
@@ -1732,16 +1831,22 @@ function generateConfigSubtabScript() {
1732
1831
  " .then(function(response) { return response.json(); })",
1733
1832
  " .then(function(result) {",
1734
1833
  " if (result.success) {",
1735
- " showStatus('All predefined channels ' + (enable ? 'enabled' : 'disabled') + '.', 'success');",
1736
- " window.location.reload();",
1834
+ " showToast('All predefined channels ' + (enable ? 'enabled' : 'disabled') + '.', 'success');",
1835
+ " var rows = document.querySelectorAll('tr[id^=\"display-row-\"]:not(.user-channel)');",
1836
+ " for (var i = 0; i < rows.length; i++) {",
1837
+ " var rowKey = rows[i].id.replace('display-row-', '');",
1838
+ " setRowDisabledState(rowKey, !enable);",
1839
+ " }",
1840
+ " updateBulkToggleButton();",
1841
+ " updateDisabledCount();",
1737
1842
  " } else {",
1738
- " showStatus(result.error || 'Failed to toggle channels.', 'error');",
1843
+ " showToast(result.error || 'Failed to toggle channels.', 'error');",
1739
1844
  " }",
1740
1845
  " })",
1741
- " .catch(function(err) { showStatus('Failed to toggle channels: ' + err.message, 'error'); });",
1846
+ " .catch(function(err) { showToast('Failed to toggle channels: ' + err.message, 'error'); });",
1742
1847
  " };",
1743
- // Update a channel row's disabled state without full page reload.
1744
- " function updateChannelRowDisabledState(key, disabled) {",
1848
+ // Set a channel row's disabled state without triggering count updates. Used by toggleAllPredefined for efficient bulk updates.
1849
+ " function setRowDisabledState(key, disabled) {",
1745
1850
  " var row = document.getElementById('display-row-' + key);",
1746
1851
  " if (!row) return;",
1747
1852
  " var btnGroup = row.querySelector('.btn-group');",
@@ -1761,17 +1866,21 @@ function generateConfigSubtabScript() {
1761
1866
  " row.classList.remove('channel-disabled');",
1762
1867
  " var enableBtn = btnGroup.querySelector('.btn-enable');",
1763
1868
  " if (enableBtn) {",
1764
- " var loginBtn = document.createElement('button');",
1765
- " loginBtn.type = 'button';",
1766
- " loginBtn.className = 'btn btn-secondary btn-sm';",
1767
- " loginBtn.setAttribute('onclick', \"startChannelLogin('\" + key + \"')\");",
1768
- " loginBtn.textContent = 'Login';",
1769
- " btnGroup.insertBefore(loginBtn, enableBtn);",
1869
+ " var newLoginBtn = document.createElement('button');",
1870
+ " newLoginBtn.type = 'button';",
1871
+ " newLoginBtn.className = 'btn btn-secondary btn-sm';",
1872
+ " newLoginBtn.setAttribute('onclick', \"startChannelLogin('\" + key + \"')\");",
1873
+ " newLoginBtn.textContent = 'Login';",
1874
+ " btnGroup.insertBefore(newLoginBtn, enableBtn);",
1770
1875
  " enableBtn.className = 'btn btn-disable btn-sm';",
1771
1876
  " enableBtn.textContent = 'Disable';",
1772
1877
  " enableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', false)\");",
1773
1878
  " }",
1774
1879
  " }",
1880
+ " }",
1881
+ // Update a single channel row's disabled state and refresh counts. Used by individual togglePredefinedChannel.
1882
+ " function updateChannelRowDisabledState(key, disabled) {",
1883
+ " setRowDisabledState(key, disabled);",
1775
1884
  " updateBulkToggleButton();",
1776
1885
  " updateDisabledCount();",
1777
1886
  " };",
@@ -1790,13 +1899,207 @@ function generateConfigSubtabScript() {
1790
1899
  " btn.setAttribute('onclick', 'toggleAllPredefined(false)');",
1791
1900
  " }",
1792
1901
  " };",
1793
- // Update the disabled channel count shown in the toolbar toggle label.
1902
+ // Update the disabled channel count shown in the toolbar toggle label. Uses a union selector to avoid double-counting rows that are both disabled and
1903
+ // provider-unavailable.
1794
1904
  " function updateDisabledCount() {",
1795
1905
  " var countEl = document.getElementById('disabled-count');",
1796
1906
  " if (!countEl) return;",
1797
- " var disabledRows = document.querySelectorAll('tr.channel-disabled:not(.user-channel)');",
1798
- " countEl.textContent = String(disabledRows.length);",
1907
+ " var hiddenRows = document.querySelectorAll('tr.channel-disabled:not(.user-channel), tr.channel-unavailable');",
1908
+ " countEl.textContent = String(hiddenRows.length);",
1799
1909
  " };",
1910
+ // Provider filter: toggle a provider tag on/off.
1911
+ " window.toggleProviderTag = function(checkbox) {",
1912
+ " var menu = checkbox.closest('.provider-dropdown-menu');",
1913
+ " if (!menu) return;",
1914
+ " var checkboxes = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
1915
+ " var enabledTags = [];",
1916
+ " for (var i = 0; i < checkboxes.length; i++) {",
1917
+ " if (checkboxes[i].checked) enabledTags.push(checkboxes[i].getAttribute('data-tag'));",
1918
+ " }",
1919
+ // If all checkboxes are checked, clear the filter (empty array = no filter).
1920
+ " var allCheckboxes = menu.querySelectorAll('input[type=\"checkbox\"]');",
1921
+ " var allChecked = true;",
1922
+ " for (var j = 0; j < allCheckboxes.length; j++) {",
1923
+ " if (!allCheckboxes[j].checked && !allCheckboxes[j].disabled) { allChecked = false; break; }",
1924
+ " }",
1925
+ " if (allChecked) enabledTags = [];",
1926
+ // POST to server.
1927
+ " fetch('/config/provider-filter', {",
1928
+ " method: 'POST',",
1929
+ " headers: { 'Content-Type': 'application/json' },",
1930
+ " body: JSON.stringify({ enabledProviders: enabledTags })",
1931
+ " })",
1932
+ " .then(function(r) { return r.json(); })",
1933
+ " .then(function(result) {",
1934
+ " if (result.success) {",
1935
+ " updateProviderChips(enabledTags);",
1936
+ " filterChannelRows(enabledTags);",
1937
+ " updateBulkAssignOptions(enabledTags);",
1938
+ " updateProviderFilterButton(enabledTags);",
1939
+ " updateDisabledCount();",
1940
+ " }",
1941
+ " })",
1942
+ " .catch(function(err) { showToast('Failed to update filter: ' + err.message, 'error'); });",
1943
+ " };",
1944
+ // Remove a provider chip (uncheck the tag and update).
1945
+ " window.removeProviderChip = function(tag) {",
1946
+ " var menu = document.querySelector('.provider-dropdown-menu');",
1947
+ " if (!menu) return;",
1948
+ " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1949
+ " if (cb) { cb.checked = false; toggleProviderTag(cb); }",
1950
+ " };",
1951
+ // Update the provider filter button text.
1952
+ " function updateProviderFilterButton(enabledTags) {",
1953
+ " var btn = document.getElementById('provider-filter-btn');",
1954
+ " if (!btn) return;",
1955
+ " btn.innerHTML = (enabledTags.length > 0) ? 'Filtered &#9662;' : 'All Providers &#9662;';",
1956
+ " };",
1957
+ // Rebuild the provider chips from the enabled tags.
1958
+ " function updateProviderChips(enabledTags) {",
1959
+ " var container = document.getElementById('provider-chips');",
1960
+ " if (!container) return;",
1961
+ " container.innerHTML = '';",
1962
+ " if (enabledTags.length === 0) return;",
1963
+ " var menu = document.querySelector('.provider-dropdown-menu');",
1964
+ " for (var i = 0; i < enabledTags.length; i++) {",
1965
+ " var tag = enabledTags[i];",
1966
+ " if (tag === 'direct') continue;",
1967
+ " var label = tag;",
1968
+ " if (menu) {",
1969
+ " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1970
+ " if (cb && cb.parentElement) label = cb.parentElement.textContent.trim();",
1971
+ " }",
1972
+ " var chip = document.createElement('span');",
1973
+ " chip.className = 'provider-chip';",
1974
+ " chip.setAttribute('data-tag', tag);",
1975
+ " chip.innerHTML = label + '<button type=\"button\" class=\"chip-close\" onclick=\"removeProviderChip(\\'' + tag + '\\')\">\\u00d7</button>';",
1976
+ " container.appendChild(chip);",
1977
+ " }",
1978
+ " };",
1979
+ // Filter channel rows based on enabled provider tags. Toggles the channel-unavailable class on each row and updates Source column content. Filtered-out options
1980
+ // 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
1981
+ // 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
1982
+ // restore priority: (1) the saved choice (HTML selected attribute, kept in sync by updateProviderSelection), (2) the previous visual selection, (3) first option.
1983
+ " function filterChannelRows(enabledTags) {",
1984
+ " var rows = document.querySelectorAll('tr[data-provider-tags]');",
1985
+ " for (var i = 0; i < rows.length; i++) {",
1986
+ " var tags = rows[i].getAttribute('data-provider-tags').split(',');",
1987
+ " var available = true;",
1988
+ " if (enabledTags.length > 0) {",
1989
+ " available = false;",
1990
+ " for (var j = 0; j < tags.length; j++) {",
1991
+ " if (tags[j] === 'direct' || enabledTags.indexOf(tags[j]) !== -1) { available = true; break; }",
1992
+ " }",
1993
+ " }",
1994
+ " if (available) { rows[i].classList.remove('channel-unavailable'); }",
1995
+ " else { rows[i].classList.add('channel-unavailable'); }",
1996
+ // Update Source column elements: toggle between the no-provider label and the provider content (select or static name).
1997
+ " var label = rows[i].querySelector('.no-provider-label');",
1998
+ " var sel = rows[i].querySelector('.provider-select');",
1999
+ " var name = rows[i].querySelector('.provider-name');",
2000
+ " if (label) label.style.display = available ? 'none' : '';",
2001
+ " if (name) name.style.display = available ? '' : 'none';",
2002
+ " if (sel) {",
2003
+ " sel.style.display = available ? '' : 'none';",
2004
+ // On first call, snapshot all options (including server-hidden ones) into a persistent array.
2005
+ " if (!sel._allOptions) { sel._allOptions = Array.prototype.slice.call(sel.querySelectorAll('option')); }",
2006
+ " var prevValue = sel.value;",
2007
+ " sel.innerHTML = '';",
2008
+ " var serverDefault = null;",
2009
+ " var prevExists = false;",
2010
+ " for (var k = 0; k < sel._allOptions.length; k++) {",
2011
+ " var opt = sel._allOptions[k];",
2012
+ " var oTag = opt.getAttribute('data-provider-tag');",
2013
+ " var show = (enabledTags.length === 0) || oTag === 'direct' || enabledTags.indexOf(oTag) !== -1;",
2014
+ " if (show) {",
2015
+ " sel.appendChild(opt);",
2016
+ " if (opt.hasAttribute('selected')) serverDefault = opt;",
2017
+ " if (opt.value === prevValue) prevExists = true;",
2018
+ " }",
2019
+ " }",
2020
+ " if (serverDefault) { sel.value = serverDefault.value; }",
2021
+ " else if (prevExists) { sel.value = prevValue; }",
2022
+ " else if (sel.options.length > 0) { sel.selectedIndex = 0; }",
2023
+ " }",
2024
+ " }",
2025
+ " };",
2026
+ // Update bulk assign dropdown to only show enabled providers. Uses DOM removal like filterChannelRows because Safari ignores hidden/display:none on option
2027
+ // elements. The snapshot filters by truthy .value to exclude the "Choose provider..." placeholder (value="") so it is never removed from the DOM.
2028
+ " function updateBulkAssignOptions(enabledTags) {",
2029
+ " var select = document.getElementById('bulk-assign');",
2030
+ " if (!select) return;",
2031
+ " if (!select._allOptions) {",
2032
+ " select._allOptions = [];",
2033
+ " var all = select.querySelectorAll('option');",
2034
+ " for (var a = 0; a < all.length; a++) { if (all[a].value) select._allOptions.push(all[a]); }",
2035
+ " }",
2036
+ " for (var i = 0; i < select._allOptions.length; i++) {",
2037
+ " var opt = select._allOptions[i];",
2038
+ " if (opt.parentNode === select) { select.removeChild(opt); }",
2039
+ " }",
2040
+ " for (var j = 0; j < select._allOptions.length; j++) {",
2041
+ " var opt2 = select._allOptions[j];",
2042
+ " if (enabledTags.length === 0 || opt2.value === 'direct' || enabledTags.indexOf(opt2.value) !== -1) {",
2043
+ " select.appendChild(opt2);",
2044
+ " }",
2045
+ " }",
2046
+ " select.value = '';",
2047
+ " };",
2048
+ // Bulk assign all channels to a specific provider. Updates all dropdowns and profile cells in-place.
2049
+ " window.bulkAssignProvider = function(selectEl) {",
2050
+ " var providerTag = selectEl.value;",
2051
+ " if (!providerTag) return;",
2052
+ " selectEl.value = '';",
2053
+ " fetch('/config/provider-bulk-assign', {",
2054
+ " method: 'POST',",
2055
+ " headers: { 'Content-Type': 'application/json' },",
2056
+ " body: JSON.stringify({ provider: providerTag })",
2057
+ " })",
2058
+ " .then(function(r) { return r.json(); })",
2059
+ " .then(function(result) {",
2060
+ " if (result.success) {",
2061
+ " var msg = result.affected + ' of ' + result.total + ' channel(s) updated.';",
2062
+ " var undoAction = null;",
2063
+ " if (result.affected > 0 && result.previousSelections) {",
2064
+ " var prevSelections = result.previousSelections;",
2065
+ " undoAction = { label: 'Undo', onclick: function() { restoreBulkProviders(prevSelections); } };",
2066
+ " }",
2067
+ " showToast(msg, 'success', undoAction ? 10000 : undefined, undoAction);",
2068
+ " if (result.selections) {",
2069
+ " for (var key in result.selections) {",
2070
+ " var sel = result.selections[key];",
2071
+ " updateChannelProviderUI(key, sel.variant, sel.profile);",
2072
+ " }",
2073
+ " }",
2074
+ " } else {",
2075
+ " showToast(result.error || 'Failed to assign.', 'error');",
2076
+ " }",
2077
+ " })",
2078
+ " .catch(function(err) { showToast('Failed to assign: ' + err.message, 'error'); });",
2079
+ " };",
2080
+ // Restore previous provider selections (undo bulk assign). Sends the previousSelections map to the server and updates the UI with the restored selections.
2081
+ " function restoreBulkProviders(prevSelections) {",
2082
+ " fetch('/config/provider-bulk-restore', {",
2083
+ " method: 'POST',",
2084
+ " headers: { 'Content-Type': 'application/json' },",
2085
+ " body: JSON.stringify({ selections: prevSelections })",
2086
+ " })",
2087
+ " .then(function(r) { return r.json(); })",
2088
+ " .then(function(result) {",
2089
+ " if (result.success) {",
2090
+ " showToast('Bulk assign reverted.', 'success');",
2091
+ " if (result.selections) {",
2092
+ " for (var key in result.selections) {",
2093
+ " var sel = result.selections[key];",
2094
+ " updateChannelProviderUI(key, sel.variant, sel.profile);",
2095
+ " }",
2096
+ " }",
2097
+ " } else {",
2098
+ " showToast(result.error || 'Failed to revert.', 'error');",
2099
+ " }",
2100
+ " })",
2101
+ " .catch(function(err) { showToast('Failed to revert: ' + err.message, 'error'); });",
2102
+ " }",
1800
2103
  // Close all open dropdown menus.
1801
2104
  " function closeDropdowns() {",
1802
2105
  " var menus = document.querySelectorAll('.dropdown-menu.show');",
@@ -1847,14 +2150,33 @@ function generateConfigSubtabScript() {
1847
2150
  " }",
1848
2151
  " } catch (e) {}",
1849
2152
  " };",
1850
- // Initialize disabled channel toggle and URL input listeners on page load.
2153
+ // Initialize disabled channel toggle, provider filter, URL input listeners, and pending toast on page load.
1851
2154
  " (function() {",
2155
+ // Show any toast queued by showToastAfterReload() before the last page reload.
2156
+ " var pending = sessionStorage.getItem('pendingToast');",
2157
+ " if (pending) {",
2158
+ " sessionStorage.removeItem('pendingToast');",
2159
+ " try { var pt = JSON.parse(pending); showToast(pt.message, pt.type); } catch (e) {}",
2160
+ " }",
1852
2161
  " if (localStorage.getItem('prismcast-show-disabled-channels') === 'true') {",
1853
2162
  " var table = document.querySelector('.channel-table');",
1854
2163
  " var checkbox = document.getElementById('show-disabled-toggle');",
1855
2164
  " if (table) table.classList.remove('hide-disabled');",
1856
2165
  " if (checkbox) checkbox.checked = true;",
1857
2166
  " }",
2167
+ // Run filterChannelRows on page load when a provider filter is active. The server renders filtered options with the hidden attribute, but Safari ignores it on
2168
+ // option elements. This initial pass removes those options from the DOM to enforce the filter.
2169
+ " var menu = document.querySelector('.provider-dropdown-menu');",
2170
+ " if (menu) {",
2171
+ " var cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
2172
+ " var tags = [];",
2173
+ " var allChecked = true;",
2174
+ " for (var ci = 0; ci < cbs.length; ci++) {",
2175
+ " if (cbs[ci].checked) { tags.push(cbs[ci].getAttribute('data-tag')); }",
2176
+ " else { allChecked = false; }",
2177
+ " }",
2178
+ " if (!allChecked) { filterChannelRows(tags); updateBulkAssignOptions(tags); }",
2179
+ " }",
1858
2180
  " var addUrlInput = document.getElementById('add-url');",
1859
2181
  " if (addUrlInput) {",
1860
2182
  " addUrlInput.addEventListener('input', function() { updateSelectorSuggestions('add-url', 'add-selectorList'); });",
@@ -1865,7 +2187,6 @@ function generateConfigSubtabScript() {
1865
2187
  " var loginStatusInterval = null;",
1866
2188
  // Start login mode for a channel. Opens browser window and shows modal.
1867
2189
  " window.startChannelLogin = function(channel) {",
1868
- " showStatus('Starting login...', 'info');",
1869
2190
  " fetch('/auth/login', {",
1870
2191
  " method: 'POST',",
1871
2192
  " headers: { 'Content-Type': 'application/json' },",
@@ -1874,14 +2195,14 @@ function generateConfigSubtabScript() {
1874
2195
  " .then(function(response) { return response.json(); })",
1875
2196
  " .then(function(result) {",
1876
2197
  " if (result.success) {",
1877
- " showStatus('Browser window opened. Complete authentication.', 'info');",
2198
+ " showToast('Browser window opened. Complete authentication.', 'info');",
1878
2199
  " showLoginModal();",
1879
2200
  " startLoginStatusPolling();",
1880
2201
  " } else {",
1881
- " showStatus(result.error || 'Failed to start login.', 'error');",
2202
+ " showToast(result.error || 'Failed to start login.', 'error');",
1882
2203
  " }",
1883
2204
  " })",
1884
- " .catch(function(err) { showStatus('Failed to start login: ' + err.message, 'error'); });",
2205
+ " .catch(function(err) { showToast('Failed to start login: ' + err.message, 'error'); });",
1885
2206
  " };",
1886
2207
  // End login mode. Closes browser tab and hides modal.
1887
2208
  " window.endLogin = function() {",
@@ -1889,9 +2210,9 @@ function generateConfigSubtabScript() {
1889
2210
  " fetch('/auth/done', { method: 'POST' })",
1890
2211
  " .then(function() {",
1891
2212
  " hideLoginModal();",
1892
- " showStatus('Authentication complete.', 'success');",
2213
+ " showToast('Authentication complete.', 'success');",
1893
2214
  " })",
1894
- " .catch(function(err) { showStatus('Error ending login: ' + err.message, 'error'); hideLoginModal(); });",
2215
+ " .catch(function(err) { showToast('Error ending login: ' + err.message, 'error'); hideLoginModal(); });",
1895
2216
  " };",
1896
2217
  // Show the login modal.
1897
2218
  " function showLoginModal() {",
@@ -1913,7 +2234,7 @@ function generateConfigSubtabScript() {
1913
2234
  " if (!status.active) {",
1914
2235
  " stopLoginStatusPolling();",
1915
2236
  " hideLoginModal();",
1916
- " showStatus('Login session ended.', 'info');",
2237
+ " showToast('Login session ended.', 'info');",
1917
2238
  " }",
1918
2239
  " })",
1919
2240
  " .catch(function() { });",
@@ -1979,6 +2300,14 @@ function generateLandingPageStyles() {
1979
2300
  // Header status bar styles.
1980
2301
  ".header-status { display: flex; gap: 20px; align-items: center; font-size: 13px; color: var(--text-secondary); }",
1981
2302
  ".header-status span { white-space: nowrap; }",
2303
+ // Stream count popover. Clickable when streams are active; popover drops from the right edge of the header.
2304
+ "#stream-count.clickable { cursor: pointer; }",
2305
+ "#stream-count.clickable:hover { color: var(--text-primary); }",
2306
+ ".stream-popover .dropdown-menu { right: 0; left: auto; min-width: 220px; }",
2307
+ ".stream-popover-row { display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 13px; white-space: nowrap; }",
2308
+ ".stream-popover-logo { height: 18px; width: auto; max-width: 80px; vertical-align: middle; }",
2309
+ ".stream-popover-channel { color: var(--text-primary); }",
2310
+ ".stream-popover-duration { color: var(--text-muted); margin-left: auto; }",
1982
2311
  // Subtab styles for Configuration tab.
1983
2312
  ".subtab-bar { display: flex; border-bottom: 1px solid var(--border-default); margin-bottom: 20px; gap: 2px; flex-wrap: wrap; }",
1984
2313
  ".subtab-btn { padding: 8px 16px; border: none; background: var(--subtab-bg); cursor: pointer; font-size: 13px; font-weight: 500; ",
@@ -2031,6 +2360,7 @@ function generateLandingPageStyles() {
2031
2360
  ".log-entry { color: var(--dark-text-secondary); }",
2032
2361
  ".log-error { color: var(--dark-text-error); }",
2033
2362
  ".log-warn { color: var(--dark-text-warn); }",
2363
+ ".log-debug { color: var(--dark-text-debug); }",
2034
2364
  ".log-muted { color: var(--dark-text-muted); }",
2035
2365
  ".log-connecting { color: var(--dark-text-muted); }",
2036
2366
  // Channel table styles. The wrapper enables horizontal scrolling on small screens.
@@ -2058,13 +2388,42 @@ function generateLandingPageStyles() {
2058
2388
  ".channel-table tr.channel-disabled td { color: var(--text-tertiary); }",
2059
2389
  ".channel-table tr.channel-disabled code { color: var(--text-tertiary); }",
2060
2390
  ".channel-table.hide-disabled tr.channel-disabled { display: none; }",
2391
+ // Provider-filtered channel row styling. Uses reduced opacity and italic text to distinguish from manually disabled rows. The compound selector ensures that rows
2392
+ // which are both disabled and provider-filtered render at the disabled-level opacity (0.5) rather than the more aggressive unavailable-level opacity (0.4).
2393
+ ".channel-table tr.channel-unavailable { opacity: 0.4; font-style: italic; }",
2394
+ ".channel-table tr.channel-unavailable td { color: var(--text-tertiary); }",
2395
+ ".channel-table tr.channel-unavailable.channel-disabled { opacity: 0.5; }",
2396
+ ".channel-table.hide-disabled tr.channel-unavailable { display: none; }",
2397
+ ".no-provider-label { color: var(--text-tertiary); font-size: 12px; }",
2398
+ // Provider filter toolbar layout.
2399
+ ".provider-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; }",
2400
+ ".provider-toolbar .toolbar-group { display: flex; align-items: center; gap: 6px; }",
2401
+ ".provider-toolbar .toolbar-label { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }",
2402
+ ".provider-toolbar .toolbar-spacer { flex: 1; }",
2403
+ // Provider dropdown multi-select.
2404
+ ".provider-dropdown-menu { min-width: 200px; max-height: 300px; overflow-y: auto; }",
2405
+ ".provider-option { display: flex; align-items: center; gap: 6px; padding: 5px 12px; font-size: 13px; cursor: pointer; color: var(--text-primary); }",
2406
+ ".provider-option:hover { background: var(--surface-sunken); }",
2407
+ ".provider-option input[type=\"checkbox\"] { margin: 0; }",
2408
+ // Provider chips.
2409
+ ".provider-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }",
2410
+ ".provider-chip { display: inline-flex; align-items: center; gap: 4px; background: var(--surface-elevated); border: 1px solid var(--border-default); ",
2411
+ "border-radius: 12px; padding: 2px 8px 2px 10px; font-size: 12px; color: var(--text-secondary); min-height: 24px; }",
2412
+ ".chip-close { background: none; border: none; cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; color: var(--text-muted); ",
2413
+ "transition: color 0.2s; }",
2414
+ ".chip-close:hover { color: var(--text-primary); }",
2415
+ // Bulk assign dropdown.
2416
+ ".bulk-assign-select { font-size: 13px; padding: 4px 8px; border: 1px solid var(--border-default); border-radius: var(--radius-md); ",
2417
+ "background: var(--surface-default); color: var(--text-primary); cursor: pointer; }",
2418
+ // Responsive: stack provider toolbar groups vertically on small screens.
2419
+ "@media (max-width: 768px) { .provider-toolbar { flex-direction: column; align-items: flex-start; } }",
2061
2420
  // Enable/Disable button styling.
2062
2421
  ".btn-enable { background: var(--status-success-bg); color: var(--status-success-text); border: 1px solid var(--status-success-border); }",
2063
2422
  ".btn-enable:hover { background: var(--status-success-border); }",
2064
2423
  ".btn-disable { background: var(--surface-elevated); color: var(--text-secondary); border: 1px solid var(--border-default); }",
2065
2424
  ".btn-disable:hover { border-color: var(--text-secondary); }",
2066
2425
  // Channel toolbar with operation buttons and display controls.
2067
- ".channel-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 15px; }",
2426
+ ".channel-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; margin-bottom: 15px; }",
2068
2427
  ".channel-toolbar .toolbar-group { display: flex; align-items: center; gap: 6px; }",
2069
2428
  ".channel-toolbar .toolbar-spacer { flex: 1; }",
2070
2429
  ".channel-toolbar .toggle-label { font-size: 12px; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; gap: 4px; ",
@@ -2106,6 +2465,7 @@ function generateLandingPageStyles() {
2106
2465
  ".profile-list dt { font-family: var(--font-mono); font-size: 13px; font-weight: 600; margin-top: 10px; color: var(--text-primary); }",
2107
2466
  ".profile-list dt:first-child { margin-top: 0; }",
2108
2467
  ".profile-list dd { color: var(--text-secondary); font-size: 13px; margin: 4px 0 0 0; }",
2468
+ ".selector-guide-heading { margin-top: 20px !important; border-top: 1px solid var(--border-default); padding-top: 16px; }",
2109
2469
  // Other landing page styles.
2110
2470
  ".endpoint code { font-size: 13px; }",
2111
2471
  // Modified value indicator styling.
@@ -2153,12 +2513,30 @@ function generateLandingPageStyles() {
2153
2513
  ".btn-danger { background: var(--interactive-danger); color: white; border: none; padding: 10px 20px; border-radius: var(--radius-md); ",
2154
2514
  "font-size: 14px; cursor: pointer; transition: all 0.15s ease; }",
2155
2515
  ".btn-danger:hover { opacity: 0.9; }",
2156
- // Status message styling for AJAX feedback. Includes transition for smooth fade-out on auto-dismiss.
2157
- ".config-status { padding: 15px; border-radius: var(--radius-lg); margin-bottom: 20px; transition: opacity 0.3s ease; }",
2158
- ".config-status.success { background: var(--status-success-bg); border: 1px solid var(--status-success-border); color: var(--status-success-text); }",
2159
- ".config-status.error { background: var(--status-error-bg); border: 1px solid var(--status-error-border); color: var(--status-error-text); }",
2160
- ".config-status.info { background: var(--status-info-bg, #e0f2fe); border: 1px solid var(--status-info-border, #7dd3fc); ",
2161
- "color: var(--status-info-text, #0369a1); }",
2516
+ // Toast notification container: fixed top-right, above all modals.
2517
+ ".toast-container { position: fixed; top: 20px; right: 20px; z-index: 1001; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }",
2518
+ // Individual toast: themed colors, slide-in animation, close button.
2519
+ ".toast { padding: 12px 36px 12px 16px; border-radius: var(--radius-md); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); min-width: 280px; max-width: 420px; ",
2520
+ "font-size: 13px; line-height: 1.4; white-space: pre-line; position: relative; pointer-events: auto; animation: toastIn 0.3s ease-out; }",
2521
+ ".toast.toast-exit { animation: toastOut 0.3s ease-in forwards; }",
2522
+ // Type variants using existing theme status variables.
2523
+ ".toast.success { background: var(--status-success-bg); border: 1px solid var(--status-success-border); color: var(--status-success-text); }",
2524
+ ".toast.error { background: var(--status-error-bg); border: 1px solid var(--status-error-border); color: var(--status-error-text); }",
2525
+ ".toast.warning { background: var(--status-warning-bg); border: 1px solid var(--status-warning-border); color: var(--status-warning-text); }",
2526
+ ".toast.info { background: var(--status-info-bg); border: 1px solid var(--status-info-border); color: var(--status-info-text); }",
2527
+ // Close button positioned top-right within each toast.
2528
+ ".toast-close { position: absolute; top: 8px; right: 8px; background: none; border: none; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 4px; ",
2529
+ "color: inherit; opacity: 0.6; }",
2530
+ ".toast-close:hover { opacity: 1; }",
2531
+ // Action button for toasts with an undo or similar inline action.
2532
+ ".toast-action { display: inline-block; margin-left: 8px; padding: 2px 10px; border: 1px solid currentColor; border-radius: var(--radius-sm); ",
2533
+ "background: none; color: inherit; cursor: pointer; font-size: 12px; font-weight: 600; opacity: 0.8; vertical-align: baseline; }",
2534
+ ".toast-action:hover { opacity: 1; background: rgba(0, 0, 0, 0.1); }",
2535
+ // Toast slide animations.
2536
+ "@keyframes toastIn { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }",
2537
+ "@keyframes toastOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }",
2538
+ // Responsive: full-width toasts on narrow screens.
2539
+ "@media (max-width: 768px) { .toast-container { left: 20px; right: 20px; } .toast { min-width: 0; max-width: none; } }",
2162
2540
  // Loading state for buttons.
2163
2541
  ".btn.loading { opacity: 0.7; pointer-events: none; }",
2164
2542
  ".btn.loading::after { content: '...'; }",
@@ -2295,7 +2673,8 @@ export function setupRootEndpoint(app) {
2295
2673
  ].join("\n");
2296
2674
  // Build the body content.
2297
2675
  const changelogModal = generateChangelogModal();
2298
- const bodyContent = [header, tabBar, tabPanels, restartModal, changelogModal].join("\n");
2676
+ const bodyContent = [header, tabBar, tabPanels, restartModal, changelogModal,
2677
+ "<div id=\"toast-container\" class=\"toast-container\"></div>"].join("\n");
2299
2678
  // Generate scripts: tab switching, config subtab handling, then status SSE for header updates.
2300
2679
  const scripts = [
2301
2680
  generateTabScript({ localStorageKey: "prismcast-home-tab" }),