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.
- package/README.md +6 -6
- package/dist/app.d.ts +1 -1
- package/dist/app.js +41 -13
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +4 -6
- package/dist/browser/cdp.js +18 -22
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/channelSelection.d.ts +41 -11
- package/dist/browser/channelSelection.js +75 -24
- package/dist/browser/channelSelection.js.map +1 -1
- package/dist/browser/display.d.ts +1 -1
- package/dist/browser/index.d.ts +8 -8
- package/dist/browser/index.js +44 -45
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.d.ts +2 -2
- package/dist/browser/login.js +1 -1
- package/dist/browser/manifestInterceptor.d.ts +77 -0
- package/dist/browser/manifestInterceptor.js +323 -0
- package/dist/browser/manifestInterceptor.js.map +1 -0
- package/dist/browser/precaching.d.ts +1 -1
- package/dist/browser/precaching.js +23 -23
- package/dist/browser/precaching.js.map +1 -1
- package/dist/browser/tuning/comcastPolymer.d.ts +1 -1
- package/dist/browser/tuning/cox.d.ts +1 -1
- package/dist/browser/tuning/directv.d.ts +1 -1
- package/dist/browser/tuning/directv.js +65 -69
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.d.ts +1 -1
- package/dist/browser/tuning/fox.js +172 -15
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.d.ts +1 -1
- package/dist/browser/tuning/hbo.js +7 -7
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.d.ts +1 -1
- package/dist/browser/tuning/hulu.js +120 -113
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/shared.d.ts +3 -3
- package/dist/browser/tuning/shared.js +5 -5
- package/dist/browser/tuning/sling.d.ts +1 -1
- package/dist/browser/tuning/sling.js +30 -30
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/browser/tuning/spectrum.d.ts +1 -1
- package/dist/browser/tuning/spectrum.js +21 -22
- package/dist/browser/tuning/spectrum.js.map +1 -1
- package/dist/browser/tuning/thumbnailRow.d.ts +1 -1
- package/dist/browser/tuning/thumbnailRow.js +1 -1
- package/dist/browser/tuning/tileClick.d.ts +1 -1
- package/dist/browser/tuning/tileClick.js +5 -5
- package/dist/browser/tuning/xfinity.d.ts +1 -1
- package/dist/browser/tuning/youtubeTv.d.ts +1 -1
- package/dist/browser/tuning/youtubeTv.js +17 -18
- package/dist/browser/tuning/youtubeTv.js.map +1 -1
- package/dist/browser/video.d.ts +20 -6
- package/dist/browser/video.js +30 -38
- package/dist/browser/video.js.map +1 -1
- package/dist/channels/index.d.ts +1 -2
- package/dist/channels/index.js +475 -442
- package/dist/channels/index.js.map +1 -1
- package/dist/config/channelForm.d.ts +58 -0
- package/dist/config/channelForm.js +129 -0
- package/dist/config/channelForm.js.map +1 -0
- package/dist/config/consistencyProbe.d.ts +7 -0
- package/dist/config/consistencyProbe.js +143 -0
- package/dist/config/consistencyProbe.js.map +1 -0
- package/dist/config/health.d.ts +7 -6
- package/dist/config/health.js +78 -65
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +21 -14
- package/dist/config/index.js.map +1 -1
- package/dist/config/paths.d.ts +9 -3
- package/dist/config/paths.js +12 -4
- package/dist/config/paths.js.map +1 -1
- package/dist/config/persistence.context.d.ts +8 -0
- package/dist/config/persistence.context.js +30 -0
- package/dist/config/persistence.context.js.map +1 -0
- package/dist/config/persistence.d.ts +157 -0
- package/dist/config/persistence.js +446 -0
- package/dist/config/persistence.js.map +1 -0
- package/dist/config/presets.d.ts +14 -4
- package/dist/config/presets.js +84 -75
- package/dist/config/presets.js.map +1 -1
- package/dist/config/profiles.d.ts +2 -2
- package/dist/config/profiles.js +7 -9
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/servicePacks.d.ts +46 -0
- package/dist/config/{providerPacks.js → servicePacks.js} +50 -53
- package/dist/config/servicePacks.js.map +1 -0
- package/dist/config/services.d.ts +228 -0
- package/dist/config/services.js +772 -0
- package/dist/config/services.js.map +1 -0
- package/dist/config/sites.d.ts +3 -3
- package/dist/config/sites.js +75 -75
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +522 -54
- package/dist/config/userChannels.js +1343 -366
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +108 -13
- package/dist/config/userConfig.js +296 -143
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +34 -16
- package/dist/config/userProfiles.js +189 -100
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +10 -16
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/deviceId.js +10 -9
- package/dist/hdhr/deviceId.js.map +1 -1
- package/dist/hdhr/discover.js +4 -5
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/hdhr/index.js +12 -19
- package/dist/hdhr/index.js.map +1 -1
- package/dist/index.js +12 -12
- package/dist/index.js.map +1 -1
- package/dist/native/codecInference.d.ts +44 -0
- package/dist/native/codecInference.js +327 -0
- package/dist/native/codecInference.js.map +1 -0
- package/dist/native/decrypt.d.ts +1 -1
- package/dist/native/index.d.ts +5 -5
- package/dist/native/index.js +45 -67
- package/dist/native/index.js.map +1 -1
- package/dist/native/probe.d.ts +27 -9
- package/dist/native/probe.js +159 -68
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +4 -2
- package/dist/native/proxy.js +56 -53
- package/dist/native/proxy.js.map +1 -1
- package/dist/native/tokenExpiry.d.ts +22 -0
- package/dist/native/tokenExpiry.js +153 -0
- package/dist/native/tokenExpiry.js.map +1 -0
- package/dist/routes/assets.js +3 -3
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/auth.js +9 -13
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/components.js +2 -2
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/endpoints/browse.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/browse.js +195 -0
- package/dist/routes/config/channels/endpoints/browse.js.map +1 -0
- package/dist/routes/config/channels/endpoints/bulk.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/bulk.js +128 -0
- package/dist/routes/config/channels/endpoints/bulk.js.map +1 -0
- package/dist/routes/config/channels/endpoints/crud.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/crud.js +504 -0
- package/dist/routes/config/channels/endpoints/crud.js.map +1 -0
- package/dist/routes/config/channels/endpoints/importExport.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/importExport.js +131 -0
- package/dist/routes/config/channels/endpoints/importExport.js.map +1 -0
- package/dist/routes/config/channels/endpoints/predefined.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/predefined.js +78 -0
- package/dist/routes/config/channels/endpoints/predefined.js.map +1 -0
- package/dist/routes/config/channels/endpoints/prefs.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/prefs.js +58 -0
- package/dist/routes/config/channels/endpoints/prefs.js.map +1 -0
- package/dist/routes/config/channels/endpoints/service.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/service.js +149 -0
- package/dist/routes/config/channels/endpoints/service.js.map +1 -0
- package/dist/routes/config/channels/endpoints/tags.d.ts +6 -0
- package/dist/routes/config/channels/endpoints/tags.js +185 -0
- package/dist/routes/config/channels/endpoints/tags.js.map +1 -0
- package/dist/routes/config/channels/http/handler.d.ts +10 -0
- package/dist/routes/config/channels/http/handler.js +20 -0
- package/dist/routes/config/channels/http/handler.js.map +1 -0
- package/dist/routes/config/channels/http/playlistHint.d.ts +25 -0
- package/dist/routes/config/channels/http/playlistHint.js +40 -0
- package/dist/routes/config/channels/http/playlistHint.js.map +1 -0
- package/dist/routes/config/channels/http/serviceWarning.d.ts +10 -0
- package/dist/routes/config/channels/http/serviceWarning.js +28 -0
- package/dist/routes/config/channels/http/serviceWarning.js.map +1 -0
- package/dist/routes/config/channels/index.d.ts +3 -3
- package/dist/routes/config/channels/index.js +2 -2
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/{routes.d.ts → setup.d.ts} +1 -1
- package/dist/routes/config/channels/setup.js +23 -0
- package/dist/routes/config/channels/setup.js.map +1 -0
- package/dist/routes/config/channels/table.d.ts +13 -13
- package/dist/routes/config/channels/table.js +291 -184
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/http/envelope.d.ts +111 -0
- package/dist/routes/config/http/envelope.js +103 -0
- package/dist/routes/config/http/envelope.js.map +1 -0
- package/dist/routes/config/index.d.ts +9 -9
- package/dist/routes/config/index.js +5 -5
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/{providers.js → services.js} +105 -99
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.d.ts +1 -1
- package/dist/routes/config/settings.js +85 -60
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +17 -18
- package/dist/routes/debug.js.map +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/icons.js +2 -2
- package/dist/routes/icons.js.map +1 -1
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +23 -34
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/mpegts.js +1 -1
- package/dist/routes/playlist.d.ts +5 -5
- package/dist/routes/playlist.js +84 -61
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +82 -61
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/index.js +2 -5
- package/dist/routes/root/index.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +538 -581
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +868 -1022
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/index.d.ts +4 -4
- package/dist/routes/root/scripts/shared.js +445 -197
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.d.ts +4 -0
- package/dist/routes/root/scripts/status.handlers.d.ts +182 -0
- package/dist/routes/root/scripts/status.handlers.js +713 -0
- package/dist/routes/root/scripts/status.handlers.js.map +1 -0
- package/dist/routes/root/scripts/status.js +85 -436
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +16 -8
- package/dist/routes/root/styles.js.map +1 -1
- package/dist/routes/services.d.ts +6 -0
- package/dist/routes/{providers.js → services.js} +58 -55
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/sse.d.ts +16 -0
- package/dist/routes/sse.js +29 -0
- package/dist/routes/sse.js.map +1 -0
- package/dist/routes/streams.js +10 -23
- package/dist/routes/streams.js.map +1 -1
- package/dist/routes/ui.d.ts +2 -2
- package/dist/routes/ui.js +2 -0
- package/dist/routes/ui.js.map +1 -1
- package/dist/routes/upgrade.js +6 -7
- package/dist/routes/upgrade.js.map +1 -1
- package/dist/service/commands.context.d.ts +6 -0
- package/dist/service/commands.context.js +47 -0
- package/dist/service/commands.context.js.map +1 -0
- package/dist/service/commands.d.ts +53 -8
- package/dist/service/commands.js +172 -192
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.context.d.ts +6 -0
- package/dist/service/generators.context.js +45 -0
- package/dist/service/generators.context.js.map +1 -0
- package/dist/service/generators.d.ts +56 -16
- package/dist/service/generators.js +509 -291
- package/dist/service/generators.js.map +1 -1
- package/dist/service/index.d.ts +2 -2
- package/dist/streaming/clients.d.ts +2 -2
- package/dist/streaming/clients.js +2 -2
- package/dist/streaming/codec.d.ts +2 -2
- package/dist/streaming/codec.js +0 -1
- package/dist/streaming/codec.js.map +1 -1
- package/dist/streaming/fmp4Segmenter.d.ts +2 -2
- package/dist/streaming/fmp4Segmenter.js +12 -12
- package/dist/streaming/hls.d.ts +7 -7
- package/dist/streaming/hls.js +104 -92
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.d.ts +3 -3
- package/dist/streaming/hlsResume.js +6 -6
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/hlsSegments.d.ts +5 -2
- package/dist/streaming/hlsSegments.js +14 -9
- package/dist/streaming/hlsSegments.js.map +1 -1
- package/dist/streaming/monitor.d.ts +12 -12
- package/dist/streaming/monitor.js +98 -66
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/mp4Parser.d.ts +34 -4
- package/dist/streaming/mp4Parser.js +111 -29
- package/dist/streaming/mp4Parser.js.map +1 -1
- package/dist/streaming/mpegts.d.ts +1 -1
- package/dist/streaming/mpegts.js +13 -10
- package/dist/streaming/mpegts.js.map +1 -1
- package/dist/streaming/playlistBuilder.d.ts +1 -1
- package/dist/streaming/playlistBuilder.js +2 -2
- package/dist/streaming/preroll.d.ts +9 -9
- package/dist/streaming/preroll.js +40 -42
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/pretune.js +4 -4
- package/dist/streaming/pretune.js.map +1 -1
- package/dist/streaming/recovery.d.ts +3 -3
- package/dist/streaming/recovery.js +4 -4
- package/dist/streaming/registry.d.ts +6 -6
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +11 -10
- package/dist/streaming/setup.js +120 -71
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.d.ts +9 -3
- package/dist/streaming/showInfo.js +33 -27
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +8 -7
- package/dist/streaming/statusEmitter.js +5 -5
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +139 -36
- package/dist/types/channels.js +39 -1
- package/dist/types/channels.js.map +1 -1
- package/dist/types/config.d.ts +22 -3
- package/dist/types/index.d.ts +9 -8
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +14 -13
- package/dist/types/selection.d.ts +49 -5
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +2 -2
- package/dist/upgrade/commands.context.d.ts +6 -0
- package/dist/upgrade/commands.context.js +29 -0
- package/dist/upgrade/commands.context.js.map +1 -0
- package/dist/upgrade/commands.d.ts +29 -2
- package/dist/upgrade/commands.js +87 -134
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/upgrade/detection.context.d.ts +6 -0
- package/dist/upgrade/detection.context.js +24 -0
- package/dist/upgrade/detection.context.js.map +1 -0
- package/dist/upgrade/detection.d.ts +140 -10
- package/dist/upgrade/detection.js +153 -66
- package/dist/upgrade/detection.js.map +1 -1
- package/dist/upgrade/index.d.ts +2 -2
- package/dist/utils/chromeFetch.d.ts +1 -1
- package/dist/utils/chromeFetch.js +2 -2
- package/dist/utils/clock.d.ts +14 -0
- package/dist/utils/clock.js +28 -0
- package/dist/utils/clock.js.map +1 -0
- package/dist/utils/debugFilter.d.ts +1 -1
- package/dist/utils/debugFilter.js +8 -6
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/delay.d.ts +2 -3
- package/dist/utils/delay.js +10 -16
- package/dist/utils/delay.js.map +1 -1
- package/dist/utils/evaluate.js +12 -14
- package/dist/utils/evaluate.js.map +1 -1
- package/dist/utils/ffmpeg.context.d.ts +6 -0
- package/dist/utils/ffmpeg.context.js +31 -0
- package/dist/utils/ffmpeg.context.js.map +1 -0
- package/dist/utils/ffmpeg.d.ts +85 -29
- package/dist/utils/ffmpeg.js +126 -129
- package/dist/utils/ffmpeg.js.map +1 -1
- package/dist/utils/fileLogger.d.ts +18 -2
- package/dist/utils/fileLogger.js +60 -31
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +7 -1
- package/dist/utils/format.js +22 -2
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/html.js +1 -3
- package/dist/utils/html.js.map +1 -1
- package/dist/utils/index.d.ts +21 -20
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logger.js +31 -45
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/m3u.d.ts +18 -3
- package/dist/utils/m3u.js +28 -14
- package/dist/utils/m3u.js.map +1 -1
- package/dist/utils/memo.d.ts +19 -0
- package/dist/utils/memo.js +53 -0
- package/dist/utils/memo.js.map +1 -0
- package/dist/utils/morganStream.js +4 -4
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/pid.d.ts +2 -2
- package/dist/utils/pid.js +2 -2
- package/dist/utils/platform.d.ts +1 -1
- package/dist/utils/platform.js +3 -2
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/retry.d.ts +2 -0
- package/dist/utils/retry.js +9 -7
- package/dist/utils/retry.js.map +1 -1
- package/dist/utils/sanitize.d.ts +2 -2
- package/dist/utils/sanitize.js +4 -4
- package/dist/utils/streamContext.d.ts +7 -0
- package/dist/utils/streamContext.js +10 -1
- package/dist/utils/streamContext.js.map +1 -1
- package/dist/utils/timing.d.ts +6 -3
- package/dist/utils/timing.js +10 -6
- package/dist/utils/timing.js.map +1 -1
- package/dist/utils/version.d.ts +3 -2
- package/dist/utils/version.js +15 -28
- package/dist/utils/version.js.map +1 -1
- package/package.json +14 -10
- package/dist/config/providerPacks.d.ts +0 -46
- package/dist/config/providerPacks.js.map +0 -1
- package/dist/config/providers.d.ts +0 -175
- package/dist/config/providers.js +0 -691
- package/dist/config/providers.js.map +0 -1
- package/dist/native/intercept.d.ts +0 -35
- package/dist/native/intercept.js +0 -184
- package/dist/native/intercept.js.map +0 -1
- package/dist/routes/config/channels/routes.js +0 -1477
- package/dist/routes/config/channels/routes.js.map +0 -1
- package/dist/routes/config/providers.js.map +0 -1
- package/dist/routes/providers.d.ts +0 -6
- package/dist/routes/providers.js.map +0 -1
- /package/dist/routes/config/{providers.d.ts → services.d.ts} +0 -0
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
/* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
-
*
|
|
3
|
-
* table.ts: Channel table rendering for the PrismCast configuration interface.
|
|
4
|
-
*/
|
|
5
1
|
import { ICON_BOLT, ICON_COPY, ICON_DELETE, ICON_DISABLE, ICON_EDIT, ICON_ENABLE, ICON_FILTER, ICON_HEALTH, ICON_LINK, ICON_LOGIN, ICON_MANAGE, ICON_REVERT, ICON_TRANSFER } from "../../icons.js";
|
|
6
|
-
import { compareChannelSort,
|
|
2
|
+
import { compareChannelSort, getAllServiceTags, getAuthDomainForChannel, getChannelServiceLabel, getChannelServiceTags, getChannelSortKey, getEnabledServices, getPredefinedDomainMap, getServiceGroup, hasMultipleServices, isChannelAvailableByService, isServiceTagEnabled, resolvePredefinedVariant, resolveServiceKey } from "../../../config/services.js";
|
|
7
3
|
import { escapeHtml, formatTimeAgo } from "../../../utils/index.js";
|
|
8
|
-
import { getActiveTagVocabulary, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel } from "../../../config/userChannels.js";
|
|
4
|
+
import { getActiveTagVocabulary, getChannelCustomizations, getChannelEffectiveTags, getChannelListing, getChannelLogo, getChannelsParseErrorMessage, getEffectiveHdhrEnabled, getPredefinedScopeCounts, getTagRegistry, getUserChannelsFilePath, hasChannelsParseError, isPredefinedChannel, isPredefinedChannelDisabled, isUserChannel, isVisibleChannel, tagsMatch } from "../../../config/userChannels.js";
|
|
9
5
|
import { getCachedProviderChannels, getProviderDomainMap, getProviderGuideUrls, getProviderModuleInfo } from "../../../browser/channelSelection.js";
|
|
10
6
|
import { getChannelHealth, getDomainAuth } from "../../../config/health.js";
|
|
11
7
|
import { getProfileForChannel, getProfiles } from "../../../config/profiles.js";
|
|
@@ -14,22 +10,22 @@ import { PREDEFINED_CHANNELS } from "../../../channels/index.js";
|
|
|
14
10
|
import { categorizeProfiles } from "../index.js";
|
|
15
11
|
import { generateWizardModal } from "../../components.js";
|
|
16
12
|
/**
|
|
17
|
-
* Generates an annotated
|
|
18
|
-
* icon + text combination. The server just emits the text with data attributes
|
|
19
|
-
* @param name - The
|
|
20
|
-
* @param domain - The
|
|
13
|
+
* Generates an annotated service display span. The client-side page-load script processes these elements via serviceIconHtml, rendering the appropriate
|
|
14
|
+
* icon + text combination. The server just emits the text with data attributes - all icon rendering is client-side through the single serviceIconHtml path.
|
|
15
|
+
* @param name - The service display name.
|
|
16
|
+
* @param domain - The service's domain for icon fallback derivation. Undefined for services without a known domain.
|
|
21
17
|
* @param iconUrl - Optional explicit icon URL to try before domain-derived fallbacks.
|
|
22
18
|
* @param small - When true, uses the small icon variant for chips.
|
|
23
19
|
* @returns HTML span string with data attributes for client-side processing.
|
|
24
20
|
*/
|
|
25
|
-
function
|
|
21
|
+
function serviceDisplaySpan(name, domain, iconUrl, small) {
|
|
26
22
|
const domainAttr = domain ? " data-domain=\"" + escapeHtml(domain) + "\"" : "";
|
|
27
23
|
const iconAttr = iconUrl ? " data-icon-url=\"" + escapeHtml(iconUrl) + "\"" : "";
|
|
28
24
|
const sizeAttr = small ? " data-sm" : "";
|
|
29
25
|
return "<span class=\"provider-display\"" + domainAttr + iconAttr + sizeAttr + ">" + escapeHtml(name) + "</span>";
|
|
30
26
|
}
|
|
31
27
|
// Optional column definitions for the channels table. These columns can be shown or hidden by the user via the column picker. The order here determines the
|
|
32
|
-
// column order in the table, slotted between
|
|
28
|
+
// column order in the table, slotted between Service and Actions.
|
|
33
29
|
export const OPTIONAL_COLUMNS = [
|
|
34
30
|
{ align: "center", cssClass: "col-chnum", field: "channelNumber", label: "Number", width: "70px" },
|
|
35
31
|
{ align: "center", cssClass: "col-hdhr", field: "hdhrEnabled", label: "HDHR", width: "55px" },
|
|
@@ -58,10 +54,18 @@ function generateTextField(id, name, label, value, options = {}) {
|
|
|
58
54
|
const required = options.required ? " required" : "";
|
|
59
55
|
const pattern = options.pattern ? " pattern=\"" + options.pattern + "\"" : "";
|
|
60
56
|
const placeholder = options.placeholder ? " placeholder=\"" + escapeHtml(options.placeholder) + "\"" : "";
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
// Modification tracking is driven by storage provenance (isCustomized), not value comparison. A field is customized iff the user has explicitly set it on
|
|
58
|
+
// the canonical or active variant stored entry - that is the single source of truth, supplied by getChannelCustomizations in the storage layer.
|
|
59
|
+
const isModified = options.isCustomized === true;
|
|
60
|
+
const modifiedClass = isModified ? " modified" : "";
|
|
61
|
+
const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
|
|
62
|
+
const defaultAttr = (options.defaultValue !== undefined) ? " data-default=\"" + escapeHtml(options.defaultValue) + "\"" : "";
|
|
63
|
+
const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
|
|
64
|
+
"onclick=\"resetChannelField('" + id + "')\">↻</button>" : "";
|
|
65
|
+
lines.push("<div class=\"form-row" + modifiedClass + "\">");
|
|
66
|
+
lines.push(modifiedDot + "<label for=\"" + id + "\">" + label + "</label>");
|
|
63
67
|
lines.push("<input class=\"form-input\" type=\"" + inputType + "\" id=\"" + id + "\" name=\"" + name + "\"" + required + listAttr + pattern +
|
|
64
|
-
placeholder + " value=\"" + escapeHtml(value) + "\">");
|
|
68
|
+
placeholder + defaultAttr + " value=\"" + escapeHtml(value) + "\">" + resetBtn);
|
|
65
69
|
lines.push("</div>");
|
|
66
70
|
// When a datalist ID is specified, append an empty <datalist> element outside the form-row flex container. The client-side JavaScript populates it dynamically
|
|
67
71
|
// based on the URL field value.
|
|
@@ -79,11 +83,21 @@ function generateTextField(id, name, label, value, options = {}) {
|
|
|
79
83
|
* @param selectedProfile - The currently selected profile (empty string for autodetect).
|
|
80
84
|
* @param profiles - List of available profiles with descriptions and summaries.
|
|
81
85
|
* @param showHint - Whether to show the hint text with profile reference link.
|
|
86
|
+
* @param defaultProfile - When provided, used as the data-default attribute for the per-field reset button. Should be supplied alongside isCustomized=true.
|
|
87
|
+
* @param isCustomized - True when the user has explicitly customized the profile field (presence in stored deltas). Drives the modified visual treatment and
|
|
88
|
+
* the per-field reset button. Independent of value comparison - storage provenance is the single source of truth.
|
|
82
89
|
* @returns Array of HTML strings for the form row.
|
|
83
90
|
*/
|
|
84
|
-
function generateProfileDropdown(id, selectedProfile, profiles, showHint = true) {
|
|
91
|
+
function generateProfileDropdown(id, selectedProfile, profiles, showHint = true, defaultProfile, isCustomized) {
|
|
85
92
|
const lines = [];
|
|
86
93
|
const groups = categorizeProfiles(profiles);
|
|
94
|
+
// Modification tracking is driven by storage provenance (isCustomized), not value comparison. Symmetric with generateTextField.
|
|
95
|
+
const isModified = isCustomized === true;
|
|
96
|
+
const modifiedClass = isModified ? " modified" : "";
|
|
97
|
+
const modifiedDot = isModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
|
|
98
|
+
const defaultAttr = (defaultProfile !== undefined) ? " data-default=\"" + escapeHtml(defaultProfile) + "\"" : "";
|
|
99
|
+
const resetBtn = isModified ? "<button type=\"button\" class=\"btn-reset\" title=\"Reset to predefined default\" aria-label=\"Reset to predefined default\" " +
|
|
100
|
+
"onclick=\"resetChannelField('" + id + "')\">↻</button>" : "";
|
|
87
101
|
// Helper to generate option elements for a profile.
|
|
88
102
|
const renderOption = (profile) => {
|
|
89
103
|
const selected = (profile.name === selectedProfile) ? " selected" : "";
|
|
@@ -91,9 +105,9 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
|
|
|
91
105
|
const displayText = profile.summary ? profile.name + " \u2014 " + profile.summary : profile.name;
|
|
92
106
|
return "<option value=\"" + escapeHtml(profile.name) + "\"" + title + selected + ">" + escapeHtml(displayText) + "</option>";
|
|
93
107
|
};
|
|
94
|
-
lines.push("<div class=\"form-row\">");
|
|
95
|
-
lines.push("<label for=\"" + id + "\">Profile</label>");
|
|
96
|
-
lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\">");
|
|
108
|
+
lines.push("<div class=\"form-row" + modifiedClass + "\">");
|
|
109
|
+
lines.push(modifiedDot + "<label for=\"" + id + "\">Profile</label>");
|
|
110
|
+
lines.push("<select class=\"form-select field-wide\" id=\"" + id + "\" name=\"profile\"" + defaultAttr + ">");
|
|
97
111
|
lines.push("<option value=\"\">Autodetect (Recommended)</option>");
|
|
98
112
|
// Fullscreen API profiles (most common).
|
|
99
113
|
if (groups.api.length > 0) {
|
|
@@ -135,7 +149,7 @@ function generateProfileDropdown(id, selectedProfile, profiles, showHint = true)
|
|
|
135
149
|
}
|
|
136
150
|
lines.push("</optgroup>");
|
|
137
151
|
}
|
|
138
|
-
lines.push("</select>");
|
|
152
|
+
lines.push("</select>" + resetBtn);
|
|
139
153
|
lines.push("</div>");
|
|
140
154
|
if (showHint) {
|
|
141
155
|
lines.push("<div class=\"hint\">Autodetect uses predefined profiles for known sites. If video doesn't play or fullscreen fails, " +
|
|
@@ -248,7 +262,7 @@ function generateProfileReference(profiles) {
|
|
|
248
262
|
if (groups.custom.length > 0) {
|
|
249
263
|
lines.push("<div class=\"profile-category\">");
|
|
250
264
|
lines.push("<h4>Custom Profiles</h4>");
|
|
251
|
-
lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from
|
|
265
|
+
lines.push("<p class=\"category-desc\">User-defined profiles created via the profile builder wizard or imported from service packs.</p>");
|
|
252
266
|
lines.push("<dl class=\"profile-list\">");
|
|
253
267
|
for (const profile of groups.custom) {
|
|
254
268
|
lines.push("<dt>" + escapeHtml(profile.name) + "</dt>");
|
|
@@ -260,8 +274,14 @@ function generateProfileReference(profiles) {
|
|
|
260
274
|
lines.push("</div>");
|
|
261
275
|
return lines.join("\n");
|
|
262
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Generates HTML for the advanced fields section (station ID, channel selector, channel number, guide title, logo URL, HDHomeRun toggle, tags).
|
|
279
|
+
* @param idPrefix - Prefix for element IDs ("add" or "edit").
|
|
280
|
+
* @param options - Field values, defaults, and customization metadata. See AdvancedFieldOptions.
|
|
281
|
+
* @returns Array of HTML strings for the advanced fields section.
|
|
282
|
+
*/
|
|
263
283
|
function generateAdvancedFields(idPrefix, options = {}) {
|
|
264
|
-
const { channelNumberValue = "", channelSelectorValue = "", hdhrEnabled = true, showHints = true, stationIdValue = "", tagsValue = "" } = options;
|
|
284
|
+
const { channelNumberValue = "", channelSelectorValue = "", guideTitleValue = "", hdhrEnabled = true, logoUrlValue = "", showHints = true, stationIdValue = "", tagsValue = "" } = options;
|
|
265
285
|
const lines = [];
|
|
266
286
|
// Advanced fields toggle.
|
|
267
287
|
lines.push("<div class=\"advanced-toggle\" onclick=\"document.getElementById('" + idPrefix +
|
|
@@ -270,8 +290,18 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
270
290
|
lines.push("<div id=\"" + idPrefix + "-advanced\" class=\"advanced-fields\">");
|
|
271
291
|
// Station ID.
|
|
272
292
|
const stationIdHint = showHints ? "Optional Gracenote station ID for guide data (tvc-guide-stationid)." : undefined;
|
|
273
|
-
|
|
274
|
-
|
|
293
|
+
const defs = options.defaults;
|
|
294
|
+
const customized = options.customizedFields;
|
|
295
|
+
lines.push(...generateTextField(idPrefix + "-stationId", "stationId", "Station ID", stationIdValue, { defaultValue: defs?.stationId, hint: stationIdHint, isCustomized: customized?.has("stationId"), placeholder: showHints ? "e.g., 12345" : undefined }));
|
|
296
|
+
// Guide title override. When set, this value replaces the channel name in the M3U playlist's tvg-name attribute and guide placeholder airings.
|
|
297
|
+
const guideTitleHint = showHints ? "Optional title for guide display. When set, overrides the channel name in the M3U playlist (tvg-name)." : undefined;
|
|
298
|
+
lines.push(...generateTextField(idPrefix + "-guideTitle", "guideTitle", "Guide Title", guideTitleValue, { defaultValue: defs?.guideTitle, hint: guideTitleHint, isCustomized: customized?.has("guideTitle"),
|
|
299
|
+
placeholder: showHints ? "e.g., Flighty Airport Delays" : undefined }));
|
|
300
|
+
// Custom logo URL. When set, this value is emitted as the tvg-logo attribute in the M3U playlist.
|
|
301
|
+
const logoUrlHint = showHints ? "Optional logo URL for the channel. Overrides any logo derived from Channels DVR." : undefined;
|
|
302
|
+
lines.push(...generateTextField(idPrefix + "-logoUrl", "logoUrl", "Logo URL", logoUrlValue, { defaultValue: defs?.logoUrl, hint: logoUrlHint, isCustomized: customized?.has("logoUrl"),
|
|
303
|
+
placeholder: showHints ? "https://example.com/logo.png" : undefined, type: "url" }));
|
|
304
|
+
// Channel selector. The guide-based service list is derived from the provider module registry so it stays current as services are added.
|
|
275
305
|
const guideProviderNames = getProviderModuleInfo().map((p) => p.label).sort().join(", ");
|
|
276
306
|
const channelSelectorHint = showHints ?
|
|
277
307
|
"Identifies which channel to select on sites that host multiple live streams. Known values are suggested when the URL matches a supported site. " +
|
|
@@ -279,23 +309,30 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
279
309
|
"For tile and thumbnail profiles, right-click the channel element \u2192 Inspect \u2192 copy a unique value matching the profile's selector pattern " +
|
|
280
310
|
"(typically a portion of the image src URL)." :
|
|
281
311
|
undefined;
|
|
282
|
-
lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, {
|
|
312
|
+
lines.push(...generateTextField(idPrefix + "-channelSelector", "channelSelector", "Channel Selector", channelSelectorValue, { defaultValue: defs?.channelSelector, hint: channelSelectorHint, isCustomized: customized?.has("channelSelector"),
|
|
313
|
+
list: idPrefix + "-selectorList", placeholder: showHints ? "e.g., ESPN" : undefined }));
|
|
283
314
|
// Channel number for Channels DVR and Plex integration.
|
|
284
315
|
const channelNumberHint = showHints ?
|
|
285
316
|
"Optional numeric channel number for guide matching in Channels DVR and Plex." :
|
|
286
317
|
undefined;
|
|
287
|
-
lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { hint: channelNumberHint,
|
|
318
|
+
lines.push(...generateTextField(idPrefix + "-channelNumber", "channelNumber", "Channel Number", channelNumberValue, { defaultValue: defs?.channelNumber, hint: channelNumberHint, isCustomized: customized?.has("channelNumber"),
|
|
319
|
+
placeholder: showHints ? "e.g., 501" : undefined }));
|
|
288
320
|
// Tags field: checkbox grid of the active tag vocabulary. Checked tags are collected into a hidden input as a comma-separated value on form submission.
|
|
289
321
|
// This prevents users from entering tag names that don't exist in the managed vocabulary.
|
|
290
322
|
const vocabulary = getActiveTagVocabulary();
|
|
291
|
-
const currentTags =
|
|
323
|
+
const currentTags = tagsValue ? tagsValue.split(",").map((t) => t.trim()).filter((t) => t.length > 0) : [];
|
|
292
324
|
if (vocabulary.length > 0) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
325
|
+
// Tags are an identity field stored on the canonical entry. Customization is presence in the canonical-stored entry, surfaced via customizedFields.
|
|
326
|
+
const tagsModified = customized?.has("tags") === true;
|
|
327
|
+
const tagsModifiedClass = tagsModified ? " modified" : "";
|
|
328
|
+
const tagsModifiedDot = tagsModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
|
|
329
|
+
const tagsDefaultAttr = (defs?.tags !== undefined) ? " data-default=\"" + escapeHtml(defs.tags) + "\"" : "";
|
|
330
|
+
lines.push("<div class=\"form-row" + tagsModifiedClass + "\">");
|
|
331
|
+
lines.push(tagsModifiedDot + "<label>Tags</label>");
|
|
332
|
+
lines.push("<input type=\"hidden\" name=\"tags\" id=\"" + idPrefix + "-tags-hidden\" value=\"" + escapeHtml(tagsValue) + "\"" + tagsDefaultAttr + ">");
|
|
296
333
|
lines.push("<div class=\"tag-checkbox-grid\">");
|
|
297
334
|
for (const tag of vocabulary) {
|
|
298
|
-
const checked = currentTags.
|
|
335
|
+
const checked = currentTags.some((t) => tagsMatch(t, tag)) ? " checked" : "";
|
|
299
336
|
lines.push("<label class=\"tag-checkbox-label\">" +
|
|
300
337
|
"<input type=\"checkbox\" class=\"tag-checkbox\" data-tag=\"" + escapeHtml(tag) + "\"" + checked +
|
|
301
338
|
" onchange=\"updateTagsHidden(this.closest('.form-row'))\">" +
|
|
@@ -310,22 +347,29 @@ function generateAdvancedFields(idPrefix, options = {}) {
|
|
|
310
347
|
// HDHomeRun lineup inclusion. A hidden input provides the "false" value when the checkbox is unchecked (unchecked checkboxes are not submitted in FormData).
|
|
311
348
|
// When checked, the checkbox value "true" overwrites the hidden input's "false" since it appears later in DOM order.
|
|
312
349
|
const hdhrChecked = hdhrEnabled ? " checked" : "";
|
|
350
|
+
// Customization is presence in stored deltas (identity field, lives on canonical entry); surfaced via customizedFields. The default value (when present)
|
|
351
|
+
// arrives pre-stringified as "true" or "false" from resetValueFor -> getEffectiveHdhrEnabled, so it goes directly into the data-default attribute.
|
|
352
|
+
const hdhrModified = customized?.has("hdhrEnabled") === true;
|
|
353
|
+
const hdhrModifiedClass = hdhrModified ? " modified" : "";
|
|
354
|
+
const hdhrModifiedDot = hdhrModified ? "<span class=\"modified-dot\" title=\"Modified from predefined default\"></span>" : "";
|
|
355
|
+
const hdhrDefaultAttr = (defs?.hdhrEnabled !== undefined) ? " data-default=\"" + defs.hdhrEnabled + "\"" : "";
|
|
313
356
|
const hdhrHint = showHints ? "When unchecked, this channel is hidden from the HDHomeRun lineup and not available in Plex." : undefined;
|
|
314
|
-
lines.push("<div class=\"form-row form-row-checkbox\">");
|
|
315
|
-
lines.push("<label for=\"" + idPrefix + "-hdhrEnabled\">Include in HDHomeRun/Plex Lineup</label>");
|
|
357
|
+
lines.push("<div class=\"form-row form-row-checkbox" + hdhrModifiedClass + "\">");
|
|
358
|
+
lines.push(hdhrModifiedDot + "<label for=\"" + idPrefix + "-hdhrEnabled\">Include in HDHomeRun/Plex Lineup</label>");
|
|
316
359
|
lines.push("<input type=\"hidden\" name=\"hdhrEnabled\" value=\"false\">");
|
|
317
|
-
lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + ">");
|
|
360
|
+
lines.push("<input type=\"checkbox\" id=\"" + idPrefix + "-hdhrEnabled\" name=\"hdhrEnabled\" value=\"true\"" + hdhrChecked + hdhrDefaultAttr + ">");
|
|
318
361
|
lines.push("</div>");
|
|
319
362
|
if (hdhrHint) {
|
|
320
363
|
lines.push("<div class=\"hint\">" + hdhrHint + "</div>");
|
|
321
364
|
}
|
|
322
|
-
|
|
365
|
+
// Close the advanced-fields wrapper opened at the top of this builder.
|
|
366
|
+
lines.push("</div>");
|
|
323
367
|
return lines;
|
|
324
368
|
}
|
|
325
369
|
/**
|
|
326
370
|
* Generates JavaScript variables for channel selector datalist population. Produces three variables: `channelSelectorsByDomain` maps URL hostnames to known
|
|
327
|
-
* channel selector values (from predefined channels and cached
|
|
328
|
-
* client-side async discovery, and `
|
|
371
|
+
* channel selector values (from predefined channels and cached service discovery), `serviceByDomain` maps service guide URL hostnames to service slugs for
|
|
372
|
+
* client-side async discovery, and `serviceGuideUrl` maps service slugs to their guide URLs for URL correction hints. Embedded as a `<script>` block in the
|
|
329
373
|
* channels panel.
|
|
330
374
|
*
|
|
331
375
|
* @returns JavaScript variable declarations ready to embed in a `<script>` tag.
|
|
@@ -339,8 +383,12 @@ function generateChannelSelectorData() {
|
|
|
339
383
|
}
|
|
340
384
|
// Use the resolved channel (with inheritance from the canonical) so stationId, name, and other inherited fields are populated correctly for variants.
|
|
341
385
|
// The channelSelector is captured from the raw entry (already validated as non-null) to avoid non-null assertions on the resolved channel's optional field.
|
|
386
|
+
// resolvePredefinedVariant returns a fully-typed ResolvedChannel for both canonical and variant keys; defensive skip for the rare missing-canonical case.
|
|
342
387
|
const selector = raw.channelSelector;
|
|
343
|
-
const channel = resolvePredefinedVariant(key)
|
|
388
|
+
const channel = resolvePredefinedVariant(key);
|
|
389
|
+
if (!channel) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
344
392
|
const hostname = new URL(channel.url).hostname;
|
|
345
393
|
seen[hostname] ??= new Set();
|
|
346
394
|
if (seen[hostname].has(selector)) {
|
|
@@ -350,32 +398,33 @@ function generateChannelSelectorData() {
|
|
|
350
398
|
byDomain[hostname] ??= [];
|
|
351
399
|
byDomain[hostname].push({ label: channel.name ?? selector, stationId: channel.stationId, value: selector });
|
|
352
400
|
}
|
|
353
|
-
// Merge cached
|
|
354
|
-
// value is not already present for that domain. This enriches the datalist with the full
|
|
401
|
+
// Merge cached service-discovered channels into the domain map. Predefined entries take precedence - we only add discovered channels whose channelSelector
|
|
402
|
+
// value is not already present for that domain. This enriches the datalist with the full service lineup when precaching or prior discovery has run.
|
|
355
403
|
for (const provider of getCachedProviderChannels()) {
|
|
356
|
-
seen[provider.hostname] ??= new Set();
|
|
357
|
-
byDomain[provider.hostname] ??= [];
|
|
404
|
+
const seenForHost = seen[provider.hostname] ??= new Set();
|
|
405
|
+
const entriesForHost = byDomain[provider.hostname] ??= [];
|
|
358
406
|
for (const entry of provider.entries) {
|
|
359
|
-
if (!
|
|
360
|
-
|
|
361
|
-
|
|
407
|
+
if (!seenForHost.has(entry.value)) {
|
|
408
|
+
seenForHost.add(entry.value);
|
|
409
|
+
entriesForHost.push(entry);
|
|
362
410
|
}
|
|
363
411
|
}
|
|
364
412
|
}
|
|
365
|
-
// Build the hostname
|
|
366
|
-
//
|
|
367
|
-
const
|
|
368
|
-
const
|
|
413
|
+
// Build the hostname->slug map and slug->guideUrl map for all services (including those with cold caches) so the client-side fetch can trigger discovery for any
|
|
414
|
+
// service domain and the URL hint can suggest the correct guide URL.
|
|
415
|
+
const serviceByDomain = getProviderDomainMap();
|
|
416
|
+
const serviceGuideUrl = getProviderGuideUrls();
|
|
369
417
|
// Sort entries within each domain alphabetically by label for consistent ordering in the datalist dropdown.
|
|
370
418
|
for (const entries of Object.values(byDomain)) {
|
|
371
419
|
entries.sort((a, b) => a.label.localeCompare(b.label));
|
|
372
420
|
}
|
|
373
421
|
return "var channelSelectorsByDomain = " + JSON.stringify(byDomain) + ";\n" +
|
|
374
|
-
"var
|
|
375
|
-
"var
|
|
422
|
+
"var serviceByDomain = " + JSON.stringify(serviceByDomain) + ";\n" +
|
|
423
|
+
"var serviceGuideUrl = " + JSON.stringify(serviceGuideUrl) + ";\n" +
|
|
424
|
+
"var predefinedByDomain = " + JSON.stringify(getPredefinedDomainMap()) + ";";
|
|
376
425
|
}
|
|
377
426
|
/**
|
|
378
|
-
* Generates the tag column filter dropdown content. This is the single source of truth for the filter checkbox list
|
|
427
|
+
* Generates the tag column filter dropdown content. This is the single source of truth for the filter checkbox list - used for both the initial header render
|
|
379
428
|
* and for incremental updates after tag CRUD operations. The dropdown shell (button, dropdown-menu wrapper) is rendered by the header generator; this function
|
|
380
429
|
* provides only the inner content (checkbox labels + "Show All" item).
|
|
381
430
|
* @returns HTML string for the filter dropdown content.
|
|
@@ -395,7 +444,7 @@ export function generateTagFilterContent() {
|
|
|
395
444
|
return lines.join("");
|
|
396
445
|
}
|
|
397
446
|
/**
|
|
398
|
-
* Generates the Tag Management modal body HTML. This is the single source of truth for tag manager content
|
|
447
|
+
* Generates the Tag Management modal body HTML. This is the single source of truth for tag manager content - used for both the initial server render and for
|
|
399
448
|
* incremental updates after tag CRUD operations. The endpoints return this HTML in the response so the client can replace the modal content without a page reload.
|
|
400
449
|
* @returns HTML string for the tag manager body (tag list, input field, deleted tags section).
|
|
401
450
|
*/
|
|
@@ -411,7 +460,7 @@ export function generateTagManagerBody() {
|
|
|
411
460
|
"<button type=\"button\" class=\"btn-icon btn-icon-delete\" title=\"Delete tag\" onclick=\"deleteTag('" + escapeHtml(tag) +
|
|
412
461
|
"')\">" + ICON_DELETE + "</button></div>");
|
|
413
462
|
}
|
|
414
|
-
// Deleted predefined tags section
|
|
463
|
+
// Deleted predefined tags section - show restore option for tags the user has deleted.
|
|
415
464
|
const deletedItems = [];
|
|
416
465
|
for (const tag of registry.deletedTags) {
|
|
417
466
|
deletedItems.push("<div class=\"tag-manager-item tag-deleted\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
@@ -424,7 +473,7 @@ export function generateTagManagerBody() {
|
|
|
424
473
|
"for playlist filtering and organization.</p>" +
|
|
425
474
|
"<div class=\"tag-manager-add\">" +
|
|
426
475
|
"<input type=\"text\" id=\"tag-manager-input\" placeholder=\"New tag name\" maxlength=\"30\" " +
|
|
427
|
-
"pattern=\"[a-
|
|
476
|
+
"pattern=\"[a-zA-Z0-9]([a-zA-Z0-9 -]*[a-zA-Z0-9])?\" onkeydown=\"if(event.key==='Enter'){event.preventDefault();createTag();}\">" +
|
|
428
477
|
"<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"createTag()\">Add</button>" +
|
|
429
478
|
"</div>" +
|
|
430
479
|
"<div id=\"tag-manager-error\" class=\"wizard-error\" style=\"display: none;\"></div>" +
|
|
@@ -452,13 +501,13 @@ function generateTagManagementModal() {
|
|
|
452
501
|
});
|
|
453
502
|
}
|
|
454
503
|
/**
|
|
455
|
-
* Generates the Browse Channels 2-step wizard modal. Step 1 is the
|
|
456
|
-
* (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed.
|
|
504
|
+
* Generates the Browse Channels 2-step wizard modal. Step 1 is the service picker grid, step 2 is channel discovery and management. Channel state detection
|
|
505
|
+
* (new, switch, current) is handled server-side by the annotated discovery response - no client-side matching logic is needed. Service and guide URL data are
|
|
457
506
|
* embedded as JSON data blocks for the client-side wizard controller.
|
|
458
507
|
* @returns HTML string for the browse modal.
|
|
459
508
|
*/
|
|
460
509
|
function generateBrowseModal() {
|
|
461
|
-
// Build the
|
|
510
|
+
// Build the service info array from the provider module registry. The client uses this to render the service picker.
|
|
462
511
|
const providers = getProviderModuleInfo();
|
|
463
512
|
const guideUrls = getProviderGuideUrls();
|
|
464
513
|
return generateWizardModal({
|
|
@@ -469,26 +518,26 @@ function generateBrowseModal() {
|
|
|
469
518
|
],
|
|
470
519
|
contentId: "browse-content",
|
|
471
520
|
dataBlocks: [
|
|
472
|
-
"<script type=\"application/json\" id=\"browse-
|
|
521
|
+
"<script type=\"application/json\" id=\"browse-services-data\">" + JSON.stringify(providers) + "</script>",
|
|
473
522
|
"<script type=\"application/json\" id=\"browse-guide-urls-data\">" + JSON.stringify(guideUrls) + "</script>"
|
|
474
523
|
],
|
|
475
|
-
description: "Manage your channel lineup by
|
|
524
|
+
description: "Manage your channel lineup by service. Add new channels, switch services for existing channels, or remove channels you no longer need.",
|
|
476
525
|
id: "browse-modal",
|
|
477
|
-
steps: ["
|
|
526
|
+
steps: ["Service", "Channels"],
|
|
478
527
|
title: "Browse Channels",
|
|
479
528
|
titleId: "browse-title"
|
|
480
529
|
});
|
|
481
530
|
}
|
|
482
531
|
/**
|
|
483
|
-
* Generates the
|
|
532
|
+
* Generates the Service Setup 3-step wizard modal. Steps: Services (multi-select), Sign In (sequential auth), Channels (summary + finish). The
|
|
484
533
|
* setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
485
534
|
* @returns HTML string for the setup wizard modal.
|
|
486
535
|
*/
|
|
487
536
|
function generateSetupWizardModal() {
|
|
488
|
-
const enabled =
|
|
489
|
-
// Build
|
|
490
|
-
// (domain, iconUrl, displayName). The enabled field lets the client pre-check
|
|
491
|
-
const tags =
|
|
537
|
+
const enabled = getEnabledServices();
|
|
538
|
+
// Build service data with enabled state from the current service filter. getAllServiceTags is the single source of truth for service metadata
|
|
539
|
+
// (domain, iconUrl, displayName). The enabled field lets the client pre-check services on re-run without reading DOM state.
|
|
540
|
+
const tags = getAllServiceTags()
|
|
492
541
|
.filter((t) => t.tag !== "direct")
|
|
493
542
|
.map((t) => ({ ...t, enabled: enabled.includes(t.tag) }));
|
|
494
543
|
return generateWizardModal({
|
|
@@ -501,19 +550,19 @@ function generateSetupWizardModal() {
|
|
|
501
550
|
contentId: "setup-content",
|
|
502
551
|
dataAttributes: { "setup-completed": CONFIG.channels.setupCompleted ? "true" : "false" },
|
|
503
552
|
dataBlocks: [
|
|
504
|
-
"<script type=\"application/json\" id=\"setup-
|
|
553
|
+
"<script type=\"application/json\" id=\"setup-services-data\">" + JSON.stringify(tags) + "</script>"
|
|
505
554
|
],
|
|
506
|
-
description: "Set up your streaming
|
|
555
|
+
description: "Set up your streaming services in three steps: select your services, sign in, and choose your channels.",
|
|
507
556
|
errorId: "setup-error",
|
|
508
557
|
id: "setup-modal",
|
|
509
|
-
steps: ["
|
|
558
|
+
steps: ["Services", "Sign In", "Channels"],
|
|
510
559
|
stepsId: "setup-steps",
|
|
511
|
-
title: "
|
|
560
|
+
title: "Service Setup",
|
|
512
561
|
titleId: "setup-title"
|
|
513
562
|
});
|
|
514
563
|
}
|
|
515
564
|
/**
|
|
516
|
-
* Generates the HTML for a single channel's table rows (display row and edit form row). All channels
|
|
565
|
+
* Generates the HTML for a single channel's table rows (display row and edit form row). All channels - predefined, override, and user-defined - get both rows.
|
|
517
566
|
* The edit form is pre-populated with the effective (resolved) values so users see what they're changing. When called from generateChannelsPanel() which already
|
|
518
567
|
* has the listing entry, pass it via the entry parameter to avoid redundant getChannelListing() calls. POST handlers that generate a single row omit the
|
|
519
568
|
* parameter to trigger an internal lookup.
|
|
@@ -534,60 +583,66 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
534
583
|
const isPredefined = isPredefinedChannel(key);
|
|
535
584
|
const isOverride = isPredefined && isUser;
|
|
536
585
|
const isDisabled = isPredefinedChannelDisabled(key);
|
|
537
|
-
const
|
|
538
|
-
// Check if this channel has multiple
|
|
539
|
-
const
|
|
540
|
-
// Build the
|
|
541
|
-
const
|
|
542
|
-
// Generate display row. User channels
|
|
586
|
+
const isAvailableByService = isChannelAvailableByService(key);
|
|
587
|
+
// Check if this channel has multiple services.
|
|
588
|
+
const serviceGroup = getServiceGroup(key);
|
|
589
|
+
// Build the service tags data attribute for client-side filtering.
|
|
590
|
+
const serviceTags = getChannelServiceTags(key).join(",");
|
|
591
|
+
// Generate display row. User-created channels and predefined overrides get distinct CSS classes - user-created channels have a subtle background tint
|
|
592
|
+
// ("user-channel"), while predefined overrides get a left-border accent and blue dot indicator via CSS ::before ("channel-override") matching the settings
|
|
593
|
+
// form's visual language for "modified from defaults." Disabled and service-filtered rows get additional classes for opacity and visibility control.
|
|
543
594
|
const displayLines = [];
|
|
544
595
|
const rowClasses = [];
|
|
545
|
-
if (
|
|
596
|
+
if (isOverride) {
|
|
597
|
+
rowClasses.push("channel-override");
|
|
598
|
+
}
|
|
599
|
+
else if (isUser) {
|
|
546
600
|
rowClasses.push("user-channel");
|
|
547
601
|
}
|
|
548
602
|
if (isDisabled) {
|
|
549
603
|
rowClasses.push("channel-disabled");
|
|
550
604
|
}
|
|
551
|
-
if (!
|
|
605
|
+
if (!isAvailableByService) {
|
|
552
606
|
rowClasses.push("channel-unavailable");
|
|
553
607
|
}
|
|
554
608
|
const rowClassAttr = (rowClasses.length > 0) ? " class=\"" + rowClasses.join(" ") + "\"" : "";
|
|
555
|
-
|
|
609
|
+
const rowTitleAttr = isOverride ? " title=\"Customized from predefined defaults\"" : "";
|
|
610
|
+
// Compute effective tags early - used for both the row data attribute (tag column filter) and the Tags column cell rendering below.
|
|
556
611
|
const effectiveTags = getChannelEffectiveTags(channel);
|
|
557
612
|
const channelTagsAttr = (effectiveTags.length > 0) ? " data-channel-tags=\"" + escapeHtml(effectiveTags.join(",")) + "\"" : "";
|
|
558
|
-
displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + " data-provider-tags=\"" + escapeHtml(
|
|
613
|
+
displayLines.push("<tr id=\"display-row-" + escapeHtml(key) + "\"" + rowClassAttr + rowTitleAttr + " data-provider-tags=\"" + escapeHtml(serviceTags) +
|
|
559
614
|
"\"" + channelTagsAttr + ">");
|
|
560
615
|
displayLines.push("<td class=\"ch-key\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "key")) + "\">" + escapeHtml(key) + "</td>");
|
|
561
616
|
const channelLogoUrl = getChannelLogo(key) ?? "";
|
|
562
617
|
const logoAttr = channelLogoUrl ? " data-logo=\"" + escapeHtml(channelLogoUrl) + "\"" : "";
|
|
563
618
|
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "name")) + "\"" + logoAttr +
|
|
564
619
|
"><span class=\"channel-name-cell\">" + escapeHtml(channel.name ?? key) + "</span></td>");
|
|
565
|
-
//
|
|
566
|
-
// label alongside the
|
|
567
|
-
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "
|
|
568
|
-
const labelHidden =
|
|
569
|
-
const contentHidden =
|
|
570
|
-
displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available
|
|
571
|
-
if (
|
|
572
|
-
// Multi-
|
|
573
|
-
// options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls
|
|
620
|
+
// Service column: dropdown for multi-service channels, static service name for single-service. Both states always render a hidden "No available services"
|
|
621
|
+
// label alongside the service content so that client-side channelTable.filter() can toggle between them without a page reload.
|
|
622
|
+
displayLines.push("<td data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "service")) + "\">");
|
|
623
|
+
const labelHidden = isAvailableByService ? " style=\"display:none\"" : "";
|
|
624
|
+
const contentHidden = isAvailableByService ? "" : " style=\"display:none\"";
|
|
625
|
+
displayLines.push("<em class=\"no-provider-label\"" + labelHidden + ">No available services</em>");
|
|
626
|
+
if (hasMultipleServices(key) && serviceGroup) {
|
|
627
|
+
// Multi-service: render ALL variants with data-provider-tag attributes so client-side JS can filter options when the service selection changes. Filtered-out
|
|
628
|
+
// options get the hidden attribute for immediate filtering in Chrome. Safari ignores hidden on option elements, so the page-load JS init calls channelTable.filter()
|
|
574
629
|
// to remove them from the DOM.
|
|
575
|
-
const currentSelection =
|
|
630
|
+
const currentSelection = resolveServiceKey(key);
|
|
576
631
|
displayLines.push("<select class=\"provider-select\" data-channel=\"" + escapeHtml(key) +
|
|
577
|
-
"\" title=\"Choose which streaming
|
|
578
|
-
for (const variant of
|
|
632
|
+
"\" title=\"Choose which streaming service delivers this channel\" onchange=\"updateServiceSelection(this)\"" + contentHidden + ">");
|
|
633
|
+
for (const variant of serviceGroup.variants) {
|
|
579
634
|
const selected = (variant.key === currentSelection) ? " selected" : "";
|
|
580
|
-
const optionHidden = !
|
|
635
|
+
const optionHidden = !isServiceTagEnabled(variant.tag) ? " hidden" : "";
|
|
581
636
|
displayLines.push("<option value=\"" + escapeHtml(variant.key) + "\" data-provider-tag=\"" + escapeHtml(variant.tag) + "\"" + selected + optionHidden + ">" +
|
|
582
637
|
escapeHtml(variant.label) + "</option>");
|
|
583
638
|
}
|
|
584
639
|
displayLines.push("</select>");
|
|
585
640
|
}
|
|
586
641
|
else {
|
|
587
|
-
// Single-
|
|
588
|
-
// channels with explicit profile assignments show the profile's
|
|
642
|
+
// Single-service: wrap the service name in a span so client-side JS can toggle it with the no-service label. Uses profile-aware label resolution so
|
|
643
|
+
// channels with explicit profile assignments show the profile's service name rather than the built-in name for the URL domain.
|
|
589
644
|
displayLines.push("<span class=\"provider-name\"" + contentHidden + ">" +
|
|
590
|
-
escapeHtml(
|
|
645
|
+
escapeHtml(getChannelServiceLabel(channel)) + "</span>");
|
|
591
646
|
}
|
|
592
647
|
displayLines.push("</td>");
|
|
593
648
|
// Optional columns: Number, HDHR, Station ID, Profile, Selector. All five are always rendered; visibility is controlled by CSS classes on the table element.
|
|
@@ -597,7 +652,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
597
652
|
"\" onclick=\"startInlineEdit(this)\">" + (channel.channelNumber ? escapeHtml(String(channel.channelNumber)) : "<span class=\"text-muted\">–</span>") +
|
|
598
653
|
"</td>");
|
|
599
654
|
// HDHR column: inline checkbox for quick toggling. The checkbox submits changes via the toggleHdhr() client-side function.
|
|
600
|
-
const hdhrCheckedAttr = (channel
|
|
655
|
+
const hdhrCheckedAttr = getEffectiveHdhrEnabled(channel) ? " checked" : "";
|
|
601
656
|
displayLines.push("<td class=\"col-hdhr\" data-sort-value=\"" + escapeHtml(getChannelSortKey(channel, key, "hdhrEnabled")) +
|
|
602
657
|
"\"><input type=\"checkbox\" data-key=\"" + cellKey + "\"" + hdhrCheckedAttr +
|
|
603
658
|
" onchange=\"toggleHdhr(this)\" title=\"Include in HDHomeRun/Plex lineup\"></td>");
|
|
@@ -612,7 +667,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
612
667
|
else {
|
|
613
668
|
const resolved = getProfileForChannel(channel);
|
|
614
669
|
if (resolved.profileName !== "default") {
|
|
615
|
-
const label =
|
|
670
|
+
const label = getChannelServiceLabel(channel);
|
|
616
671
|
displayLines.push("<td class=\"col-profile\" data-sort-value=\"" + profileSortKey +
|
|
617
672
|
"\"><span class=\"text-muted\">" + escapeHtml(label + " (auto)") + "</span></td>");
|
|
618
673
|
}
|
|
@@ -634,14 +689,14 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
634
689
|
displayLines.push("<td>");
|
|
635
690
|
displayLines.push("<div class=\"btn-group\">");
|
|
636
691
|
const escapedKey = escapeHtml(key);
|
|
637
|
-
// Resolve the auth domain for the currently selected
|
|
638
|
-
const variantKey =
|
|
692
|
+
// Resolve the auth domain for the currently selected service variant. Used for both login icon color and health icon lookups.
|
|
693
|
+
const variantKey = resolveServiceKey(key);
|
|
639
694
|
const authDomain = getAuthDomainForChannel(variantKey);
|
|
640
695
|
// Position 1: Edit (all channels).
|
|
641
696
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-edit\" title=\"Edit\" aria-label=\"Edit\" onclick=\"showEditForm('" + escapedKey +
|
|
642
697
|
"')\">" + ICON_EDIT + "</button>");
|
|
643
698
|
// Position 2: Login for enabled channels (with domain auth color), placeholder for disabled predefined. Custom channels (user-defined, not predefined) skip
|
|
644
|
-
// login coloring because they have no
|
|
699
|
+
// login coloring because they have no service concept.
|
|
645
700
|
if (!isDisabled) {
|
|
646
701
|
const authTimestamp = (isPredefined || isOverride) ? getDomainAuth(authDomain) : null;
|
|
647
702
|
const loginColorClass = authTimestamp ? " health-success" : "";
|
|
@@ -666,7 +721,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
666
721
|
displayLines.push("<span class=\"btn-icon-placeholder\"></span>");
|
|
667
722
|
}
|
|
668
723
|
// Position 4: enable/disable toggle for predefined channels (regardless of override state), delete for user-defined channels. The enable/disable state and
|
|
669
|
-
// property overrides are independent concerns
|
|
724
|
+
// property overrides are independent concerns - a predefined channel can be disabled AND have customizations, and the toggle always reflects the visibility state.
|
|
670
725
|
if (isPredefined) {
|
|
671
726
|
if (isDisabled) {
|
|
672
727
|
displayLines.push("<button type=\"button\" class=\"btn-icon btn-icon-enable\" title=\"Enable\" aria-label=\"Enable\" onclick=\"togglePredefinedChannel('" +
|
|
@@ -694,8 +749,8 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
694
749
|
displayLines.push("</td>");
|
|
695
750
|
displayLines.push("</tr>");
|
|
696
751
|
const displayRow = displayLines.join("\n");
|
|
697
|
-
// Generate edit form row for all channels. Pre-populate with the currently selected
|
|
698
|
-
// canonical definition which may differ when a
|
|
752
|
+
// Generate edit form row for all channels. Pre-populate with the currently selected service's values so the user sees what they're actually streaming, not the
|
|
753
|
+
// canonical definition which may differ when a service variant is selected.
|
|
699
754
|
const editLines = [];
|
|
700
755
|
editLines.push("<tr id=\"edit-row-" + escapedKey + "\" style=\"display: none;\">");
|
|
701
756
|
editLines.push("<td colspan=\"" + String(TOTAL_COLUMN_COUNT) + "\">");
|
|
@@ -704,24 +759,72 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
704
759
|
editLines.push("<form id=\"edit-channel-form-" + escapedKey + "\" onsubmit=\"return submitChannelForm(event, 'edit')\">");
|
|
705
760
|
editLines.push("<input type=\"hidden\" name=\"action\" value=\"edit\">");
|
|
706
761
|
editLines.push("<input type=\"hidden\" name=\"key\" value=\"" + escapedKey + "\">");
|
|
762
|
+
// For override channels, fetch the customization provenance once. The accessor returns which fields the user has explicitly customized (across canonical and
|
|
763
|
+
// active-variant stored entries) and the per-field reset value (looked up from the appropriate predefined entry, with variant-inheritance fallback). This is
|
|
764
|
+
// the single source of truth for the modified-field treatment - it produces no false positives when service variants are resolved, because storage provenance,
|
|
765
|
+
// not value comparison, drives the determination.
|
|
766
|
+
//
|
|
767
|
+
// For non-override channels (predefined-only or pure user-defined), the customization concept does not apply: predefined-only channels have no user
|
|
768
|
+
// customizations to mark, and pure user channels have no predefined baseline to compare against. Both paths skip the accessor and pass undefined defaults.
|
|
769
|
+
const customizations = isOverride ? getChannelCustomizations(key).customizations : undefined;
|
|
770
|
+
const customizedFields = customizations ? new Set(customizations.keys()) : undefined;
|
|
771
|
+
// resetValueFor stringifies the raw resetValue from the accessor for use in input data-default attributes. Returns undefined when the field is not
|
|
772
|
+
// customized, signalling the field generators to skip data-default and the modified treatment entirely. Array values (tags) are joined with ", " to match
|
|
773
|
+
// the comma-separated form representation; primitive values are stringified normally; null/undefined become the empty string. Field parameter is typed as
|
|
774
|
+
// CustomizableField so the call sites below get compile-time validation that they reference real customizable fields.
|
|
775
|
+
const resetValueFor = (field) => {
|
|
776
|
+
const entry = customizations?.get(field);
|
|
777
|
+
if (!entry) {
|
|
778
|
+
return undefined;
|
|
779
|
+
}
|
|
780
|
+
const value = entry.resetValue;
|
|
781
|
+
if ((value === undefined) || (value === null)) {
|
|
782
|
+
return "";
|
|
783
|
+
}
|
|
784
|
+
if (Array.isArray(value)) {
|
|
785
|
+
return value.join(", ");
|
|
786
|
+
}
|
|
787
|
+
if ((typeof value === "string") || (typeof value === "number") || (typeof value === "boolean")) {
|
|
788
|
+
return String(value);
|
|
789
|
+
}
|
|
790
|
+
return "";
|
|
791
|
+
};
|
|
707
792
|
// Channel name.
|
|
708
793
|
editLines.push(...generateTextField("edit-name-" + key, "name", "Display Name", channel.name ?? key, {
|
|
794
|
+
defaultValue: resetValueFor("name"),
|
|
709
795
|
hint: "Friendly name shown in the playlist and UI.",
|
|
796
|
+
isCustomized: customizedFields?.has("name"),
|
|
710
797
|
required: true
|
|
711
798
|
}));
|
|
712
799
|
// Channel URL.
|
|
713
800
|
editLines.push(...generateTextField("edit-url-" + key, "url", "Stream URL", channel.url, {
|
|
801
|
+
defaultValue: resetValueFor("url"),
|
|
714
802
|
hint: "The URL of the streaming page to capture.",
|
|
803
|
+
isCustomized: customizedFields?.has("url"),
|
|
715
804
|
required: true,
|
|
716
805
|
type: "url"
|
|
717
806
|
}));
|
|
718
807
|
// Profile dropdown.
|
|
719
|
-
editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles));
|
|
720
|
-
// Advanced fields.
|
|
808
|
+
editLines.push(...generateProfileDropdown("edit-profile-" + key, channel.profile ?? "", profiles, true, resetValueFor("profile"), customizedFields?.has("profile")));
|
|
809
|
+
// Advanced fields. Every reset value flows through resetValueFor, which delegates to getChannelCustomizations -> computeResetValue. The accessor applies the
|
|
810
|
+
// effective-view rules (vocabulary-filtered tags, implicit-true hdhrEnabled) and looks up the right predefined source (canonical or variant with inheritance
|
|
811
|
+
// fallback) so the form sees a uniformly stringified default for every field.
|
|
721
812
|
editLines.push(...generateAdvancedFields("edit-" + key, {
|
|
722
813
|
channelNumberValue: channel.channelNumber ? String(channel.channelNumber) : "",
|
|
723
814
|
channelSelectorValue: channel.channelSelector ?? "",
|
|
724
|
-
|
|
815
|
+
customizedFields,
|
|
816
|
+
defaults: isOverride ? {
|
|
817
|
+
channelNumber: resetValueFor("channelNumber"),
|
|
818
|
+
channelSelector: resetValueFor("channelSelector"),
|
|
819
|
+
guideTitle: resetValueFor("guideTitle"),
|
|
820
|
+
hdhrEnabled: resetValueFor("hdhrEnabled"),
|
|
821
|
+
logoUrl: resetValueFor("logoUrl"),
|
|
822
|
+
stationId: resetValueFor("stationId"),
|
|
823
|
+
tags: resetValueFor("tags")
|
|
824
|
+
} : undefined,
|
|
825
|
+
guideTitleValue: channel.guideTitle ?? "",
|
|
826
|
+
hdhrEnabled: getEffectiveHdhrEnabled(channel),
|
|
827
|
+
logoUrlValue: channel.logoUrl ?? "",
|
|
725
828
|
stationIdValue: channel.stationId ?? "",
|
|
726
829
|
tagsValue: effectiveTags.join(", ")
|
|
727
830
|
}));
|
|
@@ -742,7 +845,7 @@ export function generateChannelRowHtml(key, profiles, entry) {
|
|
|
742
845
|
return { displayRow, editRow };
|
|
743
846
|
}
|
|
744
847
|
/**
|
|
745
|
-
* Computes the complete channel table state
|
|
848
|
+
* Computes the complete channel table state - summary counts and scope toggle counts - from the current channel listing. This is the server-side single source
|
|
746
849
|
* of truth for all count computation, replacing the client-side DOM-scanning approach. Called once per mutation as part of building a patch.
|
|
747
850
|
* @param listing - Optional pre-fetched listing. When omitted, fetches from getChannelListing(). Passing the listing avoids a redundant computation when
|
|
748
851
|
* buildChannelTablePatch already has it.
|
|
@@ -760,7 +863,7 @@ export function buildChannelTableState(listing) {
|
|
|
760
863
|
else {
|
|
761
864
|
predefined++;
|
|
762
865
|
}
|
|
763
|
-
if (!entry.enabled || !entry.
|
|
866
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
764
867
|
disabled++;
|
|
765
868
|
}
|
|
766
869
|
}
|
|
@@ -772,7 +875,7 @@ export function buildChannelTableState(listing) {
|
|
|
772
875
|
};
|
|
773
876
|
}
|
|
774
877
|
/**
|
|
775
|
-
* Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled,
|
|
878
|
+
* Counts channels included and excluded from the HDHomeRun lineup. Only counts enabled, service-available channels (the same set visible in the table).
|
|
776
879
|
* @param listing - The channel listing entries.
|
|
777
880
|
* @returns An object with `enabled` (HDHR-included) and `total` counts.
|
|
778
881
|
*/
|
|
@@ -780,18 +883,18 @@ function getHdhrCounts(listing) {
|
|
|
780
883
|
let enabled = 0;
|
|
781
884
|
let total = 0;
|
|
782
885
|
for (const entry of listing) {
|
|
783
|
-
if (!entry.enabled || !entry.
|
|
886
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
784
887
|
continue;
|
|
785
888
|
}
|
|
786
889
|
total++;
|
|
787
|
-
if (entry.channel
|
|
890
|
+
if (getEffectiveHdhrEnabled(entry.channel)) {
|
|
788
891
|
enabled++;
|
|
789
892
|
}
|
|
790
893
|
}
|
|
791
894
|
return { enabled, total };
|
|
792
895
|
}
|
|
793
896
|
/**
|
|
794
|
-
* Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled,
|
|
897
|
+
* Computes per-tag channel counts for the Quick Actions tag bulk toggles. For each tag in the active vocabulary, counts how many enabled, service-available
|
|
795
898
|
* channels have that tag in their effective tags. Returns undefined when the tags column is not visible (no tag toggles to update).
|
|
796
899
|
* @param listing - The channel listing entries.
|
|
797
900
|
* @returns A record mapping tag names to { count, total }, or undefined when hidden.
|
|
@@ -807,18 +910,18 @@ function getTagCounts(listing) {
|
|
|
807
910
|
}
|
|
808
911
|
const result = {};
|
|
809
912
|
let total = 0;
|
|
810
|
-
// Pre-compute the vocabulary Set once for the per-channel effective tags filter.
|
|
811
|
-
const vocabularySet = new Set(vocabulary);
|
|
812
913
|
const counts = new Map();
|
|
813
914
|
for (const tag of vocabulary) {
|
|
814
915
|
counts.set(tag, 0);
|
|
815
916
|
}
|
|
816
917
|
for (const entry of listing) {
|
|
817
|
-
if (!entry.enabled || !entry.
|
|
918
|
+
if (!entry.enabled || !entry.availableByService) {
|
|
818
919
|
continue;
|
|
819
920
|
}
|
|
820
921
|
total++;
|
|
821
|
-
|
|
922
|
+
// Use the canonical helper for vocabulary-filtered tags. Inline filtering would diverge from the rest of the codebase on the case-insensitive matching
|
|
923
|
+
// policy that getChannelEffectiveTags applies via tagsMatch.
|
|
924
|
+
const effectiveTags = getChannelEffectiveTags(entry.channel);
|
|
822
925
|
for (const tag of effectiveTags) {
|
|
823
926
|
const current = counts.get(tag);
|
|
824
927
|
if (current !== undefined) {
|
|
@@ -857,21 +960,21 @@ export function buildChannelTablePatch(affectedKeys, profiles) {
|
|
|
857
960
|
return { counts, hdhrCounts: getHdhrCounts(listing), rows, scopeCounts, tagCounts: getTagCounts(listing) };
|
|
858
961
|
}
|
|
859
962
|
/**
|
|
860
|
-
* Generates the
|
|
861
|
-
* @returns HTML string for the
|
|
963
|
+
* Generates the service filter toolbar HTML with a multi-select dropdown and dismissable chips.
|
|
964
|
+
* @returns HTML string for the service filter toolbar.
|
|
862
965
|
*/
|
|
863
|
-
export function
|
|
864
|
-
const allTags =
|
|
865
|
-
const enabled =
|
|
966
|
+
export function generateServiceFilterToolbar() {
|
|
967
|
+
const allTags = getAllServiceTags();
|
|
968
|
+
const enabled = getEnabledServices();
|
|
866
969
|
const hasFilter = enabled.length > 0;
|
|
867
970
|
const lines = [];
|
|
868
971
|
lines.push("<div class=\"provider-toolbar\">");
|
|
869
|
-
//
|
|
972
|
+
// Service filter dropdown and chips. The button follows the toolbar dropdown pattern: icon + label + caret in a single button.
|
|
870
973
|
lines.push("<div class=\"toolbar-group\">");
|
|
871
974
|
lines.push("<div class=\"dropdown provider-dropdown\">");
|
|
872
|
-
const buttonText = hasFilter ? "Filtered" : "All
|
|
873
|
-
lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-
|
|
874
|
-
"title=\"Filter channels by streaming
|
|
975
|
+
const buttonText = hasFilter ? "Filtered" : "All Services";
|
|
976
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm toolbar-icon-btn\" id=\"provider-filter-btn\" " +
|
|
977
|
+
"title=\"Filter channels by streaming service\" onclick=\"toggleDropdown(this)\">" + ICON_FILTER + " " + buttonText + " ▾</button>");
|
|
875
978
|
lines.push("<div class=\"dropdown-menu provider-dropdown-menu\">");
|
|
876
979
|
for (const tagInfo of allTags) {
|
|
877
980
|
const isDirectTag = tagInfo.tag === "direct";
|
|
@@ -880,7 +983,7 @@ export function generateProviderFilterToolbar() {
|
|
|
880
983
|
const disabledAttr = isDirectTag ? " disabled" : "";
|
|
881
984
|
lines.push("<label class=\"provider-option\">");
|
|
882
985
|
lines.push("<input type=\"checkbox\" data-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + checkedAttr + disabledAttr +
|
|
883
|
-
" onchange=\"
|
|
986
|
+
" onchange=\"toggleServiceTag(this)\"> " + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl));
|
|
884
987
|
lines.push("</label>");
|
|
885
988
|
}
|
|
886
989
|
lines.push("</div>");
|
|
@@ -895,9 +998,9 @@ export function generateProviderFilterToolbar() {
|
|
|
895
998
|
const chipTag = allTags.find((t) => t.tag === tag);
|
|
896
999
|
const displayName = chipTag?.displayName ?? tag;
|
|
897
1000
|
lines.push("<span class=\"provider-chip\" data-tag=\"" + escapeHtml(tag) + "\">" +
|
|
898
|
-
|
|
1001
|
+
serviceDisplaySpan(displayName, chipTag?.domain, chipTag?.iconUrl, true) +
|
|
899
1002
|
"<button type=\"button\" class=\"chip-close\" title=\"Remove " + escapeHtml(displayName) + " from filter\" aria-label=\"Remove " +
|
|
900
|
-
escapeHtml(displayName) + "\" onclick=\"
|
|
1003
|
+
escapeHtml(displayName) + "\" onclick=\"removeServiceChip('" + escapeHtml(tag) + "')\">×</button></span>");
|
|
901
1004
|
}
|
|
902
1005
|
}
|
|
903
1006
|
lines.push("</div>");
|
|
@@ -916,26 +1019,26 @@ export function generateProviderFilterToolbar() {
|
|
|
916
1019
|
* @returns HTML string for the Channels panel content.
|
|
917
1020
|
*/
|
|
918
1021
|
export function generateChannelsPanel(channelMessage, channelError, editingChannelKey, showAddForm, formErrors, formValues) {
|
|
919
|
-
// Get the canonical channel listing (
|
|
920
|
-
// it handles predefined/user merging, disabled state, and
|
|
1022
|
+
// Get the canonical channel listing (service variants already filtered out, sorted by key). This is the single source of truth for merged channel data -
|
|
1023
|
+
// it handles predefined/user merging, disabled state, and service availability.
|
|
921
1024
|
const listing = getChannelListing();
|
|
922
1025
|
const profiles = getProfiles();
|
|
923
|
-
// Count channels hidden from the default view: disabled predefined channels OR channels with no available
|
|
924
|
-
const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.
|
|
1026
|
+
// Count channels hidden from the default view: disabled predefined channels OR channels with no available services.
|
|
1027
|
+
const totalHiddenCount = listing.filter((entry) => !entry.enabled || !entry.availableByService).length;
|
|
925
1028
|
const lines = [];
|
|
926
1029
|
// Panel description.
|
|
927
1030
|
lines.push("<div class=\"settings-panel-description\">");
|
|
928
1031
|
lines.push("<p>Define and manage streaming channels for the playlist. Customized channels are highlighted.</p>");
|
|
929
|
-
lines.push("<p class=\"description-hint\">Tip: Use the <strong>
|
|
1032
|
+
lines.push("<p class=\"description-hint\">Tip: Use the <strong>service filter</strong> above to show only channels from services you subscribe to — ", "this also controls which channels Channels DVR sees in the playlist. Use the <strong>service dropdown</strong> on any multi-service channel to choose ", "which streaming service delivers it (e.g., Comedy Central via Hulu vs Sling). Click the <strong>edit icon</strong> to customize any channel's name, ", "Gracenote station ID, URL, or other properties.</p>");
|
|
930
1033
|
lines.push("</div>");
|
|
931
1034
|
// Toolbar with three dropdown menus: Manage Channels (primary actions), Import/Export (data I/O), and Quick Actions (bulk operations). Each dropdown has an
|
|
932
1035
|
// inline SVG icon + label + chevron for visual discoverability. Grouped menus reduce visual clutter and separate channel management from data I/O and bulk
|
|
933
1036
|
// operations.
|
|
934
1037
|
lines.push("<div class=\"channel-toolbar\">");
|
|
935
1038
|
lines.push("<div class=\"toolbar-group\">");
|
|
936
|
-
// Manage Channels dropdown
|
|
1039
|
+
// Manage Channels dropdown - primary channel creation and setup actions.
|
|
937
1040
|
lines.push("<div class=\"dropdown\">");
|
|
938
|
-
lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-
|
|
1041
|
+
lines.push("<button type=\"button\" class=\"btn btn-primary btn-sm toolbar-icon-btn\" title=\"Add, browse, or set up channels\" " +
|
|
939
1042
|
"onclick=\"toggleDropdown(this)\">" + ICON_MANAGE + " Manage Channels ▾</button>");
|
|
940
1043
|
lines.push("<div class=\"dropdown-menu\">");
|
|
941
1044
|
// Dropdown item icons: tinted variants of the shared icons for visual differentiation in menu items. Each uses a distinct color so items are scannable at
|
|
@@ -951,47 +1054,47 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
951
1054
|
const TINTED_TAG = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#9b59b6\" stroke-width=\"1.5\" " +
|
|
952
1055
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M1.5 8.7V2.5a1 1 0 011-1h6.2a1 1 0 01.7.3l5.1 5.1a1 1 0 010 1.4l-5.5 5.5a1 " +
|
|
953
1056
|
"1 0 01-1.4 0L1.8 9.4a1 1 0 01-.3-.7z\"/><circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>";
|
|
954
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1057
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); " +
|
|
955
1058
|
"document.getElementById('add-channel-form').style.display='block';\">" + TINTED_ADD + " Add Channel</div>");
|
|
956
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
957
|
-
" Browse
|
|
958
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1059
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openBrowseModal()\">" + TINTED_BROWSE +
|
|
1060
|
+
" Browse Service Channels</div>");
|
|
1061
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openTagManager()\">" + TINTED_TAG +
|
|
959
1062
|
" Manage Tags</div>");
|
|
960
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
961
|
-
"
|
|
1063
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); openSetupWizard()\">" + TINTED_SETUP +
|
|
1064
|
+
" Service Setup</div>");
|
|
962
1065
|
lines.push("</div>");
|
|
963
1066
|
lines.push("</div>");
|
|
964
|
-
// Import / Export dropdown
|
|
1067
|
+
// Import / Export dropdown - data I/O operations.
|
|
965
1068
|
lines.push("<div class=\"dropdown\">");
|
|
966
|
-
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-
|
|
1069
|
+
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Import or export channel data\" " +
|
|
967
1070
|
"onclick=\"toggleDropdown(this)\">" + ICON_TRANSFER + " Import / Export ▾</button>");
|
|
968
1071
|
lines.push("<div class=\"dropdown-menu\">");
|
|
969
1072
|
const TINTED_IMPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#5b8def\" stroke-width=\"1.5\" " +
|
|
970
1073
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 2v8M5 7l3 3 3-3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
|
|
971
1074
|
const TINTED_EXPORT = "<svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"#22a563\" stroke-width=\"1.5\" " +
|
|
972
1075
|
"stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 10V2M5 5l3-3 3 3\"/><path d=\"M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2\"/></svg>";
|
|
973
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1076
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-channels-file').click()\">" +
|
|
974
1077
|
TINTED_IMPORT + " Import Channels (JSON)</div>");
|
|
975
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1078
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); document.getElementById('import-m3u-file').click()\">" +
|
|
976
1079
|
TINTED_IMPORT + " Import M3U Playlist</div>");
|
|
977
1080
|
lines.push("<label class=\"dropdown-option\"><input type=\"checkbox\" id=\"m3u-replace-duplicates\"> Replace duplicates on M3U import</label>");
|
|
978
1081
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
979
|
-
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"
|
|
1082
|
+
lines.push("<div class=\"dropdown-item dropdown-item-icon\" onclick=\"dropdowns.close(); exportChannels()\">" +
|
|
980
1083
|
TINTED_EXPORT + " Export Channels (JSON)</div>");
|
|
981
1084
|
lines.push("</div>");
|
|
982
1085
|
lines.push("</div>");
|
|
983
1086
|
lines.push("<input type=\"file\" id=\"import-m3u-file\" accept=\".m3u,.m3u8\" style=\"display: none;\" onchange=\"importM3U(this)\">");
|
|
984
1087
|
// Visible column set, used by Quick Actions to gate column-dependent options and by the table header to set initial hide classes.
|
|
985
1088
|
const visibleCols = new Set(CONFIG.channels.visibleColumns);
|
|
986
|
-
// Quick Actions dropdown
|
|
1089
|
+
// Quick Actions dropdown - bulk operations for predefined channels.
|
|
987
1090
|
lines.push("<div class=\"dropdown quick-actions-dropdown\">");
|
|
988
|
-
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-
|
|
1091
|
+
lines.push("<button type=\"button\" class=\"btn btn-secondary btn-sm toolbar-icon-btn\" title=\"Bulk operations for predefined channels\" " +
|
|
989
1092
|
"onclick=\"toggleDropdown(this)\">" + ICON_BOLT + " Quick Actions ▾</button>");
|
|
990
1093
|
lines.push("<div class=\"dropdown-menu\">");
|
|
991
|
-
// Compute initial toggle counts for predefined channel scopes. The server is the single source of truth
|
|
1094
|
+
// Compute initial toggle counts for predefined channel scopes. The server is the single source of truth - the client renders what we return here.
|
|
992
1095
|
const scopeCounts = getPredefinedScopeCounts();
|
|
993
1096
|
// Three toggle rows: checkbox + label + count. Clicking toggles the group via bulkTogglePredefined(). The onclick uses event.preventDefault() to stop the
|
|
994
|
-
// native checkbox toggle
|
|
1097
|
+
// native checkbox toggle - the server response drives the update.
|
|
995
1098
|
const scopes = [
|
|
996
1099
|
{ count: scopeCounts.all.enabled, label: "All Predefined", scope: "all", total: scopeCounts.all.total },
|
|
997
1100
|
{ count: scopeCounts.east.enabled, label: "East Variants", scope: "east", total: scopeCounts.east.total },
|
|
@@ -1005,15 +1108,15 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1005
1108
|
"\">" + String(s.count) + " of " + String(s.total) + " enabled</span></label>");
|
|
1006
1109
|
}
|
|
1007
1110
|
lines.push("<div class=\"dropdown-divider\"></div>");
|
|
1008
|
-
// Bulk assign select
|
|
1009
|
-
const allTags =
|
|
1010
|
-
const enabled =
|
|
1111
|
+
// Bulk assign select - switch all multi-service channels to a single service.
|
|
1112
|
+
const allTags = getAllServiceTags();
|
|
1113
|
+
const enabled = getEnabledServices();
|
|
1011
1114
|
const hasFilter = enabled.length > 0;
|
|
1012
1115
|
lines.push("<div class=\"bulk-assign-row\">");
|
|
1013
1116
|
lines.push("<span>Set all channels to:</span>");
|
|
1014
|
-
lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-
|
|
1015
|
-
"onchange=\"if(this.value) {
|
|
1016
|
-
lines.push("<option value=\"\">Select
|
|
1117
|
+
lines.push("<select id=\"bulk-assign-select\" class=\"bulk-assign-select\" title=\"Switch all multi-service channels to this service\" " +
|
|
1118
|
+
"onchange=\"if(this.value) { dropdowns.close(); bulkAssignService(this.value); this.value = ''; }\">");
|
|
1119
|
+
lines.push("<option value=\"\">Select service</option>");
|
|
1017
1120
|
for (const tagInfo of allTags) {
|
|
1018
1121
|
const hidden = (hasFilter && !enabled.includes(tagInfo.tag) && (tagInfo.tag !== "direct")) ? " hidden" : "";
|
|
1019
1122
|
lines.push("<option value=\"" + escapeHtml(tagInfo.tag) + "\" data-provider-tag=\"" + escapeHtml(tagInfo.tag) + "\"" + hidden + ">" +
|
|
@@ -1024,17 +1127,17 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1024
1127
|
// Divider before column-gated quick actions. Hidden when neither the channel number nor HDHR column is visible.
|
|
1025
1128
|
const dividerVisible = (visibleCols.has("channelNumber") || visibleCols.has("hdhrEnabled")) ? "" : " style=\"display: none;\"";
|
|
1026
1129
|
lines.push("<div class=\"dropdown-divider\" id=\"quick-action-divider\"" + dividerVisible + "></div>");
|
|
1027
|
-
// Auto-number channels
|
|
1130
|
+
// Auto-number channels - assign sequential channel numbers to visible channels in current sort order. Visible when the channel number column is shown.
|
|
1028
1131
|
const autoNumberVisible = visibleCols.has("channelNumber") ? "" : " style=\"display: none;\"";
|
|
1029
1132
|
lines.push("<div id=\"quick-action-autonumber\"" + autoNumberVisible + ">");
|
|
1030
1133
|
lines.push("<div class=\"bulk-assign-row\">");
|
|
1031
1134
|
lines.push("<span>Auto-number from:</span>");
|
|
1032
1135
|
lines.push("<input type=\"number\" id=\"auto-number-start\" class=\"auto-number-input\" value=\"1\" min=\"0\" max=\"99999\" " +
|
|
1033
1136
|
"placeholder=\"Clear\" onclick=\"event.stopPropagation()\">");
|
|
1034
|
-
lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"
|
|
1137
|
+
lines.push("<button type=\"button\" class=\"btn btn-sm btn-secondary\" onclick=\"dropdowns.close(); autoNumberChannels()\">Apply</button>");
|
|
1035
1138
|
lines.push("</div>");
|
|
1036
1139
|
lines.push("</div>");
|
|
1037
|
-
// HDHR bulk toggle
|
|
1140
|
+
// HDHR bulk toggle - tri-state checkbox to enable/disable all channels for the HDHomeRun lineup. Visible when the HDHR column is shown.
|
|
1038
1141
|
const hdhrVisible = visibleCols.has("hdhrEnabled") ? "" : " style=\"display: none;\"";
|
|
1039
1142
|
const hdhrCounts = getHdhrCounts(listing);
|
|
1040
1143
|
const hdhrCheckedAttr = (hdhrCounts.enabled === hdhrCounts.total) ? " checked" : "";
|
|
@@ -1046,13 +1149,13 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1046
1149
|
lines.push("<span class=\"quick-action-count\" id=\"hdhr-bulk-count\">" + String(hdhrCounts.enabled) + " of " + String(hdhrCounts.total) + "</span>");
|
|
1047
1150
|
lines.push("</label>");
|
|
1048
1151
|
lines.push("</div>");
|
|
1049
|
-
// Bulk tag toggles
|
|
1152
|
+
// Bulk tag toggles - tristate checkboxes for each tag in the active vocabulary. Visible when the tags column is shown. Each checkbox shows how many enabled
|
|
1050
1153
|
// channels have the tag, and clicking it adds or removes the tag on all visible channels.
|
|
1051
1154
|
const tagsVisible = visibleCols.has("tags") ? "" : " style=\"display: none;\"";
|
|
1052
1155
|
const tagsVocabulary = getActiveTagVocabulary();
|
|
1053
1156
|
if (tagsVocabulary.length > 0) {
|
|
1054
|
-
// Count how many enabled,
|
|
1055
|
-
const enabledListing = listing.filter(
|
|
1157
|
+
// Count how many enabled, service-available channels have each tag.
|
|
1158
|
+
const enabledListing = listing.filter(isVisibleChannel);
|
|
1056
1159
|
const totalEnabled = enabledListing.length;
|
|
1057
1160
|
const tagCountMap = new Map();
|
|
1058
1161
|
for (const tag of tagsVocabulary) {
|
|
@@ -1131,12 +1234,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1131
1234
|
lines.push("<h3>Add New Channel</h3>");
|
|
1132
1235
|
lines.push("<form id=\"add-channel-form-el\" onsubmit=\"return submitChannelForm(event, 'add')\">");
|
|
1133
1236
|
lines.push("<input type=\"hidden\" name=\"action\" value=\"add\">");
|
|
1134
|
-
//
|
|
1135
|
-
// resolution). The pills use
|
|
1136
|
-
// guideUrl; non-guide-grid
|
|
1137
|
-
const addFormTags =
|
|
1237
|
+
// Service pills. Clicking a service auto-fills the URL field, which triggers the existing URL-change infrastructure (datalist population, profile
|
|
1238
|
+
// resolution). The pills use serviceDisplaySpan for icon rendering via the shared processServiceDisplays path. Guide-grid services fill with their
|
|
1239
|
+
// guideUrl; non-guide-grid services fill with https://{domain}/.
|
|
1240
|
+
const addFormTags = getAllServiceTags();
|
|
1138
1241
|
const addFormGuideUrls = getProviderGuideUrls();
|
|
1139
|
-
lines.push("<div class=\"form-row\"><label>
|
|
1242
|
+
lines.push("<div class=\"form-row\"><label>Service</label>");
|
|
1140
1243
|
lines.push("<div class=\"provider-pills\">");
|
|
1141
1244
|
for (const tagInfo of addFormTags) {
|
|
1142
1245
|
if (tagInfo.tag === "direct") {
|
|
@@ -1144,11 +1247,11 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1144
1247
|
}
|
|
1145
1248
|
const pillUrl = addFormGuideUrls[tagInfo.tag] ?? (tagInfo.domain ? "https://" + tagInfo.domain + "/" : "");
|
|
1146
1249
|
lines.push("<button type=\"button\" class=\"provider-pill\" data-slug=\"" + escapeHtml(tagInfo.tag) + "\" data-url=\"" + escapeHtml(pillUrl) +
|
|
1147
|
-
"\" onclick=\"
|
|
1250
|
+
"\" onclick=\"selectServicePill(this)\">" + serviceDisplaySpan(tagInfo.displayName, tagInfo.domain, tagInfo.iconUrl) + "</button>");
|
|
1148
1251
|
}
|
|
1149
1252
|
lines.push("</div>");
|
|
1150
1253
|
lines.push("</div>");
|
|
1151
|
-
lines.push("<div class=\"hint\">Select a
|
|
1254
|
+
lines.push("<div class=\"hint\">Select a service to auto-fill the URL, or enter one manually below.</div>");
|
|
1152
1255
|
// Channel key (add form only).
|
|
1153
1256
|
lines.push(...generateTextField("add-key", "key", "Channel Key", formValues?.get("key") ?? "", {
|
|
1154
1257
|
hint: "Lowercase letters, numbers, and hyphens only. Used in the URL: /stream/channel-key",
|
|
@@ -1169,9 +1272,12 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1169
1272
|
required: true,
|
|
1170
1273
|
type: "url"
|
|
1171
1274
|
}));
|
|
1275
|
+
// Inline hint for predefined channel matches. Hidden by default, shown by the URL field change listener in config.ts when the entered URL's domain matches a
|
|
1276
|
+
// predefined channel. This extends the form's existing URL-based intelligence (channelSelector suggestions, stationId auto-fill, profile auto-detection).
|
|
1277
|
+
lines.push("<div id=\"add-predefined-hint\" class=\"hint predefined-hint\" style=\"display: none;\"></div>");
|
|
1172
1278
|
// Profile dropdown.
|
|
1173
1279
|
lines.push(...generateProfileDropdown("add-profile", formValues?.get("profile") ?? "", profiles));
|
|
1174
|
-
// Advanced fields (station ID, channel selector, channel number, HDHR).
|
|
1280
|
+
// Advanced fields (station ID, guide title, logo URL, channel selector, channel number, tags, HDHR).
|
|
1175
1281
|
lines.push(...generateAdvancedFields("add", {
|
|
1176
1282
|
channelNumberValue: formValues?.get("channelNumber") ?? "",
|
|
1177
1283
|
channelSelectorValue: formValues?.get("channelSelector") ?? "",
|
|
@@ -1184,7 +1290,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1184
1290
|
lines.push("<button type=\"button\" class=\"btn btn-secondary\" onclick=\"document.getElementById('add-channel-form').style.display='none';\">Cancel</button>");
|
|
1185
1291
|
lines.push("</div>");
|
|
1186
1292
|
lines.push("</form>");
|
|
1187
|
-
|
|
1293
|
+
// Close the add-channel-form wrapper opened at the top of this builder.
|
|
1294
|
+
lines.push("</div>");
|
|
1188
1295
|
// Profile reference section (hidden by default, toggled via link in profile dropdown hint).
|
|
1189
1296
|
lines.push(generateProfileReference(profiles));
|
|
1190
1297
|
// Channels table. Disabled predefined channels are hidden by default and revealed via the "Show disabled" toggle. The wrapper div enables horizontal scrolling on
|
|
@@ -1197,8 +1304,8 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1197
1304
|
}
|
|
1198
1305
|
const sortField = CONFIG.channels.channelSortField;
|
|
1199
1306
|
const sortDir = CONFIG.channels.channelSortDirection;
|
|
1200
|
-
//
|
|
1201
|
-
lines.push(
|
|
1307
|
+
// Service filter toolbar with multi-select dropdown and chips.
|
|
1308
|
+
lines.push(generateServiceFilterToolbar());
|
|
1202
1309
|
// Channel summary line with predefined/user breakdown, placed directly above the table for visual proximity. The user-count span contains the entire user
|
|
1203
1310
|
// portion (comma, count, and label) so the client can toggle it by setting textContent. When there are no user channels, the span is empty.
|
|
1204
1311
|
const userPortion = (userCount > 0) ? ", " + String(userCount) + " user" : "";
|
|
@@ -1216,20 +1323,20 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1216
1323
|
const sortableHeaders = [
|
|
1217
1324
|
{ cssClass: "col-key", field: "key", label: "Key" },
|
|
1218
1325
|
{ cssClass: "col-name", field: "name", label: "Name" },
|
|
1219
|
-
{ cssClass: "col-provider", field: "provider", label: "
|
|
1326
|
+
{ cssClass: "col-provider", field: "provider", label: "Service" },
|
|
1220
1327
|
...OPTIONAL_COLUMNS
|
|
1221
1328
|
];
|
|
1222
1329
|
// All sortable headers use the same DOM structure: <th onclick> wraps a <span class="sort-label"> for the label text. The sort update logic targets
|
|
1223
|
-
// .sort-label to modify only the label
|
|
1330
|
+
// .sort-label to modify only the label - never touching other children like the Tags filter dropdown. Clicking anywhere on the <th> triggers sort;
|
|
1224
1331
|
// additional children (like the filter button) use event.stopPropagation() to prevent sort when interacting with them.
|
|
1225
1332
|
for (const hdr of sortableHeaders) {
|
|
1226
1333
|
const isActive = (sortField === hdr.field);
|
|
1227
1334
|
const activeIndicator = isActive ? ((sortDir === "asc") ? " ▲" : " ▼") : "";
|
|
1228
1335
|
lines.push("<th class=\"" + hdr.cssClass + " sortable\" data-sort-field=\"" + hdr.field +
|
|
1229
|
-
"\" onclick=\"
|
|
1336
|
+
"\" onclick=\"channelTable.sort('" + hdr.field + "')\">");
|
|
1230
1337
|
lines.push("<span class=\"sort-label\">" + hdr.label + activeIndicator + "</span>");
|
|
1231
1338
|
// Tags header: additional filter dropdown alongside the sort label. The dropdown is a client-side-only view filter (transient, not persisted) that
|
|
1232
|
-
// shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent()
|
|
1339
|
+
// shows/hides rows based on their data-channel-tags attribute. The dropdown content comes from generateTagFilterContent() - the single source of truth
|
|
1233
1340
|
// shared with the tag CRUD incremental update path.
|
|
1234
1341
|
if (hdr.field === "tags") {
|
|
1235
1342
|
lines.push("<div class=\"dropdown tag-filter-dropdown\" style=\"display: inline;\">");
|
|
@@ -1269,7 +1376,7 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1269
1376
|
lines.push("</thead>");
|
|
1270
1377
|
lines.push("<tbody>");
|
|
1271
1378
|
// Sort the listing by the user's preferred field and direction before rendering rows. The canonical getChannelListing() order is preserved for other callers.
|
|
1272
|
-
const sortedListing =
|
|
1379
|
+
const sortedListing = listing.toSorted((a, b) => compareChannelSort(a.channel, a.key, b.channel, b.key, sortField, sortDir));
|
|
1273
1380
|
// Generate rows for all channels using the shared row generator.
|
|
1274
1381
|
for (const entry of sortedListing) {
|
|
1275
1382
|
const rowHtml = generateChannelRowHtml(entry.key, profiles, entry);
|
|
@@ -1282,16 +1389,16 @@ export function generateChannelsPanel(channelMessage, channelError, editingChann
|
|
|
1282
1389
|
// Embed channel selector data for datalist population. The client-side JavaScript uses this to offer known selector suggestions when the URL matches a
|
|
1283
1390
|
// multi-channel site like Disney+ or USA Network.
|
|
1284
1391
|
lines.push("<script>" + generateChannelSelectorData() + "</script>");
|
|
1285
|
-
// Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (
|
|
1286
|
-
// client-side after fetching discovered channels from the
|
|
1392
|
+
// Browse Channels modal. The shell is server-rendered using wizard modal CSS classes. The content area (service picker, channel list) is rendered
|
|
1393
|
+
// client-side after fetching discovered channels from the services endpoint.
|
|
1287
1394
|
lines.push(generateBrowseModal());
|
|
1288
|
-
//
|
|
1395
|
+
// Service Setup wizard modal. Follows the same wizard pattern as the service profile builder. Three steps: pick services, authenticate, browse channels.
|
|
1289
1396
|
// The setupCompleted flag is embedded as a data attribute so the client can auto-show the wizard on first visit.
|
|
1290
1397
|
lines.push(generateSetupWizardModal());
|
|
1291
1398
|
// Tag Management modal. A simple dialog for creating, deleting, and restoring organizational tags. Client-side handlers drive the CRUD operations via the
|
|
1292
1399
|
// tag management API endpoints.
|
|
1293
1400
|
lines.push(generateTagManagementModal());
|
|
1294
|
-
// Inline tag edit portal
|
|
1401
|
+
// Inline tag edit portal - a shared dropdown portaled to <body> on first use by getTagPortal() in config.ts. One instance shared across all channel rows;
|
|
1295
1402
|
// populated dynamically from the clicked cell's data-tags attribute. Positioned via getBoundingClientRect() to escape the table wrapper's overflow:auto clipping.
|
|
1296
1403
|
const vocabulary = getActiveTagVocabulary();
|
|
1297
1404
|
lines.push("<div id=\"inline-tag-portal\" class=\"dropdown-menu inline-tag-menu\">");
|