prismcast 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +6 -6
  2. package/dist/app.js +24 -13
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/cdp.d.ts +4 -6
  5. package/dist/browser/cdp.js +13 -17
  6. package/dist/browser/cdp.js.map +1 -1
  7. package/dist/browser/index.js +3 -3
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/login.js +11 -8
  10. package/dist/browser/login.js.map +1 -1
  11. package/dist/browser/precaching.d.ts +1 -1
  12. package/dist/browser/precaching.js +23 -23
  13. package/dist/browser/precaching.js.map +1 -1
  14. package/dist/browser/tuning/directv.js +4 -7
  15. package/dist/browser/tuning/directv.js.map +1 -1
  16. package/dist/browser/tuning/fox.js +1 -1
  17. package/dist/browser/tuning/fox.js.map +1 -1
  18. package/dist/browser/tuning/hbo.js +1 -1
  19. package/dist/browser/tuning/hbo.js.map +1 -1
  20. package/dist/browser/tuning/hulu.js +3 -3
  21. package/dist/browser/tuning/hulu.js.map +1 -1
  22. package/dist/browser/tuning/sling.js +2 -2
  23. package/dist/browser/tuning/sling.js.map +1 -1
  24. package/dist/channels/index.d.ts +2 -0
  25. package/dist/channels/index.js +438 -252
  26. package/dist/channels/index.js.map +1 -1
  27. package/dist/config/health.d.ts +3 -3
  28. package/dist/config/health.js +13 -18
  29. package/dist/config/health.js.map +1 -1
  30. package/dist/config/index.js +11 -2
  31. package/dist/config/index.js.map +1 -1
  32. package/dist/config/persistence.d.ts +59 -0
  33. package/dist/config/persistence.js +127 -0
  34. package/dist/config/persistence.js.map +1 -0
  35. package/dist/config/profiles.js +10 -10
  36. package/dist/config/profiles.js.map +1 -1
  37. package/dist/config/servicePacks.d.ts +46 -0
  38. package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
  39. package/dist/config/servicePacks.js.map +1 -0
  40. package/dist/config/services.d.ts +202 -0
  41. package/dist/config/services.js +758 -0
  42. package/dist/config/services.js.map +1 -0
  43. package/dist/config/sites.js +61 -61
  44. package/dist/config/sites.js.map +1 -1
  45. package/dist/config/userChannels.d.ts +89 -27
  46. package/dist/config/userChannels.js +400 -184
  47. package/dist/config/userChannels.js.map +1 -1
  48. package/dist/config/userConfig.d.ts +13 -9
  49. package/dist/config/userConfig.js +78 -60
  50. package/dist/config/userConfig.js.map +1 -1
  51. package/dist/config/userProfiles.d.ts +31 -15
  52. package/dist/config/userProfiles.js +154 -123
  53. package/dist/config/userProfiles.js.map +1 -1
  54. package/dist/hdhr/channelMap.d.ts +1 -1
  55. package/dist/hdhr/channelMap.js +4 -7
  56. package/dist/hdhr/channelMap.js.map +1 -1
  57. package/dist/hdhr/discover.js +7 -15
  58. package/dist/hdhr/discover.js.map +1 -1
  59. package/dist/hdhr/index.js +6 -9
  60. package/dist/hdhr/index.js.map +1 -1
  61. package/dist/native/index.d.ts +2 -2
  62. package/dist/native/index.js +17 -27
  63. package/dist/native/index.js.map +1 -1
  64. package/dist/native/intercept.js +86 -89
  65. package/dist/native/intercept.js.map +1 -1
  66. package/dist/native/probe.js +2 -2
  67. package/dist/native/probe.js.map +1 -1
  68. package/dist/native/proxy.d.ts +2 -2
  69. package/dist/native/proxy.js +4 -4
  70. package/dist/native/proxy.js.map +1 -1
  71. package/dist/routes/assets.js +29 -51
  72. package/dist/routes/assets.js.map +1 -1
  73. package/dist/routes/auth.js +3 -3
  74. package/dist/routes/auth.js.map +1 -1
  75. package/dist/routes/channels.js +5 -11
  76. package/dist/routes/channels.js.map +1 -1
  77. package/dist/routes/components.d.ts +0 -8
  78. package/dist/routes/components.js +0 -19
  79. package/dist/routes/components.js.map +1 -1
  80. package/dist/routes/config/channels/index.d.ts +1 -1
  81. package/dist/routes/config/channels/index.js +1 -1
  82. package/dist/routes/config/channels/index.js.map +1 -1
  83. package/dist/routes/config/channels/routes.js +748 -420
  84. package/dist/routes/config/channels/routes.js.map +1 -1
  85. package/dist/routes/config/channels/table.d.ts +21 -6
  86. package/dist/routes/config/channels/table.js +456 -193
  87. package/dist/routes/config/channels/table.js.map +1 -1
  88. package/dist/routes/config/index.d.ts +2 -2
  89. package/dist/routes/config/index.js +3 -3
  90. package/dist/routes/config/index.js.map +1 -1
  91. package/dist/routes/config/{providers.js → services.js} +47 -42
  92. package/dist/routes/config/services.js.map +1 -0
  93. package/dist/routes/config/settings.js +88 -49
  94. package/dist/routes/config/settings.js.map +1 -1
  95. package/dist/routes/debug.js +5 -7
  96. package/dist/routes/debug.js.map +1 -1
  97. package/dist/routes/health.js +1 -1
  98. package/dist/routes/health.js.map +1 -1
  99. package/dist/routes/icons.d.ts +16 -0
  100. package/dist/routes/icons.js +44 -0
  101. package/dist/routes/icons.js.map +1 -0
  102. package/dist/routes/index.js +2 -2
  103. package/dist/routes/index.js.map +1 -1
  104. package/dist/routes/logs.js +4 -4
  105. package/dist/routes/logs.js.map +1 -1
  106. package/dist/routes/playlist.d.ts +6 -4
  107. package/dist/routes/playlist.js +98 -45
  108. package/dist/routes/playlist.js.map +1 -1
  109. package/dist/routes/root/content.js +94 -55
  110. package/dist/routes/root/content.js.map +1 -1
  111. package/dist/routes/root/scripts/channels.js +620 -478
  112. package/dist/routes/root/scripts/channels.js.map +1 -1
  113. package/dist/routes/root/scripts/config.js +884 -964
  114. package/dist/routes/root/scripts/config.js.map +1 -1
  115. package/dist/routes/root/scripts/shared.js +468 -188
  116. package/dist/routes/root/scripts/shared.js.map +1 -1
  117. package/dist/routes/root/scripts/status.js +252 -207
  118. package/dist/routes/root/scripts/status.js.map +1 -1
  119. package/dist/routes/root/styles.js +57 -8
  120. package/dist/routes/root/styles.js.map +1 -1
  121. package/dist/routes/services.d.ts +6 -0
  122. package/dist/routes/{providers.js → services.js} +43 -43
  123. package/dist/routes/services.js.map +1 -0
  124. package/dist/routes/ui.d.ts +1 -1
  125. package/dist/routes/ui.js +3 -1
  126. package/dist/routes/ui.js.map +1 -1
  127. package/dist/service/commands.js +8 -43
  128. package/dist/service/commands.js.map +1 -1
  129. package/dist/service/generators.d.ts +1 -1
  130. package/dist/service/generators.js +143 -41
  131. package/dist/service/generators.js.map +1 -1
  132. package/dist/streaming/codec.d.ts +20 -0
  133. package/dist/streaming/codec.js +68 -0
  134. package/dist/streaming/codec.js.map +1 -0
  135. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  136. package/dist/streaming/fmp4Segmenter.js.map +1 -1
  137. package/dist/streaming/hls.d.ts +4 -4
  138. package/dist/streaming/hls.js +35 -29
  139. package/dist/streaming/hls.js.map +1 -1
  140. package/dist/streaming/hlsResume.js +3 -3
  141. package/dist/streaming/hlsResume.js.map +1 -1
  142. package/dist/streaming/monitor.d.ts +2 -2
  143. package/dist/streaming/monitor.js +19 -13
  144. package/dist/streaming/monitor.js.map +1 -1
  145. package/dist/streaming/preroll.d.ts +15 -14
  146. package/dist/streaming/preroll.js +32 -12
  147. package/dist/streaming/preroll.js.map +1 -1
  148. package/dist/streaming/registry.d.ts +2 -2
  149. package/dist/streaming/registry.js +4 -8
  150. package/dist/streaming/registry.js.map +1 -1
  151. package/dist/streaming/setup.d.ts +4 -4
  152. package/dist/streaming/setup.js +28 -34
  153. package/dist/streaming/setup.js.map +1 -1
  154. package/dist/streaming/showInfo.js +8 -10
  155. package/dist/streaming/showInfo.js.map +1 -1
  156. package/dist/streaming/statusEmitter.d.ts +4 -3
  157. package/dist/streaming/statusEmitter.js +3 -3
  158. package/dist/streaming/statusEmitter.js.map +1 -1
  159. package/dist/types/channels.d.ts +18 -11
  160. package/dist/types/config.d.ts +3 -2
  161. package/dist/types/index.d.ts +4 -3
  162. package/dist/types/index.js +1 -0
  163. package/dist/types/index.js.map +1 -1
  164. package/dist/types/profiles.d.ts +9 -9
  165. package/dist/types/selection.d.ts +1 -1
  166. package/dist/types/shared.d.ts +1 -1
  167. package/dist/types/streaming.d.ts +8 -16
  168. package/dist/types/streaming.js +5 -1
  169. package/dist/types/streaming.js.map +1 -1
  170. package/dist/upgrade/commands.js +1 -16
  171. package/dist/upgrade/commands.js.map +1 -1
  172. package/dist/utils/chromeFetch.js +1 -1
  173. package/dist/utils/cliOutput.d.ts +10 -0
  174. package/dist/utils/cliOutput.js +17 -0
  175. package/dist/utils/cliOutput.js.map +1 -0
  176. package/dist/utils/debugFilter.js +3 -3
  177. package/dist/utils/debugFilter.js.map +1 -1
  178. package/dist/utils/fileLogger.js +4 -3
  179. package/dist/utils/fileLogger.js.map +1 -1
  180. package/dist/utils/format.d.ts +21 -4
  181. package/dist/utils/format.js +45 -7
  182. package/dist/utils/format.js.map +1 -1
  183. package/dist/utils/index.d.ts +1 -0
  184. package/dist/utils/index.js +1 -0
  185. package/dist/utils/index.js.map +1 -1
  186. package/dist/utils/logger.js +17 -27
  187. package/dist/utils/logger.js.map +1 -1
  188. package/dist/utils/morganStream.js +2 -2
  189. package/dist/utils/morganStream.js.map +1 -1
  190. package/dist/utils/platform.js +3 -2
  191. package/dist/utils/platform.js.map +1 -1
  192. package/dist/utils/retry.js +2 -4
  193. package/dist/utils/retry.js.map +1 -1
  194. package/dist/utils/streamContext.d.ts +7 -0
  195. package/dist/utils/streamContext.js +10 -1
  196. package/dist/utils/streamContext.js.map +1 -1
  197. package/dist/utils/version.js +4 -4
  198. package/dist/utils/version.js.map +1 -1
  199. package/package.json +5 -7
  200. package/dist/config/providerPacks.d.ts +0 -46
  201. package/dist/config/providerPacks.js.map +0 -1
  202. package/dist/config/providers.d.ts +0 -175
  203. package/dist/config/providers.js +0 -678
  204. package/dist/config/providers.js.map +0 -1
  205. package/dist/routes/config/providers.js.map +0 -1
  206. package/dist/routes/providers.d.ts +0 -6
  207. package/dist/routes/providers.js.map +0 -1
  208. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -15,79 +15,194 @@ export function generateSharedUtilitiesScript() {
15
15
  // Show a toast notification. Auto-dismiss durations: success/info = 5s, warning = 8s, error = no auto-dismiss. Optional action: { label, onclick } appends an
16
16
  // inline button between the message text and the close button.
17
17
  " function showToast(message, type, duration, action) {",
18
- " var container = document.getElementById('toast-container');",
19
- " if (!container) return;",
20
- " var toast = document.createElement('div');",
18
+ " const container = document.getElementById('toast-container');",
19
+ " if(!container) return;",
20
+ " const toast = document.createElement('div');",
21
21
  " toast.className = 'toast ' + (type || 'info');",
22
22
  " toast.textContent = message;",
23
- " toast.setAttribute('role', (type === 'error' || type === 'warning') ? 'alert' : 'status');",
24
- " if (action && action.label) {",
25
- " var actionBtn = document.createElement('button');",
23
+ " toast.setAttribute('role', ((type === 'error') || (type === 'warning')) ? 'alert' : 'status');",
24
+ " if(action && action.label) {",
25
+ " const actionBtn = document.createElement('button');",
26
26
  " actionBtn.type = 'button';",
27
27
  " actionBtn.className = 'toast-action';",
28
28
  " actionBtn.textContent = action.label;",
29
- " actionBtn.onclick = function() { if (action.onclick) action.onclick(); dismissToast(toast); };",
29
+ " actionBtn.onclick = () => { if(action.onclick) action.onclick(); dismissToast(toast); };",
30
30
  " toast.appendChild(actionBtn);",
31
31
  " }",
32
- " var closeBtn = document.createElement('button');",
32
+ " const closeBtn = document.createElement('button');",
33
33
  " closeBtn.type = 'button';",
34
34
  " closeBtn.className = 'toast-close';",
35
35
  " closeBtn.textContent = '\\u00d7';",
36
36
  " closeBtn.setAttribute('aria-label', 'Dismiss');",
37
- " closeBtn.onclick = function() { dismissToast(toast); };",
37
+ " closeBtn.onclick = () => { dismissToast(toast); };",
38
38
  " toast.appendChild(closeBtn);",
39
39
  " container.appendChild(toast);",
40
- " var ms = duration !== undefined ? duration : type === 'error' ? 0 : type === 'warning' ? 8000 : 5000;",
41
- " if (ms > 0) { setTimeout(function() { dismissToast(toast); }, ms); }",
40
+ " const ms = (duration !== undefined) ? duration : ((type === 'error') ? 0 : ((type === 'warning') ? 8000 : 5000));",
41
+ " if(ms > 0) { setTimeout(() => { dismissToast(toast); }, ms); }",
42
42
  " }",
43
43
  " window.showToast = showToast;",
44
44
  // Dismiss a toast with slide-out animation.
45
45
  " function dismissToast(toast) {",
46
- " if (toast.classList.contains('toast-exit')) return;",
46
+ " if(toast.classList.contains('toast-exit')) return;",
47
47
  " toast.classList.add('toast-exit');",
48
- " toast.addEventListener('animationend', function() { if (toast.parentNode) toast.parentNode.removeChild(toast); });",
48
+ " toast.addEventListener('animationend', () => { toast.remove(); });",
49
49
  " }",
50
- // Close all open dropdown menus and remove the scroll and resize listeners.
51
- " function closeDropdowns() {",
52
- " var menus = document.querySelectorAll('.dropdown-menu.show');",
53
- " for (var i = 0; i < menus.length; i++) menus[i].classList.remove('show');",
54
- " window.removeEventListener('scroll', closeDropdowns, true);",
55
- " window.removeEventListener('resize', closeDropdowns);",
50
+ /* Client-side namespace SSOT for all dropdown operations — close, and before-close hook registration. Private state (the hook array and both functions)
51
+ * lives in module-scope closures so the scroll/resize listener references stay stable across open/close cycles. The namespace object exposes the two
52
+ * operations as property references into those module-scope functions, which keeps both entries structurally symmetric and preserves identity for
53
+ * addEventListener / removeEventListener. Consumers call dropdowns.close() to close all open menus and dropdowns.addHook(fn) to register a before-close
54
+ * callback — for example, the inline tag editor registers a batch-save callback so pending changes flush on any close path (click outside, scroll,
55
+ * resize, next toggle). Registration is de-duplicated so accidental double-registration is a no-op.
56
+ *
57
+ * This is the "closure-backed" variant of the client-side namespace pattern documented in CLAUDE.md. The channelTable namespace uses the this.*-backed
58
+ * variant because its methods are only ever called as methods, never as detached listener references; dropdowns cannot use that variant because close()
59
+ * must survive being passed to addEventListener and later matched in removeEventListener.
60
+ */
61
+ " const dropdownHooks = [];",
62
+ " function addDropdownHook(fn) {",
63
+ " if(!dropdownHooks.includes(fn)) dropdownHooks.push(fn);",
64
+ " }",
65
+ " function closeAllDropdowns() {",
66
+ " for(const hook of dropdownHooks) hook();",
67
+ " const menus = document.querySelectorAll('.dropdown-menu.show');",
68
+ " for(const menu of menus) menu.classList.remove('show');",
69
+ " window.removeEventListener('scroll', closeAllDropdowns, true);",
70
+ " window.removeEventListener('resize', closeAllDropdowns);",
71
+ " }",
72
+ " window.dropdowns = {",
73
+ " addHook: addDropdownHook,",
74
+ " close: closeAllDropdowns",
75
+ " };",
76
+ /* Safe localStorage wrappers. localStorage access can throw in private browsing mode or when storage quota is exceeded. Centralizing the try/catch here
77
+ * means every call site treats localStorage as best-effort persistence without repeating the same try/catch at every site. Failures are silently ignored
78
+ * because they are not actionable by the client — there is no way to recover quota or exit private browsing from JavaScript, and log noise from every
79
+ * subtab switch or page load would dominate useful diagnostics. Callers that need to react to failure should use the raw localStorage API directly.
80
+ */
81
+ " window.safeStorageGet = (key) => {",
82
+ " try { return localStorage.getItem(key); }",
83
+ " catch(e) { return null; }",
84
+ " };",
85
+ " window.safeStorageSet = (key, value) => {",
86
+ " try { localStorage.setItem(key, value); }",
87
+ " catch(e) {}",
88
+ " };",
89
+ " window.safeStorageRemove = (key) => {",
90
+ " try { localStorage.removeItem(key); }",
91
+ " catch(e) {}",
92
+ " };",
93
+ /* Persist channel table display preferences to the server. Single source of truth for POSTs to /config/channels/display-prefs so every call site (sort,
94
+ * column visibility, and any future preference) shares one fetch path and one error-handling decision. Fire-and-forget — the caller does not await the
95
+ * round-trip because the client state is already updated and the persist is a best-effort sync to the server.
96
+ */
97
+ " window.persistDisplayPrefs = (body) => {",
98
+ " fetch('/config/channels/display-prefs', {",
99
+ " body: JSON.stringify(body),",
100
+ " headers: { 'Content-Type': 'application/json' },",
101
+ " method: 'POST'",
102
+ " }).catch((err) => { console.warn('Display preferences failed to persist.', err); });",
103
+ " };",
104
+ /* Subtab switching factory. Creates a reusable switch function parameterized by CSS selectors, storage key, and hash format, and attaches click event
105
+ * handlers to all matching buttons so consumers need not wire the click-to-switch binding separately. The returned function handles button active states,
106
+ * panel visibility, localStorage persistence (via safeStorageSet), and URL hash updates. An optional onSwitch callback lets the caller inject per-tab-category
107
+ * logic (e.g., hiding settings buttons on the backup subtab in config). Used by both the Config and Channels tab subtab systems.
108
+ */
109
+ " window.createSubtabSwitcher = (config) => {",
110
+ " const switchFn = (subtab, updateUrl) => {",
111
+ " for(const btn of document.querySelectorAll(config.btnSelector)) {",
112
+ " btn.classList.remove('active');",
113
+ " btn.setAttribute('aria-selected', 'false');",
114
+ " if(btn.getAttribute(config.dataAttr) === subtab) {",
115
+ " btn.classList.add('active');",
116
+ " btn.setAttribute('aria-selected', 'true');",
117
+ " }",
118
+ " }",
119
+ " for(const panel of document.querySelectorAll(config.panelSelector)) {",
120
+ " panel.classList.remove('active');",
121
+ " if(panel.id === (config.panelPrefix + subtab)) panel.classList.add('active');",
122
+ " }",
123
+ " if(config.onSwitch) config.onSwitch(subtab);",
124
+ " safeStorageSet(config.storageKey, subtab);",
125
+ " if(updateUrl !== false) {",
126
+ " const newHash = config.hashFn(subtab);",
127
+ " if(window.location.hash !== newHash) window.location.hash = newHash;",
128
+ " }",
129
+ " };",
130
+ " for(const btn of document.querySelectorAll(config.btnSelector)) {",
131
+ " btn.addEventListener('click', function() { switchFn(this.getAttribute(config.dataAttr)); });",
132
+ " }",
133
+ " return switchFn;",
56
134
  " };",
57
- " window.closeDropdowns = closeDropdowns;",
58
- // Toggle a dropdown menu open or closed. On first call for a given button, the menu is detached from its .dropdown parent and portaled to <body> with
59
- // position: fixed. This allows the menu to escape overflow: auto containers like the channel table wrapper. On every open, the menu is positioned relative
60
- // to the button's bounding rect, with edge-of-viewport clamping and above-button flip when it would extend below the viewport.
61
- " window.toggleDropdown = function(btn) {",
62
- " var menu = btn._portalMenu;",
63
- " var isOpen = menu && menu.classList.contains('show');",
64
- " closeDropdowns();",
65
- " if (isOpen) return;",
66
- " if (!menu) {",
67
- " menu = btn.nextElementSibling;",
68
- " if (!menu) return;",
69
- " btn._portalMenu = menu;",
135
+ /* Initialize a subtab system on page load. Reads the initial subtab from the hash (set by the main tab script) or localStorage, validates the button
136
+ * exists in the DOM, and calls the switch function to activate it. Called once per subtab system at the end of the IIFE.
137
+ */
138
+ " window.initSubtab = (config) => {",
139
+ " const initial = config.hashVar ?? safeStorageGet(config.storageKey);",
140
+ " if(initial && document.querySelector(config.btnSelector + '[' + config.dataAttr + '=\"' + initial + '\"]')) {",
141
+ " config.switchFn(initial, false);",
142
+ " }",
143
+ " };",
144
+ // Copy text to the clipboard and show a toast. Uses the modern Clipboard API when available (secure contexts), falling back to execCommand for plain HTTP
145
+ // access via IP address.
146
+ " window.copyToClipboard = async (text, successMessage) => {",
147
+ " if(navigator.clipboard?.writeText) {",
148
+ " try { await navigator.clipboard.writeText(text); showToast(successMessage, 'success'); }",
149
+ " catch(e) { showToast('Failed to copy to clipboard.', 'error'); }",
150
+ " } else {",
151
+ " const ta = document.createElement('textarea');",
152
+ " ta.value = text;",
153
+ " ta.style.position = 'fixed';",
154
+ " ta.style.opacity = '0';",
155
+ " document.body.appendChild(ta);",
156
+ " ta.select();",
157
+ " try { document.execCommand('copy'); showToast(successMessage, 'success'); }",
158
+ " catch(e) { showToast('Failed to copy to clipboard.', 'error'); }",
159
+ " document.body.removeChild(ta);",
160
+ " }",
161
+ " };",
162
+ // Layer 1: Portal positioning. Positions a menu element below an anchor with viewport edge clamping and above-anchor flip. Uses position: absolute with
163
+ // scroll offsets for reliable behavior at all browser zoom levels. The menu must be visible (display: block) for offsetWidth/offsetHeight measurement.
164
+ // This is the single source of truth for all portal positioning math.
165
+ " function positionPortal(menu, anchor) {",
166
+ " const rect = anchor.getBoundingClientRect();",
167
+ " let top = rect.bottom + 2;",
168
+ " let left = rect.left;",
169
+ " if((left + menu.offsetWidth) > (window.innerWidth - 4)) left = rect.right - menu.offsetWidth;",
170
+ " if(left < 4) left = 4;",
171
+ " if((top + menu.offsetHeight) > (window.innerHeight - 4)) top = rect.top - menu.offsetHeight - 2;",
172
+ " const sx = window.scrollX || 0;",
173
+ " const sy = window.scrollY || 0;",
174
+ " menu.style.top = (top + sy) + 'px';",
175
+ " menu.style.left = (left + sx) + 'px';",
176
+ " }",
177
+ " window.positionPortal = positionPortal;",
178
+ // Layer 2: Dropdown toggle with lifecycle hooks. Manages portal append, toggle state, scroll/resize dismissal, and optional lifecycle callbacks. The
179
+ // zero-argument form (toggleDropdown(btn)) works unchanged for standard dropdowns. The options form adds: menu (explicit menu element), onOpen (called
180
+ // after show but before positioning so content can be set and measured correctly for viewport clamping).
181
+ " window.toggleDropdown = (btn, opts) => {",
182
+ " const o = opts || {};",
183
+ " const menu = btn._portalMenu || o.menu || btn.nextElementSibling;",
184
+ " if(!menu) return;",
185
+ " const isOpen = menu.classList.contains('show');",
186
+ " closeAllDropdowns();",
187
+ " if(isOpen) return;",
188
+ " btn._portalMenu = menu;",
189
+ " if(!menu._portaled) {",
70
190
  " document.body.appendChild(menu);",
71
- " menu.style.position = 'fixed';",
191
+ " menu.style.position = 'absolute';",
72
192
  " menu.style.marginTop = '0';",
193
+ " menu._portaled = true;",
73
194
  " }",
74
195
  " menu.classList.add('show');",
75
- " var rect = btn.getBoundingClientRect();",
76
- " var top = rect.bottom + 2;",
77
- " var left = rect.left;",
78
- " if (left + menu.offsetWidth > window.innerWidth - 4) left = rect.right - menu.offsetWidth;",
79
- " if (left < 4) left = 4;",
80
- " if (top + menu.offsetHeight > window.innerHeight - 4) top = rect.top - menu.offsetHeight - 2;",
81
- " menu.style.top = top + 'px';",
82
- " menu.style.left = left + 'px';",
83
- " window.addEventListener('scroll', closeDropdowns, true);",
84
- " window.addEventListener('resize', closeDropdowns);",
196
+ " if(o.onOpen) o.onOpen(menu);",
197
+ " positionPortal(menu, btn);",
198
+ " window.addEventListener('scroll', closeAllDropdowns, true);",
199
+ " window.addEventListener('resize', closeAllDropdowns);",
85
200
  " };",
86
201
  // Shared channel display renderer with three modes. The mode parameter controls presentation: 'logo' (default) shows the logo with text as an onerror
87
202
  // fallback, 'both' shows the logo and text side by side with onerror hiding the broken image, 'text' shows only the text and ignores the logo URL.
88
- " window.channelDisplayHtml = function(logoUrl, name, logoClass, textClass, mode) {",
89
- " var m = mode || 'logo';",
90
- " if(m === 'text' || !logoUrl) {",
203
+ " window.channelDisplayHtml = (logoUrl, name, logoClass, textClass, mode) => {",
204
+ " const m = mode || 'logo';",
205
+ " if((m === 'text') || !logoUrl) {",
91
206
  " return '<span class=\"' + textClass + '\">' + name + '</span>';",
92
207
  " }",
93
208
  " if(m === 'both') {",
@@ -101,29 +216,29 @@ export function generateSharedUtilitiesScript() {
101
216
  " };",
102
217
  // Image fallback handler. Reads pipe-separated fallback URLs from a data-fallbacks attribute and tries each in sequence on error. When all fallbacks are
103
218
  // exhausted, hides the image and reveals the text sibling if it was hidden (logo mode).
104
- " window.imgFallback = function(img) {",
105
- " var fallbacks = (img.getAttribute('data-fallbacks') || '').split('|').filter(Boolean);",
106
- " var idx = parseInt(img.getAttribute('data-fb-idx') || '0', 10);",
219
+ " window.imgFallback = (img) => {",
220
+ " const fallbacks = (img.getAttribute('data-fallbacks') || '').split('|').filter(Boolean);",
221
+ " const idx = parseInt(img.getAttribute('data-fb-idx') || '0', 10);",
107
222
  " if(idx < fallbacks.length) {",
108
223
  " img.setAttribute('data-fb-idx', String(idx + 1));",
109
224
  " img.src = fallbacks[idx];",
110
225
  " } else {",
111
226
  " img.style.display = 'none';",
112
- " var sib = img.nextElementSibling;",
113
- " if(sib && sib.style.display === 'none') sib.style.display = 'inline';",
227
+ " const sib = img.nextElementSibling;",
228
+ " if(sib && (sib.style.display === 'none')) sib.style.display = 'inline';",
114
229
  " }",
115
230
  " };",
116
- // Provider icon renderer with three modes, mirroring channelDisplayHtml. The icon source chain is: iconUrl (if specified) → Apple touch icon → favicon.
231
+ // Service icon renderer with three modes, mirroring channelDisplayHtml. The icon source chain is: iconUrl (if specified) → Apple touch icon → favicon.
117
232
  // Fallback URLs are stored in a data-fallbacks attribute and processed by the shared imgFallback handler.
118
- " window.providerIconHtml = function(domain, name, iconClass, textClass, mode, iconUrl) {",
119
- " var m = mode || 'logo';",
120
- " if(m === 'text' || !domain) {",
233
+ " window.serviceIconHtml = (domain, name, iconClass, textClass, mode, iconUrl) => {",
234
+ " const m = mode || 'logo';",
235
+ " if((m === 'text') || !domain) {",
121
236
  " return '<span class=\"' + textClass + '\">' + name + '</span>';",
122
237
  " }",
123
- " var touchIcon = 'https://' + domain + '/apple-touch-icon.png';",
124
- " var favicon = 'https://' + domain + '/favicon.ico';",
125
- " var src = iconUrl || touchIcon;",
126
- " var fallbacks = (iconUrl ? [ touchIcon, favicon ] : [ favicon ]).join('|');",
238
+ " const touchIcon = 'https://' + domain + '/apple-touch-icon.png';",
239
+ " const favicon = 'https://' + domain + '/favicon.ico';",
240
+ " const src = iconUrl || touchIcon;",
241
+ " const fallbacks = (iconUrl ? [ touchIcon, favicon ] : [ favicon ]).join('|');",
127
242
  " if(m === 'both') {",
128
243
  " return '<img src=\"' + src + '\" class=\"' + iconClass + '\" alt=\"\" title=\"' + name + '\" ' +",
129
244
  " 'data-fallbacks=\"' + fallbacks + '\" onerror=\"imgFallback(this)\">' +",
@@ -133,33 +248,17 @@ export function generateSharedUtilitiesScript() {
133
248
  " 'data-fallbacks=\"' + fallbacks + '\" onerror=\"imgFallback(this)\">' +",
134
249
  " '<span class=\"' + textClass + '\" style=\"display:none\">' + name + '</span>';",
135
250
  " };",
136
- // Process channel logo data attributes. Finds all td[data-logo] elements and renders their channel-name-cell spans via channelDisplayHtml in 'both' mode.
137
- // Called on page load and after any DOM mutation that inserts server-rendered channel rows (add, edit, delete-with-replacement).
138
- " window.processChannelLogos = function() {",
139
- " var cells = document.querySelectorAll('td[data-logo]');",
140
- " for(var i = 0; i < cells.length; i++) {",
141
- " var cell = cells[i];",
142
- " if(cell.getAttribute('data-logo-processed')) continue;",
143
- " var url = cell.getAttribute('data-logo');",
144
- " var nameSpan = cell.querySelector('.channel-name-cell');",
145
- " if(url && nameSpan) {",
146
- " nameSpan.innerHTML = channelDisplayHtml(url, nameSpan.textContent || '', 'channel-table-logo', 'channel-table-text', 'both');",
147
- " cell.setAttribute('data-logo-processed', '1');",
148
- " }",
149
- " }",
150
- " };",
151
- // Process provider display spans. Finds all .provider-display elements and renders them via providerIconHtml in 'both' mode. Called on page load and after
152
- // any DOM mutation that introduces new provider display elements (chip rebuild, filter updates).
153
- " window.processProviderDisplays = function() {",
154
- " var els = document.querySelectorAll('.provider-display');",
155
- " for(var i = 0; i < els.length; i++) {",
156
- " var el = els[i];",
251
+ // Process service display spans. Finds all .provider-display elements and renders them via serviceIconHtml in 'both' mode. Called on page load and after
252
+ // any DOM mutation that introduces new service display elements (chip rebuild, filter updates).
253
+ " window.processServiceDisplays = () => {",
254
+ " const els = document.querySelectorAll('.provider-display');",
255
+ " for(const el of els) {",
157
256
  " if(el.getAttribute('data-processed')) continue;",
158
- " var domain = el.getAttribute('data-domain') || '';",
159
- " var iconUrl = el.getAttribute('data-icon-url') || '';",
160
- " var name = el.textContent || '';",
161
- " var sm = el.hasAttribute('data-sm');",
162
- " el.innerHTML = providerIconHtml(domain, name, sm ? 'provider-icon-sm' : 'provider-icon',",
257
+ " const domain = el.getAttribute('data-domain') || '';",
258
+ " const iconUrl = el.getAttribute('data-icon-url') || '';",
259
+ " const name = el.textContent || '';",
260
+ " const sm = el.hasAttribute('data-sm');",
261
+ " el.innerHTML = serviceIconHtml(domain, name, sm ? 'provider-icon-sm' : 'provider-icon',",
163
262
  " sm ? 'provider-chip-text' : 'provider-icon-text', 'both', iconUrl);",
164
263
  " el.setAttribute('data-processed', '1');",
165
264
  " }",
@@ -181,29 +280,29 @@ export function generateSharedUtilitiesScript() {
181
280
  // - titleId: ID of the title h3 element (optional, for dynamic title updates).
182
281
  //
183
282
  // Returns: { back, close, getStep, goToStep, hide, next, open, setError, setTitle, show, state }
184
- " window.createWizardController = function(config) {",
185
- " var currentStep = 1;",
186
- " var highestStep = 1;",
187
- " var stepsContainerId = config.stepsId || (config.modalId + '-steps');",
283
+ " window.createWizardController = (config) => {",
284
+ " let currentStep = 1;",
285
+ " let highestStep = 1;",
286
+ " const stepsContainerId = config.stepsId || (config.modalId + '-steps');",
188
287
  // Update the step indicator DOM. Iterates .wizard-step children within the scoped container, setting active/completed/clickable classes and attaching
189
288
  // click handlers for visited steps.
190
289
  " function updateStepIndicator() {",
191
- " var container = document.getElementById(stepsContainerId);",
290
+ " const container = document.getElementById(stepsContainerId);",
192
291
  " if(!container) return;",
193
- " var steps = container.querySelectorAll('.wizard-step');",
194
- " for(var i = 0; i < steps.length; i++) {",
195
- " var stepNum = parseInt(steps[i].getAttribute('data-step'), 10);",
196
- " steps[i].classList.remove('active', 'completed', 'clickable');",
197
- " if(stepNum < currentStep) steps[i].classList.add('completed');",
198
- " if(stepNum === currentStep) steps[i].classList.add('active');",
199
- " if(stepNum !== currentStep && stepNum <= highestStep) steps[i].classList.add('clickable');",
200
- " steps[i].onclick = (function(n) { return function() { ctrl.goToStep(n); }; })(stepNum);",
292
+ " const steps = container.querySelectorAll('.wizard-step');",
293
+ " for(const step of steps) {",
294
+ " const stepNum = parseInt(step.getAttribute('data-step'), 10);",
295
+ " step.classList.remove('active', 'completed', 'clickable');",
296
+ " if(stepNum < currentStep) step.classList.add('completed');",
297
+ " if(stepNum === currentStep) step.classList.add('active');",
298
+ " if((stepNum !== currentStep) && (stepNum <= highestStep)) step.classList.add('clickable');",
299
+ " step.onclick = () => ctrl.goToStep(stepNum);",
201
300
  " }",
202
301
  " }",
203
302
  // Clear the error display.
204
303
  " function clearError() {",
205
304
  " if(!config.errorId) return;",
206
- " var el = document.getElementById(config.errorId);",
305
+ " const el = document.getElementById(config.errorId);",
207
306
  " if(el) { el.textContent = ''; el.style.display = 'none'; }",
208
307
  " }",
209
308
  // Render the current step. Clears errors, updates the step indicator, and delegates to the caller's onRender callback.
@@ -222,30 +321,32 @@ export function generateSharedUtilitiesScript() {
222
321
  // return is always a Promise, so callers can uniformly await the result regardless of whether onValidate is sync or async.
223
322
  " async function validateAndAdvance(target) {",
224
323
  " try {",
225
- " var err = await config.onValidate(currentStep);",
324
+ " const err = await config.onValidate(currentStep);",
226
325
  " if(err) { ctrl.setError(err); return false; }",
227
326
  " advance(target);",
228
327
  " return true;",
229
- " } catch(e) { ctrl.setError('Validation failed.'); return false; }",
328
+ " } catch(e) { console.error('Wizard validation threw an unexpected error.', e); ctrl.setError('Validation failed.'); return false; }",
230
329
  " }",
231
- " var ctrl = {",
330
+ " const ctrl = {",
232
331
  // Arbitrary state object for the caller to store wizard-specific data. Reset to {} on close.
233
332
  " state: {},",
234
333
  // Navigate backward one step.
235
- " back: function() {",
334
+ " back() {",
236
335
  " if(currentStep > 1) { currentStep--; render(); }",
237
336
  " },",
238
337
  // Close the modal. Invokes the optional onClose callback before hiding, then resets state.
239
- " close: function() {",
338
+ " close() {",
240
339
  " if(config.onClose) config.onClose();",
241
340
  " document.getElementById(config.modalId).style.display = 'none';",
242
341
  " ctrl.state = {};",
243
342
  " },",
244
343
  // Return the current step number.
245
- " getStep: function() { return currentStep; },",
344
+ " getStep() {",
345
+ " return currentStep;",
346
+ " },",
246
347
  // Navigate to a specific step by clicking the step indicator. Going backward is always allowed for visited steps. Going forward validates the current step
247
348
  // and only allows jumps to previously visited steps. Supports async validation - returns a Promise when onValidate is async.
248
- " goToStep: function(n) {",
349
+ " goToStep(n) {",
249
350
  " if(n === currentStep) return;",
250
351
  " if(n > highestStep) return;",
251
352
  " if(n > currentStep) {",
@@ -255,133 +356,312 @@ export function generateSharedUtilitiesScript() {
255
356
  " render();",
256
357
  " },",
257
358
  // Hide a button by ID.
258
- " hide: function(id) {",
259
- " var el = document.getElementById(id);",
359
+ " hide(id) {",
360
+ " const el = document.getElementById(id);",
260
361
  " if(el) el.style.display = 'none';",
261
362
  " },",
262
363
  // Navigate forward one step. Validates the current step first. Returns false (sync) or a Promise resolving to false (async) if validation failed.
263
- " next: function() {",
364
+ " next() {",
264
365
  " if(currentStep >= config.stepCount) return true;",
265
366
  " return validateAndAdvance(currentStep + 1);",
266
367
  " },",
267
368
  // Open the modal and render. Options: { step, highestStep, title } - all optional.
268
- " open: function(options) {",
269
- " var opts = options || {};",
369
+ " open(options) {",
370
+ " const opts = options || {};",
270
371
  " currentStep = opts.step || 1;",
271
372
  " highestStep = opts.highestStep || currentStep;",
272
373
  " if(opts.title && config.titleId) {",
273
- " var titleEl = document.getElementById(config.titleId);",
374
+ " const titleEl = document.getElementById(config.titleId);",
274
375
  " if(titleEl) titleEl.textContent = opts.title;",
275
376
  " }",
276
377
  " document.getElementById(config.modalId).style.display = 'flex';",
277
378
  " render();",
278
379
  " },",
279
380
  // Show a validation error message.
280
- " setError: function(msg) {",
381
+ " setError(msg) {",
281
382
  " if(!config.errorId) return;",
282
- " var el = document.getElementById(config.errorId);",
383
+ " const el = document.getElementById(config.errorId);",
283
384
  " if(el) { el.textContent = msg; el.style.display = msg ? '' : 'none'; }",
284
385
  " },",
285
386
  // Update the modal title dynamically.
286
- " setTitle: function(html) {",
387
+ " setTitle(html) {",
287
388
  " if(!config.titleId) return;",
288
- " var el = document.getElementById(config.titleId);",
389
+ " const el = document.getElementById(config.titleId);",
289
390
  " if(el) el.innerHTML = html;",
290
391
  " },",
291
392
  // Show a button by ID.
292
- " show: function(id) {",
293
- " var el = document.getElementById(id);",
393
+ " show(id) {",
394
+ " const el = document.getElementById(id);",
294
395
  " if(el) el.style.display = '';",
295
396
  " }",
296
397
  " };",
297
398
  // Attach click handlers to controller-managed buttons within the modal. Role-tagged buttons (data-wizard-role) and the X close button (.wizard-close) get
298
399
  // handlers attached from inside the IIFE closure, where the controller variable is accessible. This is the same pattern used for step indicator click
299
400
  // handlers and avoids the inline-onclick-to-global-scope problem that breaks when controller instances are IIFE-scoped.
300
- " var modal = document.getElementById(config.modalId);",
401
+ " const modal = document.getElementById(config.modalId);",
301
402
  " if(modal) {",
302
- " var roleButtons = modal.querySelectorAll('[data-wizard-role]');",
303
- " for(var i = 0; i < roleButtons.length; i++) {",
304
- " (function(btn) {",
305
- " var role = btn.getAttribute('data-wizard-role');",
306
- " if(role === 'back') { btn.onclick = function() { ctrl.back(); }; }",
307
- " else if(role === 'next') { btn.onclick = function() { ctrl.next(); }; }",
308
- " else if(role === 'close') { btn.onclick = function() { ctrl.close(); }; }",
309
- " })(roleButtons[i]);",
403
+ " const roleButtons = modal.querySelectorAll('[data-wizard-role]');",
404
+ " for(const btn of roleButtons) {",
405
+ " const role = btn.getAttribute('data-wizard-role');",
406
+ " if(role === 'back') { btn.onclick = () => { ctrl.back(); }; }",
407
+ " else if(role === 'next') { btn.onclick = () => { ctrl.next(); }; }",
408
+ " else if(role === 'close') { btn.onclick = () => { ctrl.close(); }; }",
310
409
  " }",
311
- " var closeBtn = modal.querySelector('.wizard-close');",
312
- " if(closeBtn) { closeBtn.onclick = function() { ctrl.close(); }; }",
410
+ " const closeBtn = modal.querySelector('.wizard-close');",
411
+ " if(closeBtn) { closeBtn.onclick = () => { ctrl.close(); }; }",
313
412
  " }",
314
413
  " return ctrl;",
315
414
  " };",
415
+ /* Channel table DOM manipulation namespace. All functions that insert, remove, filter, sort, or update channel table rows live here as methods of a single
416
+ * namespace object. Internal collaborators invoke each other via this.* which keeps the call graph contained inside the namespace. External callers across
417
+ * all client scripts use channelTable.* (e.g., channelTable.applyPatch, channelTable.filter).
418
+ */
419
+ " window.channelTable = {",
420
+ /* Cached column index map for _getSortValue. Built lazily on first access by scanning the table header row's data-sort-field attributes, then held for
421
+ * the page lifetime with no invalidation path. This relies on a load-bearing invariant: the channel table header is rendered once by the server and
422
+ * never rebuilt client-side — only CSS visibility classes are toggled by toggleColumn, never DOM structure. Do not introduce dynamic thead mutation
423
+ * without also adding an explicit cache invalidation (e.g., set _colIndexCache = null after rewriting the header row).
424
+ */
425
+ " _colIndexCache: null,",
426
+ // Read the server-stamped sort value from a row's cell for a given field. Private helper used by sort() and insertRow() to compute insertion order.
427
+ " _getSortValue(row, field) {",
428
+ " if(!this._colIndexCache) {",
429
+ " this._colIndexCache = {};",
430
+ " const ths = document.querySelectorAll('.channel-table th[data-sort-field]');",
431
+ " for(let i = 0; i < ths.length; i++) { this._colIndexCache[ths[i].getAttribute('data-sort-field')] = i; }",
432
+ " }",
433
+ " const idx = this._colIndexCache[field];",
434
+ " if(idx === undefined) return '';",
435
+ " const cell = row.children[idx];",
436
+ " return cell?.getAttribute('data-sort-value') || '';",
437
+ " },",
316
438
  // Apply a channel table patch from a mutation response or SSE event. Handles whichever fields are present: rows (insert/remove), counts (summary header),
317
- // scopeCounts (Quick Actions toggles), logos (channel logo attributes). This is the single client-side function for all channel table updates.
318
- " window.applyChannelPatch = function(patch) {",
319
- " if(!patch) return;",
439
+ // scopeCounts (Quick Actions toggles), hdhrCounts, tagCounts, logos. This is the single entry point for all channel table updates.
440
+ " applyPatch(patch) {",
441
+ " if(!patch) return;",
320
442
  // Apply row updates (insert, replace, or remove).
321
- " if(patch.rows) {",
322
- " for(var i = 0; i < patch.rows.length; i++) {",
323
- " var row = patch.rows[i];",
324
- " if(row.action === 'remove') {",
325
- " if(window.removeChannelRow) window.removeChannelRow(row.key);",
326
- " } else if(row.action === 'update' && row.displayRow) {",
327
- " if(window.insertChannelRow) window.insertChannelRow({ displayRow: row.displayRow, editRow: row.editRow || '' }, row.key);",
443
+ " if(patch.rows) {",
444
+ " for(const row of patch.rows) {",
445
+ " if(row.action === 'remove') this.removeRow(row.key);",
446
+ " else if((row.action === 'update') && row.displayRow) {",
447
+ " this.insertRow({ displayRow: row.displayRow, editRow: row.editRow || '' }, row.key);",
448
+ " }",
328
449
  " }",
329
450
  " }",
330
- " }",
331
- // Apply summary counts directly from server-computed values. Replaces the client-side DOM-scanning updateDisabledCount pattern.
332
- " if(patch.counts) {",
333
- " var c = patch.counts;",
334
- " var el;",
335
- " el = document.getElementById('total-count'); if(el) el.textContent = String(c.total);",
336
- " el = document.getElementById('enabled-count'); if(el) el.textContent = String(c.enabled);",
337
- " el = document.getElementById('disabled-count'); if(el) el.textContent = String(c.disabled);",
338
- " el = document.getElementById('predefined-count'); if(el) el.textContent = String(c.predefined);",
339
- " el = document.getElementById('user-count'); if(el) el.textContent = (c.user > 0) ? ', ' + String(c.user) + ' user' : '';",
340
- " }",
341
- // Apply scope toggle counts from server-computed values. Replaces the updateScopeToggles pattern.
342
- " if(patch.scopeCounts) {",
343
- " var scopes = ['all', 'east', 'pacific'];",
344
- " for(var j = 0; j < scopes.length; j++) {",
345
- " var s = scopes[j];",
346
- " var sc = patch.scopeCounts[s];",
347
- " if(!sc) continue;",
348
- " var cb = document.querySelector('.scope-toggle[data-scope=\"' + s + '\"]');",
349
- " var span = document.querySelector('.quick-action-count[data-scope=\"' + s + '\"]');",
350
- " if(cb) {",
351
- " cb.checked = (sc.enabled === sc.total);",
352
- " cb.indeterminate = (sc.enabled > 0) && (sc.enabled < sc.total);",
353
- " }",
354
- " if(span) { span.setAttribute('data-enabled', sc.enabled); span.setAttribute('data-total', sc.total); " +
451
+ // Apply summary counts directly from server-computed values. The entries array drives a single loop rather than repeating the getElementById + null-check
452
+ // pattern for each counter. Alphabetized by element ID.
453
+ " if(patch.counts) {",
454
+ " const c = patch.counts;",
455
+ " for(const [ id, val ] of [",
456
+ " [ 'disabled-count', String(c.disabled) ],",
457
+ " [ 'enabled-count', String(c.enabled) ],",
458
+ " [ 'predefined-count', String(c.predefined) ],",
459
+ " [ 'total-count', String(c.total) ],",
460
+ " [ 'user-count', (c.user > 0) ? ', ' + String(c.user) + ' user' : '' ]",
461
+ " ]) { const el = document.getElementById(id); if(el) el.textContent = val; }",
462
+ " }",
463
+ // Apply scope toggle counts from server-computed values. Iterates the server payload directly so this block stays symmetric with the tagCounts and logos
464
+ // blocks below, and so the client does not hardcode the set of scope names.
465
+ " if(patch.scopeCounts) {",
466
+ " for(const [ s, sc ] of Object.entries(patch.scopeCounts)) {",
467
+ " const cb = document.querySelector('.scope-toggle[data-scope=\"' + s + '\"]');",
468
+ " const span = document.querySelector('.quick-action-count[data-scope=\"' + s + '\"]');",
469
+ " if(cb) {",
470
+ " cb.checked = (sc.enabled === sc.total);",
471
+ " cb.indeterminate = (sc.enabled > 0) && (sc.enabled < sc.total);",
472
+ " }",
473
+ " if(span) { span.setAttribute('data-enabled', sc.enabled); span.setAttribute('data-total', sc.total); " +
355
474
  "span.textContent = sc.enabled + ' of ' + sc.total + ' enabled'; }",
475
+ " }",
356
476
  " }",
357
- " }",
358
477
  // Apply HDHR bulk toggle counts from server-computed values.
359
- " if(patch.hdhrCounts) {",
360
- " var hc = patch.hdhrCounts;",
361
- " var htoggle = document.getElementById('hdhr-bulk-toggle');",
362
- " var hcount = document.getElementById('hdhr-bulk-count');",
363
- " if(htoggle) {",
364
- " htoggle.checked = (hc.enabled === hc.total) && (hc.total > 0);",
365
- " htoggle.indeterminate = (hc.enabled > 0) && (hc.enabled < hc.total);",
478
+ " if(patch.hdhrCounts) {",
479
+ " const hc = patch.hdhrCounts;",
480
+ " const htoggle = document.getElementById('hdhr-bulk-toggle');",
481
+ " const hcount = document.getElementById('hdhr-bulk-count');",
482
+ " if(htoggle) {",
483
+ " htoggle.checked = (hc.enabled === hc.total) && (hc.total > 0);",
484
+ " htoggle.indeterminate = (hc.enabled > 0) && (hc.enabled < hc.total);",
485
+ " }",
486
+ " if(hcount) hcount.textContent = hc.enabled + ' of ' + hc.total;",
366
487
  " }",
367
- " if(hcount) hcount.textContent = hc.enabled + ' of ' + hc.total;",
368
- " }",
369
- // Apply channel logos from a logo map (key → URL). Sets data-logo attributes on matching rows so processChannelLogos can render the images.
370
- " if(patch.logos) {",
371
- " for(var key in patch.logos) {",
372
- " var logoRow = document.getElementById('display-row-' + key);",
373
- " if(logoRow) {",
374
- " var nameCell = logoRow.querySelector('.channel-name-cell');",
375
- " if(nameCell && nameCell.parentElement) nameCell.parentElement.setAttribute('data-logo', patch.logos[key]);",
488
+ // Apply tag bulk toggle counts from server-computed values.
489
+ " if(patch.tagCounts) {",
490
+ " for(const [ tagName, tc ] of Object.entries(patch.tagCounts)) {",
491
+ " const ttoggle = document.querySelector('.tag-bulk-toggle[data-tag=\"' + tagName + '\"]');",
492
+ " const tcount = document.querySelector('[data-tag-count=\"' + tagName + '\"]');",
493
+ " if(ttoggle) {",
494
+ " ttoggle.checked = (tc.count === tc.total) && (tc.total > 0);",
495
+ " ttoggle.indeterminate = (tc.count > 0) && (tc.count < tc.total);",
496
+ " }",
497
+ " if(tcount) tcount.textContent = tc.count + ' of ' + tc.total;",
498
+ " }",
499
+ " }",
500
+ // Apply channel logos from a logo map (key → URL). Sets data-logo attributes on matching rows so processLogos() can render the images.
501
+ " if(patch.logos) {",
502
+ " for(const [ key, logoUrl ] of Object.entries(patch.logos)) {",
503
+ " const logoRow = document.getElementById('display-row-' + key);",
504
+ " const nameCell = logoRow?.querySelector('.channel-name-cell');",
505
+ " nameCell?.parentElement?.setAttribute('data-logo', logoUrl);",
506
+ " }",
507
+ " }",
508
+ // Post-update: refilter rows for the service filter and render any new logos. Row insertions reset the select options (Safari ignores hidden), so the
509
+ // filter must be re-applied. Logo rendering handles both logo-only patches and newly-inserted rows.
510
+ " if(patch.rows && (patch.rows.length > 0)) this.refilter();",
511
+ " if(patch.logos || (patch.rows && (patch.rows.length > 0))) this.processLogos();",
512
+ " },",
513
+ // Insert or replace a channel row in the table. Always removes existing rows with the same key first (handles edits and overrides of builtin channels).
514
+ // Uses the current table sort field and direction for correct insertion order.
515
+ " insertRow(html, key) {",
516
+ " const table = document.querySelector('.channel-table');",
517
+ " const tbody = table?.querySelector('tbody');",
518
+ " if(!table || !tbody || !html) return;",
519
+ " document.getElementById('edit-row-' + key)?.remove();",
520
+ " document.getElementById('display-row-' + key)?.remove();",
521
+ " const temp = document.createElement('tbody');",
522
+ " temp.innerHTML = html.displayRow + (html.editRow || '');",
523
+ " const newDisplayRow = temp.firstElementChild;",
524
+ " const newEditRow = temp.children[1] || null;",
525
+ " const sortField = table.getAttribute('data-sort-field') || 'name';",
526
+ " const sortDir = table.getAttribute('data-sort-dir') || 'asc';",
527
+ " const newVal = this._getSortValue(newDisplayRow, sortField);",
528
+ " const rows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
529
+ " let inserted = false;",
530
+ " for(const row of rows) {",
531
+ " const rowVal = this._getSortValue(row, sortField);",
532
+ " const cmp = (sortDir === 'asc') ? (newVal < rowVal) : (newVal > rowVal);",
533
+ " if(cmp) {",
534
+ " tbody.insertBefore(newDisplayRow, row);",
535
+ " if(newEditRow) tbody.insertBefore(newEditRow, row);",
536
+ " inserted = true;",
537
+ " break;",
538
+ " }",
539
+ " }",
540
+ " if(!inserted) {",
541
+ " tbody.appendChild(newDisplayRow);",
542
+ " if(newEditRow) tbody.appendChild(newEditRow);",
543
+ " }",
544
+ " this.processLogos();",
545
+ " },",
546
+ // Remove a channel row (both display and edit variants) from the table.
547
+ " removeRow(key) {",
548
+ " document.getElementById('display-row-' + key)?.remove();",
549
+ " document.getElementById('edit-row-' + key)?.remove();",
550
+ " },",
551
+ /* Apply the service filter to all channel rows. Hides rows whose service tags aren't enabled and filters service dropdown options for multi-service channels.
552
+ * Uses a persistent _allOptions array on each select to remember all server-rendered options across filter applications — the server marks disabled options
553
+ * with the hidden attribute, but Safari ignores hidden on option elements, so we rebuild the select with only enabled options each time. Selection restore
554
+ * priority: (1) saved server choice (HTML selected attribute), (2) previous visual selection, (3) first option.
555
+ *
556
+ * Cache lifecycle: _allOptions is attached to the DOM select element itself, so its lifetime is tied to that element. Row replacement via insertRow() drops
557
+ * the old row (and its select) and inserts fresh server-rendered HTML, which transparently resets the cache with whatever option set the server just sent.
558
+ * This invariant is load-bearing — do not refactor insertRow() into an in-place row update without also adding an explicit cache invalidation path here.
559
+ */
560
+ " filter(enabledTags) {",
561
+ " const rows = document.querySelectorAll('tr[data-provider-tags]');",
562
+ " for(const row of rows) {",
563
+ " const tags = row.getAttribute('data-provider-tags').split(',');",
564
+ " const available = (enabledTags.length === 0) || tags.some((t) => (t === 'direct') || enabledTags.includes(t));",
565
+ " row.classList.toggle('channel-unavailable', !available);",
566
+ " const label = row.querySelector('.no-provider-label');",
567
+ " const sel = row.querySelector('.provider-select');",
568
+ " const name = row.querySelector('.provider-name');",
569
+ " if(label) label.style.display = available ? 'none' : '';",
570
+ " if(name) name.style.display = available ? '' : 'none';",
571
+ " if(sel) {",
572
+ " sel.style.display = available ? '' : 'none';",
573
+ " sel._allOptions ??= [ ...sel.querySelectorAll('option') ];",
574
+ " const prevValue = sel.value;",
575
+ " sel.innerHTML = '';",
576
+ " let serverDefault = null;",
577
+ " let prevExists = false;",
578
+ " for(const opt of sel._allOptions) {",
579
+ " const oTag = opt.getAttribute('data-provider-tag');",
580
+ " const show = (enabledTags.length === 0) || (oTag === 'direct') || enabledTags.includes(oTag);",
581
+ " if(show) {",
582
+ " sel.appendChild(opt);",
583
+ " if(opt.hasAttribute('selected')) serverDefault = opt;",
584
+ " if(opt.value === prevValue) prevExists = true;",
585
+ " }",
586
+ " }",
587
+ " if(serverDefault) sel.value = serverDefault.value;",
588
+ " else if(prevExists) sel.value = prevValue;",
589
+ " else if(sel.options.length > 0) sel.selectedIndex = 0;",
590
+ " }",
591
+ " }",
592
+ " },",
593
+ // Returns the currently enabled service filter tags by walking the filter dropdown checkboxes. Returns an empty array when the menu is missing, when all
594
+ // checkboxes are checked (treated as no filter), or when no checkboxes are checked. The empty-when-all-checked semantics match the server-side filter model:
595
+ // an empty enabledServices array means "show all services" rather than "show none."
596
+ " getEnabledFilterTags() {",
597
+ " const menu = document.querySelector('.provider-dropdown-menu');",
598
+ " if(!menu) return [];",
599
+ " const cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
600
+ " const enabledTags = [];",
601
+ " let allChecked = true;",
602
+ " for(const cb of cbs) {",
603
+ " if(cb.checked) enabledTags.push(cb.getAttribute('data-tag'));",
604
+ " else allChecked = false;",
605
+ " }",
606
+ " return allChecked ? [] : enabledTags;",
607
+ " },",
608
+ // Re-apply the service filter using the current checkbox state from the filter dropdown menu. Called after row insertions because fresh server HTML contains
609
+ // all options with hidden attributes (which Safari ignores), so the filter must be re-established.
610
+ " refilter() {",
611
+ " const tags = this.getEnabledFilterTags();",
612
+ " if(tags.length > 0) this.filter(tags);",
613
+ " },",
614
+ // Sort the channel table by the specified field. Toggles direction if the same field is clicked again. Persists the sort preference to the server.
615
+ " sort(field) {",
616
+ " const table = document.querySelector('.channel-table');",
617
+ " if(!table) return;",
618
+ " const currentField = table.getAttribute('data-sort-field');",
619
+ " const currentDir = table.getAttribute('data-sort-dir') || 'asc';",
620
+ " const dir = (field === currentField) ? ((currentDir === 'asc') ? 'desc' : 'asc') : 'asc';",
621
+ " table.setAttribute('data-sort-field', field);",
622
+ " table.setAttribute('data-sort-dir', dir);",
623
+ " const tbody = table.querySelector('tbody');",
624
+ " const displayRows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
625
+ " const pairs = [];",
626
+ " for(const dr of displayRows) {",
627
+ " const key = dr.id.replace('display-row-', '');",
628
+ " const er = document.getElementById('edit-row-' + key);",
629
+ " const name = dr.children[1]?.textContent.trim().toLowerCase() ?? '';",
630
+ " pairs.push({ displayRow: dr, editRow: er, name, val: this._getSortValue(dr, field) });",
631
+ " }",
632
+ // Secondary sort by channel name stabilizes rows with identical primary values. Always ascending regardless of primary direction so groups maintain
633
+ // consistent alphabetical order within the sort.
634
+ " pairs.sort((a, b) => {",
635
+ " const cmp = (dir === 'asc') ? a.val.localeCompare(b.val) : b.val.localeCompare(a.val);",
636
+ " return (cmp !== 0) ? cmp : a.name.localeCompare(b.name);",
637
+ " });",
638
+ " for(const pair of pairs) {",
639
+ " tbody.appendChild(pair.displayRow);",
640
+ " if(pair.editRow) tbody.appendChild(pair.editRow);",
641
+ " }",
642
+ // Update header sort indicators. Targets .sort-label within each th so other children (like tag filter dropdowns) are untouched.
643
+ " for(const th of table.querySelectorAll('th.sortable')) {",
644
+ " const hField = th.getAttribute('data-sort-field');",
645
+ " const sortLabel = th.querySelector('.sort-label');",
646
+ " if(!sortLabel) continue;",
647
+ " const label = sortLabel.textContent.replace(/[\\u25B2\\u25BC]/g, '').trim();",
648
+ " if(hField === field) sortLabel.innerHTML = label + ((dir === 'asc') ? ' &#9650;' : ' &#9660;');",
649
+ " else sortLabel.textContent = label;",
650
+ " }",
651
+ " persistDisplayPrefs({ sortDirection: dir, sortField: field });",
652
+ " },",
653
+ // Process channel logo data attributes. Finds all td[data-logo] elements and renders their channel-name-cell spans via channelDisplayHtml in 'both' mode.
654
+ // Called on page load and after any DOM mutation that inserts server-rendered channel rows (add, edit, delete-with-replacement).
655
+ " processLogos() {",
656
+ " for(const cell of document.querySelectorAll('td[data-logo]')) {",
657
+ " if(cell.getAttribute('data-logo-processed')) continue;",
658
+ " const url = cell.getAttribute('data-logo');",
659
+ " const nameSpan = cell.querySelector('.channel-name-cell');",
660
+ " if(url && nameSpan) {",
661
+ " nameSpan.innerHTML = channelDisplayHtml(url, nameSpan.textContent || '', 'channel-table-logo', 'channel-table-text', 'both');",
662
+ " cell.setAttribute('data-logo-processed', '1');",
376
663
  " }",
377
664
  " }",
378
- " }",
379
- // Post-update: refilter rows for provider filter and render any new logos.
380
- " if(patch.rows && patch.rows.length > 0) {",
381
- " if(window.refilterChannelRows) window.refilterChannelRows();",
382
- " }",
383
- " if(patch.logos || (patch.rows && patch.rows.length > 0)) {",
384
- " if(window.processChannelLogos) window.processChannelLogos();",
385
665
  " }",
386
666
  " };",
387
667
  "})();",