prismcast 1.8.0 → 1.10.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 (392) hide show
  1. package/README.md +6 -6
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +41 -13
  4. package/dist/app.js.map +1 -1
  5. package/dist/browser/cdp.d.ts +4 -6
  6. package/dist/browser/cdp.js +18 -22
  7. package/dist/browser/cdp.js.map +1 -1
  8. package/dist/browser/channelSelection.d.ts +41 -11
  9. package/dist/browser/channelSelection.js +75 -24
  10. package/dist/browser/channelSelection.js.map +1 -1
  11. package/dist/browser/display.d.ts +1 -1
  12. package/dist/browser/index.d.ts +8 -8
  13. package/dist/browser/index.js +44 -45
  14. package/dist/browser/index.js.map +1 -1
  15. package/dist/browser/login.d.ts +2 -2
  16. package/dist/browser/login.js +1 -1
  17. package/dist/browser/manifestInterceptor.d.ts +77 -0
  18. package/dist/browser/manifestInterceptor.js +323 -0
  19. package/dist/browser/manifestInterceptor.js.map +1 -0
  20. package/dist/browser/precaching.d.ts +1 -1
  21. package/dist/browser/precaching.js +23 -23
  22. package/dist/browser/precaching.js.map +1 -1
  23. package/dist/browser/tuning/comcastPolymer.d.ts +1 -1
  24. package/dist/browser/tuning/cox.d.ts +1 -1
  25. package/dist/browser/tuning/directv.d.ts +1 -1
  26. package/dist/browser/tuning/directv.js +65 -69
  27. package/dist/browser/tuning/directv.js.map +1 -1
  28. package/dist/browser/tuning/fox.d.ts +1 -1
  29. package/dist/browser/tuning/fox.js +172 -15
  30. package/dist/browser/tuning/fox.js.map +1 -1
  31. package/dist/browser/tuning/hbo.d.ts +1 -1
  32. package/dist/browser/tuning/hbo.js +7 -7
  33. package/dist/browser/tuning/hbo.js.map +1 -1
  34. package/dist/browser/tuning/hulu.d.ts +1 -1
  35. package/dist/browser/tuning/hulu.js +120 -113
  36. package/dist/browser/tuning/hulu.js.map +1 -1
  37. package/dist/browser/tuning/shared.d.ts +3 -3
  38. package/dist/browser/tuning/shared.js +5 -5
  39. package/dist/browser/tuning/sling.d.ts +1 -1
  40. package/dist/browser/tuning/sling.js +30 -30
  41. package/dist/browser/tuning/sling.js.map +1 -1
  42. package/dist/browser/tuning/spectrum.d.ts +1 -1
  43. package/dist/browser/tuning/spectrum.js +21 -22
  44. package/dist/browser/tuning/spectrum.js.map +1 -1
  45. package/dist/browser/tuning/thumbnailRow.d.ts +1 -1
  46. package/dist/browser/tuning/thumbnailRow.js +1 -1
  47. package/dist/browser/tuning/tileClick.d.ts +1 -1
  48. package/dist/browser/tuning/tileClick.js +5 -5
  49. package/dist/browser/tuning/xfinity.d.ts +1 -1
  50. package/dist/browser/tuning/youtubeTv.d.ts +1 -1
  51. package/dist/browser/tuning/youtubeTv.js +17 -18
  52. package/dist/browser/tuning/youtubeTv.js.map +1 -1
  53. package/dist/browser/video.d.ts +20 -6
  54. package/dist/browser/video.js +30 -38
  55. package/dist/browser/video.js.map +1 -1
  56. package/dist/channels/index.d.ts +1 -2
  57. package/dist/channels/index.js +475 -442
  58. package/dist/channels/index.js.map +1 -1
  59. package/dist/config/channelForm.d.ts +58 -0
  60. package/dist/config/channelForm.js +129 -0
  61. package/dist/config/channelForm.js.map +1 -0
  62. package/dist/config/consistencyProbe.d.ts +7 -0
  63. package/dist/config/consistencyProbe.js +143 -0
  64. package/dist/config/consistencyProbe.js.map +1 -0
  65. package/dist/config/health.d.ts +7 -6
  66. package/dist/config/health.js +78 -65
  67. package/dist/config/health.js.map +1 -1
  68. package/dist/config/index.d.ts +1 -1
  69. package/dist/config/index.js +21 -14
  70. package/dist/config/index.js.map +1 -1
  71. package/dist/config/paths.d.ts +9 -3
  72. package/dist/config/paths.js +12 -4
  73. package/dist/config/paths.js.map +1 -1
  74. package/dist/config/persistence.context.d.ts +8 -0
  75. package/dist/config/persistence.context.js +30 -0
  76. package/dist/config/persistence.context.js.map +1 -0
  77. package/dist/config/persistence.d.ts +157 -0
  78. package/dist/config/persistence.js +446 -0
  79. package/dist/config/persistence.js.map +1 -0
  80. package/dist/config/presets.d.ts +14 -4
  81. package/dist/config/presets.js +84 -75
  82. package/dist/config/presets.js.map +1 -1
  83. package/dist/config/profiles.d.ts +2 -2
  84. package/dist/config/profiles.js +7 -9
  85. package/dist/config/profiles.js.map +1 -1
  86. package/dist/config/servicePacks.d.ts +46 -0
  87. package/dist/config/{providerPacks.js → servicePacks.js} +50 -53
  88. package/dist/config/servicePacks.js.map +1 -0
  89. package/dist/config/services.d.ts +228 -0
  90. package/dist/config/services.js +772 -0
  91. package/dist/config/services.js.map +1 -0
  92. package/dist/config/sites.d.ts +3 -3
  93. package/dist/config/sites.js +75 -75
  94. package/dist/config/sites.js.map +1 -1
  95. package/dist/config/userChannels.d.ts +522 -54
  96. package/dist/config/userChannels.js +1343 -366
  97. package/dist/config/userChannels.js.map +1 -1
  98. package/dist/config/userConfig.d.ts +108 -13
  99. package/dist/config/userConfig.js +296 -143
  100. package/dist/config/userConfig.js.map +1 -1
  101. package/dist/config/userProfiles.d.ts +34 -16
  102. package/dist/config/userProfiles.js +189 -100
  103. package/dist/config/userProfiles.js.map +1 -1
  104. package/dist/hdhr/channelMap.d.ts +1 -1
  105. package/dist/hdhr/channelMap.js +10 -16
  106. package/dist/hdhr/channelMap.js.map +1 -1
  107. package/dist/hdhr/deviceId.js +10 -9
  108. package/dist/hdhr/deviceId.js.map +1 -1
  109. package/dist/hdhr/discover.js +4 -5
  110. package/dist/hdhr/discover.js.map +1 -1
  111. package/dist/hdhr/index.js +12 -19
  112. package/dist/hdhr/index.js.map +1 -1
  113. package/dist/index.js +12 -12
  114. package/dist/index.js.map +1 -1
  115. package/dist/native/codecInference.d.ts +44 -0
  116. package/dist/native/codecInference.js +327 -0
  117. package/dist/native/codecInference.js.map +1 -0
  118. package/dist/native/decrypt.d.ts +1 -1
  119. package/dist/native/index.d.ts +5 -5
  120. package/dist/native/index.js +45 -67
  121. package/dist/native/index.js.map +1 -1
  122. package/dist/native/probe.d.ts +27 -9
  123. package/dist/native/probe.js +159 -68
  124. package/dist/native/probe.js.map +1 -1
  125. package/dist/native/proxy.d.ts +4 -2
  126. package/dist/native/proxy.js +56 -53
  127. package/dist/native/proxy.js.map +1 -1
  128. package/dist/native/tokenExpiry.d.ts +22 -0
  129. package/dist/native/tokenExpiry.js +153 -0
  130. package/dist/native/tokenExpiry.js.map +1 -0
  131. package/dist/routes/assets.js +3 -3
  132. package/dist/routes/assets.js.map +1 -1
  133. package/dist/routes/auth.js +9 -13
  134. package/dist/routes/auth.js.map +1 -1
  135. package/dist/routes/components.js +2 -2
  136. package/dist/routes/components.js.map +1 -1
  137. package/dist/routes/config/channels/endpoints/browse.d.ts +6 -0
  138. package/dist/routes/config/channels/endpoints/browse.js +195 -0
  139. package/dist/routes/config/channels/endpoints/browse.js.map +1 -0
  140. package/dist/routes/config/channels/endpoints/bulk.d.ts +6 -0
  141. package/dist/routes/config/channels/endpoints/bulk.js +128 -0
  142. package/dist/routes/config/channels/endpoints/bulk.js.map +1 -0
  143. package/dist/routes/config/channels/endpoints/crud.d.ts +6 -0
  144. package/dist/routes/config/channels/endpoints/crud.js +504 -0
  145. package/dist/routes/config/channels/endpoints/crud.js.map +1 -0
  146. package/dist/routes/config/channels/endpoints/importExport.d.ts +6 -0
  147. package/dist/routes/config/channels/endpoints/importExport.js +131 -0
  148. package/dist/routes/config/channels/endpoints/importExport.js.map +1 -0
  149. package/dist/routes/config/channels/endpoints/predefined.d.ts +6 -0
  150. package/dist/routes/config/channels/endpoints/predefined.js +78 -0
  151. package/dist/routes/config/channels/endpoints/predefined.js.map +1 -0
  152. package/dist/routes/config/channels/endpoints/prefs.d.ts +6 -0
  153. package/dist/routes/config/channels/endpoints/prefs.js +58 -0
  154. package/dist/routes/config/channels/endpoints/prefs.js.map +1 -0
  155. package/dist/routes/config/channels/endpoints/service.d.ts +6 -0
  156. package/dist/routes/config/channels/endpoints/service.js +149 -0
  157. package/dist/routes/config/channels/endpoints/service.js.map +1 -0
  158. package/dist/routes/config/channels/endpoints/tags.d.ts +6 -0
  159. package/dist/routes/config/channels/endpoints/tags.js +185 -0
  160. package/dist/routes/config/channels/endpoints/tags.js.map +1 -0
  161. package/dist/routes/config/channels/http/handler.d.ts +10 -0
  162. package/dist/routes/config/channels/http/handler.js +20 -0
  163. package/dist/routes/config/channels/http/handler.js.map +1 -0
  164. package/dist/routes/config/channels/http/playlistHint.d.ts +25 -0
  165. package/dist/routes/config/channels/http/playlistHint.js +40 -0
  166. package/dist/routes/config/channels/http/playlistHint.js.map +1 -0
  167. package/dist/routes/config/channels/http/serviceWarning.d.ts +10 -0
  168. package/dist/routes/config/channels/http/serviceWarning.js +28 -0
  169. package/dist/routes/config/channels/http/serviceWarning.js.map +1 -0
  170. package/dist/routes/config/channels/index.d.ts +3 -3
  171. package/dist/routes/config/channels/index.js +2 -2
  172. package/dist/routes/config/channels/index.js.map +1 -1
  173. package/dist/routes/config/channels/{routes.d.ts → setup.d.ts} +1 -1
  174. package/dist/routes/config/channels/setup.js +23 -0
  175. package/dist/routes/config/channels/setup.js.map +1 -0
  176. package/dist/routes/config/channels/table.d.ts +13 -13
  177. package/dist/routes/config/channels/table.js +291 -184
  178. package/dist/routes/config/channels/table.js.map +1 -1
  179. package/dist/routes/config/http/envelope.d.ts +111 -0
  180. package/dist/routes/config/http/envelope.js +103 -0
  181. package/dist/routes/config/http/envelope.js.map +1 -0
  182. package/dist/routes/config/index.d.ts +9 -9
  183. package/dist/routes/config/index.js +5 -5
  184. package/dist/routes/config/index.js.map +1 -1
  185. package/dist/routes/config/{providers.js → services.js} +105 -99
  186. package/dist/routes/config/services.js.map +1 -0
  187. package/dist/routes/config/settings.d.ts +1 -1
  188. package/dist/routes/config/settings.js +85 -60
  189. package/dist/routes/config/settings.js.map +1 -1
  190. package/dist/routes/debug.js +17 -18
  191. package/dist/routes/debug.js.map +1 -1
  192. package/dist/routes/health.js +1 -1
  193. package/dist/routes/health.js.map +1 -1
  194. package/dist/routes/icons.js +2 -2
  195. package/dist/routes/icons.js.map +1 -1
  196. package/dist/routes/index.js +2 -2
  197. package/dist/routes/index.js.map +1 -1
  198. package/dist/routes/logs.js +23 -34
  199. package/dist/routes/logs.js.map +1 -1
  200. package/dist/routes/mpegts.js +1 -1
  201. package/dist/routes/playlist.d.ts +5 -5
  202. package/dist/routes/playlist.js +84 -61
  203. package/dist/routes/playlist.js.map +1 -1
  204. package/dist/routes/root/content.js +82 -61
  205. package/dist/routes/root/content.js.map +1 -1
  206. package/dist/routes/root/index.js +2 -5
  207. package/dist/routes/root/index.js.map +1 -1
  208. package/dist/routes/root/scripts/channels.js +538 -581
  209. package/dist/routes/root/scripts/channels.js.map +1 -1
  210. package/dist/routes/root/scripts/config.js +868 -1022
  211. package/dist/routes/root/scripts/config.js.map +1 -1
  212. package/dist/routes/root/scripts/index.d.ts +4 -4
  213. package/dist/routes/root/scripts/shared.js +445 -197
  214. package/dist/routes/root/scripts/shared.js.map +1 -1
  215. package/dist/routes/root/scripts/status.d.ts +4 -0
  216. package/dist/routes/root/scripts/status.handlers.d.ts +182 -0
  217. package/dist/routes/root/scripts/status.handlers.js +713 -0
  218. package/dist/routes/root/scripts/status.handlers.js.map +1 -0
  219. package/dist/routes/root/scripts/status.js +85 -436
  220. package/dist/routes/root/scripts/status.js.map +1 -1
  221. package/dist/routes/root/styles.js +16 -8
  222. package/dist/routes/root/styles.js.map +1 -1
  223. package/dist/routes/services.d.ts +6 -0
  224. package/dist/routes/{providers.js → services.js} +58 -55
  225. package/dist/routes/services.js.map +1 -0
  226. package/dist/routes/sse.d.ts +16 -0
  227. package/dist/routes/sse.js +29 -0
  228. package/dist/routes/sse.js.map +1 -0
  229. package/dist/routes/streams.js +10 -23
  230. package/dist/routes/streams.js.map +1 -1
  231. package/dist/routes/ui.d.ts +2 -2
  232. package/dist/routes/ui.js +2 -0
  233. package/dist/routes/ui.js.map +1 -1
  234. package/dist/routes/upgrade.js +6 -7
  235. package/dist/routes/upgrade.js.map +1 -1
  236. package/dist/service/commands.context.d.ts +6 -0
  237. package/dist/service/commands.context.js +47 -0
  238. package/dist/service/commands.context.js.map +1 -0
  239. package/dist/service/commands.d.ts +53 -8
  240. package/dist/service/commands.js +172 -192
  241. package/dist/service/commands.js.map +1 -1
  242. package/dist/service/generators.context.d.ts +6 -0
  243. package/dist/service/generators.context.js +45 -0
  244. package/dist/service/generators.context.js.map +1 -0
  245. package/dist/service/generators.d.ts +56 -16
  246. package/dist/service/generators.js +509 -291
  247. package/dist/service/generators.js.map +1 -1
  248. package/dist/service/index.d.ts +2 -2
  249. package/dist/streaming/clients.d.ts +2 -2
  250. package/dist/streaming/clients.js +2 -2
  251. package/dist/streaming/codec.d.ts +2 -2
  252. package/dist/streaming/codec.js +0 -1
  253. package/dist/streaming/codec.js.map +1 -1
  254. package/dist/streaming/fmp4Segmenter.d.ts +2 -2
  255. package/dist/streaming/fmp4Segmenter.js +12 -12
  256. package/dist/streaming/hls.d.ts +7 -7
  257. package/dist/streaming/hls.js +104 -92
  258. package/dist/streaming/hls.js.map +1 -1
  259. package/dist/streaming/hlsResume.d.ts +3 -3
  260. package/dist/streaming/hlsResume.js +6 -6
  261. package/dist/streaming/hlsResume.js.map +1 -1
  262. package/dist/streaming/hlsSegments.d.ts +5 -2
  263. package/dist/streaming/hlsSegments.js +14 -9
  264. package/dist/streaming/hlsSegments.js.map +1 -1
  265. package/dist/streaming/monitor.d.ts +12 -12
  266. package/dist/streaming/monitor.js +98 -66
  267. package/dist/streaming/monitor.js.map +1 -1
  268. package/dist/streaming/mp4Parser.d.ts +34 -4
  269. package/dist/streaming/mp4Parser.js +111 -29
  270. package/dist/streaming/mp4Parser.js.map +1 -1
  271. package/dist/streaming/mpegts.d.ts +1 -1
  272. package/dist/streaming/mpegts.js +13 -10
  273. package/dist/streaming/mpegts.js.map +1 -1
  274. package/dist/streaming/playlistBuilder.d.ts +1 -1
  275. package/dist/streaming/playlistBuilder.js +2 -2
  276. package/dist/streaming/preroll.d.ts +9 -9
  277. package/dist/streaming/preroll.js +40 -42
  278. package/dist/streaming/preroll.js.map +1 -1
  279. package/dist/streaming/pretune.js +4 -4
  280. package/dist/streaming/pretune.js.map +1 -1
  281. package/dist/streaming/recovery.d.ts +3 -3
  282. package/dist/streaming/recovery.js +4 -4
  283. package/dist/streaming/registry.d.ts +6 -6
  284. package/dist/streaming/registry.js +4 -8
  285. package/dist/streaming/registry.js.map +1 -1
  286. package/dist/streaming/setup.d.ts +11 -10
  287. package/dist/streaming/setup.js +120 -71
  288. package/dist/streaming/setup.js.map +1 -1
  289. package/dist/streaming/showInfo.d.ts +9 -3
  290. package/dist/streaming/showInfo.js +33 -27
  291. package/dist/streaming/showInfo.js.map +1 -1
  292. package/dist/streaming/statusEmitter.d.ts +8 -7
  293. package/dist/streaming/statusEmitter.js +5 -5
  294. package/dist/streaming/statusEmitter.js.map +1 -1
  295. package/dist/types/channels.d.ts +139 -36
  296. package/dist/types/channels.js +39 -1
  297. package/dist/types/channels.js.map +1 -1
  298. package/dist/types/config.d.ts +22 -3
  299. package/dist/types/index.d.ts +9 -8
  300. package/dist/types/index.js +1 -0
  301. package/dist/types/index.js.map +1 -1
  302. package/dist/types/profiles.d.ts +14 -13
  303. package/dist/types/selection.d.ts +49 -5
  304. package/dist/types/shared.d.ts +1 -1
  305. package/dist/types/streaming.d.ts +2 -2
  306. package/dist/upgrade/commands.context.d.ts +6 -0
  307. package/dist/upgrade/commands.context.js +29 -0
  308. package/dist/upgrade/commands.context.js.map +1 -0
  309. package/dist/upgrade/commands.d.ts +29 -2
  310. package/dist/upgrade/commands.js +87 -134
  311. package/dist/upgrade/commands.js.map +1 -1
  312. package/dist/upgrade/detection.context.d.ts +6 -0
  313. package/dist/upgrade/detection.context.js +24 -0
  314. package/dist/upgrade/detection.context.js.map +1 -0
  315. package/dist/upgrade/detection.d.ts +140 -10
  316. package/dist/upgrade/detection.js +153 -66
  317. package/dist/upgrade/detection.js.map +1 -1
  318. package/dist/upgrade/index.d.ts +2 -2
  319. package/dist/utils/chromeFetch.d.ts +1 -1
  320. package/dist/utils/chromeFetch.js +2 -2
  321. package/dist/utils/clock.d.ts +14 -0
  322. package/dist/utils/clock.js +28 -0
  323. package/dist/utils/clock.js.map +1 -0
  324. package/dist/utils/debugFilter.d.ts +1 -1
  325. package/dist/utils/debugFilter.js +8 -6
  326. package/dist/utils/debugFilter.js.map +1 -1
  327. package/dist/utils/delay.d.ts +2 -3
  328. package/dist/utils/delay.js +10 -16
  329. package/dist/utils/delay.js.map +1 -1
  330. package/dist/utils/evaluate.js +12 -14
  331. package/dist/utils/evaluate.js.map +1 -1
  332. package/dist/utils/ffmpeg.context.d.ts +6 -0
  333. package/dist/utils/ffmpeg.context.js +31 -0
  334. package/dist/utils/ffmpeg.context.js.map +1 -0
  335. package/dist/utils/ffmpeg.d.ts +85 -29
  336. package/dist/utils/ffmpeg.js +126 -129
  337. package/dist/utils/ffmpeg.js.map +1 -1
  338. package/dist/utils/fileLogger.d.ts +18 -2
  339. package/dist/utils/fileLogger.js +60 -31
  340. package/dist/utils/fileLogger.js.map +1 -1
  341. package/dist/utils/format.d.ts +7 -1
  342. package/dist/utils/format.js +22 -2
  343. package/dist/utils/format.js.map +1 -1
  344. package/dist/utils/html.js +1 -3
  345. package/dist/utils/html.js.map +1 -1
  346. package/dist/utils/index.d.ts +21 -20
  347. package/dist/utils/index.js +1 -0
  348. package/dist/utils/index.js.map +1 -1
  349. package/dist/utils/logger.js +31 -45
  350. package/dist/utils/logger.js.map +1 -1
  351. package/dist/utils/m3u.d.ts +18 -3
  352. package/dist/utils/m3u.js +28 -14
  353. package/dist/utils/m3u.js.map +1 -1
  354. package/dist/utils/memo.d.ts +19 -0
  355. package/dist/utils/memo.js +53 -0
  356. package/dist/utils/memo.js.map +1 -0
  357. package/dist/utils/morganStream.js +4 -4
  358. package/dist/utils/morganStream.js.map +1 -1
  359. package/dist/utils/pid.d.ts +2 -2
  360. package/dist/utils/pid.js +2 -2
  361. package/dist/utils/platform.d.ts +1 -1
  362. package/dist/utils/platform.js +3 -2
  363. package/dist/utils/platform.js.map +1 -1
  364. package/dist/utils/retry.d.ts +2 -0
  365. package/dist/utils/retry.js +9 -7
  366. package/dist/utils/retry.js.map +1 -1
  367. package/dist/utils/sanitize.d.ts +2 -2
  368. package/dist/utils/sanitize.js +4 -4
  369. package/dist/utils/streamContext.d.ts +7 -0
  370. package/dist/utils/streamContext.js +10 -1
  371. package/dist/utils/streamContext.js.map +1 -1
  372. package/dist/utils/timing.d.ts +6 -3
  373. package/dist/utils/timing.js +10 -6
  374. package/dist/utils/timing.js.map +1 -1
  375. package/dist/utils/version.d.ts +3 -2
  376. package/dist/utils/version.js +15 -28
  377. package/dist/utils/version.js.map +1 -1
  378. package/package.json +14 -10
  379. package/dist/config/providerPacks.d.ts +0 -46
  380. package/dist/config/providerPacks.js.map +0 -1
  381. package/dist/config/providers.d.ts +0 -175
  382. package/dist/config/providers.js +0 -691
  383. package/dist/config/providers.js.map +0 -1
  384. package/dist/native/intercept.d.ts +0 -35
  385. package/dist/native/intercept.js +0 -184
  386. package/dist/native/intercept.js.map +0 -1
  387. package/dist/routes/config/channels/routes.js +0 -1477
  388. package/dist/routes/config/channels/routes.js.map +0 -1
  389. package/dist/routes/config/providers.js.map +0 -1
  390. package/dist/routes/providers.d.ts +0 -6
  391. package/dist/routes/providers.js.map +0 -1
  392. /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
@@ -26,34 +26,48 @@ export function generateConfigSubtabScript() {
26
26
  "<script>",
27
27
  "(function() {",
28
28
  // Service mode flag for conditional UI behavior.
29
- " var isServiceMode = " + String(isService) + ";",
29
+ " const isServiceMode = " + String(isService) + ";",
30
30
  // Preset values for auto-filling bitrate and frame rate.
31
- " var presetValues = {",
31
+ " const presetValues = {",
32
32
  presetBlocks.join(",\n"),
33
33
  " };",
34
34
  // When quality preset changes, auto-fill bitrate and frame rate with preset values.
35
35
  " function onPresetChange(presetId) {",
36
- " var values = presetValues[presetId];",
37
- " if (!values) return;",
38
- " for (var inputId in values) {",
39
- " var input = document.getElementById(inputId);",
40
- " if (input) {",
41
- " input.value = values[inputId];",
36
+ " const values = presetValues[presetId];",
37
+ " if(!values) return;",
38
+ " for(const [ inputId, value ] of Object.entries(values)) {",
39
+ " const input = document.getElementById(inputId);",
40
+ " if(input) {",
41
+ " input.value = value;",
42
42
  " input.dispatchEvent(new Event('input', { bubbles: true }));",
43
43
  " }",
44
44
  " }",
45
45
  " }",
46
46
  // Hint appended to success toasts when a channel operation changes M3U playlist content that Channels DVR consumes.
47
- " var PLAYLIST_HINT = ' Reload the playlist in Channels DVR to see this change.';",
47
+ " const PLAYLIST_HINT = ' Reload the playlist in Channels DVR to see this change.';",
48
+ // Pending-toast helpers. sessionStorage access is wrapped here so private browsing mode does not crash showToastAfterReload or the page-load consumer.
49
+ // The producer serializes the toast, the consumer reads and removes it in one call.
50
+ " function setPendingToast(message, type) {",
51
+ " try { sessionStorage.setItem('pendingToast', JSON.stringify({ message: message, type: type || 'success' })); }",
52
+ " catch(e) {}",
53
+ " }",
54
+ " function consumePendingToast() {",
55
+ " try {",
56
+ " const raw = sessionStorage.getItem('pendingToast');",
57
+ " if(!raw) return null;",
58
+ " sessionStorage.removeItem('pendingToast');",
59
+ " return JSON.parse(raw);",
60
+ " } catch(e) { console.warn('Pending toast data failed to read or parse.', e); return null; }",
61
+ " }",
48
62
  // Queue a toast to appear after the next page reload.
49
63
  " function showToastAfterReload(message, type) {",
50
- " sessionStorage.setItem('pendingToast', JSON.stringify({ message: message, type: type || 'success' }));",
64
+ " setPendingToast(message, type);",
51
65
  " location.reload();",
52
66
  " }",
53
67
  // Interval handle for restart polling.
54
- " var restartPollInterval = null;",
68
+ " let restartPollInterval = null;",
55
69
  // Track whether a restart is pending (deferred due to active streams).
56
- " var pendingRestart = false;",
70
+ " let pendingRestart = false;",
57
71
  // Show the pending restart dialog when streams are active.
58
72
  " function showPendingRestartDialog(streamCount) {",
59
73
  " pendingRestart = true;",
@@ -63,9 +77,9 @@ export function generateConfigSubtabScript() {
63
77
  " }",
64
78
  // Update the restart dialog when stream count changes. Exposed on window so the status script's streamRemoved handler can trigger it across script blocks.
65
79
  " function updateRestartDialogStatus() {",
66
- " var count = Object.keys(streamData).length;",
80
+ " const count = Object.keys(streamData).length;",
67
81
  " document.getElementById('restart-stream-count').textContent = count;",
68
- " if (count === 0 && pendingRestart) {",
82
+ " if((count === 0) && pendingRestart) {",
69
83
  " pendingRestart = false;",
70
84
  " document.getElementById('restart-dialog').style.display = 'none';",
71
85
  " triggerRestart();",
@@ -73,21 +87,21 @@ export function generateConfigSubtabScript() {
73
87
  " }",
74
88
  " window.updateRestartDialogStatus = updateRestartDialogStatus;",
75
89
  // Cancel the pending restart.
76
- " window.cancelPendingRestart = function() {",
90
+ " window.cancelPendingRestart = () => {",
77
91
  " pendingRestart = false;",
78
92
  " document.getElementById('restart-dialog').style.display = 'none';",
79
93
  " showToast('Restart cancelled. Changes will apply on next restart.', 'info');",
80
94
  " };",
81
95
  // Force immediate restart despite active streams.
82
- " window.forceRestart = async function() {",
96
+ " window.forceRestart = async () => {",
83
97
  " pendingRestart = false;",
84
98
  " document.getElementById('restart-dialog').style.display = 'none';",
85
99
  " try {",
86
- " var res = await fetch('/config/restart-now', { method: 'POST' });",
87
- " if (res.ok) {",
100
+ " const res = await fetch('/config/restart-now', { method: 'POST' });",
101
+ " if(res.ok) {",
88
102
  " waitForServerRestart();",
89
103
  " } else {",
90
- " var data = await res.json();",
104
+ " const data = await res.json();",
91
105
  " throw new Error(data.message || 'Restart failed');",
92
106
  " }",
93
107
  " } catch(err) {",
@@ -97,8 +111,8 @@ export function generateConfigSubtabScript() {
97
111
  // Trigger restart (called when streams reach 0).
98
112
  " async function triggerRestart() {",
99
113
  " try {",
100
- " var res = await fetch('/config/restart-now', { method: 'POST' });",
101
- " if (res.ok) {",
114
+ " const res = await fetch('/config/restart-now', { method: 'POST' });",
115
+ " if(res.ok) {",
102
116
  " waitForServerRestart();",
103
117
  " }",
104
118
  " } catch(err) {",
@@ -107,21 +121,21 @@ export function generateConfigSubtabScript() {
107
121
  " }",
108
122
  // Wait for server restart by polling /health, then reload.
109
123
  " function waitForServerRestart() {",
110
- " var attempts = 0;",
111
- " var maxAttempts = 30;",
124
+ " let attempts = 0;",
125
+ " const maxAttempts = 30;",
112
126
  " showToast('Restarting server...', 'info', 0);",
113
- " if (restartPollInterval) { clearInterval(restartPollInterval); }",
114
- " restartPollInterval = setInterval(async function() {",
127
+ " if(restartPollInterval) { clearInterval(restartPollInterval); }",
128
+ " restartPollInterval = setInterval(async () => {",
115
129
  " attempts++;",
116
130
  " try {",
117
- " var res = await fetch('/health');",
118
- " if (res.ok) {",
131
+ " const res = await fetch('/health');",
132
+ " if(res.ok) {",
119
133
  " clearInterval(restartPollInterval);",
120
134
  " restartPollInterval = null;",
121
135
  " showToastAfterReload('Server restarted.', 'success');",
122
136
  " }",
123
137
  " } catch(err) {",
124
- " if (attempts >= maxAttempts) {",
138
+ " if(attempts >= maxAttempts) {",
125
139
  " clearInterval(restartPollInterval);",
126
140
  " restartPollInterval = null;",
127
141
  " showToast('Server did not restart within 30 seconds. Please check the server manually.', 'error');",
@@ -131,33 +145,33 @@ export function generateConfigSubtabScript() {
131
145
  " }",
132
146
  // Escape HTML entities in text for safe display.
133
147
  " function escapeHtmlText(text) {",
134
- " var div = document.createElement('div');",
148
+ " const div = document.createElement('div');",
135
149
  " div.textContent = text;",
136
150
  " return div.innerHTML;",
137
151
  " }",
138
152
  // Open the changelog modal and fetch content dynamically. Also checks whether an upgrade button should be shown.
139
- " window.openChangelogModal = async function() {",
140
- " var modal = document.getElementById('changelog-modal');",
141
- " if (!modal) return;",
142
- " var title = modal.querySelector('.changelog-title');",
143
- " var loading = modal.querySelector('.changelog-loading');",
144
- " var content = modal.querySelector('.changelog-content');",
145
- " var error = modal.querySelector('.changelog-error');",
146
- " var upgradeBtn = document.getElementById('changelog-upgrade-btn');",
153
+ " window.openChangelogModal = async () => {",
154
+ " const modal = document.getElementById('changelog-modal');",
155
+ " if(!modal) return;",
156
+ " const title = modal.querySelector('.changelog-title');",
157
+ " const loading = modal.querySelector('.changelog-loading');",
158
+ " const content = modal.querySelector('.changelog-content');",
159
+ " const error = modal.querySelector('.changelog-error');",
160
+ " const upgradeBtn = document.getElementById('changelog-upgrade-btn');",
147
161
  " modal.style.display = 'flex';",
148
162
  " loading.style.display = 'block';",
149
163
  " content.style.display = 'none';",
150
164
  " error.style.display = 'none';",
151
- " if (upgradeBtn) upgradeBtn.style.display = 'none';",
165
+ " if(upgradeBtn) upgradeBtn.style.display = 'none';",
152
166
  " try {",
153
- " var res = await fetch('/version/changelog');",
154
- " var data = await res.json();",
167
+ " const res = await fetch('/version/changelog');",
168
+ " const data = await res.json();",
155
169
  " loading.style.display = 'none';",
156
170
  " title.textContent = \"What's new in v\" + data.displayVersion;",
157
- " if (data.items && data.items.length > 0) {",
158
- " var html = '<ul class=\"changelog-list\">';",
159
- " for (var i = 0; i < data.items.length; i++) {",
160
- " html += '<li>' + escapeHtmlText(data.items[i]) + '</li>';",
171
+ " if(data.items && (data.items.length > 0)) {",
172
+ " let html = '<ul class=\"changelog-list\">';",
173
+ " for(const item of data.items) {",
174
+ " html += '<li>' + escapeHtmlText(item) + '</li>';",
161
175
  " }",
162
176
  " html += '</ul>';",
163
177
  " content.innerHTML = html;",
@@ -165,113 +179,113 @@ export function generateConfigSubtabScript() {
165
179
  " } else {",
166
180
  " error.style.display = 'block';",
167
181
  " }",
168
- " if (data.updateAvailable && upgradeBtn) { upgradeBtn.style.display = ''; }",
182
+ " if(data.updateAvailable && upgradeBtn) { upgradeBtn.style.display = ''; }",
169
183
  " } catch(err) {",
170
184
  " loading.style.display = 'none';",
171
185
  " error.style.display = 'block';",
172
186
  " }",
173
187
  " };",
174
188
  // Close the changelog modal.
175
- " window.closeChangelogModal = function() {",
176
- " var modal = document.getElementById('changelog-modal');",
177
- " if (modal) { modal.style.display = 'none'; }",
189
+ " window.closeChangelogModal = () => {",
190
+ " const modal = document.getElementById('changelog-modal');",
191
+ " if(modal) { modal.style.display = 'none'; }",
178
192
  " };",
179
193
  // Start the upgrade process from the changelog modal. Fetches upgrade info, confirms with the user if there are active streams, then executes the upgrade.
180
- " window.startUpgrade = async function() {",
194
+ " window.startUpgrade = async () => {",
181
195
  " try {",
182
- " var infoRes = await fetch('/upgrade/info');",
183
- " var info = await infoRes.json();",
184
- " if (!info.upgradeable) {",
196
+ " const infoRes = await fetch('/upgrade/info');",
197
+ " const info = await infoRes.json();",
198
+ " if(!info.upgradeable) {",
185
199
  " closeChangelogModal();",
186
- " var msg = info.method === 'docker'",
200
+ " const msg = info.method === 'docker'",
187
201
  " ? 'Docker containers cannot be upgraded in-place. Pull the latest image and recreate the container.'",
188
202
  " : 'Manual upgrade required: ' + info.upgradeCommand;",
189
203
  " showToast(msg, 'info', 8000);",
190
204
  " return;",
191
205
  " }",
192
- " var streamCount = Object.keys(streamData).length;",
193
- " if (streamCount > 0) {",
194
- " if (!confirm('There are ' + streamCount + ' active stream(s). Upgrading will interrupt them. Continue?')) return;",
206
+ " const streamCount = Object.keys(streamData).length;",
207
+ " if(streamCount > 0) {",
208
+ " if(!confirm('There are ' + streamCount + ' active stream(s). Upgrading will interrupt them. Continue?')) return;",
195
209
  " }",
196
210
  " closeChangelogModal();",
197
211
  " showToast('Upgrading PrismCast...', 'info', 0);",
198
- " var upgradeRes = await fetch('/upgrade', { method: 'POST' });",
199
- " var result = await upgradeRes.json();",
200
- " if (result.success && result.willRestart) {",
212
+ " const upgradeRes = await fetch('/upgrade', { method: 'POST' });",
213
+ " const result = await upgradeRes.json();",
214
+ " if(result.success && result.willRestart) {",
201
215
  " waitForServerRestart();",
202
- " } else if (result.success) {",
216
+ " } else if(result.success) {",
203
217
  " showToast('Upgrade complete. Please restart PrismCast manually.', 'success', 8000);",
204
218
  " } else {",
205
- " showToast('Upgrade failed: ' + result.message, 'error');",
219
+ " showToast('Upgrade failed: ' + result.error, 'error');",
206
220
  " }",
207
221
  " } catch(err) {",
208
222
  " showToast('Upgrade failed: ' + err.message, 'error');",
209
223
  " }",
210
224
  " };",
211
225
  // Check for updates manually. Updates the version link in-place when a new version is found.
212
- " window.checkForUpdates = async function() {",
213
- " var btn = document.querySelector('.version-check');",
214
- " if (!btn || btn.classList.contains('checking')) return;",
226
+ " window.checkForUpdates = async () => {",
227
+ " const btn = document.querySelector('.version-check');",
228
+ " if(!btn || btn.classList.contains('checking')) return;",
215
229
  " btn.classList.add('checking');",
216
230
  " try {",
217
- " var res = await fetch('/version/check', { method: 'POST' });",
218
- " var data = await res.json();",
231
+ " const res = await fetch('/version/check', { method: 'POST' });",
232
+ " const data = await res.json();",
219
233
  " btn.classList.remove('checking');",
220
- " if (data.updateAvailable && data.latestVersion) {",
221
- " var link = document.querySelector('.version-container .version');",
222
- " if (link && !link.classList.contains('version-update')) {",
234
+ " if(data.updateAvailable && data.latestVersion) {",
235
+ " const link = document.querySelector('.version-container .version');",
236
+ " if(link && !link.classList.contains('version-update')) {",
223
237
  " link.textContent = 'v' + data.currentVersion + ' \\u2192 v' + data.latestVersion;",
224
238
  " link.classList.add('version-update');",
225
239
  " }",
226
240
  " } else {",
227
241
  " btn.classList.add('up-to-date');",
228
- " setTimeout(function() { btn.classList.remove('up-to-date'); }, 2000);",
242
+ " setTimeout(() => { btn.classList.remove('up-to-date'); }, 2000);",
229
243
  " }",
230
244
  " } catch(err) {",
231
245
  " btn.classList.remove('checking');",
232
246
  " btn.classList.add('check-error');",
233
- " setTimeout(function() { btn.classList.remove('check-error'); }, 2000);",
247
+ " setTimeout(() => { btn.classList.remove('check-error'); }, 2000);",
234
248
  " }",
235
249
  " };",
236
250
  // Clear all field error indicators.
237
251
  " function clearFieldErrors() {",
238
- " var errorInputs = document.querySelectorAll('.form-input.error, .form-select.error');",
239
- " for (var i = 0; i < errorInputs.length; i++) {",
240
- " errorInputs[i].classList.remove('error');",
252
+ " const errorInputs = document.querySelectorAll('.form-input.error, .form-select.error');",
253
+ " for(const errorInput of errorInputs) {",
254
+ " errorInput.classList.remove('error');",
241
255
  " }",
242
- " var errorMsgs = document.querySelectorAll('.form-error.dynamic');",
243
- " for (var j = 0; j < errorMsgs.length; j++) {",
244
- " errorMsgs[j].remove();",
256
+ " const errorMsgs = document.querySelectorAll('.form-error.dynamic');",
257
+ " for(const errorMsg of errorMsgs) {",
258
+ " errorMsg.remove();",
245
259
  " }",
246
260
  " }",
247
261
  // Display field-level errors from server response.
248
262
  " function displayFieldErrors(errors) {",
249
- " for (var path in errors) {",
250
- " var inputId = path.replace(/\\./g, '-');",
251
- " var input = document.getElementById(inputId);",
252
- " if (input) {",
263
+ " for(const [ path, message ] of Object.entries(errors)) {",
264
+ " const inputId = path.replaceAll('.', '-');",
265
+ " const input = document.getElementById(inputId);",
266
+ " if(input) {",
253
267
  " input.classList.add('error');",
254
- " var errorDiv = document.createElement('div');",
268
+ " const errorDiv = document.createElement('div');",
255
269
  " errorDiv.className = 'form-error dynamic';",
256
- " errorDiv.textContent = errors[path];",
270
+ " errorDiv.textContent = message;",
257
271
  " input.closest('.form-group').appendChild(errorDiv);",
258
272
  " }",
259
273
  " }",
260
274
  " }",
261
275
  // Set a form input's value, handling checkbox and standard input types uniformly.
262
276
  " function setInputValue(input, value) {",
263
- " if (input.type === 'checkbox') {",
277
+ " if(input.type === 'checkbox') {",
264
278
  " input.checked = value === 'true';",
265
279
  " } else {",
266
280
  " input.value = value;",
267
281
  " }",
268
- " if (input.hasAttribute('data-checkbox-list')) {",
269
- " var values = JSON.parse(value || '[]');",
270
- " var grid = input.closest('.form-group').querySelector('.checkbox-list-grid');",
271
- " if (grid) {",
272
- " var cbs = grid.querySelectorAll('input[type=\"checkbox\"]');",
273
- " for (var k = 0; k < cbs.length; k++) {",
274
- " cbs[k].checked = values.indexOf(cbs[k].value) !== -1;",
282
+ " if(input.hasAttribute('data-checkbox-list')) {",
283
+ " const values = JSON.parse(value || '[]');",
284
+ " const grid = input.closest('.form-group').querySelector('.checkbox-list-grid');",
285
+ " if(grid) {",
286
+ " const cbs = grid.querySelectorAll('input[type=\"checkbox\"]');",
287
+ " for(const cb of cbs) {",
288
+ " cb.checked = values.includes(cb.value);",
275
289
  " }",
276
290
  " }",
277
291
  " }",
@@ -283,189 +297,187 @@ export function generateConfigSubtabScript() {
283
297
  // Toggle dependent fields when a boolean checkbox changes. Fields with data-depends-on are visually greyed out and removed from the tab order when the
284
298
  // referenced checkbox is unchecked. This function is defined at the top level so that both event handlers and reset functions can call it.
285
299
  " function updateDependentFields(checkboxId) {",
286
- " var checkbox = document.getElementById(checkboxId);",
287
- " if (!checkbox) return;",
288
- " var dependents = document.querySelectorAll('[data-depends-on=\"' + checkboxId + '\"]');",
289
- " for (var i = 0; i < dependents.length; i++) {",
290
- " if (checkbox.checked) {",
291
- " dependents[i].classList.remove('depends-disabled');",
300
+ " const checkbox = document.getElementById(checkboxId);",
301
+ " if(!checkbox) return;",
302
+ " const dependents = document.querySelectorAll('[data-depends-on=\"' + checkboxId + '\"]');",
303
+ " for(const dependent of dependents) {",
304
+ " if(checkbox.checked) {",
305
+ " dependent.classList.remove('depends-disabled');",
292
306
  " } else {",
293
- " dependents[i].classList.add('depends-disabled');",
307
+ " dependent.classList.add('depends-disabled');",
294
308
  " }",
295
- " var depInputs = dependents[i].querySelectorAll('input:not([type=\"hidden\"]), select');",
296
- " for (var j = 0; j < depInputs.length; j++) {",
297
- " depInputs[j].tabIndex = checkbox.checked ? 0 : -1;",
309
+ " const depInputs = dependent.querySelectorAll('input:not([type=\"hidden\"]), select');",
310
+ " for(const depInput of depInputs) {",
311
+ " depInput.tabIndex = checkbox.checked ? 0 : -1;",
298
312
  " }",
299
313
  " }",
300
314
  " }",
301
315
  // Update modified indicators for a single input.
302
316
  " function updateModifiedIndicator(input) {",
303
- " var defaultVal = input.getAttribute('data-default');",
304
- " var currentVal = getInputValue(input);",
305
- " var formGroup = input.closest('.form-group');",
306
- " if (!formGroup) return;",
307
- " var isModified = currentVal !== defaultVal;",
308
- " var dot = formGroup.querySelector('.modified-dot');",
309
- " var resetBtn = formGroup.querySelector('.btn-reset');",
310
- " if (isModified) {",
317
+ " const defaultVal = input.getAttribute('data-default');",
318
+ " const currentVal = getInputValue(input);",
319
+ " const formGroup = input.closest('.form-group');",
320
+ " if(!formGroup) return;",
321
+ " const isModified = currentVal !== defaultVal;",
322
+ " const dot = formGroup.querySelector('.modified-dot');",
323
+ " const resetBtn = formGroup.querySelector('.btn-reset');",
324
+ " if(isModified) {",
311
325
  " formGroup.classList.add('modified');",
312
- " if (!dot) {",
313
- " var label = formGroup.querySelector('.form-label');",
314
- " if (label) {",
315
- " var newDot = document.createElement('span');",
326
+ " if(!dot) {",
327
+ " const label = formGroup.querySelector('.form-label');",
328
+ " if(label) {",
329
+ " const newDot = document.createElement('span');",
316
330
  " newDot.className = 'modified-dot';",
317
331
  " newDot.title = 'Modified from default';",
318
332
  " label.insertBefore(newDot, label.firstChild);",
319
333
  " }",
320
334
  " }",
321
- " if (!resetBtn) {",
322
- " var row = formGroup.querySelector('.form-row');",
323
- " if (row) {",
324
- " var path = input.getAttribute('name');",
325
- " var btn = document.createElement('button');",
335
+ " if(!resetBtn) {",
336
+ " const row = formGroup.querySelector('.form-row');",
337
+ " if(row) {",
338
+ " const path = input.getAttribute('name');",
339
+ " const btn = document.createElement('button');",
326
340
  " btn.type = 'button';",
327
341
  " btn.className = 'btn-reset';",
328
342
  " btn.title = 'Reset to default';",
329
343
  " btn.setAttribute('aria-label', 'Reset to default');",
330
344
  " btn.innerHTML = '&#8635;';",
331
- " btn.onclick = function() { resetSetting(path); };",
345
+ " btn.onclick = () => { resetSetting(path); };",
332
346
  " row.appendChild(btn);",
333
347
  " }",
334
348
  " }",
335
349
  " } else {",
336
350
  " formGroup.classList.remove('modified');",
337
- " if (dot) dot.remove();",
338
- " if (resetBtn) resetBtn.remove();",
351
+ " if(dot) dot.remove();",
352
+ " if(resetBtn) resetBtn.remove();",
339
353
  " }",
340
354
  " }",
341
355
  // Reset a single setting to its default value (client-side only). Dispatches both input and change events to match browser behavior: input for validation and
342
356
  // modified indicator updates, change for cascade handlers (e.g., preset dropdown updating bitrate and frame rate fields).
343
- " window.resetSetting = function(path) {",
344
- " var inputId = path.replace(/\\./g, '-');",
345
- " var input = document.getElementById(inputId);",
346
- " if (!input) return;",
347
- " var defaultVal = input.getAttribute('data-default');",
348
- " if (defaultVal !== null) {",
357
+ " window.resetSetting = (path) => {",
358
+ " const inputId = path.replaceAll('.', '-');",
359
+ " const input = document.getElementById(inputId);",
360
+ " if(!input) return;",
361
+ " const defaultVal = input.getAttribute('data-default');",
362
+ " if(defaultVal !== null) {",
349
363
  " setInputValue(input, defaultVal);",
350
364
  " input.dispatchEvent(new Event('input', { bubbles: true }));",
351
365
  " input.dispatchEvent(new Event('change', { bubbles: true }));",
352
366
  " }",
353
367
  " };",
354
368
  // Reset all settings in a tab to defaults (client-side only). Works for both settings and advanced tabs.
355
- " window.resetTabToDefaults = function(tabId) {",
356
- " if (!confirm('Reset all settings in this tab to defaults?')) return;",
357
- " var panel = document.getElementById('subtab-' + tabId);",
358
- " if (!panel) return;",
359
- " var inputs = panel.querySelectorAll('input[data-default], select[data-default]');",
360
- " for (var i = 0; i < inputs.length; i++) {",
361
- " var input = inputs[i];",
362
- " if (!input.disabled) {",
369
+ " window.resetTabToDefaults = (tabId) => {",
370
+ " if(!confirm('Reset all settings in this tab to defaults?')) return;",
371
+ " const panel = document.getElementById('subtab-' + tabId);",
372
+ " if(!panel) return;",
373
+ " const inputs = panel.querySelectorAll('input[data-default], select[data-default]');",
374
+ " for(const input of inputs) {",
375
+ " if(!input.disabled) {",
363
376
  " setInputValue(input, input.getAttribute('data-default'));",
364
377
  " updateModifiedIndicator(input);",
365
378
  " }",
366
379
  " }",
367
- " var cbInputs = panel.querySelectorAll('input[type=\"checkbox\"][data-default]');",
368
- " for (var j = 0; j < cbInputs.length; j++) {",
369
- " if (!cbInputs[j].disabled) {",
370
- " updateDependentFields(cbInputs[j].id);",
380
+ " const cbInputs = panel.querySelectorAll('input[type=\"checkbox\"][data-default]');",
381
+ " for(const cbInput of cbInputs) {",
382
+ " if(!cbInput.disabled) {",
383
+ " updateDependentFields(cbInput.id);",
371
384
  " }",
372
385
  " }",
373
386
  " showToast('Settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
374
387
  " };",
375
- // Toggle collapsible section in Advanced tab.
376
- " window.toggleSection = function(sectionId) {",
377
- " var section = document.querySelector('.advanced-section[data-section=\"' + sectionId + '\"]');",
378
- " if (!section) return;",
379
- " var header = section.querySelector('.section-header');",
380
- " var content = section.querySelector('.section-content');",
381
- " if (!header || !content) return;",
382
- " var isExpanded = content.classList.contains('expanded');",
383
- " if (isExpanded) {",
388
+ // Read the advanced section expansion state from localStorage. Returns the parsed object or {} if missing or corrupted. Single source of truth for
389
+ // reading this key - used by both toggleSection (read-modify-write) and initSections (read-only).
390
+ " function readAdvancedSectionState() {",
391
+ " const stored = safeStorageGet('prismcast-advanced-sections');",
392
+ " if(!stored) return {};",
393
+ " try { return JSON.parse(stored); }",
394
+ " catch(e) { console.warn('Advanced section state failed to parse.', e); return {}; }",
395
+ " }",
396
+ " window.toggleSection = (sectionId) => {",
397
+ " const section = document.querySelector('.advanced-section[data-section=\"' + sectionId + '\"]');",
398
+ " if(!section) return;",
399
+ " const header = section.querySelector('.section-header');",
400
+ " const content = section.querySelector('.section-content');",
401
+ " if(!header || !content) return;",
402
+ " const isExpanded = content.classList.contains('expanded');",
403
+ " if(isExpanded) {",
384
404
  " content.classList.remove('expanded');",
385
405
  " header.classList.remove('expanded');",
386
406
  " } else {",
387
407
  " content.classList.add('expanded');",
388
408
  " header.classList.add('expanded');",
389
409
  " }",
390
- " try {",
391
- " var expanded = JSON.parse(localStorage.getItem('prismcast-advanced-sections') || '{}');",
392
- " expanded[sectionId] = !isExpanded;",
393
- " localStorage.setItem('prismcast-advanced-sections', JSON.stringify(expanded));",
394
- " } catch(e) {}",
410
+ " const expanded = readAdvancedSectionState();",
411
+ " expanded[sectionId] = !isExpanded;",
412
+ " safeStorageSet('prismcast-advanced-sections', JSON.stringify(expanded));",
395
413
  " };",
396
- // Initialize section expansion state from localStorage.
414
+ // Initialize section expansion state from localStorage on page load.
397
415
  " function initSections() {",
398
- " try {",
399
- " var expanded = JSON.parse(localStorage.getItem('prismcast-advanced-sections') || '{}');",
400
- " for (var sectionId in expanded) {",
401
- " if (expanded[sectionId]) {",
402
- " var section = document.querySelector('.advanced-section[data-section=\"' + sectionId + '\"]');",
403
- " if (section) {",
404
- " var header = section.querySelector('.section-header');",
405
- " var content = section.querySelector('.section-content');",
406
- " if (header && content) {",
407
- " header.classList.add('expanded');",
408
- " content.classList.add('expanded');",
409
- " }",
410
- " }",
411
- " }",
416
+ " for(const [ sectionId, isExpanded ] of Object.entries(readAdvancedSectionState())) {",
417
+ " if(!isExpanded) continue;",
418
+ " const section = document.querySelector('.advanced-section[data-section=\"' + sectionId + '\"]');",
419
+ " if(!section) continue;",
420
+ " const header = section.querySelector('.section-header');",
421
+ " const content = section.querySelector('.section-content');",
422
+ " if(header && content) {",
423
+ " header.classList.add('expanded');",
424
+ " content.classList.add('expanded');",
412
425
  " }",
413
- " } catch(e) {}",
426
+ " }",
414
427
  " }",
415
428
  " initSections();",
416
429
  // Reset all settings to defaults (client-side only).
417
- " window.resetAllToDefaults = function() {",
418
- " if (!confirm('Reset ALL settings to defaults? Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' after to apply.')) return;",
419
- " var form = document.getElementById('settings-form');",
420
- " if (!form) return;",
421
- " var inputs = form.querySelectorAll('input[data-default], select[data-default]');",
422
- " for (var i = 0; i < inputs.length; i++) {",
423
- " var input = inputs[i];",
424
- " if (!input.disabled) {",
430
+ " window.resetAllToDefaults = () => {",
431
+ " if(!confirm('Reset ALL settings to defaults? Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' after to apply.')) return;",
432
+ " const form = document.getElementById('settings-form');",
433
+ " if(!form) return;",
434
+ " const inputs = form.querySelectorAll('input[data-default], select[data-default]');",
435
+ " for(const input of inputs) {",
436
+ " if(!input.disabled) {",
425
437
  " setInputValue(input, input.getAttribute('data-default'));",
426
438
  " updateModifiedIndicator(input);",
427
439
  " }",
428
440
  " }",
429
- " var cbInputs = form.querySelectorAll('input[type=\"checkbox\"][data-default]');",
430
- " for (var j = 0; j < cbInputs.length; j++) {",
431
- " if (!cbInputs[j].disabled) {",
432
- " updateDependentFields(cbInputs[j].id);",
441
+ " const cbInputs = form.querySelectorAll('input[type=\"checkbox\"][data-default]');",
442
+ " for(const cbInput of cbInputs) {",
443
+ " if(!cbInput.disabled) {",
444
+ " updateDependentFields(cbInput.id);",
433
445
  " }",
434
446
  " }",
435
447
  " showToast('All settings reset to defaults. Click ' + (isServiceMode ? 'Save & Restart' : 'Save Settings') + ' to apply changes.', 'info');",
436
448
  " };",
437
449
  // Submit settings form via AJAX.
438
- " window.submitSettingsForm = async function(event) {",
450
+ " window.submitSettingsForm = async (event) => {",
439
451
  " event.preventDefault();",
440
452
  " clearFieldErrors();",
441
- " var form = document.getElementById('settings-form');",
442
- " var saveBtn = document.getElementById('save-btn');",
443
- " if (!form) return false;",
444
- " var formData = new FormData(form);",
445
- " var config = {};",
446
- " for (var pair of formData.entries()) {",
447
- " var path = pair[0];",
448
- " var value = pair[1];",
449
- " var parts = path.split('.');",
450
- " var obj = config;",
451
- " for (var i = 0; i < parts.length - 1; i++) {",
452
- " if (!obj[parts[i]]) obj[parts[i]] = {};",
453
+ " const form = document.getElementById('settings-form');",
454
+ " const saveBtn = document.getElementById('save-btn');",
455
+ " if(!form) return false;",
456
+ " const formData = new FormData(form);",
457
+ " const config = {};",
458
+ " for(const pair of formData.entries()) {",
459
+ " const path = pair[0];",
460
+ " const value = pair[1];",
461
+ " const parts = path.split('.');",
462
+ " let obj = config;",
463
+ " for(let i = 0; i < (parts.length - 1); i++) {",
464
+ " if(!obj[parts[i]]) obj[parts[i]] = {};",
453
465
  " obj = obj[parts[i]];",
454
466
  " }",
455
- " obj[parts[parts.length - 1]] = value;",
467
+ " obj[parts.at(-1)] = value;",
456
468
  " }",
457
- " if (saveBtn) saveBtn.classList.add('loading');",
469
+ " if(saveBtn) saveBtn.classList.add('loading');",
458
470
  " try {",
459
- " var res = await fetch('/config', {",
460
- " method: 'POST',",
461
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
462
- " body: JSON.stringify(config)",
471
+ " const res = await fetch('/config', {",
472
+ " body: JSON.stringify(config),",
473
+ " headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },",
474
+ " method: 'POST'",
463
475
  " });",
464
- " var data = await res.json();",
465
- " if (saveBtn) saveBtn.classList.remove('loading');",
466
- " if (res.ok && data.success) {",
467
- " if (data.willRestart) {",
468
- " if (data.deferred) {",
476
+ " const data = await res.json();",
477
+ " if(saveBtn) saveBtn.classList.remove('loading');",
478
+ " if(res.ok && data.success) {",
479
+ " if(data.willRestart) {",
480
+ " if(data.deferred) {",
469
481
  " showPendingRestartDialog(data.activeStreams);",
470
482
  " } else {",
471
483
  " waitForServerRestart();",
@@ -473,25 +485,25 @@ export function generateConfigSubtabScript() {
473
485
  " } else {",
474
486
  " showToast(data.message || 'Configuration saved.', 'info');",
475
487
  " }",
476
- " } else if (data.errors) {",
488
+ " } else if(data.errors) {",
477
489
  " displayFieldErrors(data.errors);",
478
490
  " showToast('Please correct the errors below.', 'error');",
479
491
  " } else {",
480
- " showToast(data.message || 'Failed to save configuration.', 'error');",
492
+ " showToast(extractErrorMessage(data, 'Failed to save configuration.'), 'error');",
481
493
  " }",
482
494
  " } catch(err) {",
483
- " if (saveBtn) saveBtn.classList.remove('loading');",
495
+ " if(saveBtn) saveBtn.classList.remove('loading');",
484
496
  " showToast('Failed to save configuration: ' + err.message, 'error');",
485
497
  " }",
486
498
  " return false;",
487
499
  " };",
488
500
  // Export configuration as JSON download.
489
- " window.exportConfig = async function() {",
501
+ " window.exportConfig = async () => {",
490
502
  " try {",
491
- " var res = await fetch('/config/export');",
492
- " var blob = await res.blob();",
493
- " var url = window.URL.createObjectURL(blob);",
494
- " var a = document.createElement('a');",
503
+ " const res = await fetch('/config/export');",
504
+ " const blob = await res.blob();",
505
+ " const url = window.URL.createObjectURL(blob);",
506
+ " const a = document.createElement('a');",
495
507
  " a.href = url;",
496
508
  " a.download = 'prismcast-config.json';",
497
509
  " document.body.appendChild(a);",
@@ -503,34 +515,34 @@ export function generateConfigSubtabScript() {
503
515
  " }",
504
516
  " };",
505
517
  // Import configuration from file.
506
- " window.importConfig = function(fileInput) {",
507
- " var file = fileInput.files[0];",
508
- " if (!file) return;",
509
- " var reader = new FileReader();",
510
- " reader.onload = async function(e) {",
518
+ " window.importConfig = (fileInput) => {",
519
+ " const file = fileInput.files[0];",
520
+ " if(!file) return;",
521
+ " const reader = new FileReader();",
522
+ " reader.onload = async (e) => {",
511
523
  " try {",
512
- " var config = JSON.parse(e.target.result);",
513
- " if (confirm('Import this configuration? The server may restart to apply changes.')) {",
514
- " var res = await fetch('/config/import', {",
515
- " method: 'POST',",
524
+ " const config = JSON.parse(e.target.result);",
525
+ " if(confirm('Import this configuration? The server may restart to apply changes.')) {",
526
+ " const res = await fetch('/config/import', {",
527
+ " body: JSON.stringify(config),",
516
528
  " headers: { 'Content-Type': 'application/json' },",
517
- " body: JSON.stringify(config)",
529
+ " method: 'POST'",
518
530
  " });",
519
- " var data = await res.json();",
520
- " if (res.ok && data.success) {",
531
+ " const data = await res.json();",
532
+ " if(res.ok && data.success) {",
521
533
  " showToast(data.message || 'Configuration imported.', 'success');",
522
- " if (data.willRestart) {",
523
- " if (data.deferred) {",
534
+ " if(data.willRestart) {",
535
+ " if(data.deferred) {",
524
536
  " showPendingRestartDialog(data.activeStreams);",
525
537
  " } else {",
526
538
  " waitForServerRestart();",
527
539
  " }",
528
540
  " }",
529
541
  " } else {",
530
- " throw new Error(data.message || data.error || 'Import failed');",
542
+ " throw new Error(extractErrorMessage(data, 'Import failed'));",
531
543
  " }",
532
544
  " }",
533
- " } catch (err) {",
545
+ " } catch(err) {",
534
546
  " showToast('Failed to import configuration: ' + err.message, 'error');",
535
547
  " }",
536
548
  " fileInput.value = '';",
@@ -538,12 +550,12 @@ export function generateConfigSubtabScript() {
538
550
  " reader.readAsText(file);",
539
551
  " };",
540
552
  // Export channels as JSON download.
541
- " window.exportChannels = async function() {",
553
+ " window.exportChannels = async () => {",
542
554
  " try {",
543
- " var res = await fetch('/config/channels/export');",
544
- " var blob = await res.blob();",
545
- " var url = window.URL.createObjectURL(blob);",
546
- " var a = document.createElement('a');",
555
+ " const res = await fetch('/config/channels/export');",
556
+ " const blob = await res.blob();",
557
+ " const url = window.URL.createObjectURL(blob);",
558
+ " const a = document.createElement('a');",
547
559
  " a.href = url;",
548
560
  " a.download = 'prismcast-channels.json';",
549
561
  " document.body.appendChild(a);",
@@ -555,28 +567,28 @@ export function generateConfigSubtabScript() {
555
567
  " }",
556
568
  " };",
557
569
  // Import channels from file.
558
- " window.importChannels = function(fileInput) {",
559
- " var file = fileInput.files[0];",
560
- " if (!file) return;",
561
- " var reader = new FileReader();",
562
- " reader.onload = async function(e) {",
570
+ " window.importChannels = (fileInput) => {",
571
+ " const file = fileInput.files[0];",
572
+ " if(!file) return;",
573
+ " const reader = new FileReader();",
574
+ " reader.onload = async (e) => {",
563
575
  " try {",
564
- " var channels = JSON.parse(e.target.result);",
565
- " if (confirm('Import these channels? This will replace all existing user channels.')) {",
566
- " var res = await fetch('/config/channels/import', {",
567
- " method: 'POST',",
576
+ " const channels = JSON.parse(e.target.result);",
577
+ " if(confirm('Import these channels? This will replace all existing user channels.')) {",
578
+ " const res = await fetch('/config/channels/import', {",
579
+ " body: JSON.stringify(channels),",
568
580
  " headers: { 'Content-Type': 'application/json' },",
569
- " body: JSON.stringify(channels)",
581
+ " method: 'POST'",
570
582
  " });",
571
- " var data = await res.json();",
572
- " if (data.success) {",
583
+ " const data = await res.json();",
584
+ " if(data.success) {",
573
585
  " showToast((data.message || 'Channels imported successfully.') + PLAYLIST_HINT, 'success');",
574
- " if (data.patch) { applyChannelPatch(data.patch); }",
586
+ " if(data.patch) { channelTable.applyPatch(data.patch); }",
575
587
  " } else {",
576
- " showToast('Failed to import channels: ' + (data.error || 'Unknown error.'), 'error');",
588
+ " showToast('Failed to import channels: ' + extractErrorMessage(data, 'Unknown error.'), 'error');",
577
589
  " }",
578
590
  " }",
579
- " } catch (err) {",
591
+ " } catch(err) {",
580
592
  " showToast('Failed to import channels: ' + err.message, 'error');",
581
593
  " }",
582
594
  " fileInput.value = '';",
@@ -584,39 +596,40 @@ export function generateConfigSubtabScript() {
584
596
  " reader.readAsText(file);",
585
597
  " };",
586
598
  // Import channels from M3U playlist file.
587
- " window.importM3U = function(fileInput) {",
588
- " var file = fileInput.files[0];",
589
- " if (!file) return;",
590
- " var replaceCheckbox = document.getElementById('m3u-replace-duplicates');",
591
- " var conflictMode = (replaceCheckbox && replaceCheckbox.checked) ? 'replace' : 'skip';",
592
- " var reader = new FileReader();",
593
- " reader.onload = async function(e) {",
599
+ " window.importM3U = (fileInput) => {",
600
+ " const file = fileInput.files[0];",
601
+ " if(!file) return;",
602
+ " const replaceCheckbox = document.getElementById('m3u-replace-duplicates');",
603
+ " const conflictMode = (replaceCheckbox && replaceCheckbox.checked) ? 'replace' : 'skip';",
604
+ " const reader = new FileReader();",
605
+ " reader.onload = async (e) => {",
594
606
  " try {",
595
- " var res = await fetch('/config/channels/import-m3u', {",
596
- " method: 'POST',",
607
+ " const res = await fetch('/config/channels/import-m3u', {",
608
+ " body: JSON.stringify({ content: e.target.result, conflictMode: conflictMode }),",
597
609
  " headers: { 'Content-Type': 'application/json' },",
598
- " body: JSON.stringify({ content: e.target.result, conflictMode: conflictMode })",
610
+ " method: 'POST'",
599
611
  " });",
600
- " var data = await res.json();",
601
- " if (data.success) {",
602
- " var msg = 'M3U Import Complete\\n\\n';",
612
+ " const data = await res.json();",
613
+ " if(data.success) {",
614
+ " let msg = 'M3U Import Complete\\n\\n';",
603
615
  " msg += '\\u2713 ' + data.imported + ' channel(s) imported\\n';",
604
- " if (data.replaced > 0) msg += '\\u21BB ' + data.replaced + ' channel(s) replaced\\n';",
605
- " if (data.skipped > 0) msg += '\\u25CB ' + data.skipped + ' duplicate(s) skipped\\n';",
606
- " if (data.errors && data.errors.length > 0) {",
616
+ " if(data.replaced > 0) msg += '\\u21BB ' + data.replaced + ' channel(s) replaced\\n';",
617
+ " if(data.skipped > 0) msg += '\\u25CB ' + data.skipped + ' duplicate(s) skipped\\n';",
618
+ " if(data.errors && (data.errors.length > 0)) {",
607
619
  " msg += '\\n! ' + data.errors.length + ' error(s):\\n';",
608
- " for (var i = 0; i < Math.min(data.errors.length, 5); i++) {",
620
+ " const limit = Math.min(data.errors.length, 5);",
621
+ " for(let i = 0; i < limit; i++) {",
609
622
  " msg += ' - ' + data.errors[i] + '\\n';",
610
623
  " }",
611
- " if (data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
624
+ " if(data.errors.length > 5) msg += ' ... and ' + (data.errors.length - 5) + ' more\\n';",
612
625
  " }",
613
- " if (data.patch) { applyChannelPatch(data.patch); }",
614
- " showToast(msg + ((data.imported > 0 || data.replaced > 0) ? PLAYLIST_HINT : ''), 'success');",
626
+ " if(data.patch) { channelTable.applyPatch(data.patch); }",
627
+ " showToast(msg + (((data.imported > 0) || (data.replaced > 0)) ? PLAYLIST_HINT : ''), 'success');",
615
628
  " } else {",
616
- " showToast('M3U import failed: ' + (data.error || 'Unknown error'), 'error');",
629
+ " showToast('M3U import failed: ' + extractErrorMessage(data, 'Unknown error'), 'error');",
617
630
  " }",
618
631
  " } catch(err) {",
619
- " console.error('M3U import error:', err);",
632
+ " console.error('M3U channel import failed.', err);",
620
633
  " showToast('Failed to import M3U: ' + err.message, 'error');",
621
634
  " }",
622
635
  " fileInput.value = '';",
@@ -625,174 +638,122 @@ export function generateConfigSubtabScript() {
625
638
  " };",
626
639
  // Client-side validation for numeric inputs.
627
640
  " function validateInput(input) {",
628
- " var min = input.min !== '' ? Number(input.min) : null;",
629
- " var max = input.max !== '' ? Number(input.max) : null;",
630
- " var value = Number(input.value);",
631
- " var isValid = true;",
632
- " if (input.type === 'number') {",
633
- " if (isNaN(value)) { isValid = false; }",
634
- " else if (min !== null && value < min) { isValid = false; }",
635
- " else if (max !== null && value > max) { isValid = false; }",
636
- " }",
637
- " if (isValid) {",
641
+ " const min = input.min !== '' ? Number(input.min) : null;",
642
+ " const max = input.max !== '' ? Number(input.max) : null;",
643
+ " const value = Number(input.value);",
644
+ " let isValid = true;",
645
+ " if(input.type === 'number') {",
646
+ " if(isNaN(value)) { isValid = false; }",
647
+ " else if((min !== null) && (value < min)) { isValid = false; }",
648
+ " else if((max !== null) && (value > max)) { isValid = false; }",
649
+ " }",
650
+ " if(isValid) {",
638
651
  " input.classList.remove('error');",
639
652
  " } else {",
640
653
  " input.classList.add('error');",
641
654
  " }",
642
655
  " return isValid;",
643
656
  " }",
644
- // Subtab switching function.
645
- " function switchSubtab(subtab, updateUrl) {",
646
- " var btns = document.querySelectorAll('.subtab-btn');",
647
- " var panels = document.querySelectorAll('.subtab-panel');",
648
- " var settingsButtons = document.getElementById('settings-buttons');",
649
- " for (var i = 0; i < btns.length; i++) {",
650
- " btns[i].classList.remove('active');",
651
- " btns[i].setAttribute('aria-selected', 'false');",
652
- " if (btns[i].getAttribute('data-subtab') === subtab) {",
653
- " btns[i].classList.add('active');",
654
- " btns[i].setAttribute('aria-selected', 'true');",
655
- " }",
656
- " }",
657
- " for (var j = 0; j < panels.length; j++) {",
658
- " panels[j].classList.remove('active');",
659
- " if (panels[j].id === 'subtab-' + subtab) {",
660
- " panels[j].classList.add('active');",
661
- " }",
662
- " }",
663
- // Show/hide settings buttons based on subtab (hidden on backup subtab).
664
- " if (settingsButtons) {",
665
- " settingsButtons.style.display = (subtab === 'backup') ? 'none' : 'flex';",
666
- " }",
667
- // Update localStorage and URL hash.
668
- " try { localStorage.setItem('prismcast-config-subtab', subtab); } catch(e) {}",
669
- " if (updateUrl !== false) {",
670
- " var newHash = '#config/' + subtab;",
671
- " if (window.location.hash !== newHash) {",
672
- " window.location.hash = newHash;",
673
- " }",
674
- " }",
675
- " }",
676
- // Expose for main tab script to use.
657
+ // Subtab switching via the shared factory. The onSwitch callback handles the config-specific behavior of hiding settings buttons on the backup subtab.
658
+ " const switchSubtab = createSubtabSwitcher({",
659
+ " btnSelector: '.subtab-btn',",
660
+ " dataAttr: 'data-subtab',",
661
+ " hashFn: (s) => '#config/' + s,",
662
+ " onSwitch: (subtab) => {",
663
+ " const settingsButtons = document.getElementById('settings-buttons');",
664
+ " if(settingsButtons) settingsButtons.style.display = (subtab === 'backup') ? 'none' : 'flex';",
665
+ " },",
666
+ " panelPrefix: 'subtab-',",
667
+ " panelSelector: '.subtab-panel',",
668
+ " storageKey: 'prismcast-config-subtab'",
669
+ " });",
677
670
  " window.switchConfigSubtab = switchSubtab;",
678
- // Attach click handlers to subtab buttons.
679
- " var subtabBtns = document.querySelectorAll('.subtab-btn');",
680
- " for (var i = 0; i < subtabBtns.length; i++) {",
681
- " subtabBtns[i].addEventListener('click', function() {",
682
- " switchSubtab(this.getAttribute('data-subtab'));",
683
- " });",
684
- " }",
685
671
  // Channel edit form show/hide functions.
686
- " window.showEditForm = function(key) {",
687
- " var displayRow = document.getElementById('display-row-' + key);",
688
- " var editRow = document.getElementById('edit-row-' + key);",
689
- " if (displayRow) displayRow.style.display = 'none';",
690
- " if (editRow) {",
672
+ " window.showEditForm = (key) => {",
673
+ " const displayRow = document.getElementById('display-row-' + key);",
674
+ " const editRow = document.getElementById('edit-row-' + key);",
675
+ " if(displayRow) displayRow.style.display = 'none';",
676
+ " if(editRow) {",
691
677
  " editRow.style.display = '';",
692
678
  " updateSelectorSuggestions('edit-url-' + key, 'edit-' + key + '-selectorList');",
693
- " var urlInput = document.getElementById('edit-url-' + key);",
694
- " if (urlInput && !urlInput.dataset.selectorBound) {",
695
- " urlInput.addEventListener('input', function() { updateSelectorSuggestions('edit-url-' + key, 'edit-' + key + '-selectorList'); });",
679
+ " const urlInput = document.getElementById('edit-url-' + key);",
680
+ " if(urlInput && !urlInput.dataset.selectorBound) {",
681
+ " urlInput.addEventListener('input', () => { updateSelectorSuggestions('edit-url-' + key, 'edit-' + key + '-selectorList'); });",
696
682
  " urlInput.dataset.selectorBound = 'true';",
697
683
  " }",
698
684
  " }",
699
685
  " };",
700
- " window.hideEditForm = function(key) {",
701
- " var displayRow = document.getElementById('display-row-' + key);",
702
- " var editRow = document.getElementById('edit-row-' + key);",
703
- " if (displayRow) displayRow.style.display = '';",
704
- " if (editRow) editRow.style.display = 'none';",
686
+ " window.hideEditForm = (key) => {",
687
+ " const displayRow = document.getElementById('display-row-' + key);",
688
+ " const editRow = document.getElementById('edit-row-' + key);",
689
+ " if(displayRow) displayRow.style.display = '';",
690
+ " if(editRow) editRow.style.display = 'none';",
691
+ " };",
692
+ // Reset a single channel edit form field to its predefined default. Reads the data-default attribute, sets the value, removes the modified treatment
693
+ // (border accent, dot, reset button), and dispatches events so dependent handlers (e.g., tag checkbox sync) update. Same pattern as the settings form's
694
+ // resetSetting() but for channel fields within the edit form.
695
+ " window.resetChannelField = (inputId) => {",
696
+ " const input = document.getElementById(inputId);",
697
+ " if(!input) return;",
698
+ " const defaultVal = input.getAttribute('data-default');",
699
+ " if(defaultVal === null) return;",
700
+ " setInputValue(input, defaultVal);",
701
+ " const row = input.closest('.form-row');",
702
+ " if(row) {",
703
+ " row.classList.remove('modified');",
704
+ " const dot = row.querySelector('.modified-dot');",
705
+ " if(dot) dot.remove();",
706
+ " const btn = row.querySelector('.btn-reset');",
707
+ " if(btn) btn.remove();",
708
+ " }",
709
+ " input.dispatchEvent(new Event('input', { bubbles: true }));",
710
+ " input.dispatchEvent(new Event('change', { bubbles: true }));",
705
711
  " };",
706
- // Select a provider pill in the add form. Clicking an inactive pill highlights it, fills the URL field, sets profile to autodetect (disabled), and expands
707
- // the advanced section (channel selector is essential for provider channels). Clicking the active pill deselects it, clears the URL, and re-enables the
712
+ // Select a service pill in the add form. Clicking an inactive pill highlights it, fills the URL field, sets profile to autodetect (disabled), and expands
713
+ // the advanced section (channel selector is essential for service channels). Clicking the active pill deselects it, clears the URL, and re-enables the
708
714
  // profile dropdown for manual entry mode.
709
- " window.selectProviderPill = function(btn) {",
710
- " var wasActive = btn.classList.contains('active');",
711
- " var pills = btn.parentElement.querySelectorAll('.provider-pill');",
712
- " for(var i = 0; i < pills.length; i++) pills[i].classList.remove('active');",
715
+ " window.selectServicePill = (btn) => {",
716
+ " const wasActive = btn.classList.contains('active');",
717
+ " const pills = btn.parentElement.querySelectorAll('.provider-pill');",
718
+ " for(const pill of pills) pill.classList.remove('active');",
713
719
  " if(!wasActive) btn.classList.add('active');",
714
- " var urlInput = document.getElementById('add-url');",
720
+ " const urlInput = document.getElementById('add-url');",
715
721
  " if(urlInput) {",
716
722
  " urlInput.value = wasActive ? '' : (btn.getAttribute('data-url') || '');",
717
723
  " urlInput.dispatchEvent(new Event('input', { bubbles: true }));",
718
724
  " }",
719
- " var profileSelect = document.getElementById('add-profile');",
725
+ " const profileSelect = document.getElementById('add-profile');",
720
726
  " if(profileSelect) {",
721
727
  " if(!wasActive) { profileSelect.value = ''; profileSelect.disabled = true; }",
722
728
  " else { profileSelect.disabled = false; }",
723
729
  " }",
724
730
  " if(!wasActive) {",
725
- " var advanced = document.getElementById('add-advanced');",
726
- " var toggle = advanced ? advanced.previousElementSibling : null;",
731
+ " const advanced = document.getElementById('add-advanced');",
732
+ " const toggle = advanced ? advanced.previousElementSibling : null;",
727
733
  " if(advanced && !advanced.classList.contains('show')) {",
728
734
  " advanced.classList.add('show');",
729
735
  " if(toggle) toggle.textContent = 'Hide Advanced Options';",
730
736
  " }",
731
737
  " }",
732
738
  " };",
733
- // Hide the add form and reset all fields and provider pill selection.
734
- " window.hideAddForm = function() {",
735
- " var addForm = document.getElementById('add-channel-form');",
736
- " if (addForm) addForm.style.display = 'none';",
737
- " if (addForm) {",
739
+ // Hide the add form and reset all fields and service pill selection.
740
+ " window.hideAddForm = () => {",
741
+ " const addForm = document.getElementById('add-channel-form');",
742
+ " if(addForm) addForm.style.display = 'none';",
743
+ " if(addForm) {",
738
744
  " addForm.querySelector('form').reset();",
739
- " var pills = addForm.querySelectorAll('.provider-pill');",
740
- " for(var i = 0; i < pills.length; i++) pills[i].classList.remove('active');",
741
- " var profileSelect = addForm.querySelector('#add-profile');",
745
+ " const pills = addForm.querySelectorAll('.provider-pill');",
746
+ " for(const pill of pills) pill.classList.remove('active');",
747
+ " const profileSelect = addForm.querySelector('#add-profile');",
742
748
  " if(profileSelect) profileSelect.disabled = false;",
743
749
  " }",
744
750
  " };",
745
- // Insert or replace channel rows in the table. Always removes existing rows with the same key first (handles both edits and overrides of builtin channels).
746
- // Uses the current table sort field and direction for correct insertion order.
747
- " window.insertChannelRow = function(html, key) {",
748
- " var tbody = document.querySelector('.channel-table tbody');",
749
- " var table = document.querySelector('.channel-table');",
750
- " if (!tbody || !html || !table) return;",
751
- // Remove any existing rows with this key (edit or override of builtin).
752
- " var oldDisplay = document.getElementById('display-row-' + key);",
753
- " var oldEdit = document.getElementById('edit-row-' + key);",
754
- " if (oldEdit) oldEdit.remove();",
755
- " if (oldDisplay) oldDisplay.remove();",
756
- // Create temporary container to parse HTML.
757
- " var temp = document.createElement('tbody');",
758
- " temp.innerHTML = html.displayRow + (html.editRow || '');",
759
- " var newDisplayRow = temp.firstElementChild;",
760
- " var newEditRow = temp.children[1] || null;",
761
- // Find insertion point using current sort field and direction.
762
- " var sortField = table.getAttribute('data-sort-field') || 'name';",
763
- " var sortDir = table.getAttribute('data-sort-dir') || 'asc';",
764
- " var newVal = getSortValue(newDisplayRow, sortField);",
765
- " var rows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
766
- " var inserted = false;",
767
- " for (var i = 0; i < rows.length; i++) {",
768
- " var rowVal = getSortValue(rows[i], sortField);",
769
- " var cmp = (sortDir === 'asc') ? (newVal < rowVal) : (newVal > rowVal);",
770
- " if (cmp) {",
771
- " tbody.insertBefore(newDisplayRow, rows[i]);",
772
- " if (newEditRow) tbody.insertBefore(newEditRow, rows[i]);",
773
- " inserted = true;",
774
- " break;",
775
- " }",
776
- " }",
777
- " if (!inserted) {",
778
- " tbody.appendChild(newDisplayRow);",
779
- " if (newEditRow) tbody.appendChild(newEditRow);",
780
- " }",
781
- " processChannelLogos();",
782
- " };",
783
- // Remove channel rows from the table.
784
- " window.removeChannelRow = function(key) {",
785
- " var displayRow = document.getElementById('display-row-' + key);",
786
- " var editRow = document.getElementById('edit-row-' + key);",
787
- " if (displayRow) displayRow.remove();",
788
- " if (editRow) editRow.remove();",
789
- " };",
790
751
  // Advanced fields toggle for channel forms.
791
- " window.toggleAdvanced = function(prefix) {",
792
- " var fields = document.getElementById(prefix + '-advanced');",
793
- " var toggle = document.getElementById(prefix + '-toggle');",
794
- " if (fields && toggle) {",
795
- " if (fields.classList.contains('show')) {",
752
+ " window.toggleAdvanced = (prefix) => {",
753
+ " const fields = document.getElementById(prefix + '-advanced');",
754
+ " const toggle = document.getElementById(prefix + '-toggle');",
755
+ " if(fields && toggle) {",
756
+ " if(fields.classList.contains('show')) {",
796
757
  " fields.classList.remove('show');",
797
758
  " toggle.textContent = '\\u25B6 Show Advanced Options';",
798
759
  " } else {",
@@ -802,22 +763,22 @@ export function generateConfigSubtabScript() {
802
763
  " }",
803
764
  " };",
804
765
  // Profile reference toggle.
805
- " window.toggleProfileReference = function() {",
806
- " var ref = document.getElementById('profile-reference');",
807
- " if (ref) {",
766
+ " window.toggleProfileReference = () => {",
767
+ " const ref = document.getElementById('profile-reference');",
768
+ " if(ref) {",
808
769
  " ref.style.display = ref.style.display === 'none' ? 'block' : 'none';",
809
770
  " }",
810
771
  " };",
811
- // Inline cell editing for channel number and station ID. Clicking an editable cell replaces its content with a text input. Enter or blur saves the value
812
- // via the existing channel POST endpoint. Escape cancels and restores the original value. The cell's data attributes carry the field name, channel key, and
813
- // current value so the handler is generic across both fields.
814
- " window.startInlineEdit = function(td) {",
772
+ // Inline cell editing for channel number and station ID. Clicking an editable cell replaces its content with a text input. Enter or blur PATCHes the channel
773
+ // record at /config/channels/:key with a typed partial update. Escape cancels and restores the original value. The cell's data attributes carry the field
774
+ // name, channel key, and current value so the handler is generic across both fields.
775
+ " window.startInlineEdit = (td) => {",
815
776
  " if(td.querySelector('input.inline-edit')) return;",
816
- " var field = td.getAttribute('data-field');",
817
- " var key = td.getAttribute('data-key');",
818
- " var value = td.getAttribute('data-value') || '';",
819
- " var original = td.innerHTML;",
820
- " var input = document.createElement('input');",
777
+ " const field = td.getAttribute('data-field');",
778
+ " const key = td.getAttribute('data-key');",
779
+ " const value = td.getAttribute('data-value') || '';",
780
+ " const original = td.innerHTML;",
781
+ " const input = document.createElement('input');",
821
782
  " input.type = (field === 'channelNumber') ? 'number' : 'text';",
822
783
  " input.className = 'inline-edit';",
823
784
  " input.value = value;",
@@ -825,55 +786,58 @@ export function generateConfigSubtabScript() {
825
786
  " td.appendChild(input);",
826
787
  " input.focus();",
827
788
  " input.select();",
828
- " var saving = false;",
789
+ " let saving = false;",
829
790
  " async function save() {",
830
791
  " if(saving) return;",
831
792
  " saving = true;",
832
- " var newValue = input.value.trim();",
793
+ " const newValue = input.value.trim();",
833
794
  " if(newValue === value) { cancel(); return; }",
834
- " var data = { action: 'inline-edit', field: field, key: key, value: newValue };",
795
+ // Build a typed partial-update body. channelNumber is parsed as an integer (null clears). stationId uses "" for empty converted to null.
796
+ " const body = {};",
797
+ " if(field === 'channelNumber') body.channelNumber = newValue ? parseInt(newValue, 10) : null;",
798
+ " else if(field === 'stationId') body.stationId = newValue || null;",
799
+ " else body[field] = newValue;",
835
800
  " try {",
836
- " var res = await fetch('/config/channels', {",
837
- " method: 'POST',",
838
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
839
- " body: JSON.stringify(data)",
801
+ " const res = await fetch('/config/channels/' + encodeURIComponent(key), {",
802
+ " body: JSON.stringify(body),",
803
+ " headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },",
804
+ " method: 'PATCH'",
840
805
  " });",
841
- " var d = await res.json();",
806
+ " const d = await res.json();",
842
807
  " if(res.ok && d.success) {",
843
- " if(d.patch) { applyChannelPatch(d.patch); }",
808
+ " if(d.patch) { channelTable.applyPatch(d.patch); }",
844
809
  " } else {",
845
810
  " td.innerHTML = original;",
846
- " showToast(d.message || 'Failed to save.', 'error');",
811
+ " showToast(extractErrorMessage(d, 'Failed to save.'), 'error');",
847
812
  " }",
848
813
  " } catch(err) {",
849
- " console.error('Inline edit error:', err);",
814
+ " console.error('Channel inline edit failed.', err);",
850
815
  " td.innerHTML = original;",
851
816
  " showToast('Failed to save: ' + err.message, 'error');",
852
817
  " }",
853
818
  " }",
854
819
  " function cancel() { td.innerHTML = original; }",
855
- " input.addEventListener('keydown', function(e) {",
820
+ " input.addEventListener('keydown', (e) => {",
856
821
  " if(e.key === 'Enter') { e.preventDefault(); save(); }",
857
822
  " else if(e.key === 'Escape') { cancel(); }",
858
823
  " });",
859
- " input.addEventListener('blur', function() { if(!saving) save(); });",
824
+ " input.addEventListener('blur', () => { if(!saving) save(); });",
860
825
  " };",
861
826
  // Toggle HDHR lineup inclusion for a channel via inline checkbox. Uses the inline-edit endpoint with hdhrEnabled field.
862
- " window.toggleHdhr = async function(checkbox) {",
863
- " var key = checkbox.getAttribute('data-key');",
864
- " var value = checkbox.checked ? 'true' : 'false';",
827
+ " window.toggleHdhr = async (checkbox) => {",
828
+ " const key = checkbox.getAttribute('data-key');",
865
829
  " try {",
866
- " var res = await fetch('/config/channels', {",
867
- " method: 'POST',",
868
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
869
- " body: JSON.stringify({ action: 'inline-edit', field: 'hdhrEnabled', key: key, value: value })",
830
+ " const res = await fetch('/config/channels/' + encodeURIComponent(key), {",
831
+ " body: JSON.stringify({ hdhrEnabled: checkbox.checked }),",
832
+ " headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },",
833
+ " method: 'PATCH'",
870
834
  " });",
871
- " var d = await res.json();",
835
+ " const d = await res.json();",
872
836
  " if(res.ok && d.success) {",
873
- " if(d.patch) { applyChannelPatch(d.patch); }",
837
+ " if(d.patch) { channelTable.applyPatch(d.patch); }",
874
838
  " } else {",
875
839
  " checkbox.checked = !checkbox.checked;",
876
- " showToast(d.message || 'Failed to update HDHR setting.', 'error');",
840
+ " showToast(extractErrorMessage(d, 'Failed to update HDHR setting.'), 'error');",
877
841
  " }",
878
842
  " } catch(err) {",
879
843
  " checkbox.checked = !checkbox.checked;",
@@ -881,520 +845,456 @@ export function generateConfigSubtabScript() {
881
845
  " }",
882
846
  " };",
883
847
  // Submit channel form via AJAX (add or edit).
884
- " window.submitChannelForm = async function(event, action) {",
848
+ " window.submitChannelForm = async (event, action) => {",
885
849
  " event.preventDefault();",
886
- " var form = event.target;",
887
- " var formData = new FormData(form);",
888
- " var data = {};",
889
- " for (var pair of formData.entries()) { data[pair[0]] = pair[1]; }",
850
+ " const form = event.target;",
851
+ " const formData = new FormData(form);",
852
+ " const data = {};",
853
+ " for(const pair of formData.entries()) { data[pair[0]] = pair[1]; }",
854
+ // RESTful split: new channels go to POST /config/channels; edits go to PUT /config/channels/:key.
855
+ " const url = (action === 'add') ? '/config/channels' : ('/config/channels/' + encodeURIComponent(data.key));",
856
+ " const method = (action === 'add') ? 'POST' : 'PUT';",
890
857
  " try {",
891
- " var res = await fetch('/config/channels', {",
892
- " method: 'POST',",
893
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
894
- " body: JSON.stringify(data)",
858
+ " const res = await fetch(url, {",
859
+ " body: JSON.stringify(data),",
860
+ " headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },",
861
+ " method: method",
895
862
  " });",
896
- " var d = await res.json();",
897
- " if (res.ok && d.success) {",
863
+ " const d = await res.json();",
864
+ " if(res.ok && d.success) {",
898
865
  " showToast(d.message, 'success');",
899
- " if (d.patch) { applyChannelPatch(d.patch); }",
900
- " if (action === 'add') {",
866
+ " if(d.patch) { channelTable.applyPatch(d.patch); }",
867
+ " if(action === 'add') {",
901
868
  " hideAddForm();",
869
+ " if(d.serviceWarning) { showServiceFilterWarning(d.serviceWarning); }",
902
870
  " } else {",
903
871
  " hideEditForm(d.key);",
904
872
  " }",
905
- " } else if (d.errors) {",
906
- " var errorMsgs = [];",
907
- " for (var field in d.errors) { errorMsgs.push(field + ': ' + d.errors[field]); }",
908
- " showToast('Validation errors: ' + errorMsgs.join(', '), 'error');",
909
873
  " } else {",
910
- " showToast(d.message || 'Failed to save channel.', 'error');",
874
+ " showToast(extractErrorMessage(d, 'Failed to save channel.'), 'error');",
911
875
  " }",
912
876
  " } catch(err) {",
913
- " console.error('Channel save error:', err);",
877
+ " console.error('Channel save request failed.', err);",
914
878
  " showToast('Failed to save channel: ' + err.message, 'error');",
915
879
  " }",
916
880
  " return false;",
917
881
  " };",
918
882
  // Delete channel via AJAX.
919
- " window.deleteChannel = async function(key) {",
920
- " if (!confirm('Delete channel ' + key + '?')) return;",
883
+ " window.deleteChannel = async (key) => {",
884
+ " if(!confirm('Delete channel ' + key + '?')) return;",
921
885
  " try {",
922
- " var res = await fetch('/config/channels', {",
923
- " method: 'POST',",
924
- " headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },",
925
- " body: JSON.stringify({ action: 'delete', key: key })",
886
+ " const res = await fetch('/config/channels/' + encodeURIComponent(key), {",
887
+ " headers: { 'Accept': 'application/json' },",
888
+ " method: 'DELETE'",
926
889
  " });",
927
- " var d = await res.json();",
928
- " if (res.ok && d.success) {",
890
+ " const d = await res.json();",
891
+ " if(res.ok && d.success) {",
929
892
  " showToast(d.message, 'success');",
930
- " if (d.patch) { applyChannelPatch(d.patch); }",
893
+ " if(d.patch) { channelTable.applyPatch(d.patch); }",
931
894
  " } else {",
932
- " showToast(d.message || 'Failed to delete channel.', 'error');",
895
+ " showToast(extractErrorMessage(d, 'Failed to delete channel.'), 'error');",
933
896
  " }",
934
897
  " } catch(err) {",
935
- " console.error('Channel delete error:', err);",
898
+ " console.error('Channel delete request failed.', err);",
936
899
  " showToast('Failed to delete channel: ' + err.message, 'error');",
937
900
  " }",
938
901
  " };",
939
902
  // Toggle a single predefined channel's enabled/disabled state.
940
- " window.togglePredefinedChannel = async function(key, enable) {",
903
+ " window.togglePredefinedChannel = async (key, enable) => {",
941
904
  " try {",
942
- " var res = await fetch('/config/channels/toggle-predefined', {",
943
- " method: 'POST',",
905
+ " const res = await fetch('/config/channels/toggle-predefined', {",
906
+ " body: JSON.stringify({ key: key, enabled: enable }),",
944
907
  " headers: { 'Content-Type': 'application/json' },",
945
- " body: JSON.stringify({ key: key, enabled: enable })",
908
+ " method: 'POST'",
946
909
  " });",
947
- " var result = await res.json();",
948
- " if (result.success) {",
910
+ " const result = await res.json();",
911
+ " if(result.success) {",
949
912
  " showToast('Channel ' + key + ' ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
950
- " if(result.patch) { applyChannelPatch(result.patch); }",
913
+ " if(result.patch) { channelTable.applyPatch(result.patch); }",
951
914
  " } else {",
952
- " showToast(result.error || 'Failed to toggle channel.', 'error');",
915
+ " showToast(extractErrorMessage(result, 'Failed to toggle channel.'), 'error');",
953
916
  " }",
954
917
  " } catch(err) {",
955
- " console.error('Channel toggle error:', err);",
918
+ " console.error('Channel toggle request failed.', err);",
956
919
  " showToast('Failed to toggle channel: ' + err.message, 'error');",
957
920
  " }",
958
921
  " };",
959
- // Update a channel row's provider selection in-place. Syncs the HTML selected attribute so filterChannelRows() restore logic works correctly. We iterate
922
+ // Update a channel row's service selection in-place. Syncs the HTML selected attribute so channelTable.filter() restore logic works correctly. We iterate
960
923
  // _allOptions (if present) rather than querySelectorAll because filtered-out options are removed from the DOM but still tracked in the array.
961
- " function updateChannelProviderUI(channelKey, variant) {",
962
- " var row = document.getElementById('display-row-' + channelKey);",
963
- " if (!row) return;",
964
- " var sel = row.querySelector('.provider-select');",
965
- " if (!sel) return;",
924
+ " function updateChannelServiceUI(channelKey, variant) {",
925
+ " const row = document.getElementById('display-row-' + channelKey);",
926
+ " if(!row) return;",
927
+ " const sel = row.querySelector('.provider-select');",
928
+ " if(!sel) return;",
966
929
  " sel.value = variant;",
967
- " var allOpts = sel._allOptions || Array.prototype.slice.call(sel.querySelectorAll('option'));",
968
- " for (var oi = 0; oi < allOpts.length; oi++) {",
969
- " if (allOpts[oi].value === variant) { allOpts[oi].setAttribute('selected', ''); }",
970
- " else { allOpts[oi].removeAttribute('selected'); }",
930
+ " const allOpts = sel._allOptions || [...sel.querySelectorAll('option')];",
931
+ " for(const opt of allOpts) {",
932
+ " if(opt.value === variant) { opt.setAttribute('selected', ''); }",
933
+ " else { opt.removeAttribute('selected'); }",
971
934
  " }",
972
935
  " }",
973
- // Update provider selection for a multi-provider channel.
974
- " window.updateProviderSelection = async function(selectElement) {",
975
- " var channelKey = selectElement.getAttribute('data-channel');",
976
- " var providerKey = selectElement.value;",
936
+ // Update service selection for a multi-service channel.
937
+ " window.updateServiceSelection = async (selectElement) => {",
938
+ " const channelKey = selectElement.getAttribute('data-channel');",
939
+ " const serviceKey = selectElement.value;",
977
940
  " try {",
978
- " var res = await fetch('/config/provider', {",
979
- " method: 'POST',",
941
+ " const res = await fetch('/config/channels/' + encodeURIComponent(channelKey) + '/service', {",
942
+ " body: JSON.stringify({ service: serviceKey }),",
980
943
  " headers: { 'Content-Type': 'application/json' },",
981
- " body: JSON.stringify({ channel: channelKey, provider: providerKey })",
944
+ " method: 'PUT'",
982
945
  " });",
983
- " var result = await res.json();",
984
- " if (result.success) {",
985
- " showToast('Provider updated. New streams will use the selected provider.', 'success');",
986
- " if (result.patch) { applyChannelPatch(result.patch); }",
946
+ " const result = await res.json();",
947
+ " if(result.success) {",
948
+ " showToast('Service updated. New streams will use the selected service.', 'success');",
949
+ " if(result.patch) { channelTable.applyPatch(result.patch); }",
987
950
  " } else {",
988
- " showToast(result.error || 'Failed to update provider.', 'error');",
951
+ " showToast(extractErrorMessage(result, 'Failed to update service.'), 'error');",
989
952
  " }",
990
953
  " } catch(err) {",
991
- " console.error('Provider update error:', err);",
992
- " showToast('Failed to update provider: ' + err.message, 'error');",
954
+ " console.error('Service selection update failed.', err);",
955
+ " showToast('Failed to update service: ' + err.message, 'error');",
993
956
  " }",
994
957
  " };",
995
958
  // Bulk toggle predefined channels by scope (all, pacific, east).
996
- " window.bulkTogglePredefined = async function(enable, scope) {",
959
+ " window.bulkTogglePredefined = async (enable, scope) => {",
997
960
  " try {",
998
- " var res = await fetch('/config/channels/bulk-toggle-predefined', {",
999
- " method: 'POST',",
961
+ " const res = await fetch('/config/channels/bulk-toggle-predefined', {",
962
+ " body: JSON.stringify({ enabled: enable, scope: scope }),",
1000
963
  " headers: { 'Content-Type': 'application/json' },",
1001
- " body: JSON.stringify({ enabled: enable, scope: scope })",
964
+ " method: 'POST'",
1002
965
  " });",
1003
- " var result = await res.json();",
1004
- " if (result.success) {",
1005
- " var labels = { all: 'All predefined', pacific: 'Pacific predefined', east: 'East predefined' };",
966
+ " const result = await res.json();",
967
+ " if(result.success) {",
968
+ " const labels = { all: 'All predefined', pacific: 'Pacific predefined', east: 'East predefined' };",
1006
969
  " showToast((labels[scope] || 'Predefined') + ' channels ' + (enable ? 'enabled' : 'disabled') + '.' + PLAYLIST_HINT, 'success');",
1007
- " if (result.patch) { applyChannelPatch(result.patch); }",
970
+ " if(result.patch) { channelTable.applyPatch(result.patch); }",
1008
971
  " } else {",
1009
- " showToast(result.error || 'Failed to toggle channels.', 'error');",
972
+ " showToast(extractErrorMessage(result, 'Failed to toggle channels.'), 'error');",
1010
973
  " }",
1011
974
  " } catch(err) {",
1012
- " console.error('Bulk toggle error:', err);",
975
+ " console.error('Bulk channel toggle failed.', err);",
1013
976
  " showToast('Failed to toggle channels: ' + err.message, 'error');",
1014
977
  " }",
1015
978
  " };",
1016
979
  // Auto-number visible channels sequentially based on the current sort order. Reads the starting number from the input in the Quick Actions dropdown. When
1017
980
  // the input is empty, clears all channel numbers from visible channels instead of assigning them.
1018
- " window.autoNumberChannels = async function() {",
1019
- " var startInput = document.getElementById('auto-number-start');",
1020
- " var raw = startInput ? startInput.value.trim() : '1';",
1021
- " var clear = (raw === '');",
1022
- " var start = clear ? 0 : parseInt(raw, 10);",
981
+ " window.autoNumberChannels = async () => {",
982
+ " const startInput = document.getElementById('auto-number-start');",
983
+ " const raw = startInput ? startInput.value.trim() : '1';",
984
+ " const clear = (raw === '');",
985
+ " let start = clear ? 0 : parseInt(raw, 10);",
1023
986
  " if(!clear && (isNaN(start) || (start < 1))) start = 1;",
1024
- " var table = document.querySelector('.channel-table');",
1025
- " var sortField = table ? (table.getAttribute('data-sort-field') || 'name') : 'name';",
1026
- " var sortDir = table ? (table.getAttribute('data-sort-dir') || 'asc') : 'asc';",
1027
- " var msg = clear ? 'Clear channel numbers from all visible channels?' " +
987
+ " const table = document.querySelector('.channel-table');",
988
+ " const sortField = table ? (table.getAttribute('data-sort-field') || 'name') : 'name';",
989
+ " const sortDir = table ? (table.getAttribute('data-sort-dir') || 'asc') : 'asc';",
990
+ " const msg = clear ? 'Clear channel numbers from all visible channels?' " +
1028
991
  ": 'Assign channel numbers starting from ' + start + ' to all visible channels in the current sort order? This will overwrite existing numbers.';",
1029
992
  " if(!confirm(msg)) return;",
1030
993
  " try {",
1031
- " var res = await fetch('/config/channels/auto-number', {",
1032
- " method: 'POST',",
994
+ " const res = await fetch('/config/channels/auto-number', {",
995
+ " body: JSON.stringify({ start: start, sortField: sortField, sortDirection: sortDir }),",
1033
996
  " headers: { 'Content-Type': 'application/json' },",
1034
- " body: JSON.stringify({ start: start, sortField: sortField, sortDirection: sortDir })",
997
+ " method: 'POST'",
1035
998
  " });",
1036
- " var result = await res.json();",
999
+ " const result = await res.json();",
1037
1000
  " if(result.success) {",
1038
1001
  " showToast(result.message || 'Channels numbered.', 'success');",
1039
- " if(result.patch) { applyChannelPatch(result.patch); }",
1002
+ " if(result.patch) { channelTable.applyPatch(result.patch); }",
1040
1003
  " } else {",
1041
- " showToast(result.error || 'Failed to auto-number channels.', 'error');",
1004
+ " showToast(extractErrorMessage(result, 'Failed to auto-number channels.'), 'error');",
1042
1005
  " }",
1043
1006
  " } catch(err) {",
1044
- " console.error('Auto-number error:', err);",
1007
+ " console.error('Channel auto-numbering failed.', err);",
1045
1008
  " showToast('Failed to auto-number channels: ' + err.message, 'error');",
1046
1009
  " }",
1047
1010
  " };",
1048
1011
  // Bulk toggle HDHR lineup inclusion for all visible channels. The tri-state checkbox calls this on click: from checked (all enabled) it disables all; from
1049
1012
  // unchecked or indeterminate it enables all.
1050
- " window.bulkToggleHdhr = async function() {",
1051
- " var toggle = document.getElementById('hdhr-bulk-toggle');",
1013
+ " window.bulkToggleHdhr = async () => {",
1014
+ " const toggle = document.getElementById('hdhr-bulk-toggle');",
1052
1015
  " if(!toggle) return;",
1053
- " var enable = !toggle.checked;",
1054
- " var msg = enable ? 'Include all visible channels in the HDHomeRun/Plex lineup?' : 'Exclude all visible channels from the HDHomeRun/Plex lineup?';",
1016
+ " const enable = !toggle.checked;",
1017
+ " const msg = enable ? 'Include all visible channels in the HDHomeRun/Plex lineup?' : 'Exclude all visible channels from the HDHomeRun/Plex lineup?';",
1055
1018
  " if(!confirm(msg)) return;",
1056
1019
  " try {",
1057
- " var res = await fetch('/config/channels/hdhr-bulk', {",
1058
- " method: 'POST',",
1020
+ " const res = await fetch('/config/channels/hdhr-bulk', {",
1021
+ " body: JSON.stringify({ enable: enable }),",
1059
1022
  " headers: { 'Content-Type': 'application/json' },",
1060
- " body: JSON.stringify({ enable: enable })",
1023
+ " method: 'POST'",
1061
1024
  " });",
1062
- " var result = await res.json();",
1025
+ " const result = await res.json();",
1063
1026
  " if(result.success) {",
1064
1027
  " showToast(result.message || 'HDHR settings updated.', 'success');",
1065
- " if(result.patch) { applyChannelPatch(result.patch); }",
1028
+ " if(result.patch) { channelTable.applyPatch(result.patch); }",
1066
1029
  " } else {",
1067
- " showToast(result.error || 'Failed to update HDHR settings.', 'error');",
1030
+ " showToast(extractErrorMessage(result, 'Failed to update HDHR settings.'), 'error');",
1068
1031
  " }",
1069
1032
  " } catch(err) {",
1070
- " console.error('HDHR bulk toggle error:', err);",
1033
+ " console.error('HDHR bulk toggle failed.', err);",
1071
1034
  " showToast('Failed to update HDHR settings: ' + err.message, 'error');",
1072
1035
  " }",
1073
1036
  " };",
1074
1037
  // Bulk add or remove a tag on all visible channels. The add parameter controls the direction: true adds the tag, false removes it. The tristate checkbox in
1075
- // Quick Actions calls this on click — from checked (all have tag) it removes; from unchecked or indeterminate it adds. Uses applyTagResponse (defined in
1038
+ // Quick Actions calls this on click - from checked (all have tag) it removes; from unchecked or indeterminate it adds. Uses applyTagResponse (defined in
1076
1039
  // channels.ts, exposed on window) to update the tag manager modal, filter dropdown, and channel table in one call.
1077
- " window.bulkToggleTag = async function(tag, add) {",
1040
+ " window.bulkToggleTag = async (tag, add) => {",
1078
1041
  " try {",
1079
- " var res = await fetch('/config/channels/bulk-tags', {",
1080
- " method: 'POST',",
1042
+ " const res = await fetch('/config/channels/bulk-tags', {",
1043
+ " body: JSON.stringify({ action: add ? 'add' : 'remove', tag: tag }),",
1081
1044
  " headers: { 'Content-Type': 'application/json' },",
1082
- " body: JSON.stringify({ action: add ? 'add' : 'remove', tag: tag })",
1045
+ " method: 'POST'",
1083
1046
  " });",
1084
- " var result = await res.json();",
1047
+ " const result = await res.json();",
1085
1048
  " if(result.success) {",
1086
1049
  " if(window.applyTagResponse) { window.applyTagResponse(result, result.message || 'Tags updated.'); }",
1087
1050
  " } else {",
1088
- " showToast(result.error || 'Failed to update tags.', 'error');",
1051
+ " showToast(extractErrorMessage(result, 'Failed to update tags.'), 'error');",
1089
1052
  " }",
1090
1053
  " } catch(err) {",
1091
- " console.error('Bulk tag error:', err);",
1054
+ " console.error('Bulk tag update failed.', err);",
1092
1055
  " showToast('Failed to update tags: ' + err.message, 'error');",
1093
1056
  " }",
1094
1057
  " };",
1095
- // Inline tag edit portal — uses toggleDropdown with lifecycle hooks for batch-save behavior. The before-close hook saves pending changes whenever the
1058
+ // Inline tag edit portal - uses toggleDropdown with lifecycle hooks for batch-save behavior. The before-close hook saves pending changes whenever the
1096
1059
  // dropdown closes (click-outside, scroll, resize, or next toggle). The onOpen hook populates checkbox state from the cell's data-tags attribute.
1097
- " var activeTagDropdown = null;",
1098
- " var tagPortal = null;",
1060
+ " let activeTagDropdown = null;",
1061
+ " let tagPortal = null;",
1099
1062
  " function getTagPortal() {",
1100
1063
  " if(!tagPortal) { tagPortal = document.getElementById('inline-tag-portal'); }",
1101
1064
  " return tagPortal;",
1102
1065
  " }",
1103
- " function saveTagDropdownIfChanged() {",
1066
+ " async function saveTagDropdownIfChanged() {",
1104
1067
  " if(!activeTagDropdown) return;",
1105
- " var portal = getTagPortal();",
1068
+ " const portal = getTagPortal();",
1106
1069
  " if(!portal) return;",
1107
- " var original = activeTagDropdown.original;",
1108
- " var key = activeTagDropdown.key;",
1109
- " var checkboxes = portal.querySelectorAll('.inline-tag-checkbox');",
1110
- " var tags = [];",
1111
- " for(var i = 0; i < checkboxes.length; i++) {",
1112
- " if(checkboxes[i].checked) tags.push(checkboxes[i].getAttribute('data-tag'));",
1113
- " }",
1114
- " var newValue = tags.sort().join(',');",
1070
+ " const original = activeTagDropdown.original;",
1071
+ " const key = activeTagDropdown.key;",
1072
+ " const checkboxes = portal.querySelectorAll('.inline-tag-checkbox');",
1073
+ " const tags = [];",
1074
+ " for(const checkbox of checkboxes) {",
1075
+ " if(checkbox.checked) tags.push(checkbox.getAttribute('data-tag'));",
1076
+ " }",
1077
+ " const newValue = tags.toSorted().join(',');",
1115
1078
  " activeTagDropdown = null;",
1116
1079
  " if(newValue === original) return;",
1117
- " fetch('/config/channels', {",
1118
- " method: 'POST',",
1119
- " headers: { 'Content-Type': 'application/json' },",
1120
- " body: JSON.stringify({ action: 'inline-edit', field: 'tags', key: key, value: newValue })",
1121
- " }).then(function(res) { return res.json(); }).then(function(d) {",
1122
- " if(d.success && d.patch) { applyChannelPatch(d.patch); }",
1123
- " else if(!d.success) { showToast(d.message || 'Failed to update tags.', 'error'); }",
1124
- " }).catch(function(err) {",
1080
+ " try {",
1081
+ " const res = await fetch('/config/channels/' + encodeURIComponent(key), {",
1082
+ " body: JSON.stringify({ tags: tags }),",
1083
+ " headers: { 'Content-Type': 'application/json' },",
1084
+ " method: 'PATCH'",
1085
+ " });",
1086
+ " const d = await res.json();",
1087
+ " if(d.success && d.patch) { channelTable.applyPatch(d.patch); }",
1088
+ " else if(!d.success) { showToast(extractErrorMessage(d, 'Failed to update tags.'), 'error'); }",
1089
+ " } catch(err) {",
1090
+ " console.error('Tag edit failed to persist.', err);",
1125
1091
  " showToast('Failed to update tags: ' + err.message, 'error');",
1126
- " });",
1092
+ " }",
1127
1093
  " }",
1128
1094
  // Register the batch-save hook so all close paths (click-outside, scroll, resize, next toggle) trigger save.
1129
- " closeDropdowns.addHook(saveTagDropdownIfChanged);",
1130
- " window.toggleInlineTagDropdown = function(td) {",
1131
- " var portal = getTagPortal();",
1095
+ " dropdowns.addHook(saveTagDropdownIfChanged);",
1096
+ " window.toggleInlineTagDropdown = (td) => {",
1097
+ " const portal = getTagPortal();",
1132
1098
  " if(!portal) return;",
1133
- " var isOpen = activeTagDropdown && (activeTagDropdown.td === td);",
1134
- " closeDropdowns();",
1099
+ " const isOpen = activeTagDropdown && (activeTagDropdown.td === td);",
1100
+ " dropdowns.close();",
1135
1101
  " if(isOpen) return;",
1136
1102
  // Populate checkbox state from the cell's data-tags attribute, then toggle the dropdown with the shared portal positioning.
1137
- " var currentTags = (td.getAttribute('data-tags') || '').split(',').filter(function(t) { return t.length > 0; });",
1138
- " var tagSet = {};",
1139
- " for(var i = 0; i < currentTags.length; i++) tagSet[currentTags[i]] = true;",
1140
- " var checkboxes = portal.querySelectorAll('.inline-tag-checkbox');",
1141
- " for(var j = 0; j < checkboxes.length; j++) {",
1142
- " checkboxes[j].checked = !!tagSet[checkboxes[j].getAttribute('data-tag')];",
1103
+ " const currentTags = (td.getAttribute('data-tags') || '').split(',').filter((t) => t.length > 0);",
1104
+ " const tagSet = {};",
1105
+ " for(const currentTag of currentTags) tagSet[currentTag] = true;",
1106
+ " const checkboxes = portal.querySelectorAll('.inline-tag-checkbox');",
1107
+ " for(const checkbox of checkboxes) {",
1108
+ " checkbox.checked = !!tagSet[checkbox.getAttribute('data-tag')];",
1143
1109
  " }",
1144
1110
  " toggleDropdown(td, { menu: portal });",
1145
- " activeTagDropdown = { key: td.getAttribute('data-key'), original: currentTags.sort().join(','), td: td };",
1111
+ " activeTagDropdown = { key: td.getAttribute('data-key'), original: currentTags.toSorted().join(','), td };",
1146
1112
  " };",
1147
1113
  // Collect checked tag checkboxes into the hidden tags input for form submission. Called when any tag checkbox changes in the channel edit form.
1148
- " window.updateTagsHidden = function(formRow) {",
1149
- " var checkboxes = formRow.querySelectorAll('.tag-checkbox:checked');",
1150
- " var tags = [];",
1151
- " for(var i = 0; i < checkboxes.length; i++) tags.push(checkboxes[i].getAttribute('data-tag'));",
1152
- " var hidden = formRow.querySelector('input[type=\"hidden\"][name=\"tags\"]');",
1153
- " if(hidden) hidden.value = tags.sort().join(', ');",
1114
+ " window.updateTagsHidden = (formRow) => {",
1115
+ " const checkboxes = formRow.querySelectorAll('.tag-checkbox:checked');",
1116
+ " const tags = [];",
1117
+ " for(const checkbox of checkboxes) tags.push(checkbox.getAttribute('data-tag'));",
1118
+ " const hidden = formRow.querySelector('input[type=\"hidden\"][name=\"tags\"]');",
1119
+ " if(hidden) hidden.value = tags.toSorted().join(', ');",
1154
1120
  " };",
1155
1121
  // Revert a channel override back to predefined defaults.
1156
- " window.revertChannel = async function(key) {",
1122
+ " window.revertChannel = async (key) => {",
1157
1123
  " if(!confirm('Revert channel ' + key + ' to predefined defaults?')) return;",
1158
1124
  " try {",
1159
- " var res = await fetch('/config/channels', {",
1160
- " method: 'POST',",
1125
+ " const res = await fetch('/config/channels/' + encodeURIComponent(key) + '/revert', {",
1161
1126
  " headers: { 'Content-Type': 'application/json' },",
1162
- " body: JSON.stringify({ action: 'revert', key: key })",
1127
+ " method: 'POST'",
1163
1128
  " });",
1164
- " var d = await res.json();",
1129
+ " const d = await res.json();",
1165
1130
  " if(res.ok && d.success) {",
1166
1131
  " showToast(d.message, 'success');",
1167
- " if(d.patch) { applyChannelPatch(d.patch); }",
1132
+ " if(d.patch) { channelTable.applyPatch(d.patch); }",
1168
1133
  " } else {",
1169
- " showToast(d.message || 'Failed to revert channel.', 'error');",
1134
+ " showToast(extractErrorMessage(d, 'Failed to revert channel.'), 'error');",
1170
1135
  " }",
1171
1136
  " } catch(err) {",
1172
1137
  " showToast('Failed to revert channel: ' + err.message, 'error');",
1173
1138
  " }",
1174
1139
  " };",
1175
- // Save the provider filter to the server and update the UI on success.
1176
- " async function saveProviderFilter(enabledTags) {",
1140
+ // Save the service filter to the server and update the UI on success.
1141
+ " async function saveServiceFilter(enabledTags) {",
1177
1142
  " try {",
1178
- " var r = await fetch('/config/provider-filter', {",
1179
- " method: 'POST',",
1143
+ " const r = await fetch('/config/service-filter', {",
1144
+ " body: JSON.stringify({ enabledServices: enabledTags }),",
1180
1145
  " headers: { 'Content-Type': 'application/json' },",
1181
- " body: JSON.stringify({ enabledProviders: enabledTags })",
1146
+ " method: 'POST'",
1182
1147
  " });",
1183
- " var result = await r.json();",
1184
- " if (result.success) {",
1185
- " updateProviderChips(enabledTags);",
1186
- " filterChannelRows(enabledTags);",
1148
+ " const result = await r.json();",
1149
+ " if(result.success) {",
1150
+ " updateServiceChips(enabledTags);",
1151
+ " channelTable.filter(enabledTags);",
1187
1152
  " updateBulkAssignOptions(enabledTags);",
1188
- " updateProviderFilterButton(enabledTags);",
1189
- " if(result.patch) { applyChannelPatch(result.patch); }",
1190
- " showToast('Provider filter updated.' + PLAYLIST_HINT, 'success');",
1153
+ " updateServiceFilterButton(enabledTags);",
1154
+ " if(result.patch) { channelTable.applyPatch(result.patch); }",
1155
+ " showToast('Service filter updated.' + PLAYLIST_HINT, 'success');",
1191
1156
  " }",
1192
1157
  " } catch(err) {",
1193
- " console.error('Provider filter error:', err);",
1158
+ " console.error('Service filter update failed.', err);",
1194
1159
  " showToast('Failed to update filter: ' + err.message, 'error');",
1195
1160
  " }",
1196
1161
  " };",
1197
- // Provider filter: toggle a provider tag on/off.
1198
- " window.toggleProviderTag = function(checkbox) {",
1199
- " var menu = checkbox.closest('.provider-dropdown-menu');",
1200
- " if (!menu) return;",
1201
- " var checkboxes = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
1202
- " var enabledTags = [];",
1203
- " for (var i = 0; i < checkboxes.length; i++) {",
1204
- " if (checkboxes[i].checked) enabledTags.push(checkboxes[i].getAttribute('data-tag'));",
1205
- " }",
1206
- // If all checkboxes are checked, clear the filter (empty array = no filter).
1207
- " var allCheckboxes = menu.querySelectorAll('input[type=\"checkbox\"]');",
1208
- " var allChecked = true;",
1209
- " for (var j = 0; j < allCheckboxes.length; j++) {",
1210
- " if (!allCheckboxes[j].checked && !allCheckboxes[j].disabled) { allChecked = false; break; }",
1211
- " }",
1212
- " if (allChecked) enabledTags = [];",
1213
- " saveProviderFilter(enabledTags);",
1162
+ // Show a toast warning when a newly added channel's service isn't in the active filter. Offers a one-click action to enable the service tag.
1163
+ " function showServiceFilterWarning(warning) {",
1164
+ " showToast(warning.serviceLabel + ' is not in your active service filter. Channels won\\'t appear in the playlist until enabled.', 'warning', 10000,",
1165
+ " { label: 'Enable ' + warning.serviceLabel, onclick: () => { enableServiceInFilter(warning.serviceTag); } });",
1166
+ " }",
1167
+ // Enable a service tag in the filter by adding it to the current set and saving. Called by the service filter warning toast action button.
1168
+ " async function enableServiceInFilter(tag) {",
1169
+ " const chips = document.querySelectorAll('#provider-chips .provider-chip[data-tag]');",
1170
+ " const enabledTags = [];",
1171
+ " for(const chip of chips) { enabledTags.push(chip.getAttribute('data-tag')); }",
1172
+ " if(!enabledTags.includes(tag)) { enabledTags.push(tag); }",
1173
+ " await saveServiceFilter(enabledTags);",
1174
+ " }",
1175
+ // Service filter: toggle a service tag on/off. The checkbox change has already been applied to the DOM, so getEnabledFilterTags() reads the new state directly.
1176
+ " window.toggleServiceTag = (checkbox) => {",
1177
+ " saveServiceFilter(channelTable.getEnabledFilterTags());",
1214
1178
  " };",
1215
- // Remove a provider chip (uncheck the tag and update). Orphaned tags (no checkbox in the dropdown) are removed by rebuilding the list from the remaining chips.
1216
- " window.removeProviderChip = function(tag) {",
1217
- " var menu = document.querySelector('.provider-dropdown-menu');",
1218
- " if (!menu) return;",
1219
- " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1220
- " if (cb) { cb.checked = false; toggleProviderTag(cb); return; }",
1221
- // No checkbox exists for this tag — it is an orphaned entry with no corresponding provider. Build the enabled list from the remaining chips and POST directly.
1222
- " var chips = document.querySelectorAll('#provider-chips .provider-chip[data-tag]');",
1223
- " var enabledTags = [];",
1224
- " for (var i = 0; i < chips.length; i++) {",
1225
- " var ct = chips[i].getAttribute('data-tag');",
1226
- " if (ct && ct !== tag) enabledTags.push(ct);",
1227
- " }",
1228
- " saveProviderFilter(enabledTags);",
1179
+ // Remove a service chip (uncheck the tag and update). Orphaned tags (no checkbox in the dropdown) are removed by rebuilding the list from the remaining chips.
1180
+ " window.removeServiceChip = (tag) => {",
1181
+ " const menu = document.querySelector('.provider-dropdown-menu');",
1182
+ " if(!menu) return;",
1183
+ " const cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1184
+ " if(cb) { cb.checked = false; toggleServiceTag(cb); return; }",
1185
+ // No checkbox exists for this tag - it is an orphaned entry with no corresponding service. Build the enabled list from the remaining chips and POST directly.
1186
+ " const chips = document.querySelectorAll('#provider-chips .provider-chip[data-tag]');",
1187
+ " const enabledTags = [];",
1188
+ " for(const chip of chips) {",
1189
+ " const ct = chip.getAttribute('data-tag');",
1190
+ " if(ct && (ct !== tag)) enabledTags.push(ct);",
1191
+ " }",
1192
+ " saveServiceFilter(enabledTags);",
1229
1193
  " };",
1230
- // Update the provider filter button text. The filter icon SVG is inlined here because this runs client-side where the server's icon module is not available.
1231
- " var filterIcon = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
1194
+ // Update the service filter button text. The filter icon SVG is inlined here because this runs client-side where the server's icon module is not available.
1195
+ " const filterIcon = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" " +
1232
1196
  "stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1 2h14l-5 6v5l-4 2V8z\"/></svg>';",
1233
- " function updateProviderFilterButton(enabledTags) {",
1234
- " var btn = document.getElementById('provider-filter-btn');",
1197
+ " function updateServiceFilterButton(enabledTags) {",
1198
+ " const btn = document.getElementById('provider-filter-btn');",
1235
1199
  " if(!btn) return;",
1236
- " var label = (enabledTags.length > 0) ? 'Filtered' : 'All Providers';",
1200
+ " const label = (enabledTags.length > 0) ? 'Filtered' : 'All Services';",
1237
1201
  " btn.innerHTML = filterIcon + ' ' + label + ' &#9662;';",
1238
1202
  " };",
1239
- // Rebuild the provider chips from the enabled tags.
1240
- " function updateProviderChips(enabledTags) {",
1241
- " var container = document.getElementById('provider-chips');",
1242
- " if (!container) return;",
1203
+ // Rebuild the service chips from the enabled tags.
1204
+ " function updateServiceChips(enabledTags) {",
1205
+ " const container = document.getElementById('provider-chips');",
1206
+ " if(!container) return;",
1243
1207
  " container.innerHTML = '';",
1244
- " if (enabledTags.length === 0) return;",
1245
- " var menu = document.querySelector('.provider-dropdown-menu');",
1246
- " for (var i = 0; i < enabledTags.length; i++) {",
1247
- " var tag = enabledTags[i];",
1248
- " if (tag === 'direct') continue;",
1249
- " var label = tag;",
1250
- " var chipDomain = '';",
1251
- " var chipIconUrl = '';",
1252
- " if (menu) {",
1253
- " var cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1254
- " if (cb && cb.parentElement) {",
1208
+ " if(enabledTags.length === 0) return;",
1209
+ " const menu = document.querySelector('.provider-dropdown-menu');",
1210
+ " for(const tag of enabledTags) {",
1211
+ " if(tag === 'direct') continue;",
1212
+ " let label = tag;",
1213
+ " let chipDomain = '';",
1214
+ " let chipIconUrl = '';",
1215
+ " if(menu) {",
1216
+ " const cb = menu.querySelector('input[data-tag=\"' + tag + '\"]');",
1217
+ " if(cb && cb.parentElement) {",
1255
1218
  " label = cb.parentElement.textContent.trim();",
1256
- " var pd = cb.parentElement.querySelector('.provider-display');",
1257
- " if (pd) {",
1219
+ " const pd = cb.parentElement.querySelector('.provider-display');",
1220
+ " if(pd) {",
1258
1221
  " chipDomain = pd.getAttribute('data-domain') || '';",
1259
1222
  " chipIconUrl = pd.getAttribute('data-icon-url') || '';",
1260
1223
  " }",
1261
1224
  " }",
1262
1225
  " }",
1263
- " var chip = document.createElement('span');",
1226
+ " const chip = document.createElement('span');",
1264
1227
  " chip.className = 'provider-chip';",
1265
1228
  " chip.setAttribute('data-tag', tag);",
1266
- " var domainAttr = chipDomain ? ' data-domain=\"' + chipDomain + '\"' : '';",
1267
- " var iconAttr = chipIconUrl ? ' data-icon-url=\"' + chipIconUrl + '\"' : '';",
1229
+ " const domainAttr = chipDomain ? ' data-domain=\"' + chipDomain + '\"' : '';",
1230
+ " const iconAttr = chipIconUrl ? ' data-icon-url=\"' + chipIconUrl + '\"' : '';",
1268
1231
  " chip.innerHTML = '<span class=\"provider-display\"' + domainAttr + iconAttr + ' data-sm>' + label + '</span>' +",
1269
1232
  " '<button type=\"button\" class=\"chip-close\" aria-label=\"Remove ' + label +",
1270
- " '\" onclick=\"removeProviderChip(\\'' + tag + '\\')\">\\u00d7</button>';",
1233
+ " '\" onclick=\"removeServiceChip(\\'' + tag + '\\')\">\\u00d7</button>';",
1271
1234
  " container.appendChild(chip);",
1272
1235
  " }",
1273
- " processProviderDisplays();",
1236
+ " processServiceDisplays();",
1274
1237
  " };",
1275
- // Filter channel rows based on enabled provider tags. Toggles the channel-unavailable class on each row and updates Source column content. Filtered-out options
1276
- // are removed from the DOM entirely rather than hidden — Safari ignores both the hidden attribute and display:none on option elements because they are rendered by
1277
- // the OS native widget. All options (visible and removed) are stored in a _allOptions array on each select for reinsertion when the filter changes. Selection
1278
- // restore priority: (1) the saved choice (HTML selected attribute, kept in sync by updateProviderSelection), (2) the previous visual selection, (3) first option.
1279
- " function filterChannelRows(enabledTags) {",
1280
- " var rows = document.querySelectorAll('tr[data-provider-tags]');",
1281
- " for (var i = 0; i < rows.length; i++) {",
1282
- " var tags = rows[i].getAttribute('data-provider-tags').split(',');",
1283
- " var available = true;",
1284
- " if (enabledTags.length > 0) {",
1285
- " available = false;",
1286
- " for (var j = 0; j < tags.length; j++) {",
1287
- " if (tags[j] === 'direct' || enabledTags.indexOf(tags[j]) !== -1) { available = true; break; }",
1288
- " }",
1289
- " }",
1290
- " if (available) { rows[i].classList.remove('channel-unavailable'); }",
1291
- " else { rows[i].classList.add('channel-unavailable'); }",
1292
- // Update Source column elements: toggle between the no-provider label and the provider content (select or static name).
1293
- " var label = rows[i].querySelector('.no-provider-label');",
1294
- " var sel = rows[i].querySelector('.provider-select');",
1295
- " var name = rows[i].querySelector('.provider-name');",
1296
- " if (label) label.style.display = available ? 'none' : '';",
1297
- " if (name) name.style.display = available ? '' : 'none';",
1298
- " if (sel) {",
1299
- " sel.style.display = available ? '' : 'none';",
1300
- // On first call, snapshot all options (including server-hidden ones) into a persistent array.
1301
- " if (!sel._allOptions) { sel._allOptions = Array.prototype.slice.call(sel.querySelectorAll('option')); }",
1302
- " var prevValue = sel.value;",
1303
- " sel.innerHTML = '';",
1304
- " var serverDefault = null;",
1305
- " var prevExists = false;",
1306
- " for (var k = 0; k < sel._allOptions.length; k++) {",
1307
- " var opt = sel._allOptions[k];",
1308
- " var oTag = opt.getAttribute('data-provider-tag');",
1309
- " var show = (enabledTags.length === 0) || oTag === 'direct' || enabledTags.indexOf(oTag) !== -1;",
1310
- " if (show) {",
1311
- " sel.appendChild(opt);",
1312
- " if (opt.hasAttribute('selected')) serverDefault = opt;",
1313
- " if (opt.value === prevValue) prevExists = true;",
1314
- " }",
1315
- " }",
1316
- " if (serverDefault) { sel.value = serverDefault.value; }",
1317
- " else if (prevExists) { sel.value = prevValue; }",
1318
- " else if (sel.options.length > 0) { sel.selectedIndex = 0; }",
1319
- " }",
1320
- " }",
1321
- " };",
1322
- // Re-run the provider filter on all channel rows using the current checkbox state. Called after insertChannelRow replaces a row (which loses the filter state).
1323
- " function refilterChannelRows() {",
1324
- " var menu = document.querySelector('.provider-dropdown-menu');",
1325
- " if (!menu) return;",
1326
- " var cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
1327
- " var enabledTags = [];",
1328
- " var allChecked = true;",
1329
- " for (var i = 0; i < cbs.length; i++) {",
1330
- " if (cbs[i].checked) { enabledTags.push(cbs[i].getAttribute('data-tag')); }",
1331
- " else { allChecked = false; }",
1332
- " }",
1333
- " if (allChecked) { enabledTags = []; }",
1334
- " if (enabledTags.length > 0) { filterChannelRows(enabledTags); }",
1335
- " }",
1336
- // Update bulk assign options in the Quick Actions select to only show enabled providers. Toggles the hidden attribute on option elements based on the filter.
1238
+ // Update bulk assign options in the Quick Actions select to only show enabled services. Toggles the hidden attribute on option elements based on the filter.
1337
1239
  " function updateBulkAssignOptions(enabledTags) {",
1338
- " var select = document.getElementById('bulk-assign-select');",
1240
+ " const select = document.getElementById('bulk-assign-select');",
1339
1241
  " if(!select) return;",
1340
- " var options = select.querySelectorAll('option[data-provider-tag]');",
1341
- " for(var i = 0; i < options.length; i++) {",
1342
- " var tag = options[i].getAttribute('data-provider-tag');",
1343
- " var show = (enabledTags.length === 0) || (tag === 'direct') || (enabledTags.indexOf(tag) !== -1);",
1344
- " if(show) options[i].removeAttribute('hidden');",
1345
- " else options[i].setAttribute('hidden', '');",
1242
+ " const options = select.querySelectorAll('option[data-provider-tag]');",
1243
+ " for(const option of options) {",
1244
+ " const tag = option.getAttribute('data-provider-tag');",
1245
+ " const show = (enabledTags.length === 0) || (tag === 'direct') || enabledTags.includes(tag);",
1246
+ " if(show) option.removeAttribute('hidden');",
1247
+ " else option.setAttribute('hidden', '');",
1346
1248
  " }",
1347
1249
  " };",
1348
- // Bulk assign all channels to a specific provider. Updates all dropdowns and profile cells in-place.
1349
- " window.bulkAssignProvider = async function(providerTag) {",
1350
- " if (!providerTag) return;",
1250
+ // Bulk assign all channels to a specific service. Updates all dropdowns and profile cells in-place.
1251
+ " window.bulkAssignService = async (serviceTag) => {",
1252
+ " if(!serviceTag) return;",
1351
1253
  " try {",
1352
- " var r = await fetch('/config/provider-bulk-assign', {",
1353
- " method: 'POST',",
1254
+ " const r = await fetch('/config/service-bulk-assign', {",
1255
+ " body: JSON.stringify({ service: serviceTag }),",
1354
1256
  " headers: { 'Content-Type': 'application/json' },",
1355
- " body: JSON.stringify({ provider: providerTag })",
1257
+ " method: 'POST'",
1356
1258
  " });",
1357
- " var result = await r.json();",
1358
- " if (result.success) {",
1359
- " var msg = result.affected + ' of ' + result.total + ' channel(s) updated.';",
1360
- " var undoAction = null;",
1361
- " if (result.affected > 0 && result.previousSelections) {",
1362
- " var prevSelections = result.previousSelections;",
1363
- " undoAction = { label: 'Undo', onclick: function() { restoreBulkProviders(prevSelections); } };",
1259
+ " const result = await r.json();",
1260
+ " if(result.success) {",
1261
+ " const msg = result.affected + ' of ' + result.total + ' channel(s) updated.';",
1262
+ " let undoAction = null;",
1263
+ " if((result.affected > 0) && result.previousSelections) {",
1264
+ " const prevSelections = result.previousSelections;",
1265
+ " undoAction = { label: 'Undo', onclick: () => { restoreBulkServices(prevSelections); } };",
1364
1266
  " }",
1365
1267
  " showToast(msg, 'success', undoAction ? 10000 : undefined, undoAction);",
1366
- " if (result.selections) {",
1367
- " for (var key in result.selections) {",
1368
- " var sel = result.selections[key];",
1369
- " updateChannelProviderUI(key, sel.variant);",
1268
+ " if(result.selections) {",
1269
+ " for(const [ key, sel ] of Object.entries(result.selections)) {",
1270
+ " updateChannelServiceUI(key, sel.variant);",
1370
1271
  " }",
1371
1272
  " }",
1372
1273
  " } else {",
1373
- " showToast(result.error || 'Failed to assign.', 'error');",
1274
+ " showToast(extractErrorMessage(result, 'Failed to assign.'), 'error');",
1374
1275
  " }",
1375
1276
  " } catch(err) {",
1376
1277
  " showToast('Failed to assign: ' + err.message, 'error');",
1377
1278
  " }",
1378
1279
  " };",
1379
- // Restore previous provider selections (undo bulk assign). Sends the previousSelections map to the server and updates the UI with the restored selections.
1380
- " async function restoreBulkProviders(prevSelections) {",
1280
+ // Restore previous service selections (undo bulk assign). Sends the previousSelections map to the server and updates the UI with the restored selections.
1281
+ " async function restoreBulkServices(prevSelections) {",
1381
1282
  " try {",
1382
- " var r = await fetch('/config/provider-bulk-restore', {",
1383
- " method: 'POST',",
1283
+ " const r = await fetch('/config/service-bulk-restore', {",
1284
+ " body: JSON.stringify({ selections: prevSelections }),",
1384
1285
  " headers: { 'Content-Type': 'application/json' },",
1385
- " body: JSON.stringify({ selections: prevSelections })",
1286
+ " method: 'POST'",
1386
1287
  " });",
1387
- " var result = await r.json();",
1388
- " if (result.success) {",
1288
+ " const result = await r.json();",
1289
+ " if(result.success) {",
1389
1290
  " showToast('Bulk assign reverted.', 'success');",
1390
- " if (result.selections) {",
1391
- " for (var key in result.selections) {",
1392
- " var sel = result.selections[key];",
1393
- " updateChannelProviderUI(key, sel.variant);",
1291
+ " if(result.selections) {",
1292
+ " for(const [ key, sel ] of Object.entries(result.selections)) {",
1293
+ " updateChannelServiceUI(key, sel.variant);",
1394
1294
  " }",
1395
1295
  " }",
1396
1296
  " } else {",
1397
- " showToast(result.error || 'Failed to revert.', 'error');",
1297
+ " showToast(extractErrorMessage(result, 'Failed to revert.'), 'error');",
1398
1298
  " }",
1399
1299
  " } catch(err) {",
1400
1300
  " showToast('Failed to revert: ' + err.message, 'error');",
@@ -1402,181 +1302,115 @@ export function generateConfigSubtabScript() {
1402
1302
  " }",
1403
1303
  // Close dropdowns when clicking outside. After portaling, menus live on <body> outside .dropdown containers, so we also check .dropdown-menu to prevent
1404
1304
  // clicks on menu items (like column picker checkboxes) from triggering close.
1405
- " document.addEventListener('click', function(e) {",
1406
- " if (!e.target.closest('.dropdown') && !e.target.closest('.dropdown-menu')) closeDropdowns();",
1305
+ " document.addEventListener('click', (e) => {",
1306
+ " if(!e.target.closest('.dropdown') && !e.target.closest('.dropdown-menu')) dropdowns.close();",
1407
1307
  " });",
1408
1308
  // Copy a stream URL to the clipboard. The type parameter selects HLS or MPEG-TS format. Delegates to the shared copyToClipboard utility.
1409
- " window.copyStreamUrl = function(type, key) {",
1410
- " closeDropdowns();",
1411
- " var url = (type === 'hls') ? (location.origin + '/hls/' + key + '/stream.m3u8') : (location.origin + '/stream/' + key);",
1309
+ " window.copyStreamUrl = (type, key) => {",
1310
+ " dropdowns.close();",
1311
+ " const url = (type === 'hls') ? (location.origin + '/hls/' + key + '/stream.m3u8') : (location.origin + '/stream/' + key);",
1412
1312
  " copyToClipboard(url, 'Stream URL copied to clipboard.');",
1413
1313
  " };",
1414
1314
  // Toggle visibility of disabled predefined channels and persist preference.
1415
- " window.toggleDisabledVisibility = function() {",
1416
- " var table = document.querySelector('.channel-table');",
1417
- " var checkbox = document.getElementById('show-disabled-toggle');",
1418
- " if (!table || !checkbox) return;",
1419
- " if (checkbox.checked) {",
1315
+ " window.toggleDisabledVisibility = () => {",
1316
+ " const table = document.querySelector('.channel-table');",
1317
+ " const checkbox = document.getElementById('show-disabled-toggle');",
1318
+ " if(!table || !checkbox) return;",
1319
+ " if(checkbox.checked) {",
1420
1320
  " table.classList.remove('hide-disabled');",
1421
- " localStorage.setItem('prismcast-show-disabled-channels', 'true');",
1321
+ " safeStorageSet('prismcast-show-disabled-channels', 'true');",
1422
1322
  " } else {",
1423
1323
  " table.classList.add('hide-disabled');",
1424
- " localStorage.removeItem('prismcast-show-disabled-channels');",
1324
+ " safeStorageRemove('prismcast-show-disabled-channels');",
1425
1325
  " }",
1426
1326
  " };",
1427
1327
  // Toggle visibility of an optional column and persist via background POST.
1428
- " window.toggleColumn = function(checkbox) {",
1429
- " var table = document.querySelector('.channel-table');",
1430
- " if (!table || !checkbox) return;",
1431
- " var colClass = checkbox.getAttribute('data-col-class');",
1432
- " var hideClass = 'hide-' + colClass;",
1433
- " if (checkbox.checked) { table.classList.remove(hideClass); }",
1328
+ " window.toggleColumn = (checkbox) => {",
1329
+ " const table = document.querySelector('.channel-table');",
1330
+ " if(!table || !checkbox) return;",
1331
+ " const colClass = checkbox.getAttribute('data-col-class');",
1332
+ " const hideClass = 'hide-' + colClass;",
1333
+ " if(checkbox.checked) { table.classList.remove(hideClass); }",
1434
1334
  " else { table.classList.add(hideClass); }",
1435
1335
  // Collect all currently checked columns from the picker menu.
1436
- " var menu = document.querySelector('.column-picker-menu');",
1437
- " var cols = [];",
1438
- " if (menu) {",
1439
- " var cbs = menu.querySelectorAll('input[type=\"checkbox\"][data-col-field]');",
1440
- " for (var i = 0; i < cbs.length; i++) {",
1441
- " if (cbs[i].checked) cols.push(cbs[i].getAttribute('data-col-field'));",
1336
+ " const menu = document.querySelector('.column-picker-menu');",
1337
+ " const cols = [];",
1338
+ " if(menu) {",
1339
+ " const cbs = menu.querySelectorAll('input[type=\"checkbox\"][data-col-field]');",
1340
+ " for(const cb of cbs) {",
1341
+ " if(cb.checked) cols.push(cb.getAttribute('data-col-field'));",
1442
1342
  " }",
1443
1343
  " }",
1444
1344
  // Show or hide Quick Action sections gated on column visibility.
1445
- " var colField = checkbox.getAttribute('data-col-field');",
1446
- " var qaMap = { channelNumber: 'quick-action-autonumber', hdhrEnabled: 'quick-action-hdhr' };",
1447
- " var qaId = qaMap[colField];",
1345
+ " const colField = checkbox.getAttribute('data-col-field');",
1346
+ " const qaMap = { channelNumber: 'quick-action-autonumber', hdhrEnabled: 'quick-action-hdhr' };",
1347
+ " const qaId = qaMap[colField];",
1448
1348
  " if(qaId) {",
1449
- " var qaEl = document.getElementById(qaId);",
1349
+ " const qaEl = document.getElementById(qaId);",
1450
1350
  " if(qaEl) qaEl.style.display = checkbox.checked ? '' : 'none';",
1451
- " var divider = document.getElementById('quick-action-divider');",
1452
- " var anyVisible = document.getElementById('quick-action-autonumber')?.style.display !== 'none' ||",
1453
- " document.getElementById('quick-action-hdhr')?.style.display !== 'none';",
1351
+ " const divider = document.getElementById('quick-action-divider');",
1352
+ " const anyVisible = (document.getElementById('quick-action-autonumber')?.style.display !== 'none') ||",
1353
+ " (document.getElementById('quick-action-hdhr')?.style.display !== 'none');",
1454
1354
  " if(divider) divider.style.display = anyVisible ? '' : 'none';",
1455
1355
  " }",
1456
- " fetch('/config/channels/display-prefs', {",
1457
- " method: 'POST', headers: { 'Content-Type': 'application/json' },",
1458
- " body: JSON.stringify({ visibleColumns: cols })",
1459
- " }).catch(function() {});",
1460
- " };",
1461
- // Read the server-stamped sort value from the data-sort-value attribute. The column index is derived from the header row's data-sort-field attributes rather
1462
- // than a hardcoded map — new columns are automatically supported without updating client-side code.
1463
- " var colIndexCache = null;",
1464
- " function getSortValue(row, field) {",
1465
- " if(!colIndexCache) {",
1466
- " colIndexCache = {};",
1467
- " var ths = document.querySelectorAll('.channel-table th[data-sort-field]');",
1468
- " for(var i = 0; i < ths.length; i++) { colIndexCache[ths[i].getAttribute('data-sort-field')] = i; }",
1469
- " }",
1470
- " var idx = colIndexCache[field];",
1471
- " if(idx === undefined) return '';",
1472
- " var cell = row.children[idx];",
1473
- " if(!cell) return '';",
1474
- " return cell.getAttribute('data-sort-value') || '';",
1475
- " }",
1476
- // Sort the channel table by the specified field. Toggles direction if the same field is clicked again.
1477
- " window.sortChannelTable = function(field) {",
1478
- " var table = document.querySelector('.channel-table');",
1479
- " if (!table) return;",
1480
- " var currentField = table.getAttribute('data-sort-field');",
1481
- " var currentDir = table.getAttribute('data-sort-dir') || 'asc';",
1482
- " var dir = (field === currentField) ? (currentDir === 'asc' ? 'desc' : 'asc') : 'asc';",
1483
- " table.setAttribute('data-sort-field', field);",
1484
- " table.setAttribute('data-sort-dir', dir);",
1485
- // Collect display rows and their paired edit rows.
1486
- " var tbody = table.querySelector('tbody');",
1487
- " var displayRows = tbody.querySelectorAll('tr[id^=\"display-row-\"]');",
1488
- " var pairs = [];",
1489
- " for (var i = 0; i < displayRows.length; i++) {",
1490
- " var dr = displayRows[i];",
1491
- " var key = dr.id.replace('display-row-', '');",
1492
- " var er = document.getElementById('edit-row-' + key);",
1493
- " var name = dr.children[1] ? dr.children[1].textContent.trim().toLowerCase() : '';",
1494
- " pairs.push({ displayRow: dr, editRow: er, val: getSortValue(dr, field), name: name });",
1495
- " }",
1496
- // Sort pairs. The channel name is used as a secondary sort key to stabilize rows with identical primary values. The secondary sort is always ascending
1497
- // regardless of the primary direction so that rows within each group maintain a consistent alphabetical order.
1498
- " pairs.sort(function(a, b) {",
1499
- " var cmp = (dir === 'asc') ? a.val.localeCompare(b.val) : b.val.localeCompare(a.val);",
1500
- " if (cmp !== 0) return cmp;",
1501
- " return a.name.localeCompare(b.name);",
1502
- " });",
1503
- // Re-append in sorted order.
1504
- " for (var j = 0; j < pairs.length; j++) {",
1505
- " tbody.appendChild(pairs[j].displayRow);",
1506
- " if (pairs[j].editRow) tbody.appendChild(pairs[j].editRow);",
1507
- " }",
1508
- // Update header sort indicators. Targets .sort-label within each <th> so other children (like the tag filter dropdown) are untouched.
1509
- " var headers = table.querySelectorAll('th.sortable');",
1510
- " for(var h = 0; h < headers.length; h++) {",
1511
- " var th = headers[h];",
1512
- " var hField = th.getAttribute('data-sort-field');",
1513
- " var sortLabel = th.querySelector('.sort-label');",
1514
- " if(!sortLabel) continue;",
1515
- " var label = sortLabel.textContent.replace(/[\\u25B2\\u25BC]/g, '').trim();",
1516
- " if(hField === field) { sortLabel.innerHTML = label + (dir === 'asc' ? ' &#9650;' : ' &#9660;'); }",
1517
- " else { sortLabel.textContent = label; }",
1518
- " }",
1519
- // Persist sort preference.
1520
- " fetch('/config/channels/display-prefs', {",
1521
- " method: 'POST', headers: { 'Content-Type': 'application/json' },",
1522
- " body: JSON.stringify({ sortField: field, sortDirection: dir })",
1523
- " }).catch(function() {});",
1356
+ " persistDisplayPrefs({ visibleColumns: cols });",
1524
1357
  " };",
1525
1358
  // Populate channel selector datalist based on the URL field value. First populates from server-rendered data (predefined channels and cached provider
1526
1359
  // discovery), then asynchronously fetches from the provider discovery endpoint to pick up any channels not yet in the static data.
1527
1360
  " function updateSelectorSuggestions(urlInputId, datalistId) {",
1528
- " var urlInput = document.getElementById(urlInputId);",
1529
- " var datalist = document.getElementById(datalistId);",
1530
- " if (!urlInput || !datalist) return;",
1361
+ " const urlInput = document.getElementById(urlInputId);",
1362
+ " const datalist = document.getElementById(datalistId);",
1363
+ " if(!urlInput || !datalist) return;",
1531
1364
  " datalist.innerHTML = '';",
1532
1365
  // Find or create the URL hint element. Placed after the URL input's parent form-row so it sits below the field.
1533
- " var hintId = urlInputId + '-url-hint';",
1534
- " var hintEl = document.getElementById(hintId);",
1535
- " if (!hintEl) {",
1366
+ " const hintId = urlInputId + '-url-hint';",
1367
+ " let hintEl = document.getElementById(hintId);",
1368
+ " if(!hintEl) {",
1536
1369
  " hintEl = document.createElement('div');",
1537
1370
  " hintEl.id = hintId;",
1538
1371
  " hintEl.className = 'hint';",
1539
1372
  " hintEl.style.marginBottom = '0';",
1540
- " var formRow = urlInput.closest('.form-row');",
1541
- " if (formRow) { formRow.parentNode.insertBefore(hintEl, formRow.nextSibling); }",
1373
+ " const formRow = urlInput.closest('.form-row');",
1374
+ " if(formRow) { formRow.parentNode.insertBefore(hintEl, formRow.nextSibling); }",
1542
1375
  " }",
1543
1376
  " hintEl.innerHTML = '';",
1544
1377
  " hintEl.style.display = 'none';",
1545
- " var hostname;",
1546
- " var raw = urlInput.value;",
1547
- " if (raw && !raw.startsWith('http://') && !raw.startsWith('https://')) { raw = 'https://' + raw; }",
1548
- " try { hostname = new URL(raw).hostname; } catch (e) { return; }",
1549
- " var alt = hostname.startsWith('www.') ? hostname.substring(4) : 'www.' + hostname;",
1378
+ " let raw = urlInput.value;",
1379
+ " if(raw && !raw.startsWith('http://') && !raw.startsWith('https://')) { raw = 'https://' + raw; }",
1380
+ " const parsed = URL.parse(raw);",
1381
+ " if(!parsed) return;",
1382
+ " const hostname = parsed.hostname;",
1383
+ " const alt = hostname.startsWith('www.') ? hostname.substring(4) : 'www.' + hostname;",
1550
1384
  // Resolve the provider hostname. Try exact match first, then www-variant, then base domain match (e.g., sling.com matches watch.sling.com).
1551
- " var exactMatch = (typeof channelSelectorsByDomain !== 'undefined') && channelSelectorsByDomain[hostname];",
1552
- " var resolvedHostname = null;",
1553
- " if ((typeof providerByDomain !== 'undefined') && providerByDomain[hostname]) { resolvedHostname = hostname; }",
1554
- " else if ((typeof providerByDomain !== 'undefined') && providerByDomain[alt]) { resolvedHostname = alt; }",
1555
- " else if (typeof providerByDomain !== 'undefined') {",
1556
- " var suffix = '.' + hostname;",
1557
- " var domainKeys = Object.keys(providerByDomain);",
1558
- " for (var d = 0; d < domainKeys.length; d++) {",
1559
- " if (domainKeys[d].endsWith(suffix)) { resolvedHostname = domainKeys[d]; break; }",
1385
+ " const exactMatch = (typeof channelSelectorsByDomain !== 'undefined') && channelSelectorsByDomain[hostname];",
1386
+ " let resolvedHostname = null;",
1387
+ " if((typeof serviceByDomain !== 'undefined') && serviceByDomain[hostname]) { resolvedHostname = hostname; }",
1388
+ " else if((typeof serviceByDomain !== 'undefined') && serviceByDomain[alt]) { resolvedHostname = alt; }",
1389
+ " else if(typeof serviceByDomain !== 'undefined') {",
1390
+ " const suffix = '.' + hostname;",
1391
+ " const domainKeys = Object.keys(serviceByDomain);",
1392
+ " for(const domainKey of domainKeys) {",
1393
+ " if(domainKey.endsWith(suffix)) { resolvedHostname = domainKey; break; }",
1560
1394
  " }",
1561
1395
  " }",
1562
- " var slug = resolvedHostname ? providerByDomain[resolvedHostname] : null;",
1563
- " var entries = exactMatch || ((typeof channelSelectorsByDomain !== 'undefined')",
1396
+ " const slug = resolvedHostname ? serviceByDomain[resolvedHostname] : null;",
1397
+ " const entries = exactMatch || ((typeof channelSelectorsByDomain !== 'undefined')",
1564
1398
  " ? (channelSelectorsByDomain[alt] || (resolvedHostname ? channelSelectorsByDomain[resolvedHostname] : null)) : null);",
1565
- " if (entries) {",
1566
- " for (var i = 0; i < entries.length; i++) {",
1567
- " var opt = document.createElement('option');",
1568
- " opt.value = entries[i].value;",
1569
- " opt.label = entries[i].label;",
1399
+ " if(entries) {",
1400
+ " for(const entry of entries) {",
1401
+ " const opt = document.createElement('option');",
1402
+ " opt.value = entry.value;",
1403
+ " opt.label = entry.label;",
1570
1404
  " datalist.appendChild(opt);",
1571
1405
  " }",
1572
1406
  " }",
1573
1407
  // Show a URL hint when the hostname didn't match exactly and we found a provider via fallback.
1574
- " if (!exactMatch && slug && (typeof providerGuideUrl !== 'undefined') && providerGuideUrl[slug]) {",
1575
- " var suggestedUrl = providerGuideUrl[slug];",
1576
- " var link = document.createElement('a');",
1408
+ " if(!exactMatch && slug && (typeof serviceGuideUrl !== 'undefined') && serviceGuideUrl[slug]) {",
1409
+ " const suggestedUrl = serviceGuideUrl[slug];",
1410
+ " const link = document.createElement('a');",
1577
1411
  " link.href = '#';",
1578
1412
  " link.textContent = suggestedUrl;",
1579
- " link.addEventListener('click', function(e) {",
1413
+ " link.addEventListener('click', (e) => {",
1580
1414
  " e.preventDefault();",
1581
1415
  " urlInput.value = suggestedUrl;",
1582
1416
  " hintEl.innerHTML = '';",
@@ -1588,132 +1422,141 @@ export function generateConfigSubtabScript() {
1588
1422
  " hintEl.appendChild(document.createTextNode('?'));",
1589
1423
  " hintEl.style.display = '';",
1590
1424
  " }",
1591
- " if (!slug) return;",
1592
- " var snapshotUrl = urlInput.value;",
1593
- " (async function() {",
1425
+ " if(!slug) return;",
1426
+ " const snapshotUrl = urlInput.value;",
1427
+ " (async () => {",
1594
1428
  " try {",
1595
- " var r = await fetch('/providers/' + slug + '/channels');",
1596
- " var channels = r.ok ? await r.json() : null;",
1597
- " if (!channels || urlInput.value !== snapshotUrl) return;",
1598
- " var existing = {};",
1599
- " var opts = datalist.querySelectorAll('option');",
1600
- " for (var j = 0; j < opts.length; j++) { existing[opts[j].value] = true; }",
1601
- " for (var k = 0; k < channels.length; k++) {",
1602
- " if (!existing[channels[k].channelSelector]) {",
1603
- " var o = document.createElement('option');",
1604
- " o.value = channels[k].channelSelector;",
1605
- " o.label = channels[k].name;",
1429
+ " const r = await fetch('/services/' + slug + '/channels');",
1430
+ " const channels = r.ok ? await r.json() : null;",
1431
+ " if(!channels || (urlInput.value !== snapshotUrl)) return;",
1432
+ " const existing = {};",
1433
+ " const opts = datalist.querySelectorAll('option');",
1434
+ " for(const opt of opts) { existing[opt.value] = true; }",
1435
+ " for(const channel of channels) {",
1436
+ " if(!existing[channel.channelSelector]) {",
1437
+ " const o = document.createElement('option');",
1438
+ " o.value = channel.channelSelector;",
1439
+ " o.label = channel.name;",
1606
1440
  " datalist.appendChild(o);",
1607
1441
  " }",
1608
1442
  " }",
1609
- " } catch(err) {}",
1443
+ " } catch(err) { console.warn('Channel selector suggestions failed to load.', err); }",
1610
1444
  " })();",
1611
1445
  " };",
1612
- // Initialize disabled channel toggle, provider filter, URL input listeners, and pending toast on page load.
1613
- " (function() {",
1446
+ // Initialize disabled channel toggle, service filter, URL input listeners, and pending toast on page load.
1447
+ " (() => {",
1614
1448
  // Show any toast queued by showToastAfterReload() before the last page reload.
1615
- " var pending = sessionStorage.getItem('pendingToast');",
1616
- " if (pending) {",
1617
- " sessionStorage.removeItem('pendingToast');",
1618
- " try { var pt = JSON.parse(pending); showToast(pt.message, pt.type); } catch (e) {}",
1619
- " }",
1620
- " if (localStorage.getItem('prismcast-show-disabled-channels') === 'true') {",
1621
- " var table = document.querySelector('.channel-table');",
1622
- " var checkbox = document.getElementById('show-disabled-toggle');",
1623
- " if (table) table.classList.remove('hide-disabled');",
1624
- " if (checkbox) checkbox.checked = true;",
1625
- " }",
1626
- // Set indeterminate state on scope toggle checkboxes. HTML has no indeterminate attribute — it must be set via JS on page load. The enabled and total values
1627
- // are read from data attributes on the quick-action-count span, not from the display text — this decouples checkbox state from the text format.
1628
- " var scopeSpans = document.querySelectorAll('.quick-action-count[data-enabled][data-total]');",
1629
- " for(var si = 0; si < scopeSpans.length; si++) {",
1630
- " var span = scopeSpans[si];",
1631
- " var enabled = parseInt(span.getAttribute('data-enabled'), 10);",
1632
- " var total = parseInt(span.getAttribute('data-total'), 10);",
1449
+ " const pt = consumePendingToast();",
1450
+ " if(pt) showToast(pt.message, pt.type);",
1451
+ " if(safeStorageGet('prismcast-show-disabled-channels') === 'true') {",
1452
+ " const table = document.querySelector('.channel-table');",
1453
+ " const checkbox = document.getElementById('show-disabled-toggle');",
1454
+ " if(table) table.classList.remove('hide-disabled');",
1455
+ " if(checkbox) checkbox.checked = true;",
1456
+ " }",
1457
+ // Set indeterminate state on scope toggle checkboxes. HTML has no indeterminate attribute - it must be set via JS on page load. The enabled and total values
1458
+ // are read from data attributes on the quick-action-count span, not from the display text - this decouples checkbox state from the text format.
1459
+ " const scopeSpans = document.querySelectorAll('.quick-action-count[data-enabled][data-total]');",
1460
+ " for(const span of scopeSpans) {",
1461
+ " const enabled = parseInt(span.getAttribute('data-enabled'), 10);",
1462
+ " const total = parseInt(span.getAttribute('data-total'), 10);",
1633
1463
  " if((total > 0) && (enabled > 0) && (enabled < total)) {",
1634
- " var cb = document.querySelector('.scope-toggle[data-scope=\"' + span.getAttribute('data-scope') + '\"]');",
1464
+ " const cb = document.querySelector('.scope-toggle[data-scope=\"' + span.getAttribute('data-scope') + '\"]');",
1635
1465
  " if(cb) cb.indeterminate = true;",
1636
1466
  " }",
1637
1467
  " }",
1638
1468
  // Set indeterminate state on the HDHR bulk toggle if data-indeterminate is present.
1639
- " var hdhrToggle = document.getElementById('hdhr-bulk-toggle');",
1640
- " if(hdhrToggle && hdhrToggle.getAttribute('data-indeterminate') === 'true') hdhrToggle.indeterminate = true;",
1641
- // Set indeterminate state on tag bulk toggle checkboxes. Same pattern as HDHR — HTML has no indeterminate attribute, so JS must set it on page load.
1642
- " var tagToggles = document.querySelectorAll('.tag-bulk-toggle[data-indeterminate=\"true\"]');",
1643
- " for(var ti = 0; ti < tagToggles.length; ti++) tagToggles[ti].indeterminate = true;",
1644
- // Run filterChannelRows on page load when a provider filter is active. The server renders filtered options with the hidden attribute, but Safari ignores it on
1469
+ " const hdhrToggle = document.getElementById('hdhr-bulk-toggle');",
1470
+ " if(hdhrToggle && (hdhrToggle.getAttribute('data-indeterminate') === 'true')) hdhrToggle.indeterminate = true;",
1471
+ // Set indeterminate state on tag bulk toggle checkboxes. Same pattern as HDHR - HTML has no indeterminate attribute, so JS must set it on page load.
1472
+ " const tagToggles = document.querySelectorAll('.tag-bulk-toggle[data-indeterminate=\"true\"]');",
1473
+ " for(const tagToggle of tagToggles) tagToggle.indeterminate = true;",
1474
+ // Run channelTable.filter() on page load when a service filter is active. The server renders filtered options with the hidden attribute, but Safari ignores it on
1645
1475
  // option elements. This initial pass removes those options from the DOM to enforce the filter.
1646
- " var menu = document.querySelector('.provider-dropdown-menu');",
1647
- " if (menu) {",
1648
- " var cbs = menu.querySelectorAll('input[type=\"checkbox\"]:not(:disabled)');",
1649
- " var tags = [];",
1650
- " var allChecked = true;",
1651
- " for (var ci = 0; ci < cbs.length; ci++) {",
1652
- " if (cbs[ci].checked) { tags.push(cbs[ci].getAttribute('data-tag')); }",
1653
- " else { allChecked = false; }",
1654
- " }",
1655
- " if (!allChecked) { filterChannelRows(tags); updateBulkAssignOptions(tags); }",
1656
- " }",
1657
- " var addUrlInput = document.getElementById('add-url');",
1658
- " if (addUrlInput) {",
1659
- " addUrlInput.addEventListener('input', function() {",
1476
+ " const initFilterTags = channelTable.getEnabledFilterTags();",
1477
+ " if(initFilterTags.length > 0) { channelTable.filter(initFilterTags); updateBulkAssignOptions(initFilterTags); }",
1478
+ " const addUrlInput = document.getElementById('add-url');",
1479
+ " if(addUrlInput) {",
1480
+ " addUrlInput.addEventListener('input', () => {",
1660
1481
  " updateSelectorSuggestions('add-url', 'add-selectorList');",
1661
- // Deselect the active provider pill if the user manually edits the URL. Check whether the current URL still matches the active pill's URL — if not, the
1482
+ // Deselect the active service pill if the user manually edits the URL. Check whether the current URL still matches the active pill's URL - if not, the
1662
1483
  // user has overridden the auto-fill and the pill should unhighlight. Also re-enable the profile dropdown since manual URL entry may need a custom profile.
1663
- " var activePill = document.querySelector('.provider-pill.active');",
1664
- " if(activePill && activePill.getAttribute('data-url') !== addUrlInput.value) {",
1484
+ " const activePill = document.querySelector('.provider-pill.active');",
1485
+ " if(activePill && (activePill.getAttribute('data-url') !== addUrlInput.value)) {",
1665
1486
  " activePill.classList.remove('active');",
1666
- " var profileSelect = document.getElementById('add-profile');",
1487
+ " const profileSelect = document.getElementById('add-profile');",
1667
1488
  " if(profileSelect) profileSelect.disabled = false;",
1668
1489
  " }",
1490
+ // Check the entered URL against predefined channel domains. If the domain matches, show an inline hint suggesting the predefined channel. This extends the
1491
+ // form's existing URL-based intelligence (channelSelector suggestions, stationId auto-fill) with predefined channel awareness.
1492
+ " const predefinedHint = document.getElementById('add-predefined-hint');",
1493
+ " if(predefinedHint && (typeof predefinedByDomain !== 'undefined')) {",
1494
+ " const parsedUrl = URL.parse(addUrlInput.value);",
1495
+ " if(!parsedUrl) {",
1496
+ " predefinedHint.style.display = 'none';",
1497
+ " } else {",
1498
+ " const urlHostname = parsedUrl.hostname;",
1499
+ " const parts = urlHostname.split('.');",
1500
+ " const conciseDomain = (parts.length > 2) ? parts.slice(-2).join('.') : urlHostname;",
1501
+ " const matches = predefinedByDomain[conciseDomain];",
1502
+ " if(matches && (matches.length > 0)) {",
1503
+ " const names = matches.map((m) => '<strong>' + esc(m.name) + '</strong> (' + m.serviceCount + ' service' +",
1504
+ " ((m.serviceCount === 1) ? '' : 's') + ')');",
1505
+ " predefinedHint.innerHTML = names.join(', ') + ' available as predefined ' + ((matches.length === 1) ? 'channel' : 'channels') +",
1506
+ " ' with multi-service support. <a href=\"#\" onclick=\"event.preventDefault(); openBrowseModal();\">Browse predefined channels</a>';",
1507
+ " predefinedHint.style.display = '';",
1508
+ " } else {",
1509
+ " predefinedHint.style.display = 'none';",
1510
+ " }",
1511
+ " }",
1512
+ " }",
1669
1513
  " });",
1670
1514
  " updateSelectorSuggestions('add-url', 'add-selectorList');",
1671
1515
  " }",
1672
1516
  // Auto-fill station ID when the channelSelector matches a known entry with a stationId. Reads from the same channelSelectorsByDomain data used by the
1673
- // datalist. The data-auto attribute tracks whether the current value was auto-filled — user edits clear it, preventing overwrites of intentional input.
1674
- " var addSelectorInput = document.getElementById('add-channelSelector');",
1675
- " var addStationInput = document.getElementById('add-stationId');",
1676
- " if (addSelectorInput && addStationInput) {",
1677
- " addStationInput.addEventListener('input', function() { addStationInput.removeAttribute('data-auto'); });",
1678
- " addSelectorInput.addEventListener('input', function() {",
1679
- " if (addStationInput.value && !addStationInput.hasAttribute('data-auto')) return;",
1680
- " var urlInput = document.getElementById('add-url');",
1681
- " if (!urlInput || !urlInput.value) return;",
1682
- " try {",
1683
- " var hostname = new URL(urlInput.value).hostname;",
1684
- " var entries = (typeof channelSelectorsByDomain !== 'undefined') ? channelSelectorsByDomain[hostname] : null;",
1685
- " if (!entries) return;",
1686
- " var match = entries.find(function(e) { return e.value === addSelectorInput.value; });",
1687
- " if (match && match.stationId) { addStationInput.value = match.stationId; addStationInput.setAttribute('data-auto', '1'); }",
1688
- " else if (addStationInput.hasAttribute('data-auto')) { addStationInput.value = ''; addStationInput.removeAttribute('data-auto'); }",
1689
- " } catch(e) {}",
1517
+ // datalist. The data-auto attribute tracks whether the current value was auto-filled - user edits clear it, preventing overwrites of intentional input.
1518
+ " const addSelectorInput = document.getElementById('add-channelSelector');",
1519
+ " const addStationInput = document.getElementById('add-stationId');",
1520
+ " if(addSelectorInput && addStationInput) {",
1521
+ " addStationInput.addEventListener('input', () => { addStationInput.removeAttribute('data-auto'); });",
1522
+ " addSelectorInput.addEventListener('input', () => {",
1523
+ " if(addStationInput.value && !addStationInput.hasAttribute('data-auto')) return;",
1524
+ " const urlInput = document.getElementById('add-url');",
1525
+ " if(!urlInput || !urlInput.value) return;",
1526
+ " const parsed = URL.parse(urlInput.value);",
1527
+ " if(!parsed) return;",
1528
+ " const entries = (typeof channelSelectorsByDomain !== 'undefined') ? channelSelectorsByDomain[parsed.hostname] : null;",
1529
+ " if(!entries) return;",
1530
+ " const match = entries.find((e) => e.value === addSelectorInput.value);",
1531
+ " if(match && match.stationId) { addStationInput.value = match.stationId; addStationInput.setAttribute('data-auto', '1'); }",
1532
+ " else if(addStationInput.hasAttribute('data-auto')) { addStationInput.value = ''; addStationInput.removeAttribute('data-auto'); }",
1690
1533
  " });",
1691
1534
  " }",
1692
1535
  " })();",
1693
1536
  // Login modal state tracking.
1694
- " var loginStatusInterval = null;",
1537
+ " let loginStatusInterval = null;",
1695
1538
  // Start login mode for a channel. Opens browser window and shows modal.
1696
- " window.startChannelLogin = async function(channel) {",
1539
+ " window.startChannelLogin = async (channel) => {",
1697
1540
  " try {",
1698
- " var res = await fetch('/auth/login', {",
1699
- " method: 'POST',",
1541
+ " const res = await fetch('/auth/login', {",
1542
+ " body: JSON.stringify({ channel }),",
1700
1543
  " headers: { 'Content-Type': 'application/json' },",
1701
- " body: JSON.stringify({ channel: channel })",
1544
+ " method: 'POST'",
1702
1545
  " });",
1703
- " var result = await res.json();",
1704
- " if (result.success) {",
1546
+ " const result = await res.json();",
1547
+ " if(result.success) {",
1705
1548
  " showToast('Browser window opened. Complete authentication.', 'info');",
1706
1549
  " showLoginModal();",
1707
1550
  " startLoginStatusPolling();",
1708
1551
  " } else {",
1709
- " showToast(result.error || 'Failed to start login.', 'error');",
1552
+ " showToast(extractErrorMessage(result, 'Failed to start login.'), 'error');",
1710
1553
  " }",
1711
1554
  " } catch(err) {",
1712
1555
  " showToast('Failed to start login: ' + err.message, 'error');",
1713
1556
  " }",
1714
1557
  " };",
1715
1558
  // End login mode. Closes browser tab and hides modal.
1716
- " window.endLogin = async function() {",
1559
+ " window.endLogin = async () => {",
1717
1560
  " stopLoginStatusPolling();",
1718
1561
  " try {",
1719
1562
  " await fetch('/auth/done', { method: 'POST' });",
@@ -1726,85 +1569,88 @@ export function generateConfigSubtabScript() {
1726
1569
  " };",
1727
1570
  // Show the login modal.
1728
1571
  " function showLoginModal() {",
1729
- " var modal = document.getElementById('login-modal');",
1730
- " if (modal) modal.style.display = 'flex';",
1572
+ " const modal = document.getElementById('login-modal');",
1573
+ " if(modal) modal.style.display = 'flex';",
1731
1574
  " }",
1732
1575
  // Hide the login modal.
1733
1576
  " function hideLoginModal() {",
1734
- " var modal = document.getElementById('login-modal');",
1735
- " if (modal) modal.style.display = 'none';",
1577
+ " const modal = document.getElementById('login-modal');",
1578
+ " if(modal) modal.style.display = 'none';",
1736
1579
  " }",
1737
- // Start polling login status to detect when tab is closed externally.
1580
+ // Start polling login status to detect when tab is closed externally. Errors are logged only on the first failure per polling session - a dead endpoint
1581
+ // would otherwise spam the console at 1Hz. The flag resets at each startLoginStatusPolling() call so subsequent sessions get a fresh log.
1738
1582
  " function startLoginStatusPolling() {",
1739
1583
  " stopLoginStatusPolling();",
1740
- " loginStatusInterval = setInterval(async function() {",
1584
+ " let errorLogged = false;",
1585
+ " loginStatusInterval = setInterval(async () => {",
1741
1586
  " try {",
1742
- " var res = await fetch('/auth/status');",
1743
- " var status = await res.json();",
1744
- " if (!status.active) {",
1587
+ " const res = await fetch('/auth/status');",
1588
+ " const status = await res.json();",
1589
+ " errorLogged = false;",
1590
+ " if(!status.active) {",
1745
1591
  " stopLoginStatusPolling();",
1746
1592
  " hideLoginModal();",
1747
1593
  " showToast('Login session ended.', 'info');",
1748
1594
  " }",
1749
- " } catch(err) {}",
1595
+ " } catch(err) {",
1596
+ " if(!errorLogged) { console.warn('Login status poll failed; suppressing further logs until next success.', err); errorLogged = true; }",
1597
+ " }",
1750
1598
  " }, 1000);",
1751
1599
  " }",
1752
1600
  // Stop polling login status.
1753
1601
  " function stopLoginStatusPolling() {",
1754
- " if (loginStatusInterval) {",
1602
+ " if(loginStatusInterval) {",
1755
1603
  " clearInterval(loginStatusInterval);",
1756
1604
  " loginStatusInterval = null;",
1757
1605
  " }",
1758
1606
  " }",
1759
1607
  // Attach validation and modified indicator handlers to form inputs.
1760
- " var form = document.getElementById('settings-form');",
1761
- " if (form) {",
1762
- " var inputs = form.querySelectorAll('input, select');",
1763
- " for (var k = 0; k < inputs.length; k++) {",
1764
- " var eventType = inputs[k].type === 'checkbox' ? 'change' : 'input';",
1765
- " inputs[k].addEventListener(eventType, function() {",
1608
+ " const form = document.getElementById('settings-form');",
1609
+ " if(form) {",
1610
+ " const inputs = form.querySelectorAll('input, select');",
1611
+ " for(const input of inputs) {",
1612
+ " const eventType = input.type === 'checkbox' ? 'change' : 'input';",
1613
+ " input.addEventListener(eventType, function() {",
1766
1614
  " validateInput(this);",
1767
1615
  " updateModifiedIndicator(this);",
1768
1616
  " });",
1769
1617
  " }",
1770
1618
  // Wire up all checkboxes that have dependent fields. The updateDependentFields function is defined at the top level alongside the other helpers.
1771
- " var checkboxes = form.querySelectorAll('input[type=\"checkbox\"]');",
1772
- " for (var c = 0; c < checkboxes.length; c++) {",
1773
- " (function(cb) {",
1774
- " cb.addEventListener('change', function() { updateDependentFields(cb.id); });",
1775
- " })(checkboxes[c]);",
1619
+ " const checkboxes = form.querySelectorAll('input[type=\"checkbox\"]');",
1620
+ " for(const cb of checkboxes) {",
1621
+ " cb.addEventListener('change', () => { updateDependentFields(cb.id); });",
1776
1622
  " }",
1777
1623
  // Attach preset change handler to auto-fill bitrate and frame rate.
1778
- " var presetSelect = document.getElementById('streaming-qualityPreset');",
1779
- " if (presetSelect) {",
1624
+ " const presetSelect = document.getElementById('streaming-qualityPreset');",
1625
+ " if(presetSelect) {",
1780
1626
  " presetSelect.addEventListener('change', function() { onPresetChange(this.value); });",
1781
1627
  " }",
1782
1628
  " }",
1783
1629
  // Checkbox list: collect checked values into the hidden input and update the modified indicator. Called by onchange on individual checkboxes within a
1784
1630
  // checkbox-list-grid. The hidden input holds the JSON-encoded array and participates in the standard form submission.
1785
- " window.updateCheckboxList = function(checkbox) {",
1786
- " var group = checkbox.closest('.form-group');",
1787
- " if (!group) return;",
1788
- " var hidden = group.querySelector('[data-checkbox-list]');",
1789
- " if (!hidden) return;",
1790
- " var grid = group.querySelector('.checkbox-list-grid');",
1791
- " if (!grid) return;",
1792
- " var cbs = grid.querySelectorAll('input[type=\"checkbox\"]');",
1793
- " var values = [];",
1794
- " for (var i = 0; i < cbs.length; i++) {",
1795
- " if (cbs[i].checked) values.push(cbs[i].value);",
1631
+ " window.updateCheckboxList = (checkbox) => {",
1632
+ " const group = checkbox.closest('.form-group');",
1633
+ " if(!group) return;",
1634
+ " const hidden = group.querySelector('[data-checkbox-list]');",
1635
+ " if(!hidden) return;",
1636
+ " const grid = group.querySelector('.checkbox-list-grid');",
1637
+ " if(!grid) return;",
1638
+ " const cbs = grid.querySelectorAll('input[type=\"checkbox\"]');",
1639
+ " const values = [];",
1640
+ " for(const cb of cbs) {",
1641
+ " if(cb.checked) values.push(cb.value);",
1796
1642
  " }",
1797
1643
  " hidden.value = JSON.stringify(values);",
1798
1644
  " updateModifiedIndicator(hidden);",
1799
1645
  " };",
1800
1646
  // Initialize subtab on load: hash > localStorage > default.
1801
- " var initialSubtab = window.initialHashSubtab;",
1802
- " if (!initialSubtab) {",
1803
- " try { initialSubtab = localStorage.getItem('prismcast-config-subtab'); } catch(e) {}",
1804
- " }",
1805
- " if (initialSubtab && document.querySelector('.subtab-btn[data-subtab=\"' + initialSubtab + '\"]')) {",
1806
- " switchSubtab(initialSubtab, false);",
1807
- " }",
1647
+ " initSubtab({",
1648
+ " btnSelector: '.subtab-btn',",
1649
+ " dataAttr: 'data-subtab',",
1650
+ " hashVar: window.initialHashSubtab,",
1651
+ " storageKey: 'prismcast-config-subtab',",
1652
+ " switchFn: switchSubtab",
1653
+ " });",
1808
1654
  "})();",
1809
1655
  "</script>"
1810
1656
  ].join("\n");