prismcast 1.10.2 → 1.10.3

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 (166) hide show
  1. package/dist/app.d.ts +5 -3
  2. package/dist/app.js +42 -41
  3. package/dist/app.js.map +1 -1
  4. package/dist/browser/hlsPlaylistObserver.d.ts +38 -0
  5. package/dist/browser/hlsPlaylistObserver.js +113 -0
  6. package/dist/browser/hlsPlaylistObserver.js.map +1 -0
  7. package/dist/browser/index.d.ts +26 -16
  8. package/dist/browser/index.js +95 -70
  9. package/dist/browser/index.js.map +1 -1
  10. package/dist/browser/manifestInterceptor.d.ts +29 -29
  11. package/dist/browser/manifestInterceptor.js +106 -212
  12. package/dist/browser/manifestInterceptor.js.map +1 -1
  13. package/dist/browser/tabNetworkObserver.d.ts +48 -0
  14. package/dist/browser/tabNetworkObserver.js +140 -0
  15. package/dist/browser/tabNetworkObserver.js.map +1 -0
  16. package/dist/config/consistencyProbe.js +13 -11
  17. package/dist/config/consistencyProbe.js.map +1 -1
  18. package/dist/config/paths.d.ts +3 -8
  19. package/dist/config/paths.js +3 -10
  20. package/dist/config/paths.js.map +1 -1
  21. package/dist/config/profiles.d.ts +1 -1
  22. package/dist/config/profiles.js +6 -6
  23. package/dist/config/profiles.js.map +1 -1
  24. package/dist/config/services.d.ts +5 -5
  25. package/dist/config/services.js +8 -8
  26. package/dist/config/services.js.map +1 -1
  27. package/dist/config/sites.d.ts +2 -2
  28. package/dist/config/sites.js +6 -6
  29. package/dist/config/sites.js.map +1 -1
  30. package/dist/config/userChannels.d.ts +108 -14
  31. package/dist/config/userChannels.js +291 -37
  32. package/dist/config/userChannels.js.map +1 -1
  33. package/dist/config/userConfig.js +1 -1
  34. package/dist/config/userConfig.js.map +1 -1
  35. package/dist/config/userProfiles.d.ts +3 -3
  36. package/dist/config/userProfiles.js +19 -19
  37. package/dist/config/userProfiles.js.map +1 -1
  38. package/dist/index.js +7 -9
  39. package/dist/index.js.map +1 -1
  40. package/dist/native/index.d.ts +1 -1
  41. package/dist/native/index.js +9 -18
  42. package/dist/native/index.js.map +1 -1
  43. package/dist/native/proxy.d.ts +1 -4
  44. package/dist/native/proxy.js +0 -9
  45. package/dist/native/proxy.js.map +1 -1
  46. package/dist/routes/clientActions.d.ts +97 -0
  47. package/dist/routes/clientActions.js +113 -0
  48. package/dist/routes/clientActions.js.map +1 -0
  49. package/dist/routes/components.d.ts +4 -3
  50. package/dist/routes/components.js +36 -39
  51. package/dist/routes/components.js.map +1 -1
  52. package/dist/routes/config/channels/endpoints/browse.js.map +1 -1
  53. package/dist/routes/config/channels/endpoints/crud.js +37 -72
  54. package/dist/routes/config/channels/endpoints/crud.js.map +1 -1
  55. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -1
  56. package/dist/routes/config/channels/healthBridge.d.ts +23 -0
  57. package/dist/routes/config/channels/healthBridge.js +71 -0
  58. package/dist/routes/config/channels/healthBridge.js.map +1 -0
  59. package/dist/routes/config/channels/table.js +97 -84
  60. package/dist/routes/config/channels/table.js.map +1 -1
  61. package/dist/routes/config/services.js +25 -22
  62. package/dist/routes/config/services.js.map +1 -1
  63. package/dist/routes/config/settings.js +9 -6
  64. package/dist/routes/config/settings.js.map +1 -1
  65. package/dist/routes/debug.js +277 -139
  66. package/dist/routes/debug.js.map +1 -1
  67. package/dist/routes/logs.js +1 -1
  68. package/dist/routes/logs.js.map +1 -1
  69. package/dist/routes/root/content.js +18 -14
  70. package/dist/routes/root/content.js.map +1 -1
  71. package/dist/routes/root/index.js +17 -14
  72. package/dist/routes/root/index.js.map +1 -1
  73. package/dist/routes/root/scripts/channels.js +50 -21
  74. package/dist/routes/root/scripts/channels.js.map +1 -1
  75. package/dist/routes/root/scripts/config.js +72 -3
  76. package/dist/routes/root/scripts/config.js.map +1 -1
  77. package/dist/routes/root/scripts/shared.js +60 -0
  78. package/dist/routes/root/scripts/shared.js.map +1 -1
  79. package/dist/routes/root/scripts/status.handlers.d.ts +5 -27
  80. package/dist/routes/root/scripts/status.handlers.js +8 -60
  81. package/dist/routes/root/scripts/status.handlers.js.map +1 -1
  82. package/dist/routes/root/scripts/status.js +6 -1
  83. package/dist/routes/root/scripts/status.js.map +1 -1
  84. package/dist/routes/streams.js +5 -3
  85. package/dist/routes/streams.js.map +1 -1
  86. package/dist/service/commands.context.js +1 -1
  87. package/dist/service/commands.context.js.map +1 -1
  88. package/dist/service/generators.context.js +1 -1
  89. package/dist/service/generators.context.js.map +1 -1
  90. package/dist/service/generators.d.ts +1 -1
  91. package/dist/service/generators.js +3 -11
  92. package/dist/service/generators.js.map +1 -1
  93. package/dist/streaming/hls.js +1 -5
  94. package/dist/streaming/hls.js.map +1 -1
  95. package/dist/streaming/hlsSegments.d.ts +1 -1
  96. package/dist/streaming/hlsSegments.js.map +1 -1
  97. package/dist/streaming/preroll.d.ts +1 -1
  98. package/dist/streaming/preroll.js +1 -5
  99. package/dist/streaming/preroll.js.map +1 -1
  100. package/dist/streaming/statusEmitter.d.ts +5 -8
  101. package/dist/streaming/statusEmitter.js +0 -5
  102. package/dist/streaming/statusEmitter.js.map +1 -1
  103. package/dist/types/channels.d.ts +9 -0
  104. package/dist/types/channels.js.map +1 -1
  105. package/dist/types/profiles.d.ts +2 -2
  106. package/dist/upgrade/commands.context.js +14 -10
  107. package/dist/upgrade/commands.context.js.map +1 -1
  108. package/dist/upgrade/commands.d.ts +7 -15
  109. package/dist/upgrade/commands.js +27 -28
  110. package/dist/upgrade/commands.js.map +1 -1
  111. package/dist/upgrade/detection.context.js +2 -1
  112. package/dist/upgrade/detection.context.js.map +1 -1
  113. package/dist/upgrade/detection.d.ts +2 -1
  114. package/dist/upgrade/detection.js +20 -51
  115. package/dist/upgrade/detection.js.map +1 -1
  116. package/dist/upgrade/index.d.ts +1 -0
  117. package/dist/upgrade/index.js +1 -0
  118. package/dist/upgrade/index.js.map +1 -1
  119. package/dist/upgrade/lifecycle.context.d.ts +19 -0
  120. package/dist/upgrade/lifecycle.context.js +47 -0
  121. package/dist/upgrade/lifecycle.context.js.map +1 -0
  122. package/dist/upgrade/lifecycle.d.ts +72 -0
  123. package/dist/upgrade/lifecycle.js +176 -0
  124. package/dist/upgrade/lifecycle.js.map +1 -0
  125. package/dist/upgrade/pathHandle.d.ts +28 -0
  126. package/dist/upgrade/pathHandle.js +101 -0
  127. package/dist/upgrade/pathHandle.js.map +1 -0
  128. package/dist/utils/bootSession.context.d.ts +15 -0
  129. package/dist/utils/bootSession.context.js +59 -0
  130. package/dist/utils/bootSession.context.js.map +1 -0
  131. package/dist/utils/bootSession.d.ts +16 -0
  132. package/dist/utils/bootSession.js +20 -0
  133. package/dist/utils/bootSession.js.map +1 -0
  134. package/dist/utils/clock.js +1 -1
  135. package/dist/utils/ffmpeg.js +1 -1
  136. package/dist/utils/ffmpeg.js.map +1 -1
  137. package/dist/utils/index.d.ts +3 -0
  138. package/dist/utils/index.js +3 -0
  139. package/dist/utils/index.js.map +1 -1
  140. package/dist/utils/markup.d.ts +10 -0
  141. package/dist/utils/markup.js +23 -0
  142. package/dist/utils/markup.js.map +1 -1
  143. package/dist/utils/pid.d.ts +2 -25
  144. package/dist/utils/pid.js +10 -49
  145. package/dist/utils/pid.js.map +1 -1
  146. package/dist/utils/processInspector.context.d.ts +7 -0
  147. package/dist/utils/processInspector.context.js +104 -0
  148. package/dist/utils/processInspector.context.js.map +1 -0
  149. package/dist/utils/processInspector.d.ts +54 -0
  150. package/dist/utils/processInspector.js +125 -0
  151. package/dist/utils/processInspector.js.map +1 -0
  152. package/dist/utils/retry.d.ts +1 -1
  153. package/dist/utils/retry.js +1 -1
  154. package/dist/utils/retry.js.map +1 -1
  155. package/dist/utils/runtimeIdentity.context.d.ts +6 -0
  156. package/dist/utils/runtimeIdentity.context.js +13 -0
  157. package/dist/utils/runtimeIdentity.context.js.map +1 -0
  158. package/dist/utils/runtimeIdentity.d.ts +96 -0
  159. package/dist/utils/runtimeIdentity.js +171 -0
  160. package/dist/utils/runtimeIdentity.js.map +1 -0
  161. package/dist/utils/timing.d.ts +1 -1
  162. package/dist/utils/timing.js +0 -5
  163. package/dist/utils/timing.js.map +1 -1
  164. package/dist/utils/version.js +40 -10
  165. package/dist/utils/version.js.map +1 -1
  166. package/package.json +2 -2
@@ -5,6 +5,7 @@ import { getActiveTagVocabulary, getChannelCustomizations, getChannelEffectiveTa
5
5
  import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls, getProviderModuleInfo } from "../../../browser/channelSelection.js";
6
6
  import { getChannelHealth, getDomainAuth } from "../../../config/health.js";
7
7
  import { getProfileForChannel, getProfiles } from "../../../config/profiles.js";
8
+ import { ACTIONS } from "../../clientActions.js";
8
9
  import { CONFIG } from "../../../config/index.js";
9
10
  import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
10
11
  import { categorizeProfiles } from "../index.js";
@@ -61,7 +62,7 @@ function generateTextField(id, name, label, value, options = {}) {
61
62
  const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
62
63
  const defaultAttr = (options.defaultValue !== undefined) ? " data-default=\"" + escapeHtml(options.defaultValue) + "\"" : "";
63
64
  const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
64
- "onclick=\"resetChannelField('" + id + "')\">&#8635;</button>" : "";
65
+ "data-click-action=\"" + ACTIONS.resetChannelField + "\" data-field-id=\"" + id + "\">&#8635;</button>" : "";
65
66
  lines.push("<div class=\"form-row" + modifiedClass + "\">");
66
67
  lines.push(modifiedDot + "<label for=\"" + id + "\">" + label + "</label>");
67
68
  lines.push("<input class=\"form-input\" type=\"" + inputType + "\" id=\"" + id + "\" name=\"" + name + "\"" + required + listAttr + pattern +
@@ -97,7 +98,7 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true,
97
98
  const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
98
99
  const defaultAttr = (defaultProfile !== undefined) ? " data-default=\"" + escapeHtml(defaultProfile) + "\"" : "";
99
100
  const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
100
- "onclick=\"resetChannelField('" + id + "')\">&#8635;</button>" : "";
101
+ "data-click-action=\"" + ACTIONS.resetChannelField + "\" data-field-id=\"" + id + "\">&#8635;</button>" : "";
101
102
  // Helper to generate option elements for a profile.
102
103
  const renderOption = (profile) => {
103
104
  const selected = (profile.name === selectedProfile) ? " selected" : "";
@@ -154,7 +155,7 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true,
154
155
  if (showHint) {
155
156
  lines.push("<div class=\"hint\">Autodetect uses predefined profiles for known sites. If video doesn't play or fullscreen fails, " +
156
157
  "try experimenting with different profiles. ");
157
- lines.push("<a href=\"#\" onclick=\"toggleProfileReference(); return false;\">View profile reference</a></div>");
158
+ lines.push("<a href=\"#\" data-click-action=\"" + ACTIONS.toggleProfileReference + "\" data-click-prevent-default>View profile reference</a></div>");
158
159
  }
159
160
  return lines;
160
161
  }
@@ -170,7 +171,8 @@ function generateProfileReference(profiles) {
170
171
  lines.push("<div id=\"profile-reference\" class=\"profile-reference\" style=\"display: none;\">");
171
172
  lines.push("<div class=\"profile-reference-header\">");
172
173
  lines.push("<h3>Profile Reference</h3>");
173
- lines.push("<button type=\"button\" class=\"profile-reference-close\" aria-label=\"Close\" onclick=\"toggleProfileReference()\">\u2715</button>");
174
+ lines.push("<button type=\"button\" class=\"profile-reference-close\" aria-label=\"Close\" data-click-action=\"" +
175
+ ACTIONS.toggleProfileReference + "\">\u2715</button>");
174
176
  lines.push("</div>");
175
177
  lines.push("<p class=\"reference-intro\">Profiles configure how PrismCast interacts with different video players. Autodetect uses predefined ");
176
178
  lines.push("profiles for known sites. If video doesn't play or fullscreen fails, use this reference to experiment with different profiles.</p>");
@@ -284,9 +286,8 @@ function generateAdvancedFields(idPrefix, options = {}) {
284
286
  const { channelNumberValue = "", channelSelectorValue = "", guideTitleValue = "", hdhrEnabled = true, logoUrlValue = "", showHints = true, stationIdValue = "", tagsValue = "" } = options;
285
287
  const lines = [];
286
288
  // Advanced fields toggle.
287
- lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
288
- "-advanced').classList.toggle('show'); this.textContent = this.textContent === 'Show Advanced Options' ? " +
289
- "'Hide Advanced Options' : 'Show Advanced Options';\">Show Advanced Options</div>");
289
+ lines.push("<div class=\"advanced-toggle\" data-click-action=\"" + ACTIONS.toggleAdvancedFields + "\" data-advanced-id=\"" + idPrefix +
290
+ "-advanced\">Show Advanced Options</div>");
290
291
  lines.push("<div id=\"" + idPrefix + "-advanced\" class=\"advanced-fields\">");
291
292
  // Station ID.
292
293
  const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
@@ -335,7 +336,7 @@ function generateAdvancedFields(idPrefix, options = {}) {
335
336
  const checked = currentTags.some((t) => tagsMatch(t, tag)) ? " checked" : "";
336
337
  lines.push("<label class=\"tag-checkbox-label\">" +
337
338
  "<input type=\"checkbox\" class=\"tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"" + checked +
338
- " onchange=\"updateTagsHidden(this.closest('.form-row'))\">" +
339
+ " data-change-action=\"" + ACTIONS.updateTagsHidden + "\">" +
339
340
  "<span class=\"tag-badge\">" + escapeHtml(tag) + "</span></label>");
340
341
  }
341
342
  lines.push("</div>");
@@ -433,13 +434,14 @@ export function generateTagFilterContent() {
433
434
  const vocabulary = getActiveTagVocabulary();
434
435
  const lines = [];
435
436
  for (const tag of vocabulary) {
436
- lines.push("<label class=\"provider-option\" onclick=\"event.stopPropagation()\">" +
437
- "<input type=\"checkbox\" class=\"tag-filter-checkbox\" data-tag=\"" + escapeHtml(tag) + "\" checked onchange=\"applyTagColumnFilter()\"> " +
438
- escapeHtml(tag) + "</label>");
437
+ lines.push("<label class=\"provider-option\" data-click-stop-propagation>" +
438
+ "<input type=\"checkbox\" class=\"tag-filter-checkbox\" data-tag=\"" + escapeHtml(tag) + "\" checked data-change-action=\"" +
439
+ ACTIONS.applyTagColumnFilter + "\"> " + escapeHtml(tag) + "</label>");
439
440
  }
440
441
  if (vocabulary.length > 0) {
441
442
  lines.push("<div class=\"dropdown-divider\"></div>");
442
- lines.push("<div class=\"dropdown-item\" id=\"tag-filter-toggle\" onclick=\"event.stopPropagation(); toggleTagColumnFilter()\">Show None</div>");
443
+ lines.push("<div class=\"dropdown-item\" id=\"tag-filter-toggle\" data-click-action=\"" + ACTIONS.toggleTagColumnFilter +
444
+ "\" data-click-stop-propagation>Show None</div>");
443
445
  }
444
446
  return lines.join("");
445
447
  }
@@ -455,26 +457,27 @@ export function generateTagManagerBody() {
455
457
  const tagListItems = [];
456
458
  for (const tag of vocabulary) {
457
459
  tagListItems.push("<div class=\"tag-manager-item\" data-tag=\"" + escapeHtml(tag) + "\">" +
458
- "<span class=\"tag-badge tag-editable\" title=\"Click to rename\" onclick=\"startTagRename(this, '" + escapeHtml(tag) +
459
- "')\">" + escapeHtml(tag) + "</span>" +
460
- "<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete tag\" onclick=\"deleteTag('" + escapeHtml(tag) +
461
- "')\">" + ICON_DELETE + "</button></div>");
460
+ "<span class=\"tag-badge tag-editable\" title=\"Click to rename\" data-click-action=\"" + ACTIONS.startTagRename +
461
+ "\" data-tag-name=\"" + escapeHtml(tag) + "\">" + escapeHtml(tag) + "</span>" +
462
+ "<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete tag\" data-click-action=\"" +
463
+ ACTIONS.deleteTag + "\" data-tag-name=\"" + escapeHtml(tag) +
464
+ "\">" + ICON_DELETE + "</button></div>");
462
465
  }
463
466
  // Deleted predefined tags section - show restore option for tags the user has deleted.
464
467
  const deletedItems = [];
465
468
  for (const tag of registry.deletedTags) {
466
469
  deletedItems.push("<div class=\"tag-manager-item tag-deleted\" data-tag=\"" + escapeHtml(tag) + "\">" +
467
470
  "<span class=\"tag-badge tag-badge-deleted\">" + escapeHtml(tag) + "</span> <span class=\"tag-annotation\">(deleted)</span>" +
468
- "<button type=\"button\" class=\"btn-icon\" title=\"Restore tag\" onclick=\"restoreTag('" + escapeHtml(tag) +
469
- "')\">" + ICON_REVERT + "</button></div>");
471
+ "<button type=\"button\" class=\"btn-icon\" title=\"Restore tag\" data-click-action=\"" + ACTIONS.restoreTag + "\" data-tag-name=\"" + escapeHtml(tag) +
472
+ "\">" + ICON_REVERT + "</button></div>");
470
473
  }
471
474
  return "<div class=\"tag-manager\">" +
472
475
  "<p class=\"wizard-hint\">Create and manage the tag vocabulary. Tags can be assigned to channels via the channel table " +
473
476
  "for playlist filtering and organization.</p>" +
474
477
  "<div class=\"tag-manager-add\">" +
475
478
  "<input type=\"text\" id=\"tag-manager-input\" placeholder=\"New tag name\" maxlength=\"30\" " +
476
- "pattern=\"[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
477
- "<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createTag()\">Add</button>" +
479
+ "pattern=\"[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?\" data-keydown-action=\"" + ACTIONS.createTagOnEnter + "\">" +
480
+ "<button type=\"button\" class=\"btn btn-primary btn-sm\" data-click-action=\"" + ACTIONS.createTag + "\">Add</button>" +
478
481
  "</div>" +
479
482
  "<div id=\"tag-manager-error\" class=\"wizard-error\" style=\"display: none;\"></div>" +
480
483
  "<div id=\"tag-manager-list\" class=\"tag-manager-list\">" +
@@ -492,11 +495,11 @@ function generateTagManagementModal() {
492
495
  return generateWizardModal({
493
496
  body: generateTagManagerBody(),
494
497
  buttons: [
495
- { label: "Done", onclick: "closeTagManager()", position: "right", variant: "primary" }
498
+ { action: ACTIONS.closeTagManager, label: "Done", position: "right", variant: "primary" }
496
499
  ],
500
+ closeAction: ACTIONS.closeTagManager,
497
501
  id: "tag-manager-modal",
498
502
  maxWidth: "420px",
499
- onClose: "closeTagManager()",
500
503
  title: "Manage Tags"
501
504
  });
502
505
  }
@@ -514,7 +517,7 @@ function generateBrowseModal() {
514
517
  buttons: [
515
518
  { id: "browse-back", label: "Back", position: "left", role: "back", visible: false },
516
519
  { label: "Cancel", position: "right", role: "close" },
517
- { id: "browse-add-btn", label: "Apply", onclick: "submitBrowseChannels()", position: "right", variant: "primary", visible: false }
520
+ { action: ACTIONS.submitBrowseChannels, id: "browse-add-btn", label: "Apply", position: "right", variant: "primary", visible: false }
518
521
  ],
519
522
  contentId: "browse-content",
520
523
  dataBlocks: [
@@ -543,9 +546,9 @@ function generateSetupWizardModal() {
543
546
  return generateWizardModal({
544
547
  buttons: [
545
548
  { id: "setup-back", label: "Back", position: "left", role: "back", visible: false },
546
- { id: "setup-skip", label: "Skip Setup", onclick: "skipSetup()", position: "right" },
549
+ { action: ACTIONS.skipSetup, id: "setup-skip", label: "Skip Setup", position: "right" },
547
550
  { id: "setup-next", label: "Next", position: "right", role: "next", variant: "primary" },
548
- { id: "setup-finish", label: "Finish", onclick: "finishSetup()", position: "right", variant: "primary", visible: false }
551
+ { action: ACTIONS.finishSetup, id: "setup-finish", label: "Finish", position: "right", variant: "primary", visible: false }
549
552
  ],
550
553
  contentId: "setup-content",
551
554
  dataAttributes: { "setup-completed": CONFIG.channels.setupCompleted ? "true" : "false" },
@@ -629,7 +632,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
629
632
  // to remove them from the DOM.
630
633
  const currentSelection = resolveServiceKey(key);
631
634
  displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) +
632
- "\" title=\"Choose which streaming service delivers this channel\" onchange=\"updateServiceSelection(this)\"" + contentHidden + ">");
635
+ "\" title=\"Choose which streaming service delivers this channel\" data-change-action=\"" + ACTIONS.updateServiceSelection + "\"" + contentHidden + ">");
633
636
  for (const variant of serviceGroup.variants) {
634
637
  const selected = (variant.key === currentSelection) ? " selected" : "";
635
638
  const optionHidden = !isServiceTagEnabled(variant.tag) ? " hidden" : "";
@@ -640,7 +643,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
640
643
  }
641
644
  else {
642
645
  // Single-service: wrap the service name in a span so client-side JS can toggle it with the no-service label. Uses profile-aware label resolution so
643
- // channels with explicit profile assignments show the profile's service name rather than the built-in name for the URL domain.
646
+ // channels with explicit profile assignments show the profile's service name rather than the builtin name for the URL domain.
644
647
  displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
645
648
  escapeHtml(getChannelServiceLabel(channel)) + "</span>");
646
649
  }
@@ -649,16 +652,17 @@ export function generateChannelRowHtml(key, profiles, entry) {
649
652
  const cellKey = escapeHtml(key);
650
653
  displayLines.push("<td class=\"col-chnum editable-cell\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "channelNumber")) +
651
654
  "\" data-field=\"channelNumber\" data-key=\"" + cellKey + "\" data-value=\"" + (channel.channelNumber ? escapeHtml(String(channel.channelNumber)) : "") +
652
- "\" onclick=\"startInlineEdit(this)\">" + (channel.channelNumber ? escapeHtml(String(channel.channelNumber)) : "<span class=\"text-muted\">&ndash;</span>") +
653
- "</td>");
655
+ "\" data-click-action=\"" + ACTIONS.startInlineEdit + "\">" +
656
+ (channel.channelNumber ? escapeHtml(String(channel.channelNumber)) : "<span class=\"text-muted\">&ndash;</span>") + "</td>");
654
657
  // HDHR column: inline checkbox for quick toggling. The checkbox submits changes via the toggleHdhr() client-side function.
655
658
  const hdhrCheckedAttr = getEffectiveHdhrEnabled(channel) ? " checked" : "";
656
659
  displayLines.push("<td class=\"col-hdhr\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "hdhrEnabled")) +
657
660
  "\"><input type=\"checkbox\" data-key=\"" + cellKey + "\"" + hdhrCheckedAttr +
658
- " onchange=\"toggleHdhr(this)\" title=\"Include in HDHomeRun/Plex lineup\"></td>");
661
+ " data-change-action=\"" + ACTIONS.toggleHdhr + "\" title=\"Include in HDHomeRun/Plex lineup\"></td>");
659
662
  displayLines.push("<td class=\"col-stationid editable-cell\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "stationId")) +
660
663
  "\" data-field=\"stationId\" data-key=\"" + cellKey + "\" data-value=\"" + (channel.stationId ? escapeHtml(channel.stationId) : "") +
661
- "\" onclick=\"startInlineEdit(this)\">" + (channel.stationId ? escapeHtml(channel.stationId) : "<span class=\"text-muted\">&ndash;</span>") + "</td>");
664
+ "\" data-click-action=\"" + ACTIONS.startInlineEdit + "\">" +
665
+ (channel.stationId ? escapeHtml(channel.stationId) : "<span class=\"text-muted\">&ndash;</span>") + "</td>");
662
666
  // Profile column: show explicit profile as-is, or the auto-resolved friendly name with "(auto)" suffix in muted style.
663
667
  const profileSortKey = escapeHtml(getChannelSortKey(channel, key, "profile"));
664
668
  if (channel.profile) {
@@ -684,7 +688,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
684
688
  "<span class=\"text-muted\">&ndash;</span>";
685
689
  displayLines.push("<td class=\"col-tags editable-cell dropdown\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "tags")) +
686
690
  "\" data-key=\"" + cellKey + "\" data-tags=\"" + escapeHtml(effectiveTags.join(",")) +
687
- "\" onclick=\"toggleInlineTagDropdown(this)\">" + tagsHtml + "</td>");
691
+ "\" data-click-action=\"" + ACTIONS.toggleInlineTagDropdown + "\">" + tagsHtml + "</td>");
688
692
  // Actions column with icon buttons. Five positions per row: Edit (always), Login/placeholder, Health/placeholder, context-sensitive, and Copy URL.
689
693
  displayLines.push("<td>");
690
694
  displayLines.push("<div class=\"btn-group\">");
@@ -693,17 +697,21 @@ export function generateChannelRowHtml(key, profiles, entry) {
693
697
  const variantKey = resolveServiceKey(key);
694
698
  const authDomain = getAuthDomainForChannel(variantKey);
695
699
  // Position 1: Edit (all channels).
696
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
697
- "')\">" + ICON_EDIT + "</button>");
700
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" " +
701
+ "data-click-action=\"" + ACTIONS.showEditForm + "\" data-channel-key=\"" + escapedKey + "\">" + ICON_EDIT + "</button>");
698
702
  // Position 2: Login for enabled channels (with domain auth color), placeholder for disabled predefined. Custom channels (user-defined, not predefined) skip
699
703
  // login coloring because they have no service concept.
700
704
  if (!isDisabled) {
701
705
  const authTimestamp = (isPredefined || isOverride) ? getDomainAuth(authDomain) : null;
702
706
  const loginColorClass = authTimestamp ? " health-success" : "";
703
- const loginTitle = authTimestamp ? "Verified " + formatTimeAgo(authTimestamp) : "Not yet verified";
707
+ // Lead the title with the action so users discover that the icon is interactive...the trailing fact reports the service-scoped verified state, since the
708
+ // auth domain is shared across every channel of the same service.
709
+ const serviceLabel = getChannelServiceLabel(channel);
710
+ const loginTitle = "Click to open this channel in PrismCast's Chrome to sign in or test. " + serviceLabel +
711
+ (authTimestamp ? " verified " + formatTimeAgo(authTimestamp) : " not yet verified") + ".";
704
712
  displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-login" + loginColorClass + "\" data-auth-domain=\"" +
705
- escapeHtml(authDomain) + "\" title=\"" + loginTitle + "\" aria-label=\"Login\" " +
706
- "onclick=\"startChannelLogin('" + escapedKey + "')\">" + ICON_LOGIN + "</button>");
713
+ escapeHtml(authDomain) + "\" title=\"" + escapeHtml(loginTitle) + "\" aria-label=\"Login\" " +
714
+ "data-click-action=\"" + ACTIONS.startChannelLogin + "\" data-channel-key=\"" + escapedKey + "\">" + ICON_LOGIN + "</button>");
707
715
  }
708
716
  else {
709
717
  displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
@@ -713,7 +721,8 @@ export function generateChannelRowHtml(key, profiles, entry) {
713
721
  const channelHealthResult = getChannelHealth(key, authDomain);
714
722
  const healthColorClass = (channelHealthResult?.status === "success") ? " health-success" : (channelHealthResult?.status === "failed") ? " health-failed" : "";
715
723
  const healthTitle = channelHealthResult ?
716
- (channelHealthResult.status === "success" ? "Succeeded " : "Failed ") + formatTimeAgo(channelHealthResult.timestamp) : "Not yet tuned";
724
+ "Last tune " + (channelHealthResult.status === "success" ? "succeeded " : "failed ") + formatTimeAgo(channelHealthResult.timestamp) + "." :
725
+ "Not yet tuned.";
717
726
  displayLines.push("<span class=\"btn-icon btn-icon-health" + healthColorClass + "\" title=\"" + healthTitle +
718
727
  "\" aria-label=\"Channel health\">" + ICON_HEALTH + "</span>");
719
728
  }
@@ -724,25 +733,27 @@ export function generateChannelRowHtml(key, profiles, entry) {
724
733
  // property overrides are independent concerns - a predefined channel can be disabled AND have customizations, and the toggle always reflects the visibility state.
725
734
  if (isPredefined) {
726
735
  if (isDisabled) {
727
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
728
- escapedKey + "', true)\">" + ICON_ENABLE + "</button>");
736
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" " +
737
+ "data-click-action=\"" + ACTIONS.togglePredefinedChannel + "\" data-channel-key=\"" + escapedKey + "\" data-enabled=\"true\">" + ICON_ENABLE + "</button>");
729
738
  }
730
739
  else {
731
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-disable\" title=\"Disable\" aria-label=\"Disable\" onclick=\"togglePredefinedChannel('" +
732
- escapedKey + "', false)\">" + ICON_DISABLE + "</button>");
740
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-disable\" title=\"Disable\" aria-label=\"Disable\" " +
741
+ "data-click-action=\"" + ACTIONS.togglePredefinedChannel + "\" data-channel-key=\"" + escapedKey + "\" data-enabled=\"false\">" + ICON_DISABLE + "</button>");
733
742
  }
734
743
  }
735
744
  else if (isUser) {
736
- displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" onclick=\"deleteChannel('" +
737
- escapedKey + "')\">" + ICON_DELETE + "</button>");
745
+ displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete\" aria-label=\"Delete\" " +
746
+ "data-click-action=\"" + ACTIONS.deleteChannel + "\" data-channel-key=\"" + escapedKey + "\">" + ICON_DELETE + "</button>");
738
747
  }
739
748
  // Position 5: Copy URL dropdown (all channels).
740
749
  displayLines.push("<div class=\"dropdown copy-dropdown\">");
741
750
  displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-copy\" title=\"Copy stream URL\" aria-label=\"Copy stream URL\" " +
742
- "onclick=\"toggleDropdown(this)\">" + ICON_COPY + "</button>");
751
+ "data-click-action=\"" + ACTIONS.toggleDropdown + "\">" + ICON_COPY + "</button>");
743
752
  displayLines.push("<div class=\"dropdown-menu copy-url-menu\">");
744
- displayLines.push("<div class=\"dropdown-item\" onclick=\"copyStreamUrl('hls', '" + escapedKey + "')\">Copy HLS URL</div>");
745
- displayLines.push("<div class=\"dropdown-item\" onclick=\"copyStreamUrl('mpegts', '" + escapedKey + "')\">Copy MPEG-TS URL</div>");
753
+ displayLines.push("<div class=\"dropdown-item\" data-click-action=\"" + ACTIONS.copyStreamUrl + "\" data-protocol=\"hls\" data-channel-key=\"" +
754
+ escapedKey + "\">Copy HLS URL</div>");
755
+ displayLines.push("<div class=\"dropdown-item\" data-click-action=\"" + ACTIONS.copyStreamUrl + "\" data-protocol=\"mpegts\" data-channel-key=\"" +
756
+ escapedKey + "\">Copy MPEG-TS URL</div>");
746
757
  displayLines.push("</div>");
747
758
  displayLines.push("</div>");
748
759
  displayLines.push("</div>");
@@ -756,7 +767,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
756
767
  editLines.push("<td colspan=\"" + String(TOTAL_COLUMN_COUNT) + "\">");
757
768
  editLines.push("<div class=\"channel-form\" style=\"margin: 0;\">");
758
769
  editLines.push("<h3>Edit Channel: " + escapedKey + "</h3>");
759
- editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
770
+ editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" data-submit-action=\"" + ACTIONS.submitChannelFormEdit + "\" data-submit-prevent-default>");
760
771
  editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
761
772
  editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapedKey + "\">");
762
773
  // For override channels, fetch the customization provenance once. The accessor returns which fields the user has explicitly customized (across canonical and
@@ -831,10 +842,11 @@ export function generateChannelRowHtml(key, profiles, entry) {
831
842
  // Form buttons.
832
843
  editLines.push("<div class=\"form-buttons\">");
833
844
  editLines.push("<button type=\"submit\" class=\"btn btn-primary\">Save Changes</button>");
834
- editLines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"hideEditForm('" + escapedKey + "')\">Cancel</button>");
845
+ editLines.push("<button type=\"button\" class=\"btn btn-secondary\" data-click-action=\"" + ACTIONS.hideEditForm +
846
+ "\" data-channel-key=\"" + escapedKey + "\">Cancel</button>");
835
847
  if (isOverride) {
836
- editLines.push("<button type=\"button\" class=\"btn btn-secondary btn-revert\" onclick=\"revertChannel('" + escapedKey +
837
- "')\">Revert to Defaults</button>");
848
+ editLines.push("<button type=\"button\" class=\"btn btn-secondary btn-revert\" data-click-action=\"" + ACTIONS.revertChannel + "\" data-channel-key=\"" + escapedKey +
849
+ "\">Revert to Defaults</button>");
838
850
  }
839
851
  editLines.push("</div>");
840
852
  editLines.push("</form>");
@@ -974,7 +986,7 @@ export function generateServiceFilterToolbar() {
974
986
  lines.push("<div class=\"dropdown provider-dropdown\">");
975
987
  const buttonText = hasFilter ? "Filtered" : "All Services";
976
988
  lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-icon-btn\" id=\"provider-filter-btn\" " +
977
- "title=\"Filter channels by streaming service\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " &#9662;</button>");
989
+ "title=\"Filter channels by streaming service\" data-click-action=\"" + ACTIONS.toggleDropdown + "\">" + ICON_FILTER + " " + buttonText + " &#9662;</button>");
978
990
  lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
979
991
  for (const tagInfo of allTags) {
980
992
  const isDirectTag = tagInfo.tag === "direct";
@@ -983,7 +995,7 @@ export function generateServiceFilterToolbar() {
983
995
  const disabledAttr = isDirectTag ? " disabled" : "";
984
996
  lines.push("<label class=\"provider-option\">");
985
997
  lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
986
- " onchange=\"toggleServiceTag(this)\"> " + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
998
+ " data-change-action=\"" + ACTIONS.toggleServiceTag + "\"> " + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
987
999
  lines.push("</label>");
988
1000
  }
989
1001
  lines.push("</div>");
@@ -1000,7 +1012,7 @@ export function generateServiceFilterToolbar() {
1000
1012
  lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" +
1001
1013
  serviceDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
1002
1014
  "<button type=\"button\" class=\"chip-close\" title=\"Remove " + escapeHtml(displayName) + " from filter\" aria-label=\"Remove " +
1003
- escapeHtml(displayName) + "\" onclick=\"removeServiceChip('" + escapeHtml(tag) + "')\">&times;</button></span>");
1015
+ escapeHtml(displayName) + "\" data-click-action=\"" + ACTIONS.removeServiceChip + "\" data-tag-name=\"" + escapeHtml(tag) + "\">&times;</button></span>");
1004
1016
  }
1005
1017
  }
1006
1018
  lines.push("</div>");
@@ -1039,7 +1051,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1039
1051
  // Manage Channels dropdown - primary channel creation and setup actions.
1040
1052
  lines.push("<div class=\"dropdown\">");
1041
1053
  lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-icon-btn\" title=\"Add, browse, or set up channels\" " +
1042
- "onclick=\"toggleDropdown(this)\">" + ICON_MANAGE + " Manage Channels &#9662;</button>");
1054
+ "data-click-action=\"" + ACTIONS.toggleDropdown + "\">" + ICON_MANAGE + " Manage Channels &#9662;</button>");
1043
1055
  lines.push("<div class=\"dropdown-menu\">");
1044
1056
  // Dropdown item icons: tinted variants of the shared icons for visual differentiation in menu items. Each uses a distinct color so items are scannable at
1045
1057
  // a glance. These are local to this function because the tint colors are context-specific to this dropdown.
@@ -1054,42 +1066,42 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1054
1066
  const TINTED_TAG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b59b6\" stroke-width=\"1.5\" " +
1055
1067
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1.5 8.7V2.5a1 1 0 011-1h6.2a1 1 0 01.7.3l5.1 5.1a1 1 0 010 1.4l-5.5 5.5a1 " +
1056
1068
  "1 0 01-1.4 0L1.8 9.4a1 1 0 01-.3-.7z\"/><circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>";
1057
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); " +
1058
- "document.getElementById('add-channel-form').style.display='block';\">" + TINTED_ADD + " Add Channel</div>");
1059
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openBrowseModal()\">" + TINTED_BROWSE +
1069
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" data-click-action=\"" + ACTIONS.showAddChannelForm + "\" data-click-close-dropdown>" + TINTED_ADD +
1070
+ " Add Channel</div>");
1071
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" data-click-action=\"" + ACTIONS.openBrowseModal + "\" data-click-close-dropdown>" + TINTED_BROWSE +
1060
1072
  " Browse Service Channels</div>");
1061
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openTagManager()\">" + TINTED_TAG +
1073
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" data-click-action=\"" + ACTIONS.openTagManager + "\" data-click-close-dropdown>" + TINTED_TAG +
1062
1074
  " Manage Tags</div>");
1063
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openSetupWizard()\">" + TINTED_SETUP +
1075
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" data-click-action=\"" + ACTIONS.openSetupWizard + "\" data-click-close-dropdown>" + TINTED_SETUP +
1064
1076
  " Service Setup</div>");
1065
1077
  lines.push("</div>");
1066
1078
  lines.push("</div>");
1067
1079
  // Import / Export dropdown - data I/O operations.
1068
1080
  lines.push("<div class=\"dropdown\">");
1069
1081
  lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Import or export channel data\" " +
1070
- "onclick=\"toggleDropdown(this)\">" + ICON_TRANSFER + " Import / Export &#9662;</button>");
1082
+ "data-click-action=\"" + ACTIONS.toggleDropdown + "\">" + ICON_TRANSFER + " Import / Export &#9662;</button>");
1071
1083
  lines.push("<div class=\"dropdown-menu\">");
1072
1084
  const TINTED_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
1073
1085
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 2v8M5 7l3 3 3-3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
1074
1086
  const TINTED_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
1075
1087
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 10V2M5 5l3-3 3 3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
1076
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-channels-file').click()\">" +
1088
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" data-click-action=\"" + ACTIONS.triggerChannelsImport + "\" data-click-close-dropdown>" +
1077
1089
  TINTED_IMPORT + " Import Channels (JSON)</div>");
1078
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-m3u-file').click()\">" +
1090
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" data-click-action=\"" + ACTIONS.triggerM3uImport + "\" data-click-close-dropdown>" +
1079
1091
  TINTED_IMPORT + " Import M3U Playlist</div>");
1080
1092
  lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates on M3U import</label>");
1081
1093
  lines.push("<div class=\"dropdown-divider\"></div>");
1082
- lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); exportChannels()\">" +
1094
+ lines.push("<div class=\"dropdown-item dropdown-item-icon\" data-click-action=\"" + ACTIONS.exportChannels + "\" data-click-close-dropdown>" +
1083
1095
  TINTED_EXPORT + " Export Channels (JSON)</div>");
1084
1096
  lines.push("</div>");
1085
1097
  lines.push("</div>");
1086
- lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" onchange=\"importM3U(this)\">");
1098
+ lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" data-change-action=\"" + ACTIONS.importM3u + "\">");
1087
1099
  // Visible column set, used by Quick Actions to gate column-dependent options and by the table header to set initial hide classes.
1088
1100
  const visibleCols = new Set(CONFIG.channels.visibleColumns);
1089
1101
  // Quick Actions dropdown - bulk operations for predefined channels.
1090
1102
  lines.push("<div class=\"dropdown quick-actions-dropdown\">");
1091
1103
  lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Bulk operations for predefined channels\" " +
1092
- "onclick=\"toggleDropdown(this)\">" + ICON_BOLT + " Quick Actions &#9662;</button>");
1104
+ "data-click-action=\"" + ACTIONS.toggleDropdown + "\">" + ICON_BOLT + " Quick Actions &#9662;</button>");
1093
1105
  lines.push("<div class=\"dropdown-menu\">");
1094
1106
  // Compute initial toggle counts for predefined channel scopes. The server is the single source of truth - the client renders what we return here.
1095
1107
  const scopeCounts = getPredefinedScopeCounts();
@@ -1102,8 +1114,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1102
1114
  ];
1103
1115
  for (const s of scopes) {
1104
1116
  const checked = (s.count === s.total) ? " checked" : "";
1105
- lines.push("<label class=\"provider-option\" onclick=\"event.preventDefault(); bulkTogglePredefined(!this.querySelector('input').checked, '" + s.scope +
1106
- "')\">" + "<input type=\"checkbox\" class=\"scope-toggle\" data-scope=\"" + s.scope + "\"" + checked + "> " +
1117
+ lines.push("<label class=\"provider-option\" data-click-action=\"" + ACTIONS.bulkTogglePredefined + "\" data-scope=\"" + s.scope + "\" data-click-prevent-default>" +
1118
+ "<input type=\"checkbox\" class=\"scope-toggle\" data-scope=\"" + s.scope + "\"" + checked + "> " +
1107
1119
  s.label + "<span class=\"quick-action-count\" data-scope=\"" + s.scope + "\" data-enabled=\"" + String(s.count) + "\" data-total=\"" + String(s.total) +
1108
1120
  "\">" + String(s.count) + " of " + String(s.total) + " enabled</span></label>");
1109
1121
  }
@@ -1115,7 +1127,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1115
1127
  lines.push("<div class=\"bulk-assign-row\">");
1116
1128
  lines.push("<span>Set all channels to:</span>");
1117
1129
  lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-service channels to this service\" " +
1118
- "onchange=\"if(this.value) { dropdowns.close(); bulkAssignService(this.value); this.value = ''; }\">");
1130
+ "data-change-action=\"" + ACTIONS.bulkAssignService + "\">");
1119
1131
  lines.push("<option value=\"\">Select service</option>");
1120
1132
  for (const tagInfo of allTags) {
1121
1133
  const hidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
@@ -1133,8 +1145,9 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1133
1145
  lines.push("<div class=\"bulk-assign-row\">");
1134
1146
  lines.push("<span>Auto-number from:</span>");
1135
1147
  lines.push("<input type=\"number\" id=\"auto-number-start\" class=\"auto-number-input\" value=\"1\" min=\"0\" max=\"99999\" " +
1136
- "placeholder=\"Clear\" onclick=\"event.stopPropagation()\">");
1137
- lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"dropdowns.close(); autoNumberChannels()\">Apply</button>");
1148
+ "placeholder=\"Clear\" data-click-stop-propagation>");
1149
+ lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" data-click-action=\"" + ACTIONS.autoNumberChannels +
1150
+ "\" data-click-close-dropdown>Apply</button>");
1138
1151
  lines.push("</div>");
1139
1152
  lines.push("</div>");
1140
1153
  // HDHR bulk toggle - tri-state checkbox to enable/disable all channels for the HDHomeRun lineup. Visible when the HDHR column is shown.
@@ -1143,7 +1156,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1143
1156
  const hdhrCheckedAttr = (hdhrCounts.enabled === hdhrCounts.total) ? " checked" : "";
1144
1157
  const hdhrIndeterminate = ((hdhrCounts.enabled > 0) && (hdhrCounts.enabled < hdhrCounts.total)) ? " data-indeterminate=\"true\"" : "";
1145
1158
  lines.push("<div id=\"quick-action-hdhr\"" + hdhrVisible + ">");
1146
- lines.push("<label class=\"provider-option\" onclick=\"event.preventDefault(); bulkToggleHdhr()\">");
1159
+ lines.push("<label class=\"provider-option\" data-click-action=\"" + ACTIONS.bulkToggleHdhr + "\" data-click-prevent-default>");
1147
1160
  lines.push("<input type=\"checkbox\" id=\"hdhr-bulk-toggle\"" + hdhrCheckedAttr + hdhrIndeterminate + "> ");
1148
1161
  lines.push("Include all in HDHR/Plex");
1149
1162
  lines.push("<span class=\"quick-action-count\" id=\"hdhr-bulk-count\">" + String(hdhrCounts.enabled) + " of " + String(hdhrCounts.total) + "</span>");
@@ -1177,8 +1190,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1177
1190
  const count = tagCountMap.get(tag) ?? 0;
1178
1191
  const checkedAttr = (count === totalEnabled) ? " checked" : "";
1179
1192
  const indeterminate = ((count > 0) && (count < totalEnabled)) ? " data-indeterminate=\"true\"" : "";
1180
- lines.push("<label class=\"provider-option\" onclick=\"event.preventDefault(); bulkToggleTag('" + escapeHtml(tag) +
1181
- "', !this.querySelector('input').checked)\">" +
1193
+ lines.push("<label class=\"provider-option\" data-click-action=\"" + ACTIONS.bulkToggleTag +
1194
+ "\" data-tag-name=\"" + escapeHtml(tag) + "\" data-click-prevent-default>" +
1182
1195
  "<input type=\"checkbox\" class=\"tag-bulk-toggle\" data-tag=\"" + escapeHtml(tag) + "\"" + checkedAttr + indeterminate + "> " +
1183
1196
  escapeHtml(tag) +
1184
1197
  "<span class=\"quick-action-count\" data-tag-count=\"" + escapeHtml(tag) + "\">" +
@@ -1232,7 +1245,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1232
1245
  const addFormVisible = (showAddForm === true) || (formErrors && formErrors.has("key") && !editingChannelKey);
1233
1246
  lines.push("<div id=\"add-channel-form\" class=\"channel-form\" style=\"display: " + (addFormVisible ? "block" : "none") + ";\">");
1234
1247
  lines.push("<h3>Add New Channel</h3>");
1235
- lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
1248
+ lines.push("<form id=\"add-channel-form-el\" data-submit-action=\"" + ACTIONS.submitChannelFormAdd + "\" data-submit-prevent-default>");
1236
1249
  lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
1237
1250
  // Service pills. Clicking a service auto-fills the URL field, which triggers the existing URL-change infrastructure (datalist population, profile
1238
1251
  // resolution). The pills use serviceDisplaySpan for icon rendering via the shared processServiceDisplays path. Guide-grid services fill with their
@@ -1247,7 +1260,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1247
1260
  }
1248
1261
  const pillUrl = addFormGuideUrls[tagInfo.tag] ?? (tagInfo.domain ? "https://" + tagInfo.domain + "/" : "");
1249
1262
  lines.push("<button type=\"button\" class=\"provider-pill\" data-slug=\"" + escapeHtml(tagInfo.tag) + "\" data-url=\"" + escapeHtml(pillUrl) +
1250
- "\" onclick=\"selectServicePill(this)\">" + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
1263
+ "\" data-click-action=\"" + ACTIONS.selectServicePill + "\">" + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
1251
1264
  }
1252
1265
  lines.push("</div>");
1253
1266
  lines.push("</div>");
@@ -1287,7 +1300,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1287
1300
  // Form buttons.
1288
1301
  lines.push("<div class=\"form-buttons\">");
1289
1302
  lines.push("<button type=\"submit\" class=\"btn btn-primary\">Add Channel</button>");
1290
- lines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"document.getElementById('add-channel-form').style.display='none';\">Cancel</button>");
1303
+ lines.push("<button type=\"button\" class=\"btn btn-secondary\" data-click-action=\"" + ACTIONS.hideAddChannelForm + "\">Cancel</button>");
1291
1304
  lines.push("</div>");
1292
1305
  lines.push("</form>");
1293
1306
  // Close the add-channel-form wrapper opened at the top of this builder.
@@ -1333,7 +1346,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1333
1346
  const isActive = (sortField === hdr.field);
1334
1347
  const activeIndicator = isActive ? ((sortDir === "asc") ? " &#9650;" : " &#9660;") : "";
1335
1348
  lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
1336
- "\" onclick=\"channelTable.sort('" + hdr.field + "')\">");
1349
+ "\" data-click-action=\"" + ACTIONS.channelTableSort + "\" data-field=\"" + hdr.field + "\">");
1337
1350
  lines.push("<span class=\"sort-label\">" + hdr.label + activeIndicator + "</span>");
1338
1351
  // Tags header: additional filter dropdown alongside the sort label. The dropdown is a client-side-only view filter (transient, not persisted) that
1339
1352
  // shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() - the single source of truth
@@ -1341,7 +1354,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1341
1354
  if (hdr.field === "tags") {
1342
1355
  lines.push("<div class=\"dropdown tag-filter-dropdown\" style=\"display: inline;\">");
1343
1356
  lines.push("<button type=\"button\" class=\"btn-icon btn-tag-filter\" title=\"Filter by tag\" " +
1344
- "onclick=\"event.stopPropagation(); toggleDropdown(this)\">" + ICON_FILTER + "</button>");
1357
+ "data-click-action=\"" + ACTIONS.toggleDropdown + "\" data-click-stop-propagation>" + ICON_FILTER + "</button>");
1345
1358
  lines.push("<div class=\"dropdown-menu\" id=\"tag-filter-menu\">" + generateTagFilterContent() + "</div>");
1346
1359
  lines.push("</div>");
1347
1360
  // Playlist hint icon. Hidden by default, shown by applyTagColumnFilter() when the filter is active. Clicking opens a popover with the playlist URL
@@ -1349,7 +1362,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1349
1362
  lines.push("<div class=\"dropdown\" style=\"display: inline;\">");
1350
1363
  lines.push("<button type=\"button\" class=\"btn-icon btn-playlist-hint\" id=\"playlist-hint-btn\" " +
1351
1364
  "title=\"Playlist URL for this filter\" style=\"display: none;\" " +
1352
- "onclick=\"event.stopPropagation(); showPlaylistHint(this)\">" + ICON_LINK + "</button>");
1365
+ "data-click-action=\"" + ACTIONS.showPlaylistHint + "\" data-click-stop-propagation>" + ICON_LINK + "</button>");
1353
1366
  lines.push("<div class=\"dropdown-menu playlist-hint-menu\"></div>");
1354
1367
  lines.push("</div>");
1355
1368
  }
@@ -1359,15 +1372,15 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1359
1372
  lines.push("<th class=\"col-actions\"><span>Actions</span>");
1360
1373
  lines.push("<div class=\"dropdown column-picker\">");
1361
1374
  lines.push("<button type=\"button\" class=\"btn-icon btn-col-picker\" title=\"Table options\" aria-label=\"Table options\" " +
1362
- "onclick=\"toggleDropdown(this)\">&#8942;</button>");
1375
+ "data-click-action=\"" + ACTIONS.toggleDropdown + "\">&#8942;</button>");
1363
1376
  lines.push("<div class=\"dropdown-menu column-picker-menu\">");
1364
- lines.push("<label class=\"provider-option\"><input type=\"checkbox\" id=\"show-disabled-toggle\" onchange=\"toggleDisabledVisibility()\"> " +
1377
+ lines.push("<label class=\"provider-option\"><input type=\"checkbox\" id=\"show-disabled-toggle\" data-change-action=\"" + ACTIONS.toggleDisabledVisibility + "\"> " +
1365
1378
  "Show disabled channels</label>");
1366
1379
  lines.push("<div class=\"dropdown-divider\"></div>");
1367
1380
  for (const col of OPTIONAL_COLUMNS) {
1368
1381
  const checked = visibleCols.has(col.field) ? " checked" : "";
1369
1382
  lines.push("<label class=\"provider-option\"><input type=\"checkbox\" data-col-class=\"" + col.cssClass + "\" data-col-field=\"" + col.field +
1370
- "\" onchange=\"toggleColumn(this)\"" + checked + "> " + col.label + "</label>");
1383
+ "\" data-change-action=\"" + ACTIONS.toggleColumn + "\"" + checked + "> " + col.label + "</label>");
1371
1384
  }
1372
1385
  lines.push("</div>");
1373
1386
  lines.push("</div>");
@@ -1403,7 +1416,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
1403
1416
  const vocabulary = getActiveTagVocabulary();
1404
1417
  lines.push("<div id=\"inline-tag-portal\" class=\"dropdown-menu inline-tag-menu\">");
1405
1418
  for (const tag of vocabulary) {
1406
- lines.push("<label class=\"provider-option\" onclick=\"event.stopPropagation()\">" +
1419
+ lines.push("<label class=\"provider-option\" data-click-stop-propagation>" +
1407
1420
  "<input type=\"checkbox\" class=\"inline-tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"> " + escapeHtml(tag) + "</label>");
1408
1421
  }
1409
1422
  lines.push("</div>");