prismcast 1.7.0 → 1.9.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.js +24 -13
- package/dist/app.js.map +1 -1
- package/dist/browser/cdp.d.ts +4 -6
- package/dist/browser/cdp.js +13 -17
- package/dist/browser/cdp.js.map +1 -1
- package/dist/browser/index.js +3 -3
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/login.js +11 -8
- package/dist/browser/login.js.map +1 -1
- 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/directv.js +4 -7
- package/dist/browser/tuning/directv.js.map +1 -1
- package/dist/browser/tuning/fox.js +1 -1
- package/dist/browser/tuning/fox.js.map +1 -1
- package/dist/browser/tuning/hbo.js +1 -1
- package/dist/browser/tuning/hbo.js.map +1 -1
- package/dist/browser/tuning/hulu.js +3 -3
- package/dist/browser/tuning/hulu.js.map +1 -1
- package/dist/browser/tuning/sling.js +2 -2
- package/dist/browser/tuning/sling.js.map +1 -1
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.js +438 -252
- package/dist/channels/index.js.map +1 -1
- package/dist/config/health.d.ts +3 -3
- package/dist/config/health.js +13 -18
- package/dist/config/health.js.map +1 -1
- package/dist/config/index.js +11 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/persistence.d.ts +59 -0
- package/dist/config/persistence.js +127 -0
- package/dist/config/persistence.js.map +1 -0
- package/dist/config/profiles.js +10 -10
- package/dist/config/profiles.js.map +1 -1
- package/dist/config/servicePacks.d.ts +46 -0
- package/dist/config/{providerPacks.js → servicePacks.js} +28 -31
- package/dist/config/servicePacks.js.map +1 -0
- package/dist/config/services.d.ts +202 -0
- package/dist/config/services.js +758 -0
- package/dist/config/services.js.map +1 -0
- package/dist/config/sites.js +61 -61
- package/dist/config/sites.js.map +1 -1
- package/dist/config/userChannels.d.ts +89 -27
- package/dist/config/userChannels.js +400 -184
- package/dist/config/userChannels.js.map +1 -1
- package/dist/config/userConfig.d.ts +13 -9
- package/dist/config/userConfig.js +78 -60
- package/dist/config/userConfig.js.map +1 -1
- package/dist/config/userProfiles.d.ts +31 -15
- package/dist/config/userProfiles.js +154 -123
- package/dist/config/userProfiles.js.map +1 -1
- package/dist/hdhr/channelMap.d.ts +1 -1
- package/dist/hdhr/channelMap.js +4 -7
- package/dist/hdhr/channelMap.js.map +1 -1
- package/dist/hdhr/discover.js +7 -15
- package/dist/hdhr/discover.js.map +1 -1
- package/dist/hdhr/index.js +6 -9
- package/dist/hdhr/index.js.map +1 -1
- package/dist/native/index.d.ts +2 -2
- package/dist/native/index.js +17 -27
- package/dist/native/index.js.map +1 -1
- package/dist/native/intercept.js +86 -89
- package/dist/native/intercept.js.map +1 -1
- package/dist/native/probe.js +2 -2
- package/dist/native/probe.js.map +1 -1
- package/dist/native/proxy.d.ts +2 -2
- package/dist/native/proxy.js +4 -4
- package/dist/native/proxy.js.map +1 -1
- package/dist/routes/assets.js +29 -51
- package/dist/routes/assets.js.map +1 -1
- package/dist/routes/auth.js +3 -3
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/channels.js +5 -11
- package/dist/routes/channels.js.map +1 -1
- package/dist/routes/components.d.ts +0 -8
- package/dist/routes/components.js +0 -19
- package/dist/routes/components.js.map +1 -1
- package/dist/routes/config/channels/index.d.ts +1 -1
- package/dist/routes/config/channels/index.js +1 -1
- package/dist/routes/config/channels/index.js.map +1 -1
- package/dist/routes/config/channels/routes.js +748 -420
- package/dist/routes/config/channels/routes.js.map +1 -1
- package/dist/routes/config/channels/table.d.ts +21 -6
- package/dist/routes/config/channels/table.js +456 -193
- package/dist/routes/config/channels/table.js.map +1 -1
- package/dist/routes/config/index.d.ts +2 -2
- package/dist/routes/config/index.js +3 -3
- package/dist/routes/config/index.js.map +1 -1
- package/dist/routes/config/{providers.js → services.js} +47 -42
- package/dist/routes/config/services.js.map +1 -0
- package/dist/routes/config/settings.js +88 -49
- package/dist/routes/config/settings.js.map +1 -1
- package/dist/routes/debug.js +5 -7
- 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.d.ts +16 -0
- package/dist/routes/icons.js +44 -0
- package/dist/routes/icons.js.map +1 -0
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/logs.js +4 -4
- package/dist/routes/logs.js.map +1 -1
- package/dist/routes/playlist.d.ts +6 -4
- package/dist/routes/playlist.js +98 -45
- package/dist/routes/playlist.js.map +1 -1
- package/dist/routes/root/content.js +94 -55
- package/dist/routes/root/content.js.map +1 -1
- package/dist/routes/root/scripts/channels.js +620 -478
- package/dist/routes/root/scripts/channels.js.map +1 -1
- package/dist/routes/root/scripts/config.js +884 -964
- package/dist/routes/root/scripts/config.js.map +1 -1
- package/dist/routes/root/scripts/shared.js +468 -188
- package/dist/routes/root/scripts/shared.js.map +1 -1
- package/dist/routes/root/scripts/status.js +252 -207
- package/dist/routes/root/scripts/status.js.map +1 -1
- package/dist/routes/root/styles.js +57 -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} +43 -43
- package/dist/routes/services.js.map +1 -0
- package/dist/routes/ui.d.ts +1 -1
- package/dist/routes/ui.js +3 -1
- package/dist/routes/ui.js.map +1 -1
- package/dist/service/commands.js +8 -43
- package/dist/service/commands.js.map +1 -1
- package/dist/service/generators.d.ts +1 -1
- package/dist/service/generators.js +143 -41
- package/dist/service/generators.js.map +1 -1
- package/dist/streaming/codec.d.ts +20 -0
- package/dist/streaming/codec.js +68 -0
- package/dist/streaming/codec.js.map +1 -0
- package/dist/streaming/fmp4Segmenter.d.ts +2 -2
- package/dist/streaming/fmp4Segmenter.js.map +1 -1
- package/dist/streaming/hls.d.ts +4 -4
- package/dist/streaming/hls.js +35 -29
- package/dist/streaming/hls.js.map +1 -1
- package/dist/streaming/hlsResume.js +3 -3
- package/dist/streaming/hlsResume.js.map +1 -1
- package/dist/streaming/monitor.d.ts +2 -2
- package/dist/streaming/monitor.js +19 -13
- package/dist/streaming/monitor.js.map +1 -1
- package/dist/streaming/preroll.d.ts +15 -14
- package/dist/streaming/preroll.js +32 -12
- package/dist/streaming/preroll.js.map +1 -1
- package/dist/streaming/registry.d.ts +2 -2
- package/dist/streaming/registry.js +4 -8
- package/dist/streaming/registry.js.map +1 -1
- package/dist/streaming/setup.d.ts +4 -4
- package/dist/streaming/setup.js +28 -34
- package/dist/streaming/setup.js.map +1 -1
- package/dist/streaming/showInfo.js +8 -10
- package/dist/streaming/showInfo.js.map +1 -1
- package/dist/streaming/statusEmitter.d.ts +4 -3
- package/dist/streaming/statusEmitter.js +3 -3
- package/dist/streaming/statusEmitter.js.map +1 -1
- package/dist/types/channels.d.ts +18 -11
- package/dist/types/config.d.ts +3 -2
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/profiles.d.ts +9 -9
- package/dist/types/selection.d.ts +1 -1
- package/dist/types/shared.d.ts +1 -1
- package/dist/types/streaming.d.ts +8 -16
- package/dist/types/streaming.js +5 -1
- package/dist/types/streaming.js.map +1 -1
- package/dist/upgrade/commands.js +1 -16
- package/dist/upgrade/commands.js.map +1 -1
- package/dist/utils/chromeFetch.js +1 -1
- package/dist/utils/cliOutput.d.ts +10 -0
- package/dist/utils/cliOutput.js +17 -0
- package/dist/utils/cliOutput.js.map +1 -0
- package/dist/utils/debugFilter.js +3 -3
- package/dist/utils/debugFilter.js.map +1 -1
- package/dist/utils/fileLogger.js +4 -3
- package/dist/utils/fileLogger.js.map +1 -1
- package/dist/utils/format.d.ts +21 -4
- package/dist/utils/format.js +45 -7
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/logger.js +17 -27
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/morganStream.js +2 -2
- package/dist/utils/morganStream.js.map +1 -1
- package/dist/utils/platform.js +3 -2
- package/dist/utils/platform.js.map +1 -1
- package/dist/utils/retry.js +2 -4
- package/dist/utils/retry.js.map +1 -1
- 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/version.js +4 -4
- package/dist/utils/version.js.map +1 -1
- package/package.json +5 -7
- 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 -678
- package/dist/config/providers.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
|
@@ -6,116 +6,90 @@ export function generateChannelsSubtabScript() {
|
|
|
6
6
|
return [
|
|
7
7
|
"<script>",
|
|
8
8
|
"(function() {",
|
|
9
|
-
// Channels subtab switching
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
" btns[i].setAttribute('aria-selected', 'true');",
|
|
19
|
-
" }",
|
|
20
|
-
" }",
|
|
21
|
-
" for(var j = 0; j < panels.length; j++) {",
|
|
22
|
-
" panels[j].classList.remove('active');",
|
|
23
|
-
" if(panels[j].id === 'channels-subtab-' + subtab) {",
|
|
24
|
-
" panels[j].classList.add('active');",
|
|
25
|
-
" }",
|
|
26
|
-
" }",
|
|
27
|
-
" try { localStorage.setItem('prismcast-channels-subtab', subtab); } catch(e) {}",
|
|
28
|
-
" if(updateUrl !== false) {",
|
|
29
|
-
" var newHash = (subtab === 'channels') ? '#channels' : '#channels/' + subtab;",
|
|
30
|
-
" if(window.location.hash !== newHash) {",
|
|
31
|
-
" window.location.hash = newHash;",
|
|
32
|
-
" }",
|
|
33
|
-
" }",
|
|
34
|
-
" }",
|
|
35
|
-
// Expose for main tab script hashchange handler.
|
|
9
|
+
// Channels subtab switching via the shared factory.
|
|
10
|
+
" const switchChannelsSubtab = createSubtabSwitcher({",
|
|
11
|
+
" btnSelector: '.channels-subtab-btn',",
|
|
12
|
+
" dataAttr: 'data-channels-subtab',",
|
|
13
|
+
" hashFn: (s) => (s === 'channels') ? '#channels' : '#channels/' + s,",
|
|
14
|
+
" panelPrefix: 'channels-subtab-',",
|
|
15
|
+
" panelSelector: '.channels-subtab-panel',",
|
|
16
|
+
" storageKey: 'prismcast-channels-subtab'",
|
|
17
|
+
" });",
|
|
36
18
|
" window.switchChannelsSubtab = switchChannelsSubtab;",
|
|
37
|
-
// Attach click handlers to channels subtab buttons.
|
|
38
|
-
" var btns = document.querySelectorAll('.channels-subtab-btn');",
|
|
39
|
-
" for(var i = 0; i < btns.length; i++) {",
|
|
40
|
-
" btns[i].addEventListener('click', function() {",
|
|
41
|
-
" switchChannelsSubtab(this.getAttribute('data-channels-subtab'));",
|
|
42
|
-
" });",
|
|
43
|
-
" }",
|
|
44
19
|
// Replace the Custom Profiles panel content with server-rendered HTML. Called after profile mutations to update the panel without a page reload.
|
|
45
|
-
// Re-runs
|
|
20
|
+
// Re-runs processServiceDisplays() so dynamically swapped service icon elements render correctly.
|
|
46
21
|
" function applyProfilePanel(html) {",
|
|
47
|
-
"
|
|
22
|
+
" const panel = document.querySelector('#channels-subtab-custom-profiles .section');",
|
|
48
23
|
" if(panel) {",
|
|
49
24
|
" panel.innerHTML = html;",
|
|
50
|
-
" if(window.
|
|
25
|
+
" if(window.processServiceDisplays) { processServiceDisplays(); }",
|
|
51
26
|
" }",
|
|
52
27
|
" }",
|
|
53
28
|
// Delete a user profile with confirmation.
|
|
54
|
-
" window.deleteUserProfile = async
|
|
29
|
+
" window.deleteUserProfile = async (key) => {",
|
|
55
30
|
" if(!confirm('Delete profile \"' + key + '\" and all its domain mappings?')) return;",
|
|
56
31
|
" try {",
|
|
57
|
-
"
|
|
58
|
-
"
|
|
32
|
+
" const res = await fetch('/config/profiles/' + encodeURIComponent(key), { method: 'DELETE' });",
|
|
33
|
+
" const data = await res.json();",
|
|
59
34
|
" if(data.success) {",
|
|
60
35
|
" showToast(data.message || 'Profile deleted.', 'success');",
|
|
61
36
|
" if(data.panelHtml) { applyProfilePanel(data.panelHtml); }",
|
|
62
37
|
" } else { showToast('Failed to delete profile: ' + (data.error || 'Unknown error.'), 'error'); }",
|
|
63
|
-
" } catch(err) { console.error('Profile delete
|
|
38
|
+
" } catch(err) { console.error('Profile delete request failed.', err); showToast('Failed to delete profile.', 'error'); }",
|
|
64
39
|
" };",
|
|
65
40
|
// Edit an existing user profile by pre-populating the wizard.
|
|
66
|
-
" window.editUserProfile = async
|
|
41
|
+
" window.editUserProfile = async (key) => {",
|
|
67
42
|
" try {",
|
|
68
|
-
"
|
|
69
|
-
"
|
|
43
|
+
" const res = await fetch('/config/profiles');",
|
|
44
|
+
" const data = await res.json();",
|
|
70
45
|
" if(!data.success) { showToast('Failed to load profiles.', 'error'); return; }",
|
|
71
|
-
"
|
|
72
|
-
"
|
|
46
|
+
" const profiles = data.profiles || [];",
|
|
47
|
+
" const match = profiles.find((p) => p.key === key);",
|
|
73
48
|
" if(!match || !match.profile) { showToast('Profile not found.', 'error'); return; }",
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
49
|
+
" const p = match.profile;",
|
|
50
|
+
" const editCs = p.channelSelection || {};",
|
|
51
|
+
" const strat = editCs.strategy || 'none';",
|
|
52
|
+
" const strategyFields = {};",
|
|
53
|
+
" const editStrat = window.__wizardStrategies.find((st) => st.id === strat);",
|
|
79
54
|
" if(editStrat) {",
|
|
80
|
-
" for(
|
|
81
|
-
" var ef = editStrat.fields[i];",
|
|
55
|
+
" for(const ef of editStrat.fields) {",
|
|
82
56
|
" if(ef.type === 'text' && editCs[ef.id]) { strategyFields[ef.id] = editCs[ef.id]; }",
|
|
83
57
|
" else if(ef.type === 'boolean' && editCs[ef.id]) { strategyFields[ef.id] = true; }",
|
|
84
58
|
" }",
|
|
85
59
|
" }",
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
" for(
|
|
89
|
-
"
|
|
90
|
-
" match.domains.map(
|
|
91
|
-
" [{ domain: '',
|
|
60
|
+
" const flags = {};",
|
|
61
|
+
" const wizFlags = window.__wizardFlags;",
|
|
62
|
+
" for(const wf of wizFlags) { if(p[wf.id]) flags[wf.id] = true; }",
|
|
63
|
+
" const domains = (match.domains && match.domains.length > 0) ?",
|
|
64
|
+
" match.domains.map((d) => ({ domain: d.domain, service: d.service || '', serviceTag: d.serviceTag || '' })) :",
|
|
65
|
+
" [{ domain: '', service: '', serviceTag: '' }];",
|
|
92
66
|
" profileWizard.state = {",
|
|
93
67
|
" editKey: key, editMode: true, profileName: key,",
|
|
94
68
|
" baseProfile: p.extends || '', strategy: strat,",
|
|
95
69
|
" strategyFields: strategyFields, flags: flags,",
|
|
96
70
|
" hideSelector: p.hideSelector || '', domains: domains, description: p.description || ''",
|
|
97
71
|
" };",
|
|
98
|
-
" profileWizard.open({ highestStep: 5, title: 'Edit
|
|
99
|
-
" } catch(e) { showToast('Failed to load profile data.', 'error'); }",
|
|
72
|
+
" profileWizard.open({ highestStep: 5, title: 'Edit Service Profile' });",
|
|
73
|
+
" } catch(e) { console.error('Profile data failed to load.', e); showToast('Failed to load profile data.', 'error'); }",
|
|
100
74
|
" };",
|
|
101
75
|
// Pending import data stored while the import preview modal is open.
|
|
102
|
-
"
|
|
103
|
-
// Import a
|
|
104
|
-
" window.
|
|
105
|
-
"
|
|
76
|
+
" let pendingImportData = null;",
|
|
77
|
+
// Import a service pack via file picker — shows a preview modal before importing.
|
|
78
|
+
" window.startServiceImport = () => {",
|
|
79
|
+
" const input = document.createElement('input');",
|
|
106
80
|
" input.type = 'file';",
|
|
107
81
|
" input.accept = '.json,application/json';",
|
|
108
|
-
" input.onchange =
|
|
82
|
+
" input.onchange = () => {",
|
|
109
83
|
" if(!input.files || !input.files[0]) return;",
|
|
110
|
-
"
|
|
111
|
-
" reader.onload =
|
|
84
|
+
" const reader = new FileReader();",
|
|
85
|
+
" reader.onload = () => {",
|
|
112
86
|
" try {",
|
|
113
|
-
"
|
|
87
|
+
" const data = JSON.parse(reader.result);",
|
|
114
88
|
" pendingImportData = data;",
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"
|
|
89
|
+
" const profileCount = data.profiles ? Object.keys(data.profiles).length : 0;",
|
|
90
|
+
" const domainCount = data.domains ? Object.keys(data.domains).length : 0;",
|
|
91
|
+
" const channelCount = data.channels ? Object.keys(data.channels).length : 0;",
|
|
92
|
+
" let html = '<table class=\"import-summary-table\">';",
|
|
119
93
|
" html += '<tr><td class=\"import-summary-label\">Pack name</td>';",
|
|
120
94
|
" html += '<td>' + esc(data.name || 'Unnamed') + '</td></tr>';",
|
|
121
95
|
" html += '<tr><td class=\"import-summary-label\">Profiles</td>';",
|
|
@@ -129,11 +103,12 @@ export function generateChannelsSubtabScript() {
|
|
|
129
103
|
" html += '<div class=\"export-divider\"></div>';",
|
|
130
104
|
" html += '<label class=\"export-option-label\">';",
|
|
131
105
|
" html += '<input type=\"checkbox\" id=\"import-include-channels\" checked> Include ' + plural(channelCount, 'channel') + '</label>';",
|
|
132
|
-
" html += '<div class=\"export-hint\">Import the channel definitions from this pack. Uncheck to import only the
|
|
106
|
+
" html += '<div class=\"export-hint\">Import the channel definitions from this pack. Uncheck to import only the service profiles and domains.</div>';",
|
|
133
107
|
" }",
|
|
134
108
|
" document.getElementById('import-modal-body').innerHTML = html;",
|
|
135
109
|
" document.getElementById('import-modal').style.display = 'flex';",
|
|
136
110
|
" } catch(e) {",
|
|
111
|
+
" console.error('Service pack JSON failed to parse.', e);",
|
|
137
112
|
" showToast('Invalid JSON file.', 'error');",
|
|
138
113
|
" }",
|
|
139
114
|
" };",
|
|
@@ -142,28 +117,28 @@ export function generateChannelsSubtabScript() {
|
|
|
142
117
|
" input.click();",
|
|
143
118
|
" };",
|
|
144
119
|
// Close the import preview modal.
|
|
145
|
-
" window.closeImportModal =
|
|
120
|
+
" window.closeImportModal = () => {",
|
|
146
121
|
" document.getElementById('import-modal').style.display = 'none';",
|
|
147
122
|
" pendingImportData = null;",
|
|
148
123
|
" };",
|
|
149
124
|
// Execute the import after the user confirms in the preview modal.
|
|
150
|
-
" window.executeImport = async
|
|
125
|
+
" window.executeImport = async () => {",
|
|
151
126
|
" if(!pendingImportData) return;",
|
|
152
|
-
"
|
|
153
|
-
"
|
|
127
|
+
" let skipChannels = false;",
|
|
128
|
+
" const cb = document.getElementById('import-include-channels');",
|
|
154
129
|
" if(cb && !cb.checked) skipChannels = true;",
|
|
155
|
-
"
|
|
130
|
+
" const body = { ...pendingImportData };",
|
|
156
131
|
" if(skipChannels) body.skipChannels = true;",
|
|
157
132
|
" try {",
|
|
158
|
-
"
|
|
159
|
-
"
|
|
133
|
+
" const res = await fetch('/config/profiles/import', {",
|
|
134
|
+
" body: JSON.stringify(body),",
|
|
160
135
|
" headers: { 'Content-Type': 'application/json' },",
|
|
161
|
-
"
|
|
136
|
+
" method: 'POST'",
|
|
162
137
|
" });",
|
|
163
|
-
"
|
|
138
|
+
" const result = await res.json();",
|
|
164
139
|
" closeImportModal();",
|
|
165
140
|
" if(result.success) {",
|
|
166
|
-
"
|
|
141
|
+
" let msg = 'Imported ' + plural(result.summary.profilesAdded, 'profile') + ', ' +",
|
|
167
142
|
" plural(result.summary.domainsAdded, 'domain') + ', ' + plural(result.summary.channelsAdded, 'channel') + '.';",
|
|
168
143
|
" if(result.warnings && result.warnings.length > 0) msg += ' Warnings: ' + result.warnings.join('; ');",
|
|
169
144
|
" showToast(msg, 'success');",
|
|
@@ -171,28 +146,27 @@ export function generateChannelsSubtabScript() {
|
|
|
171
146
|
" } else {",
|
|
172
147
|
" showToast('Import failed: ' + (result.error || 'Unknown error.'), 'error');",
|
|
173
148
|
" }",
|
|
174
|
-
" } catch(err) { console.error('
|
|
149
|
+
" } catch(err) { console.error('Service pack import request failed.', err); closeImportModal(); showToast('Import request failed.', 'error'); }",
|
|
175
150
|
" };",
|
|
176
|
-
// Export a
|
|
151
|
+
// Export a service pack. Opens a modal with profile checklist and options. Shows service name prominently for each profile with domain and channel counts.
|
|
177
152
|
// The select-all row is hidden when there is only one profile since the single checkbox makes it redundant.
|
|
178
|
-
" window.
|
|
153
|
+
" window.startServiceExport = async () => {",
|
|
179
154
|
" try {",
|
|
180
|
-
"
|
|
181
|
-
"
|
|
182
|
-
"
|
|
155
|
+
" const res = await fetch('/config/profiles');",
|
|
156
|
+
" const data = await res.json();",
|
|
157
|
+
" const profiles = data.profiles || [];",
|
|
183
158
|
" if(profiles.length === 0) { showToast('No user profiles to export.', 'error'); return; }",
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
" for(
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
" if(p.domains[di].provider) { providerName = p.domains[di].provider; break; }",
|
|
159
|
+
" const listDiv = document.getElementById('export-profile-list');",
|
|
160
|
+
" let html = '';",
|
|
161
|
+
" for(const p of profiles) {",
|
|
162
|
+
" const domainCount = p.domains ? p.domains.length : 0;",
|
|
163
|
+
" const channelCount = p.channelCount || 0;",
|
|
164
|
+
" let serviceName = '';",
|
|
165
|
+
" for(const dom of (p.domains || [])) {",
|
|
166
|
+
" if(dom.service) { serviceName = dom.service; break; }",
|
|
193
167
|
" }",
|
|
194
|
-
"
|
|
195
|
-
" if(
|
|
168
|
+
" const meta = [];",
|
|
169
|
+
" if(serviceName) meta.push(esc(serviceName));",
|
|
196
170
|
" meta.push(plural(domainCount, 'domain'));",
|
|
197
171
|
" meta.push(plural(channelCount, 'channel'));",
|
|
198
172
|
" html += '<label class=\"export-profile-item\">';",
|
|
@@ -207,35 +181,35 @@ export function generateChannelsSubtabScript() {
|
|
|
207
181
|
" document.getElementById('export-select-all-row').style.display = (profiles.length > 1) ? '' : 'none';",
|
|
208
182
|
" document.getElementById('export-include-channels').checked = false;",
|
|
209
183
|
" document.getElementById('export-modal').style.display = 'flex';",
|
|
210
|
-
" } catch(e) { showToast('Failed to load profiles.', 'error'); }",
|
|
184
|
+
" } catch(e) { console.error('Profiles failed to load for export.', e); showToast('Failed to load profiles.', 'error'); }",
|
|
211
185
|
" };",
|
|
212
186
|
// Toggle all export profile checkboxes.
|
|
213
|
-
" window.toggleExportAll =
|
|
214
|
-
"
|
|
215
|
-
" for(
|
|
187
|
+
" window.toggleExportAll = (master) => {",
|
|
188
|
+
" const cbs = document.querySelectorAll('.export-profile-cb');",
|
|
189
|
+
" for(const cb of cbs) cb.checked = master.checked;",
|
|
216
190
|
" };",
|
|
217
191
|
// Close the export modal.
|
|
218
|
-
" window.closeExportModal =
|
|
192
|
+
" window.closeExportModal = () => {",
|
|
219
193
|
" document.getElementById('export-modal').style.display = 'none';",
|
|
220
194
|
" };",
|
|
221
195
|
// Execute the export by building the download URL from the selected profiles.
|
|
222
|
-
" window.executeExport =
|
|
223
|
-
"
|
|
196
|
+
" window.executeExport = () => {",
|
|
197
|
+
" const cbs = document.querySelectorAll('.export-profile-cb:checked');",
|
|
224
198
|
" if(cbs.length === 0) { showToast('Select at least one profile.', 'error'); return; }",
|
|
225
|
-
"
|
|
226
|
-
" for(
|
|
227
|
-
"
|
|
199
|
+
" const keys = [];",
|
|
200
|
+
" for(const cb of cbs) keys.push(cb.value);",
|
|
201
|
+
" let url = '/config/profiles/export?profile=' + encodeURIComponent(keys.join(',')) + '&domains=1';",
|
|
228
202
|
" if(document.getElementById('export-include-channels').checked) url += '&channels=1';",
|
|
229
203
|
" closeExportModal();",
|
|
230
204
|
" window.location.href = url;",
|
|
231
205
|
" };",
|
|
232
|
-
// Auto-generate a
|
|
233
|
-
" function
|
|
206
|
+
// Auto-generate a service tag from a service name: lowercase, strip non-alphanumeric.
|
|
207
|
+
" function autoServiceTag(name) {",
|
|
234
208
|
" return name.toLowerCase().replace(/[^a-z0-9]/g, '');",
|
|
235
209
|
" }",
|
|
236
210
|
// HTML-escape user input for safe innerHTML insertion in the review step.
|
|
237
211
|
" function esc(str) {",
|
|
238
|
-
"
|
|
212
|
+
" const d = document.createElement('div');",
|
|
239
213
|
" d.appendChild(document.createTextNode(str || ''));",
|
|
240
214
|
" return d.innerHTML;",
|
|
241
215
|
" }",
|
|
@@ -245,7 +219,7 @@ export function generateChannelsSubtabScript() {
|
|
|
245
219
|
" }",
|
|
246
220
|
// Profile builder wizard. Uses the shared wizard controller for step navigation, indicator updates, and error display. The state object holds wizard-specific
|
|
247
221
|
// form data (profile name, base profile, strategy, flags, domains, description).
|
|
248
|
-
"
|
|
222
|
+
" const profileWizard = createWizardController({",
|
|
249
223
|
" contentId: 'wizard-content',",
|
|
250
224
|
" errorId: 'wizard-error',",
|
|
251
225
|
" modalId: 'wizard-modal',",
|
|
@@ -256,28 +230,28 @@ export function generateChannelsSubtabScript() {
|
|
|
256
230
|
" });",
|
|
257
231
|
// Render the current profile wizard step into the content area. Manages button visibility and delegates to the step-specific content renderer.
|
|
258
232
|
" function renderProfileStep(step) {",
|
|
259
|
-
"
|
|
233
|
+
" const content = document.getElementById('wizard-content');",
|
|
260
234
|
" if(!content) return;",
|
|
261
235
|
// Show/hide navigation buttons based on the current step.
|
|
262
236
|
" if(step > 1) { profileWizard.show('wizard-back'); } else { profileWizard.hide('wizard-back'); }",
|
|
263
237
|
" if(step < 5) { profileWizard.show('wizard-next'); } else { profileWizard.hide('wizard-next'); }",
|
|
264
238
|
" if(step === 5) { profileWizard.show('wizard-save'); profileWizard.show('wizard-save-test'); }",
|
|
265
239
|
" else { profileWizard.hide('wizard-save'); profileWizard.hide('wizard-save-test'); }",
|
|
266
|
-
"
|
|
267
|
-
"
|
|
240
|
+
" let html = '';",
|
|
241
|
+
" const s = profileWizard.state;",
|
|
268
242
|
// Step 1: Base Profile.
|
|
269
243
|
" if(step === 1) {",
|
|
270
244
|
" html += '<div class=\"field-group\">';",
|
|
271
245
|
" html += '<label class=\"wizard-label\" for=\"wizard-profile-name\">Profile Name</label>';",
|
|
272
|
-
"
|
|
273
|
-
" html += '<input type=\"text\" id=\"wizard-profile-name\" value=\"' + s.profileName.
|
|
274
|
-
" '\" placeholder=\"e.g.,
|
|
246
|
+
" const nameReadonly = s.editMode ? ' readonly style=\"opacity: 0.6; cursor: not-allowed;\"' : '';",
|
|
247
|
+
" html += '<input type=\"text\" id=\"wizard-profile-name\" value=\"' + s.profileName.replaceAll('\"', '"') +",
|
|
248
|
+
" '\" placeholder=\"e.g., myService\"' + nameReadonly + '>';",
|
|
275
249
|
" html += '<div class=\"field-hint\">' + (s.editMode ? 'Profile name cannot be changed.' :",
|
|
276
250
|
" 'Unique identifier. Letters, numbers, and hyphens only.') + '</div>';",
|
|
277
251
|
" html += '</div>';",
|
|
278
252
|
" html += '<label class=\"wizard-label\">Base Profile</label>';",
|
|
279
|
-
"
|
|
280
|
-
"
|
|
253
|
+
" const profiles = window.__wizardProfiles || {};",
|
|
254
|
+
" const cats = [",
|
|
281
255
|
" { key: 'api', title: 'Fullscreen API',",
|
|
282
256
|
" desc: 'For single-channel sites that require JavaScript\\'s requestFullscreen() API instead of keyboard shortcuts.' },",
|
|
283
257
|
" { key: 'keyboard', title: 'Keyboard Fullscreen',",
|
|
@@ -288,19 +262,17 @@ export function generateChannelsSubtabScript() {
|
|
|
288
262
|
" desc: 'For non-standard use cases like static pages without video.' },",
|
|
289
263
|
" { key: 'custom', title: 'Custom', desc: '' }",
|
|
290
264
|
" ];",
|
|
291
|
-
" for(
|
|
292
|
-
"
|
|
293
|
-
" var items = (profiles[cat.key] || []).filter(function(p) { return !s.editMode || p.name !== s.editKey; });",
|
|
265
|
+
" for(const cat of cats) {",
|
|
266
|
+
" const items = (profiles[cat.key] || []).filter((p) => !s.editMode || p.name !== s.editKey);",
|
|
294
267
|
" if(items.length === 0) continue;",
|
|
295
268
|
" html += '<div class=\"wizard-profile-category\">';",
|
|
296
269
|
" html += '<h4>' + cat.title + '</h4>';",
|
|
297
270
|
" if(cat.desc) html += '<div class=\"wizard-category-desc\">' + esc(cat.desc) + '</div>';",
|
|
298
|
-
" for(
|
|
299
|
-
"
|
|
300
|
-
" var checked = (s.baseProfile === p.name) ? ' checked' : '';",
|
|
271
|
+
" for(const p of items) {",
|
|
272
|
+
" const checked = (s.baseProfile === p.name) ? ' checked' : '';",
|
|
301
273
|
" html += '<label class=\"wizard-profile-option\">';",
|
|
302
274
|
" html += '<input type=\"radio\" name=\"baseProfile\" value=\"' + p.name + '\"' + checked + '>';",
|
|
303
|
-
"
|
|
275
|
+
" const sumTag = p.summary ? ' <span class=\"profile-option-summary\">(' + esc(p.summary) + ')</span>' : '';",
|
|
304
276
|
" html += '<div><div class=\"profile-option-name\">' + esc(p.name) + sumTag + '</div>';",
|
|
305
277
|
" html += '<div class=\"profile-option-desc\">' + esc(p.description) + '</div></div>';",
|
|
306
278
|
" html += '</label>';",
|
|
@@ -311,33 +283,31 @@ export function generateChannelsSubtabScript() {
|
|
|
311
283
|
// Step 2: Channel Selection Strategy. Strategies and their fields are rendered from the server-side registry (window.__wizardStrategies).
|
|
312
284
|
" if(step === 2) {",
|
|
313
285
|
" html += '<label class=\"wizard-label\">Channel Selection Strategy</label>';",
|
|
314
|
-
"
|
|
315
|
-
" for(
|
|
316
|
-
"
|
|
317
|
-
" var checked = (s.strategy === st.id) ? ' checked' : '';",
|
|
286
|
+
" const strategies = window.__wizardStrategies;",
|
|
287
|
+
" for(const st of strategies) {",
|
|
288
|
+
" const checked = (s.strategy === st.id) ? ' checked' : '';",
|
|
318
289
|
" html += '<label class=\"wizard-strategy-option\">';",
|
|
319
290
|
" html += '<input type=\"radio\" name=\"strategy\" value=\"' + st.id + '\"' + checked + '>';",
|
|
320
291
|
" html += '<div><div class=\"wizard-strategy-label\">' + st.name + '</div>';",
|
|
321
292
|
" html += '<div class=\"wizard-strategy-desc\">' + st.description + '</div></div>';",
|
|
322
293
|
" html += '</label>';",
|
|
323
294
|
" }",
|
|
324
|
-
"
|
|
295
|
+
" const currentStrat = strategies.find((st) => st.id === s.strategy);",
|
|
325
296
|
" if(currentStrat && currentStrat.fields.length > 0) {",
|
|
326
297
|
" html += '<hr style=\"border: none; border-top: 1px solid var(--border-default); margin: 16px 0;\">';",
|
|
327
|
-
" for(
|
|
328
|
-
" var f = currentStrat.fields[fi];",
|
|
298
|
+
" for(const f of currentStrat.fields) {",
|
|
329
299
|
" if(f.type === 'text') {",
|
|
330
|
-
"
|
|
331
|
-
"
|
|
300
|
+
" const val = (s.strategyFields[f.id] || '').replaceAll('\"', '"');",
|
|
301
|
+
" const optLabel = f.required ? '' : ' <span style=\"color: var(--text-muted);\">(optional)</span>';",
|
|
332
302
|
" html += '<div class=\"field-group\">';",
|
|
333
303
|
" html += '<label class=\"wizard-label\" for=\"wizard-sf-' + f.id + '\">' + f.label + optLabel + '</label>';",
|
|
334
304
|
" html += '<input type=\"text\" id=\"wizard-sf-' + f.id + '\" data-field=\"' + f.id + '\" value=\"' + val + '\"';",
|
|
335
|
-
" if(f.placeholder) html += ' placeholder=\"' + f.placeholder.
|
|
305
|
+
" if(f.placeholder) html += ' placeholder=\"' + f.placeholder.replaceAll('\"', '"') + '\"';",
|
|
336
306
|
" html += '>';",
|
|
337
307
|
" if(f.hint) html += '<div class=\"field-hint\">' + f.hint + '</div>';",
|
|
338
308
|
" html += '</div>';",
|
|
339
309
|
" } else if(f.type === 'boolean') {",
|
|
340
|
-
"
|
|
310
|
+
" const boolChecked = s.strategyFields[f.id] ? ' checked' : '';",
|
|
341
311
|
" html += '<label class=\"wizard-flag\">';",
|
|
342
312
|
" html += '<input type=\"checkbox\" data-field=\"' + f.id + '\"' + boolChecked + '>';",
|
|
343
313
|
" html += '<div><div class=\"wizard-flag-label\">' + f.label + '</div>';",
|
|
@@ -352,10 +322,9 @@ export function generateChannelsSubtabScript() {
|
|
|
352
322
|
" html += '<label class=\"wizard-label\">Profile Flags</label>';",
|
|
353
323
|
" html += '<div class=\"field-hint\" style=\"margin-bottom: 12px;\">These override the base profile. ' +",
|
|
354
324
|
" 'Only change what differs from ' + s.baseProfile + '.</div>';",
|
|
355
|
-
"
|
|
356
|
-
" for(
|
|
357
|
-
"
|
|
358
|
-
" var checked = s.flags[f.id] ? ' checked' : '';",
|
|
325
|
+
" const wizFlags = window.__wizardFlags;",
|
|
326
|
+
" for(const f of wizFlags) {",
|
|
327
|
+
" const checked = s.flags[f.id] ? ' checked' : '';",
|
|
359
328
|
" html += '<label class=\"wizard-flag\">';",
|
|
360
329
|
" html += '<input type=\"checkbox\" name=\"flag-' + f.id + '\"' + checked + '>';",
|
|
361
330
|
" html += '<div><div class=\"wizard-flag-label\">' + f.label + '</div>';",
|
|
@@ -364,7 +333,7 @@ export function generateChannelsSubtabScript() {
|
|
|
364
333
|
" }",
|
|
365
334
|
" html += '<div class=\"field-group\" style=\"margin-top: 12px;\">';",
|
|
366
335
|
" html += '<label class=\"wizard-label\" for=\"wizard-hide-selector\">Hide Selector (CSS) <span style=\"color: var(--text-muted);\">(optional)</span></label>';",
|
|
367
|
-
" html += '<input type=\"text\" id=\"wizard-hide-selector\" value=\"' + s.hideSelector.
|
|
336
|
+
" html += '<input type=\"text\" id=\"wizard-hide-selector\" value=\"' + s.hideSelector.replaceAll('\"', '"') +",
|
|
368
337
|
" '\" placeholder=\"e.g., .overlay, .ad-banner\">';",
|
|
369
338
|
" html += '<div class=\"field-hint\">Overlay elements to hide during capture.</div>';",
|
|
370
339
|
" html += '</div>';",
|
|
@@ -373,22 +342,21 @@ export function generateChannelsSubtabScript() {
|
|
|
373
342
|
" if(step === 4) {",
|
|
374
343
|
" html += '<label class=\"wizard-label\">Domain Mapping</label>';",
|
|
375
344
|
" html += '<div class=\"field-hint\" style=\"margin-bottom: 12px;\">Map a streaming site domain to this profile.</div>';",
|
|
376
|
-
" for(
|
|
377
|
-
" var d = s.domains[di];",
|
|
345
|
+
" for(const [ di, d ] of s.domains.entries()) {",
|
|
378
346
|
" html += '<div class=\"wizard-domain-row\" data-domain-idx=\"' + di + '\">';",
|
|
379
347
|
" html += '<div class=\"field-group\">';",
|
|
380
348
|
" html += '<label class=\"wizard-label\">Domain</label>';",
|
|
381
|
-
" html += '<input type=\"text\" class=\"domain-input\" value=\"' + d.domain.
|
|
349
|
+
" html += '<input type=\"text\" class=\"domain-input\" value=\"' + d.domain.replaceAll('\"', '"') + '\" placeholder=\"e.g., watch.sling.com\">';",
|
|
382
350
|
" html += '</div>';",
|
|
383
351
|
" html += '<div class=\"field-group\">';",
|
|
384
|
-
" html += '<label class=\"wizard-label\">
|
|
385
|
-
" html += '<input type=\"text\" class=\"provider-input\" value=\"' + d.
|
|
352
|
+
" html += '<label class=\"wizard-label\">Service Name</label>';",
|
|
353
|
+
" html += '<input type=\"text\" class=\"provider-input\" value=\"' + d.service.replaceAll('\"', '"') + '\" placeholder=\"e.g., Sling TV\">';",
|
|
386
354
|
" html += '</div>';",
|
|
387
355
|
" html += '<div class=\"field-group\">';",
|
|
388
|
-
" html += '<label class=\"wizard-label\">
|
|
389
|
-
" html += '<input type=\"text\" class=\"provider-tag-input\" value=\"' + d.
|
|
356
|
+
" html += '<label class=\"wizard-label\">Service Tag <span style=\"color: var(--text-muted);\">(auto-filled)</span></label>';",
|
|
357
|
+
" html += '<input type=\"text\" class=\"provider-tag-input\" value=\"' + d.serviceTag.replaceAll('\"', '"') +",
|
|
390
358
|
" '\" placeholder=\"auto-generated\">';",
|
|
391
|
-
" html += '<div class=\"field-hint\">Identifier for the
|
|
359
|
+
" html += '<div class=\"field-hint\">Identifier for the service filter. Auto-generated from service name.</div>';",
|
|
392
360
|
" html += '</div>';",
|
|
393
361
|
" html += '</div>';",
|
|
394
362
|
" }",
|
|
@@ -401,16 +369,15 @@ export function generateChannelsSubtabScript() {
|
|
|
401
369
|
" html += '<tr><td>Profile</td><td>' + esc(s.profileName) + '</td></tr>';",
|
|
402
370
|
" html += '<tr><td>Extends</td><td>' + esc(s.baseProfile) + '</td></tr>';",
|
|
403
371
|
" html += '<tr><td>Strategy</td><td>' + esc(s.strategy) + '</td></tr>';",
|
|
404
|
-
"
|
|
372
|
+
" const domainList = s.domains.filter((d) => d.domain).map((d) => esc(d.domain)).join(', ');",
|
|
405
373
|
" html += '<tr><td>Domain(s)</td><td>' + (domainList || 'none') + '</td></tr>';",
|
|
406
|
-
"
|
|
407
|
-
" html += '<tr><td>
|
|
374
|
+
" const serviceList = s.domains.filter((d) => d.service).map((d) => esc(d.service)).join(', ');",
|
|
375
|
+
" html += '<tr><td>Service</td><td>' + (serviceList || 'none') + '</td></tr>';",
|
|
408
376
|
" if(s.strategy !== 'none') {",
|
|
409
|
-
"
|
|
377
|
+
" const reviewStrat = window.__wizardStrategies.find((st) => st.id === s.strategy);",
|
|
410
378
|
" if(reviewStrat) {",
|
|
411
|
-
" for(
|
|
412
|
-
"
|
|
413
|
-
" var rv = s.strategyFields[rf.id];",
|
|
379
|
+
" for(const rf of reviewStrat.fields) {",
|
|
380
|
+
" const rv = s.strategyFields[rf.id];",
|
|
414
381
|
" if(rf.type === 'text' && rv) {",
|
|
415
382
|
" html += '<tr><td>' + esc(rf.label.replace(/ \\(CSS\\)/, '')) + '</td><td><code>' + esc(rv) + '</code></td></tr>';",
|
|
416
383
|
" } else if(rf.type === 'boolean' && rv) {",
|
|
@@ -419,7 +386,7 @@ export function generateChannelsSubtabScript() {
|
|
|
419
386
|
" }",
|
|
420
387
|
" }",
|
|
421
388
|
" }",
|
|
422
|
-
"
|
|
389
|
+
" const flagNames = Object.keys(s.flags).filter((k) => s.flags[k]);",
|
|
423
390
|
" if(flagNames.length > 0) {",
|
|
424
391
|
" html += '<tr><td>Flags</td><td>' + esc(flagNames.join(', ')) + '</td></tr>';",
|
|
425
392
|
" }",
|
|
@@ -436,68 +403,65 @@ export function generateChannelsSubtabScript() {
|
|
|
436
403
|
" }",
|
|
437
404
|
// Attach event handlers to dynamically rendered profile wizard inputs.
|
|
438
405
|
" function attachProfileHandlers() {",
|
|
439
|
-
"
|
|
406
|
+
" const s = profileWizard.state;",
|
|
440
407
|
// Step 1: profile name and base profile radio.
|
|
441
|
-
"
|
|
408
|
+
" const nameInput = document.getElementById('wizard-profile-name');",
|
|
442
409
|
" if(nameInput) nameInput.addEventListener('input', function() { s.profileName = this.value; });",
|
|
443
|
-
"
|
|
444
|
-
" for(
|
|
445
|
-
"
|
|
410
|
+
" const radios = document.querySelectorAll('input[name=\"baseProfile\"]');",
|
|
411
|
+
" for(const radio of radios) {",
|
|
412
|
+
" radio.addEventListener('change', function() { s.baseProfile = this.value; });",
|
|
446
413
|
" }",
|
|
447
414
|
// Step 2: strategy radio and strategy field inputs. Text and boolean fields are bound by data-field attribute. Strategy radio triggers a re-render to show
|
|
448
415
|
// the selected strategy's fields.
|
|
449
|
-
"
|
|
450
|
-
" for(
|
|
451
|
-
"
|
|
416
|
+
" const stratRadios = document.querySelectorAll('input[name=\"strategy\"]');",
|
|
417
|
+
" for(const stratRadio of stratRadios) {",
|
|
418
|
+
" stratRadio.addEventListener('change', function() { s.strategy = this.value; renderProfileStep(profileWizard.getStep()); });",
|
|
452
419
|
" }",
|
|
453
|
-
"
|
|
454
|
-
" for(
|
|
455
|
-
"
|
|
420
|
+
" const sfTextInputs = document.querySelectorAll('#wizard-content input[type=\"text\"][data-field]');",
|
|
421
|
+
" for(const sfTextInput of sfTextInputs) {",
|
|
422
|
+
" sfTextInput.addEventListener('input', function() { s.strategyFields[this.getAttribute('data-field')] = this.value; });",
|
|
456
423
|
" }",
|
|
457
|
-
"
|
|
458
|
-
" for(
|
|
459
|
-
"
|
|
424
|
+
" const sfCheckInputs = document.querySelectorAll('#wizard-content input[type=\"checkbox\"][data-field]');",
|
|
425
|
+
" for(const sfCheckInput of sfCheckInputs) {",
|
|
426
|
+
" sfCheckInput.addEventListener('change', function() {",
|
|
460
427
|
" if(this.checked) { s.strategyFields[this.getAttribute('data-field')] = true; }",
|
|
461
428
|
" else { delete s.strategyFields[this.getAttribute('data-field')]; }",
|
|
462
429
|
" });",
|
|
463
430
|
" }",
|
|
464
431
|
// Step 3: flag checkboxes and hide selector.
|
|
465
|
-
"
|
|
466
|
-
" for(
|
|
467
|
-
"
|
|
468
|
-
"
|
|
432
|
+
" const flagCbs = document.querySelectorAll('input[name^=\"flag-\"]');",
|
|
433
|
+
" for(const flagCb of flagCbs) {",
|
|
434
|
+
" flagCb.addEventListener('change', function() {",
|
|
435
|
+
" const flagName = this.name.replace('flag-', '');",
|
|
469
436
|
" if(this.checked) { s.flags[flagName] = true; } else { delete s.flags[flagName]; }",
|
|
470
437
|
" });",
|
|
471
438
|
" }",
|
|
472
|
-
"
|
|
439
|
+
" const hideInput = document.getElementById('wizard-hide-selector');",
|
|
473
440
|
" if(hideInput) hideInput.addEventListener('input', function() { s.hideSelector = this.value; });",
|
|
474
|
-
// Step 4: add-domain button and domain inputs with auto-fill for
|
|
475
|
-
|
|
441
|
+
// Step 4: add-domain button and domain inputs with auto-fill for service tag. Per-iteration let bindings from for...of.entries() capture the correct index
|
|
442
|
+
// in each closure — no IIFE wrapper needed.
|
|
443
|
+
" const addDomainBtn = document.getElementById('wizard-add-domain-btn');",
|
|
476
444
|
" if(addDomainBtn) addDomainBtn.addEventListener('click', addWizardDomain);",
|
|
477
|
-
"
|
|
478
|
-
"
|
|
479
|
-
" (
|
|
480
|
-
"
|
|
481
|
-
"
|
|
482
|
-
"
|
|
483
|
-
"
|
|
484
|
-
"
|
|
485
|
-
"
|
|
486
|
-
"
|
|
487
|
-
"
|
|
488
|
-
"
|
|
489
|
-
" if(tagInput) tagInput.value = tag;",
|
|
490
|
-
" });",
|
|
491
|
-
" if(tagInput) tagInput.addEventListener('input', function() { s.domains[idx].providerTag = this.value; });",
|
|
492
|
-
" })(i);",
|
|
445
|
+
" for(const [ idx, row ] of document.querySelectorAll('.wizard-domain-row').entries()) {",
|
|
446
|
+
" const domInput = row.querySelector('.domain-input');",
|
|
447
|
+
" const provInput = row.querySelector('.provider-input');",
|
|
448
|
+
" const tagInput = row.querySelector('.provider-tag-input');",
|
|
449
|
+
" if(domInput) domInput.addEventListener('input', function() { s.domains[idx].domain = this.value; });",
|
|
450
|
+
" if(provInput) provInput.addEventListener('input', function() {",
|
|
451
|
+
" s.domains[idx].service = this.value;",
|
|
452
|
+
" const tag = autoServiceTag(this.value);",
|
|
453
|
+
" s.domains[idx].serviceTag = tag;",
|
|
454
|
+
" if(tagInput) tagInput.value = tag;",
|
|
455
|
+
" });",
|
|
456
|
+
" if(tagInput) tagInput.addEventListener('input', function() { s.domains[idx].serviceTag = this.value; });",
|
|
493
457
|
" }",
|
|
494
458
|
// Step 5: description textarea.
|
|
495
|
-
"
|
|
459
|
+
" const descInput = document.getElementById('wizard-description');",
|
|
496
460
|
" if(descInput) descInput.addEventListener('input', function() { s.description = this.value; });",
|
|
497
461
|
" }",
|
|
498
462
|
// Validate the current profile wizard step. Returns an error message or empty string if valid.
|
|
499
463
|
" function validateProfileStep(step) {",
|
|
500
|
-
"
|
|
464
|
+
" const s = profileWizard.state;",
|
|
501
465
|
" if(step === 1) {",
|
|
502
466
|
" if(!s.editMode) {",
|
|
503
467
|
" if(!s.profileName.trim()) return 'Profile name is required.';",
|
|
@@ -508,10 +472,9 @@ export function generateChannelsSubtabScript() {
|
|
|
508
472
|
" if(!s.baseProfile) return 'Select a base profile.';",
|
|
509
473
|
" }",
|
|
510
474
|
" if(step === 2) {",
|
|
511
|
-
"
|
|
475
|
+
" const valStrat = window.__wizardStrategies.find((st) => st.id === s.strategy);",
|
|
512
476
|
" if(valStrat) {",
|
|
513
|
-
" for(
|
|
514
|
-
" var vf = valStrat.fields[vi];",
|
|
477
|
+
" for(const vf of valStrat.fields) {",
|
|
515
478
|
" if(vf.required && (vf.type === 'text') && !(s.strategyFields[vf.id] || '').trim()) {",
|
|
516
479
|
" return vf.label.replace(/ \\(CSS\\)/, '') + ' is required for the ' + valStrat.name + ' strategy.';",
|
|
517
480
|
" }",
|
|
@@ -519,63 +482,61 @@ export function generateChannelsSubtabScript() {
|
|
|
519
482
|
" }",
|
|
520
483
|
" }",
|
|
521
484
|
" if(step === 4) {",
|
|
522
|
-
"
|
|
485
|
+
" const hasDomain = s.domains.some((d) => d.domain.trim());",
|
|
523
486
|
" if(!hasDomain) return 'At least one domain is required.';",
|
|
524
487
|
" }",
|
|
525
488
|
" return '';",
|
|
526
489
|
" }",
|
|
527
490
|
// Open the profile builder wizard and reset state for a new profile.
|
|
528
|
-
" window.openWizard =
|
|
491
|
+
" window.openWizard = () => {",
|
|
529
492
|
" profileWizard.state = {",
|
|
530
493
|
" editKey: null, editMode: false, profileName: '', baseProfile: '', strategy: 'none',",
|
|
531
494
|
" strategyFields: {}, flags: {}, hideSelector: '',",
|
|
532
|
-
" domains: [{ domain: '',
|
|
495
|
+
" domains: [{ domain: '', service: '', serviceTag: '' }], description: ''",
|
|
533
496
|
" };",
|
|
534
|
-
" profileWizard.open({ title: 'New
|
|
497
|
+
" profileWizard.open({ title: 'New Service Profile' });",
|
|
535
498
|
" };",
|
|
536
499
|
// Add another domain row to step 4 and re-render.
|
|
537
500
|
" function addWizardDomain() {",
|
|
538
|
-
" profileWizard.state.domains.push({ domain: '',
|
|
501
|
+
" profileWizard.state.domains.push({ domain: '', service: '', serviceTag: '' });",
|
|
539
502
|
" renderProfileStep(profileWizard.getStep());",
|
|
540
503
|
" }",
|
|
541
504
|
// Save the profile to the server.
|
|
542
|
-
" window.saveProfile = async
|
|
543
|
-
"
|
|
544
|
-
"
|
|
505
|
+
" window.saveProfile = async (andTest) => {",
|
|
506
|
+
" const s = profileWizard.state;",
|
|
507
|
+
" const profile = { extends: s.baseProfile };",
|
|
545
508
|
" if(s.strategy !== 'none') {",
|
|
546
|
-
"
|
|
547
|
-
"
|
|
509
|
+
" const cs = { strategy: s.strategy };",
|
|
510
|
+
" const saveStrat = window.__wizardStrategies.find((st) => st.id === s.strategy);",
|
|
548
511
|
" if(saveStrat) {",
|
|
549
|
-
" for(
|
|
550
|
-
"
|
|
551
|
-
" var sfVal = s.strategyFields[sf.id];",
|
|
512
|
+
" for(const sf of saveStrat.fields) {",
|
|
513
|
+
" const sfVal = s.strategyFields[sf.id];",
|
|
552
514
|
" if(sf.type === 'text' && sfVal) { cs[sf.id] = sfVal; }",
|
|
553
515
|
" else if(sf.type === 'boolean' && sfVal) { cs[sf.id] = true; }",
|
|
554
516
|
" }",
|
|
555
517
|
" }",
|
|
556
518
|
" profile.channelSelection = cs;",
|
|
557
519
|
" }",
|
|
558
|
-
"
|
|
559
|
-
" for(
|
|
520
|
+
" const flagKeys = Object.keys(s.flags).filter((k) => s.flags[k]);",
|
|
521
|
+
" for(const fk of flagKeys) { profile[fk] = true; }",
|
|
560
522
|
" if(s.hideSelector) profile.hideSelector = s.hideSelector;",
|
|
561
523
|
" if(s.description) profile.description = s.description;",
|
|
562
|
-
"
|
|
563
|
-
" for(
|
|
564
|
-
" var d = s.domains[i];",
|
|
524
|
+
" const domains = {};",
|
|
525
|
+
" for(const d of s.domains) {",
|
|
565
526
|
" if(d.domain.trim()) {",
|
|
566
527
|
" domains[d.domain.trim()] = { profile: s.profileName };",
|
|
567
|
-
" if(d.
|
|
568
|
-
" if(d.
|
|
528
|
+
" if(d.service.trim()) domains[d.domain.trim()].service = d.service.trim();",
|
|
529
|
+
" if(d.serviceTag.trim()) domains[d.domain.trim()].serviceTag = d.serviceTag.trim();",
|
|
569
530
|
" }",
|
|
570
531
|
" }",
|
|
571
|
-
"
|
|
532
|
+
" const body = { domains: domains, key: s.profileName, profile: profile };",
|
|
572
533
|
" try {",
|
|
573
|
-
"
|
|
574
|
-
"
|
|
534
|
+
" const res = await fetch('/config/profiles', {",
|
|
535
|
+
" body: JSON.stringify(body),",
|
|
575
536
|
" headers: { 'Content-Type': 'application/json' },",
|
|
576
|
-
"
|
|
537
|
+
" method: 'POST'",
|
|
577
538
|
" });",
|
|
578
|
-
"
|
|
539
|
+
" const data = await res.json();",
|
|
579
540
|
" if(data.success) {",
|
|
580
541
|
" profileWizard.close();",
|
|
581
542
|
" if(data.panelHtml) { applyProfilePanel(data.panelHtml); }",
|
|
@@ -586,27 +547,27 @@ export function generateChannelsSubtabScript() {
|
|
|
586
547
|
" profileWizard.setError(data.error || 'Failed to save profile.');",
|
|
587
548
|
" }",
|
|
588
549
|
" } catch(err) {",
|
|
589
|
-
" console.error('Profile save
|
|
550
|
+
" console.error('Profile save request failed.', err);",
|
|
590
551
|
" profileWizard.setError('Request failed.');",
|
|
591
552
|
" }",
|
|
592
553
|
" };",
|
|
593
554
|
// Test flow: open a browser to a URL with the profile applied and allow selector checking.
|
|
594
|
-
"
|
|
555
|
+
" let testSelectors = {};",
|
|
595
556
|
// Start a profile test by opening the browser to a URL.
|
|
596
|
-
" window.startProfileTest = async
|
|
597
|
-
"
|
|
557
|
+
" window.startProfileTest = async (url) => {",
|
|
558
|
+
" const s = profileWizard.state;",
|
|
598
559
|
" testSelectors = {};",
|
|
599
560
|
" if(s.strategy !== 'none' && s.strategyFields.matchSelector) testSelectors.matchSelector = s.strategyFields.matchSelector;",
|
|
600
561
|
" if(s.strategyFields.playSelector) testSelectors.playSelector = s.strategyFields.playSelector;",
|
|
601
562
|
" if(s.hideSelector) testSelectors.hideSelector = s.hideSelector;",
|
|
602
|
-
"
|
|
563
|
+
" const testUrl = url.includes('://') ? url : 'https://' + url;",
|
|
603
564
|
" try {",
|
|
604
|
-
"
|
|
605
|
-
"
|
|
565
|
+
" const res = await fetch('/config/profiles/test', {",
|
|
566
|
+
" body: JSON.stringify({ url: testUrl }),",
|
|
606
567
|
" headers: { 'Content-Type': 'application/json' },",
|
|
607
|
-
"
|
|
568
|
+
" method: 'POST'",
|
|
608
569
|
" });",
|
|
609
|
-
"
|
|
570
|
+
" const data = await res.json();",
|
|
610
571
|
" if(data.success) {",
|
|
611
572
|
" document.getElementById('test-modal').style.display = 'flex';",
|
|
612
573
|
" document.getElementById('test-selector-results').style.display = 'none';",
|
|
@@ -615,31 +576,30 @@ export function generateChannelsSubtabScript() {
|
|
|
615
576
|
" } else {",
|
|
616
577
|
" showToast('Failed to start test: ' + (data.error || 'Unknown error.'), 'error');",
|
|
617
578
|
" }",
|
|
618
|
-
" } catch(e) { showToast('Failed to start test.', 'error'); }",
|
|
579
|
+
" } catch(e) { console.error('Profile test failed to start.', e); showToast('Failed to start test.', 'error'); }",
|
|
619
580
|
" };",
|
|
620
581
|
// Check CSS selectors against the live page.
|
|
621
|
-
" window.checkSelectors = async
|
|
582
|
+
" window.checkSelectors = async () => {",
|
|
622
583
|
" if(Object.keys(testSelectors).length === 0) return;",
|
|
623
584
|
" try {",
|
|
624
|
-
"
|
|
625
|
-
"
|
|
585
|
+
" const res = await fetch('/config/profiles/test/check', {",
|
|
586
|
+
" body: JSON.stringify({ selectors: testSelectors }),",
|
|
626
587
|
" headers: { 'Content-Type': 'application/json' },",
|
|
627
|
-
"
|
|
588
|
+
" method: 'POST'",
|
|
628
589
|
" });",
|
|
629
|
-
"
|
|
630
|
-
"
|
|
590
|
+
" const data = await res.json();",
|
|
591
|
+
" const resultsDiv = document.getElementById('test-selector-results');",
|
|
631
592
|
" if(!data.success || !data.results) {",
|
|
632
593
|
" resultsDiv.innerHTML = '<p style=\"color: var(--status-error-text);\">Check failed.</p>';",
|
|
633
594
|
" resultsDiv.style.display = 'block';",
|
|
634
595
|
" return;",
|
|
635
596
|
" }",
|
|
636
|
-
"
|
|
637
|
-
"
|
|
638
|
-
" for(
|
|
639
|
-
"
|
|
640
|
-
"
|
|
641
|
-
"
|
|
642
|
-
" var text = !r.valid ? 'Invalid selector' : r.count + ' element(s)';",
|
|
597
|
+
" let html = '<table style=\"width: 100%; font-size: 13px; margin: 12px 0;\">';",
|
|
598
|
+
" const names = Object.keys(data.results);",
|
|
599
|
+
" for(const name of names) {",
|
|
600
|
+
" const r = data.results[name];",
|
|
601
|
+
" const color = r.valid && r.count > 0 ? 'var(--status-success-text)' : 'var(--status-error-text)';",
|
|
602
|
+
" const text = !r.valid ? 'Invalid selector' : r.count + ' element(s)';",
|
|
643
603
|
" html += '<tr><td style=\"padding: 4px 8px;\">' + esc(name) + '</td>';",
|
|
644
604
|
" html += '<td style=\"padding: 4px 8px; color: ' + color + ';\">' + text + '</td></tr>';",
|
|
645
605
|
" }",
|
|
@@ -647,59 +607,59 @@ export function generateChannelsSubtabScript() {
|
|
|
647
607
|
" resultsDiv.innerHTML = html;",
|
|
648
608
|
" resultsDiv.style.display = 'block';",
|
|
649
609
|
" } catch(e) {",
|
|
650
|
-
"
|
|
610
|
+
" const resultsDiv = document.getElementById('test-selector-results');",
|
|
651
611
|
" resultsDiv.innerHTML = '<p style=\"color: var(--status-error-text);\">Check request failed.</p>';",
|
|
652
612
|
" resultsDiv.style.display = 'block';",
|
|
653
613
|
" }",
|
|
654
614
|
" };",
|
|
655
615
|
// End the test flow and close the test dialog.
|
|
656
|
-
" window.endProfileTest = async
|
|
616
|
+
" window.endProfileTest = async () => {",
|
|
657
617
|
" try {",
|
|
658
|
-
"
|
|
659
|
-
"
|
|
618
|
+
" const res = await fetch('/config/profiles/test/done', { method: 'POST' });",
|
|
619
|
+
" const data = await res.json();",
|
|
660
620
|
" document.getElementById('test-modal').style.display = 'none';",
|
|
661
621
|
" if(data.panelHtml) { applyProfilePanel(data.panelHtml); }",
|
|
662
622
|
" } catch(err) {",
|
|
663
|
-
" console.error('
|
|
623
|
+
" console.error('Profile test end request failed.', err);",
|
|
664
624
|
" document.getElementById('test-modal').style.display = 'none';",
|
|
665
625
|
" }",
|
|
666
626
|
" };",
|
|
667
|
-
// Read embedded JSON data for the browse modal.
|
|
627
|
+
// Read embedded JSON data for the browse modal. Service list and guide URLs are embedded server-side. Channel state (lineup matching) is provided by
|
|
668
628
|
// the annotated discovery response, not by embedded data.
|
|
669
|
-
"
|
|
670
|
-
"
|
|
629
|
+
" let browseServices = [];",
|
|
630
|
+
" let browseGuideUrls = {};",
|
|
671
631
|
" try {",
|
|
672
|
-
"
|
|
673
|
-
" if(pEl)
|
|
674
|
-
"
|
|
632
|
+
" const pEl = document.getElementById('browse-services-data');",
|
|
633
|
+
" if(pEl) browseServices = JSON.parse(pEl.textContent || '[]');",
|
|
634
|
+
" const gEl = document.getElementById('browse-guide-urls-data');",
|
|
675
635
|
" if(gEl) browseGuideUrls = JSON.parse(gEl.textContent || '{}');",
|
|
676
|
-
" } catch(e) {}",
|
|
677
|
-
// Browse Channels wizard. Two steps:
|
|
678
|
-
// and indicator updates. The state object tracks the selected
|
|
679
|
-
"
|
|
636
|
+
" } catch(e) { console.warn('Embedded browse services data failed to parse.', e); }",
|
|
637
|
+
// Browse Channels wizard. Two steps: Service (picker grid) and Channels (discovery + channel list). Uses the shared wizard controller for step navigation
|
|
638
|
+
// and indicator updates. The state object tracks the selected service slug and label, and the filtered service list for the current session.
|
|
639
|
+
" const browseWizard = createWizardController({",
|
|
680
640
|
" contentId: 'browse-content',",
|
|
681
641
|
" modalId: 'browse-modal',",
|
|
682
642
|
" onRender: renderBrowseStep,",
|
|
683
|
-
" onValidate:
|
|
643
|
+
" onValidate: (step) => (step === 1 && !browseWizard.state.slug) ? 'Select a service.' : '',",
|
|
684
644
|
" stepCount: 2,",
|
|
685
645
|
" titleId: 'browse-title'",
|
|
686
646
|
" });",
|
|
687
|
-
// Open the browse modal. If only one
|
|
688
|
-
" window.openBrowseModal =
|
|
689
|
-
// Determine which
|
|
690
|
-
"
|
|
691
|
-
"
|
|
692
|
-
" for(
|
|
693
|
-
"
|
|
647
|
+
// Open the browse modal. If only one service is available (or in the filter), skip step 1 and open directly on step 2 with the service pre-selected.
|
|
648
|
+
" window.openBrowseModal = () => {",
|
|
649
|
+
// Determine which services to show. Filter to services that are enabled in the service filter if a filter is active.
|
|
650
|
+
" const enabledFilter = [];",
|
|
651
|
+
" const chips = document.querySelectorAll('.provider-chip');",
|
|
652
|
+
" for(const chip of chips) {",
|
|
653
|
+
" const tag = chip.getAttribute('data-tag');",
|
|
694
654
|
" if(tag) enabledFilter.push(tag);",
|
|
695
655
|
" }",
|
|
696
|
-
"
|
|
656
|
+
" let available = browseServices;",
|
|
697
657
|
" if(enabledFilter.length > 0) {",
|
|
698
|
-
" available =
|
|
658
|
+
" available = browseServices.filter((p) => enabledFilter.includes(p.slug));",
|
|
699
659
|
" }",
|
|
700
|
-
// If no discoverable
|
|
701
|
-
" if(available.length === 0) available =
|
|
702
|
-
// If exactly one
|
|
660
|
+
// If no discoverable services match the filter, show all services.
|
|
661
|
+
" if(available.length === 0) available = browseServices;",
|
|
662
|
+
// If exactly one service is available, skip the picker and go straight to channel discovery on step 2.
|
|
703
663
|
" if(available.length === 1) {",
|
|
704
664
|
" browseWizard.state = { available: available, label: available[0].label, slug: available[0].slug };",
|
|
705
665
|
" browseWizard.open({ highestStep: 2, step: 2 });",
|
|
@@ -708,57 +668,56 @@ export function generateChannelsSubtabScript() {
|
|
|
708
668
|
" browseWizard.state = { available: available, label: null, slug: null };",
|
|
709
669
|
" browseWizard.open();",
|
|
710
670
|
" };",
|
|
711
|
-
// Select a
|
|
712
|
-
" function
|
|
671
|
+
// Select a service from the picker grid and advance to step 2.
|
|
672
|
+
" function selectBrowseService(slug, label) {",
|
|
713
673
|
" browseWizard.state.slug = slug;",
|
|
714
674
|
" browseWizard.state.label = label;",
|
|
715
675
|
" browseWizard.next();",
|
|
716
676
|
" }",
|
|
717
|
-
// Render the current browse step. Step 1:
|
|
677
|
+
// Render the current browse step. Step 1: service picker grid. Step 2: channel discovery and list.
|
|
718
678
|
" function renderBrowseStep(step) {",
|
|
719
|
-
"
|
|
720
|
-
// Show/hide navigation buttons. Back is only available on step 2 when there are multiple
|
|
679
|
+
" const content = document.getElementById('browse-content');",
|
|
680
|
+
// Show/hide navigation buttons. Back is only available on step 2 when there are multiple services.
|
|
721
681
|
" if(step > 1 && browseWizard.state.available.length > 1) { browseWizard.show('browse-back'); }",
|
|
722
682
|
" else { browseWizard.hide('browse-back'); }",
|
|
723
683
|
// Reset title and apply button on each render.
|
|
724
684
|
" browseWizard.setTitle('Browse Channels');",
|
|
725
685
|
" browseWizard.hide('browse-add-btn');",
|
|
726
|
-
// Step 1:
|
|
727
|
-
//
|
|
686
|
+
// Step 1: Service picker grid. Clear the service selection so the user must pick again (prevents the step indicator from jumping to step 2 with a stale
|
|
687
|
+
// service after navigating back).
|
|
728
688
|
" if(step === 1) {",
|
|
729
689
|
" browseWizard.state.slug = null;",
|
|
730
690
|
" browseWizard.state.label = null;",
|
|
731
|
-
"
|
|
732
|
-
"
|
|
691
|
+
" const available = browseWizard.state.available;",
|
|
692
|
+
" let html = '<p class=\"wizard-hint\">Select a service to see its available channels.</p>';",
|
|
733
693
|
" html += '<div class=\"wizard-provider-grid\">';",
|
|
734
|
-
" for(
|
|
735
|
-
" var p = available[j];",
|
|
694
|
+
" for(const p of available) {",
|
|
736
695
|
" html += '<div class=\"wizard-provider-card\" data-slug=\"' + esc(p.slug) + '\" data-label=\"' + esc(p.label) + '\">';",
|
|
737
|
-
" html +=
|
|
696
|
+
" html += serviceIconHtml(p.domain, esc(p.label), 'provider-icon', 'provider-icon-text', 'both', p.iconUrl || '');",
|
|
738
697
|
" html += '</div>';",
|
|
739
698
|
" }",
|
|
740
699
|
" html += '</div>';",
|
|
741
700
|
" content.innerHTML = html;",
|
|
742
|
-
// Attach click handlers to
|
|
743
|
-
"
|
|
744
|
-
" for(
|
|
745
|
-
"
|
|
746
|
-
"
|
|
701
|
+
// Attach click handlers to service cards programmatically. The handler reads slug and label from data attributes, avoiding inline onclick and global scope.
|
|
702
|
+
" const cards = content.querySelectorAll('.wizard-provider-card');",
|
|
703
|
+
" for(const card of cards) {",
|
|
704
|
+
" card.addEventListener('click', function() {",
|
|
705
|
+
" selectBrowseService(this.getAttribute('data-slug'), this.getAttribute('data-label'));",
|
|
747
706
|
" });",
|
|
748
707
|
" }",
|
|
749
708
|
" return;",
|
|
750
709
|
" }",
|
|
751
710
|
// Step 2: Fetch discovered channels and render the channel list.
|
|
752
|
-
"
|
|
753
|
-
"
|
|
711
|
+
" const slug = browseWizard.state.slug;",
|
|
712
|
+
" const label = browseWizard.state.label;",
|
|
754
713
|
// Show a spinner while fetching channels.
|
|
755
714
|
" content.innerHTML = '<div class=\"wizard-spinner\">Discovering channels from ' + esc(label) + '...</div>';",
|
|
756
|
-
// Fetch discovered channels from the
|
|
757
|
-
" (async
|
|
715
|
+
// Fetch discovered channels from the service endpoint.
|
|
716
|
+
" (async () => {",
|
|
758
717
|
" try {",
|
|
759
|
-
"
|
|
760
|
-
" if(!r.ok) {
|
|
761
|
-
"
|
|
718
|
+
" const r = await fetch('/services/' + encodeURIComponent(slug) + '/channels?lineup=true');",
|
|
719
|
+
" if(!r.ok) { const d = await r.json(); throw new Error(d.error || 'Discovery failed.'); }",
|
|
720
|
+
" const channels = await r.json();",
|
|
762
721
|
" if(browseWizard.state.slug !== slug) return;",
|
|
763
722
|
" renderBrowseChannelList(channels, slug, label);",
|
|
764
723
|
" } catch(err) {",
|
|
@@ -769,7 +728,7 @@ export function generateChannelsSubtabScript() {
|
|
|
769
728
|
" })();",
|
|
770
729
|
" }",
|
|
771
730
|
// Render the channel list with three-state checkboxes. Each discovered channel carries an optional lineup field from the server's annotation layer.
|
|
772
|
-
// When lineup is present, the channel exists in the user's lineup. When lineup.currentTag matches the browsed
|
|
731
|
+
// When lineup is present, the channel exists in the user's lineup. When lineup.currentTag matches the browsed service slug, the channel is "current"
|
|
773
732
|
// (checked). When lineup is present but the tag differs, the channel is "switch" (indeterminate). When lineup is absent, the channel is "new" (empty).
|
|
774
733
|
" function renderBrowseChannelList(channels, slug, label) {",
|
|
775
734
|
" if(!channels || channels.length === 0) {",
|
|
@@ -779,8 +738,8 @@ export function generateChannelsSubtabScript() {
|
|
|
779
738
|
" return;",
|
|
780
739
|
" }",
|
|
781
740
|
// Build the channel list HTML with hint, legend, search filter, and three-state checkboxes.
|
|
782
|
-
"
|
|
783
|
-
" html += '<p class=\"wizard-hint\">Check to add or switch to this
|
|
741
|
+
" let html = '';",
|
|
742
|
+
" html += '<p class=\"wizard-hint\">Check to add or switch to this service. Uncheck to remove. Changes take effect when you click Apply.</p>';",
|
|
784
743
|
" html += '<div class=\"browse-toolbar\">';",
|
|
785
744
|
" html += '<label class=\"browse-select-all\">';",
|
|
786
745
|
" html += '<input type=\"checkbox\" id=\"browse-select-all-cb\">';",
|
|
@@ -789,18 +748,17 @@ export function generateChannelsSubtabScript() {
|
|
|
789
748
|
" html += '</div>';",
|
|
790
749
|
" html += '<div class=\"browse-legend\">';",
|
|
791
750
|
" html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" disabled> New channel</span>';",
|
|
792
|
-
" html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" class=\"browse-legend-indeterminate\" disabled> Available via another
|
|
793
|
-
" html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" checked disabled> Active on this
|
|
751
|
+
" html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" class=\"browse-legend-indeterminate\" disabled> Available via another service</span>';",
|
|
752
|
+
" html += '<span class=\"browse-legend-item\"><input type=\"checkbox\" checked disabled> Active on this service</span>';",
|
|
794
753
|
" html += '</div>';",
|
|
795
754
|
" html += '<div class=\"browse-channel-list\" id=\"browse-channel-list\">';",
|
|
796
755
|
// Render each discovered channel. The lineup field from the server annotation determines the checkbox state.
|
|
797
|
-
" for(
|
|
798
|
-
"
|
|
799
|
-
" var lu = ch.lineup;",
|
|
756
|
+
" for(const ch of channels) {",
|
|
757
|
+
" const lu = ch.lineup;",
|
|
800
758
|
// Determine the channel's state from the server-provided lineup annotation. Four states: new (no lineup data), disabled (predefined but disabled),
|
|
801
|
-
// switch (exists via a different
|
|
802
|
-
"
|
|
803
|
-
"
|
|
759
|
+
// switch (exists via a different service), current (already using this service).
|
|
760
|
+
" let state = 'new';",
|
|
761
|
+
" let stateLabel = '';",
|
|
804
762
|
" if(lu) {",
|
|
805
763
|
" if(!lu.enabled) {",
|
|
806
764
|
" state = 'disabled';",
|
|
@@ -810,16 +768,16 @@ export function generateChannelsSubtabScript() {
|
|
|
810
768
|
" stateLabel = lu.source === 'predefined' ? 'Predefined' : 'User channel';",
|
|
811
769
|
" } else {",
|
|
812
770
|
" state = 'switch';",
|
|
813
|
-
" stateLabel = 'via ' + lu.
|
|
771
|
+
" stateLabel = 'via ' + lu.currentService;",
|
|
814
772
|
" }",
|
|
815
773
|
" }",
|
|
816
|
-
"
|
|
817
|
-
"
|
|
818
|
-
" html += '<label class=\"' + itemClass + '\" data-name=\"' + esc(searchName).
|
|
774
|
+
" const itemClass = 'browse-channel-item browse-state-' + state;",
|
|
775
|
+
" const searchName = ((lu && lu.displayName) ? lu.displayName : ch.name).toLowerCase();",
|
|
776
|
+
" html += '<label class=\"' + itemClass + '\" data-name=\"' + esc(searchName).replaceAll('\"', '"') + '\">';",
|
|
819
777
|
// Checkbox with data attributes for state tracking and submission.
|
|
820
778
|
" html += '<input type=\"checkbox\" class=\"browse-channel-cb\"';",
|
|
821
|
-
" html += ' data-name=\"' + esc(ch.name).
|
|
822
|
-
" html += ' data-selector=\"' + esc(ch.channelSelector).
|
|
779
|
+
" html += ' data-name=\"' + esc(ch.name).replaceAll('\"', '"') + '\"';",
|
|
780
|
+
" html += ' data-selector=\"' + esc(ch.channelSelector).replaceAll('\"', '"') + '\"';",
|
|
823
781
|
" html += ' data-original=\"' + state + '\"';",
|
|
824
782
|
" if(lu) html += ' data-canonical=\"' + esc(lu.canonicalKey) + '\" data-has-alternatives=\"' + (lu.hasAlternatives ? 'true' : 'false') + '\"';",
|
|
825
783
|
" if(ch.stationId) html += ' data-station-id=\"' + esc(ch.stationId) + '\"';",
|
|
@@ -827,7 +785,7 @@ export function generateChannelsSubtabScript() {
|
|
|
827
785
|
" html += '>';",
|
|
828
786
|
// Channel name with optional logo and affiliate. When the channel matches a lineup entry, use the predefined display name so the browse modal shows the
|
|
829
787
|
// same names as the channels table. For new channels (no lineup match), use the raw discovery name.
|
|
830
|
-
"
|
|
788
|
+
" const channelName = (lu && lu.displayName) ? lu.displayName : ch.name;",
|
|
831
789
|
" html += '<span class=\"browse-channel-name\">';",
|
|
832
790
|
" html += channelDisplayHtml(lu ? lu.logoUrl || '' : '', esc(channelName), 'browse-channel-logo', 'browse-channel-text', 'both');",
|
|
833
791
|
" if(ch.affiliate) html += ' <span class=\"browse-affiliate\">(' + esc(ch.affiliate) + ')</span>';",
|
|
@@ -842,21 +800,21 @@ export function generateChannelsSubtabScript() {
|
|
|
842
800
|
" html += '</div>';",
|
|
843
801
|
" document.getElementById('browse-content').innerHTML = html;",
|
|
844
802
|
" browseWizard.setTitle('Browse Channels <span class=\"browse-header-count\">(' + esc(label) + ' \\u2014 ' + channels.length + ' channels)</span>');",
|
|
845
|
-
// Set indeterminate state on checkboxes for channels that exist via a different
|
|
803
|
+
// Set indeterminate state on checkboxes for channels that exist via a different service. The indeterminate property cannot be set via HTML attributes —
|
|
846
804
|
// it must be set via JavaScript after the elements are in the DOM.
|
|
847
|
-
"
|
|
848
|
-
" for(
|
|
805
|
+
" const switchCbs = document.querySelectorAll('.browse-channel-cb[data-original=\"switch\"]');",
|
|
806
|
+
" for(const sc of switchCbs) sc.indeterminate = true;",
|
|
849
807
|
// Set the legend's indeterminate checkbox visual.
|
|
850
|
-
"
|
|
808
|
+
" const legendInd = document.querySelector('.browse-legend-indeterminate');",
|
|
851
809
|
" if(legendInd) legendInd.indeterminate = true;",
|
|
852
810
|
// Attach event handlers programmatically to keep them scoped to the IIFE closure.
|
|
853
|
-
"
|
|
811
|
+
" const selectAllCb = document.getElementById('browse-select-all-cb');",
|
|
854
812
|
" if(selectAllCb) selectAllCb.addEventListener('change', function() { toggleBrowseSelectAll(this.checked); });",
|
|
855
|
-
"
|
|
813
|
+
" const searchInput = document.getElementById('browse-search');",
|
|
856
814
|
" if(searchInput) searchInput.addEventListener('input', filterBrowseChannels);",
|
|
857
|
-
"
|
|
858
|
-
" for(
|
|
859
|
-
"
|
|
815
|
+
" const channelCbs = document.querySelectorAll('.browse-channel-cb');",
|
|
816
|
+
" for(const cb of channelCbs) {",
|
|
817
|
+
" cb.addEventListener('change', function() { onBrowseCheckboxChange(this); });",
|
|
860
818
|
" }",
|
|
861
819
|
// Show the apply button and update counts.
|
|
862
820
|
" browseWizard.show('browse-add-btn');",
|
|
@@ -867,8 +825,8 @@ export function generateChannelsSubtabScript() {
|
|
|
867
825
|
// disabled channels stay empty on uncheck.
|
|
868
826
|
" function onBrowseCheckboxChange(cb) {",
|
|
869
827
|
" if(!cb.checked) {",
|
|
870
|
-
"
|
|
871
|
-
"
|
|
828
|
+
" const original = cb.getAttribute('data-original');",
|
|
829
|
+
" const hasAlt = cb.getAttribute('data-has-alternatives') === 'true';",
|
|
872
830
|
" if((original === 'switch') || ((original === 'current') && hasAlt)) {",
|
|
873
831
|
" cb.indeterminate = true;",
|
|
874
832
|
" }",
|
|
@@ -876,19 +834,18 @@ export function generateChannelsSubtabScript() {
|
|
|
876
834
|
" updateBrowseCount();",
|
|
877
835
|
" }",
|
|
878
836
|
// Toggle all actionable checkboxes. Select-all checks all channels and clears indeterminate. Deselect-all unchecks new and disabled channels, and restores
|
|
879
|
-
// switch channels and current channels with alternatives to indeterminate (indicating the channel exists but is not selected for this
|
|
837
|
+
// switch channels and current channels with alternatives to indeterminate (indicating the channel exists but is not selected for this service).
|
|
880
838
|
" function toggleBrowseSelectAll(checked) {",
|
|
881
|
-
"
|
|
882
|
-
" for(
|
|
883
|
-
"
|
|
884
|
-
"
|
|
885
|
-
" var row = cb.closest('.browse-channel-item');",
|
|
839
|
+
" const cbs = document.querySelectorAll('.browse-channel-cb');",
|
|
840
|
+
" for(const cb of cbs) {",
|
|
841
|
+
" const original = cb.getAttribute('data-original');",
|
|
842
|
+
" const row = cb.closest('.browse-channel-item');",
|
|
886
843
|
" if(row && row.style.display === 'none') continue;",
|
|
887
844
|
" if(checked) {",
|
|
888
845
|
" cb.checked = true;",
|
|
889
846
|
" cb.indeterminate = false;",
|
|
890
847
|
" } else {",
|
|
891
|
-
"
|
|
848
|
+
" const hasAlt = cb.getAttribute('data-has-alternatives') === 'true';",
|
|
892
849
|
" cb.checked = false;",
|
|
893
850
|
" cb.indeterminate = (original === 'switch') || ((original === 'current') && hasAlt);",
|
|
894
851
|
" }",
|
|
@@ -897,143 +854,142 @@ export function generateChannelsSubtabScript() {
|
|
|
897
854
|
" }",
|
|
898
855
|
// Filter the channel list by search text. Hides rows whose channel name does not contain the search term.
|
|
899
856
|
" function filterBrowseChannels() {",
|
|
900
|
-
"
|
|
901
|
-
"
|
|
902
|
-
" for(
|
|
903
|
-
"
|
|
904
|
-
"
|
|
857
|
+
" const query = (document.getElementById('browse-search').value || '').toLowerCase();",
|
|
858
|
+
" const rows = document.querySelectorAll('.browse-channel-item');",
|
|
859
|
+
" for(const row of rows) {",
|
|
860
|
+
" const name = row.getAttribute('data-name') || '';",
|
|
861
|
+
" row.style.display = (!query || name.includes(query)) ? '' : 'none';",
|
|
905
862
|
" }",
|
|
906
863
|
" }",
|
|
907
864
|
// Update the apply button text with categorized action counts. Computes add, switch, and remove counts based on checkbox state vs data-original.
|
|
908
865
|
" function updateBrowseCount() {",
|
|
909
|
-
"
|
|
910
|
-
"
|
|
911
|
-
"
|
|
912
|
-
"
|
|
913
|
-
" for(
|
|
914
|
-
"
|
|
915
|
-
"
|
|
916
|
-
" var row = cb.closest('.browse-channel-item');",
|
|
866
|
+
" const cbs = document.querySelectorAll('.browse-channel-cb');",
|
|
867
|
+
" let addCount = 0;",
|
|
868
|
+
" let switchCount = 0;",
|
|
869
|
+
" let removeCount = 0;",
|
|
870
|
+
" for(const cb of cbs) {",
|
|
871
|
+
" const original = cb.getAttribute('data-original');",
|
|
872
|
+
" const row = cb.closest('.browse-channel-item');",
|
|
917
873
|
" if(row && row.style.display === 'none') continue;",
|
|
918
874
|
" if((original === 'new' || original === 'disabled') && cb.checked) addCount++;",
|
|
919
875
|
" if(original === 'switch' && cb.checked && !cb.indeterminate) switchCount++;",
|
|
920
876
|
" if(original === 'current' && !cb.checked) removeCount++;",
|
|
921
877
|
" }",
|
|
922
|
-
"
|
|
923
|
-
"
|
|
924
|
-
"
|
|
878
|
+
" const btn = document.getElementById('browse-add-btn');",
|
|
879
|
+
" const total = addCount + switchCount + removeCount;",
|
|
880
|
+
" const parts = [];",
|
|
925
881
|
" if(addCount > 0) parts.push('add ' + addCount);",
|
|
926
882
|
" if(switchCount > 0) parts.push('switch ' + switchCount);",
|
|
927
883
|
" if(removeCount > 0) parts.push('remove ' + removeCount);",
|
|
928
884
|
" btn.textContent = total > 0 ? 'Apply (' + parts.join(', ') + ')' : 'Apply';",
|
|
929
885
|
" btn.disabled = total === 0;",
|
|
930
886
|
// Update select-all checkbox state.
|
|
931
|
-
"
|
|
932
|
-
"
|
|
933
|
-
"
|
|
934
|
-
" for(
|
|
935
|
-
"
|
|
887
|
+
" const allCbs = document.querySelectorAll('.browse-channel-cb');",
|
|
888
|
+
" let allChecked = true;",
|
|
889
|
+
" let visibleCount = 0;",
|
|
890
|
+
" for(const ac of allCbs) {",
|
|
891
|
+
" const r = ac.closest('.browse-channel-item');",
|
|
936
892
|
" if(r && r.style.display === 'none') continue;",
|
|
937
893
|
" visibleCount++;",
|
|
938
|
-
" if(!
|
|
894
|
+
" if(!ac.checked) allChecked = false;",
|
|
939
895
|
" }",
|
|
940
|
-
"
|
|
896
|
+
" const selectAll = document.getElementById('browse-select-all-cb');",
|
|
941
897
|
" if(selectAll) selectAll.checked = (visibleCount > 0) && allChecked;",
|
|
942
898
|
" }",
|
|
943
899
|
// Submit channel changes via the modify endpoint. Collects action types: add (new channels checked), enable (disabled channels checked), switch
|
|
944
900
|
// remove (current channels unchecked). Each entry includes an action field so the server can handle them appropriately.
|
|
945
|
-
" window.submitBrowseChannels =
|
|
946
|
-
"
|
|
947
|
-
"
|
|
948
|
-
"
|
|
949
|
-
" for(
|
|
950
|
-
"
|
|
951
|
-
"
|
|
952
|
-
" var row = cb.closest('.browse-channel-item');",
|
|
901
|
+
" window.submitBrowseChannels = () => {",
|
|
902
|
+
" const cbs = document.querySelectorAll('.browse-channel-cb');",
|
|
903
|
+
" const guideUrl = browseGuideUrls[browseWizard.state.slug] || '';",
|
|
904
|
+
" const entries = [];",
|
|
905
|
+
" for(const cb of cbs) {",
|
|
906
|
+
" const original = cb.getAttribute('data-original');",
|
|
907
|
+
" const row = cb.closest('.browse-channel-item');",
|
|
953
908
|
" if(row && row.style.display === 'none') continue;",
|
|
954
|
-
"
|
|
909
|
+
" let action = null;",
|
|
955
910
|
" if(original === 'new' && cb.checked) action = 'add';",
|
|
956
911
|
" if(original === 'disabled' && cb.checked) action = 'enable';",
|
|
957
912
|
" if(original === 'switch' && cb.checked && !cb.indeterminate) action = 'switch';",
|
|
958
913
|
" if(original === 'current' && !cb.checked) action = 'remove';",
|
|
959
914
|
" if(!action) continue;",
|
|
960
|
-
"
|
|
915
|
+
" const entry = {",
|
|
961
916
|
" action: action,",
|
|
962
917
|
" canonicalKey: cb.getAttribute('data-canonical') || '',",
|
|
963
918
|
" channelSelector: cb.getAttribute('data-selector'),",
|
|
964
919
|
" name: cb.getAttribute('data-name'),",
|
|
965
|
-
"
|
|
920
|
+
" serviceSlug: browseWizard.state.slug,",
|
|
966
921
|
" url: guideUrl",
|
|
967
922
|
" };",
|
|
968
|
-
"
|
|
923
|
+
" const sid = cb.getAttribute('data-station-id');",
|
|
969
924
|
" if(sid) entry.stationId = sid;",
|
|
970
925
|
" entries.push(entry);",
|
|
971
926
|
" }",
|
|
972
927
|
" if(entries.length === 0) return;",
|
|
973
928
|
// Disable the button to prevent double submission.
|
|
974
|
-
"
|
|
929
|
+
" const btn = document.getElementById('browse-add-btn');",
|
|
975
930
|
" btn.disabled = true;",
|
|
976
931
|
" btn.textContent = 'Applying...';",
|
|
977
|
-
" (async
|
|
932
|
+
" (async () => {",
|
|
978
933
|
" try {",
|
|
979
|
-
"
|
|
980
|
-
"
|
|
934
|
+
" const res = await fetch('/config/channels/modify', {",
|
|
935
|
+
" body: JSON.stringify({ channels: entries }),",
|
|
981
936
|
" headers: { 'Content-Type': 'application/json' },",
|
|
982
|
-
"
|
|
937
|
+
" method: 'POST'",
|
|
983
938
|
" });",
|
|
984
|
-
"
|
|
939
|
+
" const data = await res.json();",
|
|
985
940
|
" if(data.success) {",
|
|
986
941
|
" browseWizard.close();",
|
|
987
942
|
" showToast(data.message || 'Channels added.', 'success');",
|
|
988
|
-
" if(data.patch) {
|
|
943
|
+
" if(data.patch) { channelTable.applyPatch(data.patch); }",
|
|
944
|
+
" if(data.serviceWarning && typeof showServiceFilterWarning === 'function') { showServiceFilterWarning(data.serviceWarning); }",
|
|
989
945
|
" } else {",
|
|
990
946
|
" btn.disabled = false;",
|
|
991
947
|
" updateBrowseCount();",
|
|
992
948
|
" showToast('Failed to add channels: ' + (data.error || 'Unknown error.'), 'error');",
|
|
993
949
|
" }",
|
|
994
950
|
" } catch(err) {",
|
|
995
|
-
" console.error('Browse channels
|
|
951
|
+
" console.error('Browse channels request failed.', err);",
|
|
996
952
|
" btn.disabled = false;",
|
|
997
953
|
" updateBrowseCount();",
|
|
998
954
|
" showToast('Failed to add channels.', 'error');",
|
|
999
955
|
" }",
|
|
1000
956
|
" })();",
|
|
1001
957
|
" };",
|
|
1002
|
-
// Read embedded
|
|
1003
|
-
"
|
|
958
|
+
// Read embedded service data for the setup wizard. Each entry includes domain, iconUrl, displayName, tag, and enabled state from the server.
|
|
959
|
+
" let setupServices = [];",
|
|
1004
960
|
" try {",
|
|
1005
|
-
"
|
|
1006
|
-
" if(spEl)
|
|
1007
|
-
" } catch(e) {}",
|
|
1008
|
-
// Validate the current setup step. Step 1 collects selected
|
|
961
|
+
" const spEl = document.getElementById('setup-services-data');",
|
|
962
|
+
" if(spEl) setupServices = JSON.parse(spEl.textContent || '[]');",
|
|
963
|
+
" } catch(e) { console.warn('Embedded setup services data failed to parse.', e); }",
|
|
964
|
+
// Validate the current setup step. Step 1 collects selected services, validates at least one is selected, and saves the service filter to the server
|
|
1009
965
|
// asynchronously. Returns a Promise that resolves to an error string or empty string. Step 2+ always passes (no validation needed).
|
|
1010
966
|
" async function validateSetupStep(step) {",
|
|
1011
967
|
" if(step === 1) {",
|
|
1012
|
-
"
|
|
1013
|
-
"
|
|
1014
|
-
" for(
|
|
1015
|
-
" if(selected.length === 0) return 'Select at least one
|
|
1016
|
-
" setupWizard.state.
|
|
968
|
+
" const selected = [];",
|
|
969
|
+
" const cbs = document.querySelectorAll('.setup-provider-cb:checked');",
|
|
970
|
+
" for(const cb of cbs) selected.push(cb.value);",
|
|
971
|
+
" if(selected.length === 0) return 'Select at least one service.';",
|
|
972
|
+
" setupWizard.state.selectedServices = selected;",
|
|
1017
973
|
" setupWizard.state.authIndex = 0;",
|
|
1018
974
|
" try {",
|
|
1019
|
-
" await fetch('/config/
|
|
1020
|
-
"
|
|
975
|
+
" await fetch('/config/service-filter', {",
|
|
976
|
+
" body: JSON.stringify({ enabledServices: selected }),",
|
|
1021
977
|
" headers: { 'Content-Type': 'application/json' },",
|
|
1022
|
-
"
|
|
978
|
+
" method: 'POST'",
|
|
1023
979
|
" });",
|
|
1024
980
|
" return '';",
|
|
1025
|
-
" } catch(e) { return 'Failed to save
|
|
981
|
+
" } catch(e) { return 'Failed to save service selection.'; }",
|
|
1026
982
|
" }",
|
|
1027
983
|
" return '';",
|
|
1028
984
|
" }",
|
|
1029
|
-
//
|
|
985
|
+
// Service Setup wizard. Uses the shared wizard controller for step navigation and indicator updates. The state object tracks selected services and
|
|
1030
986
|
// the current auth index for the sequential sign-in flow.
|
|
1031
|
-
"
|
|
987
|
+
" const setupWizard = createWizardController({",
|
|
1032
988
|
" contentId: 'setup-content',",
|
|
1033
989
|
" errorId: 'setup-error',",
|
|
1034
990
|
" modalId: 'setup-modal',",
|
|
1035
|
-
" onClose:
|
|
1036
|
-
" if(setupWizard.getStep() === 2) { fetch('/auth/done', { method: 'POST' }).catch(
|
|
991
|
+
" onClose: () => {",
|
|
992
|
+
" if(setupWizard.getStep() === 2) { fetch('/auth/done', { method: 'POST' }).catch((err) => { console.warn('Auth done notification failed.', err); }); }",
|
|
1037
993
|
" },",
|
|
1038
994
|
" onRender: renderSetupStep,",
|
|
1039
995
|
" onValidate: validateSetupStep,",
|
|
@@ -1041,163 +997,349 @@ export function generateChannelsSubtabScript() {
|
|
|
1041
997
|
" stepsId: 'setup-steps',",
|
|
1042
998
|
" titleId: 'setup-title'",
|
|
1043
999
|
" });",
|
|
1044
|
-
// Open the setup wizard and render the first step. Pre-populate selected
|
|
1045
|
-
// current
|
|
1046
|
-
" window.openSetupWizard =
|
|
1047
|
-
"
|
|
1048
|
-
" setupWizard.state = {
|
|
1000
|
+
// Open the setup wizard and render the first step. Pre-populate selected services from the embedded enabled state so re-running the wizard shows the
|
|
1001
|
+
// current service filter as pre-checked.
|
|
1002
|
+
" window.openSetupWizard = () => {",
|
|
1003
|
+
" const preSelected = setupServices.filter((p) => p.enabled).map((p) => p.tag);",
|
|
1004
|
+
" setupWizard.state = { selectedServices: preSelected, authIndex: 0 };",
|
|
1049
1005
|
" setupWizard.open();",
|
|
1050
1006
|
" };",
|
|
1051
1007
|
// Skip the entire setup flow and mark as completed.
|
|
1052
|
-
" window.skipSetup = async
|
|
1053
|
-
" try { await fetch('/config/channels/setup-completed', {
|
|
1054
|
-
" catch(e) {}",
|
|
1008
|
+
" window.skipSetup = async () => {",
|
|
1009
|
+
" try { await fetch('/config/channels/setup-completed', { headers: { 'Content-Type': 'application/json' }, method: 'POST' }); }",
|
|
1010
|
+
" catch(e) { console.warn('Setup completion failed to persist on skip.', e); }",
|
|
1055
1011
|
" setupWizard.close();",
|
|
1056
1012
|
" };",
|
|
1057
|
-
// Authenticate the current
|
|
1058
|
-
" async function
|
|
1059
|
-
"
|
|
1060
|
-
"
|
|
1061
|
-
"
|
|
1013
|
+
// Authenticate the current service in step 2. Opens the browser and shows a waiting prompt.
|
|
1014
|
+
" async function setupAuthService() {",
|
|
1015
|
+
" const slug = setupWizard.state.selectedServices[setupWizard.state.authIndex];",
|
|
1016
|
+
" const svc = setupServices.find((p) => p.tag === slug);",
|
|
1017
|
+
" const url = svc && svc.domain ? 'https://' + svc.domain + '/' : '';",
|
|
1062
1018
|
" if(!url) { setupAdvanceAuth(); return; }",
|
|
1063
1019
|
" try {",
|
|
1064
|
-
"
|
|
1065
|
-
"
|
|
1020
|
+
" const res = await fetch('/auth/login', {",
|
|
1021
|
+
" body: JSON.stringify({ url: url }),",
|
|
1066
1022
|
" headers: { 'Content-Type': 'application/json' },",
|
|
1067
|
-
"
|
|
1023
|
+
" method: 'POST'",
|
|
1068
1024
|
" });",
|
|
1069
|
-
"
|
|
1025
|
+
" const data = await res.json();",
|
|
1070
1026
|
" if(data.success) renderSetupAuthWaiting();",
|
|
1071
1027
|
" else showToast('Failed to start login: ' + (data.error || 'Unknown error.'), 'error');",
|
|
1072
|
-
" } catch(e) { showToast('Failed to start login.', 'error'); }",
|
|
1028
|
+
" } catch(e) { console.error('Login start request failed.', e); showToast('Failed to start login.', 'error'); }",
|
|
1073
1029
|
" }",
|
|
1074
|
-
// Mark the current
|
|
1030
|
+
// Mark the current service auth as done and advance to the next.
|
|
1075
1031
|
" function setupAuthDone() {",
|
|
1076
|
-
" fetch('/auth/done', { method: 'POST' }).catch(
|
|
1032
|
+
" fetch('/auth/done', { method: 'POST' }).catch((err) => { console.warn('Auth done notification failed.', err); });",
|
|
1077
1033
|
" setupAdvanceAuth();",
|
|
1078
1034
|
" }",
|
|
1079
|
-
// Skip the current
|
|
1035
|
+
// Skip the current service auth and advance.
|
|
1080
1036
|
" function setupAuthSkip() {",
|
|
1081
|
-
" fetch('/auth/done', { method: 'POST' }).catch(
|
|
1037
|
+
" fetch('/auth/done', { method: 'POST' }).catch((err) => { console.warn('Auth done notification failed.', err); });",
|
|
1082
1038
|
" setupAdvanceAuth();",
|
|
1083
1039
|
" }",
|
|
1084
|
-
// Advance to the next
|
|
1040
|
+
// Advance to the next service or to step 3 if all are done.
|
|
1085
1041
|
" function setupAdvanceAuth() {",
|
|
1086
1042
|
" setupWizard.state.authIndex++;",
|
|
1087
|
-
" if(setupWizard.state.authIndex >= setupWizard.state.
|
|
1043
|
+
" if(setupWizard.state.authIndex >= setupWizard.state.selectedServices.length) {",
|
|
1088
1044
|
" setupWizard.next();",
|
|
1089
1045
|
" } else {",
|
|
1090
1046
|
" renderSetupStep(setupWizard.getStep());",
|
|
1091
1047
|
" }",
|
|
1092
1048
|
" }",
|
|
1093
1049
|
// Finish the setup wizard. Mark as completed and refresh the channel table.
|
|
1094
|
-
" window.finishSetup = async
|
|
1050
|
+
" window.finishSetup = async () => {",
|
|
1095
1051
|
" try {",
|
|
1096
|
-
"
|
|
1097
|
-
"
|
|
1052
|
+
" const res = await fetch('/config/channels/setup-completed', { headers: { 'Content-Type': 'application/json' }, method: 'POST' });",
|
|
1053
|
+
" const data = await res.json();",
|
|
1098
1054
|
" setupWizard.close();",
|
|
1099
|
-
" if(data.patch) {
|
|
1100
|
-
" } catch(err) { console.error('Setup finish
|
|
1055
|
+
" if(data.patch) { channelTable.applyPatch(data.patch); }",
|
|
1056
|
+
" } catch(err) { console.error('Setup finish request failed.', err); setupWizard.close(); }",
|
|
1101
1057
|
" };",
|
|
1102
1058
|
// Render the auth waiting state - shows a message while the user authenticates in Chrome.
|
|
1103
1059
|
" function renderSetupAuthWaiting() {",
|
|
1104
|
-
"
|
|
1105
|
-
"
|
|
1106
|
-
"
|
|
1107
|
-
"
|
|
1060
|
+
" const slug = setupWizard.state.selectedServices[setupWizard.state.authIndex];",
|
|
1061
|
+
" const svc = setupServices.find((p) => p.tag === slug);",
|
|
1062
|
+
" const label = svc ? svc.displayName : slug;",
|
|
1063
|
+
" const content = document.getElementById('setup-content');",
|
|
1108
1064
|
" content.innerHTML = '<div class=\"wizard-spinner\">Complete authentication for ' + esc(label) +",
|
|
1109
1065
|
" ' in the Chrome browser window...</div>' +",
|
|
1110
1066
|
" '<div class=\"wizard-step-centered-compact\">' +",
|
|
1111
1067
|
" '<button type=\"button\" class=\"btn btn-primary\" data-action=\"auth-done\">Done</button> ' +",
|
|
1112
1068
|
" '<button type=\"button\" class=\"btn btn-secondary\" data-action=\"auth-skip\">Skip</button></div>';",
|
|
1113
1069
|
// Attach handlers to the auth waiting buttons programmatically.
|
|
1114
|
-
"
|
|
1115
|
-
" for(
|
|
1116
|
-
" (
|
|
1117
|
-
"
|
|
1118
|
-
"
|
|
1119
|
-
" else if(action === 'auth-skip') { btn.onclick = function() { setupAuthSkip(); }; }",
|
|
1120
|
-
" })(waitActions[wi]);",
|
|
1070
|
+
" const waitActions = content.querySelectorAll('[data-action]');",
|
|
1071
|
+
" for(const btn of waitActions) {",
|
|
1072
|
+
" const action = btn.getAttribute('data-action');",
|
|
1073
|
+
" if(action === 'auth-done') { btn.onclick = () => { setupAuthDone(); }; }",
|
|
1074
|
+
" else if(action === 'auth-skip') { btn.onclick = () => { setupAuthSkip(); }; }",
|
|
1121
1075
|
" }",
|
|
1122
1076
|
" }",
|
|
1123
1077
|
// Render the current setup step content and update navigation buttons. The controller handles step indicator updates and error clearing.
|
|
1124
1078
|
" function renderSetupStep(step) {",
|
|
1125
|
-
"
|
|
1079
|
+
" const content = document.getElementById('setup-content');",
|
|
1126
1080
|
// Show/hide navigation buttons based on the current step.
|
|
1127
1081
|
" if(step > 1) { setupWizard.show('setup-back'); } else { setupWizard.hide('setup-back'); }",
|
|
1128
1082
|
" if(step < 3) { setupWizard.show('setup-next'); setupWizard.show('setup-skip'); }",
|
|
1129
1083
|
" else { setupWizard.hide('setup-next'); setupWizard.hide('setup-skip'); }",
|
|
1130
1084
|
" if(step === 3) { setupWizard.show('setup-finish'); } else { setupWizard.hide('setup-finish'); }",
|
|
1131
|
-
"
|
|
1132
|
-
// Step 1:
|
|
1085
|
+
" let html = '';",
|
|
1086
|
+
// Step 1: Service selection with multi-select cards.
|
|
1133
1087
|
" if(step === 1) {",
|
|
1134
1088
|
" html += '<p class=\"wizard-hint\">Select the streaming services you subscribe to. " +
|
|
1135
1089
|
"This determines which channels are available in your lineup.</p>';",
|
|
1136
1090
|
" html += '<div class=\"wizard-provider-grid\">';",
|
|
1137
|
-
" for(
|
|
1138
|
-
"
|
|
1139
|
-
" var checked = (setupWizard.state.selectedProviders.indexOf(p.tag) !== -1) ? ' checked' : '';",
|
|
1091
|
+
" for(const p of setupServices) {",
|
|
1092
|
+
" const checked = setupWizard.state.selectedServices.includes(p.tag) ? ' checked' : '';",
|
|
1140
1093
|
" html += '<label class=\"wizard-provider-card\">';",
|
|
1141
1094
|
" html += '<input type=\"checkbox\" class=\"setup-provider-cb\" value=\"' + p.tag + '\"' + checked + '>';",
|
|
1142
|
-
" html +=
|
|
1095
|
+
" html += serviceIconHtml(p.domain || '', esc(p.displayName), 'provider-icon', 'provider-icon-text', 'both', p.iconUrl || '');",
|
|
1143
1096
|
" html += '</label>';",
|
|
1144
1097
|
" }",
|
|
1145
1098
|
" html += '</div>';",
|
|
1146
1099
|
" }",
|
|
1147
1100
|
// Step 2: Sequential authentication prompts.
|
|
1148
1101
|
" if(step === 2) {",
|
|
1149
|
-
"
|
|
1150
|
-
"
|
|
1151
|
-
"
|
|
1152
|
-
"
|
|
1153
|
-
"
|
|
1154
|
-
" html += '<p class=\"wizard-hint\">Sign in to each
|
|
1102
|
+
" const slug = setupWizard.state.selectedServices[setupWizard.state.authIndex];",
|
|
1103
|
+
" const svc = setupServices.find((p) => p.tag === slug);",
|
|
1104
|
+
" const label = svc ? svc.displayName : slug;",
|
|
1105
|
+
" const total = setupWizard.state.selectedServices.length;",
|
|
1106
|
+
" const current = setupWizard.state.authIndex + 1;",
|
|
1107
|
+
" html += '<p class=\"wizard-hint\">Sign in to each service in the Chrome browser window. " +
|
|
1155
1108
|
"PrismCast uses your login session to access live streams.</p>';",
|
|
1156
1109
|
" html += '<div class=\"wizard-step-centered\">';",
|
|
1157
1110
|
" html += '<p class=\"wizard-step-provider-name\">' + esc(label) + '</p>';",
|
|
1158
|
-
" html += '<p class=\"wizard-step-provider-count\">
|
|
1111
|
+
" html += '<p class=\"wizard-step-provider-count\">Service ' + current + ' of ' + total + '</p>';",
|
|
1159
1112
|
" html += '<button type=\"button\" class=\"btn btn-primary\" data-action=\"auth-start\">Sign In</button> ';",
|
|
1160
1113
|
" html += '<button type=\"button\" class=\"btn btn-secondary\" data-action=\"auth-skip\">Skip</button>';",
|
|
1161
1114
|
" html += '</div>';",
|
|
1162
1115
|
" }",
|
|
1163
1116
|
// Step 3: Summary and finish.
|
|
1164
1117
|
" if(step === 3) {",
|
|
1165
|
-
" html += '<p class=\"wizard-hint\">Your
|
|
1118
|
+
" html += '<p class=\"wizard-hint\">Your services are ready. Click Finish to complete setup. " +
|
|
1166
1119
|
"You can browse and manage channels anytime using the Browse Channels button.</p>';",
|
|
1167
1120
|
" html += '<div class=\"wizard-step-centered\">';",
|
|
1168
|
-
" html += '<p class=\"wizard-step-summary\">Selected ' + setupWizard.state.
|
|
1169
|
-
" (setupWizard.state.
|
|
1121
|
+
" html += '<p class=\"wizard-step-summary\">Selected ' + setupWizard.state.selectedServices.length + ' service' +",
|
|
1122
|
+
" (setupWizard.state.selectedServices.length === 1 ? '' : 's') + '.</p>';",
|
|
1170
1123
|
" html += '</div>';",
|
|
1171
1124
|
" }",
|
|
1172
1125
|
" content.innerHTML = html;",
|
|
1173
1126
|
// Attach event handlers to dynamically-rendered buttons programmatically. Uses data-action attributes to identify buttons without inline onclick.
|
|
1174
|
-
"
|
|
1175
|
-
" for(
|
|
1176
|
-
" (
|
|
1177
|
-
"
|
|
1178
|
-
"
|
|
1179
|
-
"
|
|
1180
|
-
" else if(action === 'auth-done') { btn.onclick = function() { setupAuthDone(); }; }",
|
|
1181
|
-
" })(actions[ai]);",
|
|
1127
|
+
" const actions = content.querySelectorAll('[data-action]');",
|
|
1128
|
+
" for(const btn of actions) {",
|
|
1129
|
+
" const action = btn.getAttribute('data-action');",
|
|
1130
|
+
" if(action === 'auth-start') { btn.onclick = () => { setupAuthService(); }; }",
|
|
1131
|
+
" else if(action === 'auth-skip') { btn.onclick = () => { setupAuthSkip(); }; }",
|
|
1132
|
+
" else if(action === 'auth-done') { btn.onclick = () => { setupAuthDone(); }; }",
|
|
1182
1133
|
" }",
|
|
1183
1134
|
" }",
|
|
1135
|
+
// Tag Management Modal.
|
|
1136
|
+
// Single function for applying tag mutation responses. Updates the tag manager modal body, the column header filter dropdown, the channel table patch, and shows
|
|
1137
|
+
// a toast. Exposed on window so both channels.ts and config.ts (bulkToggleTag) can use the same code path. No page reload needed.
|
|
1138
|
+
" window.applyTagResponse = (data, toastMessage) => {",
|
|
1139
|
+
" const contentDiv = document.getElementById('tag-manager-modal-content');",
|
|
1140
|
+
" if(contentDiv && data.modalBody) { contentDiv.innerHTML = data.modalBody; }",
|
|
1141
|
+
" const filterMenu = document.getElementById('tag-filter-menu');",
|
|
1142
|
+
" if(filterMenu && data.filterContent) { filterMenu.innerHTML = data.filterContent; applyTagColumnFilter(); }",
|
|
1143
|
+
" if(data.patch) { channelTable.applyPatch(data.patch); }",
|
|
1144
|
+
" if(toastMessage && window.showToast) { window.showToast(toastMessage); }",
|
|
1145
|
+
" };",
|
|
1146
|
+
" window.openTagManager = () => {",
|
|
1147
|
+
" const modal = document.getElementById('tag-manager-modal');",
|
|
1148
|
+
" if(modal) { modal.style.display = 'flex'; }",
|
|
1149
|
+
" };",
|
|
1150
|
+
" window.closeTagManager = () => {",
|
|
1151
|
+
" const modal = document.getElementById('tag-manager-modal');",
|
|
1152
|
+
" if(modal) { modal.style.display = 'none'; }",
|
|
1153
|
+
" };",
|
|
1154
|
+
// Create a new tag via the API, then update the modal incrementally and show a toast.
|
|
1155
|
+
" window.createTag = async () => {",
|
|
1156
|
+
" const input = document.getElementById('tag-manager-input');",
|
|
1157
|
+
" const errorDiv = document.getElementById('tag-manager-error');",
|
|
1158
|
+
" const tag = input ? input.value.trim() : '';",
|
|
1159
|
+
" if(!tag) return;",
|
|
1160
|
+
" errorDiv.style.display = 'none';",
|
|
1161
|
+
" try {",
|
|
1162
|
+
" const res = await fetch('/config/tags', {",
|
|
1163
|
+
" body: JSON.stringify({ tag }),",
|
|
1164
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1165
|
+
" method: 'POST'",
|
|
1166
|
+
" });",
|
|
1167
|
+
" const data = await res.json();",
|
|
1168
|
+
" if(!data.success) {",
|
|
1169
|
+
" errorDiv.textContent = data.error || 'Failed to create tag.';",
|
|
1170
|
+
" errorDiv.style.display = '';",
|
|
1171
|
+
" return;",
|
|
1172
|
+
" }",
|
|
1173
|
+
" applyTagResponse(data, 'Tag created: ' + tag);",
|
|
1174
|
+
" } catch(err) {",
|
|
1175
|
+
" console.error('Tag creation request failed.', err);",
|
|
1176
|
+
" errorDiv.textContent = 'Failed to create tag.';",
|
|
1177
|
+
" errorDiv.style.display = '';",
|
|
1178
|
+
" }",
|
|
1179
|
+
" };",
|
|
1180
|
+
// Delete a tag via the API, then update the modal and channel table incrementally.
|
|
1181
|
+
" window.deleteTag = async (tag) => {",
|
|
1182
|
+
" try {",
|
|
1183
|
+
" const res = await fetch('/config/tags/' + encodeURIComponent(tag), { method: 'DELETE' });",
|
|
1184
|
+
" const data = await res.json();",
|
|
1185
|
+
" if(!data.success) {",
|
|
1186
|
+
" showToast(data.error || 'Failed to delete tag.', 'error');",
|
|
1187
|
+
" return;",
|
|
1188
|
+
" }",
|
|
1189
|
+
" applyTagResponse(data, 'Tag deleted: ' + tag);",
|
|
1190
|
+
" } catch(err) {",
|
|
1191
|
+
" console.error('Tag deletion request failed.', err);",
|
|
1192
|
+
" showToast('Failed to delete tag.', 'error');",
|
|
1193
|
+
" }",
|
|
1194
|
+
" };",
|
|
1195
|
+
// Restore a deleted predefined tag via the API, then update the modal and channel table incrementally.
|
|
1196
|
+
" window.restoreTag = async (tag) => {",
|
|
1197
|
+
" try {",
|
|
1198
|
+
" const res = await fetch('/config/tags/restore', {",
|
|
1199
|
+
" body: JSON.stringify({ tag }),",
|
|
1200
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1201
|
+
" method: 'POST'",
|
|
1202
|
+
" });",
|
|
1203
|
+
" const data = await res.json();",
|
|
1204
|
+
" if(!data.success) {",
|
|
1205
|
+
" showToast(data.error || 'Failed to restore tag.', 'error');",
|
|
1206
|
+
" return;",
|
|
1207
|
+
" }",
|
|
1208
|
+
" applyTagResponse(data, 'Tag restored: ' + tag);",
|
|
1209
|
+
" } catch(err) {",
|
|
1210
|
+
" console.error('Tag restore request failed.', err);",
|
|
1211
|
+
" showToast('Failed to restore tag.', 'error');",
|
|
1212
|
+
" }",
|
|
1213
|
+
" };",
|
|
1214
|
+
// Tag Column Filter — client-side view filter that shows/hides rows based on their data-channel-tags attribute. Transient (not persisted, resets on page load).
|
|
1215
|
+
// All tag checkboxes start checked (show all). Unchecking a tag hides rows that don't have ANY of the remaining checked tags.
|
|
1216
|
+
" window.applyTagColumnFilter = () => {",
|
|
1217
|
+
" const checkboxes = document.querySelectorAll('.tag-filter-checkbox');",
|
|
1218
|
+
" const checkedTags = [];",
|
|
1219
|
+
" let allChecked = true;",
|
|
1220
|
+
" for(const checkbox of checkboxes) {",
|
|
1221
|
+
" if(checkbox.checked) { checkedTags.push(checkbox.getAttribute('data-tag')); }",
|
|
1222
|
+
" else { allChecked = false; }",
|
|
1223
|
+
" }",
|
|
1224
|
+
" const rows = document.querySelectorAll('tr[id^=\"display-row-\"]');",
|
|
1225
|
+
" for(const row of rows) {",
|
|
1226
|
+
" const editRow = document.getElementById(row.id.replace('display-row-', 'edit-row-'));",
|
|
1227
|
+
" if(allChecked) {",
|
|
1228
|
+
" row.classList.remove('tag-filtered');",
|
|
1229
|
+
" if(editRow) editRow.classList.remove('tag-filtered');",
|
|
1230
|
+
" continue;",
|
|
1231
|
+
" }",
|
|
1232
|
+
" const tags = row.getAttribute('data-channel-tags');",
|
|
1233
|
+
" const channelTags = tags ? tags.split(',') : [];",
|
|
1234
|
+
" const visible = channelTags.some((ct) => checkedTags.includes(ct));",
|
|
1235
|
+
" row.classList.toggle('tag-filtered', !visible);",
|
|
1236
|
+
" if(editRow) editRow.classList.toggle('tag-filtered', !visible);",
|
|
1237
|
+
" }",
|
|
1238
|
+
// Show or hide the playlist hint icon based on filter state. When the filter is active, users can click the icon to see the corresponding playlist URL.
|
|
1239
|
+
" const hintBtn = document.getElementById('playlist-hint-btn');",
|
|
1240
|
+
" if(hintBtn) { hintBtn.style.display = allChecked ? 'none' : ''; }",
|
|
1241
|
+
// Update the toggle label to reflect the current state: "Show All" when some are unchecked, "Show None" when all are checked.
|
|
1242
|
+
" const toggle = document.getElementById('tag-filter-toggle');",
|
|
1243
|
+
" if(toggle) { toggle.textContent = allChecked ? 'Show None' : 'Show All'; }",
|
|
1244
|
+
" };",
|
|
1245
|
+
// Toggle all tag filter checkboxes. When all are checked, uncheck all. When any are unchecked, check all. Matches the browse modal's Select All pattern.
|
|
1246
|
+
" window.toggleTagColumnFilter = () => {",
|
|
1247
|
+
" const checkboxes = document.querySelectorAll('.tag-filter-checkbox');",
|
|
1248
|
+
" let allChecked = true;",
|
|
1249
|
+
" for(const checkbox of checkboxes) { if(!checkbox.checked) { allChecked = false; break; } }",
|
|
1250
|
+
" for(const checkbox of checkboxes) { checkbox.checked = !allChecked; }",
|
|
1251
|
+
" applyTagColumnFilter();",
|
|
1252
|
+
" };",
|
|
1253
|
+
// Playlist hint popover. Builds the content dynamically from the current filter state and shows it using the existing dropdown portal pattern. Uses whichever
|
|
1254
|
+
// form (include or exclude) produces a shorter URL. The label names the specific tags so the user sees exactly what the URL produces.
|
|
1255
|
+
" window.showPlaylistHint = (btn) => {",
|
|
1256
|
+
" const checkboxes = document.querySelectorAll('.tag-filter-checkbox');",
|
|
1257
|
+
" const checked = [];",
|
|
1258
|
+
" const unchecked = [];",
|
|
1259
|
+
" for(const checkbox of checkboxes) {",
|
|
1260
|
+
" const tag = checkbox.getAttribute('data-tag');",
|
|
1261
|
+
" if(checkbox.checked) { checked.push(tag); }",
|
|
1262
|
+
" else { unchecked.push(tag); }",
|
|
1263
|
+
" }",
|
|
1264
|
+
" const useExclude = unchecked.length < checked.length;",
|
|
1265
|
+
" const tagParam = useExclude ? unchecked.map((t) => '-' + t).join(',') : checked.join(',');",
|
|
1266
|
+
" const url = location.origin + '/playlist?tag=' + tagParam;",
|
|
1267
|
+
" const label = useExclude ? 'all channels except those tagged <strong>' + unchecked.join(', ') + '</strong>'",
|
|
1268
|
+
" : 'channels tagged <strong>' + checked.join(', ') + '</strong>';",
|
|
1269
|
+
" toggleDropdown(btn, { onOpen: (menu) => {",
|
|
1270
|
+
" menu.innerHTML = '<div class=\"playlist-hint-content\">' +",
|
|
1271
|
+
" '<p>Playlist URL for Channels DVR: ' + label + '.</p>' +",
|
|
1272
|
+
" '<div class=\"playlist-hint-url\">' +",
|
|
1273
|
+
" '<code>' + url + '</code>' +",
|
|
1274
|
+
" '<button type=\"button\" class=\"btn btn-primary btn-sm\" onclick=\"copyPlaylistHintUrl()\">Copy</button>' +",
|
|
1275
|
+
" '</div></div>';",
|
|
1276
|
+
" }});",
|
|
1277
|
+
" };",
|
|
1278
|
+
// Copy the playlist hint URL to the clipboard. Thin wrapper that reads the URL from the popover's code element and delegates to the shared copyToClipboard utility.
|
|
1279
|
+
" window.copyPlaylistHintUrl = () => {",
|
|
1280
|
+
" const code = document.querySelector('.playlist-hint-content code');",
|
|
1281
|
+
" if(code) { copyToClipboard(code.textContent, 'Playlist URL copied to clipboard.'); }",
|
|
1282
|
+
" };",
|
|
1283
|
+
// Inline tag rename — replaces the tag pill with an input field. The original span is saved so it can be restored on cancel or error without a page reload.
|
|
1284
|
+
" window.startTagRename = (span, oldTag) => {",
|
|
1285
|
+
" const originalSpan = span;",
|
|
1286
|
+
" const input = document.createElement('input');",
|
|
1287
|
+
" input.type = 'text';",
|
|
1288
|
+
" input.value = oldTag;",
|
|
1289
|
+
" input.className = 'tag-rename-input';",
|
|
1290
|
+
" input.maxLength = 30;",
|
|
1291
|
+
" let submitted = false;",
|
|
1292
|
+
" function restore() { if(input.parentNode) input.replaceWith(originalSpan); }",
|
|
1293
|
+
" async function submit() {",
|
|
1294
|
+
" if(submitted) return;",
|
|
1295
|
+
" submitted = true;",
|
|
1296
|
+
" const newTag = input.value.trim();",
|
|
1297
|
+
" if(!newTag || (newTag === oldTag)) { restore(); return; }",
|
|
1298
|
+
" try {",
|
|
1299
|
+
" const res = await fetch('/config/tags/rename', {",
|
|
1300
|
+
" body: JSON.stringify({ newTag, oldTag }),",
|
|
1301
|
+
" headers: { 'Content-Type': 'application/json' },",
|
|
1302
|
+
" method: 'POST'",
|
|
1303
|
+
" });",
|
|
1304
|
+
" const data = await res.json();",
|
|
1305
|
+
" if(!data.success) {",
|
|
1306
|
+
" restore();",
|
|
1307
|
+
" showToast(data.error || 'Failed to rename tag.', 'error');",
|
|
1308
|
+
" return;",
|
|
1309
|
+
" }",
|
|
1310
|
+
" applyTagResponse(data, 'Tag renamed: ' + oldTag + ' \\u2192 ' + newTag);",
|
|
1311
|
+
" } catch(err) {",
|
|
1312
|
+
" console.error('Tag rename request failed.', err);",
|
|
1313
|
+
" restore();",
|
|
1314
|
+
" showToast('Failed to rename tag.', 'error');",
|
|
1315
|
+
" }",
|
|
1316
|
+
" }",
|
|
1317
|
+
" input.onkeydown = (e) => {",
|
|
1318
|
+
" if(e.key === 'Enter') { e.preventDefault(); submit(); }",
|
|
1319
|
+
" if(e.key === 'Escape') { e.preventDefault(); restore(); }",
|
|
1320
|
+
" };",
|
|
1321
|
+
" input.onblur = () => { submit(); };",
|
|
1322
|
+
" span.replaceWith(input);",
|
|
1323
|
+
" input.focus();",
|
|
1324
|
+
" input.select();",
|
|
1325
|
+
" };",
|
|
1184
1326
|
// Auto-show the setup wizard on first visit if setupCompleted is false.
|
|
1185
|
-
"
|
|
1327
|
+
" const setupModal = document.getElementById('setup-modal');",
|
|
1186
1328
|
" if(setupModal && setupModal.getAttribute('data-setup-completed') === 'false') {",
|
|
1187
1329
|
" openSetupWizard();",
|
|
1188
1330
|
" }",
|
|
1189
|
-
// Process channel logos and
|
|
1331
|
+
// Process channel logos and service icons on page load. These shared functions are also called after DOM mutations (row insertion, chip rebuild) to ensure
|
|
1190
1332
|
// dynamically added elements get the same icon rendering as the initial server-rendered content.
|
|
1191
|
-
"
|
|
1192
|
-
"
|
|
1333
|
+
" channelTable.processLogos();",
|
|
1334
|
+
" processServiceDisplays();",
|
|
1193
1335
|
// Initialize channels subtab on load: hash > localStorage > default.
|
|
1194
|
-
"
|
|
1195
|
-
"
|
|
1196
|
-
"
|
|
1197
|
-
"
|
|
1198
|
-
"
|
|
1199
|
-
" switchChannelsSubtab
|
|
1200
|
-
" }",
|
|
1336
|
+
" initSubtab({",
|
|
1337
|
+
" btnSelector: '.channels-subtab-btn',",
|
|
1338
|
+
" dataAttr: 'data-channels-subtab',",
|
|
1339
|
+
" hashVar: window.initialChannelsHashSubtab,",
|
|
1340
|
+
" storageKey: 'prismcast-channels-subtab',",
|
|
1341
|
+
" switchFn: switchChannelsSubtab",
|
|
1342
|
+
" });",
|
|
1201
1343
|
"})();",
|
|
1202
1344
|
"</script>"
|
|
1203
1345
|
].join("\n");
|