prismcast 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/dist/app.js +18 -1
  2. package/dist/app.js.map +1 -1
  3. package/dist/browser/cdp.d.ts +2 -1
  4. package/dist/browser/cdp.js +44 -23
  5. package/dist/browser/cdp.js.map +1 -1
  6. package/dist/browser/channelSelection.d.ts +45 -1
  7. package/dist/browser/channelSelection.js +164 -27
  8. package/dist/browser/channelSelection.js.map +1 -1
  9. package/dist/browser/display.d.ts +0 -4
  10. package/dist/browser/display.js +0 -7
  11. package/dist/browser/display.js.map +1 -1
  12. package/dist/browser/index.d.ts +5 -0
  13. package/dist/browser/index.js +14 -0
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/precaching.d.ts +5 -0
  16. package/dist/browser/precaching.js +112 -0
  17. package/dist/browser/precaching.js.map +1 -0
  18. package/dist/browser/tuning/directv.d.ts +2 -0
  19. package/dist/browser/tuning/directv.js +723 -0
  20. package/dist/browser/tuning/directv.js.map +1 -0
  21. package/dist/browser/tuning/fox.d.ts +2 -2
  22. package/dist/browser/tuning/fox.js +141 -22
  23. package/dist/browser/tuning/fox.js.map +1 -1
  24. package/dist/browser/tuning/hbo.d.ts +2 -2
  25. package/dist/browser/tuning/hbo.js +110 -41
  26. package/dist/browser/tuning/hbo.js.map +1 -1
  27. package/dist/browser/tuning/hulu.d.ts +2 -2
  28. package/dist/browser/tuning/hulu.js +442 -168
  29. package/dist/browser/tuning/hulu.js.map +1 -1
  30. package/dist/browser/tuning/sling.d.ts +2 -2
  31. package/dist/browser/tuning/sling.js +200 -56
  32. package/dist/browser/tuning/sling.js.map +1 -1
  33. package/dist/browser/tuning/youtubeTv.d.ts +2 -2
  34. package/dist/browser/tuning/youtubeTv.js +243 -57
  35. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  36. package/dist/browser/video.d.ts +1 -1
  37. package/dist/browser/video.js +4 -2
  38. package/dist/browser/video.js.map +1 -1
  39. package/dist/channels/index.js +280 -34
  40. package/dist/channels/index.js.map +1 -1
  41. package/dist/config/health.d.ts +78 -0
  42. package/dist/config/health.js +202 -0
  43. package/dist/config/health.js.map +1 -0
  44. package/dist/config/paths.d.ts +10 -0
  45. package/dist/config/paths.js +14 -0
  46. package/dist/config/paths.js.map +1 -1
  47. package/dist/config/profiles.d.ts +6 -4
  48. package/dist/config/profiles.js +65 -14
  49. package/dist/config/profiles.js.map +1 -1
  50. package/dist/config/providerPacks.d.ts +46 -0
  51. package/dist/config/providerPacks.js +200 -0
  52. package/dist/config/providerPacks.js.map +1 -0
  53. package/dist/config/providers.d.ts +36 -3
  54. package/dist/config/providers.js +168 -8
  55. package/dist/config/providers.js.map +1 -1
  56. package/dist/config/sites.d.ts +1 -12
  57. package/dist/config/sites.js +38 -5
  58. package/dist/config/sites.js.map +1 -1
  59. package/dist/config/userChannels.d.ts +12 -0
  60. package/dist/config/userChannels.js +81 -14
  61. package/dist/config/userChannels.js.map +1 -1
  62. package/dist/config/userConfig.d.ts +5 -1
  63. package/dist/config/userConfig.js +58 -6
  64. package/dist/config/userConfig.js.map +1 -1
  65. package/dist/config/userProfiles.d.ts +81 -0
  66. package/dist/config/userProfiles.js +424 -0
  67. package/dist/config/userProfiles.js.map +1 -0
  68. package/dist/routes/config/channels/index.d.ts +3 -0
  69. package/dist/routes/config/channels/index.js +3 -0
  70. package/dist/routes/config/channels/index.js.map +1 -0
  71. package/dist/routes/config/channels/routes.d.ts +6 -0
  72. package/dist/routes/config/channels/routes.js +797 -0
  73. package/dist/routes/config/channels/routes.js.map +1 -0
  74. package/dist/routes/config/channels/table.d.ts +46 -0
  75. package/dist/routes/config/channels/table.js +811 -0
  76. package/dist/routes/config/channels/table.js.map +1 -0
  77. package/dist/routes/config/index.d.ts +37 -0
  78. package/dist/routes/config/index.js +81 -0
  79. package/dist/routes/config/index.js.map +1 -0
  80. package/dist/routes/config/providers.d.ts +18 -0
  81. package/dist/routes/config/providers.js +573 -0
  82. package/dist/routes/config/providers.js.map +1 -0
  83. package/dist/routes/config/settings.d.ts +39 -0
  84. package/dist/routes/config/settings.js +820 -0
  85. package/dist/routes/config/settings.js.map +1 -0
  86. package/dist/routes/index.d.ts +3 -2
  87. package/dist/routes/index.js +7 -4
  88. package/dist/routes/index.js.map +1 -1
  89. package/dist/routes/playlist.d.ts +5 -1
  90. package/dist/routes/playlist.js +32 -7
  91. package/dist/routes/playlist.js.map +1 -1
  92. package/dist/routes/providers.d.ts +6 -0
  93. package/dist/routes/providers.js +166 -0
  94. package/dist/routes/providers.js.map +1 -0
  95. package/dist/routes/root/content.d.ts +32 -0
  96. package/dist/routes/root/content.js +857 -0
  97. package/dist/routes/root/content.js.map +1 -0
  98. package/dist/routes/root/index.js +197 -0
  99. package/dist/routes/root/index.js.map +1 -0
  100. package/dist/routes/root/scripts/channels.d.ts +1 -0
  101. package/dist/routes/root/scripts/channels.js +745 -0
  102. package/dist/routes/root/scripts/channels.js.map +1 -0
  103. package/dist/routes/root/scripts/config.d.ts +1 -0
  104. package/dist/routes/root/scripts/config.js +1662 -0
  105. package/dist/routes/root/scripts/config.js.map +1 -0
  106. package/dist/routes/root/scripts/index.d.ts +3 -0
  107. package/dist/routes/root/scripts/index.js +8 -0
  108. package/dist/routes/root/scripts/index.js.map +1 -0
  109. package/dist/routes/root/scripts/status.d.ts +1 -0
  110. package/dist/routes/root/scripts/status.js +436 -0
  111. package/dist/routes/root/scripts/status.js.map +1 -0
  112. package/dist/routes/root/styles.d.ts +5 -0
  113. package/dist/routes/root/styles.js +447 -0
  114. package/dist/routes/root/styles.js.map +1 -0
  115. package/dist/routes/ui.js +8 -3
  116. package/dist/routes/ui.js.map +1 -1
  117. package/dist/service/commands.d.ts +5 -3
  118. package/dist/service/commands.js +117 -33
  119. package/dist/service/commands.js.map +1 -1
  120. package/dist/service/generators.d.ts +27 -0
  121. package/dist/service/generators.js +97 -6
  122. package/dist/service/generators.js.map +1 -1
  123. package/dist/streaming/hls.js +18 -1
  124. package/dist/streaming/hls.js.map +1 -1
  125. package/dist/streaming/lifecycle.js +1 -1
  126. package/dist/streaming/lifecycle.js.map +1 -1
  127. package/dist/streaming/monitor.d.ts +1 -38
  128. package/dist/streaming/monitor.js +2 -289
  129. package/dist/streaming/monitor.js.map +1 -1
  130. package/dist/streaming/recovery.d.ts +143 -0
  131. package/dist/streaming/recovery.js +286 -0
  132. package/dist/streaming/recovery.js.map +1 -0
  133. package/dist/streaming/registry.d.ts +1 -1
  134. package/dist/streaming/setup.d.ts +1 -1
  135. package/dist/streaming/setup.js +3 -1
  136. package/dist/streaming/setup.js.map +1 -1
  137. package/dist/streaming/statusEmitter.d.ts +4 -2
  138. package/dist/streaming/statusEmitter.js +5 -0
  139. package/dist/streaming/statusEmitter.js.map +1 -1
  140. package/dist/types/channels.d.ts +68 -0
  141. package/dist/types/channels.js +2 -0
  142. package/dist/types/channels.js.map +1 -0
  143. package/dist/types/config.d.ts +127 -0
  144. package/dist/types/config.js +2 -0
  145. package/dist/types/config.js.map +1 -0
  146. package/dist/types/index.d.ts +7 -429
  147. package/dist/types/index.js +1 -5
  148. package/dist/types/index.js.map +1 -1
  149. package/dist/types/profiles.d.ts +141 -0
  150. package/dist/types/profiles.js +2 -0
  151. package/dist/types/profiles.js.map +1 -0
  152. package/dist/types/selection.d.ts +101 -0
  153. package/dist/types/selection.js +6 -0
  154. package/dist/types/selection.js.map +1 -0
  155. package/dist/types/shared.d.ts +7 -0
  156. package/dist/types/shared.js +6 -0
  157. package/dist/types/shared.js.map +1 -0
  158. package/dist/types/streaming.d.ts +96 -0
  159. package/dist/types/streaming.js +2 -0
  160. package/dist/types/streaming.js.map +1 -0
  161. package/dist/utils/debugFilter.js +3 -1
  162. package/dist/utils/debugFilter.js.map +1 -1
  163. package/dist/utils/format.d.ts +7 -0
  164. package/dist/utils/format.js +22 -0
  165. package/dist/utils/format.js.map +1 -1
  166. package/dist/utils/index.d.ts +1 -0
  167. package/dist/utils/index.js +1 -0
  168. package/dist/utils/index.js.map +1 -1
  169. package/dist/utils/sanitize.d.ts +14 -0
  170. package/dist/utils/sanitize.js +37 -0
  171. package/dist/utils/sanitize.js.map +1 -0
  172. package/package.json +5 -3
  173. package/dist/routes/config.d.ts +0 -76
  174. package/dist/routes/config.js +0 -2187
  175. package/dist/routes/config.js.map +0 -1
  176. package/dist/routes/root.js +0 -3158
  177. package/dist/routes/root.js.map +0 -1
  178. /package/dist/routes/{root.d.ts → root/index.d.ts} +0 -0
@@ -0,0 +1,811 @@
1
+ /* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * table.ts: Channel table rendering for the PrismCast configuration interface.
4
+ */
5
+ import { compareChannelSort, getAllProviderTags, getChannelProviderLabel, getChannelProviderTags, getChannelSortKey, getEnabledProviders, getProviderGroup, getProviderSelection, getProviderTagForChannel, getResolvedChannel, hasMultipleProviders, isChannelAvailableByProvider, isProviderTagEnabled, resolveProviderKey } from "../../../config/providers.js";
6
+ import { escapeHtml, formatTimeAgo } from "../../../utils/index.js";
7
+ import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls } from "../../../browser/channelSelection.js";
8
+ import { getChannelHealth, getProviderAuth } from "../../../config/health.js";
9
+ import { getChannelListing, getChannelsParseErrorMessage, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
10
+ import { getProfileForChannel, getProfiles } from "../../../config/profiles.js";
11
+ import { CONFIG } from "../../../config/index.js";
12
+ import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
13
+ import { categorizeProfiles } from "../index.js";
14
+ // SVG icon constants for channel action buttons. Each icon is 14x14px with a 16x16 viewBox, stroke-based with currentColor, and uses round line caps/joins.
15
+ export const ICON_EDIT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
16
+ "stroke-linejoin=\"round\"><path d=\"M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z\"/></svg>";
17
+ const ICON_LOGIN = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
18
+ "stroke-linejoin=\"round\"><path d=\"M6.5 2H3.5a1 1 0 00-1 1v10a1 1 0 001 1h3\"/><path d=\"M10.5 11l3-3-3-3\"/><path d=\"M13.5 8H6.5\"/></svg>";
19
+ export const ICON_DELETE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
20
+ "stroke-linejoin=\"round\"><path d=\"M2 4h12\"/><path d=\"M5 4V2.5a.5.5 0 01.5-.5h5a.5.5 0 01.5.5V4\"/><path d=\"M12.5 4l-.5 9.5a1 1 0 01-1 .5H5a1 1 0 " +
21
+ "01-1-.5L3.5 4\"/></svg>";
22
+ const ICON_ENABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
23
+ "stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 8l2 2 3.5-4\"/></svg>";
24
+ const ICON_DISABLE = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
25
+ "stroke-linejoin=\"round\"><circle cx=\"8\" cy=\"8\" r=\"6\"/><path d=\"M5.5 5.5l5 5\"/></svg>";
26
+ const ICON_HEALTH = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
27
+ "stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"1,9 4,9 6,4 8,12 10,7 12,9 15,9\"/></svg>";
28
+ const ICON_REVERT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
29
+ "stroke-linejoin=\"round\"><path d=\"M3 8a5 5 0 1 1 1.5 3.5\"/><path d=\"M3 4v4h4\"/></svg>";
30
+ const ICON_COPY = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" " +
31
+ "stroke-linejoin=\"round\"><rect x=\"5\" y=\"5\" width=\"9\" height=\"9\" rx=\"1\"/><path d=\"M5 11H3a1 1 0 01-1-1V3a1 1 0 011-1h7a1 1 0 011 1v2\"/></svg>";
32
+ // Optional column definitions for the channels table. These columns can be shown or hidden by the user via the column picker. The order here determines the
33
+ // column order in the table, slotted between Provider and Actions.
34
+ export const OPTIONAL_COLUMNS = [
35
+ { align: "center", cssClass: "col-chnum", field: "channelNumber", label: "Number", width: "70px" },
36
+ { align: "center", cssClass: "col-stationid", field: "stationId", label: "Station ID", width: "100px" },
37
+ { align: "left", cssClass: "col-profile", field: "profile", label: "Profile", width: "130px" },
38
+ { align: "left", cssClass: "col-selector", field: "channelSelector", label: "Selector", width: "130px" }
39
+ ];
40
+ // Total number of columns in the channels table (4 required + 4 optional).
41
+ const TOTAL_COLUMN_COUNT = 8;
42
+ // Valid optional column field names.
43
+ export const VALID_OPTIONAL_COLUMNS = new Set(OPTIONAL_COLUMNS.map((c) => c.field));
44
+ /**
45
+ * Generates HTML for a text input form field with label and optional hint.
46
+ * @param id - The input element ID.
47
+ * @param name - The input name attribute.
48
+ * @param label - The label text.
49
+ * @param value - The current value.
50
+ * @param options - Additional options (hint, list, pattern, placeholder, required, type).
51
+ * @returns Array of HTML strings for the form row.
52
+ */
53
+ function generateTextField(id, name, label, value, options = {}) {
54
+ const lines = [];
55
+ const inputType = options.type ?? "text";
56
+ const listAttr = options.list ? " list=\"" + options.list + "\"" : "";
57
+ const required = options.required ? " required" : "";
58
+ const pattern = options.pattern ? " pattern=\"" + options.pattern + "\"" : "";
59
+ const placeholder = options.placeholder ? " placeholder=\"" + escapeHtml(options.placeholder) + "\"" : "";
60
+ lines.push("<div class=\"form-row\">");
61
+ lines.push("<label for=\"" + id + "\">" + label + "</label>");
62
+ lines.push("<input class=\"form-input\" type=\"" + inputType + "\" id=\"" + id + "\" name=\"" + name + "\"" + required + listAttr + pattern +
63
+ placeholder + " value=\"" + escapeHtml(value) + "\">");
64
+ lines.push("</div>");
65
+ // When a datalist ID is specified, append an empty <datalist> element outside the form-row flex container. The client-side JavaScript populates it dynamically
66
+ // based on the URL field value.
67
+ if (options.list) {
68
+ lines.push("<datalist id=\"" + options.list + "\"></datalist>");
69
+ }
70
+ if (options.hint) {
71
+ lines.push("<div class=\"hint\">" + options.hint + "</div>");
72
+ }
73
+ return lines;
74
+ }
75
+ /**
76
+ * Generates HTML for the profile dropdown field with descriptions as tooltips and summaries inline.
77
+ * @param id - The select element ID.
78
+ * @param selectedProfile - The currently selected profile (empty string for autodetect).
79
+ * @param profiles - List of available profiles with descriptions and summaries.
80
+ * @param showHint - Whether to show the hint text with profile reference link.
81
+ * @returns Array of HTML strings for the form row.
82
+ */
83
+ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true) {
84
+ const lines = [];
85
+ const groups = categorizeProfiles(profiles);
86
+ // Helper to generate option elements for a profile.
87
+ const renderOption = (profile) => {
88
+ const selected = (profile.name === selectedProfile) ? " selected" : "";
89
+ const title = profile.description ? " title=\"" + escapeHtml(profile.description) + "\"" : "";
90
+ const displayText = profile.summary ? profile.name + " \u2014 " + profile.summary : profile.name;
91
+ return "<option value=\"" + escapeHtml(profile.name) + "\"" + title + selected + ">" + escapeHtml(displayText) + "</option>";
92
+ };
93
+ lines.push("<div class=\"form-row\">");
94
+ lines.push("<label for=\"" + id + "\">Profile</label>");
95
+ lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\">");
96
+ lines.push("<option value=\"\">Autodetect (Recommended)</option>");
97
+ // Fullscreen API profiles (most common).
98
+ if (groups.api.length > 0) {
99
+ lines.push("<optgroup label=\"Fullscreen API\">");
100
+ for (const profile of groups.api) {
101
+ lines.push(renderOption(profile));
102
+ }
103
+ lines.push("</optgroup>");
104
+ }
105
+ // Keyboard fullscreen profiles.
106
+ if (groups.keyboard.length > 0) {
107
+ lines.push("<optgroup label=\"Keyboard Fullscreen\">");
108
+ for (const profile of groups.keyboard) {
109
+ lines.push(renderOption(profile));
110
+ }
111
+ lines.push("</optgroup>");
112
+ }
113
+ // Special profiles.
114
+ if (groups.special.length > 0) {
115
+ lines.push("<optgroup label=\"Special\">");
116
+ for (const profile of groups.special) {
117
+ lines.push(renderOption(profile));
118
+ }
119
+ lines.push("</optgroup>");
120
+ }
121
+ // Multi-channel profiles.
122
+ if (groups.multiChannel.length > 0) {
123
+ lines.push("<optgroup label=\"Multi-Channel (needs selector)\">");
124
+ for (const profile of groups.multiChannel) {
125
+ lines.push(renderOption(profile));
126
+ }
127
+ lines.push("</optgroup>");
128
+ }
129
+ // Custom (user-defined) profiles.
130
+ if (groups.custom.length > 0) {
131
+ lines.push("<optgroup label=\"Custom\">");
132
+ for (const profile of groups.custom) {
133
+ lines.push(renderOption(profile));
134
+ }
135
+ lines.push("</optgroup>");
136
+ }
137
+ lines.push("</select>");
138
+ lines.push("</div>");
139
+ if (showHint) {
140
+ lines.push("<div class=\"hint\">Autodetect uses predefined profiles for known sites. If video doesn't play or fullscreen fails, " +
141
+ "try experimenting with different profiles. ");
142
+ lines.push("<a href=\"#\" onclick=\"toggleProfileReference(); return false;\">View profile reference</a></div>");
143
+ }
144
+ return lines;
145
+ }
146
+ /**
147
+ * Generates HTML for the profile reference section. This collapsible section provides detailed documentation for all available profiles, grouped by category to
148
+ * help users understand which profile to select for their site.
149
+ * @param profiles - List of available profiles with descriptions and summaries.
150
+ * @returns HTML string for the profile reference section.
151
+ */
152
+ function generateProfileReference(profiles) {
153
+ const lines = [];
154
+ const groups = categorizeProfiles(profiles);
155
+ lines.push("<div id=\"profile-reference\" class=\"profile-reference\" style=\"display: none;\">");
156
+ lines.push("<div class=\"profile-reference-header\">");
157
+ lines.push("<h3>Profile Reference</h3>");
158
+ lines.push("<button type=\"button\" class=\"profile-reference-close\" aria-label=\"Close\" onclick=\"toggleProfileReference()\">\u2715</button>");
159
+ lines.push("</div>");
160
+ lines.push("<p class=\"reference-intro\">Profiles configure how PrismCast interacts with different video players. Autodetect uses predefined ");
161
+ lines.push("profiles for known sites. If video doesn't play or fullscreen fails, use this reference to experiment with different profiles.</p>");
162
+ // Fullscreen API profiles (most common).
163
+ if (groups.api.length > 0) {
164
+ lines.push("<div class=\"profile-category\">");
165
+ lines.push("<h4>Fullscreen API Profiles</h4>");
166
+ lines.push("<p class=\"category-desc\">For single-channel sites that require JavaScript's requestFullscreen() API instead of keyboard shortcuts.</p>");
167
+ lines.push("<dl class=\"profile-list\">");
168
+ for (const profile of groups.api) {
169
+ lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
170
+ lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
171
+ }
172
+ lines.push("</dl>");
173
+ lines.push("</div>");
174
+ }
175
+ // Keyboard fullscreen profiles.
176
+ if (groups.keyboard.length > 0) {
177
+ lines.push("<div class=\"profile-category\">");
178
+ lines.push("<h4>Keyboard Fullscreen Profiles</h4>");
179
+ lines.push("<p class=\"category-desc\">For single-channel sites that use the 'f' key to toggle fullscreen mode.</p>");
180
+ lines.push("<dl class=\"profile-list\">");
181
+ for (const profile of groups.keyboard) {
182
+ lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
183
+ lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
184
+ }
185
+ lines.push("</dl>");
186
+ lines.push("</div>");
187
+ }
188
+ // Special profiles.
189
+ if (groups.special.length > 0) {
190
+ lines.push("<div class=\"profile-category\">");
191
+ lines.push("<h4>Special Profiles</h4>");
192
+ lines.push("<p class=\"category-desc\">For non-standard use cases like static pages without video.</p>");
193
+ lines.push("<dl class=\"profile-list\">");
194
+ for (const profile of groups.special) {
195
+ lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
196
+ lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
197
+ }
198
+ lines.push("</dl>");
199
+ lines.push("</div>");
200
+ }
201
+ // Multi-channel profiles (requires channel selector) - at the end since these are more advanced.
202
+ if (groups.multiChannel.length > 0) {
203
+ lines.push("<div class=\"profile-category\">");
204
+ lines.push("<h4>Multi-Channel Profiles</h4>");
205
+ lines.push("<p class=\"category-desc\">For sites that host multiple live channels on a single page. These profiles require a channel selector ");
206
+ lines.push("to identify which channel to tune to. Set the Channel Selector field in Advanced Options when using these profiles.</p>");
207
+ lines.push("<dl class=\"profile-list\">");
208
+ for (const profile of groups.multiChannel) {
209
+ lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
210
+ lines.push("<dd>" + escapeHtml(profile.description) + "</dd>");
211
+ }
212
+ lines.push("</dl>");
213
+ // Per-strategy guidance for finding Channel Selector values. Organized by strategy type since the same strategy can be used across multiple profiles.
214
+ lines.push("<h4 class=\"selector-guide-heading\">Finding Your Channel Selector</h4>");
215
+ lines.push("<p class=\"category-desc\">Predefined channels already have Channel Selector values set. For custom channels, the value depends on the ");
216
+ lines.push("profile's strategy type:</p>");
217
+ lines.push("<dl class=\"profile-list\">");
218
+ lines.push("<dt>apiMultiVideo, disneyPlus, keyboardDynamicMultiVideo (element selector)</dt>");
219
+ lines.push("<dd>These profiles use a <code>matchSelector</code> CSS template to find the channel element. The default pattern matches image URLs: ");
220
+ lines.push("right-click the channel's image on the site \u2192 Inspect Element \u2192 find the &lt;img&gt; tag \u2192 copy a unique portion ");
221
+ lines.push("of the <code>src</code> URL that identifies the channel (e.g., \"espn\" from a URL containing \"poster_linear_espn_none\"). ");
222
+ lines.push("Custom <code>matchSelector</code> patterns can match any attribute (aria-label, data-testid, title, etc.).</dd>");
223
+ lines.push("<dt>foxLive (station code)</dt>");
224
+ lines.push("<dd>Inspect a channel logo in the guide \u2192 find the <code>&lt;button&gt;</code> inside <code>GuideChannelLogo</code> \u2192 use ");
225
+ lines.push("the <code>title</code> attribute value (e.g., BTN, FOXD2C, FS1, FS2, FWX).</dd>");
226
+ lines.push("<dt>hboMax (channel name)</dt>");
227
+ lines.push("<dd>Inspect a channel tile in the HBO rail \u2192 find the <code>&lt;p aria-hidden=\"true\"&gt;</code> element \u2192 use the text ");
228
+ lines.push("content (e.g., HBO, HBO Comedy, HBO Drama, HBO Hits, HBO Movies).</dd>");
229
+ lines.push("<dt>huluLive (channel name)</dt>");
230
+ lines.push("<dd>Inspect a channel entry in the guide \u2192 find the <code>data-testid</code> attribute starting with ");
231
+ lines.push("<code>live-guide-channel-kyber-</code> \u2192 use the portion after that prefix. The name may differ from the logo shown ");
232
+ lines.push("(e.g., the full name rather than an abbreviation). For local affiliates (ABC, CBS, FOX, NBC), use the network name \u2014 PrismCast ");
233
+ lines.push("resolves the local station automatically.</dd>");
234
+ lines.push("<dt>slingLive (channel name)</dt>");
235
+ lines.push("<dd>Inspect a channel entry in the guide \u2192 find the <code>data-testid</code> attribute starting with <code>channel-</code> ");
236
+ lines.push("\u2192 use the portion after that prefix. The name may differ from the logo shown (e.g., \"FOX Sports 1\" not \"FS1\"). For local ");
237
+ lines.push("affiliates (ABC, CBS, FOX, NBC), use the network name \u2014 PrismCast resolves the local station automatically.</dd>");
238
+ lines.push("<dt>youtubeTV (channel name)</dt>");
239
+ lines.push("<dd>Inspect a channel thumbnail in the guide \u2192 find the <code>aria-label</code> attribute on the ");
240
+ lines.push("<code>ytu-endpoint</code> element \u2192 use the name after \"watch \" (e.g., <code>aria-label=\"watch CNN\"</code> \u2192 CNN). ");
241
+ lines.push("For locals, use the network name (e.g., NBC) \u2014 affiliates like \"NBC 5\" are resolved automatically. PBS resolves to the ");
242
+ lines.push("local affiliate in major markets.</dd>");
243
+ lines.push("</dl>");
244
+ lines.push("</div>");
245
+ }
246
+ // Custom (user-defined) profiles.
247
+ if (groups.custom.length > 0) {
248
+ lines.push("<div class=\"profile-category\">");
249
+ lines.push("<h4>Custom Profiles</h4>");
250
+ lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from provider packs.</p>");
251
+ lines.push("<dl class=\"profile-list\">");
252
+ for (const profile of groups.custom) {
253
+ lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
254
+ lines.push("<dd>" + escapeHtml(profile.description || "No description provided.") + "</dd>");
255
+ }
256
+ lines.push("</dl>");
257
+ lines.push("</div>");
258
+ }
259
+ lines.push("</div>");
260
+ return lines.join("\n");
261
+ }
262
+ /**
263
+ * Generates HTML for the advanced fields section (station ID, channel selector, and channel number).
264
+ * @param idPrefix - Prefix for element IDs ("add" or "edit").
265
+ * @param stationIdValue - Current station ID value.
266
+ * @param channelSelectorValue - Current channel selector value.
267
+ * @param channelNumberValue - Current channel number value.
268
+ * @param showHints - Whether to show hint text.
269
+ * @returns Array of HTML strings for the advanced fields section.
270
+ */
271
+ function generateAdvancedFields(idPrefix, stationIdValue, channelSelectorValue, channelNumberValue, showHints = true) {
272
+ const lines = [];
273
+ // Advanced fields toggle.
274
+ lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
275
+ "-advanced').classList.toggle('show'); this.textContent = this.textContent === 'Show Advanced Options' ? " +
276
+ "'Hide Advanced Options' : 'Show Advanced Options';\">Show Advanced Options</div>");
277
+ lines.push("<div id=\"" + idPrefix + "-advanced\" class=\"advanced-fields\">");
278
+ // Station ID.
279
+ const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
280
+ lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { hint: stationIdHint, placeholder: showHints ? "e.g., 12345" : undefined }));
281
+ // Channel selector.
282
+ const channelSelectorHint = showHints ?
283
+ "Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
284
+ "For guide-based profiles (Fox, HBO Max, Hulu, Sling, YouTube TV), use the channel name or station code from the guide. " +
285
+ "For tile and thumbnail profiles, right-click the channel element \u2192 Inspect \u2192 copy a unique value matching the profile's selector pattern " +
286
+ "(typically a portion of the image src URL)." :
287
+ undefined;
288
+ lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { hint: channelSelectorHint, list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
289
+ // Channel number for Channels DVR and Plex integration.
290
+ const channelNumberHint = showHints ?
291
+ "Optional numeric channel number for guide matching in Channels DVR and Plex." :
292
+ undefined;
293
+ lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { hint: channelNumberHint, placeholder: showHints ? "e.g., 501" : undefined }));
294
+ lines.push("</div>"); // End advanced fields.
295
+ return lines;
296
+ }
297
+ /**
298
+ * Generates JavaScript variables for channel selector datalist population. Produces three variables: `channelSelectorsByDomain` maps URL hostnames to known
299
+ * channel selector values (from predefined channels and cached provider discovery), `providerByDomain` maps provider guide URL hostnames to provider slugs for
300
+ * client-side async discovery, and `providerGuideUrl` maps provider slugs to their guide URLs for URL correction hints. Embedded as a `<script>` block in the
301
+ * channels panel.
302
+ *
303
+ * @returns JavaScript variable declarations ready to embed in a `<script>` tag.
304
+ */
305
+ function generateChannelSelectorData() {
306
+ const byDomain = {};
307
+ const seen = {};
308
+ for (const channel of Object.values(PREDEFINED_CHANNELS)) {
309
+ if (!channel.channelSelector) {
310
+ continue;
311
+ }
312
+ const hostname = new URL(channel.url).hostname;
313
+ seen[hostname] ??= new Set();
314
+ if (seen[hostname].has(channel.channelSelector)) {
315
+ continue;
316
+ }
317
+ seen[hostname].add(channel.channelSelector);
318
+ byDomain[hostname] ??= [];
319
+ byDomain[hostname].push({ label: channel.name ?? channel.channelSelector, value: channel.channelSelector });
320
+ }
321
+ // Merge cached provider-discovered channels into the domain map. Predefined entries take precedence — we only add discovered channels whose channelSelector
322
+ // value is not already present for that domain. This enriches the datalist with the full provider lineup when precaching or prior discovery has run.
323
+ for (const provider of getCachedProviderChannels()) {
324
+ seen[provider.hostname] ??= new Set();
325
+ byDomain[provider.hostname] ??= [];
326
+ for (const entry of provider.entries) {
327
+ if (!seen[provider.hostname].has(entry.value)) {
328
+ seen[provider.hostname].add(entry.value);
329
+ byDomain[provider.hostname].push(entry);
330
+ }
331
+ }
332
+ }
333
+ // Build the hostname→slug map and slug→guideUrl map for all providers (including those with cold caches) so the client-side fetch can trigger discovery for any
334
+ // provider domain and the URL hint can suggest the correct guide URL.
335
+ const providerByDomain = getProviderDomainMap();
336
+ const providerGuideUrl = getProviderGuideUrls();
337
+ // Sort entries within each domain alphabetically by label for consistent ordering in the datalist dropdown.
338
+ for (const entries of Object.values(byDomain)) {
339
+ entries.sort((a, b) => a.label.localeCompare(b.label));
340
+ }
341
+ return "var channelSelectorsByDomain = " + JSON.stringify(byDomain) + ";\n" +
342
+ "var providerByDomain = " + JSON.stringify(providerByDomain) + ";\n" +
343
+ "var providerGuideUrl = " + JSON.stringify(providerGuideUrl) + ";";
344
+ }
345
+ /**
346
+ * Generates the HTML for a single channel's table rows (display row and edit form row). All channels — predefined, override, and user-defined — get both rows.
347
+ * The edit form is pre-populated with the effective (resolved) values so users see what they're changing. When called from generateChannelsPanel() which already
348
+ * has the listing entry, pass it via the entry parameter to avoid redundant getChannelListing() calls. POST handlers that generate a single row omit the
349
+ * parameter to trigger an internal lookup.
350
+ * @param key - The channel key.
351
+ * @param profiles - List of available profiles with descriptions for the dropdown.
352
+ * @param entry - Optional pre-resolved listing entry. When omitted, looked up from getChannelListing().
353
+ * @returns Object with displayRow and editRow HTML strings.
354
+ */
355
+ export function generateChannelRowHtml(key, profiles, entry) {
356
+ // Resolve the effective channel. Use the provided entry if available, otherwise look it up from the listing.
357
+ const listing = entry ?? getChannelListing().find((e) => e.key === key);
358
+ // If channel doesn't exist in the listing, return empty rows (shouldn't happen in normal use).
359
+ if (!listing) {
360
+ return { displayRow: "", editRow: "" };
361
+ }
362
+ const channel = listing.channel;
363
+ // Resolve the selected provider's channel data for display purposes (edit form pre-population). This ensures the values shown reflect the currently selected provider.
364
+ const resolvedKey = resolveProviderKey(key);
365
+ const resolvedChannel = getResolvedChannel(resolvedKey);
366
+ const displayChannel = resolvedChannel ?? channel;
367
+ const isUser = isUserChannel(key);
368
+ const isPredefined = isPredefinedChannel(key);
369
+ const isOverride = isPredefined && isUser;
370
+ const isDisabled = isPredefinedChannelDisabled(key);
371
+ const isAvailableByProvider = isChannelAvailableByProvider(key);
372
+ // Check if this channel has multiple providers.
373
+ const providerGroup = getProviderGroup(key);
374
+ // Build the provider tags data attribute for client-side filtering.
375
+ const providerTags = getChannelProviderTags(key).join(",");
376
+ // Generate display row. User channels get one CSS class, disabled predefined get another, provider-filtered get a third.
377
+ const displayLines = [];
378
+ const rowClasses = [];
379
+ if (isUser) {
380
+ rowClasses.push("user-channel");
381
+ }
382
+ if (isDisabled) {
383
+ rowClasses.push("channel-disabled");
384
+ }
385
+ if (!isAvailableByProvider) {
386
+ rowClasses.push("channel-unavailable");
387
+ }
388
+ const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
389
+ displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(providerTags) + "\">");
390
+ displayLines.push("<td class=\"ch-key\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "key")) + "\">" + escapeHtml(key) + "</td>");
391
+ displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "name")) + "\">" + escapeHtml(channel.name ?? key) + "</td>");
392
+ // Provider column: dropdown for multi-provider channels, static provider name for single-provider. Both states always render a hidden "No available providers"
393
+ // label alongside the provider content so that client-side filterChannelRows() can toggle between them without a page reload.
394
+ displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "provider")) + "\">");
395
+ const labelHidden = isAvailableByProvider ? " style=\"display:none\"" : "";
396
+ const contentHidden = isAvailableByProvider ? "" : " style=\"display:none\"";
397
+ displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available providers</em>");
398
+ if (hasMultipleProviders(key) && providerGroup) {
399
+ // Multi-provider: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the provider selection changes. Filtered-out
400
+ // options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls filterChannelRows()
401
+ // to remove them from the DOM.
402
+ const currentSelection = getProviderSelection(key) ?? key;
403
+ displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) + "\" onchange=\"updateProviderSelection(this)\"" +
404
+ contentHidden + ">");
405
+ for (const variant of providerGroup.variants) {
406
+ const selected = (variant.key === currentSelection) ? " selected" : "";
407
+ const tag = getProviderTagForChannel(variant.key);
408
+ const optionHidden = !isProviderTagEnabled(tag) ? " hidden" : "";
409
+ displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(tag) + "\"" + selected + optionHidden + ">" +
410
+ escapeHtml(variant.label) + "</option>");
411
+ }
412
+ displayLines.push("</select>");
413
+ }
414
+ else {
415
+ // Single-provider: wrap the provider name in a span so client-side JS can toggle it with the no-provider label. Uses profile-aware label resolution so
416
+ // channels with explicit profile assignments show the profile's provider name rather than the built-in name for the URL domain.
417
+ displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
418
+ escapeHtml(getChannelProviderLabel(channel)) + "</span>");
419
+ }
420
+ displayLines.push("</td>");
421
+ // Optional columns: Number, Station ID, Profile, Selector. All four are always rendered; visibility is controlled by CSS classes on the table element.
422
+ displayLines.push("<td class=\"col-chnum\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "channelNumber")) + "\">" +
423
+ (channel.channelNumber ? escapeHtml(String(channel.channelNumber)) : "") + "</td>");
424
+ displayLines.push("<td class=\"col-stationid\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "stationId")) + "\">" +
425
+ (channel.stationId ? escapeHtml(channel.stationId) : "") + "</td>");
426
+ // Profile column: show explicit profile as-is, or the auto-resolved friendly name with "(auto)" suffix in muted style. The sort key is computed once and applied
427
+ // uniformly to all three display branches so server-side sort order always matches client-side re-sort.
428
+ const profileSortKey = escapeHtml(getChannelSortKey(channel, key, "profile"));
429
+ if (channel.profile) {
430
+ displayLines.push("<td class=\"col-profile\" data-sort-value=\"" + profileSortKey + "\">" + escapeHtml(channel.profile) + "</td>");
431
+ }
432
+ else {
433
+ const resolved = getProfileForChannel(channel);
434
+ if (resolved.profileName !== "default") {
435
+ const label = getChannelProviderLabel(channel);
436
+ displayLines.push("<td class=\"col-profile\" data-sort-value=\"" + profileSortKey +
437
+ "\"><span class=\"text-muted\">" + escapeHtml(label + " (auto)") + "</span></td>");
438
+ }
439
+ else {
440
+ displayLines.push("<td class=\"col-profile\" data-sort-value=\"" + profileSortKey + "\"></td>");
441
+ }
442
+ }
443
+ displayLines.push("<td class=\"col-selector\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "channelSelector")) + "\">" +
444
+ (channel.channelSelector ? escapeHtml(channel.channelSelector) : "<span class=\"text-muted\">&ndash;</span>") + "</td>");
445
+ // Actions column with icon buttons. Five positions per row: Edit (always), Login/placeholder, Health/placeholder, context-sensitive, and Copy URL.
446
+ displayLines.push("<td>");
447
+ displayLines.push("<div class=\"btn-group\">");
448
+ const escapedKey = escapeHtml(key);
449
+ // Resolve the provider tag for the currently selected provider variant. Used for both login icon color and health icon lookups.
450
+ const variantKey = resolveProviderKey(key);
451
+ const providerTag = getProviderTagForChannel(variantKey);
452
+ // Position 1: Edit (all channels).
453
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
454
+ "')\">" + ICON_EDIT + "</button>");
455
+ // Position 2: Login for enabled channels (with provider auth color), placeholder for disabled predefined. Custom channels (user-defined, not predefined) skip
456
+ // login coloring because they have no provider concept.
457
+ if (!isDisabled) {
458
+ const authTimestamp = (isPredefined || isOverride) ? getProviderAuth(providerTag) : null;
459
+ const loginColorClass = authTimestamp ? " health-success" : "";
460
+ const loginTitle = authTimestamp ? "Verified " + formatTimeAgo(authTimestamp) : "Not yet verified";
461
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-login" + loginColorClass + "\" data-provider-tag=\"" +
462
+ escapeHtml(providerTag) + "\" title=\"" + loginTitle + "\" aria-label=\"Login\" " +
463
+ "onclick=\"startChannelLogin('" + escapedKey + "')\">" + ICON_LOGIN + "</button>");
464
+ }
465
+ else {
466
+ displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
467
+ }
468
+ // Position 3: Channel health indicator. Shows last tune result via color. Non-interactive (span, not button). Disabled channels get a placeholder instead.
469
+ if (!isDisabled) {
470
+ const channelHealthResult = getChannelHealth(key, providerTag);
471
+ const healthColorClass = (channelHealthResult?.status === "success") ? " health-success" : (channelHealthResult?.status === "failed") ? " health-failed" : "";
472
+ const healthTitle = channelHealthResult ?
473
+ (channelHealthResult.status === "success" ? "Succeeded " : "Failed ") + formatTimeAgo(channelHealthResult.timestamp) : "Not yet tuned";
474
+ displayLines.push("<span class=\"btn-icon btn-icon-health" + healthColorClass + "\" title=\"" + healthTitle +
475
+ "\" aria-label=\"Channel health\">" + ICON_HEALTH + "</span>");
476
+ }
477
+ else {
478
+ displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
479
+ }
480
+ // Position 4: varies by row type.
481
+ if (isOverride) {
482
+ // Override: revert to predefined defaults.
483
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-revert\" title=\"Revert to defaults\" aria-label=\"Revert to defaults\"" +
484
+ " onclick=\"revertChannel('" + escapedKey + "')\">" + ICON_REVERT + "</button>");
485
+ }
486
+ else if (isUser && !isPredefined) {
487
+ // User-defined (not an override): delete.
488
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
489
+ escapedKey + "')\">" + ICON_DELETE + "</button>");
490
+ }
491
+ else if (isPredefined) {
492
+ // Predefined (no override): enable/disable toggle.
493
+ if (isDisabled) {
494
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
495
+ escapedKey + "', true)\">" + ICON_ENABLE + "</button>");
496
+ }
497
+ else {
498
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-disable\" title=\"Disable\" aria-label=\"Disable\" onclick=\"togglePredefinedChannel('" +
499
+ escapedKey + "', false)\">" + ICON_DISABLE + "</button>");
500
+ }
501
+ }
502
+ // Position 5: Copy URL dropdown (all channels).
503
+ displayLines.push("<div class=\"dropdown copy-dropdown\">");
504
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-copy\" title=\"Copy stream URL\" aria-label=\"Copy stream URL\" " +
505
+ "onclick=\"toggleDropdown(this)\">" + ICON_COPY + "</button>");
506
+ displayLines.push("<div class=\"dropdown-menu copy-url-menu\">");
507
+ displayLines.push("<div class=\"dropdown-item\" onclick=\"copyStreamUrl('hls', '" + escapedKey + "')\">Copy HLS URL</div>");
508
+ displayLines.push("<div class=\"dropdown-item\" onclick=\"copyStreamUrl('mpegts', '" + escapedKey + "')\">Copy MPEG-TS URL</div>");
509
+ displayLines.push("</div>");
510
+ displayLines.push("</div>");
511
+ displayLines.push("</div>");
512
+ displayLines.push("</td>");
513
+ displayLines.push("</tr>");
514
+ const displayRow = displayLines.join("\n");
515
+ // Generate edit form row for all channels. Pre-populate with the currently selected provider's values so the user sees what they're actually streaming, not the
516
+ // canonical definition which may differ when a provider variant is selected.
517
+ const editLines = [];
518
+ editLines.push("<tr id=\"edit-row-" + escapedKey + "\" style=\"display: none;\">");
519
+ editLines.push("<td colspan=\"" + String(TOTAL_COLUMN_COUNT) + "\">");
520
+ editLines.push("<div class=\"channel-form\" style=\"margin: 0;\">");
521
+ editLines.push("<h3>Edit Channel: " + escapedKey + "</h3>");
522
+ editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
523
+ editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
524
+ editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapedKey + "\">");
525
+ // Channel name.
526
+ editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", displayChannel.name ?? key, {
527
+ hint: "Friendly name shown in the playlist and UI.",
528
+ required: true
529
+ }));
530
+ // Channel URL.
531
+ editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", displayChannel.url, {
532
+ hint: "The URL of the streaming page to capture.",
533
+ required: true,
534
+ type: "url"
535
+ }));
536
+ // Profile dropdown.
537
+ editLines.push(...generateProfileDropdown("edit-profile-" + key, displayChannel.profile ?? "", profiles));
538
+ // Advanced fields.
539
+ editLines.push(...generateAdvancedFields("edit-" + key, displayChannel.stationId ?? "", displayChannel.channelSelector ?? "", displayChannel.channelNumber ? String(displayChannel.channelNumber) : ""));
540
+ // Form buttons.
541
+ editLines.push("<div class=\"form-buttons\">");
542
+ editLines.push("<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button>");
543
+ editLines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"hideEditForm('" + escapedKey + "')\">Cancel</button>");
544
+ editLines.push("</div>");
545
+ editLines.push("</form>");
546
+ editLines.push("</div>");
547
+ editLines.push("</td>");
548
+ editLines.push("</tr>");
549
+ const editRow = editLines.join("\n");
550
+ return { displayRow, editRow };
551
+ }
552
+ /**
553
+ * Generates the provider filter toolbar HTML with a multi-select dropdown, dismissable chips, and a bulk-assign dropdown.
554
+ * @returns HTML string for the provider filter toolbar.
555
+ */
556
+ export function generateProviderFilterToolbar() {
557
+ const allTags = getAllProviderTags();
558
+ const enabled = getEnabledProviders();
559
+ const hasFilter = enabled.length > 0;
560
+ const lines = [];
561
+ lines.push("<div class=\"provider-toolbar\">");
562
+ // Left group: Provider filter dropdown and chips.
563
+ lines.push("<div class=\"toolbar-group\">");
564
+ lines.push("<span class=\"toolbar-label\">Providers:</span>");
565
+ lines.push("<div class=\"dropdown provider-dropdown\">");
566
+ const buttonText = hasFilter ? "Filtered" : "All Providers";
567
+ lines.push("<button type=\"button\" class=\"btn btn-sm\" id=\"provider-filter-btn\" onclick=\"toggleDropdown(this)\">" + buttonText + " &#9662;</button>");
568
+ lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
569
+ for (const tagInfo of allTags) {
570
+ const isDirectTag = tagInfo.tag === "direct";
571
+ const isChecked = isDirectTag || !hasFilter || enabled.includes(tagInfo.tag);
572
+ const checkedAttr = isChecked ? " checked" : "";
573
+ const disabledAttr = isDirectTag ? " disabled" : "";
574
+ lines.push("<label class=\"provider-option\">");
575
+ lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
576
+ " onchange=\"toggleProviderTag(this)\"> " + escapeHtml(tagInfo.displayName));
577
+ lines.push("</label>");
578
+ }
579
+ lines.push("</div>");
580
+ lines.push("</div>");
581
+ // Chips container for active filter tags.
582
+ lines.push("<div class=\"provider-chips\" id=\"provider-chips\">");
583
+ if (hasFilter) {
584
+ for (const tag of enabled) {
585
+ if (tag === "direct") {
586
+ continue;
587
+ }
588
+ const displayName = allTags.find((t) => t.tag === tag)?.displayName ?? tag;
589
+ lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" + escapeHtml(displayName) +
590
+ "<button type=\"button\" class=\"chip-close\" aria-label=\"Remove " + escapeHtml(displayName) + "\" onclick=\"removeProviderChip('" + escapeHtml(tag) +
591
+ "')\">&times;</button></span>");
592
+ }
593
+ }
594
+ lines.push("</div>");
595
+ lines.push("</div>");
596
+ // Spacer.
597
+ lines.push("<div class=\"toolbar-spacer\"></div>");
598
+ // Right group: Bulk assign dropdown.
599
+ lines.push("<div class=\"toolbar-group\">");
600
+ lines.push("<span class=\"toolbar-label\">Set all channels to:</span>");
601
+ lines.push("<select class=\"form-select bulk-assign-select\" id=\"bulk-assign\" onchange=\"bulkAssignProvider(this)\">");
602
+ lines.push("<option value=\"\" disabled selected>Choose provider...</option>");
603
+ // Render all provider options so the client-side _allOptions snapshot captures them for reinsertion when the filter changes. Filtered-out options get the hidden
604
+ // attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls updateBulkAssignOptions() to remove them
605
+ // from the DOM.
606
+ for (const tagInfo of allTags) {
607
+ const optionHidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
608
+ lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\"" + optionHidden + ">" + escapeHtml(tagInfo.displayName) + "</option>");
609
+ }
610
+ lines.push("</select>");
611
+ lines.push("</div>");
612
+ lines.push("</div>");
613
+ return lines.join("\n");
614
+ }
615
+ /**
616
+ * Generates the Channels panel HTML content.
617
+ * @param channelMessage - Optional message to display (success or error).
618
+ * @param channelError - If true, display as error; otherwise as success.
619
+ * @param editingChannelKey - If set, show the edit form for this channel.
620
+ * @param showAddForm - If true, show the add channel form.
621
+ * @param formErrors - Validation errors for the channel form.
622
+ * @param formValues - Form values to re-populate after validation error.
623
+ * @returns HTML string for the Channels panel content.
624
+ */
625
+ export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
626
+ // Get the canonical channel listing (provider variants already filtered out, sorted by key). This is the single source of truth for merged channel data —
627
+ // it handles predefined/user merging, disabled state, and provider availability.
628
+ const listing = getChannelListing();
629
+ const profiles = getProfiles();
630
+ // Count channels hidden from the default view: disabled predefined channels OR channels with no available providers.
631
+ const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByProvider).length;
632
+ const lines = [];
633
+ // Panel description.
634
+ lines.push("<div class=\"settings-panel-description\">");
635
+ lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
636
+ lines.push("<p class=\"description-hint\">Tip: Use the <strong>provider filter</strong> above to show only channels from services you subscribe to &mdash; ", "this also controls which channels Channels DVR sees in the playlist. Use the <strong>provider dropdown</strong> on any multi-provider channel to choose ", "which streaming service delivers it (e.g., Comedy Central via Hulu vs Sling). Click the <strong>edit icon</strong> to customize any channel's name, ", "Gracenote station ID, URL, or other properties.</p>");
637
+ lines.push("</div>");
638
+ // Toolbar with channel operations and display controls.
639
+ lines.push("<div class=\"channel-toolbar\">");
640
+ // Left group: channel operations. Import uses a dropdown menu to consolidate M3U and JSON import into a single button.
641
+ lines.push("<div class=\"toolbar-group\">");
642
+ lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm\" id=\"add-channel-btn\" onclick=\"document.getElementById('add-channel-form')", ".style.display='block'; this.style.display='none';\">Add Channel</button>");
643
+ lines.push("<div class=\"dropdown\">");
644
+ lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" onclick=\"toggleDropdown(this)\">Import &#9662;</button>");
645
+ lines.push("<div class=\"dropdown-menu\">");
646
+ lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); document.getElementById('import-channels-file').click()\">Channels (JSON)</div>");
647
+ lines.push("<div class=\"dropdown-divider\"></div>");
648
+ lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); document.getElementById('import-m3u-file').click()\">M3U Playlist</div>");
649
+ lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates</label>");
650
+ lines.push("</div>");
651
+ lines.push("</div>");
652
+ lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" onclick=\"exportChannels()\">Export</button>");
653
+ lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" onchange=\"importM3U(this)\">");
654
+ lines.push("</div>");
655
+ // Spacer.
656
+ lines.push("<div class=\"toolbar-spacer\"></div>");
657
+ // Right group: display controls.
658
+ lines.push("<div class=\"toolbar-group\">");
659
+ lines.push("<div class=\"dropdown bulk-actions-dropdown\">");
660
+ lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm\" onclick=\"toggleDropdown(this)\">Bulk Actions &#9662;</button>");
661
+ lines.push("<div class=\"dropdown-menu\">");
662
+ lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); bulkTogglePredefined(true, 'all')\">Enable All Predefined</div>");
663
+ lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); bulkTogglePredefined(false, 'all')\">Disable All Predefined</div>");
664
+ lines.push("<div class=\"dropdown-divider\"></div>");
665
+ lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); bulkTogglePredefined(true, 'pacific')\">Enable Pacific Variants</div>");
666
+ lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); bulkTogglePredefined(false, 'pacific')\">Disable Pacific Variants</div>");
667
+ lines.push("<div class=\"dropdown-divider\"></div>");
668
+ lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); bulkTogglePredefined(true, 'east')\">Enable East Variants</div>");
669
+ lines.push("<div class=\"dropdown-item\" onclick=\"closeDropdowns(); bulkTogglePredefined(false, 'east')\">Disable East Variants</div>");
670
+ lines.push("</div>");
671
+ lines.push("</div>");
672
+ lines.push("<label class=\"toggle-label\"><input type=\"checkbox\" id=\"show-disabled-toggle\" onchange=\"toggleDisabledVisibility()\"> ", "Show disabled (<span id=\"disabled-count\">" + String(totalHiddenCount) + "</span>)</label>");
673
+ lines.push("</div>");
674
+ lines.push("</div>");
675
+ // Show channels file parse error if applicable.
676
+ if (hasChannelsParseError()) {
677
+ lines.push("<div class=\"error\">");
678
+ lines.push("<div class=\"error-title\">Channels File Error</div>");
679
+ lines.push("The channels file at <code>" + escapeHtml(getUserChannelsFilePath()) + "</code> contains invalid JSON and could not be loaded. ");
680
+ lines.push("User channels are disabled. Fix the file manually or add a new channel to create a valid file.");
681
+ const parseError = getChannelsParseErrorMessage();
682
+ if (parseError) {
683
+ lines.push("<br><br>Error: <code>" + escapeHtml(parseError) + "</code>");
684
+ }
685
+ lines.push("</div>");
686
+ }
687
+ // Show channel message if present.
688
+ if (channelMessage) {
689
+ const messageClass = channelError ? "error" : "success";
690
+ const titleClass = channelError ? "error-title" : "success-title";
691
+ const title = channelError ? "Error" : "Success";
692
+ lines.push("<div class=\"" + messageClass + "\">");
693
+ lines.push("<div class=\"" + titleClass + "\">" + title + "</div>");
694
+ lines.push(escapeHtml(channelMessage));
695
+ lines.push("</div>");
696
+ }
697
+ // Show validation errors if present.
698
+ if (formErrors && (formErrors.size > 0)) {
699
+ lines.push("<div class=\"error\">");
700
+ lines.push("<div class=\"error-title\">Validation Errors</div>");
701
+ lines.push("Please correct the following errors:");
702
+ lines.push("<ul>");
703
+ for (const [field, error] of formErrors) {
704
+ lines.push("<li><strong>" + escapeHtml(field) + "</strong>: " + escapeHtml(error) + "</li>");
705
+ }
706
+ lines.push("</ul>");
707
+ lines.push("</div>");
708
+ }
709
+ // Add channel form (hidden by default unless showAddForm is true or there are form errors for a new channel).
710
+ const addFormVisible = (showAddForm === true) || (formErrors && formErrors.has("key") && !editingChannelKey);
711
+ lines.push("<div id=\"add-channel-form\" class=\"channel-form\" style=\"display: " + (addFormVisible ? "block" : "none") + ";\">");
712
+ lines.push("<h3>Add New Channel</h3>");
713
+ lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
714
+ lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
715
+ // Channel key (add form only).
716
+ lines.push(...generateTextField("add-key", "key", "Channel Key", formValues?.get("key") ?? "", {
717
+ hint: "Lowercase letters, numbers, and hyphens only. Used in the URL: /stream/channel-key",
718
+ pattern: "[a-z0-9-]+",
719
+ placeholder: "e.g., my-channel",
720
+ required: true
721
+ }));
722
+ // Channel name.
723
+ lines.push(...generateTextField("add-name", "name", "Display Name", formValues?.get("name") ?? "", {
724
+ hint: "Friendly name shown in the playlist and UI.",
725
+ placeholder: "e.g., My Channel",
726
+ required: true
727
+ }));
728
+ // Channel URL.
729
+ lines.push(...generateTextField("add-url", "url", "Stream URL", formValues?.get("url") ?? "", {
730
+ hint: "The URL of the streaming page to capture.",
731
+ placeholder: "https://example.com/live",
732
+ required: true,
733
+ type: "url"
734
+ }));
735
+ // Profile dropdown.
736
+ lines.push(...generateProfileDropdown("add-profile", formValues?.get("profile") ?? "", profiles));
737
+ // Advanced fields (station ID, channel selector, channel number).
738
+ lines.push(...generateAdvancedFields("add", formValues?.get("stationId") ?? "", formValues?.get("channelSelector") ?? "", formValues?.get("channelNumber") ?? ""));
739
+ // Form buttons.
740
+ lines.push("<div class=\"form-buttons\">");
741
+ lines.push("<button type=\"submit\" class=\"btn btn-primary\">Add Channel</button>");
742
+ lines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"document.getElementById('add-channel-form').style.display='none'; ", "document.getElementById('add-channel-btn').style.display='inline-block';\">Cancel</button>");
743
+ lines.push("</div>");
744
+ lines.push("</form>");
745
+ lines.push("</div>"); // End add-channel-form.
746
+ // Provider filter toolbar with multi-select dropdown, chips, and bulk assign. Placed after the add channel form so the form flows directly from its trigger button.
747
+ lines.push(generateProviderFilterToolbar());
748
+ // Profile reference section (hidden by default, toggled via link in profile dropdown hint).
749
+ lines.push(generateProfileReference(profiles));
750
+ // Channels table. Disabled predefined channels are hidden by default and revealed via the "Show disabled" toggle. The wrapper div enables horizontal scrolling on
751
+ // small screens. Table classes dynamically include hide-col-* for each hidden optional column.
752
+ const visibleCols = new Set(CONFIG.channels.visibleColumns);
753
+ const tableClasses = ["channel-table", "hide-disabled"];
754
+ for (const col of OPTIONAL_COLUMNS) {
755
+ if (!visibleCols.has(col.field)) {
756
+ tableClasses.push("hide-" + col.cssClass);
757
+ }
758
+ }
759
+ const sortField = CONFIG.channels.channelSortField;
760
+ const sortDir = CONFIG.channels.channelSortDirection;
761
+ lines.push("<div class=\"channel-table-wrapper\">");
762
+ lines.push("<table class=\"" + tableClasses.join(" ") + "\" data-sort-field=\"" + sortField + "\" data-sort-dir=\"" + sortDir + "\">");
763
+ lines.push("<thead>");
764
+ lines.push("<tr>");
765
+ // Sortable column headers. All columns except Actions are sortable. The active sort column gets a direction indicator triangle.
766
+ const sortableHeaders = [
767
+ { cssClass: "col-key", field: "key", label: "Key" },
768
+ { cssClass: "col-name", field: "name", label: "Name" },
769
+ { cssClass: "col-provider", field: "provider", label: "Provider" },
770
+ ...OPTIONAL_COLUMNS
771
+ ];
772
+ for (const hdr of sortableHeaders) {
773
+ const isActive = (sortField === hdr.field);
774
+ const indicator = isActive ? ((sortDir === "asc") ? " &#9650;" : " &#9660;") : "";
775
+ lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field + "\" onclick=\"sortChannelTable('" + hdr.field + "')\">" +
776
+ hdr.label + indicator + "</th>");
777
+ }
778
+ // Actions header with column picker dropdown.
779
+ lines.push("<th class=\"col-actions\"><span>Actions</span>");
780
+ lines.push("<div class=\"dropdown column-picker\">");
781
+ lines.push("<button type=\"button\" class=\"btn-icon btn-col-picker\" title=\"Show/hide columns\" aria-label=\"Show/hide columns\" " +
782
+ "onclick=\"toggleDropdown(this)\">&#8942;</button>");
783
+ lines.push("<div class=\"dropdown-menu column-picker-menu\">");
784
+ for (const col of OPTIONAL_COLUMNS) {
785
+ const checked = visibleCols.has(col.field) ? " checked" : "";
786
+ lines.push("<label class=\"provider-option\"><input type=\"checkbox\" data-col-class=\"" + col.cssClass + "\" data-col-field=\"" + col.field +
787
+ "\" onchange=\"toggleColumn(this)\"" + checked + "> " + col.label + "</label>");
788
+ }
789
+ lines.push("</div>");
790
+ lines.push("</div>");
791
+ lines.push("</th>");
792
+ lines.push("</tr>");
793
+ lines.push("</thead>");
794
+ lines.push("<tbody>");
795
+ // Sort the listing by the user's preferred field and direction before rendering rows. The canonical getChannelListing() order is preserved for other callers.
796
+ const sortedListing = [...listing].sort((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
797
+ // Generate rows for all channels using the shared row generator.
798
+ for (const entry of sortedListing) {
799
+ const rowHtml = generateChannelRowHtml(entry.key, profiles, entry);
800
+ lines.push(rowHtml.displayRow);
801
+ lines.push(rowHtml.editRow);
802
+ }
803
+ lines.push("</tbody>");
804
+ lines.push("</table>");
805
+ lines.push("</div>");
806
+ // Embed channel selector data for datalist population. The client-side JavaScript uses this to offer known selector suggestions when the URL matches a
807
+ // multi-channel site like Disney+ or USA Network.
808
+ lines.push("<script>" + generateChannelSelectorData() + "</script>");
809
+ return lines.join("\n");
810
+ }
811
+ //# sourceMappingURL=table.js.map