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
|
@@ -5,98 +5,93 @@
|
|
|
5
5
|
export function generateStatusScript() {
|
|
6
6
|
return [
|
|
7
7
|
"<script>",
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
8
|
+
"let statusEventSource = null;",
|
|
9
|
+
"let streamData = {};",
|
|
10
|
+
"let systemData = null;",
|
|
11
|
+
"const expandedStreams = {};",
|
|
12
|
+
"const healthColorVars = { healthy: 'var(--stream-healthy)', buffering: 'var(--stream-buffering)', recovering: 'var(--stream-recovering)', ",
|
|
13
13
|
" stalled: 'var(--stream-stalled)', error: 'var(--stream-error)' };",
|
|
14
|
+
// Client type labels for the detail row. Hoisted to module scope so formatClients does not reallocate the map on every render.
|
|
15
|
+
"const clientTypeLabels = { hls: 'HLS', mpegts: 'MPEG-TS' };",
|
|
16
|
+
// Resolution labels for the native HLS quality suffix. Hoisted to module scope so renderStreamsTable does not reallocate the map on every stream row.
|
|
17
|
+
"const nativeResolutionLabels = { '360': '360p', '480': '480p', '720': '720p', '1080': '1080p', '2160': '4K' };",
|
|
18
|
+
// Row background tints keyed by health state. Module-scope constant so renderStreamsTable reads from a single shared map instead of allocating a fresh
|
|
19
|
+
// object for every stream row. Consistent with clientTypeLabels and nativeResolutionLabels above — accessed inline at the call site, no wrapper helper.
|
|
20
|
+
"const rowTints = { healthy: 'transparent', buffering: 'var(--stream-tint-buffering)', stalled: 'var(--stream-tint-stalled)', " +
|
|
21
|
+
"recovering: 'var(--stream-tint-recovering)', error: 'var(--stream-tint-error)' };",
|
|
14
22
|
// Format duration in human readable format.
|
|
15
23
|
"function formatDuration(seconds) {",
|
|
16
|
-
" if
|
|
17
|
-
" if
|
|
18
|
-
"
|
|
19
|
-
"
|
|
24
|
+
" if(seconds < 60) return seconds + 's';",
|
|
25
|
+
" if(seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';",
|
|
26
|
+
" const h = Math.floor(seconds / 3600);",
|
|
27
|
+
" const m = Math.floor((seconds % 3600) / 60);",
|
|
20
28
|
" return h + 'h ' + m + 'm';",
|
|
21
29
|
"}",
|
|
22
30
|
// Format bytes in human readable format.
|
|
23
31
|
"function formatBytes(bytes) {",
|
|
24
|
-
" if
|
|
25
|
-
" if
|
|
26
|
-
" if
|
|
32
|
+
" if(bytes === 0) return '0 B';",
|
|
33
|
+
" if(bytes < 1024) return bytes + ' B';",
|
|
34
|
+
" if(bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';",
|
|
27
35
|
" return (bytes / 1048576).toFixed(1) + ' MB';",
|
|
28
36
|
"}",
|
|
29
37
|
// Format absolute time (e.g., "6:54 AM" or "Jan 14, 6:54 AM" if different day).
|
|
30
38
|
"function formatTime(isoString) {",
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
39
|
+
" const date = new Date(isoString);",
|
|
40
|
+
" const now = new Date();",
|
|
41
|
+
" let hours = date.getHours();",
|
|
42
|
+
" const minutes = date.getMinutes();",
|
|
43
|
+
" const ampm = hours >= 12 ? 'PM' : 'AM';",
|
|
36
44
|
" hours = hours % 12;",
|
|
37
45
|
" hours = hours ? hours : 12;",
|
|
38
|
-
"
|
|
39
|
-
" if
|
|
40
|
-
"
|
|
46
|
+
" let timeStr = hours + ':' + (minutes < 10 ? '0' : '') + minutes + ' ' + ampm;",
|
|
47
|
+
" if(date.toDateString() !== now.toDateString()) {",
|
|
48
|
+
" const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];",
|
|
41
49
|
" timeStr = months[date.getMonth()] + ' ' + date.getDate() + ', ' + timeStr;",
|
|
42
50
|
" }",
|
|
43
51
|
" return timeStr;",
|
|
44
52
|
"}",
|
|
45
53
|
// Extract concise domain from URL for display (last two hostname parts). Mirrors the server-side extractDomain() in utils/format.ts.
|
|
46
54
|
"function getDomain(url) {",
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
" return url;",
|
|
52
|
-
" }",
|
|
53
|
-
"}",
|
|
54
|
-
// Get row background color based on health status. Uses CSS variables for theme support.
|
|
55
|
-
"function getRowTint(health) {",
|
|
56
|
-
" var tints = {",
|
|
57
|
-
" healthy: 'transparent',",
|
|
58
|
-
" buffering: 'var(--stream-tint-buffering)',",
|
|
59
|
-
" stalled: 'var(--stream-tint-stalled)',",
|
|
60
|
-
" recovering: 'var(--stream-tint-recovering)',",
|
|
61
|
-
" error: 'var(--stream-tint-error)'",
|
|
62
|
-
" };",
|
|
63
|
-
" return tints[health] || 'transparent';",
|
|
55
|
+
" const parsed = URL.parse(url);",
|
|
56
|
+
" if(!parsed) return url;",
|
|
57
|
+
" const parts = parsed.hostname.split('.');",
|
|
58
|
+
" return (parts.length > 2) ? parts.slice(-2).join('.') : parts.join('.');",
|
|
64
59
|
"}",
|
|
65
|
-
//
|
|
60
|
+
// Resolve the recovery-level label for a stream in the recovering state. Kept as a dedicated helper because the mapping is level-based, not string-based,
|
|
61
|
+
// so it cannot fold into the same lookup map as the top-level health labels. Escalation level semantics defined in monitor.ts:
|
|
66
62
|
// L1=play/unmute, L2=seek, L3=source reload, L4=page navigation.
|
|
67
|
-
"function
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
" else if (health === 'recovering') {",
|
|
74
|
-
" if (level === 1) { label = 'Resuming playback'; }",
|
|
75
|
-
" else if (level === 2) { label = 'Syncing to live'; }",
|
|
76
|
-
" else if (level === 3) { label = 'Reloading player'; }",
|
|
77
|
-
" else if (level >= 4) { label = 'Reloading page'; }",
|
|
78
|
-
" else { label = 'Recovering'; }",
|
|
63
|
+
"function getRecoveringLabel(level) {",
|
|
64
|
+
" switch(level) {",
|
|
65
|
+
" case 1: return 'Resuming playback';",
|
|
66
|
+
" case 2: return 'Syncing to live';",
|
|
67
|
+
" case 3: return 'Reloading player';",
|
|
68
|
+
" default: return (level >= 4) ? 'Reloading page' : 'Recovering';",
|
|
79
69
|
" }",
|
|
80
|
-
"
|
|
81
|
-
|
|
70
|
+
"}",
|
|
71
|
+
// Static labels for each top-level health state. The recovering state delegates to getRecoveringLabel which dispatches on the escalation level.
|
|
72
|
+
"const healthLabels = { healthy: 'Healthy', buffering: 'Buffering', stalled: 'Stalled', error: 'Error' };",
|
|
73
|
+
// Get health badge HTML using CSS variables for theme-aware colors.
|
|
74
|
+
"function getHealthBadge(health, level) {",
|
|
75
|
+
" const label = (health === 'recovering') ? getRecoveringLabel(level) : (healthLabels[health] ?? health);",
|
|
76
|
+
" return '<span class=\"status-dot\" style=\"color: ' + (healthColorVars[health] ?? 'var(--text-muted)') + ';\">●</span> ' +",
|
|
82
77
|
" '<span style=\"color: var(--text-secondary);\">' + label + '</span>';",
|
|
83
78
|
"}",
|
|
84
79
|
// Update system status display in header. Shows system health (green dot when connected, red with label when not) and stream count.
|
|
85
80
|
"function updateSystemStatus() {",
|
|
86
81
|
" if(!systemData) return;",
|
|
87
|
-
"
|
|
88
|
-
"
|
|
82
|
+
" const healthEl = document.getElementById('system-health');",
|
|
83
|
+
" const streamEl = document.getElementById('stream-count');",
|
|
89
84
|
" if(systemData.browser.connected) {",
|
|
90
85
|
" healthEl.innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-healthy);\">●</span>';",
|
|
91
86
|
" } else {",
|
|
92
87
|
" healthEl.innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-error);\">●</span> Browser offline';",
|
|
93
88
|
" }",
|
|
94
|
-
"
|
|
95
|
-
"
|
|
89
|
+
" const active = systemData.streams.active;",
|
|
90
|
+
" const limit = systemData.streams.limit;",
|
|
96
91
|
" if(active === 0) {",
|
|
97
92
|
" streamEl.textContent = '0 streams';",
|
|
98
93
|
" streamEl.classList.remove('clickable');",
|
|
99
|
-
"
|
|
94
|
+
" const popMenu = document.getElementById('stream-popover-menu');",
|
|
100
95
|
" if(popMenu) popMenu.classList.remove('show');",
|
|
101
96
|
" } else {",
|
|
102
97
|
" streamEl.textContent = active + '/' + limit + ' streams';",
|
|
@@ -105,16 +100,14 @@ export function generateStatusScript() {
|
|
|
105
100
|
"}",
|
|
106
101
|
// Build the popover content from streamData. Populates the given menu element with one row per active stream.
|
|
107
102
|
"function buildStreamPopoverContent(menu) {",
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
116
|
-
" var hwBadge = s.hardwareAccelerated ? ' <span title=\"Hardware accelerated\">\\u26A1</span>' : '';",
|
|
117
|
-
" var showSuffix = s.showName ? ' <span class=\"stream-popover-show\">' + s.showName + '</span>' : '';",
|
|
103
|
+
" let html = '';",
|
|
104
|
+
" const now = Date.now();",
|
|
105
|
+
" for(const s of Object.values(streamData)) {",
|
|
106
|
+
" const color = healthColorVars[s.health] ?? 'var(--text-muted)';",
|
|
107
|
+
" const name = s.channel || s.serviceName || getDomain(s.url);",
|
|
108
|
+
" const dur = Math.floor((now - new Date(s.startTime).getTime()) / 1000);",
|
|
109
|
+
" const hwBadge = s.hardwareAccelerated ? ' <span title=\"Hardware accelerated\">\\u26A1</span>' : '';",
|
|
110
|
+
" const showSuffix = s.showName ? ' <span class=\"stream-popover-show\">' + s.showName + '</span>' : '';",
|
|
118
111
|
" html += '<div class=\"stream-popover-row\">';",
|
|
119
112
|
" html += '<span class=\"status-dot\" style=\"color: ' + color + ';\">●</span>';",
|
|
120
113
|
" html += channelDisplayHtml(s.logoUrl, name, 'stream-popover-logo', 'stream-popover-channel');",
|
|
@@ -126,9 +119,9 @@ export function generateStatusScript() {
|
|
|
126
119
|
"}",
|
|
127
120
|
// Update an already-open stream popover with current data. Called from SSE handlers and the duration interval.
|
|
128
121
|
"function updateStreamPopover() {",
|
|
129
|
-
"
|
|
122
|
+
" const menu = document.getElementById('stream-popover-menu');",
|
|
130
123
|
" if(!menu || !menu.classList.contains('show')) return;",
|
|
131
|
-
"
|
|
124
|
+
" const ids = Object.keys(streamData);",
|
|
132
125
|
" if(ids.length === 0) {",
|
|
133
126
|
" menu.classList.remove('show');",
|
|
134
127
|
" return;",
|
|
@@ -136,13 +129,13 @@ export function generateStatusScript() {
|
|
|
136
129
|
" buildStreamPopoverContent(menu);",
|
|
137
130
|
"}",
|
|
138
131
|
// Toggle the stream popover open or closed.
|
|
139
|
-
"window.toggleStreamPopover =
|
|
140
|
-
"
|
|
132
|
+
"window.toggleStreamPopover = () => {",
|
|
133
|
+
" const ids = Object.keys(streamData);",
|
|
141
134
|
" if(ids.length === 0) return;",
|
|
142
|
-
"
|
|
135
|
+
" const menu = document.getElementById('stream-popover-menu');",
|
|
143
136
|
" if(!menu) return;",
|
|
144
|
-
"
|
|
145
|
-
"
|
|
137
|
+
" const isOpen = menu.classList.contains('show');",
|
|
138
|
+
" dropdowns.close();",
|
|
146
139
|
" if(!isOpen) {",
|
|
147
140
|
" buildStreamPopoverContent(menu);",
|
|
148
141
|
" menu.classList.add('show');",
|
|
@@ -150,95 +143,120 @@ export function generateStatusScript() {
|
|
|
150
143
|
"};",
|
|
151
144
|
// Format last issue for display.
|
|
152
145
|
"function formatLastIssue(s) {",
|
|
153
|
-
" if
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
146
|
+
" if(!s.lastIssueType || !s.lastIssueTime) { return 'None'; }",
|
|
147
|
+
" const issueLabel = s.lastIssueType.charAt(0).toUpperCase() + s.lastIssueType.slice(1);",
|
|
148
|
+
" const timeStr = formatTime(new Date(s.lastIssueTime).toISOString());",
|
|
149
|
+
" const status = (s.health === 'healthy') ? ' (recovered)' : ' (recovering)';",
|
|
157
150
|
" return issueLabel + ' at ' + timeStr + status;",
|
|
158
151
|
"}",
|
|
159
152
|
// Format auto-recovery info for display.
|
|
160
153
|
"function formatAutoRecovery(s) {",
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
" if
|
|
164
|
-
"
|
|
165
|
-
" if
|
|
154
|
+
" const attempts = s.recoveryAttempts;",
|
|
155
|
+
" const reloads = s.pageReloadsInWindow;",
|
|
156
|
+
" if(attempts === 0) { return 'N/A'; }",
|
|
157
|
+
" let str = attempts + (attempts === 1 ? ' attempt' : ' attempts');",
|
|
158
|
+
" if(reloads > 0) { str += ', ' + reloads + (reloads === 1 ? ' page reload' : ' page reloads'); }",
|
|
166
159
|
" return str;",
|
|
167
160
|
"}",
|
|
168
161
|
// Format client type breakdown for the detail row.
|
|
169
162
|
"function formatClients(s) {",
|
|
170
|
-
" if
|
|
171
|
-
"
|
|
172
|
-
"
|
|
173
|
-
"
|
|
174
|
-
" var c = s.clients[i];",
|
|
175
|
-
" parts.push(c.count + ' ' + (labels[c.type] || c.type));",
|
|
163
|
+
" if(s.clientCount === 0) { return 'None'; }",
|
|
164
|
+
" const parts = [];",
|
|
165
|
+
" for(const c of s.clients) {",
|
|
166
|
+
" parts.push(c.count + ' ' + (clientTypeLabels[c.type] ?? c.type));",
|
|
176
167
|
" }",
|
|
177
168
|
" return parts.join(', ');",
|
|
178
169
|
"}",
|
|
170
|
+
// Render scheduling. All DOM-writing functions funnel through requestAnimationFrame gates so multiple SSE events arriving within the same frame (e.g., two
|
|
171
|
+
// streamHealthChanged events 150ms apart) produce a single DOM write instead of redundant back-to-back rebuilds that destroy and recreate image elements.
|
|
172
|
+
"let tableRenderPending = false;",
|
|
173
|
+
"let popoverRenderPending = false;",
|
|
174
|
+
"function scheduleTableRender() {",
|
|
175
|
+
" if(!tableRenderPending) {",
|
|
176
|
+
" tableRenderPending = true;",
|
|
177
|
+
" requestAnimationFrame(() => { tableRenderPending = false; renderStreamsTable(); });",
|
|
178
|
+
" }",
|
|
179
|
+
"}",
|
|
180
|
+
"function schedulePopoverRender() {",
|
|
181
|
+
" if(!popoverRenderPending) {",
|
|
182
|
+
" popoverRenderPending = true;",
|
|
183
|
+
" requestAnimationFrame(() => { popoverRenderPending = false; updateStreamPopover(); });",
|
|
184
|
+
" }",
|
|
185
|
+
"}",
|
|
186
|
+
// Shared cell content renderers. Used by both renderStreamsTable (full rebuild) and updateStreamRow (targeted update) so that each cell's HTML is produced
|
|
187
|
+
// by a single function. This prevents the two rendering paths from diverging — if a cell format changes, the shared function is the only place to update.
|
|
188
|
+
// Health cell: client indicator dot + health badge.
|
|
189
|
+
"function renderHealthCellContent(s) {",
|
|
190
|
+
" let clientIndicator = '';",
|
|
191
|
+
" if(s.clientCount > 0) {",
|
|
192
|
+
" const title = s.clientCount + (s.clientCount !== 1 ? ' clients' : ' client');",
|
|
193
|
+
" clientIndicator = '<span class=\"client-count\" title=\"' + title + '\">◉ ' + s.clientCount + '</span> ';",
|
|
194
|
+
" }",
|
|
195
|
+
" return clientIndicator + getHealthBadge(s.health, s.escalationLevel);",
|
|
196
|
+
"}",
|
|
197
|
+
// Detail panel: codec line with mode and native quality suffix.
|
|
198
|
+
"function renderDetailCodec(s) {",
|
|
199
|
+
" const codecLabel = s.captureCodec ? (s.hardwareAccelerated ? '\\u26A1 ' + s.captureCodec : s.captureCodec) :",
|
|
200
|
+
" (s.streamingMode === 'native' ? 'Native HLS' : 'Unknown');",
|
|
201
|
+
" const modeLabel = s.streamingMode === 'native' ? 'Native HLS' : 'Capture';",
|
|
202
|
+
" let qualitySuffix = '';",
|
|
203
|
+
" if(s.streamingMode === 'native') {",
|
|
204
|
+
" const qParts = [];",
|
|
205
|
+
" if(s.nativeBandwidth > 0) { qParts.push((s.nativeBandwidth / 1000000).toFixed(1) + 'Mbps'); }",
|
|
206
|
+
" if(s.nativeResolution) {",
|
|
207
|
+
" const h = s.nativeResolution.split('x')[1];",
|
|
208
|
+
" qParts.push(nativeResolutionLabels[h] ?? s.nativeResolution);",
|
|
209
|
+
" }",
|
|
210
|
+
" if(qParts.length > 0) { qualitySuffix = ' - ' + qParts.join(' '); }",
|
|
211
|
+
" }",
|
|
212
|
+
" return '<strong>Codec:</strong> ' + codecLabel + ' (' + modeLabel + ')' + qualitySuffix;",
|
|
213
|
+
"}",
|
|
214
|
+
// Detail panel: started time with optional client count suffix.
|
|
215
|
+
"function renderDetailStarted(s) {",
|
|
216
|
+
" const clientSuffix = s.clientCount > 0 ? ' · ' + formatClients(s) : '';",
|
|
217
|
+
" return '<strong>Started:</strong> ' + formatTime(s.startTime) + clientSuffix;",
|
|
218
|
+
"}",
|
|
179
219
|
// Render the streams table.
|
|
180
220
|
"function renderStreamsTable() {",
|
|
181
|
-
"
|
|
182
|
-
" if
|
|
183
|
-
"
|
|
184
|
-
" if
|
|
221
|
+
" const tbody = document.getElementById('streams-tbody');",
|
|
222
|
+
" if(!tbody) return;",
|
|
223
|
+
" const entries = Object.entries(streamData);",
|
|
224
|
+
" if(entries.length === 0) {",
|
|
185
225
|
" tbody.innerHTML = '<tr class=\"empty-row\"><td colspan=\"4\">No active streams</td></tr>';",
|
|
186
226
|
" return;",
|
|
187
227
|
" }",
|
|
188
|
-
"
|
|
189
|
-
" for
|
|
190
|
-
"
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
" var channelText = s.channel || s.providerName || getDomain(s.url);",
|
|
196
|
-
" var channelDisplay = channelDisplayHtml(s.logoUrl, channelText, 'channel-logo', 'channel-text');",
|
|
228
|
+
" let html = '';",
|
|
229
|
+
" for(const [ id, s ] of entries) {",
|
|
230
|
+
" const isExpanded = expandedStreams[id];",
|
|
231
|
+
" const chevron = isExpanded ? '▼' : '▶';",
|
|
232
|
+
" const rowTint = rowTints[s.health] ?? 'transparent';",
|
|
233
|
+
" const channelText = s.channel || s.serviceName || getDomain(s.url);",
|
|
234
|
+
" const channelDisplay = channelDisplayHtml(s.logoUrl, channelText, 'channel-logo', 'channel-text');",
|
|
197
235
|
" html += '<tr class=\"stream-row\" data-id=\"' + id + '\" onclick=\"toggleStreamDetails(' + id + ')\" style=\"background-color: ' + rowTint + ';\">';",
|
|
198
236
|
" html += '<td class=\"chevron\">' + chevron + '</td>';",
|
|
199
|
-
"
|
|
200
|
-
"
|
|
201
|
-
" if
|
|
237
|
+
" const hwIcon = s.hardwareAccelerated ? '\\u26A1 ' : '';",
|
|
238
|
+
" let nativeBadge = '';",
|
|
239
|
+
" if(s.streamingMode === 'native') {",
|
|
202
240
|
" nativeBadge = ' <span class=\"native-badge\" title=\"Native HLS\">Native</span>';",
|
|
203
|
-
" } else if
|
|
241
|
+
" } else if(s.hardwareAccelerated) {",
|
|
204
242
|
" nativeBadge = ' <span class=\"native-badge\" title=\"Hardware accelerated\">' + hwIcon + (s.captureCodec || '') + '</span>';",
|
|
205
243
|
" }",
|
|
206
|
-
"
|
|
244
|
+
" const durationSpan = '<span class=\"stream-duration\" id=\"duration-' + id + '\">\\u00b7 ' + formatDuration(s.duration) + '</span>';",
|
|
207
245
|
" html += '<td class=\"stream-info\">' + channelDisplay + nativeBadge + ' ' + durationSpan + '</td>';",
|
|
208
|
-
"
|
|
246
|
+
" const showDisplay = s.showName ? s.showName : '';",
|
|
209
247
|
" html += '<td class=\"stream-show\">' + showDisplay + '</td>';",
|
|
210
|
-
"
|
|
211
|
-
" if (s.clientCount > 0) {",
|
|
212
|
-
" var title = s.clientCount + (s.clientCount !== 1 ? ' clients' : ' client');",
|
|
213
|
-
" clientIndicator = '<span class=\"client-count\" title=\"' + title + '\">◉ ' + s.clientCount + '</span> ';",
|
|
214
|
-
" }",
|
|
215
|
-
" html += '<td class=\"stream-health\">' + clientIndicator + getHealthBadge(s.health, s.escalationLevel) + '</td>';",
|
|
248
|
+
" html += '<td class=\"stream-health\">' + renderHealthCellContent(s) + '</td>';",
|
|
216
249
|
" html += '</tr>';",
|
|
217
|
-
" if
|
|
250
|
+
" if(isExpanded) {",
|
|
218
251
|
" html += '<tr class=\"stream-details\" data-id=\"' + id + '\">';",
|
|
219
252
|
" html += '<td colspan=\"4\">';",
|
|
220
253
|
" html += '<div class=\"details-content\">';",
|
|
221
254
|
" html += '<div class=\"details-header\">';",
|
|
222
255
|
" html += '<div class=\"details-url\">' + s.url + '</div>';",
|
|
223
|
-
"
|
|
224
|
-
" html += '<div class=\"details-started\"><strong>Started:</strong> ' + formatTime(s.startTime) + clientSuffix + '</div>';",
|
|
256
|
+
" html += '<div class=\"details-started\">' + renderDetailStarted(s) + '</div>';",
|
|
225
257
|
" html += '</div>';",
|
|
226
258
|
" html += '<div class=\"details-metrics\">';",
|
|
227
|
-
"
|
|
228
|
-
" (s.streamingMode === 'native' ? 'Native HLS' : 'Unknown');",
|
|
229
|
-
" var modeLabel = s.streamingMode === 'native' ? 'Native HLS' : 'Capture';",
|
|
230
|
-
" var qualitySuffix = '';",
|
|
231
|
-
" if (s.streamingMode === 'native') {",
|
|
232
|
-
" var qParts = [];",
|
|
233
|
-
" if (s.nativeBandwidth > 0) { qParts.push((s.nativeBandwidth / 1000000).toFixed(1) + 'Mbps'); }",
|
|
234
|
-
" if (s.nativeResolution) {",
|
|
235
|
-
" var resLabels = { '1080': '1080p', '2160': '4K', '360': '360p', '480': '480p', '720': '720p' };",
|
|
236
|
-
" var h = s.nativeResolution.split('x')[1];",
|
|
237
|
-
" qParts.push(resLabels[h] || s.nativeResolution);",
|
|
238
|
-
" }",
|
|
239
|
-
" if (qParts.length > 0) { qualitySuffix = ' - ' + qParts.join(' '); }",
|
|
240
|
-
" }",
|
|
241
|
-
" html += '<div class=\"details-codec\"><strong>Codec:</strong> ' + codecLabel + ' (' + modeLabel + ')' + qualitySuffix + '</div>';",
|
|
259
|
+
" html += '<div class=\"details-codec\">' + renderDetailCodec(s) + '</div>';",
|
|
242
260
|
" html += '<div class=\"details-issue\"><strong>Last issue:</strong> ' + formatLastIssue(s) + '</div>';",
|
|
243
261
|
" html += '<div class=\"details-recovery\"><strong>Recovery:</strong> ' + formatAutoRecovery(s) + '</div>';",
|
|
244
262
|
" html += '<div class=\"details-memory\"><strong>Memory:</strong> ' + formatBytes(s.memoryBytes) + '</div>';",
|
|
@@ -249,6 +267,37 @@ export function generateStatusScript() {
|
|
|
249
267
|
" }",
|
|
250
268
|
" tbody.innerHTML = html;",
|
|
251
269
|
"}",
|
|
270
|
+
// Targeted update for a single stream row. Updates only the cells that change between health ticks (health badge, show name, client count, row tint, and
|
|
271
|
+
// detail panel metrics if expanded). Leaves the logo, channel name, badge, and structural elements untouched so image elements are never destroyed and
|
|
272
|
+
// recreated. Falls back to a full table render if the row doesn't exist yet (e.g., race between streamAdded and streamHealthChanged).
|
|
273
|
+
"function updateStreamRow(s) {",
|
|
274
|
+
" const row = document.querySelector('.stream-row[data-id=\"' + s.id + '\"]');",
|
|
275
|
+
" if(!row) { scheduleTableRender(); return; }",
|
|
276
|
+
// Row tint.
|
|
277
|
+
" row.style.backgroundColor = rowTints[s.health] ?? 'transparent';",
|
|
278
|
+
// Health badge cell.
|
|
279
|
+
" const healthCell = row.querySelector('.stream-health');",
|
|
280
|
+
" if(healthCell) { healthCell.innerHTML = renderHealthCellContent(s); }",
|
|
281
|
+
// Show name cell.
|
|
282
|
+
" const showCell = row.querySelector('.stream-show');",
|
|
283
|
+
" if(showCell) { showCell.textContent = s.showName || ''; }",
|
|
284
|
+
// Detail panel (if expanded). The detail row is a sibling <tr> with the same data-id.
|
|
285
|
+
" if(expandedStreams[s.id]) {",
|
|
286
|
+
" const detailRow = document.querySelector('.stream-details[data-id=\"' + s.id + '\"]');",
|
|
287
|
+
" if(detailRow) {",
|
|
288
|
+
" const issueEl = detailRow.querySelector('.details-issue');",
|
|
289
|
+
" if(issueEl) { issueEl.innerHTML = '<strong>Last issue:</strong> ' + formatLastIssue(s); }",
|
|
290
|
+
" const recoveryEl = detailRow.querySelector('.details-recovery');",
|
|
291
|
+
" if(recoveryEl) { recoveryEl.innerHTML = '<strong>Recovery:</strong> ' + formatAutoRecovery(s); }",
|
|
292
|
+
" const memoryEl = detailRow.querySelector('.details-memory');",
|
|
293
|
+
" if(memoryEl) { memoryEl.innerHTML = '<strong>Memory:</strong> ' + formatBytes(s.memoryBytes); }",
|
|
294
|
+
" const codecEl = detailRow.querySelector('.details-codec');",
|
|
295
|
+
" if(codecEl) { codecEl.innerHTML = renderDetailCodec(s); }",
|
|
296
|
+
" const startedEl = detailRow.querySelector('.details-started');",
|
|
297
|
+
" if(startedEl) { startedEl.innerHTML = renderDetailStarted(s); }",
|
|
298
|
+
" }",
|
|
299
|
+
" }",
|
|
300
|
+
"}",
|
|
252
301
|
// Toggle stream details.
|
|
253
302
|
"function toggleStreamDetails(id) {",
|
|
254
303
|
" expandedStreams[id] = !expandedStreams[id];",
|
|
@@ -257,41 +306,38 @@ export function generateStatusScript() {
|
|
|
257
306
|
// Update stream durations every second. We calculate duration from the immutable startTime rather than incrementing a counter, ensuring the displayed duration is
|
|
258
307
|
// always accurate regardless of any staleness in server-sent updates.
|
|
259
308
|
"function updateDurations() {",
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"
|
|
264
|
-
"
|
|
265
|
-
" var durationSec = Math.floor((now - new Date(s.startTime).getTime()) / 1000);",
|
|
266
|
-
" var el = document.getElementById('duration-' + id);",
|
|
267
|
-
" if (el) el.textContent = '\\u00b7 ' + formatDuration(durationSec);",
|
|
309
|
+
" const now = Date.now();",
|
|
310
|
+
" for(const [ id, s ] of Object.entries(streamData)) {",
|
|
311
|
+
" const durationSec = Math.floor((now - new Date(s.startTime).getTime()) / 1000);",
|
|
312
|
+
" const el = document.getElementById('duration-' + id);",
|
|
313
|
+
" if(el) el.textContent = '\\u00b7 ' + formatDuration(durationSec);",
|
|
268
314
|
" }",
|
|
269
|
-
"
|
|
315
|
+
" schedulePopoverRender();",
|
|
270
316
|
"}",
|
|
271
317
|
// Track the last time any SSE event was received from the status stream. Used by the staleness checker to detect silently dead connections.
|
|
272
|
-
"
|
|
273
|
-
"
|
|
318
|
+
"let lastStatusEventTime = Date.now();",
|
|
319
|
+
"let hiddenSince = 0;",
|
|
274
320
|
// Real-time health indicator updates. These functions update channel health icons and provider login icons in the Channels tab without a page refresh.
|
|
275
321
|
"function formatTimeAgo(ts) {",
|
|
276
|
-
"
|
|
322
|
+
" const seconds = Math.floor((Date.now() - ts) / 1000);",
|
|
277
323
|
" if(seconds < 60) { return 'just now'; }",
|
|
278
|
-
"
|
|
324
|
+
" const minutes = Math.floor(seconds / 60);",
|
|
279
325
|
" if(minutes < 60) { return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago'); }",
|
|
280
|
-
"
|
|
326
|
+
" const hours = Math.floor(minutes / 60);",
|
|
281
327
|
" if(hours < 24) { return hours + (hours === 1 ? ' hour ago' : ' hours ago'); }",
|
|
282
|
-
"
|
|
328
|
+
" const days = Math.floor(hours / 24);",
|
|
283
329
|
" return days + (days === 1 ? ' day ago' : ' days ago');",
|
|
284
330
|
"}",
|
|
285
331
|
"function updateChannelHealth(channelKey, status, timestamp, domain) {",
|
|
286
|
-
"
|
|
332
|
+
" const row = document.getElementById('display-row-' + channelKey);",
|
|
287
333
|
" if(!row) { return; }",
|
|
288
334
|
// If a domain was supplied (snapshot and real-time events both include it), verify it matches the currently selected domain for this channel. The login button
|
|
289
|
-
// carries a data-auth-domain attribute with the selected domain. If they differ the health entry is from a previous
|
|
335
|
+
// carries a data-auth-domain attribute with the selected domain. If they differ the health entry is from a previous service and should be ignored.
|
|
290
336
|
" if(domain) {",
|
|
291
|
-
"
|
|
337
|
+
" const loginBtn = row.querySelector('.btn-icon-login');",
|
|
292
338
|
" if(loginBtn && loginBtn.getAttribute('data-auth-domain') !== domain) { return; }",
|
|
293
339
|
" }",
|
|
294
|
-
"
|
|
340
|
+
" const icon = row.querySelector('.btn-icon-health');",
|
|
295
341
|
" if(!icon) { return; }",
|
|
296
342
|
" icon.classList.remove('health-success', 'health-failed');",
|
|
297
343
|
" if(status === 'success') { icon.classList.add('health-success'); }",
|
|
@@ -299,20 +345,18 @@ export function generateStatusScript() {
|
|
|
299
345
|
" icon.title = (status === 'success' ? 'Succeeded ' : 'Failed ') + formatTimeAgo(timestamp);",
|
|
300
346
|
"}",
|
|
301
347
|
"function updateDomainAuth(domain, timestamp) {",
|
|
302
|
-
"
|
|
303
|
-
" for(
|
|
304
|
-
"
|
|
305
|
-
"
|
|
348
|
+
" const buttons = document.querySelectorAll('.btn-icon-login[data-auth-domain=\"' + domain + '\"]');",
|
|
349
|
+
" for(const button of buttons) {",
|
|
350
|
+
" button.classList.add('health-success');",
|
|
351
|
+
" button.title = 'Verified ' + formatTimeAgo(timestamp);",
|
|
306
352
|
" }",
|
|
307
353
|
"}",
|
|
308
354
|
"function applyHealthSnapshot(data) {",
|
|
309
|
-
"
|
|
310
|
-
"
|
|
311
|
-
" var entry = data.channels[key];",
|
|
312
|
-
" updateChannelHealth(key, entry.status, entry.timestamp, entry.domain);",
|
|
355
|
+
" for(const [ channelKey, entry ] of Object.entries(data.channels)) {",
|
|
356
|
+
" updateChannelHealth(channelKey, entry.status, entry.timestamp, entry.domain);",
|
|
313
357
|
" }",
|
|
314
|
-
" for(
|
|
315
|
-
" updateDomainAuth(
|
|
358
|
+
" for(const [ domain, timestamp ] of Object.entries(data.domains)) {",
|
|
359
|
+
" updateDomainAuth(domain, timestamp);",
|
|
316
360
|
" }",
|
|
317
361
|
"}",
|
|
318
362
|
// Connect (or reconnect) to the status SSE stream. Closes any existing connection first so this is safe to call repeatedly.
|
|
@@ -324,62 +368,69 @@ export function generateStatusScript() {
|
|
|
324
368
|
// be registered with just on('heartbeat') for pure keepalive tracking. The onerror handler stays outside this wrapper because errors must
|
|
325
369
|
// not reset the staleness timer — a connection that only fires errors is still dead.
|
|
326
370
|
" function on(event, handler) {",
|
|
327
|
-
" statusEventSource.addEventListener(event,
|
|
371
|
+
" statusEventSource.addEventListener(event, (e) => {",
|
|
328
372
|
" lastStatusEventTime = Date.now();",
|
|
329
373
|
" if(handler) { handler(e); }",
|
|
330
374
|
" });",
|
|
331
375
|
" }",
|
|
332
376
|
" on('heartbeat');",
|
|
333
|
-
" on('snapshot',
|
|
334
|
-
"
|
|
377
|
+
" on('snapshot', (e) => {",
|
|
378
|
+
" const data = JSON.parse(e.data);",
|
|
335
379
|
" systemData = data.system;",
|
|
336
380
|
" streamData = {};",
|
|
337
|
-
" for
|
|
338
|
-
" streamData[
|
|
381
|
+
" for(const stream of data.streams) {",
|
|
382
|
+
" streamData[stream.id] = stream;",
|
|
339
383
|
" }",
|
|
340
384
|
" updateSystemStatus();",
|
|
341
385
|
" renderStreamsTable();",
|
|
342
386
|
" updateStreamPopover();",
|
|
343
387
|
" if(data.health) { applyHealthSnapshot(data.health); }",
|
|
344
388
|
" });",
|
|
345
|
-
" on('streamAdded',
|
|
346
|
-
"
|
|
389
|
+
" on('streamAdded', (e) => {",
|
|
390
|
+
" const s = JSON.parse(e.data);",
|
|
347
391
|
" streamData[s.id] = s;",
|
|
348
392
|
" renderStreamsTable();",
|
|
349
393
|
" updateStreamPopover();",
|
|
350
394
|
" });",
|
|
351
|
-
" on('streamRemoved',
|
|
352
|
-
"
|
|
395
|
+
" on('streamRemoved', (e) => {",
|
|
396
|
+
" const data = JSON.parse(e.data);",
|
|
353
397
|
" delete streamData[data.id];",
|
|
354
398
|
" delete expandedStreams[data.id];",
|
|
355
399
|
" renderStreamsTable();",
|
|
356
400
|
" updateStreamPopover();",
|
|
357
|
-
" if
|
|
401
|
+
" if(typeof updateRestartDialogStatus === 'function') {",
|
|
358
402
|
" updateRestartDialogStatus();",
|
|
359
403
|
" }",
|
|
360
404
|
" });",
|
|
361
|
-
" on('streamHealthChanged',
|
|
362
|
-
"
|
|
363
|
-
"
|
|
405
|
+
" on('streamHealthChanged', (e) => {",
|
|
406
|
+
" const s = JSON.parse(e.data);",
|
|
407
|
+
" const prev = streamData[s.id];",
|
|
408
|
+
" if(prev) {",
|
|
409
|
+
" const structuralChange = (prev.logoUrl !== s.logoUrl) || (prev.streamingMode !== s.streamingMode) ||",
|
|
410
|
+
" (prev.hardwareAccelerated !== s.hardwareAccelerated) || (prev.captureCodec !== s.captureCodec);",
|
|
364
411
|
" streamData[s.id] = s;",
|
|
365
|
-
"
|
|
366
|
-
"
|
|
412
|
+
" if(structuralChange) {",
|
|
413
|
+
" scheduleTableRender();",
|
|
414
|
+
" } else {",
|
|
415
|
+
" updateStreamRow(s);",
|
|
416
|
+
" }",
|
|
417
|
+
" schedulePopoverRender();",
|
|
367
418
|
" }",
|
|
368
419
|
" });",
|
|
369
|
-
" on('systemStatusChanged',
|
|
420
|
+
" on('systemStatusChanged', (e) => {",
|
|
370
421
|
" systemData = JSON.parse(e.data);",
|
|
371
422
|
" updateSystemStatus();",
|
|
372
423
|
" });",
|
|
373
|
-
" on('healthChanged',
|
|
374
|
-
"
|
|
424
|
+
" on('healthChanged', (e) => {",
|
|
425
|
+
" const event = JSON.parse(e.data);",
|
|
375
426
|
" updateChannelHealth(event.channelKey, event.status, event.timestamp, event.domain);",
|
|
376
427
|
" if(event.status === 'success') { updateDomainAuth(event.domain, event.timestamp); }",
|
|
377
428
|
" });",
|
|
378
|
-
// Channel table updates from the server (e.g., logo population after startup). Applies the patch via the shared
|
|
379
|
-
" on('channelUpdate',
|
|
380
|
-
"
|
|
429
|
+
// Channel table updates from the server (e.g., logo population after startup). Applies the patch via the shared channelTable namespace.
|
|
430
|
+
" on('channelUpdate', (e) => {",
|
|
431
|
+
" channelTable.applyPatch(JSON.parse(e.data));",
|
|
381
432
|
" });",
|
|
382
|
-
" statusEventSource.onerror =
|
|
433
|
+
" statusEventSource.onerror = () => {",
|
|
383
434
|
" document.getElementById('system-health').innerHTML = '<span class=\"status-dot\" style=\"color: var(--stream-stalled);\">●</span> Updates paused';",
|
|
384
435
|
" };",
|
|
385
436
|
"}",
|
|
@@ -387,18 +438,18 @@ export function generateStatusScript() {
|
|
|
387
438
|
"connectStatusSSE();",
|
|
388
439
|
"setInterval(updateDurations, 1000);",
|
|
389
440
|
// Staleness detection: if no SSE event has arrived in 45 seconds, the connection is likely dead. Reconnect proactively.
|
|
390
|
-
"setInterval(
|
|
441
|
+
"setInterval(() => {",
|
|
391
442
|
" if((Date.now() - lastStatusEventTime) > 45000) { connectStatusSSE(); }",
|
|
392
443
|
"}, 45000);",
|
|
393
444
|
// Visibility-driven reconnect. When the page returns from being hidden for more than 30 seconds, reconnect the status stream and re-activate
|
|
394
445
|
// the current tab so the logs stream reconnects naturally through its existing tabactivated listener.
|
|
395
|
-
"document.addEventListener('visibilitychange',
|
|
446
|
+
"document.addEventListener('visibilitychange', () => {",
|
|
396
447
|
" if(document.hidden) {",
|
|
397
448
|
" hiddenSince = Date.now();",
|
|
398
449
|
" } else if((hiddenSince > 0) && ((Date.now() - hiddenSince) > 30000)) {",
|
|
399
450
|
" hiddenSince = 0;",
|
|
400
451
|
" connectStatusSSE();",
|
|
401
|
-
"
|
|
452
|
+
" const activeTab = document.querySelector('.tab-btn.active');",
|
|
402
453
|
" if(activeTab) {",
|
|
403
454
|
" document.dispatchEvent(new CustomEvent('tabactivated', { detail: { category: activeTab.getAttribute('data-category') } }));",
|
|
404
455
|
" }",
|
|
@@ -406,42 +457,36 @@ export function generateStatusScript() {
|
|
|
406
457
|
" hiddenSince = 0;",
|
|
407
458
|
" }",
|
|
408
459
|
"});",
|
|
409
|
-
// Copy playlist URL function for Overview tab Quick Start section.
|
|
410
|
-
|
|
411
|
-
"
|
|
412
|
-
"
|
|
413
|
-
"
|
|
414
|
-
" var feedback = document.getElementById('overview-copy-feedback');",
|
|
415
|
-
" if(feedback) {",
|
|
416
|
-
" feedback.style.display = 'inline';",
|
|
417
|
-
" setTimeout(function() { feedback.style.display = 'none'; }, 2000);",
|
|
418
|
-
" }",
|
|
419
|
-
" }",
|
|
460
|
+
// Copy playlist URL function for Overview tab Quick Start section. Delegates to the shared copyToClipboard utility for consistent clipboard handling
|
|
461
|
+
// across all contexts (HTTPS Clipboard API with execCommand fallback for plain HTTP).
|
|
462
|
+
"window.copyOverviewPlaylistUrl = () => {",
|
|
463
|
+
" const urlEl = document.getElementById('overview-playlist-url');",
|
|
464
|
+
" if(urlEl) { copyToClipboard(urlEl.textContent, 'Playlist URL copied to clipboard.'); }",
|
|
420
465
|
"};",
|
|
421
466
|
// JS-based tooltips for devices where the primary input can't hover (iPadOS). Safari on iPadOS doesn't show native title tooltips, so we use
|
|
422
467
|
// a single <div> appended to <body> and positioned via getBoundingClientRect(). This is immune to overflow containers and stacking contexts.
|
|
423
468
|
// On pure-touch devices without a trackpad, mouseenter never fires so the tooltip stays hidden. Desktop skips initialization entirely.
|
|
424
469
|
"(function() {",
|
|
425
470
|
" if(!window.matchMedia('(hover: none)').matches) return;",
|
|
426
|
-
"
|
|
471
|
+
" const tip = document.createElement('div');",
|
|
427
472
|
" tip.className = 'btn-icon-tooltip';",
|
|
428
473
|
" document.body.appendChild(tip);",
|
|
429
|
-
" document.addEventListener('mouseenter',
|
|
430
|
-
"
|
|
474
|
+
" document.addEventListener('mouseenter', (e) => {",
|
|
475
|
+
" const btn = e.target.closest('.btn-icon[aria-label]');",
|
|
431
476
|
" if(!btn) return;",
|
|
432
|
-
"
|
|
477
|
+
" const label = btn.getAttribute('title') || btn.getAttribute('aria-label');",
|
|
433
478
|
" if(!label) return;",
|
|
434
|
-
"
|
|
479
|
+
" const rect = btn.getBoundingClientRect();",
|
|
435
480
|
" tip.textContent = label;",
|
|
436
481
|
" tip.classList.add('visible');",
|
|
437
|
-
" tip.style.top = (rect.bottom + 6) + 'px';",
|
|
438
|
-
" tip.style.left = (rect.left + rect.width / 2) + 'px';",
|
|
482
|
+
" tip.style.top = (rect.bottom + 6 + (window.scrollY || 0)) + 'px';",
|
|
483
|
+
" tip.style.left = (rect.left + rect.width / 2 + (window.scrollX || 0)) + 'px';",
|
|
439
484
|
" tip.style.transform = 'translateX(-50%)';",
|
|
440
485
|
" }, true);",
|
|
441
|
-
" document.addEventListener('mouseleave',
|
|
442
|
-
"
|
|
486
|
+
" document.addEventListener('mouseleave', (e) => {",
|
|
487
|
+
" const src = e.target.closest('.btn-icon[aria-label]');",
|
|
443
488
|
" if(!src) return;",
|
|
444
|
-
"
|
|
489
|
+
" const dest = e.relatedTarget?.closest?.('.btn-icon[aria-label]');",
|
|
445
490
|
" if(dest === src) return;",
|
|
446
491
|
" tip.classList.remove('visible');",
|
|
447
492
|
" }, true);",
|