@bnhf/prismcast 1.3.4-2026.2.19

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 (234) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +347 -0
  3. package/bin/prismcast +6 -0
  4. package/dist/app.d.ts +6 -0
  5. package/dist/app.js +315 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/browser/cdp.d.ts +38 -0
  8. package/dist/browser/cdp.js +155 -0
  9. package/dist/browser/cdp.js.map +1 -0
  10. package/dist/browser/channelSelection.d.ts +65 -0
  11. package/dist/browser/channelSelection.js +202 -0
  12. package/dist/browser/channelSelection.js.map +1 -0
  13. package/dist/browser/display.d.ts +34 -0
  14. package/dist/browser/display.js +54 -0
  15. package/dist/browser/display.js.map +1 -0
  16. package/dist/browser/index.d.ts +205 -0
  17. package/dist/browser/index.js +1205 -0
  18. package/dist/browser/index.js.map +1 -0
  19. package/dist/browser/tuning/fox.d.ts +2 -0
  20. package/dist/browser/tuning/fox.js +83 -0
  21. package/dist/browser/tuning/fox.js.map +1 -0
  22. package/dist/browser/tuning/hbo.d.ts +2 -0
  23. package/dist/browser/tuning/hbo.js +237 -0
  24. package/dist/browser/tuning/hbo.js.map +1 -0
  25. package/dist/browser/tuning/hulu.d.ts +2 -0
  26. package/dist/browser/tuning/hulu.js +550 -0
  27. package/dist/browser/tuning/hulu.js.map +1 -0
  28. package/dist/browser/tuning/sling.d.ts +2 -0
  29. package/dist/browser/tuning/sling.js +518 -0
  30. package/dist/browser/tuning/sling.js.map +1 -0
  31. package/dist/browser/tuning/thumbnailRow.d.ts +2 -0
  32. package/dist/browser/tuning/thumbnailRow.js +108 -0
  33. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  34. package/dist/browser/tuning/tileClick.d.ts +2 -0
  35. package/dist/browser/tuning/tileClick.js +103 -0
  36. package/dist/browser/tuning/tileClick.js.map +1 -0
  37. package/dist/browser/tuning/youtubeTv.d.ts +2 -0
  38. package/dist/browser/tuning/youtubeTv.js +182 -0
  39. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  40. package/dist/browser/video.d.ts +289 -0
  41. package/dist/browser/video.js +996 -0
  42. package/dist/browser/video.js.map +1 -0
  43. package/dist/channels/index.d.ts +3 -0
  44. package/dist/channels/index.js +392 -0
  45. package/dist/channels/index.js.map +1 -0
  46. package/dist/config/index.d.ts +53 -0
  47. package/dist/config/index.js +233 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/config/presets.d.ts +98 -0
  50. package/dist/config/presets.js +241 -0
  51. package/dist/config/presets.js.map +1 -0
  52. package/dist/config/profiles.d.ts +79 -0
  53. package/dist/config/profiles.js +245 -0
  54. package/dist/config/profiles.js.map +1 -0
  55. package/dist/config/providers.d.ts +120 -0
  56. package/dist/config/providers.js +450 -0
  57. package/dist/config/providers.js.map +1 -0
  58. package/dist/config/sites.d.ts +22 -0
  59. package/dist/config/sites.js +377 -0
  60. package/dist/config/sites.js.map +1 -0
  61. package/dist/config/userChannels.d.ts +178 -0
  62. package/dist/config/userChannels.js +543 -0
  63. package/dist/config/userChannels.js.map +1 -0
  64. package/dist/config/userConfig.d.ts +235 -0
  65. package/dist/config/userConfig.js +913 -0
  66. package/dist/config/userConfig.js.map +1 -0
  67. package/dist/hdhr/channelMap.d.ts +21 -0
  68. package/dist/hdhr/channelMap.js +82 -0
  69. package/dist/hdhr/channelMap.js.map +1 -0
  70. package/dist/hdhr/deviceId.d.ts +11 -0
  71. package/dist/hdhr/deviceId.js +84 -0
  72. package/dist/hdhr/deviceId.js.map +1 -0
  73. package/dist/hdhr/discover.d.ts +6 -0
  74. package/dist/hdhr/discover.js +155 -0
  75. package/dist/hdhr/discover.js.map +1 -0
  76. package/dist/hdhr/index.d.ts +9 -0
  77. package/dist/hdhr/index.js +87 -0
  78. package/dist/hdhr/index.js.map +1 -0
  79. package/dist/index.d.ts +1 -0
  80. package/dist/index.js +144 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/routes/assets.d.ts +6 -0
  83. package/dist/routes/assets.js +79 -0
  84. package/dist/routes/assets.js.map +1 -0
  85. package/dist/routes/auth.d.ts +6 -0
  86. package/dist/routes/auth.js +77 -0
  87. package/dist/routes/auth.js.map +1 -0
  88. package/dist/routes/channels.d.ts +6 -0
  89. package/dist/routes/channels.js +40 -0
  90. package/dist/routes/channels.js.map +1 -0
  91. package/dist/routes/components.d.ts +138 -0
  92. package/dist/routes/components.js +210 -0
  93. package/dist/routes/components.js.map +1 -0
  94. package/dist/routes/config.d.ts +72 -0
  95. package/dist/routes/config.js +1977 -0
  96. package/dist/routes/config.js.map +1 -0
  97. package/dist/routes/debug.d.ts +6 -0
  98. package/dist/routes/debug.js +274 -0
  99. package/dist/routes/debug.js.map +1 -0
  100. package/dist/routes/health.d.ts +6 -0
  101. package/dist/routes/health.js +85 -0
  102. package/dist/routes/health.js.map +1 -0
  103. package/dist/routes/hls.d.ts +6 -0
  104. package/dist/routes/hls.js +25 -0
  105. package/dist/routes/hls.js.map +1 -0
  106. package/dist/routes/index.d.ts +19 -0
  107. package/dist/routes/index.js +49 -0
  108. package/dist/routes/index.js.map +1 -0
  109. package/dist/routes/logs.d.ts +6 -0
  110. package/dist/routes/logs.js +164 -0
  111. package/dist/routes/logs.js.map +1 -0
  112. package/dist/routes/mpegts.d.ts +6 -0
  113. package/dist/routes/mpegts.js +19 -0
  114. package/dist/routes/mpegts.js.map +1 -0
  115. package/dist/routes/play.d.ts +6 -0
  116. package/dist/routes/play.js +18 -0
  117. package/dist/routes/play.js.map +1 -0
  118. package/dist/routes/playlist.d.ts +36 -0
  119. package/dist/routes/playlist.js +134 -0
  120. package/dist/routes/playlist.js.map +1 -0
  121. package/dist/routes/root.d.ts +6 -0
  122. package/dist/routes/root.js +2920 -0
  123. package/dist/routes/root.js.map +1 -0
  124. package/dist/routes/streams.d.ts +6 -0
  125. package/dist/routes/streams.js +88 -0
  126. package/dist/routes/streams.js.map +1 -0
  127. package/dist/routes/theme.d.ts +15 -0
  128. package/dist/routes/theme.js +275 -0
  129. package/dist/routes/theme.js.map +1 -0
  130. package/dist/routes/ui.d.ts +56 -0
  131. package/dist/routes/ui.js +354 -0
  132. package/dist/routes/ui.js.map +1 -0
  133. package/dist/service/commands.d.ts +41 -0
  134. package/dist/service/commands.js +391 -0
  135. package/dist/service/commands.js.map +1 -0
  136. package/dist/service/generators.d.ts +33 -0
  137. package/dist/service/generators.js +432 -0
  138. package/dist/service/generators.js.map +1 -0
  139. package/dist/service/index.d.ts +2 -0
  140. package/dist/service/index.js +7 -0
  141. package/dist/service/index.js.map +1 -0
  142. package/dist/streaming/clients.d.ts +48 -0
  143. package/dist/streaming/clients.js +114 -0
  144. package/dist/streaming/clients.js.map +1 -0
  145. package/dist/streaming/fmp4Segmenter.d.ts +61 -0
  146. package/dist/streaming/fmp4Segmenter.js +461 -0
  147. package/dist/streaming/fmp4Segmenter.js.map +1 -0
  148. package/dist/streaming/hls.d.ts +120 -0
  149. package/dist/streaming/hls.js +722 -0
  150. package/dist/streaming/hls.js.map +1 -0
  151. package/dist/streaming/hlsSegments.d.ts +54 -0
  152. package/dist/streaming/hlsSegments.js +162 -0
  153. package/dist/streaming/hlsSegments.js.map +1 -0
  154. package/dist/streaming/lifecycle.d.ts +33 -0
  155. package/dist/streaming/lifecycle.js +185 -0
  156. package/dist/streaming/lifecycle.js.map +1 -0
  157. package/dist/streaming/monitor.d.ts +74 -0
  158. package/dist/streaming/monitor.js +1310 -0
  159. package/dist/streaming/monitor.js.map +1 -0
  160. package/dist/streaming/mp4Parser.d.ts +74 -0
  161. package/dist/streaming/mp4Parser.js +566 -0
  162. package/dist/streaming/mp4Parser.js.map +1 -0
  163. package/dist/streaming/mpegts.d.ts +14 -0
  164. package/dist/streaming/mpegts.js +248 -0
  165. package/dist/streaming/mpegts.js.map +1 -0
  166. package/dist/streaming/registry.d.ts +119 -0
  167. package/dist/streaming/registry.js +127 -0
  168. package/dist/streaming/registry.js.map +1 -0
  169. package/dist/streaming/setup.d.ts +135 -0
  170. package/dist/streaming/setup.js +670 -0
  171. package/dist/streaming/setup.js.map +1 -0
  172. package/dist/streaming/showInfo.d.ts +30 -0
  173. package/dist/streaming/showInfo.js +362 -0
  174. package/dist/streaming/showInfo.js.map +1 -0
  175. package/dist/streaming/statusEmitter.d.ts +125 -0
  176. package/dist/streaming/statusEmitter.js +139 -0
  177. package/dist/streaming/statusEmitter.js.map +1 -0
  178. package/dist/types/index.d.ts +403 -0
  179. package/dist/types/index.js +6 -0
  180. package/dist/types/index.js.map +1 -0
  181. package/dist/utils/debugFilter.d.ts +38 -0
  182. package/dist/utils/debugFilter.js +157 -0
  183. package/dist/utils/debugFilter.js.map +1 -0
  184. package/dist/utils/delay.d.ts +6 -0
  185. package/dist/utils/delay.js +15 -0
  186. package/dist/utils/delay.js.map +1 -0
  187. package/dist/utils/errors.d.ts +15 -0
  188. package/dist/utils/errors.js +40 -0
  189. package/dist/utils/errors.js.map +1 -0
  190. package/dist/utils/evaluate.d.ts +51 -0
  191. package/dist/utils/evaluate.js +124 -0
  192. package/dist/utils/evaluate.js.map +1 -0
  193. package/dist/utils/ffmpeg.d.ts +65 -0
  194. package/dist/utils/ffmpeg.js +317 -0
  195. package/dist/utils/ffmpeg.js.map +1 -0
  196. package/dist/utils/fileLogger.d.ts +25 -0
  197. package/dist/utils/fileLogger.js +248 -0
  198. package/dist/utils/fileLogger.js.map +1 -0
  199. package/dist/utils/format.d.ts +16 -0
  200. package/dist/utils/format.js +46 -0
  201. package/dist/utils/format.js.map +1 -0
  202. package/dist/utils/html.d.ts +6 -0
  203. package/dist/utils/html.js +24 -0
  204. package/dist/utils/html.js.map +1 -0
  205. package/dist/utils/index.d.ts +15 -0
  206. package/dist/utils/index.js +20 -0
  207. package/dist/utils/index.js.map +1 -0
  208. package/dist/utils/logEmitter.d.ts +17 -0
  209. package/dist/utils/logEmitter.js +30 -0
  210. package/dist/utils/logEmitter.js.map +1 -0
  211. package/dist/utils/logger.d.ts +82 -0
  212. package/dist/utils/logger.js +219 -0
  213. package/dist/utils/logger.js.map +1 -0
  214. package/dist/utils/m3u.d.ts +32 -0
  215. package/dist/utils/m3u.js +148 -0
  216. package/dist/utils/m3u.js.map +1 -0
  217. package/dist/utils/morganStream.d.ts +7 -0
  218. package/dist/utils/morganStream.js +33 -0
  219. package/dist/utils/morganStream.js.map +1 -0
  220. package/dist/utils/platform.d.ts +64 -0
  221. package/dist/utils/platform.js +157 -0
  222. package/dist/utils/platform.js.map +1 -0
  223. package/dist/utils/retry.d.ts +15 -0
  224. package/dist/utils/retry.js +82 -0
  225. package/dist/utils/retry.js.map +1 -0
  226. package/dist/utils/streamContext.d.ts +28 -0
  227. package/dist/utils/streamContext.js +33 -0
  228. package/dist/utils/streamContext.js.map +1 -0
  229. package/dist/utils/version.d.ts +37 -0
  230. package/dist/utils/version.js +228 -0
  231. package/dist/utils/version.js.map +1 -0
  232. package/package.json +92 -0
  233. package/prismcast.png +0 -0
  234. package/prismcast.svg +74 -0
@@ -0,0 +1,2920 @@
1
+ import { checkForUpdates, escapeHtml, getChangelogItems, getPackageVersion, getVersionInfo, isRunningAsService } from "../utils/index.js";
2
+ import { generateAdvancedTabContent, generateChannelsPanel, generateSettingsFormFooter, generateSettingsTabContent, hasEnvOverrides } from "./config.js";
3
+ import { generateBaseStyles, generatePageWrapper, generateTabButton, generateTabPanel, generateTabScript, generateTabStyles } from "./ui.js";
4
+ import { VIDEO_QUALITY_PRESETS } from "../config/presets.js";
5
+ import { getAllChannels } from "../config/userChannels.js";
6
+ import { getUITabs } from "../config/userConfig.js";
7
+ import { resolveBaseUrl } from "./playlist.js";
8
+ import { resolveProfile } from "../config/profiles.js";
9
+ /* The landing page provides operators with all the information they need to integrate with Channels DVR. It features a tabbed interface with six sections:
10
+ *
11
+ * 1. Overview - Introduction to PrismCast and Quick Start instructions
12
+ * 2. Channels - The full M3U playlist with copy functionality
13
+ * 3. Logs - Real-time log viewer for troubleshooting
14
+ * 4. Configuration - Channel management and settings (with subtabs)
15
+ * 5. API Reference - Documentation for all HTTP endpoints
16
+ * 6. Help - Updating, platform notes, troubleshooting, and known limitations
17
+ */
18
+ /**
19
+ * Generates the system status bar HTML for the page header.
20
+ * @returns HTML content for the system status bar.
21
+ */
22
+ function generateHeaderStatusHtml() {
23
+ return [
24
+ "<div id=\"system-status\" class=\"header-status\">",
25
+ "<span id=\"system-health\"><span class=\"status-dot\" style=\"color: var(--text-muted);\">&#9679;</span> Connecting...</span>",
26
+ "<div class=\"dropdown stream-popover\">",
27
+ "<span id=\"stream-count\" onclick=\"toggleStreamPopover()\">-</span>",
28
+ "<div class=\"dropdown-menu\" id=\"stream-popover-menu\"></div>",
29
+ "</div>",
30
+ "</div>"
31
+ ].join("\n");
32
+ }
33
+ /**
34
+ * Generates the version display HTML with update indicator if available.
35
+ * @returns HTML content for the version display.
36
+ */
37
+ function generateVersionHtml() {
38
+ const currentVersion = getPackageVersion();
39
+ const versionInfo = getVersionInfo(currentVersion);
40
+ // Refresh icon for manual update check (using Unicode refresh symbol).
41
+ const refreshIcon = [
42
+ "<button type=\"button\" class=\"version-check\" onclick=\"checkForUpdates()\" title=\"Check for updates\">",
43
+ "&#8635;",
44
+ "</button>"
45
+ ].join("");
46
+ if (versionInfo.updateAvailable && versionInfo.latestVersion) {
47
+ // Update available - make version area clickable to open changelog modal, with refresh icon.
48
+ return [
49
+ "<span class=\"version-container\">",
50
+ "<a href=\"#\" class=\"version version-update\" onclick=\"openChangelogModal(); return false;\">",
51
+ "v" + currentVersion + " &rarr; v" + versionInfo.latestVersion,
52
+ "</a>",
53
+ refreshIcon,
54
+ "</span>"
55
+ ].join("");
56
+ }
57
+ // No update - show current version (clickable to view changelog) with refresh icon.
58
+ return [
59
+ "<span class=\"version-container\" id=\"version-display\">",
60
+ "<a href=\"#\" class=\"version\" onclick=\"openChangelogModal(); return false;\">v" + currentVersion + "</a>",
61
+ refreshIcon,
62
+ "</span>"
63
+ ].join("");
64
+ }
65
+ /**
66
+ * Generates the changelog modal HTML with placeholder content. The actual changelog is fetched dynamically when the modal opens.
67
+ * @returns HTML content for the changelog modal.
68
+ */
69
+ function generateChangelogModal() {
70
+ return [
71
+ "<div id=\"changelog-modal\" class=\"changelog-modal\">",
72
+ "<div class=\"changelog-modal-content\">",
73
+ "<h3 class=\"changelog-title\">What's new</h3>",
74
+ "<div class=\"changelog-loading\">Loading...</div>",
75
+ "<div class=\"changelog-content\" style=\"display: none;\"></div>",
76
+ "<p class=\"changelog-error\" style=\"display: none;\">Unable to load changelog.</p>",
77
+ "<div class=\"changelog-modal-buttons\">",
78
+ "<a href=\"https://github.com/hjdhjd/prismcast/releases\" target=\"_blank\" rel=\"noopener\" class=\"btn btn-primary\">View on GitHub</a>",
79
+ "<button type=\"button\" class=\"btn btn-secondary\" onclick=\"closeChangelogModal()\">Close</button>",
80
+ "</div>",
81
+ "</div>",
82
+ "</div>"
83
+ ].join("\n");
84
+ }
85
+ /**
86
+ * Generates the active streams table for the Overview tab.
87
+ * @returns HTML content for the active streams section.
88
+ */
89
+ function generateActiveStreamsSection() {
90
+ return [
91
+ "<div id=\"streams-container\">",
92
+ "<table id=\"streams-table\" class=\"streams-table\">",
93
+ "<tbody id=\"streams-tbody\">",
94
+ "<tr class=\"empty-row\"><td colspan=\"4\">No active streams</td></tr>",
95
+ "</tbody>",
96
+ "</table>",
97
+ "</div>"
98
+ ].join("\n");
99
+ }
100
+ /**
101
+ * Generates the JavaScript for status SSE connection and UI updates. This script runs at page level to keep the header status updated across all tabs.
102
+ * @returns JavaScript code as a string wrapped in script tags.
103
+ */
104
+ function generateStatusScript() {
105
+ return [
106
+ "<script>",
107
+ "var statusEventSource = null;",
108
+ "var streamData = {};",
109
+ "var systemData = null;",
110
+ "var expandedStreams = {};",
111
+ "var healthColorVars = { healthy: 'var(--stream-healthy)', buffering: 'var(--stream-buffering)', recovering: 'var(--stream-recovering)', ",
112
+ " stalled: 'var(--stream-stalled)', error: 'var(--stream-error)' };",
113
+ // Format duration in human readable format.
114
+ "function formatDuration(seconds) {",
115
+ " if (seconds < 60) return seconds + 's';",
116
+ " if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';",
117
+ " var h = Math.floor(seconds / 3600);",
118
+ " var m = Math.floor((seconds % 3600) / 60);",
119
+ " return h + 'h ' + m + 'm';",
120
+ "}",
121
+ // Format bytes in human readable format.
122
+ "function formatBytes(bytes) {",
123
+ " if (bytes === 0) return '0 B';",
124
+ " if (bytes < 1024) return bytes + ' B';",
125
+ " if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';",
126
+ " return (bytes / 1048576).toFixed(1) + ' MB';",
127
+ "}",
128
+ // Format absolute time (e.g., "6:54 AM" or "Jan 14, 6:54 AM" if different day).
129
+ "function formatTime(isoString) {",
130
+ " var date = new Date(isoString);",
131
+ " var now = new Date();",
132
+ " var hours = date.getHours();",
133
+ " var minutes = date.getMinutes();",
134
+ " var ampm = hours >= 12 ? 'PM' : 'AM';",
135
+ " hours = hours % 12;",
136
+ " hours = hours ? hours : 12;",
137
+ " var timeStr = hours + ':' + (minutes < 10 ? '0' : '') + minutes + ' ' + ampm;",
138
+ " if (date.toDateString() !== now.toDateString()) {",
139
+ " var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];",
140
+ " timeStr = months[date.getMonth()] + ' ' + date.getDate() + ', ' + timeStr;",
141
+ " }",
142
+ " return timeStr;",
143
+ "}",
144
+ // Extract concise domain from URL for display (last two hostname parts). Mirrors the server-side extractDomain() in utils/format.ts.
145
+ "function getDomain(url) {",
146
+ " try {",
147
+ " var parts = new URL(url).hostname.split('.');",
148
+ " return parts.length > 2 ? parts.slice(-2).join('.') : parts.join('.');",
149
+ " } catch (e) {",
150
+ " return url;",
151
+ " }",
152
+ "}",
153
+ // 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
154
+ // image and reveals a text span. The logoClass and textClass parameters allow callers to apply context-specific sizing.
155
+ "function channelDisplayHtml(logoUrl, name, logoClass, textClass) {",
156
+ " if(logoUrl) {",
157
+ " return '<img src=\"' + logoUrl + '\" class=\"' + logoClass + '\" alt=\"' + name + '\" title=\"' + name + '\" ' +",
158
+ " 'onerror=\"this.style.display=\\'none\\';this.nextElementSibling.style.display=\\'inline\\'\">' +",
159
+ " '<span class=\"' + textClass + '\" style=\"display:none\">' + name + '</span>';",
160
+ " }",
161
+ " return '<span class=\"' + textClass + '\">' + name + '</span>';",
162
+ "}",
163
+ // Get row background color based on health status. Uses CSS variables for theme support.
164
+ "function getRowTint(health) {",
165
+ " var tints = {",
166
+ " healthy: 'transparent',",
167
+ " buffering: 'var(--stream-tint-buffering)',",
168
+ " stalled: 'var(--stream-tint-stalled)',",
169
+ " recovering: 'var(--stream-tint-recovering)',",
170
+ " error: 'var(--stream-tint-error)'",
171
+ " };",
172
+ " return tints[health] || 'transparent';",
173
+ "}",
174
+ // Get health badge HTML using CSS variables for theme-aware colors. NOTE: Escalation level semantics defined in monitor.ts.
175
+ // L1=play/unmute, L2=seek, L3=source reload, L4=page navigation.
176
+ "function getHealthBadge(health, level) {",
177
+ " var label = '';",
178
+ " if (health === 'healthy') { label = 'Healthy'; }",
179
+ " else if (health === 'buffering') { label = 'Buffering'; }",
180
+ " else if (health === 'stalled') { label = 'Stalled'; }",
181
+ " else if (health === 'error') { label = 'Error'; }",
182
+ " else if (health === 'recovering') {",
183
+ " if (level === 1) { label = 'Resuming playback'; }",
184
+ " else if (level === 2) { label = 'Syncing to live'; }",
185
+ " else if (level === 3) { label = 'Reloading player'; }",
186
+ " else if (level >= 4) { label = 'Reloading page'; }",
187
+ " else { label = 'Recovering'; }",
188
+ " }",
189
+ " else { label = health; }",
190
+ " return '<span class=\"status-dot\" style=\"color: ' + (healthColorVars[health] || 'var(--text-muted)') + ';\">&#9679;</span> ' +",
191
+ " '<span style=\"color: var(--text-secondary);\">' + label + '</span>';",
192
+ "}",
193
+ // Update system status display in header. Shows system health (green dot when connected, red with label when not) and stream count.
194
+ "function updateSystemStatus() {",
195
+ " if(!systemData) return;",
196
+ " var healthEl = document.getElementById('system-health');",
197
+ " var streamEl = document.getElementById('stream-count');",
198
+ " if(systemData.browser.connected) {",
199
+ " healthEl.innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-healthy);\">&#9679;</span>';",
200
+ " } else {",
201
+ " healthEl.innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-error);\">&#9679;</span> Browser offline';",
202
+ " }",
203
+ " var active = systemData.streams.active;",
204
+ " var limit = systemData.streams.limit;",
205
+ " if(active === 0) {",
206
+ " streamEl.textContent = '0 streams';",
207
+ " streamEl.classList.remove('clickable');",
208
+ " var popMenu = document.getElementById('stream-popover-menu');",
209
+ " if(popMenu) popMenu.classList.remove('show');",
210
+ " } else {",
211
+ " streamEl.textContent = active + '/' + limit + ' streams';",
212
+ " streamEl.classList.add('clickable');",
213
+ " }",
214
+ "}",
215
+ // Build the popover content from streamData. Populates the given menu element with one row per active stream.
216
+ "function buildStreamPopoverContent(menu) {",
217
+ " var ids = Object.keys(streamData);",
218
+ " var html = '';",
219
+ " var now = Date.now();",
220
+ " for(var i = 0; i < ids.length; i++) {",
221
+ " var s = streamData[ids[i]];",
222
+ " var color = healthColorVars[s.health] || 'var(--text-muted)';",
223
+ " var name = s.channel || s.providerName || getDomain(s.url);",
224
+ " var dur = Math.floor((now - new Date(s.startTime).getTime()) / 1000);",
225
+ " var titleAttr = s.showName ? ' title=\"' + s.showName + '\"' : '';",
226
+ " html += '<div class=\"stream-popover-row\"' + titleAttr + '>';",
227
+ " html += '<span class=\"status-dot\" style=\"color: ' + color + ';\">&#9679;</span>';",
228
+ " html += channelDisplayHtml(s.logoUrl, name, 'stream-popover-logo', 'stream-popover-channel');",
229
+ " html += '<span class=\"stream-popover-duration\">' + formatDuration(dur) + '</span>';",
230
+ " html += '</div>';",
231
+ " }",
232
+ " menu.innerHTML = html;",
233
+ "}",
234
+ // Update an already-open stream popover with current data. Called from SSE handlers and the duration interval.
235
+ "function updateStreamPopover() {",
236
+ " var menu = document.getElementById('stream-popover-menu');",
237
+ " if(!menu || !menu.classList.contains('show')) return;",
238
+ " var ids = Object.keys(streamData);",
239
+ " if(ids.length === 0) {",
240
+ " menu.classList.remove('show');",
241
+ " return;",
242
+ " }",
243
+ " buildStreamPopoverContent(menu);",
244
+ "}",
245
+ // Toggle the stream popover open or closed.
246
+ "window.toggleStreamPopover = function() {",
247
+ " var ids = Object.keys(streamData);",
248
+ " if(ids.length === 0) return;",
249
+ " var menu = document.getElementById('stream-popover-menu');",
250
+ " if(!menu) return;",
251
+ " var isOpen = menu.classList.contains('show');",
252
+ " closeDropdowns();",
253
+ " if(!isOpen) {",
254
+ " buildStreamPopoverContent(menu);",
255
+ " menu.classList.add('show');",
256
+ " }",
257
+ "};",
258
+ // Format last issue for display.
259
+ "function formatLastIssue(s) {",
260
+ " if (!s.lastIssueType || !s.lastIssueTime) { return 'None'; }",
261
+ " var issueLabel = s.lastIssueType.charAt(0).toUpperCase() + s.lastIssueType.slice(1);",
262
+ " var timeStr = formatTime(new Date(s.lastIssueTime).toISOString());",
263
+ " var status = (s.health === 'healthy') ? ' (recovered)' : ' (recovering)';",
264
+ " return issueLabel + ' at ' + timeStr + status;",
265
+ "}",
266
+ // Format auto-recovery info for display.
267
+ "function formatAutoRecovery(s) {",
268
+ " var attempts = s.recoveryAttempts;",
269
+ " var reloads = s.pageReloadsInWindow;",
270
+ " if (attempts === 0) { return 'N/A'; }",
271
+ " var str = attempts + (attempts === 1 ? ' attempt' : ' attempts');",
272
+ " if (reloads > 0) { str += ', ' + reloads + (reloads === 1 ? ' page reload' : ' page reloads'); }",
273
+ " return str;",
274
+ "}",
275
+ // Format client type breakdown for the detail row.
276
+ "function formatClients(s) {",
277
+ " if (s.clientCount === 0) { return 'None'; }",
278
+ " var labels = { 'hls': 'HLS', 'mpegts': 'MPEG-TS' };",
279
+ " var parts = [];",
280
+ " for (var i = 0; i < s.clients.length; i++) {",
281
+ " var c = s.clients[i];",
282
+ " parts.push(c.count + ' ' + (labels[c.type] || c.type));",
283
+ " }",
284
+ " return parts.join(', ');",
285
+ "}",
286
+ // Render the streams table.
287
+ "function renderStreamsTable() {",
288
+ " var tbody = document.getElementById('streams-tbody');",
289
+ " if (!tbody) return;",
290
+ " var streamIds = Object.keys(streamData);",
291
+ " if (streamIds.length === 0) {",
292
+ " tbody.innerHTML = '<tr class=\"empty-row\"><td colspan=\"4\">No active streams</td></tr>';",
293
+ " return;",
294
+ " }",
295
+ " var html = '';",
296
+ " for (var i = 0; i < streamIds.length; i++) {",
297
+ " var id = streamIds[i];",
298
+ " var s = streamData[id];",
299
+ " var isExpanded = expandedStreams[id];",
300
+ " var chevron = isExpanded ? '&#9660;' : '&#9654;';",
301
+ " var rowTint = getRowTint(s.health);",
302
+ " var channelText = s.channel || s.providerName || getDomain(s.url);",
303
+ " var channelDisplay = channelDisplayHtml(s.logoUrl, channelText, 'channel-logo', 'channel-text');",
304
+ " html += '<tr class=\"stream-row\" data-id=\"' + id + '\" onclick=\"toggleStreamDetails(' + id + ')\" style=\"background-color: ' + rowTint + ';\">';",
305
+ " html += '<td class=\"chevron\">' + chevron + '</td>';",
306
+ " var durationSpan = '<span class=\"stream-duration\" id=\"duration-' + id + '\">\\u00b7 ' + formatDuration(s.duration) + '</span>';",
307
+ " html += '<td class=\"stream-info\">' + channelDisplay + ' ' + durationSpan + '</td>';",
308
+ " var showDisplay = s.showName ? s.showName : '';",
309
+ " html += '<td class=\"stream-show\">' + showDisplay + '</td>';",
310
+ " var clientIndicator = '';",
311
+ " if (s.clientCount > 0) {",
312
+ " var title = s.clientCount + (s.clientCount !== 1 ? ' clients' : ' client');",
313
+ " clientIndicator = '<span class=\"client-count\" title=\"' + title + '\">&#9673; ' + s.clientCount + '</span> ';",
314
+ " }",
315
+ " html += '<td class=\"stream-health\">' + clientIndicator + getHealthBadge(s.health, s.escalationLevel) + '</td>';",
316
+ " html += '</tr>';",
317
+ " if (isExpanded) {",
318
+ " html += '<tr class=\"stream-details\" data-id=\"' + id + '\">';",
319
+ " html += '<td colspan=\"4\">';",
320
+ " html += '<div class=\"details-content\">';",
321
+ " html += '<div class=\"details-header\">';",
322
+ " html += '<div class=\"details-url\">' + s.url + '</div>';",
323
+ " var clientSuffix = s.clientCount > 0 ? ' &middot; ' + formatClients(s) : '';",
324
+ " html += '<div class=\"details-started\"><strong>Started:</strong> ' + formatTime(s.startTime) + clientSuffix + '</div>';",
325
+ " html += '</div>';",
326
+ " html += '<div class=\"details-metrics\">';",
327
+ " html += '<div class=\"details-issue\"><strong>Last issue:</strong> ' + formatLastIssue(s) + '</div>';",
328
+ " html += '<div class=\"details-recovery\"><strong>Recovery:</strong> ' + formatAutoRecovery(s) + '</div>';",
329
+ " html += '<div class=\"details-memory\"><strong>Memory:</strong> ' + formatBytes(s.memoryBytes) + '</div>';",
330
+ " html += '</div>';",
331
+ " html += '</div>';",
332
+ " html += '</td></tr>';",
333
+ " }",
334
+ " }",
335
+ " tbody.innerHTML = html;",
336
+ "}",
337
+ // Toggle stream details.
338
+ "function toggleStreamDetails(id) {",
339
+ " expandedStreams[id] = !expandedStreams[id];",
340
+ " renderStreamsTable();",
341
+ "}",
342
+ // Update stream durations every second. We calculate duration from the immutable startTime rather than incrementing a counter, ensuring the displayed duration is
343
+ // always accurate regardless of any staleness in server-sent updates.
344
+ "function updateDurations() {",
345
+ " var now = Date.now();",
346
+ " var streamIds = Object.keys(streamData);",
347
+ " for (var i = 0; i < streamIds.length; i++) {",
348
+ " var id = streamIds[i];",
349
+ " var s = streamData[id];",
350
+ " var durationSec = Math.floor((now - new Date(s.startTime).getTime()) / 1000);",
351
+ " var el = document.getElementById('duration-' + id);",
352
+ " if (el) el.textContent = '\\u00b7 ' + formatDuration(durationSec);",
353
+ " }",
354
+ " updateStreamPopover();",
355
+ "}",
356
+ // Track the last time any SSE event was received from the status stream. Used by the staleness checker to detect silently dead connections.
357
+ "var lastStatusEventTime = Date.now();",
358
+ "var hiddenSince = 0;",
359
+ // Connect (or reconnect) to the status SSE stream. Closes any existing connection first so this is safe to call repeatedly.
360
+ "function connectStatusSSE() {",
361
+ " if(statusEventSource) { statusEventSource.close(); }",
362
+ " statusEventSource = new EventSource('/streams/status');",
363
+ " lastStatusEventTime = Date.now();",
364
+ // Local helper that registers an event listener and updates the staleness timestamp on every event. Handlers are optional so heartbeat can
365
+ // be registered with just on('heartbeat') for pure keepalive tracking. The onerror handler stays outside this wrapper because errors must
366
+ // not reset the staleness timer — a connection that only fires errors is still dead.
367
+ " function on(event, handler) {",
368
+ " statusEventSource.addEventListener(event, function(e) {",
369
+ " lastStatusEventTime = Date.now();",
370
+ " if(handler) { handler(e); }",
371
+ " });",
372
+ " }",
373
+ " on('heartbeat');",
374
+ " on('snapshot', function(e) {",
375
+ " var data = JSON.parse(e.data);",
376
+ " systemData = data.system;",
377
+ " streamData = {};",
378
+ " for (var i = 0; i < data.streams.length; i++) {",
379
+ " streamData[data.streams[i].id] = data.streams[i];",
380
+ " }",
381
+ " updateSystemStatus();",
382
+ " renderStreamsTable();",
383
+ " updateStreamPopover();",
384
+ " });",
385
+ " on('streamAdded', function(e) {",
386
+ " var s = JSON.parse(e.data);",
387
+ " streamData[s.id] = s;",
388
+ " renderStreamsTable();",
389
+ " updateStreamPopover();",
390
+ " });",
391
+ " on('streamRemoved', function(e) {",
392
+ " var data = JSON.parse(e.data);",
393
+ " delete streamData[data.id];",
394
+ " delete expandedStreams[data.id];",
395
+ " renderStreamsTable();",
396
+ " updateStreamPopover();",
397
+ " if (typeof pendingRestart !== 'undefined' && pendingRestart) {",
398
+ " updateRestartDialogStatus();",
399
+ " }",
400
+ " });",
401
+ " on('streamHealthChanged', function(e) {",
402
+ " var s = JSON.parse(e.data);",
403
+ " if (streamData[s.id]) {",
404
+ " streamData[s.id] = s;",
405
+ " renderStreamsTable();",
406
+ " updateStreamPopover();",
407
+ " }",
408
+ " });",
409
+ " on('systemStatusChanged', function(e) {",
410
+ " systemData = JSON.parse(e.data);",
411
+ " updateSystemStatus();",
412
+ " });",
413
+ " statusEventSource.onerror = function() {",
414
+ " document.getElementById('system-health').innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-stalled);\">&#9679;</span> Updates paused';",
415
+ " };",
416
+ "}",
417
+ // Initial connection and periodic timers.
418
+ "connectStatusSSE();",
419
+ "setInterval(updateDurations, 1000);",
420
+ // Staleness detection: if no SSE event has arrived in 45 seconds, the connection is likely dead. Reconnect proactively.
421
+ "setInterval(function() {",
422
+ " if((Date.now() - lastStatusEventTime) > 45000) { connectStatusSSE(); }",
423
+ "}, 45000);",
424
+ // Visibility-driven reconnect. When the page returns from being hidden for more than 30 seconds, reconnect the status stream and re-activate
425
+ // the current tab so the logs stream reconnects naturally through its existing tabactivated listener.
426
+ "document.addEventListener('visibilitychange', function() {",
427
+ " if(document.hidden) {",
428
+ " hiddenSince = Date.now();",
429
+ " } else if((hiddenSince > 0) && ((Date.now() - hiddenSince) > 30000)) {",
430
+ " hiddenSince = 0;",
431
+ " connectStatusSSE();",
432
+ " var activeTab = document.querySelector('.tab-btn.active');",
433
+ " if(activeTab) {",
434
+ " document.dispatchEvent(new CustomEvent('tabactivated', { detail: { category: activeTab.getAttribute('data-category') } }));",
435
+ " }",
436
+ " } else {",
437
+ " hiddenSince = 0;",
438
+ " }",
439
+ "});",
440
+ // Copy playlist URL function for Overview tab Quick Start section.
441
+ "window.copyOverviewPlaylistUrl = function() {",
442
+ " var urlEl = document.getElementById('overview-playlist-url');",
443
+ " if (urlEl) {",
444
+ " navigator.clipboard.writeText(urlEl.textContent).then(function() {",
445
+ " var feedback = document.getElementById('overview-copy-feedback');",
446
+ " if (feedback) {",
447
+ " feedback.style.display = 'inline';",
448
+ " setTimeout(function() { feedback.style.display = 'none'; }, 2000);",
449
+ " }",
450
+ " });",
451
+ " }",
452
+ "};",
453
+ "</script>"
454
+ ].join("\n");
455
+ }
456
+ /**
457
+ * Generates the Overview tab content with a comprehensive user guide covering what PrismCast is, video quality expectations, quick start instructions, tuning speed,
458
+ * channel authentication, working with channels, and system requirements.
459
+ * @param baseUrl - The base URL for the server.
460
+ * @param videoChannelCount - The number of video channels available.
461
+ * @returns HTML content for the Overview tab.
462
+ */
463
+ function generateOverviewContent(baseUrl, videoChannelCount) {
464
+ return [
465
+ // Active streams table at the top.
466
+ generateActiveStreamsSection(),
467
+ // What Is PrismCast?
468
+ "<div class=\"section\">",
469
+ "<h3>What Is PrismCast?</h3>",
470
+ "<p>PrismCast captures live video from web-based TV players by driving a real Chrome browser. It navigates to streaming sites, captures the ",
471
+ "screen and audio output, and serves the result as HLS streams over HTTP. Think of it as a <strong>virtual TV tuner for web-based content</strong> &mdash; ",
472
+ "it lets Channels DVR (and other applications) record and watch content from streaming sites that do not offer direct video URLs.</p>",
473
+ "<p>PrismCast is built around three priorities, in order:</p>",
474
+ "<ol>",
475
+ "<li><strong>Reliability</strong> &mdash; tuning a channel always delivers that channel. When the primary approach fails, fallback strategies ",
476
+ "ensure the tune still succeeds.</li>",
477
+ "<li><strong>Health monitoring</strong> &mdash; once a channel is playing, PrismCast continuously monitors the stream and takes corrective ",
478
+ "action automatically if issues arise.</li>",
479
+ "<li><strong>Speed</strong> &mdash; tuning and recovery should be as fast as possible, but never at the expense of reliability.</li>",
480
+ "</ol>",
481
+ "<p>The ordering is intentional. PrismCast will always choose the reliable path over the fast one.</p>",
482
+ "</div>",
483
+ // Video Quality.
484
+ "<div class=\"section\">",
485
+ "<h3>Video Quality</h3>",
486
+ "<p><strong>PrismCast delivers H.264 video with AAC stereo audio</strong> at configurable quality presets ranging from 480p to 1080p. ",
487
+ "Quality presets can be changed in the <a href=\"#config/settings\">Configuration</a> tab.</p>",
488
+ "<p>This is <em>not</em> a replacement for native 4K, HDR, Dolby Vision, or surround sound &mdash; it is screen capture, not a direct feed. ",
489
+ "PrismCast captures directly from Chrome's media pipeline with <strong>no video transcoding</strong>, which is why tuning is fast and CPU usage ",
490
+ "stays low. The result is good quality video that works well for everyday viewing and DVR recording. PrismCast is designed for content you ",
491
+ "<strong>cannot get any other way</strong> in Channels DVR: network streaming sites, free ad-supported TV, and live channels that only exist on the web.</p>",
492
+ "</div>",
493
+ // Quick Start (Channels DVR).
494
+ "<div class=\"section\">",
495
+ "<h3>Quick Start</h3>",
496
+ "<p>To add PrismCast channels to Channels DVR:</p>",
497
+ "<ol>",
498
+ "<li>Go to <strong>Settings &rarr; Custom Channels</strong> in your Channels DVR server.</li>",
499
+ "<li>Click <strong>Add Source</strong> and select <strong>M3U Playlist</strong>.</li>",
500
+ "<li>Enter the playlist URL: <code id=\"overview-playlist-url\">" + baseUrl + "/playlist</code> ",
501
+ "<button class=\"btn-copy-inline\" onclick=\"copyOverviewPlaylistUrl()\" title=\"Copy URL\">Copy</button>",
502
+ "<span id=\"overview-copy-feedback\" class=\"copy-feedback-inline\">Copied!</span></li>",
503
+ "<li>Set <strong>Stream Format</strong> to <strong>HLS</strong>.</li>",
504
+ "<li>The " + String(videoChannelCount) + " configured channels will be imported automatically.</li>",
505
+ "</ol>",
506
+ "<p>Individual channels can also be streamed directly using HLS URLs like <code>" + baseUrl + "/hls/nbc/stream.m3u8</code>.</p>",
507
+ "</div>",
508
+ // Plex Integration.
509
+ "<div class=\"section\">",
510
+ "<h3>Plex Integration</h3>",
511
+ "<p>PrismCast includes built-in HDHomeRun emulation, allowing Plex to use it as a network tuner for live TV and DVR recording.</p>",
512
+ "<ol>",
513
+ "<li>In Plex, go to <strong>Settings &rarr; Live TV &amp; DVR &rarr; Set Up Plex DVR</strong>.</li>",
514
+ "<li>Enter your PrismCast server address with port 5004 (e.g., <code>192.168.1.100:5004</code>).</li>",
515
+ "<li>Plex will detect PrismCast as an HDHomeRun tuner and import available channels.</li>",
516
+ "</ol>",
517
+ "<p>HDHomeRun emulation is enabled by default and can be configured in the ",
518
+ "<a href=\"#config/settings\">HDHomeRun / Plex</a> configuration tab.</p>",
519
+ "</div>",
520
+ // Tuning Speed.
521
+ "<div class=\"section\">",
522
+ "<h3>Tuning Speed</h3>",
523
+ "<p>When a client requests a channel, PrismCast navigates Chrome to the streaming site, locates the video player, starts capture, and serves the ",
524
+ "first HLS segment. How long this takes depends on the channel type:</p>",
525
+ "<h4>Direct URL Channels (~3&ndash;5 seconds)</h4>",
526
+ "<p>Sites where PrismCast navigates directly to a player page and video starts automatically. ",
527
+ "Examples: NBC, ABC, Paramount+, USA Network.</p>",
528
+ "<h4>Guide-Based Providers &mdash; First Tune (~5&ndash;10 seconds)</h4>",
529
+ "<p>Sites where PrismCast navigates a live TV guide to find and select the channel. The first tune for a given channel is slower because the ",
530
+ "guide grid must be searched. Examples: HBO Max, Hulu, Sling TV, YouTube TV, Fox.</p>",
531
+ "<h4>Guide-Based Providers &mdash; Subsequent Tunes (~3&ndash;5 seconds)</h4>",
532
+ "<p>After the first tune, PrismCast caches channel data for <strong>HBO Max, Sling TV, and YouTube TV</strong>. ",
533
+ "Subsequent tunes skip guide navigation entirely and are comparable to direct URL channels. If cached data ",
534
+ "becomes stale, PrismCast falls back to guide navigation transparently.</p>",
535
+ "<h4>Idle Window</h4>",
536
+ "<p>Streams stay alive for <strong>30 seconds</strong> after the last client disconnects (configurable in the ",
537
+ "<a href=\"#config/settings\">Configuration</a> tab). This means channel surfing in Channels DVR is instant for recently-viewed channels &mdash; ",
538
+ "no re-tuning is needed. Combined with channel caching, the system gets faster the more you use it.</p>",
539
+ "</div>",
540
+ // Channel Authentication.
541
+ "<div class=\"section\">",
542
+ "<h3>Channel Authentication</h3>",
543
+ "<p>Many streaming channels require TV provider authentication before content can be accessed. To authenticate:</p>",
544
+ "<ol>",
545
+ "<li>Go to the <a href=\"#channels\">Channels tab</a>.</li>",
546
+ "<li>Click the <strong>Login</strong> button next to the channel you want to authenticate.</li>",
547
+ "<li>A browser window will open with the channel's streaming page.</li>",
548
+ "<li>Complete the TV provider sign-in process in the browser.</li>",
549
+ "<li>Click <strong>Done</strong> when authentication is complete.</li>",
550
+ "</ol>",
551
+ "<p>Your login credentials are saved in the browser profile and persist across restarts. You only need to authenticate once per TV provider. ",
552
+ "The Login button is stateless and always displays &ldquo;Login&rdquo; regardless of authentication status &mdash; successful authentication is ",
553
+ "confirmed when the channel streams correctly. Some TV providers periodically expire sessions on their end, requiring re-authentication. This is ",
554
+ "a provider limitation, not a PrismCast issue &mdash; simply click Login again to re-authenticate.</p>",
555
+ "<p class=\"description-hint\">If PrismCast is running headless or on a remote server, use a VNC client to access the browser for authentication.</p>",
556
+ "</div>",
557
+ // Working with Channels.
558
+ "<div class=\"section\">",
559
+ "<h3>Working with Channels</h3>",
560
+ "<h4>Predefined Channels</h4>",
561
+ "<p>PrismCast ships with channels across multiple streaming providers, maintained and updated with each release. You can disable any channels ",
562
+ "you do not need from the <a href=\"#channels\">Channels tab</a>. The predefined set covers common networks and is a good starting point &mdash; ",
563
+ "enable what you watch and disable the rest. You can also override any predefined channel with your own custom definition ",
564
+ "(see <em>Overriding Predefined Channels</em> below).</p>",
565
+ "<h4>Provider Variants</h4>",
566
+ "<p>Some channels (ESPN, Fox, NBC, etc.) are available from multiple streaming providers. The <strong>provider dropdown</strong> on each channel ",
567
+ "lets you choose which service to use for that channel. Different providers may offer different tuning performance.</p>",
568
+ "<h4>Provider Filter</h4>",
569
+ "<p>If you only subscribe to certain streaming services, use the <strong>provider filter</strong> on the ",
570
+ "<a href=\"#channels\">Channels tab</a> toolbar to show only relevant channels. This filter also applies to the M3U playlist, so Channels DVR ",
571
+ "only imports channels from providers you actually use. You can also filter programmatically using the <code>?provider=</code> query parameter ",
572
+ "on the playlist URL.</p>",
573
+ "<h4>Bulk Operations</h4>",
574
+ "<p>The <strong>Set all channels to</strong> dropdown on the <a href=\"#channels\">Channels tab</a> toolbar switches every multi-provider channel ",
575
+ "to a single provider at once. This is useful when you want all channels routed through one streaming service. The operation can be undone by ",
576
+ "switching individual channels back or selecting a different provider from the same dropdown.</p>",
577
+ "<h4>User-Defined Channels</h4>",
578
+ "<p>You can add custom channels for any streaming site. Provide a URL, select a site profile, and PrismCast will capture it. For sites with ",
579
+ "multiple live channels (like a live TV provider), the <strong>Channel Selector</strong> field tells PrismCast which channel to tune to &mdash; ",
580
+ "the expected value depends on the provider. When adding or editing a channel, select a profile to see the <strong>Profile Reference</strong> ",
581
+ "section with site-specific guidance, including expected channel selector formats for known providers.</p>",
582
+ "<h4>Overriding Predefined Channels</h4>",
583
+ "<p>To override a predefined channel, create a user-defined channel with the same channel key. Both versions will appear in the provider ",
584
+ "dropdown &mdash; yours labeled <em>Custom</em> and the original with its provider name. You can switch between them at any time.</p>",
585
+ "<p class=\"description-hint\">For automation and integration with other workflows, see the <a href=\"#api\">API Reference</a> tab for the full HTTP API.</p>",
586
+ "</div>",
587
+ // Requirements.
588
+ "<div class=\"section\">",
589
+ "<h3>Requirements</h3>",
590
+ "<ul>",
591
+ "<li>Google Chrome browser installed.</li>",
592
+ "<li>Sufficient memory for browser automation (2GB+ recommended).</li>",
593
+ "<li>Network access to streaming sites.</li>",
594
+ "</ul>",
595
+ "<p class=\"description-hint\">See the <a href=\"#help\">Help</a> tab for platform-specific requirements and troubleshooting.</p>",
596
+ "</div>"
597
+ ].join("\n");
598
+ }
599
+ /**
600
+ * Generates the Help tab content with updating instructions, platform notes, troubleshooting, and known limitations.
601
+ * @returns HTML content for the Help tab.
602
+ */
603
+ function generateHelpContent() {
604
+ return [
605
+ // Updating PrismCast.
606
+ "<div class=\"section\">",
607
+ "<h3>Updating PrismCast</h3>",
608
+ "<p>Settings and channel configurations are preserved across updates.</p>",
609
+ "<h4>Homebrew (macOS)</h4>",
610
+ "<pre>brew upgrade prismcast\nprismcast service restart</pre>",
611
+ "<h4>npm</h4>",
612
+ "<pre>npm install -g prismcast\nprismcast service restart</pre>",
613
+ "<h4>Docker</h4>",
614
+ "<p>Pull the latest image and recreate the container. If using Watchtower, updates are applied automatically.</p>",
615
+ "<pre>docker pull ghcr.io/hjdhjd/prismcast:latest\ndocker compose up -d</pre>",
616
+ "</div>",
617
+ // Display and Resolution.
618
+ "<div class=\"section\">",
619
+ "<h3>Display and Resolution</h3>",
620
+ "<p>PrismCast captures video from Chrome's display output. The <strong>capture resolution must be smaller than the physical display resolution</strong> ",
621
+ "because browser toolbars and window chrome consume approximately 100&ndash;150 vertical pixels. For example, to capture at 1080p (1920&times;1080), the ",
622
+ "display must be larger than 1080p.</p>",
623
+ "<p>When the selected quality preset exceeds what the display can provide, PrismCast logs a warning and automatically degrades to the best available preset. ",
624
+ "This is not an error &mdash; PrismCast is adapting to your display.</p>",
625
+ "<h4>Headless Servers</h4>",
626
+ "<p>macOS works without a physical monitor. Windows and Linux servers without a display need an <strong>HDMI dummy plug</strong> or a ",
627
+ "<strong>virtual display adapter</strong> to provide a display resolution for Chrome to render into.</p>",
628
+ "<h4>Remote Access</h4>",
629
+ "<p>macOS Screen Sharing and VNC work correctly. <strong>Windows Remote Desktop (RDP) does not work</strong> &mdash; RDP creates a virtual display ",
630
+ "with different properties that interfere with Chrome's rendering. Use VNC or connect a physical display on Windows.</p>",
631
+ "</div>",
632
+ // Platform Notes.
633
+ "<div class=\"section\">",
634
+ "<h3>Platform Notes</h3>",
635
+ "<h4>macOS</h4>",
636
+ "<p>Chrome on macOS uses GPU hardware acceleration for video encoding, providing the best capture performance. After installing Node.js, go to ",
637
+ "<strong>System Settings &rarr; Privacy &amp; Security &rarr; App Management</strong> and allow Node.js. Use Screen Sharing or VNC for remote access ",
638
+ "to the PrismCast machine.</p>",
639
+ "<h4>Windows</h4>",
640
+ "<p>Install PrismCast as a service with <code>prismcast service install</code>. See Remote Access above for display capture requirements.</p>",
641
+ "<h4>Linux / Docker</h4>",
642
+ "<p>Chrome cannot use GPU hardware acceleration with virtual displays on Linux (a Chrome limitation), so Docker containers rely on software ",
643
+ "rendering. Access the browser via VNC for authentication &mdash; Docker containers expose noVNC at port 6080.</p>",
644
+ "</div>",
645
+ // Troubleshooting.
646
+ "<div class=\"section\">",
647
+ "<h3>Troubleshooting</h3>",
648
+ "<table>",
649
+ "<tr><th>Problem</th><th>Cause</th><th>Solution</th></tr>",
650
+ "<tr>",
651
+ "<td>\"Browser Offline\" or \"Browser is not connected\"</td>",
652
+ "<td>An existing Chrome process is running.</td>",
653
+ "<td>Quit all Chrome instances, then restart PrismCast.</td>",
654
+ "</tr>",
655
+ "<tr>",
656
+ "<td>\"All tuners in use\" despite no active streams</td>",
657
+ "<td>Stale stream state.</td>",
658
+ "<td>Restart PrismCast service.</td>",
659
+ "</tr>",
660
+ "<tr>",
661
+ "<td>Chrome won't open for login</td>",
662
+ "<td>Running headless or as a service.</td>",
663
+ "<td>Access the PrismCast machine via VNC or Screen Sharing to complete authentication.</td>",
664
+ "</tr>",
665
+ "<tr>",
666
+ "<td>macOS blocks Node.js after install</td>",
667
+ "<td>App Management security gate.</td>",
668
+ "<td>System Settings &rarr; Privacy &amp; Security &rarr; App Management &rarr; Allow Node.js.</td>",
669
+ "</tr>",
670
+ "<tr>",
671
+ "<td>Port conflict (address in use)</td>",
672
+ "<td>Another service using port 5589.</td>",
673
+ "<td>Stop the conflicting service, or change the port in <a href=\"#config/settings\">Configuration</a>.</td>",
674
+ "</tr>",
675
+ "</table>",
676
+ "</div>",
677
+ // Known Limitations.
678
+ "<div class=\"section\">",
679
+ "<h3>Known Limitations</h3>",
680
+ "<ul>",
681
+ "<li><strong>Bitrate is approximate.</strong> Chrome's media encoder treats the configured bitrate as a target, not a hard limit. ",
682
+ "Actual bitrate may vary based on content complexity.</li>",
683
+ "<li><strong>Frame rate follows the source.</strong> If the streaming site delivers 30fps, capture will be 30fps regardless of the configured ",
684
+ "frame rate setting.</li>",
685
+ "<li><strong>No closed captions.</strong> Chrome's capture API does not include caption data. Subtitles are not available in PrismCast streams.</li>",
686
+ "<li><strong>No 4K, HDR, or surround sound.</strong> PrismCast captures H.264 video with AAC stereo audio. It is not a replacement for native ",
687
+ "4K, HDR, Dolby Vision, or Dolby Atmos content.</li>",
688
+ "<li><strong>Capture resolution is limited by display size.</strong> See the Display and Resolution section above for details.</li>",
689
+ "<li><strong>Chrome may drop frames after extended use.</strong> The Chrome encoder can degrade after many hours of continuous operation. PrismCast ",
690
+ "automatically restarts Chrome during idle periods to mitigate this.</li>",
691
+ "</ul>",
692
+ "</div>"
693
+ ].join("\n");
694
+ }
695
+ /**
696
+ * Generates the API Reference tab content with endpoint documentation.
697
+ * @returns HTML content for the API Reference tab.
698
+ */
699
+ function generateApiReferenceContent() {
700
+ return [
701
+ "<div class=\"section\">",
702
+ "<p>PrismCast provides a RESTful HTTP API for streaming, management, and diagnostics.</p>",
703
+ "</div>",
704
+ // Streaming endpoints.
705
+ "<div class=\"section\">",
706
+ "<h3>Streaming</h3>",
707
+ "<table>",
708
+ "<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
709
+ "<tr>",
710
+ "<td class=\"endpoint\"><code>GET /hls/:name/stream.m3u8</code></td>",
711
+ "<td>HLS playlist for a named channel. Example: <code>/hls/nbc/stream.m3u8</code></td>",
712
+ "</tr>",
713
+ "<tr>",
714
+ "<td class=\"endpoint\"><code>GET /hls/:name/init.mp4</code></td>",
715
+ "<td>fMP4 initialization segment containing codec configuration.</td>",
716
+ "</tr>",
717
+ "<tr>",
718
+ "<td class=\"endpoint\"><code>GET /hls/:name/:segment.m4s</code></td>",
719
+ "<td>fMP4 media segment containing audio/video data.</td>",
720
+ "</tr>",
721
+ "<tr>",
722
+ "<td class=\"endpoint\"><code>GET /play</code></td>",
723
+ "<td>Stream any URL without creating a channel definition. Pass the URL as <code>?url=&lt;url&gt;</code>. " +
724
+ "Advanced: <code>&amp;profile=</code> overrides auto-detection, <code>&amp;selector=</code> picks a channel on multi-channel sites, " +
725
+ "<code>&amp;clickToPlay=true</code> clicks the video to start playback, <code>&amp;clickSelector=</code> specifies a play button element to click " +
726
+ "(implies clickToPlay).</td>",
727
+ "</tr>",
728
+ "<tr>",
729
+ "<td class=\"endpoint\"><code>GET /stream/:name</code></td>",
730
+ "<td>MPEG-TS stream for HDHomeRun-compatible clients (e.g., Plex). Remuxes fMP4 to MPEG-TS with codec copy.</td>",
731
+ "</tr>",
732
+ "</table>",
733
+ "</div>",
734
+ // Playlist endpoints.
735
+ "<div class=\"section\">",
736
+ "<h3>Playlist</h3>",
737
+ "<table>",
738
+ "<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
739
+ "<tr>",
740
+ "<td class=\"endpoint\"><a href=\"/playlist\"><code>GET /playlist</code></a></td>",
741
+ "<td>M3U playlist of all channels in Channels DVR format. Use this URL when adding PrismCast as a custom channel source. " +
742
+ "Optional <code>?provider=</code> query parameter filters by streaming provider: " +
743
+ "<code>?provider=yttv</code> (single), <code>?provider=yttv,sling</code> (multi-include), " +
744
+ "<code>?provider=-hulu</code> (exclude). Tags are case-insensitive. " +
745
+ "<strong>This only controls which channels appear in the playlist, not which provider is used for tuning.</strong></td>",
746
+ "</tr>",
747
+ "</table>",
748
+ "</div>",
749
+ // Management endpoints.
750
+ "<div class=\"section\">",
751
+ "<h3>Management</h3>",
752
+ "<table>",
753
+ "<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
754
+ "<tr>",
755
+ "<td class=\"endpoint\"><a href=\"/channels\"><code>GET /channels</code></a></td>",
756
+ "<td>List all channels (predefined + user) as JSON with source, enabled status, and channel metadata.</td>",
757
+ "</tr>",
758
+ "<tr>",
759
+ "<td class=\"endpoint\"><a href=\"/streams\"><code>GET /streams</code></a></td>",
760
+ "<td>List all currently active streams with their ID, channel, URL, duration, and status.</td>",
761
+ "</tr>",
762
+ "<tr>",
763
+ "<td class=\"endpoint\"><code>GET /streams/status</code></td>",
764
+ "<td>Server-Sent Events stream for real-time stream and system status updates.</td>",
765
+ "</tr>",
766
+ "<tr>",
767
+ "<td class=\"endpoint\"><code>DELETE /streams/:id</code></td>",
768
+ "<td>Terminate a specific stream by its numeric ID. Returns 200 on success, 404 if not found.</td>",
769
+ "</tr>",
770
+ "</table>",
771
+ "</div>",
772
+ // Authentication endpoints.
773
+ "<div class=\"section\">",
774
+ "<h3>Authentication</h3>",
775
+ "<table>",
776
+ "<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
777
+ "<tr>",
778
+ "<td class=\"endpoint\"><code>POST /auth/login</code></td>",
779
+ "<td>Start login mode for a channel. Body: <code>{ \"channel\": \"name\" }</code> or <code>{ \"url\": \"...\" }</code></td>",
780
+ "</tr>",
781
+ "<tr>",
782
+ "<td class=\"endpoint\"><code>POST /auth/done</code></td>",
783
+ "<td>End login mode and close the login browser tab.</td>",
784
+ "</tr>",
785
+ "<tr>",
786
+ "<td class=\"endpoint\"><a href=\"/auth/status\"><code>GET /auth/status</code></a></td>",
787
+ "<td>Get current login status including whether login mode is active and which channel.</td>",
788
+ "</tr>",
789
+ "</table>",
790
+ "</div>",
791
+ // Configuration endpoints.
792
+ "<div class=\"section\">",
793
+ "<h3>Configuration</h3>",
794
+ "<table>",
795
+ "<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
796
+ "<tr>",
797
+ "<td class=\"endpoint\"><code>POST /config</code></td>",
798
+ "<td>Save configuration settings. Returns <code>{ success, message, willRestart, deferred, activeStreams }</code></td>",
799
+ "</tr>",
800
+ "<tr>",
801
+ "<td class=\"endpoint\"><a href=\"/config/export\"><code>GET /config/export</code></a></td>",
802
+ "<td>Export current configuration as a JSON file download.</td>",
803
+ "</tr>",
804
+ "<tr>",
805
+ "<td class=\"endpoint\"><code>POST /config/import</code></td>",
806
+ "<td>Import configuration from JSON. Server restarts to apply changes (if running as service).</td>",
807
+ "</tr>",
808
+ "<tr>",
809
+ "<td class=\"endpoint\"><code>POST /config/restart-now</code></td>",
810
+ "<td>Force immediate server restart regardless of active streams. Only works when running as a service.</td>",
811
+ "</tr>",
812
+ "<tr>",
813
+ "<td class=\"endpoint\"><code>POST /config/channels</code></td>",
814
+ "<td>Add, edit, or delete user channels. Body includes <code>action</code> (add/edit/delete) and channel data.</td>",
815
+ "</tr>",
816
+ "<tr>",
817
+ "<td class=\"endpoint\"><a href=\"/config/channels/export\"><code>GET /config/channels/export</code></a></td>",
818
+ "<td>Export user-defined channels as a JSON file download.</td>",
819
+ "</tr>",
820
+ "<tr>",
821
+ "<td class=\"endpoint\"><code>POST /config/channels/import</code></td>",
822
+ "<td>Import channels from JSON, replacing all existing user channels.</td>",
823
+ "</tr>",
824
+ "<tr>",
825
+ "<td class=\"endpoint\"><code>POST /config/channels/import-m3u</code></td>",
826
+ "<td>Import channels from M3U playlist. Body: <code>{ \"content\": \"...\", \"conflictMode\": \"skip\" | \"replace\" }</code></td>",
827
+ "</tr>",
828
+ "<tr>",
829
+ "<td class=\"endpoint\"><code>POST /config/channels/toggle-predefined</code></td>",
830
+ "<td>Enable or disable a single predefined channel. Body: <code>{ \"key\": \"nbc\", \"enabled\": true }</code></td>",
831
+ "</tr>",
832
+ "<tr>",
833
+ "<td class=\"endpoint\"><code>POST /config/channels/toggle-all-predefined</code></td>",
834
+ "<td>Enable or disable all predefined channels. Body: <code>{ \"enabled\": true }</code></td>",
835
+ "</tr>",
836
+ "<tr>",
837
+ "<td class=\"endpoint\"><code>POST /config/provider</code></td>",
838
+ "<td>Update provider selection for a multi-provider channel. Body: <code>{ \"channel\": \"nbc\", \"provider\": \"nbc-hulu\" }</code></td>",
839
+ "</tr>",
840
+ "<tr>",
841
+ "<td class=\"endpoint\"><code>POST /config/provider-filter</code></td>",
842
+ "<td>Set enabled provider tags. Body: <code>{ \"enabledProviders\": [\"hulu\", \"yttv\"] }</code>. Empty array disables filter.</td>",
843
+ "</tr>",
844
+ "<tr>",
845
+ "<td class=\"endpoint\"><code>POST /config/provider-bulk-assign</code></td>",
846
+ "<td>Assign a provider to all multi-provider channels. Body: <code>{ \"provider\": \"hulu\" }</code>. " +
847
+ "Returns <code>{ affected, previousSelections, selections }</code></td>",
848
+ "</tr>",
849
+ "<tr>",
850
+ "<td class=\"endpoint\"><code>POST /config/provider-bulk-restore</code></td>",
851
+ "<td>Restore previous provider selections (undo bulk assign). Body: <code>{ \"selections\": { \"nbc\": \"nbc-hulu\", \"fox\": null } }</code>. " +
852
+ "A <code>null</code> value restores the channel to its default provider.</td>",
853
+ "</tr>",
854
+ "</table>",
855
+ "</div>",
856
+ // Diagnostics endpoints.
857
+ "<div class=\"section\">",
858
+ "<h3>Diagnostics</h3>",
859
+ "<table>",
860
+ "<tr><th style=\"width: 35%;\">Endpoint</th><th>Description</th></tr>",
861
+ "<tr>",
862
+ "<td class=\"endpoint\"><a href=\"/health\"><code>GET /health</code></a></td>",
863
+ "<td>Health check returning JSON with browser status, memory usage, stream counts, and configuration.</td>",
864
+ "</tr>",
865
+ "<tr>",
866
+ "<td class=\"endpoint\"><a href=\"/logs\"><code>GET /logs</code></a></td>",
867
+ "<td>Recent log entries as JSON. Query params: <code>?lines=N</code> (default 100, max 1000), <code>?level=error|warn|info</code></td>",
868
+ "</tr>",
869
+ "<tr>",
870
+ "<td class=\"endpoint\"><code>GET /logs/stream</code></td>",
871
+ "<td>Server-Sent Events stream for real-time log entries. Query param: <code>?level=error|warn|info</code></td>",
872
+ "</tr>",
873
+ "</table>",
874
+ "</div>",
875
+ // Example responses.
876
+ "<div class=\"section\">",
877
+ "<h3>Example: Health Check Response</h3>",
878
+ "<pre>{",
879
+ " \"browser\": { \"connected\": true, \"pageCount\": 2 },",
880
+ " \"captureMode\": \"ffmpeg\",",
881
+ " \"chrome\": \"Chrome/144.0.7559.110\",",
882
+ " \"clients\": { \"byType\": [{ \"count\": 1, \"type\": \"hls\" }], \"total\": 1 },",
883
+ " \"ffmpegAvailable\": true,",
884
+ " \"memory\": { \"heapTotal\": 120000000, \"heapUsed\": 85000000, \"rss\": 150000000, \"segmentBuffers\": 25000000 },",
885
+ " \"status\": \"healthy\",",
886
+ " \"streams\": { \"active\": 1, \"limit\": 10 },",
887
+ " \"timestamp\": \"2026-01-26T12:00:00.000Z\",",
888
+ " \"uptime\": 3600.5,",
889
+ " \"version\": \"1.0.12\"",
890
+ "}</pre>",
891
+ "</div>"
892
+ ].join("\n");
893
+ }
894
+ /**
895
+ * Generates the Channels tab content. This wraps the channels panel from config.ts and includes the login modal for channel authentication.
896
+ * @returns HTML content for the Channels tab.
897
+ */
898
+ function generateChannelsTabContent() {
899
+ return [
900
+ "<div class=\"section\">",
901
+ generateChannelsPanel(),
902
+ "</div>",
903
+ // Login modal for channel authentication. Hidden by default, shown when user clicks "Login" on a channel.
904
+ "<div id=\"login-modal\" class=\"login-modal\" style=\"display: none;\">",
905
+ "<div class=\"login-modal-content\">",
906
+ "<h3>Channel Authentication</h3>",
907
+ "<p id=\"login-modal-message\">Complete authentication in the Chrome window on the PrismCast server, then click Done.</p>",
908
+ "<p class=\"login-modal-hint\">A Chrome window has been opened on the machine running PrismCast. ",
909
+ "If PrismCast is running on a remote server or headless system, you'll need screen sharing ",
910
+ "(VNC, Screen Sharing, etc.) to access it. Sign in with your TV provider credentials in that window. ",
911
+ "This login session will automatically close after 15 minutes.</p>",
912
+ "<div class=\"login-modal-buttons\">",
913
+ "<button type=\"button\" class=\"btn btn-primary\" onclick=\"endLogin()\">Done</button>",
914
+ "</div>",
915
+ "</div>",
916
+ "</div>"
917
+ ].join("\n");
918
+ }
919
+ /**
920
+ * Generates the Logs tab content with the log viewer controls and display area. Uses Server-Sent Events for real-time log streaming instead of polling.
921
+ * @returns HTML content for the Logs tab.
922
+ */
923
+ function generateLogsContent() {
924
+ return [
925
+ "<div class=\"section\">",
926
+ "<div class=\"log-controls\" style=\"display: flex; gap: 15px; align-items: center; margin-bottom: 15px; flex-wrap: wrap;\">",
927
+ "<div>",
928
+ "<label for=\"log-level\" style=\"margin-right: 5px;\">Level:</label>",
929
+ "<select id=\"log-level\" onchange=\"onLevelChange()\">",
930
+ "<option value=\"\">All</option>",
931
+ "<option value=\"error\">Errors</option>",
932
+ "<option value=\"warn\">Warnings</option>",
933
+ "<option value=\"info\">Info</option>",
934
+ "</select>",
935
+ "</div>",
936
+ "<button class=\"btn btn-primary btn-sm\" onclick=\"loadLogs()\">Reload History</button>",
937
+ "<span id=\"sse-status\" style=\"font-size: 13px; margin-left: auto;\"></span>",
938
+ "</div>",
939
+ "</div>",
940
+ "<div id=\"log-container\" class=\"log-viewer\">",
941
+ "<div class=\"log-connecting\">Connecting...</div>",
942
+ "</div>",
943
+ // Log viewer JavaScript with SSE support.
944
+ "<script>",
945
+ "var logContainer = document.getElementById('log-container');",
946
+ "var sseStatus = document.getElementById('sse-status');",
947
+ "var eventSource = null;",
948
+ "var isConsoleMode = false;",
949
+ "var currentLevel = '';",
950
+ // Load historical logs from the /logs endpoint.
951
+ "function loadLogs() {",
952
+ " var level = document.getElementById('log-level').value;",
953
+ " var url = '/logs?lines=500';",
954
+ " if(level) { url += '&level=' + level; }",
955
+ " fetch(url)",
956
+ " .then(function(res) { return res.json(); })",
957
+ " .then(function(data) {",
958
+ " if(data.mode === 'console') {",
959
+ " isConsoleMode = true;",
960
+ " logContainer.innerHTML = '<div class=\"log-warn\">File logging is disabled. Logs are being written to the console.</div>';",
961
+ " return;",
962
+ " }",
963
+ " isConsoleMode = false;",
964
+ " if(data.entries.length === 0) {",
965
+ " logContainer.innerHTML = '<div class=\"log-muted\">No log entries found.</div>';",
966
+ " } else {",
967
+ " renderHistoricalLogs(data.entries);",
968
+ " }",
969
+ " })",
970
+ " .catch(function(err) {",
971
+ " logContainer.innerHTML = '<div class=\"log-error\">Error loading logs: ' + err.message + '</div>';",
972
+ " });",
973
+ "}",
974
+ // Render historical log entries (replaces container content).
975
+ "function renderHistoricalLogs(entries) {",
976
+ " var html = '';",
977
+ " for (var i = 0; i < entries.length; i++) {",
978
+ " html += formatLogEntry(entries[i]);",
979
+ " }",
980
+ " logContainer.innerHTML = html;",
981
+ " logContainer.scrollTop = logContainer.scrollHeight;",
982
+ "}",
983
+ // Format a single log entry as HTML using CSS classes for theme-aware colors.
984
+ "function formatLogEntry(entry) {",
985
+ " var cls = 'log-entry';",
986
+ " if(entry.level === 'error') { cls += ' log-error'; }",
987
+ " else if(entry.level === 'warn') { cls += ' log-warn'; }",
988
+ " else if(entry.level === 'debug') { cls += ' log-debug'; }",
989
+ " var levelBadge = '';",
990
+ " if(entry.level !== 'info') {",
991
+ " var tag = entry.categoryTag ? entry.level.toUpperCase() + ':' + entry.categoryTag : entry.level.toUpperCase();",
992
+ " levelBadge = '[' + escapeHtml(tag) + '] ';",
993
+ " }",
994
+ " return '<div class=\"' + cls + '\">[' + escapeHtml(entry.timestamp) + '] ' + levelBadge + escapeHtml(entry.message) + '</div>';",
995
+ "}",
996
+ // Append a single log entry (for SSE streaming).
997
+ "function appendLogEntry(entry) {",
998
+ " if (isConsoleMode) { return; }",
999
+ " var level = document.getElementById('log-level').value;",
1000
+ " if (level && (entry.level !== level)) { return; }",
1001
+ " var wasAtBottom = (logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight) < 50;",
1002
+ " var entryHtml = formatLogEntry(entry);",
1003
+ " logContainer.insertAdjacentHTML('beforeend', entryHtml);",
1004
+ " if (wasAtBottom) { logContainer.scrollTop = logContainer.scrollHeight; }",
1005
+ "}",
1006
+ "function escapeHtml(text) {",
1007
+ " var div = document.createElement('div');",
1008
+ " div.textContent = text;",
1009
+ " return div.innerHTML;",
1010
+ "}",
1011
+ // Track the last time any SSE event was received from the logs stream. Used by the staleness checker below.
1012
+ "var lastLogsEventTime = 0;",
1013
+ "var logsStalenessInterval = null;",
1014
+ // Connect to the SSE stream.
1015
+ "function connectSSE() {",
1016
+ " if(eventSource) { eventSource.close(); }",
1017
+ " if(logsStalenessInterval) { clearInterval(logsStalenessInterval); }",
1018
+ " eventSource = new EventSource('/logs/stream');",
1019
+ " lastLogsEventTime = Date.now();",
1020
+ " sseStatus.innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-buffering);\">&#9679;</span> Connecting...';",
1021
+ // Same on() wrapper pattern as the status stream. Updates the staleness timestamp on every data event so the 45-second checker stays
1022
+ // satisfied as long as any data (heartbeats or log entries) is flowing. Lifecycle handlers (onopen, onerror) stay outside the wrapper.
1023
+ " function on(event, handler) {",
1024
+ " eventSource.addEventListener(event, function(e) {",
1025
+ " lastLogsEventTime = Date.now();",
1026
+ " if(handler) { handler(e); }",
1027
+ " });",
1028
+ " }",
1029
+ " on('heartbeat');",
1030
+ " on('message', function(e) {",
1031
+ " try {",
1032
+ " var entry = JSON.parse(e.data);",
1033
+ " appendLogEntry(entry);",
1034
+ " } catch(err) { /* Ignore parse errors. */ }",
1035
+ " });",
1036
+ " eventSource.onopen = function() {",
1037
+ " lastLogsEventTime = Date.now();",
1038
+ " sseStatus.innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-healthy);\">&#9679;</span> Live';",
1039
+ " loadLogs();",
1040
+ " };",
1041
+ " eventSource.onerror = function() {",
1042
+ " sseStatus.innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-error);\">&#9679;</span> Disconnected';",
1043
+ " };",
1044
+ " logsStalenessInterval = setInterval(function() {",
1045
+ " if((Date.now() - lastLogsEventTime) > 45000) { connectSSE(); }",
1046
+ " }, 45000);",
1047
+ "}",
1048
+ // Disconnect from the SSE stream.
1049
+ "function disconnectSSE() {",
1050
+ " if(logsStalenessInterval) { clearInterval(logsStalenessInterval); logsStalenessInterval = null; }",
1051
+ " if (eventSource) {",
1052
+ " eventSource.close();",
1053
+ " eventSource = null;",
1054
+ " }",
1055
+ " sseStatus.innerHTML = '';",
1056
+ "}",
1057
+ // Handle level filter change (reload history with new filter, SSE filters client-side).
1058
+ "function onLevelChange() {",
1059
+ " loadLogs();",
1060
+ "}",
1061
+ // Handle tab activation events for logs SSE connection. The onopen handler calls loadLogs() to ensure history is loaded on both initial
1062
+ // connection and reconnection after a disconnect.
1063
+ "document.addEventListener('tabactivated', function(e) {",
1064
+ " if (e.detail.category === 'logs') {",
1065
+ " connectSSE();",
1066
+ " } else {",
1067
+ " disconnectSSE();",
1068
+ " }",
1069
+ "});",
1070
+ "</script>"
1071
+ ].join("\n");
1072
+ }
1073
+ /**
1074
+ * Generates the Backup subtab content with download and import functionality for both settings and channels.
1075
+ * @returns HTML content for the Backup subtab panel.
1076
+ */
1077
+ function generateBackupPanel() {
1078
+ // Description text varies based on whether running as a managed service.
1079
+ const restartDescription = isRunningAsService() ?
1080
+ "The server will restart automatically to apply the imported settings." :
1081
+ "After importing, you will need to restart PrismCast for changes to take effect.";
1082
+ return [
1083
+ // Panel description.
1084
+ "<p class=\"settings-panel-description\">Export and import configuration and channel data.</p>",
1085
+ // Settings backup section.
1086
+ "<div class=\"backup-group\">",
1087
+ "<div class=\"backup-group-title\">Settings Backup</div>",
1088
+ "<div class=\"backup-section\">",
1089
+ "<h3>Download Settings</h3>",
1090
+ "<p>Download your current server configuration as a JSON file. This includes all settings (server, browser, streaming, playback, etc.) ",
1091
+ "but does not include channel definitions.</p>",
1092
+ "<button type=\"button\" class=\"btn btn-export\" onclick=\"exportConfig()\">Download Settings</button>",
1093
+ "</div>",
1094
+ "<div class=\"backup-section\">",
1095
+ "<h3>Import Settings</h3>",
1096
+ "<p>Import a previously saved settings file. " + restartDescription + "</p>",
1097
+ "<button type=\"button\" class=\"btn btn-import\" onclick=\"document.getElementById('import-settings-file').click()\">Import Settings</button>",
1098
+ "<input type=\"file\" id=\"import-settings-file\" accept=\".json\" onchange=\"importConfig(this)\">",
1099
+ "</div>",
1100
+ "</div>",
1101
+ // Channels backup section.
1102
+ "<div class=\"backup-group\">",
1103
+ "<div class=\"backup-group-title\">Channels Backup</div>",
1104
+ "<div class=\"backup-section\">",
1105
+ "<h3>Download Channels</h3>",
1106
+ "<p>Download your custom channel definitions as a JSON file. This includes only user-defined channels, not the predefined channels ",
1107
+ "built into PrismCast.</p>",
1108
+ "<button type=\"button\" class=\"btn btn-export\" onclick=\"exportChannels()\">Download Channels</button>",
1109
+ "</div>",
1110
+ "<div class=\"backup-section\">",
1111
+ "<h3>Import Channels</h3>",
1112
+ "<p>Import channel definitions from a previously saved file. This will <strong>replace all existing user channels</strong>.</p>",
1113
+ "<button type=\"button\" class=\"btn btn-import\" onclick=\"document.getElementById('import-channels-file').click()\">Import Channels</button>",
1114
+ "<input type=\"file\" id=\"import-channels-file\" accept=\".json\" onchange=\"importChannels(this)\">",
1115
+ "</div>",
1116
+ "</div>"
1117
+ ].join("\n");
1118
+ }
1119
+ /**
1120
+ * Generates the Configuration tab content with subtabs for channels, settings, advanced, and backup.
1121
+ * @returns HTML content for the Configuration tab.
1122
+ */
1123
+ function generateConfigContent() {
1124
+ const tabs = getUITabs();
1125
+ const lines = [];
1126
+ // Environment variable warning if applicable.
1127
+ if (hasEnvOverrides()) {
1128
+ lines.push("<div class=\"warning\">");
1129
+ lines.push("<div class=\"warning-title\">Environment Variable Overrides</div>");
1130
+ lines.push("Some settings are overridden by environment variables and cannot be changed through this interface. ");
1131
+ lines.push("To modify these settings, update your environment variables and restart the server.");
1132
+ lines.push("</div>");
1133
+ }
1134
+ // Subtab bar: Settings tabs plus Backup.
1135
+ lines.push("<div class=\"subtab-bar\" role=\"tablist\">");
1136
+ let isFirst = true;
1137
+ for (const tab of tabs) {
1138
+ const activeClass = isFirst ? " active" : "";
1139
+ const ariaSelected = isFirst ? "true" : "false";
1140
+ lines.push("<button type=\"button\" class=\"subtab-btn" + activeClass + "\" data-subtab=\"" + escapeHtml(tab.id) + "\" role=\"tab\" aria-selected=\"" +
1141
+ ariaSelected + "\">" + escapeHtml(tab.displayName) + "</button>");
1142
+ isFirst = false;
1143
+ }
1144
+ lines.push("<button type=\"button\" class=\"subtab-btn\" data-subtab=\"backup\" role=\"tab\" aria-selected=\"false\">Backup</button>");
1145
+ lines.push("</div>");
1146
+ // Start the settings form (wraps settings and advanced subtabs, not channels or backup).
1147
+ lines.push("<form id=\"settings-form\" onsubmit=\"return submitSettingsForm(event)\">");
1148
+ // Settings subtab panel with non-collapsible section headers (default active subtab).
1149
+ lines.push("<div id=\"subtab-settings\" class=\"subtab-panel active\" role=\"tabpanel\">");
1150
+ lines.push(generateSettingsTabContent());
1151
+ lines.push("</div>");
1152
+ // Advanced subtab panel with collapsible sections.
1153
+ lines.push("<div id=\"subtab-advanced\" class=\"subtab-panel\" role=\"tabpanel\">");
1154
+ lines.push(generateAdvancedTabContent());
1155
+ lines.push("</div>");
1156
+ // Settings buttons (hidden on Backup subtab). Button text varies based on whether running as a managed service.
1157
+ const saveButtonText = isRunningAsService() ? "Save &amp; Restart" : "Save Settings";
1158
+ lines.push("<div id=\"settings-buttons\" class=\"button-row\" style=\"display: flex;\">");
1159
+ lines.push("<button type=\"submit\" class=\"btn btn-primary\" id=\"save-btn\">" + saveButtonText + "</button>");
1160
+ lines.push("<button type=\"button\" class=\"btn btn-danger\" onclick=\"resetAllToDefaults()\">Reset All to Defaults</button>");
1161
+ lines.push("</div>");
1162
+ lines.push("</form>");
1163
+ // Backup subtab panel (outside form since it doesn't contain settings inputs).
1164
+ lines.push("<div id=\"subtab-backup\" class=\"subtab-panel\" role=\"tabpanel\">");
1165
+ lines.push(generateBackupPanel());
1166
+ lines.push("</div>");
1167
+ // Config path display.
1168
+ lines.push(generateSettingsFormFooter());
1169
+ return lines.join("\n");
1170
+ }
1171
+ /**
1172
+ * Generates the JavaScript for config subtab switching, channel editing, presets, validation, and import/export functionality.
1173
+ * @returns JavaScript code as a string wrapped in script tags.
1174
+ */
1175
+ function generateConfigSubtabScript() {
1176
+ // Build preset data for auto-filling bitrate and frame rate when preset changes. Viewport is derived server-side and not included here.
1177
+ const presetBlocks = [];
1178
+ for (const preset of VIDEO_QUALITY_PRESETS) {
1179
+ // Only include bitrate and frame rate, not viewport (viewport is derived from preset server-side).
1180
+ const bitrateValue = preset.values["streaming.videoBitsPerSecond"];
1181
+ const frameRateValue = preset.values["streaming.frameRate"];
1182
+ // Convert bitrate from bps to Mbps for display.
1183
+ const block = [
1184
+ " '" + preset.id + "': {",
1185
+ " 'streaming-videoBitsPerSecond': " + String(bitrateValue / 1000000) + ",",
1186
+ " 'streaming-frameRate': " + String(frameRateValue),
1187
+ " }"
1188
+ ].join("\n");
1189
+ presetBlocks.push(block);
1190
+ }
1191
+ // Pass service status to JavaScript for conditional messaging.
1192
+ const isService = isRunningAsService();
1193
+ return [
1194
+ "<script>",
1195
+ "(function() {",
1196
+ // Service mode flag for conditional UI behavior.
1197
+ " var isServiceMode = " + String(isService) + ";",
1198
+ // Preset values for auto-filling bitrate and frame rate.
1199
+ " var presetValues = {",
1200
+ presetBlocks.join(",\n"),
1201
+ " };",
1202
+ // When quality preset changes, auto-fill bitrate and frame rate with preset values.
1203
+ " function onPresetChange(presetId) {",
1204
+ " var values = presetValues[presetId];",
1205
+ " if (!values) return;",
1206
+ " for (var inputId in values) {",
1207
+ " var input = document.getElementById(inputId);",
1208
+ " if (input) {",
1209
+ " input.value = values[inputId];",
1210
+ " input.dispatchEvent(new Event('input', { bubbles: true }));",
1211
+ " }",
1212
+ " }",
1213
+ " }",
1214
+ // Show a toast notification. Auto-dismiss durations: success/info = 5s, warning = 8s, error = no auto-dismiss. Optional action: { label, onclick } appends an
1215
+ // inline button between the message text and the close button.
1216
+ " function showToast(message, type, duration, action) {",
1217
+ " var container = document.getElementById('toast-container');",
1218
+ " if (!container) return;",
1219
+ " var toast = document.createElement('div');",
1220
+ " toast.className = 'toast ' + (type || 'info');",
1221
+ " toast.textContent = message;",
1222
+ " toast.setAttribute('role', (type === 'error' || type === 'warning') ? 'alert' : 'status');",
1223
+ " if (action && action.label) {",
1224
+ " var actionBtn = document.createElement('button');",
1225
+ " actionBtn.type = 'button';",
1226
+ " actionBtn.className = 'toast-action';",
1227
+ " actionBtn.textContent = action.label;",
1228
+ " actionBtn.onclick = function() { if (action.onclick) action.onclick(); dismissToast(toast); };",
1229
+ " toast.appendChild(actionBtn);",
1230
+ " }",
1231
+ " var closeBtn = document.createElement('button');",
1232
+ " closeBtn.type = 'button';",
1233
+ " closeBtn.className = 'toast-close';",
1234
+ " closeBtn.textContent = '\\u00d7';",
1235
+ " closeBtn.onclick = function() { dismissToast(toast); };",
1236
+ " toast.appendChild(closeBtn);",
1237
+ " container.appendChild(toast);",
1238
+ " var ms = duration !== undefined ? duration : type === 'error' ? 0 : type === 'warning' ? 8000 : 5000;",
1239
+ " if (ms > 0) { setTimeout(function() { dismissToast(toast); }, ms); }",
1240
+ " }",
1241
+ // Dismiss a toast with slide-out animation.
1242
+ " function dismissToast(toast) {",
1243
+ " if (toast.classList.contains('toast-exit')) return;",
1244
+ " toast.classList.add('toast-exit');",
1245
+ " toast.addEventListener('animationend', function() { if (toast.parentNode) toast.parentNode.removeChild(toast); });",
1246
+ " }",
1247
+ // Queue a toast to appear after the next page reload.
1248
+ " function showToastAfterReload(message, type) {",
1249
+ " sessionStorage.setItem('pendingToast', JSON.stringify({ message: message, type: type || 'success' }));",
1250
+ " location.reload();",
1251
+ " }",
1252
+ // Interval handle for restart polling.
1253
+ " var restartPollInterval = null;",
1254
+ // Track whether a restart is pending (deferred due to active streams).
1255
+ " var pendingRestart = false;",
1256
+ // Show the pending restart dialog when streams are active.
1257
+ " function showPendingRestartDialog(streamCount) {",
1258
+ " pendingRestart = true;",
1259
+ " document.getElementById('restart-stream-count').textContent = streamCount;",
1260
+ " document.getElementById('restart-dialog').style.display = 'flex';",
1261
+ " updateRestartDialogStatus();",
1262
+ " }",
1263
+ // Update the restart dialog when stream count changes.
1264
+ " function updateRestartDialogStatus() {",
1265
+ " var count = Object.keys(streamData).length;",
1266
+ " document.getElementById('restart-stream-count').textContent = count;",
1267
+ " if (count === 0 && pendingRestart) {",
1268
+ " pendingRestart = false;",
1269
+ " document.getElementById('restart-dialog').style.display = 'none';",
1270
+ " triggerRestart();",
1271
+ " }",
1272
+ " }",
1273
+ // Cancel the pending restart.
1274
+ " window.cancelPendingRestart = function() {",
1275
+ " pendingRestart = false;",
1276
+ " document.getElementById('restart-dialog').style.display = 'none';",
1277
+ " showToast('Restart cancelled. Changes will apply on next restart.', 'info');",
1278
+ " };",
1279
+ // Force immediate restart despite active streams.
1280
+ " window.forceRestart = function() {",
1281
+ " pendingRestart = false;",
1282
+ " document.getElementById('restart-dialog').style.display = 'none';",
1283
+ " fetch('/config/restart-now', { method: 'POST' })",
1284
+ " .then(function(response) {",
1285
+ " if (response.ok) {",
1286
+ " waitForServerRestart();",
1287
+ " } else {",
1288
+ " return response.json().then(function(data) {",
1289
+ " throw new Error(data.message || 'Restart failed');",
1290
+ " });",
1291
+ " }",
1292
+ " })",
1293
+ " .catch(function(err) {",
1294
+ " showToast('Failed to restart: ' + err.message, 'error');",
1295
+ " });",
1296
+ " };",
1297
+ // Trigger restart (called when streams reach 0).
1298
+ " function triggerRestart() {",
1299
+ " fetch('/config/restart-now', { method: 'POST' })",
1300
+ " .then(function(response) {",
1301
+ " if (response.ok) {",
1302
+ " waitForServerRestart();",
1303
+ " }",
1304
+ " })",
1305
+ " .catch(function() {",
1306
+ " showToast('Failed to trigger restart. Please restart manually.', 'error');",
1307
+ " });",
1308
+ " }",
1309
+ // Wait for server restart by polling /health, then reload.
1310
+ " function waitForServerRestart() {",
1311
+ " var attempts = 0;",
1312
+ " var maxAttempts = 30;",
1313
+ " showToast('Restarting server...', 'info', 0);",
1314
+ " if (restartPollInterval) { clearInterval(restartPollInterval); }",
1315
+ " restartPollInterval = setInterval(function() {",
1316
+ " attempts++;",
1317
+ " fetch('/health')",
1318
+ " .then(function(response) {",
1319
+ " if (response.ok) {",
1320
+ " clearInterval(restartPollInterval);",
1321
+ " restartPollInterval = null;",
1322
+ " showToastAfterReload('Server restarted.', 'success');",
1323
+ " }",
1324
+ " })",
1325
+ " .catch(function() {",
1326
+ " if (attempts >= maxAttempts) {",
1327
+ " clearInterval(restartPollInterval);",
1328
+ " restartPollInterval = null;",
1329
+ " showToast('Server did not restart within 30 seconds. Please check the server manually.', 'error');",
1330
+ " }",
1331
+ " });",
1332
+ " }, 1000);",
1333
+ " }",
1334
+ // Escape HTML entities in text for safe display.
1335
+ " function escapeHtmlText(text) {",
1336
+ " var div = document.createElement('div');",
1337
+ " div.textContent = text;",
1338
+ " return div.innerHTML;",
1339
+ " }",
1340
+ // Open the changelog modal and fetch content dynamically.
1341
+ " window.openChangelogModal = function() {",
1342
+ " var modal = document.getElementById('changelog-modal');",
1343
+ " if (!modal) return;",
1344
+ " var title = modal.querySelector('.changelog-title');",
1345
+ " var loading = modal.querySelector('.changelog-loading');",
1346
+ " var content = modal.querySelector('.changelog-content');",
1347
+ " var error = modal.querySelector('.changelog-error');",
1348
+ " modal.style.display = 'flex';",
1349
+ " loading.style.display = 'block';",
1350
+ " content.style.display = 'none';",
1351
+ " error.style.display = 'none';",
1352
+ " fetch('/version/changelog')",
1353
+ " .then(function(res) { return res.json(); })",
1354
+ " .then(function(data) {",
1355
+ " loading.style.display = 'none';",
1356
+ " title.textContent = \"What's new in v\" + data.displayVersion;",
1357
+ " if (data.items && data.items.length > 0) {",
1358
+ " var html = '<ul class=\"changelog-list\">';",
1359
+ " for (var i = 0; i < data.items.length; i++) {",
1360
+ " html += '<li>' + escapeHtmlText(data.items[i]) + '</li>';",
1361
+ " }",
1362
+ " html += '</ul>';",
1363
+ " content.innerHTML = html;",
1364
+ " content.style.display = 'block';",
1365
+ " } else {",
1366
+ " error.style.display = 'block';",
1367
+ " }",
1368
+ " })",
1369
+ " .catch(function() {",
1370
+ " loading.style.display = 'none';",
1371
+ " error.style.display = 'block';",
1372
+ " });",
1373
+ " };",
1374
+ // Close the changelog modal.
1375
+ " window.closeChangelogModal = function() {",
1376
+ " var modal = document.getElementById('changelog-modal');",
1377
+ " if (modal) { modal.style.display = 'none'; }",
1378
+ " };",
1379
+ // Check for updates manually. Updates the version link in-place when a new version is found.
1380
+ " window.checkForUpdates = function() {",
1381
+ " var btn = document.querySelector('.version-check');",
1382
+ " if (!btn || btn.classList.contains('checking')) return;",
1383
+ " btn.classList.add('checking');",
1384
+ " fetch('/version/check', { method: 'POST' })",
1385
+ " .then(function(res) { return res.json(); })",
1386
+ " .then(function(data) {",
1387
+ " btn.classList.remove('checking');",
1388
+ " if (data.updateAvailable && data.latestVersion) {",
1389
+ " var link = document.querySelector('.version-container .version');",
1390
+ " if (link && !link.classList.contains('version-update')) {",
1391
+ " link.textContent = 'v' + data.currentVersion + ' \\u2192 v' + data.latestVersion;",
1392
+ " link.classList.add('version-update');",
1393
+ " }",
1394
+ " } else {",
1395
+ " btn.classList.add('up-to-date');",
1396
+ " setTimeout(function() { btn.classList.remove('up-to-date'); }, 2000);",
1397
+ " }",
1398
+ " })",
1399
+ " .catch(function() {",
1400
+ " btn.classList.remove('checking');",
1401
+ " btn.classList.add('check-error');",
1402
+ " setTimeout(function() { btn.classList.remove('check-error'); }, 2000);",
1403
+ " });",
1404
+ " };",
1405
+ // Clear all field error indicators.
1406
+ " function clearFieldErrors() {",
1407
+ " var errorInputs = document.querySelectorAll('.form-input.error, .form-select.error');",
1408
+ " for (var i = 0; i < errorInputs.length; i++) {",
1409
+ " errorInputs[i].classList.remove('error');",
1410
+ " }",
1411
+ " var errorMsgs = document.querySelectorAll('.form-error.dynamic');",
1412
+ " for (var j = 0; j < errorMsgs.length; j++) {",
1413
+ " errorMsgs[j].remove();",
1414
+ " }",
1415
+ " }",
1416
+ // Display field-level errors from server response.
1417
+ " function displayFieldErrors(errors) {",
1418
+ " for (var path in errors) {",
1419
+ " var inputId = path.replace(/\\./g, '-');",
1420
+ " var input = document.getElementById(inputId);",
1421
+ " if (input) {",
1422
+ " input.classList.add('error');",
1423
+ " var errorDiv = document.createElement('div');",
1424
+ " errorDiv.className = 'form-error dynamic';",
1425
+ " errorDiv.textContent = errors[path];",
1426
+ " input.closest('.form-group').appendChild(errorDiv);",
1427
+ " }",
1428
+ " }",
1429
+ " }",
1430
+ // Set a form input's value, handling checkbox and standard input types uniformly.
1431
+ " function setInputValue(input, value) {",
1432
+ " if (input.type === 'checkbox') {",
1433
+ " input.checked = value === 'true';",
1434
+ " } else {",
1435
+ " input.value = value;",
1436
+ " }",
1437
+ " }",
1438
+ // Get a form input's current value as a string, handling checkbox and standard input types uniformly.
1439
+ " function getInputValue(input) {",
1440
+ " return input.type === 'checkbox' ? String(input.checked) : input.value;",
1441
+ " }",
1442
+ // Toggle dependent fields when a boolean checkbox changes. Fields with data-depends-on are visually greyed out and removed from the tab order when the
1443
+ // referenced checkbox is unchecked. This function is defined at the top level so that both event handlers and reset functions can call it.
1444
+ " function updateDependentFields(checkboxId) {",
1445
+ " var checkbox = document.getElementById(checkboxId);",
1446
+ " if (!checkbox) return;",
1447
+ " var dependents = document.querySelectorAll('[data-depends-on=\"' + checkboxId + '\"]');",
1448
+ " for (var i = 0; i < dependents.length; i++) {",
1449
+ " if (checkbox.checked) {",
1450
+ " dependents[i].classList.remove('depends-disabled');",
1451
+ " } else {",
1452
+ " dependents[i].classList.add('depends-disabled');",
1453
+ " }",
1454
+ " var depInputs = dependents[i].querySelectorAll('input:not([type=\"hidden\"]), select');",
1455
+ " for (var j = 0; j < depInputs.length; j++) {",
1456
+ " depInputs[j].tabIndex = checkbox.checked ? 0 : -1;",
1457
+ " }",
1458
+ " }",
1459
+ " }",
1460
+ // Update modified indicators for a single input.
1461
+ " function updateModifiedIndicator(input) {",
1462
+ " var defaultVal = input.getAttribute('data-default');",
1463
+ " var currentVal = getInputValue(input);",
1464
+ " var formGroup = input.closest('.form-group');",
1465
+ " if (!formGroup) return;",
1466
+ " var isModified = currentVal !== defaultVal;",
1467
+ " var dot = formGroup.querySelector('.modified-dot');",
1468
+ " var resetBtn = formGroup.querySelector('.btn-reset');",
1469
+ " if (isModified) {",
1470
+ " formGroup.classList.add('modified');",
1471
+ " if (!dot) {",
1472
+ " var label = formGroup.querySelector('.form-label');",
1473
+ " if (label) {",
1474
+ " var newDot = document.createElement('span');",
1475
+ " newDot.className = 'modified-dot';",
1476
+ " newDot.title = 'Modified from default';",
1477
+ " label.insertBefore(newDot, label.firstChild);",
1478
+ " }",
1479
+ " }",
1480
+ " if (!resetBtn) {",
1481
+ " var row = formGroup.querySelector('.form-row');",
1482
+ " if (row) {",
1483
+ " var path = input.getAttribute('name');",
1484
+ " var btn = document.createElement('button');",
1485
+ " btn.type = 'button';",
1486
+ " btn.className = 'btn-reset';",
1487
+ " btn.title = 'Reset to default';",
1488
+ " btn.innerHTML = '&#8635;';",
1489
+ " btn.onclick = function() { resetSetting(path); };",
1490
+ " row.appendChild(btn);",
1491
+ " }",
1492
+ " }",
1493
+ " } else {",
1494
+ " formGroup.classList.remove('modified');",
1495
+ " if (dot) dot.remove();",
1496
+ " if (resetBtn) resetBtn.remove();",
1497
+ " }",
1498
+ " }",
1499
+ // Reset a single setting to its default value (client-side only). Dispatches both input and change events to match browser behavior: input for validation and
1500
+ // modified indicator updates, change for cascade handlers (e.g., preset dropdown updating bitrate and frame rate fields).
1501
+ " window.resetSetting = function(path) {",
1502
+ " var inputId = path.replace(/\\./g, '-');",
1503
+ " var input = document.getElementById(inputId);",
1504
+ " if (!input) return;",
1505
+ " var defaultVal = input.getAttribute('data-default');",
1506
+ " if (defaultVal !== null) {",
1507
+ " setInputValue(input, defaultVal);",
1508
+ " input.dispatchEvent(new Event('input', { bubbles: true }));",
1509
+ " input.dispatchEvent(new Event('change', { bubbles: true }));",
1510
+ " }",
1511
+ " };",
1512
+ // Reset all settings in a tab to defaults (client-side only). Works for both settings and advanced tabs.
1513
+ " window.resetTabToDefaults = function(tabId) {",
1514
+ " if (!confirm('Reset all settings in this tab to defaults?')) return;",
1515
+ " var panel = document.getElementById('subtab-' + tabId);",
1516
+ " if (!panel) return;",
1517
+ " var inputs = panel.querySelectorAll('input[data-default], select[data-default]');",
1518
+ " for (var i = 0; i < inputs.length; i++) {",
1519
+ " var input = inputs[i];",
1520
+ " if (!input.disabled) {",
1521
+ " setInputValue(input, input.getAttribute('data-default'));",
1522
+ " updateModifiedIndicator(input);",
1523
+ " }",
1524
+ " }",
1525
+ " var cbInputs = panel.querySelectorAll('input[type=\"checkbox\"][data-default]');",
1526
+ " for (var j = 0; j < cbInputs.length; j++) {",
1527
+ " if (!cbInputs[j].disabled) {",
1528
+ " updateDependentFields(cbInputs[j].id);",
1529
+ " }",
1530
+ " }",
1531
+ " showToast('Settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
1532
+ " };",
1533
+ // Toggle collapsible section in Advanced tab.
1534
+ " window.toggleSection = function(sectionId) {",
1535
+ " var section = document.querySelector('.advanced-section[data-section=\"' + sectionId + '\"]');",
1536
+ " if (!section) return;",
1537
+ " var header = section.querySelector('.section-header');",
1538
+ " var content = section.querySelector('.section-content');",
1539
+ " if (!header || !content) return;",
1540
+ " var isExpanded = content.classList.contains('expanded');",
1541
+ " if (isExpanded) {",
1542
+ " content.classList.remove('expanded');",
1543
+ " header.classList.remove('expanded');",
1544
+ " } else {",
1545
+ " content.classList.add('expanded');",
1546
+ " header.classList.add('expanded');",
1547
+ " }",
1548
+ " try {",
1549
+ " var expanded = JSON.parse(localStorage.getItem('prismcast-advanced-sections') || '{}');",
1550
+ " expanded[sectionId] = !isExpanded;",
1551
+ " localStorage.setItem('prismcast-advanced-sections', JSON.stringify(expanded));",
1552
+ " } catch(e) {}",
1553
+ " };",
1554
+ // Initialize section expansion state from localStorage.
1555
+ " function initSections() {",
1556
+ " try {",
1557
+ " var expanded = JSON.parse(localStorage.getItem('prismcast-advanced-sections') || '{}');",
1558
+ " for (var sectionId in expanded) {",
1559
+ " if (expanded[sectionId]) {",
1560
+ " var section = document.querySelector('.advanced-section[data-section=\"' + sectionId + '\"]');",
1561
+ " if (section) {",
1562
+ " var header = section.querySelector('.section-header');",
1563
+ " var content = section.querySelector('.section-content');",
1564
+ " if (header && content) {",
1565
+ " header.classList.add('expanded');",
1566
+ " content.classList.add('expanded');",
1567
+ " }",
1568
+ " }",
1569
+ " }",
1570
+ " }",
1571
+ " } catch(e) {}",
1572
+ " }",
1573
+ " initSections();",
1574
+ // Reset all settings to defaults (client-side only).
1575
+ " window.resetAllToDefaults = function() {",
1576
+ " if (!confirm('Reset ALL settings to defaults? Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' after to apply.')) return;",
1577
+ " var form = document.getElementById('settings-form');",
1578
+ " if (!form) return;",
1579
+ " var inputs = form.querySelectorAll('input[data-default], select[data-default]');",
1580
+ " for (var i = 0; i < inputs.length; i++) {",
1581
+ " var input = inputs[i];",
1582
+ " if (!input.disabled) {",
1583
+ " setInputValue(input, input.getAttribute('data-default'));",
1584
+ " updateModifiedIndicator(input);",
1585
+ " }",
1586
+ " }",
1587
+ " var cbInputs = form.querySelectorAll('input[type=\"checkbox\"][data-default]');",
1588
+ " for (var j = 0; j < cbInputs.length; j++) {",
1589
+ " if (!cbInputs[j].disabled) {",
1590
+ " updateDependentFields(cbInputs[j].id);",
1591
+ " }",
1592
+ " }",
1593
+ " showToast('All settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
1594
+ " };",
1595
+ // Submit settings form via AJAX.
1596
+ " window.submitSettingsForm = function(event) {",
1597
+ " event.preventDefault();",
1598
+ " clearFieldErrors();",
1599
+ " var form = document.getElementById('settings-form');",
1600
+ " var saveBtn = document.getElementById('save-btn');",
1601
+ " if (!form) return false;",
1602
+ " var formData = new FormData(form);",
1603
+ " var config = {};",
1604
+ " for (var pair of formData.entries()) {",
1605
+ " var path = pair[0];",
1606
+ " var value = pair[1];",
1607
+ " var parts = path.split('.');",
1608
+ " var obj = config;",
1609
+ " for (var i = 0; i < parts.length - 1; i++) {",
1610
+ " if (!obj[parts[i]]) obj[parts[i]] = {};",
1611
+ " obj = obj[parts[i]];",
1612
+ " }",
1613
+ " obj[parts[parts.length - 1]] = value;",
1614
+ " }",
1615
+ " if (saveBtn) saveBtn.classList.add('loading');",
1616
+ " fetch('/config', {",
1617
+ " method: 'POST',",
1618
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
1619
+ " body: JSON.stringify(config)",
1620
+ " })",
1621
+ " .then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); })",
1622
+ " .then(function(result) {",
1623
+ " if (saveBtn) saveBtn.classList.remove('loading');",
1624
+ " if (result.ok && result.data.success) {",
1625
+ " if (result.data.willRestart) {",
1626
+ " if (result.data.deferred) {",
1627
+ " showPendingRestartDialog(result.data.activeStreams);",
1628
+ " } else {",
1629
+ " waitForServerRestart();",
1630
+ " }",
1631
+ " } else {",
1632
+ " showToast(result.data.message || 'Configuration saved.', 'info');",
1633
+ " }",
1634
+ " } else if (result.data.errors) {",
1635
+ " displayFieldErrors(result.data.errors);",
1636
+ " showToast('Please correct the errors below.', 'error');",
1637
+ " } else {",
1638
+ " showToast(result.data.message || 'Failed to save configuration.', 'error');",
1639
+ " }",
1640
+ " })",
1641
+ " .catch(function(err) {",
1642
+ " if (saveBtn) saveBtn.classList.remove('loading');",
1643
+ " showToast('Failed to save configuration: ' + err.message, 'error');",
1644
+ " });",
1645
+ " return false;",
1646
+ " };",
1647
+ // Export configuration as JSON download.
1648
+ " window.exportConfig = function() {",
1649
+ " fetch('/config/export')",
1650
+ " .then(function(response) { return response.blob(); })",
1651
+ " .then(function(blob) {",
1652
+ " var url = window.URL.createObjectURL(blob);",
1653
+ " var a = document.createElement('a');",
1654
+ " a.href = url;",
1655
+ " a.download = 'prismcast-config.json';",
1656
+ " document.body.appendChild(a);",
1657
+ " a.click();",
1658
+ " document.body.removeChild(a);",
1659
+ " window.URL.revokeObjectURL(url);",
1660
+ " })",
1661
+ " .catch(function(err) { showToast('Failed to export configuration: ' + err.message, 'error'); });",
1662
+ " };",
1663
+ // Import configuration from file.
1664
+ " window.importConfig = function(fileInput) {",
1665
+ " var file = fileInput.files[0];",
1666
+ " if (!file) return;",
1667
+ " var reader = new FileReader();",
1668
+ " reader.onload = function(e) {",
1669
+ " try {",
1670
+ " var config = JSON.parse(e.target.result);",
1671
+ " if (confirm('Import this configuration? The server may restart to apply changes.')) {",
1672
+ " fetch('/config/import', {",
1673
+ " method: 'POST',",
1674
+ " headers: { 'Content-Type': 'application/json' },",
1675
+ " body: JSON.stringify(config)",
1676
+ " })",
1677
+ " .then(function(response) { return response.json().then(function(data) { return { ok: response.ok, data: data }; }); })",
1678
+ " .then(function(result) {",
1679
+ " if (result.ok && result.data.success) {",
1680
+ " showToast(result.data.message || 'Configuration imported.', 'success');",
1681
+ " if (result.data.willRestart) {",
1682
+ " if (result.data.deferred) {",
1683
+ " showPendingRestartDialog(result.data.activeStreams);",
1684
+ " } else {",
1685
+ " waitForServerRestart();",
1686
+ " }",
1687
+ " }",
1688
+ " } else {",
1689
+ " throw new Error(result.data.message || result.data.error || 'Import failed');",
1690
+ " }",
1691
+ " })",
1692
+ " .catch(function(err) { showToast('Failed to import configuration: ' + err.message, 'error'); });",
1693
+ " }",
1694
+ " } catch (err) {",
1695
+ " showToast('Invalid JSON file: ' + err.message, 'error');",
1696
+ " }",
1697
+ " fileInput.value = '';",
1698
+ " };",
1699
+ " reader.readAsText(file);",
1700
+ " };",
1701
+ // Export channels as JSON download.
1702
+ " window.exportChannels = function() {",
1703
+ " fetch('/config/channels/export')",
1704
+ " .then(function(response) { return response.blob(); })",
1705
+ " .then(function(blob) {",
1706
+ " var url = window.URL.createObjectURL(blob);",
1707
+ " var a = document.createElement('a');",
1708
+ " a.href = url;",
1709
+ " a.download = 'prismcast-channels.json';",
1710
+ " document.body.appendChild(a);",
1711
+ " a.click();",
1712
+ " document.body.removeChild(a);",
1713
+ " window.URL.revokeObjectURL(url);",
1714
+ " })",
1715
+ " .catch(function(err) { showToast('Failed to export channels: ' + err.message, 'error'); });",
1716
+ " };",
1717
+ // Import channels from file.
1718
+ " window.importChannels = function(fileInput) {",
1719
+ " var file = fileInput.files[0];",
1720
+ " if (!file) return;",
1721
+ " var reader = new FileReader();",
1722
+ " reader.onload = function(e) {",
1723
+ " try {",
1724
+ " var channels = JSON.parse(e.target.result);",
1725
+ " if (confirm('Import these channels? This will replace all existing user channels.')) {",
1726
+ " fetch('/config/channels/import', {",
1727
+ " method: 'POST',",
1728
+ " headers: { 'Content-Type': 'application/json' },",
1729
+ " body: JSON.stringify(channels)",
1730
+ " })",
1731
+ " .then(function(response) {",
1732
+ " if (response.ok) {",
1733
+ " showToastAfterReload('Channels imported successfully.', 'success');",
1734
+ " } else {",
1735
+ " return response.text().then(function(text) { throw new Error(text); });",
1736
+ " }",
1737
+ " })",
1738
+ " .catch(function(err) { showToast('Failed to import channels: ' + err.message, 'error'); });",
1739
+ " }",
1740
+ " } catch (err) {",
1741
+ " showToast('Invalid JSON file: ' + err.message, 'error');",
1742
+ " }",
1743
+ " fileInput.value = '';",
1744
+ " };",
1745
+ " reader.readAsText(file);",
1746
+ " };",
1747
+ // Import channels from M3U playlist file.
1748
+ " window.importM3U = function(fileInput) {",
1749
+ " var file = fileInput.files[0];",
1750
+ " if (!file) return;",
1751
+ " var replaceCheckbox = document.getElementById('m3u-replace-duplicates');",
1752
+ " var conflictMode = (replaceCheckbox && replaceCheckbox.checked) ? 'replace' : 'skip';",
1753
+ " var reader = new FileReader();",
1754
+ " reader.onload = function(e) {",
1755
+ " fetch('/config/channels/import-m3u', {",
1756
+ " method: 'POST',",
1757
+ " headers: { 'Content-Type': 'application/json' },",
1758
+ " body: JSON.stringify({ content: e.target.result, conflictMode: conflictMode })",
1759
+ " })",
1760
+ " .then(function(response) { return response.json(); })",
1761
+ " .then(function(data) {",
1762
+ " if (data.success) {",
1763
+ " var msg = 'M3U Import Complete\\n\\n';",
1764
+ " msg += '\\u2713 ' + data.imported + ' channel(s) imported\\n';",
1765
+ " if (data.replaced > 0) msg += '\\u21BB ' + data.replaced + ' channel(s) replaced\\n';",
1766
+ " if (data.skipped > 0) msg += '\\u25CB ' + data.skipped + ' duplicate(s) skipped\\n';",
1767
+ " if (data.errors && data.errors.length > 0) {",
1768
+ " msg += '\\n! ' + data.errors.length + ' error(s):\\n';",
1769
+ " for (var i = 0; i < Math.min(data.errors.length, 5); i++) {",
1770
+ " msg += ' - ' + data.errors[i] + '\\n';",
1771
+ " }",
1772
+ " if (data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
1773
+ " }",
1774
+ " if (data.imported > 0 || data.replaced > 0) { showToastAfterReload(msg, 'success'); }",
1775
+ " else { showToast(msg, 'success'); }",
1776
+ " } else {",
1777
+ " showToast('M3U import failed: ' + (data.error || 'Unknown error'), 'error');",
1778
+ " }",
1779
+ " })",
1780
+ " .catch(function(err) { showToast('Failed to import M3U: ' + err.message, 'error'); });",
1781
+ " fileInput.value = '';",
1782
+ " };",
1783
+ " reader.readAsText(file);",
1784
+ " };",
1785
+ // Client-side validation for numeric inputs.
1786
+ " function validateInput(input) {",
1787
+ " var min = input.min !== '' ? Number(input.min) : null;",
1788
+ " var max = input.max !== '' ? Number(input.max) : null;",
1789
+ " var value = Number(input.value);",
1790
+ " var isValid = true;",
1791
+ " if (input.type === 'number') {",
1792
+ " if (isNaN(value)) { isValid = false; }",
1793
+ " else if (min !== null && value < min) { isValid = false; }",
1794
+ " else if (max !== null && value > max) { isValid = false; }",
1795
+ " }",
1796
+ " if (isValid) {",
1797
+ " input.classList.remove('error');",
1798
+ " } else {",
1799
+ " input.classList.add('error');",
1800
+ " }",
1801
+ " return isValid;",
1802
+ " }",
1803
+ // Subtab switching function.
1804
+ " function switchSubtab(subtab, updateUrl) {",
1805
+ " var btns = document.querySelectorAll('.subtab-btn');",
1806
+ " var panels = document.querySelectorAll('.subtab-panel');",
1807
+ " var settingsButtons = document.getElementById('settings-buttons');",
1808
+ " for (var i = 0; i < btns.length; i++) {",
1809
+ " btns[i].classList.remove('active');",
1810
+ " btns[i].setAttribute('aria-selected', 'false');",
1811
+ " if (btns[i].getAttribute('data-subtab') === subtab) {",
1812
+ " btns[i].classList.add('active');",
1813
+ " btns[i].setAttribute('aria-selected', 'true');",
1814
+ " }",
1815
+ " }",
1816
+ " for (var j = 0; j < panels.length; j++) {",
1817
+ " panels[j].classList.remove('active');",
1818
+ " if (panels[j].id === 'subtab-' + subtab) {",
1819
+ " panels[j].classList.add('active');",
1820
+ " }",
1821
+ " }",
1822
+ // Show/hide settings buttons based on subtab (hidden on backup subtab).
1823
+ " if (settingsButtons) {",
1824
+ " settingsButtons.style.display = (subtab === 'backup') ? 'none' : 'flex';",
1825
+ " }",
1826
+ // Update localStorage and URL hash.
1827
+ " try { localStorage.setItem('prismcast-config-subtab', subtab); } catch(e) {}",
1828
+ " if (updateUrl !== false) {",
1829
+ " var newHash = '#config/' + subtab;",
1830
+ " if (window.location.hash !== newHash) {",
1831
+ " window.location.hash = newHash;",
1832
+ " }",
1833
+ " }",
1834
+ " }",
1835
+ // Expose for main tab script to use.
1836
+ " window.switchConfigSubtab = switchSubtab;",
1837
+ // Attach click handlers to subtab buttons.
1838
+ " var subtabBtns = document.querySelectorAll('.subtab-btn');",
1839
+ " for (var i = 0; i < subtabBtns.length; i++) {",
1840
+ " subtabBtns[i].addEventListener('click', function() {",
1841
+ " switchSubtab(this.getAttribute('data-subtab'));",
1842
+ " });",
1843
+ " }",
1844
+ // Channel edit form show/hide functions.
1845
+ " window.showEditForm = function(key) {",
1846
+ " var displayRow = document.getElementById('display-row-' + key);",
1847
+ " var editRow = document.getElementById('edit-row-' + key);",
1848
+ " if (displayRow) displayRow.style.display = 'none';",
1849
+ " if (editRow) {",
1850
+ " editRow.style.display = '';",
1851
+ " updateSelectorSuggestions('edit-url-' + key, 'edit-' + key + '-selectorList');",
1852
+ " var urlInput = document.getElementById('edit-url-' + key);",
1853
+ " if (urlInput && !urlInput.dataset.selectorBound) {",
1854
+ " urlInput.addEventListener('input', function() { updateSelectorSuggestions('edit-url-' + key, 'edit-' + key + '-selectorList'); });",
1855
+ " urlInput.dataset.selectorBound = 'true';",
1856
+ " }",
1857
+ " }",
1858
+ " };",
1859
+ " window.hideEditForm = function(key) {",
1860
+ " var displayRow = document.getElementById('display-row-' + key);",
1861
+ " var editRow = document.getElementById('edit-row-' + key);",
1862
+ " if (displayRow) displayRow.style.display = '';",
1863
+ " if (editRow) editRow.style.display = 'none';",
1864
+ " };",
1865
+ // Hide add form and show the Add Channel button.
1866
+ " window.hideAddForm = function() {",
1867
+ " var addForm = document.getElementById('add-channel-form');",
1868
+ " var addBtn = document.getElementById('add-channel-btn');",
1869
+ " if (addForm) addForm.style.display = 'none';",
1870
+ " if (addBtn) addBtn.style.display = 'inline-block';",
1871
+ " if (addForm) addForm.querySelector('form').reset();",
1872
+ " };",
1873
+ // Insert or replace channel rows in the table. Always removes existing rows with the same key first (handles both edits and overrides of builtin channels).
1874
+ " window.insertChannelRow = function(html, key) {",
1875
+ " var tbody = document.querySelector('.channel-table tbody');",
1876
+ " if (!tbody || !html) return;",
1877
+ // Remove any existing rows with this key (edit or override of builtin).
1878
+ " var oldDisplay = document.getElementById('display-row-' + key);",
1879
+ " var oldEdit = document.getElementById('edit-row-' + key);",
1880
+ " if (oldEdit) oldEdit.remove();",
1881
+ " if (oldDisplay) oldDisplay.remove();",
1882
+ // Create temporary container to parse HTML.
1883
+ " var temp = document.createElement('tbody');",
1884
+ " temp.innerHTML = html.displayRow + (html.editRow || '');",
1885
+ " var newDisplayRow = temp.firstElementChild;",
1886
+ " var newEditRow = temp.children[1] || null;",
1887
+ // Find insertion point (alphabetical by key) and insert.
1888
+ " var rows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
1889
+ " var inserted = false;",
1890
+ " for (var i = 0; i < rows.length; i++) {",
1891
+ " var rowKey = rows[i].id.replace('display-row-', '');",
1892
+ " if (key < rowKey) {",
1893
+ " tbody.insertBefore(newDisplayRow, rows[i]);",
1894
+ " if (newEditRow) tbody.insertBefore(newEditRow, rows[i]);",
1895
+ " inserted = true;",
1896
+ " break;",
1897
+ " }",
1898
+ " }",
1899
+ " if (!inserted) {",
1900
+ " tbody.appendChild(newDisplayRow);",
1901
+ " if (newEditRow) tbody.appendChild(newEditRow);",
1902
+ " }",
1903
+ " updateDisabledCount();",
1904
+ " };",
1905
+ // Remove channel rows from the table.
1906
+ " window.removeChannelRow = function(key) {",
1907
+ " var displayRow = document.getElementById('display-row-' + key);",
1908
+ " var editRow = document.getElementById('edit-row-' + key);",
1909
+ " if (displayRow) displayRow.remove();",
1910
+ " if (editRow) editRow.remove();",
1911
+ " updateDisabledCount();",
1912
+ " };",
1913
+ // Advanced fields toggle for channel forms.
1914
+ " window.toggleAdvanced = function(prefix) {",
1915
+ " var fields = document.getElementById(prefix + '-advanced');",
1916
+ " var toggle = document.getElementById(prefix + '-toggle');",
1917
+ " if (fields && toggle) {",
1918
+ " if (fields.classList.contains('show')) {",
1919
+ " fields.classList.remove('show');",
1920
+ " toggle.textContent = '\\u25B6 Show Advanced Options';",
1921
+ " } else {",
1922
+ " fields.classList.add('show');",
1923
+ " toggle.textContent = '\\u25BC Hide Advanced Options';",
1924
+ " }",
1925
+ " }",
1926
+ " };",
1927
+ // Profile reference toggle.
1928
+ " window.toggleProfileReference = function() {",
1929
+ " var ref = document.getElementById('profile-reference');",
1930
+ " if (ref) {",
1931
+ " ref.style.display = ref.style.display === 'none' ? 'block' : 'none';",
1932
+ " }",
1933
+ " };",
1934
+ // Submit channel form via AJAX (add or edit).
1935
+ " window.submitChannelForm = function(event, action) {",
1936
+ " event.preventDefault();",
1937
+ " var form = event.target;",
1938
+ " var formData = new FormData(form);",
1939
+ " var data = {};",
1940
+ " for (var pair of formData.entries()) { data[pair[0]] = pair[1]; }",
1941
+ " fetch('/config/channels', {",
1942
+ " method: 'POST',",
1943
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
1944
+ " body: JSON.stringify(data)",
1945
+ " })",
1946
+ " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
1947
+ " .then(function(result) {",
1948
+ " if (result.ok && result.data.success) {",
1949
+ " showToast(result.data.message, 'success');",
1950
+ " if (result.data.html) {",
1951
+ " insertChannelRow(result.data.html, result.data.key);",
1952
+ " if (action === 'add') {",
1953
+ " hideAddForm();",
1954
+ " } else {",
1955
+ " hideEditForm(result.data.key);",
1956
+ " }",
1957
+ " } else {",
1958
+ " showToast('Channel saved. Reload to see changes.', 'info');",
1959
+ " }",
1960
+ " } else if (result.data.errors) {",
1961
+ " var errorMsgs = [];",
1962
+ " for (var field in result.data.errors) { errorMsgs.push(field + ': ' + result.data.errors[field]); }",
1963
+ " showToast('Validation errors: ' + errorMsgs.join(', '), 'error');",
1964
+ " } else {",
1965
+ " showToast(result.data.message || 'Failed to save channel.', 'error');",
1966
+ " }",
1967
+ " })",
1968
+ " .catch(function(err) { showToast('Failed to save channel: ' + err.message, 'error'); });",
1969
+ " return false;",
1970
+ " };",
1971
+ // Delete channel via AJAX.
1972
+ " window.deleteChannel = function(key) {",
1973
+ " if (!confirm('Delete channel ' + key + '?')) return;",
1974
+ " fetch('/config/channels', {",
1975
+ " method: 'POST',",
1976
+ " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
1977
+ " body: JSON.stringify({ action: 'delete', key: key })",
1978
+ " })",
1979
+ " .then(function(response) { return response.json().then(function(d) { return { ok: response.ok, data: d }; }); })",
1980
+ " .then(function(result) {",
1981
+ " if (result.ok && result.data.success) {",
1982
+ " showToast(result.data.message, 'success');",
1983
+ " if (result.data.html) {",
1984
+ " insertChannelRow(result.data.html, result.data.key || key);",
1985
+ " } else {",
1986
+ " removeChannelRow(result.data.key || key);",
1987
+ " }",
1988
+ " } else {",
1989
+ " showToast(result.data.message || 'Failed to delete channel.', 'error');",
1990
+ " }",
1991
+ " })",
1992
+ " .catch(function(err) { showToast('Failed to delete channel: ' + err.message, 'error'); });",
1993
+ " };",
1994
+ // Toggle a single predefined channel's enabled/disabled state.
1995
+ " window.togglePredefinedChannel = function(key, enable) {",
1996
+ " fetch('/config/channels/toggle-predefined', {",
1997
+ " method: 'POST',",
1998
+ " headers: { 'Content-Type': 'application/json' },",
1999
+ " body: JSON.stringify({ key: key, enabled: enable })",
2000
+ " })",
2001
+ " .then(function(response) { return response.json(); })",
2002
+ " .then(function(result) {",
2003
+ " if (result.success) {",
2004
+ " showToast('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.', 'success');",
2005
+ " updateChannelRowDisabledState(key, !enable);",
2006
+ " } else {",
2007
+ " showToast(result.error || 'Failed to toggle channel.', 'error');",
2008
+ " }",
2009
+ " })",
2010
+ " .catch(function(err) { showToast('Failed to toggle channel: ' + err.message, 'error'); });",
2011
+ " };",
2012
+ // Update a channel row's provider selection and profile cell in-place. Syncs the HTML selected attribute so filterChannelRows() restore logic works correctly.
2013
+ // We iterate _allOptions (if present) rather than querySelectorAll because filtered-out options are removed from the DOM but still tracked in the array.
2014
+ " function updateChannelProviderUI(channelKey, variant, profile) {",
2015
+ " var row = document.getElementById('display-row-' + channelKey);",
2016
+ " if (!row) return;",
2017
+ " var sel = row.querySelector('.provider-select');",
2018
+ " if (sel) {",
2019
+ " sel.value = variant;",
2020
+ " var allOpts = sel._allOptions || Array.prototype.slice.call(sel.querySelectorAll('option'));",
2021
+ " for (var oi = 0; oi < allOpts.length; oi++) {",
2022
+ " if (allOpts[oi].value === variant) { allOpts[oi].setAttribute('selected', ''); }",
2023
+ " else { allOpts[oi].removeAttribute('selected'); }",
2024
+ " }",
2025
+ " }",
2026
+ " var profileCell = row.cells[3];",
2027
+ " if (profileCell) {",
2028
+ " if (profile) { profileCell.textContent = profile; }",
2029
+ " else { profileCell.innerHTML = '<em>auto</em>'; }",
2030
+ " }",
2031
+ " }",
2032
+ // Update provider selection for a multi-provider channel.
2033
+ " window.updateProviderSelection = function(selectElement) {",
2034
+ " var channelKey = selectElement.getAttribute('data-channel');",
2035
+ " var providerKey = selectElement.value;",
2036
+ " fetch('/config/provider', {",
2037
+ " method: 'POST',",
2038
+ " headers: { 'Content-Type': 'application/json' },",
2039
+ " body: JSON.stringify({ channel: channelKey, provider: providerKey })",
2040
+ " })",
2041
+ " .then(function(response) { return response.json(); })",
2042
+ " .then(function(result) {",
2043
+ " if (result.success) {",
2044
+ " showToast('Provider updated. New streams will use the selected provider.', 'success');",
2045
+ " updateChannelProviderUI(channelKey, providerKey, result.profile);",
2046
+ " } else {",
2047
+ " showToast(result.error || 'Failed to update provider.', 'error');",
2048
+ " }",
2049
+ " })",
2050
+ " .catch(function(err) { showToast('Failed to update provider: ' + err.message, 'error'); });",
2051
+ " };",
2052
+ // Toggle all predefined channels' enabled/disabled state.
2053
+ " window.toggleAllPredefined = function(enable) {",
2054
+ " fetch('/config/channels/toggle-all-predefined', {",
2055
+ " method: 'POST',",
2056
+ " headers: { 'Content-Type': 'application/json' },",
2057
+ " body: JSON.stringify({ enabled: enable })",
2058
+ " })",
2059
+ " .then(function(response) { return response.json(); })",
2060
+ " .then(function(result) {",
2061
+ " if (result.success) {",
2062
+ " showToast('All predefined channels ' + (enable ? 'enabled' : 'disabled') + '.', 'success');",
2063
+ " var rows = document.querySelectorAll('tr[id^=\"display-row-\"]:not(.user-channel)');",
2064
+ " for (var i = 0; i < rows.length; i++) {",
2065
+ " var rowKey = rows[i].id.replace('display-row-', '');",
2066
+ " setRowDisabledState(rowKey, !enable);",
2067
+ " }",
2068
+ " updateBulkToggleButton();",
2069
+ " updateDisabledCount();",
2070
+ " } else {",
2071
+ " showToast(result.error || 'Failed to toggle channels.', 'error');",
2072
+ " }",
2073
+ " })",
2074
+ " .catch(function(err) { showToast('Failed to toggle channels: ' + err.message, 'error'); });",
2075
+ " };",
2076
+ // Set a channel row's disabled state without triggering count updates. Used by toggleAllPredefined for efficient bulk updates.
2077
+ " function setRowDisabledState(key, disabled) {",
2078
+ " var row = document.getElementById('display-row-' + key);",
2079
+ " if (!row) return;",
2080
+ " var btnGroup = row.querySelector('.btn-group');",
2081
+ " if (!btnGroup) return;",
2082
+ " if (disabled) {",
2083
+ " row.classList.add('channel-disabled');",
2084
+ " row.classList.remove('user-channel');",
2085
+ " var loginBtn = btnGroup.querySelector('button[onclick*=\"startChannelLogin\"]');",
2086
+ " if (loginBtn) loginBtn.remove();",
2087
+ " var disableBtn = btnGroup.querySelector('.btn-disable');",
2088
+ " if (disableBtn) {",
2089
+ " disableBtn.className = 'btn btn-enable btn-sm';",
2090
+ " disableBtn.textContent = 'Enable';",
2091
+ " disableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', true)\");",
2092
+ " }",
2093
+ " } else {",
2094
+ " row.classList.remove('channel-disabled');",
2095
+ " var enableBtn = btnGroup.querySelector('.btn-enable');",
2096
+ " if (enableBtn) {",
2097
+ " var newLoginBtn = document.createElement('button');",
2098
+ " newLoginBtn.type = 'button';",
2099
+ " newLoginBtn.className = 'btn btn-secondary btn-sm';",
2100
+ " newLoginBtn.setAttribute('onclick', \"startChannelLogin('\" + key + \"')\");",
2101
+ " newLoginBtn.textContent = 'Login';",
2102
+ " btnGroup.insertBefore(newLoginBtn, enableBtn);",
2103
+ " enableBtn.className = 'btn btn-disable btn-sm';",
2104
+ " enableBtn.textContent = 'Disable';",
2105
+ " enableBtn.setAttribute('onclick', \"togglePredefinedChannel('\" + key + \"', false)\");",
2106
+ " }",
2107
+ " }",
2108
+ " }",
2109
+ // Update a single channel row's disabled state and refresh counts. Used by individual togglePredefinedChannel.
2110
+ " function updateChannelRowDisabledState(key, disabled) {",
2111
+ " setRowDisabledState(key, disabled);",
2112
+ " updateBulkToggleButton();",
2113
+ " updateDisabledCount();",
2114
+ " };",
2115
+ // Update the bulk toggle button text based on current state.
2116
+ " function updateBulkToggleButton() {",
2117
+ " var btn = document.getElementById('bulk-toggle-btn');",
2118
+ " if (!btn) return;",
2119
+ " var disabledRows = document.querySelectorAll('tr.channel-disabled:not(.user-channel)');",
2120
+ " var allRows = document.querySelectorAll('tr[id^=\"display-row-\"]:not(.user-channel)');",
2121
+ " var allDisabled = disabledRows.length === allRows.length;",
2122
+ " if (allDisabled) {",
2123
+ " btn.textContent = 'Enable All Predefined';",
2124
+ " btn.setAttribute('onclick', 'toggleAllPredefined(true)');",
2125
+ " } else {",
2126
+ " btn.textContent = 'Disable All Predefined';",
2127
+ " btn.setAttribute('onclick', 'toggleAllPredefined(false)');",
2128
+ " }",
2129
+ " };",
2130
+ // 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
2131
+ // provider-unavailable.
2132
+ " function updateDisabledCount() {",
2133
+ " var countEl = document.getElementById('disabled-count');",
2134
+ " if (!countEl) return;",
2135
+ " var hiddenRows = document.querySelectorAll('tr.channel-disabled:not(.user-channel), tr.channel-unavailable');",
2136
+ " countEl.textContent = String(hiddenRows.length);",
2137
+ " };",
2138
+ // Provider filter: toggle a provider tag on/off.
2139
+ " window.toggleProviderTag = function(checkbox) {",
2140
+ " var menu = checkbox.closest('.provider-dropdown-menu');",
2141
+ " if (!menu) return;",
2142
+ " var checkboxes = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
2143
+ " var enabledTags = [];",
2144
+ " for (var i = 0; i < checkboxes.length; i++) {",
2145
+ " if (checkboxes[i].checked) enabledTags.push(checkboxes[i].getAttribute('data-tag'));",
2146
+ " }",
2147
+ // If all checkboxes are checked, clear the filter (empty array = no filter).
2148
+ " var allCheckboxes = menu.querySelectorAll('input[type=\"checkbox\"]');",
2149
+ " var allChecked = true;",
2150
+ " for (var j = 0; j < allCheckboxes.length; j++) {",
2151
+ " if (!allCheckboxes[j].checked && !allCheckboxes[j].disabled) { allChecked = false; break; }",
2152
+ " }",
2153
+ " if (allChecked) enabledTags = [];",
2154
+ // POST to server.
2155
+ " fetch('/config/provider-filter', {",
2156
+ " method: 'POST',",
2157
+ " headers: { 'Content-Type': 'application/json' },",
2158
+ " body: JSON.stringify({ enabledProviders: enabledTags })",
2159
+ " })",
2160
+ " .then(function(r) { return r.json(); })",
2161
+ " .then(function(result) {",
2162
+ " if (result.success) {",
2163
+ " updateProviderChips(enabledTags);",
2164
+ " filterChannelRows(enabledTags);",
2165
+ " updateBulkAssignOptions(enabledTags);",
2166
+ " updateProviderFilterButton(enabledTags);",
2167
+ " updateDisabledCount();",
2168
+ " }",
2169
+ " })",
2170
+ " .catch(function(err) { showToast('Failed to update filter: ' + err.message, 'error'); });",
2171
+ " };",
2172
+ // Remove a provider chip (uncheck the tag and update).
2173
+ " window.removeProviderChip = function(tag) {",
2174
+ " var menu = document.querySelector('.provider-dropdown-menu');",
2175
+ " if (!menu) return;",
2176
+ " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
2177
+ " if (cb) { cb.checked = false; toggleProviderTag(cb); }",
2178
+ " };",
2179
+ // Update the provider filter button text.
2180
+ " function updateProviderFilterButton(enabledTags) {",
2181
+ " var btn = document.getElementById('provider-filter-btn');",
2182
+ " if (!btn) return;",
2183
+ " btn.innerHTML = (enabledTags.length > 0) ? 'Filtered &#9662;' : 'All Providers &#9662;';",
2184
+ " };",
2185
+ // Rebuild the provider chips from the enabled tags.
2186
+ " function updateProviderChips(enabledTags) {",
2187
+ " var container = document.getElementById('provider-chips');",
2188
+ " if (!container) return;",
2189
+ " container.innerHTML = '';",
2190
+ " if (enabledTags.length === 0) return;",
2191
+ " var menu = document.querySelector('.provider-dropdown-menu');",
2192
+ " for (var i = 0; i < enabledTags.length; i++) {",
2193
+ " var tag = enabledTags[i];",
2194
+ " if (tag === 'direct') continue;",
2195
+ " var label = tag;",
2196
+ " if (menu) {",
2197
+ " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
2198
+ " if (cb && cb.parentElement) label = cb.parentElement.textContent.trim();",
2199
+ " }",
2200
+ " var chip = document.createElement('span');",
2201
+ " chip.className = 'provider-chip';",
2202
+ " chip.setAttribute('data-tag', tag);",
2203
+ " chip.innerHTML = label + '<button type=\"button\" class=\"chip-close\" onclick=\"removeProviderChip(\\'' + tag + '\\')\">\\u00d7</button>';",
2204
+ " container.appendChild(chip);",
2205
+ " }",
2206
+ " };",
2207
+ // Filter channel rows based on enabled provider tags. Toggles the channel-unavailable class on each row and updates Source column content. Filtered-out options
2208
+ // 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
2209
+ // 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
2210
+ // restore priority: (1) the saved choice (HTML selected attribute, kept in sync by updateProviderSelection), (2) the previous visual selection, (3) first option.
2211
+ " function filterChannelRows(enabledTags) {",
2212
+ " var rows = document.querySelectorAll('tr[data-provider-tags]');",
2213
+ " for (var i = 0; i < rows.length; i++) {",
2214
+ " var tags = rows[i].getAttribute('data-provider-tags').split(',');",
2215
+ " var available = true;",
2216
+ " if (enabledTags.length > 0) {",
2217
+ " available = false;",
2218
+ " for (var j = 0; j < tags.length; j++) {",
2219
+ " if (tags[j] === 'direct' || enabledTags.indexOf(tags[j]) !== -1) { available = true; break; }",
2220
+ " }",
2221
+ " }",
2222
+ " if (available) { rows[i].classList.remove('channel-unavailable'); }",
2223
+ " else { rows[i].classList.add('channel-unavailable'); }",
2224
+ // Update Source column elements: toggle between the no-provider label and the provider content (select or static name).
2225
+ " var label = rows[i].querySelector('.no-provider-label');",
2226
+ " var sel = rows[i].querySelector('.provider-select');",
2227
+ " var name = rows[i].querySelector('.provider-name');",
2228
+ " if (label) label.style.display = available ? 'none' : '';",
2229
+ " if (name) name.style.display = available ? '' : 'none';",
2230
+ " if (sel) {",
2231
+ " sel.style.display = available ? '' : 'none';",
2232
+ // On first call, snapshot all options (including server-hidden ones) into a persistent array.
2233
+ " if (!sel._allOptions) { sel._allOptions = Array.prototype.slice.call(sel.querySelectorAll('option')); }",
2234
+ " var prevValue = sel.value;",
2235
+ " sel.innerHTML = '';",
2236
+ " var serverDefault = null;",
2237
+ " var prevExists = false;",
2238
+ " for (var k = 0; k < sel._allOptions.length; k++) {",
2239
+ " var opt = sel._allOptions[k];",
2240
+ " var oTag = opt.getAttribute('data-provider-tag');",
2241
+ " var show = (enabledTags.length === 0) || oTag === 'direct' || enabledTags.indexOf(oTag) !== -1;",
2242
+ " if (show) {",
2243
+ " sel.appendChild(opt);",
2244
+ " if (opt.hasAttribute('selected')) serverDefault = opt;",
2245
+ " if (opt.value === prevValue) prevExists = true;",
2246
+ " }",
2247
+ " }",
2248
+ " if (serverDefault) { sel.value = serverDefault.value; }",
2249
+ " else if (prevExists) { sel.value = prevValue; }",
2250
+ " else if (sel.options.length > 0) { sel.selectedIndex = 0; }",
2251
+ " }",
2252
+ " }",
2253
+ " };",
2254
+ // Update bulk assign dropdown to only show enabled providers. Uses DOM removal like filterChannelRows because Safari ignores hidden/display:none on option
2255
+ // elements. The snapshot filters by truthy .value to exclude the "Choose provider..." placeholder (value="") so it is never removed from the DOM.
2256
+ " function updateBulkAssignOptions(enabledTags) {",
2257
+ " var select = document.getElementById('bulk-assign');",
2258
+ " if (!select) return;",
2259
+ " if (!select._allOptions) {",
2260
+ " select._allOptions = [];",
2261
+ " var all = select.querySelectorAll('option');",
2262
+ " for (var a = 0; a < all.length; a++) { if (all[a].value) select._allOptions.push(all[a]); }",
2263
+ " }",
2264
+ " for (var i = 0; i < select._allOptions.length; i++) {",
2265
+ " var opt = select._allOptions[i];",
2266
+ " if (opt.parentNode === select) { select.removeChild(opt); }",
2267
+ " }",
2268
+ " for (var j = 0; j < select._allOptions.length; j++) {",
2269
+ " var opt2 = select._allOptions[j];",
2270
+ " if (enabledTags.length === 0 || opt2.value === 'direct' || enabledTags.indexOf(opt2.value) !== -1) {",
2271
+ " select.appendChild(opt2);",
2272
+ " }",
2273
+ " }",
2274
+ " select.value = '';",
2275
+ " };",
2276
+ // Bulk assign all channels to a specific provider. Updates all dropdowns and profile cells in-place.
2277
+ " window.bulkAssignProvider = function(selectEl) {",
2278
+ " var providerTag = selectEl.value;",
2279
+ " if (!providerTag) return;",
2280
+ " selectEl.value = '';",
2281
+ " fetch('/config/provider-bulk-assign', {",
2282
+ " method: 'POST',",
2283
+ " headers: { 'Content-Type': 'application/json' },",
2284
+ " body: JSON.stringify({ provider: providerTag })",
2285
+ " })",
2286
+ " .then(function(r) { return r.json(); })",
2287
+ " .then(function(result) {",
2288
+ " if (result.success) {",
2289
+ " var msg = result.affected + ' of ' + result.total + ' channel(s) updated.';",
2290
+ " var undoAction = null;",
2291
+ " if (result.affected > 0 && result.previousSelections) {",
2292
+ " var prevSelections = result.previousSelections;",
2293
+ " undoAction = { label: 'Undo', onclick: function() { restoreBulkProviders(prevSelections); } };",
2294
+ " }",
2295
+ " showToast(msg, 'success', undoAction ? 10000 : undefined, undoAction);",
2296
+ " if (result.selections) {",
2297
+ " for (var key in result.selections) {",
2298
+ " var sel = result.selections[key];",
2299
+ " updateChannelProviderUI(key, sel.variant, sel.profile);",
2300
+ " }",
2301
+ " }",
2302
+ " } else {",
2303
+ " showToast(result.error || 'Failed to assign.', 'error');",
2304
+ " }",
2305
+ " })",
2306
+ " .catch(function(err) { showToast('Failed to assign: ' + err.message, 'error'); });",
2307
+ " };",
2308
+ // Restore previous provider selections (undo bulk assign). Sends the previousSelections map to the server and updates the UI with the restored selections.
2309
+ " function restoreBulkProviders(prevSelections) {",
2310
+ " fetch('/config/provider-bulk-restore', {",
2311
+ " method: 'POST',",
2312
+ " headers: { 'Content-Type': 'application/json' },",
2313
+ " body: JSON.stringify({ selections: prevSelections })",
2314
+ " })",
2315
+ " .then(function(r) { return r.json(); })",
2316
+ " .then(function(result) {",
2317
+ " if (result.success) {",
2318
+ " showToast('Bulk assign reverted.', 'success');",
2319
+ " if (result.selections) {",
2320
+ " for (var key in result.selections) {",
2321
+ " var sel = result.selections[key];",
2322
+ " updateChannelProviderUI(key, sel.variant, sel.profile);",
2323
+ " }",
2324
+ " }",
2325
+ " } else {",
2326
+ " showToast(result.error || 'Failed to revert.', 'error');",
2327
+ " }",
2328
+ " })",
2329
+ " .catch(function(err) { showToast('Failed to revert: ' + err.message, 'error'); });",
2330
+ " }",
2331
+ // Close all open dropdown menus.
2332
+ " function closeDropdowns() {",
2333
+ " var menus = document.querySelectorAll('.dropdown-menu.show');",
2334
+ " for (var i = 0; i < menus.length; i++) menus[i].classList.remove('show');",
2335
+ " };",
2336
+ " window.closeDropdowns = closeDropdowns;",
2337
+ // Toggle a dropdown menu open or closed. Closes any other open dropdowns first.
2338
+ " window.toggleDropdown = function(btn) {",
2339
+ " var menu = btn.nextElementSibling;",
2340
+ " if (!menu) return;",
2341
+ " var isOpen = menu.classList.contains('show');",
2342
+ " closeDropdowns();",
2343
+ " if (!isOpen) menu.classList.add('show');",
2344
+ " };",
2345
+ // Close dropdowns when clicking outside.
2346
+ " document.addEventListener('click', function(e) {",
2347
+ " if (!e.target.closest('.dropdown')) closeDropdowns();",
2348
+ " });",
2349
+ // Toggle visibility of disabled predefined channels and persist preference.
2350
+ " window.toggleDisabledVisibility = function() {",
2351
+ " var table = document.querySelector('.channel-table');",
2352
+ " var checkbox = document.getElementById('show-disabled-toggle');",
2353
+ " if (!table || !checkbox) return;",
2354
+ " if (checkbox.checked) {",
2355
+ " table.classList.remove('hide-disabled');",
2356
+ " localStorage.setItem('prismcast-show-disabled-channels', 'true');",
2357
+ " } else {",
2358
+ " table.classList.add('hide-disabled');",
2359
+ " localStorage.removeItem('prismcast-show-disabled-channels');",
2360
+ " }",
2361
+ " };",
2362
+ // Populate channel selector datalist based on the URL field value. Looks up known selectors from predefined channels that share the same domain.
2363
+ " function updateSelectorSuggestions(urlInputId, datalistId) {",
2364
+ " var urlInput = document.getElementById(urlInputId);",
2365
+ " var datalist = document.getElementById(datalistId);",
2366
+ " if (!urlInput || !datalist) return;",
2367
+ " datalist.innerHTML = '';",
2368
+ " try {",
2369
+ " var hostname = new URL(urlInput.value).hostname;",
2370
+ " var entries = (typeof channelSelectorsByDomain !== 'undefined') ? channelSelectorsByDomain[hostname] : null;",
2371
+ " if (entries) {",
2372
+ " for (var i = 0; i < entries.length; i++) {",
2373
+ " var opt = document.createElement('option');",
2374
+ " opt.value = entries[i].value;",
2375
+ " opt.label = entries[i].label;",
2376
+ " datalist.appendChild(opt);",
2377
+ " }",
2378
+ " }",
2379
+ " } catch (e) {}",
2380
+ " };",
2381
+ // Initialize disabled channel toggle, provider filter, URL input listeners, and pending toast on page load.
2382
+ " (function() {",
2383
+ // Show any toast queued by showToastAfterReload() before the last page reload.
2384
+ " var pending = sessionStorage.getItem('pendingToast');",
2385
+ " if (pending) {",
2386
+ " sessionStorage.removeItem('pendingToast');",
2387
+ " try { var pt = JSON.parse(pending); showToast(pt.message, pt.type); } catch (e) {}",
2388
+ " }",
2389
+ " if (localStorage.getItem('prismcast-show-disabled-channels') === 'true') {",
2390
+ " var table = document.querySelector('.channel-table');",
2391
+ " var checkbox = document.getElementById('show-disabled-toggle');",
2392
+ " if (table) table.classList.remove('hide-disabled');",
2393
+ " if (checkbox) checkbox.checked = true;",
2394
+ " }",
2395
+ // 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
2396
+ // option elements. This initial pass removes those options from the DOM to enforce the filter.
2397
+ " var menu = document.querySelector('.provider-dropdown-menu');",
2398
+ " if (menu) {",
2399
+ " var cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
2400
+ " var tags = [];",
2401
+ " var allChecked = true;",
2402
+ " for (var ci = 0; ci < cbs.length; ci++) {",
2403
+ " if (cbs[ci].checked) { tags.push(cbs[ci].getAttribute('data-tag')); }",
2404
+ " else { allChecked = false; }",
2405
+ " }",
2406
+ " if (!allChecked) { filterChannelRows(tags); updateBulkAssignOptions(tags); }",
2407
+ " }",
2408
+ " var addUrlInput = document.getElementById('add-url');",
2409
+ " if (addUrlInput) {",
2410
+ " addUrlInput.addEventListener('input', function() { updateSelectorSuggestions('add-url', 'add-selectorList'); });",
2411
+ " updateSelectorSuggestions('add-url', 'add-selectorList');",
2412
+ " }",
2413
+ " })();",
2414
+ // Login modal state tracking.
2415
+ " var loginStatusInterval = null;",
2416
+ // Start login mode for a channel. Opens browser window and shows modal.
2417
+ " window.startChannelLogin = function(channel) {",
2418
+ " fetch('/auth/login', {",
2419
+ " method: 'POST',",
2420
+ " headers: { 'Content-Type': 'application/json' },",
2421
+ " body: JSON.stringify({ channel: channel })",
2422
+ " })",
2423
+ " .then(function(response) { return response.json(); })",
2424
+ " .then(function(result) {",
2425
+ " if (result.success) {",
2426
+ " showToast('Browser window opened. Complete authentication.', 'info');",
2427
+ " showLoginModal();",
2428
+ " startLoginStatusPolling();",
2429
+ " } else {",
2430
+ " showToast(result.error || 'Failed to start login.', 'error');",
2431
+ " }",
2432
+ " })",
2433
+ " .catch(function(err) { showToast('Failed to start login: ' + err.message, 'error'); });",
2434
+ " };",
2435
+ // End login mode. Closes browser tab and hides modal.
2436
+ " window.endLogin = function() {",
2437
+ " stopLoginStatusPolling();",
2438
+ " fetch('/auth/done', { method: 'POST' })",
2439
+ " .then(function() {",
2440
+ " hideLoginModal();",
2441
+ " showToast('Authentication complete.', 'success');",
2442
+ " })",
2443
+ " .catch(function(err) { showToast('Error ending login: ' + err.message, 'error'); hideLoginModal(); });",
2444
+ " };",
2445
+ // Show the login modal.
2446
+ " function showLoginModal() {",
2447
+ " var modal = document.getElementById('login-modal');",
2448
+ " if (modal) modal.style.display = 'flex';",
2449
+ " }",
2450
+ // Hide the login modal.
2451
+ " function hideLoginModal() {",
2452
+ " var modal = document.getElementById('login-modal');",
2453
+ " if (modal) modal.style.display = 'none';",
2454
+ " }",
2455
+ // Start polling login status to detect when tab is closed externally.
2456
+ " function startLoginStatusPolling() {",
2457
+ " stopLoginStatusPolling();",
2458
+ " loginStatusInterval = setInterval(function() {",
2459
+ " fetch('/auth/status')",
2460
+ " .then(function(response) { return response.json(); })",
2461
+ " .then(function(status) {",
2462
+ " if (!status.active) {",
2463
+ " stopLoginStatusPolling();",
2464
+ " hideLoginModal();",
2465
+ " showToast('Login session ended.', 'info');",
2466
+ " }",
2467
+ " })",
2468
+ " .catch(function() { });",
2469
+ " }, 1000);",
2470
+ " }",
2471
+ // Stop polling login status.
2472
+ " function stopLoginStatusPolling() {",
2473
+ " if (loginStatusInterval) {",
2474
+ " clearInterval(loginStatusInterval);",
2475
+ " loginStatusInterval = null;",
2476
+ " }",
2477
+ " }",
2478
+ // Attach validation and modified indicator handlers to form inputs.
2479
+ " var form = document.getElementById('settings-form');",
2480
+ " if (form) {",
2481
+ " var inputs = form.querySelectorAll('input, select');",
2482
+ " for (var k = 0; k < inputs.length; k++) {",
2483
+ " var eventType = inputs[k].type === 'checkbox' ? 'change' : 'input';",
2484
+ " inputs[k].addEventListener(eventType, function() {",
2485
+ " validateInput(this);",
2486
+ " updateModifiedIndicator(this);",
2487
+ " });",
2488
+ " }",
2489
+ // Wire up all checkboxes that have dependent fields. The updateDependentFields function is defined at the top level alongside the other helpers.
2490
+ " var checkboxes = form.querySelectorAll('input[type=\"checkbox\"]');",
2491
+ " for (var c = 0; c < checkboxes.length; c++) {",
2492
+ " (function(cb) {",
2493
+ " cb.addEventListener('change', function() { updateDependentFields(cb.id); });",
2494
+ " })(checkboxes[c]);",
2495
+ " }",
2496
+ // Attach preset change handler to auto-fill bitrate and frame rate.
2497
+ " var presetSelect = document.getElementById('streaming-qualityPreset');",
2498
+ " if (presetSelect) {",
2499
+ " presetSelect.addEventListener('change', function() { onPresetChange(this.value); });",
2500
+ " }",
2501
+ " }",
2502
+ // Initialize subtab on load: hash > localStorage > default.
2503
+ " var initialSubtab = window.initialHashSubtab;",
2504
+ " if (!initialSubtab) {",
2505
+ " try { initialSubtab = localStorage.getItem('prismcast-config-subtab'); } catch(e) {}",
2506
+ " }",
2507
+ " if (initialSubtab && document.querySelector('.subtab-btn[data-subtab=\"' + initialSubtab + '\"]')) {",
2508
+ " switchSubtab(initialSubtab, false);",
2509
+ " }",
2510
+ "})();",
2511
+ "</script>"
2512
+ ].join("\n");
2513
+ }
2514
+ /**
2515
+ * Generates additional CSS styles specific to the landing page. Uses CSS custom properties for theme support.
2516
+ * @returns CSS styles as a string.
2517
+ */
2518
+ function generateLandingPageStyles() {
2519
+ return [
2520
+ // Override header to use space-between for logo/title on left and status on right.
2521
+ ".header { justify-content: space-between; }",
2522
+ ".header-left { display: flex; align-items: center; gap: 20px; }",
2523
+ // Header links (GitHub, More by HJD).
2524
+ ".header-links { display: flex; align-items: center; gap: 8px; font-size: 13px; }",
2525
+ ".header-links a { color: var(--text-muted); text-decoration: none; transition: color 0.2s; }",
2526
+ ".header-links a:hover { color: var(--text-primary); }",
2527
+ ".header-links-sep { color: var(--text-muted); }",
2528
+ // Header status bar styles.
2529
+ ".header-status { display: flex; gap: 20px; align-items: center; font-size: 13px; color: var(--text-secondary); }",
2530
+ ".header-status span { white-space: nowrap; }",
2531
+ // Stream count popover. Clickable when streams are active; popover drops from the right edge of the header.
2532
+ "#stream-count.clickable { cursor: pointer; }",
2533
+ "#stream-count.clickable:hover { color: var(--text-primary); }",
2534
+ ".stream-popover .dropdown-menu { right: 0; left: auto; min-width: 220px; }",
2535
+ ".stream-popover-row { display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 13px; white-space: nowrap; }",
2536
+ ".stream-popover-logo { height: 18px; width: auto; max-width: 80px; vertical-align: middle; }",
2537
+ ".stream-popover-channel { color: var(--text-primary); }",
2538
+ ".stream-popover-duration { color: var(--text-muted); margin-left: auto; }",
2539
+ // Subtab styles for Configuration tab.
2540
+ ".subtab-bar { display: flex; border-bottom: 1px solid var(--border-default); margin-bottom: 20px; gap: 2px; flex-wrap: wrap; }",
2541
+ ".subtab-btn { padding: 8px 16px; border: none; background: var(--subtab-bg); cursor: pointer; font-size: 13px; font-weight: 500; ",
2542
+ "color: var(--tab-text); border-radius: var(--radius-md) var(--radius-md) 0 0; transition: all 0.2s; }",
2543
+ ".subtab-btn:hover { background: var(--subtab-bg-hover); color: var(--tab-text-hover); }",
2544
+ ".subtab-btn.active { background: var(--subtab-bg-active); color: var(--subtab-text-active); border-bottom: 2px solid var(--subtab-border-active); }",
2545
+ ".subtab-panel { display: none; }",
2546
+ ".subtab-panel.active { display: block; }",
2547
+ // Panel header layout for description and reset link alignment.
2548
+ ".panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }",
2549
+ // Settings panel description styling (replaces redundant header titles).
2550
+ ".settings-panel-description { margin: 0; font-size: 15px; color: var(--text-primary); }",
2551
+ ".settings-panel-description p { margin: 0; }",
2552
+ ".description-hint { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }",
2553
+ // Streams table container - outer border with rounded corners.
2554
+ "#streams-container { border: 1px solid var(--border-default); border-radius: var(--radius-md); overflow: hidden; margin-bottom: 20px; }",
2555
+ // Streams table - minimal design with no borders between columns.
2556
+ ".streams-table { width: 100%; border-collapse: collapse; margin: 0; }",
2557
+ ".streams-table td { padding: 6px 10px; border: none; color: var(--text-primary); vertical-align: middle; }",
2558
+ ".streams-table td:first-child { padding-left: 12px; }",
2559
+ ".streams-table td:last-child { padding-right: 12px; }",
2560
+ ".streams-table .empty-row td { padding: 10px 12px; text-align: center; color: var(--text-muted); }",
2561
+ ".streams-table .empty-row:hover { background: transparent; }",
2562
+ ".streams-table .stream-row { cursor: pointer; }",
2563
+ ".streams-table .stream-row:hover { background: var(--table-row-hover); }",
2564
+ ".streams-table .chevron { width: 20px; color: var(--text-muted); font-size: 10px; }",
2565
+ ".streams-table .stream-info { width: 180px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; font-size: 13px; }",
2566
+ ".streams-table .stream-duration { font-weight: 400; color: var(--text-secondary); }",
2567
+ ".streams-table .channel-logo { height: 24px; width: auto; max-width: 100px; vertical-align: middle; margin-right: 4px; }",
2568
+ ".streams-table .channel-text { vertical-align: middle; }",
2569
+ ".streams-table .stream-show { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); font-size: 13px; }",
2570
+ ".streams-table .stream-health { text-align: right; white-space: nowrap; font-size: 13px; }",
2571
+ ".streams-table .stream-details td { padding: 10px 12px 12px 32px; background: var(--surface-sunken); }",
2572
+ ".streams-table .details-content { font-size: 12px; color: var(--text-secondary); }",
2573
+ ".streams-table .details-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 10px; }",
2574
+ ".streams-table .details-url { word-break: break-all; flex: 1; min-width: 0; }",
2575
+ ".streams-table .details-started { white-space: nowrap; flex-shrink: 0; }",
2576
+ ".streams-table .details-metrics { display: flex; align-items: baseline; gap: 20px; }",
2577
+ ".streams-table .details-issue { flex: 1; min-width: 0; }",
2578
+ ".streams-table .details-recovery { white-space: nowrap; flex-shrink: 0; }",
2579
+ ".streams-table .details-memory { white-space: nowrap; flex-shrink: 0; }",
2580
+ ".streams-table .client-count { font-size: 0.85em; color: var(--text-muted); margin-right: 8px; white-space: nowrap; }",
2581
+ // Log viewer styles.
2582
+ ".log-viewer { background: var(--dark-surface-bg); color: var(--dark-text-secondary); padding: 15px; border-radius: var(--radius-lg); ",
2583
+ "font-family: 'SF Mono', Monaco, monospace; font-size: 12px; height: 500px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }",
2584
+ ".log-viewer::-webkit-scrollbar { width: 8px; }",
2585
+ ".log-viewer::-webkit-scrollbar-track { background: var(--dark-scrollbar-track); }",
2586
+ ".log-viewer::-webkit-scrollbar-thumb { background: var(--dark-scrollbar-thumb); border-radius: var(--radius-md); }",
2587
+ ".log-viewer::-webkit-scrollbar-thumb:hover { background: var(--dark-scrollbar-thumb-hover); }",
2588
+ ".log-entry { color: var(--dark-text-secondary); }",
2589
+ ".log-error { color: var(--dark-text-error); }",
2590
+ ".log-warn { color: var(--dark-text-warn); }",
2591
+ ".log-debug { color: var(--dark-text-debug); }",
2592
+ ".log-muted { color: var(--dark-text-muted); }",
2593
+ ".log-connecting { color: var(--dark-text-muted); }",
2594
+ // Channel table styles. The wrapper enables horizontal scrolling on small screens.
2595
+ ".channel-table-wrapper { overflow-x: auto; margin-bottom: 20px; }",
2596
+ ".channel-table { width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 650px; }",
2597
+ ".channel-table th, .channel-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border-default); ",
2598
+ "overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }",
2599
+ ".channel-table th { background: var(--table-header-bg); font-weight: 600; font-size: 13px; }",
2600
+ ".channel-table tr:hover { background: var(--table-row-hover); }",
2601
+ ".channel-table .col-key { width: 170px; }",
2602
+ ".channel-table .col-name { width: 250px; }",
2603
+ ".channel-table .col-source { width: 150px; }",
2604
+ ".channel-table .col-profile { width: 140px; }",
2605
+ ".channel-table .col-actions { width: 170px; white-space: nowrap; overflow: visible; }",
2606
+ ".provider-select { width: 100%; padding: 2px 4px; font-size: 12px; border: 1px solid var(--form-input-border); ",
2607
+ "border-radius: 3px; background: var(--form-input-bg); color: var(--text-primary); }",
2608
+ // Responsive: hide Profile on tablets, hide Key and Profile on phones.
2609
+ "@media (max-width: 1024px) { .channel-table .col-profile, .channel-table td:nth-child(4), .channel-table th:nth-child(4) { display: none; } }",
2610
+ "@media (max-width: 768px) { .channel-table .col-key, .channel-table td:nth-child(1), .channel-table th:nth-child(1) { display: none; } }",
2611
+ // User channel row tinting to distinguish custom/override channels from predefined.
2612
+ ".channel-table tr.user-channel { background: var(--user-channel-tint); }",
2613
+ ".channel-table tr.user-channel:hover { background: var(--user-channel-tint-hover); }",
2614
+ // Disabled predefined channel row styling and hide-disabled toggle.
2615
+ ".channel-table tr.channel-disabled { opacity: 0.5; }",
2616
+ ".channel-table tr.channel-disabled td { color: var(--text-tertiary); }",
2617
+ ".channel-table tr.channel-disabled code { color: var(--text-tertiary); }",
2618
+ ".channel-table.hide-disabled tr.channel-disabled { display: none; }",
2619
+ // Provider-filtered channel row styling. Uses reduced opacity and italic text to distinguish from manually disabled rows. The compound selector ensures that rows
2620
+ // 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).
2621
+ ".channel-table tr.channel-unavailable { opacity: 0.4; font-style: italic; }",
2622
+ ".channel-table tr.channel-unavailable td { color: var(--text-tertiary); }",
2623
+ ".channel-table tr.channel-unavailable.channel-disabled { opacity: 0.5; }",
2624
+ ".channel-table.hide-disabled tr.channel-unavailable { display: none; }",
2625
+ ".no-provider-label { color: var(--text-tertiary); font-size: 12px; }",
2626
+ // Provider filter toolbar layout.
2627
+ ".provider-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; }",
2628
+ ".provider-toolbar .toolbar-group { display: flex; align-items: center; gap: 6px; }",
2629
+ ".provider-toolbar .toolbar-label { font-size: 13px; color: var(--text-secondary); white-space: nowrap; }",
2630
+ ".provider-toolbar .toolbar-spacer { flex: 1; }",
2631
+ // Provider dropdown multi-select.
2632
+ ".provider-dropdown-menu { min-width: 200px; max-height: 300px; overflow-y: auto; }",
2633
+ ".provider-option { display: flex; align-items: center; gap: 6px; padding: 5px 12px; font-size: 13px; cursor: pointer; color: var(--text-primary); }",
2634
+ ".provider-option:hover { background: var(--surface-sunken); }",
2635
+ ".provider-option input[type=\"checkbox\"] { margin: 0; }",
2636
+ // Provider chips.
2637
+ ".provider-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }",
2638
+ ".provider-chip { display: inline-flex; align-items: center; gap: 4px; background: var(--surface-elevated); border: 1px solid var(--border-default); ",
2639
+ "border-radius: 12px; padding: 2px 8px 2px 10px; font-size: 12px; color: var(--text-secondary); min-height: 24px; }",
2640
+ ".chip-close { background: none; border: none; cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; color: var(--text-muted); ",
2641
+ "transition: color 0.2s; }",
2642
+ ".chip-close:hover { color: var(--text-primary); }",
2643
+ // Bulk assign dropdown.
2644
+ ".bulk-assign-select { font-size: 13px; padding: 4px 8px; border: 1px solid var(--border-default); border-radius: var(--radius-md); ",
2645
+ "background: var(--surface-default); color: var(--text-primary); cursor: pointer; }",
2646
+ // Responsive: stack provider toolbar groups vertically on small screens.
2647
+ "@media (max-width: 768px) { .provider-toolbar { flex-direction: column; align-items: flex-start; } }",
2648
+ // Enable/Disable button styling.
2649
+ ".btn-enable { background: var(--status-success-bg); color: var(--status-success-text); border: 1px solid var(--status-success-border); }",
2650
+ ".btn-enable:hover { background: var(--status-success-border); }",
2651
+ ".btn-disable { background: var(--surface-elevated); color: var(--text-secondary); border: 1px solid var(--border-default); }",
2652
+ ".btn-disable:hover { border-color: var(--text-secondary); }",
2653
+ // Channel toolbar with operation buttons and display controls.
2654
+ ".channel-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; margin-bottom: 15px; }",
2655
+ ".channel-toolbar .toolbar-group { display: flex; align-items: center; gap: 6px; }",
2656
+ ".channel-toolbar .toolbar-spacer { flex: 1; }",
2657
+ ".channel-toolbar .toggle-label { font-size: 12px; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; gap: 4px; ",
2658
+ "user-select: none; }",
2659
+ // Dropdown menu used by the Import button in the channel toolbar.
2660
+ ".dropdown { position: relative; display: inline-block; }",
2661
+ ".dropdown-menu { display: none; position: absolute; top: 100%; left: 0; z-index: 1000; min-width: 180px; padding: 4px 0; margin-top: 2px; ",
2662
+ "background: var(--surface-overlay); border: 1px solid var(--border-default); border-radius: var(--radius-md); ",
2663
+ "box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); }",
2664
+ ".dropdown-menu.show { display: block; }",
2665
+ ".dropdown-item { padding: 6px 12px; font-size: 13px; cursor: pointer; color: var(--text-primary); }",
2666
+ ".dropdown-item:hover { background: var(--surface-sunken); }",
2667
+ ".dropdown-option { display: block; padding: 2px 12px 6px 24px; font-size: 12px; color: var(--text-secondary); cursor: pointer; user-select: none; }",
2668
+ ".dropdown-divider { height: 1px; margin: 4px 0; background: var(--border-default); }",
2669
+ // Channel form styles. Inputs use full width; selects use width classes from ui.ts for consistency with settings forms.
2670
+ ".channel-form { background: var(--form-bg); border: 1px solid var(--border-default); border-radius: var(--radius-lg); padding: 20px; margin-bottom: 20px; }",
2671
+ ".channel-form h3 { margin-top: 0; margin-bottom: 15px; color: var(--text-heading-secondary); }",
2672
+ ".channel-form .form-row { margin-bottom: 4px; }",
2673
+ ".channel-form .form-row:last-child { margin-bottom: 0; }",
2674
+ ".channel-form .form-input { width: 100%; box-sizing: border-box; }",
2675
+ // Advanced toggle styles.
2676
+ ".advanced-toggle { color: var(--interactive-primary); cursor: pointer; font-size: 13px; margin-top: 5px; margin-bottom: 15px; }",
2677
+ ".advanced-toggle:hover { text-decoration: underline; }",
2678
+ ".advanced-fields { display: none; }",
2679
+ ".advanced-fields.show { display: block; }",
2680
+ // Profile reference section styles.
2681
+ ".profile-reference { background: var(--surface-elevated); border: 1px solid var(--border-default); border-radius: var(--radius-lg); margin: 20px 0; ",
2682
+ "padding: 20px; }",
2683
+ ".profile-reference-header { display: flex; justify-content: space-between; align-items: flex-start; }",
2684
+ ".profile-reference h3 { margin: 0 0 10px 0; color: var(--text-heading-secondary); }",
2685
+ ".profile-reference-close { color: var(--text-secondary); font-size: 18px; text-decoration: none; padding: 0 5px; }",
2686
+ ".profile-reference-close:hover { color: var(--text-primary); }",
2687
+ ".reference-intro { color: var(--text-secondary); font-size: 13px; margin-bottom: 20px; }",
2688
+ ".profile-category { margin-bottom: 20px; }",
2689
+ ".profile-category:last-child { margin-bottom: 0; }",
2690
+ ".profile-category h4 { color: var(--text-heading-secondary); font-size: 14px; font-weight: 600; margin: 0 0 8px 0; }",
2691
+ ".category-desc { color: var(--text-tertiary); font-size: 12px; margin: 0 0 10px 0; }",
2692
+ ".profile-list { margin: 0; padding: 0; }",
2693
+ ".profile-list dt { font-family: var(--font-mono); font-size: 13px; font-weight: 600; margin-top: 10px; color: var(--text-primary); }",
2694
+ ".profile-list dt:first-child { margin-top: 0; }",
2695
+ ".profile-list dd { color: var(--text-secondary); font-size: 13px; margin: 4px 0 0 0; }",
2696
+ ".selector-guide-heading { margin-top: 20px !important; border-top: 1px solid var(--border-default); padding-top: 16px; }",
2697
+ // Other landing page styles.
2698
+ ".endpoint code { font-size: 13px; }",
2699
+ // Modified value indicator styling.
2700
+ ".form-group.modified { border-left: 3px solid var(--interactive-primary); padding-left: 12px; }",
2701
+ ".modified-dot { display: inline-block; width: 8px; height: 8px; background: var(--interactive-primary); border-radius: 50%; margin-right: 6px; ",
2702
+ "vertical-align: middle; }",
2703
+ // Per-setting reset button styling.
2704
+ ".btn-reset { background: transparent; border: 1px solid var(--border-default); border-radius: var(--radius-md); padding: 4px 8px; margin-left: 8px; ",
2705
+ "cursor: pointer; font-size: 14px; color: var(--text-secondary); transition: all 0.15s ease; }",
2706
+ ".btn-reset:hover { background: var(--surface-elevated); border-color: var(--interactive-primary); color: var(--interactive-primary); }",
2707
+ // Backup subtab section styling.
2708
+ ".backup-group { margin-bottom: 35px; }",
2709
+ ".backup-group-title { font-size: 16px; font-weight: 600; margin-bottom: 15px; color: var(--text-heading); ",
2710
+ "padding-bottom: 8px; border-bottom: 1px solid var(--border-default); }",
2711
+ ".backup-section { margin-bottom: 20px; padding: 20px; background: var(--surface-elevated); border-radius: var(--radius-lg); ",
2712
+ "border: 1px solid var(--border-default); }",
2713
+ ".backup-section h3 { margin-top: 0; margin-bottom: 10px; color: var(--text-heading-secondary); font-size: 15px; }",
2714
+ ".backup-section p { color: var(--text-secondary); margin-bottom: 15px; font-size: 14px; }",
2715
+ ".backup-section code { background: var(--surface-code); padding: 2px 5px; border-radius: 3px; font-size: 12px; }",
2716
+ "#import-settings-file, #import-channels-file, #import-m3u-file { display: none; }",
2717
+ ".btn-export { background: var(--surface-elevated); border: 1px solid var(--border-default); color: var(--text-primary); ",
2718
+ "padding: 10px 20px; border-radius: var(--radius-md); font-size: 14px; cursor: pointer; transition: all 0.15s ease; }",
2719
+ ".btn-export:hover { border-color: var(--interactive-primary); color: var(--interactive-primary); }",
2720
+ ".btn-import { background: var(--surface-elevated); border: 1px solid var(--border-default); color: var(--text-primary); ",
2721
+ "padding: 10px 20px; border-radius: var(--radius-md); font-size: 14px; cursor: pointer; transition: all 0.15s ease; }",
2722
+ ".btn-import:hover { border-color: var(--interactive-primary); color: var(--interactive-primary); }",
2723
+ // Login modal styles for channel authentication.
2724
+ ".login-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; ",
2725
+ "align-items: center; justify-content: center; z-index: 1000; }",
2726
+ ".login-modal-content { background: var(--surface-overlay); padding: 30px; border-radius: var(--radius-lg); max-width: 450px; width: 90%; ",
2727
+ "box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); }",
2728
+ ".login-modal-content h3 { margin-top: 0; margin-bottom: 15px; color: var(--text-heading); }",
2729
+ ".login-modal-content p { color: var(--text-secondary); margin-bottom: 15px; font-size: 14px; line-height: 1.5; }",
2730
+ ".login-modal-hint { font-size: 13px; color: var(--text-muted); }",
2731
+ ".login-modal-buttons { margin-top: 20px; text-align: right; }",
2732
+ // Restart dialog modal styles for pending restart notification.
2733
+ ".restart-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: none; ",
2734
+ "align-items: center; justify-content: center; z-index: 1000; }",
2735
+ ".restart-modal-content { background: var(--surface-overlay); padding: 30px; border-radius: var(--radius-lg); max-width: 400px; width: 90%; ",
2736
+ "box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); text-align: center; }",
2737
+ ".restart-modal-content h3 { margin-top: 0; margin-bottom: 15px; color: var(--text-heading); }",
2738
+ ".restart-modal-content p { color: var(--text-secondary); margin-bottom: 0; font-size: 14px; line-height: 1.5; }",
2739
+ ".restart-modal-status { margin: 16px 0; color: var(--text-muted); font-size: 13px; }",
2740
+ ".restart-modal-buttons { display: flex; gap: 12px; justify-content: center; margin-top: 20px; }",
2741
+ ".btn-danger { background: var(--interactive-danger); color: white; border: none; padding: 10px 20px; border-radius: var(--radius-md); ",
2742
+ "font-size: 14px; cursor: pointer; transition: all 0.15s ease; }",
2743
+ ".btn-danger:hover { opacity: 0.9; }",
2744
+ // Toast notification container: fixed top-right, above all modals.
2745
+ ".toast-container { position: fixed; top: 20px; right: 20px; z-index: 1001; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }",
2746
+ // Individual toast: themed colors, slide-in animation, close button.
2747
+ ".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; ",
2748
+ "font-size: 13px; line-height: 1.4; white-space: pre-line; position: relative; pointer-events: auto; animation: toastIn 0.3s ease-out; }",
2749
+ ".toast.toast-exit { animation: toastOut 0.3s ease-in forwards; }",
2750
+ // Type variants using existing theme status variables.
2751
+ ".toast.success { background: var(--status-success-bg); border: 1px solid var(--status-success-border); color: var(--status-success-text); }",
2752
+ ".toast.error { background: var(--status-error-bg); border: 1px solid var(--status-error-border); color: var(--status-error-text); }",
2753
+ ".toast.warning { background: var(--status-warning-bg); border: 1px solid var(--status-warning-border); color: var(--status-warning-text); }",
2754
+ ".toast.info { background: var(--status-info-bg); border: 1px solid var(--status-info-border); color: var(--status-info-text); }",
2755
+ // Close button positioned top-right within each toast.
2756
+ ".toast-close { position: absolute; top: 8px; right: 8px; background: none; border: none; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 4px; ",
2757
+ "color: inherit; opacity: 0.6; }",
2758
+ ".toast-close:hover { opacity: 1; }",
2759
+ // Action button for toasts with an undo or similar inline action.
2760
+ ".toast-action { display: inline-block; margin-left: 8px; padding: 2px 10px; border: 1px solid currentColor; border-radius: var(--radius-sm); ",
2761
+ "background: none; color: inherit; cursor: pointer; font-size: 12px; font-weight: 600; opacity: 0.8; vertical-align: baseline; }",
2762
+ ".toast-action:hover { opacity: 1; background: rgba(0, 0, 0, 0.1); }",
2763
+ // Toast slide animations.
2764
+ "@keyframes toastIn { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }",
2765
+ "@keyframes toastOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(120%); opacity: 0; } }",
2766
+ // Responsive: full-width toasts on narrow screens.
2767
+ "@media (max-width: 768px) { .toast-container { left: 20px; right: 20px; } .toast { min-width: 0; max-width: none; } }",
2768
+ // Loading state for buttons.
2769
+ ".btn.loading { opacity: 0.7; pointer-events: none; }",
2770
+ ".btn.loading::after { content: '...'; }",
2771
+ // Inline copy button for Quick Start section.
2772
+ ".btn-copy-inline { background: var(--surface-elevated); border: 1px solid var(--border-default); padding: 2px 8px; font-size: 12px; ",
2773
+ "border-radius: var(--radius-sm); cursor: pointer; color: var(--text-secondary); margin-left: 6px; vertical-align: middle; }",
2774
+ ".btn-copy-inline:hover { background: var(--surface-hover); color: var(--text-primary); }",
2775
+ ".copy-feedback-inline { color: var(--stream-healthy); font-size: 12px; margin-left: 8px; display: none; }",
2776
+ // Version display styles.
2777
+ ".version-container { display: inline-flex; align-items: center; gap: 6px; }",
2778
+ ".version { font-size: 13px; color: var(--text-muted); font-weight: 400; text-decoration: none; transition: color 0.2s; }",
2779
+ ".version:hover { color: var(--text-primary); }",
2780
+ ".version.version-update { color: var(--interactive-primary); }",
2781
+ ".version.version-update:hover { color: var(--interactive-primary-hover, var(--interactive-primary)); text-decoration: underline; }",
2782
+ ".version-check { background: none; border: none; padding: 0; margin: 0; cursor: pointer; font-size: 14px; color: var(--text-muted); ",
2783
+ "line-height: 1; transition: color 0.2s, transform 0.3s; opacity: 0.7; }",
2784
+ ".version-check:hover { color: var(--text-primary); opacity: 1; }",
2785
+ ".version-check.checking { animation: spin 1s linear infinite; pointer-events: none; }",
2786
+ ".version-check.up-to-date { color: var(--stream-healthy); opacity: 1; }",
2787
+ ".version-check.check-error { color: var(--stream-error); opacity: 1; }",
2788
+ "@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }",
2789
+ // Changelog modal styles.
2790
+ ".changelog-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: none; ",
2791
+ "align-items: center; justify-content: center; z-index: 1000; }",
2792
+ ".changelog-modal-content { background: var(--surface-overlay); padding: 30px; border-radius: var(--radius-lg); max-width: 500px; width: 90%; ",
2793
+ "box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); }",
2794
+ ".changelog-modal-content h3 { margin-top: 0; margin-bottom: 20px; color: var(--text-heading); }",
2795
+ ".changelog-list { margin: 0 0 20px 0; padding: 0 0 0 20px; color: var(--text-secondary); font-size: 14px; line-height: 1.6; }",
2796
+ ".changelog-list li { margin-bottom: 8px; }",
2797
+ ".changelog-list li:last-child { margin-bottom: 0; }",
2798
+ ".changelog-modal-buttons { display: flex; gap: 12px; justify-content: flex-end; }"
2799
+ ].join("\n");
2800
+ }
2801
+ /**
2802
+ * Configures the root endpoint that serves as a landing page with a tabbed interface containing usage documentation, API reference, playlist, and log viewer.
2803
+ * @param app - The Express application.
2804
+ */
2805
+ export function setupRootEndpoint(app) {
2806
+ // Manual version check endpoint.
2807
+ app.post("/version/check", async (_req, res) => {
2808
+ const currentVersion = getPackageVersion();
2809
+ await checkForUpdates(currentVersion, true);
2810
+ const versionInfo = getVersionInfo(currentVersion);
2811
+ res.json({
2812
+ currentVersion,
2813
+ latestVersion: versionInfo.latestVersion,
2814
+ updateAvailable: versionInfo.updateAvailable
2815
+ });
2816
+ });
2817
+ // Changelog fetch endpoint. Returns changelog items for the appropriate version (latest if update available, otherwise current). Falls back to current version's
2818
+ // changelog if the latest version's changelog isn't available.
2819
+ app.get("/version/changelog", async (_req, res) => {
2820
+ const currentVersion = getPackageVersion();
2821
+ const versionInfo = getVersionInfo(currentVersion);
2822
+ // Prefer latest version's changelog if update available, otherwise use current version.
2823
+ let displayVersion = (versionInfo.updateAvailable && versionInfo.latestVersion) ? versionInfo.latestVersion : currentVersion;
2824
+ let items = await getChangelogItems(displayVersion);
2825
+ // Fallback: if latest version's changelog not found, try current version instead. Update displayVersion immediately so it reflects what we're actually
2826
+ // attempting to show, even if the fallback also fails.
2827
+ if ((items === null) && (displayVersion !== currentVersion)) {
2828
+ displayVersion = currentVersion;
2829
+ items = await getChangelogItems(currentVersion);
2830
+ }
2831
+ res.json({
2832
+ displayVersion,
2833
+ items,
2834
+ updateAvailable: versionInfo.updateAvailable
2835
+ });
2836
+ });
2837
+ app.get("/", (req, res) => {
2838
+ const baseUrl = resolveBaseUrl(req);
2839
+ // Count the number of video channels (excluding static pages).
2840
+ const channels = getAllChannels();
2841
+ const videoChannelCount = Object.keys(channels).filter((name) => {
2842
+ const channel = channels[name];
2843
+ const profile = resolveProfile(channel.profile);
2844
+ return !profile.noVideo;
2845
+ }).length;
2846
+ // Generate content for each tab.
2847
+ const overviewContent = generateOverviewContent(baseUrl, videoChannelCount);
2848
+ const channelsContent = generateChannelsTabContent();
2849
+ const logsContent = generateLogsContent();
2850
+ const configContent = generateConfigContent();
2851
+ const apiContent = generateApiReferenceContent();
2852
+ const helpContent = generateHelpContent();
2853
+ // Build the tab bar.
2854
+ const tabBar = [
2855
+ "<div class=\"tab-bar\" role=\"tablist\">",
2856
+ generateTabButton("overview", "Overview", true),
2857
+ generateTabButton("channels", "Channels", false),
2858
+ generateTabButton("logs", "Logs", false),
2859
+ generateTabButton("config", "Configuration", false),
2860
+ generateTabButton("api", "API Reference", false),
2861
+ generateTabButton("help", "Help", false),
2862
+ "</div>"
2863
+ ].join("\n");
2864
+ // Build the tab panels.
2865
+ const tabPanels = [
2866
+ generateTabPanel("overview", overviewContent, true),
2867
+ generateTabPanel("channels", channelsContent, false),
2868
+ generateTabPanel("logs", logsContent, false),
2869
+ generateTabPanel("config", configContent, false),
2870
+ generateTabPanel("api", apiContent, false),
2871
+ generateTabPanel("help", helpContent, false)
2872
+ ].join("\n");
2873
+ // Build the page header with logo, title, version, links, and status bar.
2874
+ const header = [
2875
+ "<div class=\"header\">",
2876
+ "<div class=\"header-left\">",
2877
+ "<img src=\"/logo.svg\" alt=\"PrismCast\" class=\"logo\">",
2878
+ "<h1>PrismCast</h1>",
2879
+ generateVersionHtml(),
2880
+ "<span class=\"header-links\">",
2881
+ "<a href=\"https://github.com/hjdhjd/prismcast\" target=\"_blank\" rel=\"noopener\">GitHub</a>",
2882
+ "<span class=\"header-links-sep\">&middot;</span>",
2883
+ "<a href=\"https://github.com/hjdhjd\" target=\"_blank\" rel=\"noopener\">More by HJD</a>",
2884
+ "</span>",
2885
+ "</div>",
2886
+ generateHeaderStatusHtml(),
2887
+ "</div>"
2888
+ ].join("\n");
2889
+ // Combine all styles.
2890
+ const styles = [generateBaseStyles(), generateTabStyles(), generateLandingPageStyles()].join("\n");
2891
+ // Restart dialog modal HTML. This is rendered hidden and shown via JavaScript when a restart is deferred due to active streams.
2892
+ const restartModal = [
2893
+ "<div id=\"restart-dialog\" class=\"restart-modal\">",
2894
+ "<div class=\"restart-modal-content\">",
2895
+ "<h3>Restart Required</h3>",
2896
+ "<p>Configuration saved. <span id=\"restart-stream-count\">0</span> active stream(s) will be interrupted if you restart now.</p>",
2897
+ "<div class=\"restart-modal-status\">Waiting for streams to end...</div>",
2898
+ "<div class=\"restart-modal-buttons\">",
2899
+ "<button type=\"button\" class=\"btn btn-secondary\" onclick=\"cancelPendingRestart()\">Cancel</button>",
2900
+ "<button type=\"button\" class=\"btn btn-danger\" onclick=\"forceRestart()\">Restart Now</button>",
2901
+ "</div>",
2902
+ "</div>",
2903
+ "</div>"
2904
+ ].join("\n");
2905
+ // Build the body content.
2906
+ const changelogModal = generateChangelogModal();
2907
+ const bodyContent = [header, tabBar, tabPanels, restartModal, changelogModal,
2908
+ "<div id=\"toast-container\" class=\"toast-container\"></div>"].join("\n");
2909
+ // Generate scripts: tab switching, config subtab handling, then status SSE for header updates.
2910
+ const scripts = [
2911
+ generateTabScript({ localStorageKey: "prismcast-home-tab" }),
2912
+ generateConfigSubtabScript(),
2913
+ generateStatusScript()
2914
+ ].join("\n");
2915
+ // Build and send the complete page.
2916
+ const html = generatePageWrapper("PrismCast", styles, bodyContent, scripts);
2917
+ res.send(html);
2918
+ });
2919
+ }
2920
+ //# sourceMappingURL=root.js.map