@bnhf/prismcast 1.3.4-2026.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +347 -0
  3. package/bin/prismcast +6 -0
  4. package/dist/app.d.ts +6 -0
  5. package/dist/app.js +315 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/browser/cdp.d.ts +38 -0
  8. package/dist/browser/cdp.js +155 -0
  9. package/dist/browser/cdp.js.map +1 -0
  10. package/dist/browser/channelSelection.d.ts +65 -0
  11. package/dist/browser/channelSelection.js +202 -0
  12. package/dist/browser/channelSelection.js.map +1 -0
  13. package/dist/browser/display.d.ts +34 -0
  14. package/dist/browser/display.js +54 -0
  15. package/dist/browser/display.js.map +1 -0
  16. package/dist/browser/index.d.ts +205 -0
  17. package/dist/browser/index.js +1205 -0
  18. package/dist/browser/index.js.map +1 -0
  19. package/dist/browser/tuning/fox.d.ts +2 -0
  20. package/dist/browser/tuning/fox.js +83 -0
  21. package/dist/browser/tuning/fox.js.map +1 -0
  22. package/dist/browser/tuning/hbo.d.ts +2 -0
  23. package/dist/browser/tuning/hbo.js +237 -0
  24. package/dist/browser/tuning/hbo.js.map +1 -0
  25. package/dist/browser/tuning/hulu.d.ts +2 -0
  26. package/dist/browser/tuning/hulu.js +550 -0
  27. package/dist/browser/tuning/hulu.js.map +1 -0
  28. package/dist/browser/tuning/sling.d.ts +2 -0
  29. package/dist/browser/tuning/sling.js +518 -0
  30. package/dist/browser/tuning/sling.js.map +1 -0
  31. package/dist/browser/tuning/thumbnailRow.d.ts +2 -0
  32. package/dist/browser/tuning/thumbnailRow.js +108 -0
  33. package/dist/browser/tuning/thumbnailRow.js.map +1 -0
  34. package/dist/browser/tuning/tileClick.d.ts +2 -0
  35. package/dist/browser/tuning/tileClick.js +103 -0
  36. package/dist/browser/tuning/tileClick.js.map +1 -0
  37. package/dist/browser/tuning/youtubeTv.d.ts +2 -0
  38. package/dist/browser/tuning/youtubeTv.js +182 -0
  39. package/dist/browser/tuning/youtubeTv.js.map +1 -0
  40. package/dist/browser/video.d.ts +289 -0
  41. package/dist/browser/video.js +996 -0
  42. package/dist/browser/video.js.map +1 -0
  43. package/dist/channels/index.d.ts +3 -0
  44. package/dist/channels/index.js +392 -0
  45. package/dist/channels/index.js.map +1 -0
  46. package/dist/config/index.d.ts +53 -0
  47. package/dist/config/index.js +233 -0
  48. package/dist/config/index.js.map +1 -0
  49. package/dist/config/presets.d.ts +98 -0
  50. package/dist/config/presets.js +241 -0
  51. package/dist/config/presets.js.map +1 -0
  52. package/dist/config/profiles.d.ts +79 -0
  53. package/dist/config/profiles.js +245 -0
  54. package/dist/config/profiles.js.map +1 -0
  55. package/dist/config/providers.d.ts +120 -0
  56. package/dist/config/providers.js +450 -0
  57. package/dist/config/providers.js.map +1 -0
  58. package/dist/config/sites.d.ts +22 -0
  59. package/dist/config/sites.js +377 -0
  60. package/dist/config/sites.js.map +1 -0
  61. package/dist/config/userChannels.d.ts +178 -0
  62. package/dist/config/userChannels.js +543 -0
  63. package/dist/config/userChannels.js.map +1 -0
  64. package/dist/config/userConfig.d.ts +235 -0
  65. package/dist/config/userConfig.js +913 -0
  66. package/dist/config/userConfig.js.map +1 -0
  67. package/dist/hdhr/channelMap.d.ts +21 -0
  68. package/dist/hdhr/channelMap.js +82 -0
  69. package/dist/hdhr/channelMap.js.map +1 -0
  70. package/dist/hdhr/deviceId.d.ts +11 -0
  71. package/dist/hdhr/deviceId.js +84 -0
  72. package/dist/hdhr/deviceId.js.map +1 -0
  73. package/dist/hdhr/discover.d.ts +6 -0
  74. package/dist/hdhr/discover.js +155 -0
  75. package/dist/hdhr/discover.js.map +1 -0
  76. package/dist/hdhr/index.d.ts +9 -0
  77. package/dist/hdhr/index.js +87 -0
  78. package/dist/hdhr/index.js.map +1 -0
  79. package/dist/index.d.ts +1 -0
  80. package/dist/index.js +144 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/routes/assets.d.ts +6 -0
  83. package/dist/routes/assets.js +79 -0
  84. package/dist/routes/assets.js.map +1 -0
  85. package/dist/routes/auth.d.ts +6 -0
  86. package/dist/routes/auth.js +77 -0
  87. package/dist/routes/auth.js.map +1 -0
  88. package/dist/routes/channels.d.ts +6 -0
  89. package/dist/routes/channels.js +40 -0
  90. package/dist/routes/channels.js.map +1 -0
  91. package/dist/routes/components.d.ts +138 -0
  92. package/dist/routes/components.js +210 -0
  93. package/dist/routes/components.js.map +1 -0
  94. package/dist/routes/config.d.ts +72 -0
  95. package/dist/routes/config.js +1977 -0
  96. package/dist/routes/config.js.map +1 -0
  97. package/dist/routes/debug.d.ts +6 -0
  98. package/dist/routes/debug.js +274 -0
  99. package/dist/routes/debug.js.map +1 -0
  100. package/dist/routes/health.d.ts +6 -0
  101. package/dist/routes/health.js +85 -0
  102. package/dist/routes/health.js.map +1 -0
  103. package/dist/routes/hls.d.ts +6 -0
  104. package/dist/routes/hls.js +25 -0
  105. package/dist/routes/hls.js.map +1 -0
  106. package/dist/routes/index.d.ts +19 -0
  107. package/dist/routes/index.js +49 -0
  108. package/dist/routes/index.js.map +1 -0
  109. package/dist/routes/logs.d.ts +6 -0
  110. package/dist/routes/logs.js +164 -0
  111. package/dist/routes/logs.js.map +1 -0
  112. package/dist/routes/mpegts.d.ts +6 -0
  113. package/dist/routes/mpegts.js +19 -0
  114. package/dist/routes/mpegts.js.map +1 -0
  115. package/dist/routes/play.d.ts +6 -0
  116. package/dist/routes/play.js +18 -0
  117. package/dist/routes/play.js.map +1 -0
  118. package/dist/routes/playlist.d.ts +36 -0
  119. package/dist/routes/playlist.js +134 -0
  120. package/dist/routes/playlist.js.map +1 -0
  121. package/dist/routes/root.d.ts +6 -0
  122. package/dist/routes/root.js +2920 -0
  123. package/dist/routes/root.js.map +1 -0
  124. package/dist/routes/streams.d.ts +6 -0
  125. package/dist/routes/streams.js +88 -0
  126. package/dist/routes/streams.js.map +1 -0
  127. package/dist/routes/theme.d.ts +15 -0
  128. package/dist/routes/theme.js +275 -0
  129. package/dist/routes/theme.js.map +1 -0
  130. package/dist/routes/ui.d.ts +56 -0
  131. package/dist/routes/ui.js +354 -0
  132. package/dist/routes/ui.js.map +1 -0
  133. package/dist/service/commands.d.ts +41 -0
  134. package/dist/service/commands.js +391 -0
  135. package/dist/service/commands.js.map +1 -0
  136. package/dist/service/generators.d.ts +33 -0
  137. package/dist/service/generators.js +432 -0
  138. package/dist/service/generators.js.map +1 -0
  139. package/dist/service/index.d.ts +2 -0
  140. package/dist/service/index.js +7 -0
  141. package/dist/service/index.js.map +1 -0
  142. package/dist/streaming/clients.d.ts +48 -0
  143. package/dist/streaming/clients.js +114 -0
  144. package/dist/streaming/clients.js.map +1 -0
  145. package/dist/streaming/fmp4Segmenter.d.ts +61 -0
  146. package/dist/streaming/fmp4Segmenter.js +461 -0
  147. package/dist/streaming/fmp4Segmenter.js.map +1 -0
  148. package/dist/streaming/hls.d.ts +120 -0
  149. package/dist/streaming/hls.js +722 -0
  150. package/dist/streaming/hls.js.map +1 -0
  151. package/dist/streaming/hlsSegments.d.ts +54 -0
  152. package/dist/streaming/hlsSegments.js +162 -0
  153. package/dist/streaming/hlsSegments.js.map +1 -0
  154. package/dist/streaming/lifecycle.d.ts +33 -0
  155. package/dist/streaming/lifecycle.js +185 -0
  156. package/dist/streaming/lifecycle.js.map +1 -0
  157. package/dist/streaming/monitor.d.ts +74 -0
  158. package/dist/streaming/monitor.js +1310 -0
  159. package/dist/streaming/monitor.js.map +1 -0
  160. package/dist/streaming/mp4Parser.d.ts +74 -0
  161. package/dist/streaming/mp4Parser.js +566 -0
  162. package/dist/streaming/mp4Parser.js.map +1 -0
  163. package/dist/streaming/mpegts.d.ts +14 -0
  164. package/dist/streaming/mpegts.js +248 -0
  165. package/dist/streaming/mpegts.js.map +1 -0
  166. package/dist/streaming/registry.d.ts +119 -0
  167. package/dist/streaming/registry.js +127 -0
  168. package/dist/streaming/registry.js.map +1 -0
  169. package/dist/streaming/setup.d.ts +135 -0
  170. package/dist/streaming/setup.js +670 -0
  171. package/dist/streaming/setup.js.map +1 -0
  172. package/dist/streaming/showInfo.d.ts +30 -0
  173. package/dist/streaming/showInfo.js +362 -0
  174. package/dist/streaming/showInfo.js.map +1 -0
  175. package/dist/streaming/statusEmitter.d.ts +125 -0
  176. package/dist/streaming/statusEmitter.js +139 -0
  177. package/dist/streaming/statusEmitter.js.map +1 -0
  178. package/dist/types/index.d.ts +403 -0
  179. package/dist/types/index.js +6 -0
  180. package/dist/types/index.js.map +1 -0
  181. package/dist/utils/debugFilter.d.ts +38 -0
  182. package/dist/utils/debugFilter.js +157 -0
  183. package/dist/utils/debugFilter.js.map +1 -0
  184. package/dist/utils/delay.d.ts +6 -0
  185. package/dist/utils/delay.js +15 -0
  186. package/dist/utils/delay.js.map +1 -0
  187. package/dist/utils/errors.d.ts +15 -0
  188. package/dist/utils/errors.js +40 -0
  189. package/dist/utils/errors.js.map +1 -0
  190. package/dist/utils/evaluate.d.ts +51 -0
  191. package/dist/utils/evaluate.js +124 -0
  192. package/dist/utils/evaluate.js.map +1 -0
  193. package/dist/utils/ffmpeg.d.ts +65 -0
  194. package/dist/utils/ffmpeg.js +317 -0
  195. package/dist/utils/ffmpeg.js.map +1 -0
  196. package/dist/utils/fileLogger.d.ts +25 -0
  197. package/dist/utils/fileLogger.js +248 -0
  198. package/dist/utils/fileLogger.js.map +1 -0
  199. package/dist/utils/format.d.ts +16 -0
  200. package/dist/utils/format.js +46 -0
  201. package/dist/utils/format.js.map +1 -0
  202. package/dist/utils/html.d.ts +6 -0
  203. package/dist/utils/html.js +24 -0
  204. package/dist/utils/html.js.map +1 -0
  205. package/dist/utils/index.d.ts +15 -0
  206. package/dist/utils/index.js +20 -0
  207. package/dist/utils/index.js.map +1 -0
  208. package/dist/utils/logEmitter.d.ts +17 -0
  209. package/dist/utils/logEmitter.js +30 -0
  210. package/dist/utils/logEmitter.js.map +1 -0
  211. package/dist/utils/logger.d.ts +82 -0
  212. package/dist/utils/logger.js +219 -0
  213. package/dist/utils/logger.js.map +1 -0
  214. package/dist/utils/m3u.d.ts +32 -0
  215. package/dist/utils/m3u.js +148 -0
  216. package/dist/utils/m3u.js.map +1 -0
  217. package/dist/utils/morganStream.d.ts +7 -0
  218. package/dist/utils/morganStream.js +33 -0
  219. package/dist/utils/morganStream.js.map +1 -0
  220. package/dist/utils/platform.d.ts +64 -0
  221. package/dist/utils/platform.js +157 -0
  222. package/dist/utils/platform.js.map +1 -0
  223. package/dist/utils/retry.d.ts +15 -0
  224. package/dist/utils/retry.js +82 -0
  225. package/dist/utils/retry.js.map +1 -0
  226. package/dist/utils/streamContext.d.ts +28 -0
  227. package/dist/utils/streamContext.js +33 -0
  228. package/dist/utils/streamContext.js.map +1 -0
  229. package/dist/utils/version.d.ts +37 -0
  230. package/dist/utils/version.js +228 -0
  231. package/dist/utils/version.js.map +1 -0
  232. package/package.json +92 -0
  233. package/prismcast.png +0 -0
  234. package/prismcast.svg +74 -0
@@ -0,0 +1,996 @@
1
+ /* Copyright(C) 2024-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * video.ts: Video context and playback handling for PrismCast.
4
+ */
5
+ import { EvaluateAbortError, LOG, delay, evaluateWithAbort, formatError, startTimer } from "../utils/index.js";
6
+ import { invalidateDirectUrl, resolveDirectUrl, selectChannel } from "./channelSelection.js";
7
+ import { CONFIG } from "../config/index.js";
8
+ /* These functions manage the video element lifecycle for streaming capture. The key challenges we solve:
9
+ *
10
+ * 1. Video context resolution: Video elements may be in the main page or nested inside iframes. Some streaming sites (like those using Brightcove or JW Player
11
+ * embedded via iframe) require searching through frames to find the video. We detect this based on the site profile's needsIframeHandling flag.
12
+ *
13
+ * 2. Video selection: Pages may have multiple video elements (ads, previews, main content). The selectReadyVideo strategy finds the video with readyState >= 3,
14
+ * which typically identifies the actively playing main content. The selectFirstVideo strategy simply takes the first video in DOM order.
15
+ *
16
+ * 3. Ready state detection: We wait for readyState >= 3 (HAVE_FUTURE_DATA) rather than readyState === 4 (HAVE_ENOUGH_DATA) because live streams continuously
17
+ * receive data and may never reach readyState 4. The >= 3 threshold indicates enough data is buffered to begin playback.
18
+ *
19
+ * 4. Fullscreen styling: To maximize capture quality, we apply CSS styles that make the video fill the entire viewport. This CSS-based approach works regardless
20
+ * of the site's native fullscreen mechanism.
21
+ *
22
+ * 5. Volume enforcement: Some sites aggressively mute videos or lower volume. We enforce volume=1 and muted=false, and for particularly aggressive sites, we use
23
+ * Object.defineProperty to intercept and ignore attempts to change these values.
24
+ *
25
+ * 6. Recovery escalation: When playback stalls, we use increasingly aggressive recovery techniques:
26
+ * - Level 1: Basic play/unmute - just call play() and ensure audio is on.
27
+ * - Level 2: Seek to live edge - jump to the end of the seekable range for live streams.
28
+ * - Level 3: Reload source - reset video.src and call load() to reinitialize the player.
29
+ * - Level 4: Full page navigation (handled in monitor.ts, not here).
30
+ *
31
+ * The video selector system uses a string type identifier ("selectReadyVideo" or "selectFirstVideo") that's passed to page.evaluate() and interpreted in the
32
+ * browser context. This avoids using eval() while still allowing dynamic video selection behavior.
33
+ */
34
+ /**
35
+ * Builds a selector type identifier for the video element based on the site profile. This returns a string that browser context code interprets to select the
36
+ * appropriate video element. Using a string identifier instead of passing functions avoids serialization issues with page.evaluate() and is more secure than
37
+ * eval()-based approaches.
38
+ * @param profile - The site profile indicating video selection strategy.
39
+ * @returns A selector type identifier: "selectReadyVideo" for sites with multiple videos, "selectFirstVideo" for standard sites.
40
+ */
41
+ export function buildVideoSelectorType(profile) {
42
+ // Sites with multiple video elements (ads, previews, main content) need to select by readyState to find the actively playing main content. Standard sites
43
+ // with a single video element can just take the first one.
44
+ return profile.selectReadyVideo ? "selectReadyVideo" : "selectFirstVideo";
45
+ }
46
+ /**
47
+ * Gets the current state of the video element for health monitoring. Returns null if no video element is found.
48
+ * @param context - The frame or page containing the video element.
49
+ * @param selectorType - The video selector type for finding the element.
50
+ * @returns The video state or null if no video found.
51
+ */
52
+ export async function getVideoState(context, selectorType) {
53
+ return evaluateWithAbort(context, (type) => {
54
+ let video;
55
+ if (type === "selectReadyVideo") {
56
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
57
+ return v.readyState >= 3;
58
+ });
59
+ }
60
+ else {
61
+ video = document.querySelector("video");
62
+ }
63
+ if (!video) {
64
+ return null;
65
+ }
66
+ return {
67
+ currentTime: video.currentTime,
68
+ ended: video.ended,
69
+ error: video.error !== null,
70
+ muted: video.muted,
71
+ networkState: video.networkState,
72
+ paused: video.paused,
73
+ readyState: video.readyState,
74
+ volume: video.volume
75
+ };
76
+ }, [selectorType]);
77
+ }
78
+ /**
79
+ * Enforces volume settings on the video element. Sets muted to false and volume to 1. This is called periodically during health monitoring to counter sites that
80
+ * aggressively mute videos.
81
+ * @param context - The frame or page containing the video element.
82
+ * @param selectorType - The video selector type for finding the element.
83
+ */
84
+ export async function enforceVideoVolume(context, selectorType) {
85
+ await evaluateWithAbort(context, (type) => {
86
+ let video;
87
+ if (type === "selectReadyVideo") {
88
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
89
+ return v.readyState >= 3;
90
+ });
91
+ }
92
+ else {
93
+ video = document.querySelector("video");
94
+ }
95
+ if (video) {
96
+ video.muted = false;
97
+ video.volume = 1;
98
+ }
99
+ }, [selectorType]);
100
+ }
101
+ /**
102
+ * Validates that a video element exists and returns its ready state. Used after page navigation to verify recovery succeeded.
103
+ * @param context - The frame or page containing the video element.
104
+ * @param selectorType - The video selector type for finding the element.
105
+ * @returns Validation result indicating if video was found and its readyState.
106
+ */
107
+ export async function validateVideoElement(context, selectorType) {
108
+ return evaluateWithAbort(context, (type) => {
109
+ let video;
110
+ if (type === "selectReadyVideo") {
111
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
112
+ return v.readyState >= 3;
113
+ });
114
+ }
115
+ else {
116
+ video = document.querySelector("video");
117
+ }
118
+ return video ? { found: true, readyState: video.readyState } : { found: false };
119
+ }, [selectorType]);
120
+ }
121
+ /**
122
+ * Checks video presence in the context, returning detailed information about what videos exist and their states. This helps distinguish between:
123
+ * - No video element exists at all (DOM issue, wrong context)
124
+ * - Video elements exist but none are ready (buffering, still loading)
125
+ * - Ready video exists (normal operation)
126
+ *
127
+ * This is useful when getVideoState returns null to determine if we should wait (video buffering) or escalate (no video at all).
128
+ * @param context - The frame or page containing the video element.
129
+ * @param selectorType - The video selector type for finding the element.
130
+ * @returns Detailed presence information.
131
+ */
132
+ export async function checkVideoPresence(context, selectorType) {
133
+ return evaluateWithAbort(context, (type) => {
134
+ const videos = Array.from(document.querySelectorAll("video"));
135
+ const videoCount = videos.length;
136
+ if (videoCount === 0) {
137
+ return { anyVideoExists: false, readyVideoFound: false, videoCount: 0 };
138
+ }
139
+ // Find the maximum readyState among all videos.
140
+ const maxReadyState = Math.max(...videos.map((v) => v.readyState));
141
+ // Check if a video matching the selector criteria exists.
142
+ let readyVideoFound = false;
143
+ if (type === "selectReadyVideo") {
144
+ readyVideoFound = videos.some((v) => v.readyState >= 3);
145
+ }
146
+ else {
147
+ // For selectFirstVideo, any video counts as ready.
148
+ readyVideoFound = true;
149
+ }
150
+ return { anyVideoExists: true, maxReadyState, readyVideoFound, videoCount };
151
+ }, [selectorType]);
152
+ }
153
+ /**
154
+ * Reloads the video source to force the player to reinitialize. This clears the src attribute, calls load() to reset the player state, restores the original src,
155
+ * and calls load() again. This is more disruptive than seeking but can fix players stuck in error states or with corrupted internal state.
156
+ * @param context - The frame or page containing the video element.
157
+ * @param selectorType - The video selector type for finding the element.
158
+ */
159
+ export async function reloadVideoSource(context, selectorType) {
160
+ await evaluateWithAbort(context, (type) => {
161
+ let video;
162
+ if (type === "selectReadyVideo") {
163
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
164
+ return v.readyState >= 3;
165
+ });
166
+ }
167
+ else {
168
+ video = document.querySelector("video");
169
+ }
170
+ if (video) {
171
+ const currentSrc = video.src;
172
+ video.src = "";
173
+ video.load();
174
+ video.src = currentSrc;
175
+ video.load();
176
+ }
177
+ }, [selectorType]);
178
+ }
179
+ /**
180
+ * Starts video playback by ensuring the video is unmuted, at full volume, and playing. This combines volume enforcement with play() initiation for efficient single
181
+ * round-trip execution in the browser context.
182
+ * @param context - The frame or page containing the video element.
183
+ * @param selectorType - The video selector type for finding the element.
184
+ */
185
+ export async function startVideoPlayback(context, selectorType) {
186
+ await evaluateWithAbort(context, (type) => {
187
+ let video;
188
+ if (type === "selectReadyVideo") {
189
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
190
+ return v.readyState >= 3;
191
+ });
192
+ }
193
+ else {
194
+ video = document.querySelector("video");
195
+ }
196
+ if (video) {
197
+ // Ensure audio is enabled. Some sites mute videos by default or in response to various events.
198
+ video.muted = false;
199
+ video.volume = 1;
200
+ // Call play() if the video is paused. The catch handles cases where autoplay is blocked (though our Chrome flags should prevent this).
201
+ if (video.paused) {
202
+ video.play().catch(() => {
203
+ // Ignore play errors - the monitor will retry if playback doesn't resume.
204
+ });
205
+ }
206
+ }
207
+ }, [selectorType]);
208
+ }
209
+ /**
210
+ * Navigates a browser page to the specified URL with site-appropriate wait conditions. The navigation strategy depends on the site's player implementation:
211
+ *
212
+ * - waitForNetworkIdle=true: Wait for network activity to settle (no requests for 500ms). This ensures all JavaScript has loaded and the player is fully
213
+ * initialized. Used for sites with complex async initialization.
214
+ *
215
+ * - waitForNetworkIdle=false: Return as soon as the page fires load event. Used for sites that have persistent connections or polling that would prevent
216
+ * networkidle from ever completing.
217
+ *
218
+ * Navigation timeouts are handled gracefully - we log a warning but don't throw, since the video may have loaded successfully even if networkidle never
219
+ * completed.
220
+ * @param page - The Puppeteer page object.
221
+ * @param url - The URL to navigate to.
222
+ * @param profile - The site profile containing navigation preferences.
223
+ */
224
+ export async function navigateToPage(page, url, profile) {
225
+ if (profile.waitForNetworkIdle) {
226
+ try {
227
+ // Wait for network idle (no requests for 500ms). This ensures complex JavaScript players have fully initialized. The networkidle2 strategy allows up
228
+ // to 2 concurrent requests, which handles sites with persistent connections for analytics.
229
+ await page.goto(url, { timeout: CONFIG.streaming.navigationTimeout, waitUntil: "networkidle2" });
230
+ }
231
+ catch (error) {
232
+ // Timeout errors during navigation are common and often non-fatal - the video may have loaded successfully even if some background requests never
233
+ // completed. We log a warning and continue rather than throwing.
234
+ if (error && (error.name === "TimeoutError")) {
235
+ LOG.warn("Page navigation timed out after %sms for %s.", CONFIG.streaming.navigationTimeout, url);
236
+ }
237
+ else {
238
+ // Non-timeout errors (network failure, invalid URL, etc.) should be propagated for retry handling.
239
+ throw error;
240
+ }
241
+ }
242
+ }
243
+ else {
244
+ // Simple navigation without waiting for network idle. Returns after the load event fires. Used for sites that would never reach networkidle due to
245
+ // persistent connections, streaming data, or continuous polling.
246
+ await page.goto(url);
247
+ }
248
+ }
249
+ /**
250
+ * Finds the appropriate context (frame or page) containing the video element. Some streaming sites embed their video player in an iframe, which creates a
251
+ * separate document context. We need to find this iframe and operate within it to access the video element.
252
+ *
253
+ * The search process:
254
+ * 1. If the profile doesn't need iframe handling, return the main page directly
255
+ * 2. Wait for an iframe element to appear in the DOM
256
+ * 3. Allow time for the iframe content to initialize (embedded players often load additional resources)
257
+ * 4. Search through all frames to find one containing a video element
258
+ * 5. Fall back to the main page if no iframe contains a video
259
+ * @param page - The Puppeteer page object.
260
+ * @param profile - The site profile indicating whether iframe handling is needed.
261
+ * @returns The frame or page containing the video element.
262
+ */
263
+ export async function findVideoContext(page, profile) {
264
+ // For sites that don't use iframes (most common case), the video is directly in the main page document. Skip the iframe search.
265
+ if (!profile.needsIframeHandling) {
266
+ return page;
267
+ }
268
+ // Wait for an iframe element to appear in the page DOM. This ensures the site has created the embedded player container.
269
+ await page.waitForSelector("iframe", { timeout: CONFIG.streaming.videoTimeout });
270
+ // Poll for a video element to appear in any iframe. Complex embedded players (Brightcove, JW Player, etc.) load additional resources and scripts after the
271
+ // iframe element appears, so the video may not be immediately available. We retry the search with brief pauses, using the configured delay as the overall
272
+ // timeout ceiling. This replaces a fixed delay with early exit — if the video appears quickly, we proceed immediately.
273
+ const deadline = Date.now() + CONFIG.playback.iframeInitDelay;
274
+ let iframeSearchComplete = false;
275
+ let lastFrameCount = 0;
276
+ while (!iframeSearchComplete && (Date.now() < deadline)) {
277
+ const pageFrames = page.frames();
278
+ lastFrameCount = pageFrames.length;
279
+ for (const frame of pageFrames) {
280
+ // Skip the main frame since we're looking for video in iframes, not the main page.
281
+ if (frame === page.mainFrame()) {
282
+ continue;
283
+ }
284
+ try {
285
+ // Check if this frame contains a video element. We use a short timeout (2 seconds) to prevent a single hanging frame from consuming the polling budget.
286
+ // eslint-disable-next-line no-await-in-loop
287
+ const hasVideo = await evaluateWithAbort(frame, () => {
288
+ return !!document.querySelector("video");
289
+ }, undefined, 2000);
290
+ if (hasVideo) {
291
+ return frame;
292
+ }
293
+ }
294
+ catch (error) {
295
+ // AbortError means stream was terminated — stop polling immediately.
296
+ if (error instanceof EvaluateAbortError) {
297
+ iframeSearchComplete = true;
298
+ break;
299
+ }
300
+ // Other errors (cross-origin, detached frame) — skip this frame and continue searching.
301
+ }
302
+ }
303
+ // Brief pause before re-checking. 200ms intervals provide responsive polling without excessive CDP overhead.
304
+ if (!iframeSearchComplete && (Date.now() < deadline)) {
305
+ // eslint-disable-next-line no-await-in-loop
306
+ await delay(200);
307
+ }
308
+ }
309
+ // If no iframe contains a video, fall back to the main page. This is a potential issue for iframe-handling profiles since we expected the video to be in an
310
+ // iframe. Log a warning and verify the main page actually has a video element.
311
+ LOG.warn("No iframe contained video element. Falling back to main page context (searched %s frames).", Math.max(0, lastFrameCount - 1));
312
+ // Check if the main page actually contains a video element.
313
+ try {
314
+ const mainPageHasVideo = await evaluateWithAbort(page, () => {
315
+ return !!document.querySelector("video");
316
+ });
317
+ if (!mainPageHasVideo) {
318
+ LOG.warn("Main page fallback: no video element found in main page either.");
319
+ }
320
+ }
321
+ catch (_error) {
322
+ // Ignore evaluation errors - we'll return the page anyway and let the caller handle missing video. Also handles AbortError if stream is terminated.
323
+ }
324
+ return page;
325
+ }
326
+ /**
327
+ * Waits for the video element to reach a ready state indicating it has loaded enough data to begin playback. We use readyState >= 3 (HAVE_FUTURE_DATA) as the
328
+ * threshold because:
329
+ *
330
+ * - readyState 0 (HAVE_NOTHING): No data available
331
+ * - readyState 1 (HAVE_METADATA): Duration and dimensions known, but no media data
332
+ * - readyState 2 (HAVE_CURRENT_DATA): Data for current position available, but not enough for playback
333
+ * - readyState 3 (HAVE_FUTURE_DATA): Enough data for current position plus at least a little ahead
334
+ * - readyState 4 (HAVE_ENOUGH_DATA): Enough data to play through without buffering (for known-length media)
335
+ *
336
+ * Live streams continuously receive data and may never reach readyState 4, so we use >= 3 as the threshold. The health monitor handles any subsequent buffering
337
+ * or playback issues.
338
+ * @param context - The frame or page containing the video element.
339
+ * @param profile - The site profile with video selection preferences.
340
+ */
341
+ export async function waitForVideoReady(context, profile) {
342
+ // First, wait for any video element to appear in the DOM. This catches cases where the video element is created dynamically by JavaScript.
343
+ await context.waitForSelector("video", { timeout: CONFIG.streaming.videoTimeout });
344
+ if (profile.selectReadyVideo) {
345
+ // For sites with multiple video elements, wait for at least one to reach readyState >= 3. This typically identifies the main content video rather than
346
+ // preloaded ad videos or preview thumbnails.
347
+ await context.waitForFunction(() => {
348
+ const videos = document.querySelectorAll("video");
349
+ return Array.from(videos).some((v) => {
350
+ return v.readyState >= 3;
351
+ });
352
+ }, { timeout: CONFIG.streaming.videoTimeout });
353
+ }
354
+ else {
355
+ // For standard sites with a single video, wait for that specific video to reach readyState >= 3.
356
+ await context.waitForFunction(() => {
357
+ const video = document.querySelector("video");
358
+ return !!video && (video.readyState >= 3);
359
+ }, { timeout: CONFIG.streaming.videoTimeout });
360
+ }
361
+ }
362
+ /**
363
+ * Applies fullscreen styling to the video element using CSS to maximize the capture area. This CSS-based approach works for all sites regardless of their native
364
+ * fullscreen mechanism (keyboard shortcuts, JavaScript API, etc.).
365
+ *
366
+ * The styling:
367
+ * - position: fixed - Removes the video from document flow and positions relative to viewport
368
+ * - top: 0; left: 0; width: 100%; height: 100% - Fills the entire viewport
369
+ * - zIndex: 999000 - Ensures the video appears above all other page content
370
+ * - objectFit: contain - Maintains aspect ratio while fitting within the viewport
371
+ * - background: black - Fills any letterbox/pillarbox areas with black
372
+ * - cursor: none - Hides the mouse cursor for cleaner capture
373
+ * @param context - The frame or page containing the video element.
374
+ * @param selectorType - The video selector type for finding the element.
375
+ * @param important - When true, applies styles with !important priority to override site JavaScript that actively fights style changes.
376
+ */
377
+ export async function applyVideoStyles(context, selectorType, important = false) {
378
+ await evaluateWithAbort(context, (type, useImportant) => {
379
+ // Find the video element using the appropriate selection strategy.
380
+ let video;
381
+ if (type === "selectReadyVideo") {
382
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
383
+ return v.readyState >= 3;
384
+ });
385
+ }
386
+ else {
387
+ video = document.querySelector("video");
388
+ }
389
+ if (!video) {
390
+ return;
391
+ }
392
+ // Apply fullscreen-like styling via CSS. This is more reliable than the native fullscreen API because it doesn't require user gesture and can't be
393
+ // blocked by the site's CSP. When important is true, we use setProperty with "important" priority to override site JavaScript that re-applies its own
394
+ // styles after our basic assignment.
395
+ const priority = useImportant ? "important" : "";
396
+ video.style.setProperty("background", "black", priority);
397
+ video.style.setProperty("cursor", "none", priority);
398
+ video.style.setProperty("height", "100%", priority);
399
+ video.style.setProperty("left", "0", priority);
400
+ video.style.setProperty("object-fit", "contain", priority);
401
+ video.style.setProperty("position", "fixed", priority);
402
+ video.style.setProperty("top", "0", priority);
403
+ video.style.setProperty("width", "100%", priority);
404
+ video.style.setProperty("z-index", "999000", priority);
405
+ }, [selectorType, important]);
406
+ }
407
+ /**
408
+ * Locks the volume properties on the video element to prevent the site's JavaScript from muting our stream. Some sites (like France24) aggressively mute videos
409
+ * or lower volume in response to various events. They may reset volume on play, on focus, on visibility change, or on a timer.
410
+ *
411
+ * This function uses Object.defineProperty to intercept property access, making it impossible for site JavaScript to change muted or volume values. The property
412
+ * descriptors are set to configurable: true so the browser can still access the underlying values for playback.
413
+ *
414
+ * The function is idempotent - a __volumeLocked flag on the video element prevents applying the lock multiple times.
415
+ * @param context - The frame or page containing the video element.
416
+ * @param selectorType - The video selector type for finding the element.
417
+ */
418
+ export async function lockVolumeProperties(context, selectorType) {
419
+ try {
420
+ await evaluateWithAbort(context, (type) => {
421
+ // Find the video element.
422
+ let video;
423
+ if (type === "selectReadyVideo") {
424
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
425
+ return v.readyState >= 3;
426
+ });
427
+ }
428
+ else {
429
+ video = document.querySelector("video");
430
+ }
431
+ // Skip if no video found or already locked. The __volumeLocked flag prevents applying the lock multiple times, which would cause issues with the
432
+ // property descriptors.
433
+ if (!video || video.__volumeLocked) {
434
+ return;
435
+ }
436
+ // Override the muted property to always return false and ignore attempts to set it. This prevents site JavaScript from muting the video.
437
+ Object.defineProperty(video, "muted", {
438
+ configurable: true,
439
+ get: function () {
440
+ return false;
441
+ },
442
+ set: function () {
443
+ // Ignore attempts to mute. The setter does nothing, so any code setting video.muted = true has no effect.
444
+ }
445
+ });
446
+ // Override the volume property to always return 1 (full volume) and ignore attempts to change it.
447
+ Object.defineProperty(video, "volume", {
448
+ configurable: true,
449
+ get: function () {
450
+ return 1;
451
+ },
452
+ set: function () {
453
+ // Ignore attempts to change volume. The setter does nothing, so any code setting video.volume = 0.5 has no effect.
454
+ }
455
+ });
456
+ // Mark the video as locked to prevent re-applying the lock.
457
+ video.__volumeLocked = true;
458
+ }, [selectorType]);
459
+ LOG.debug("browser:video", "Volume properties locked successfully.");
460
+ }
461
+ catch (error) {
462
+ // Volume locking is not critical to stream function - log a warning but don't fail the operation. Also handles AbortError if stream is terminated.
463
+ LOG.warn("Could not lock volume properties: %s.", formatError(error));
464
+ }
465
+ }
466
+ /**
467
+ * Triggers fullscreen mode using the appropriate method for the site. Different sites have different fullscreen implementations:
468
+ *
469
+ * - Keyboard shortcuts (fullscreenKey): Many players use "f" as a keyboard shortcut for fullscreen. We send this keypress to activate the player's native
470
+ * fullscreen mode.
471
+ *
472
+ * - JavaScript Fullscreen API (useRequestFullscreen): Some players require calling video.requestFullscreen() directly. This may trigger browser permission
473
+ * prompts or be blocked by CSP, but works on many sites.
474
+ *
475
+ * Note that we also apply CSS-based fullscreen styling separately (in applyVideoStyles), which provides a reliable fallback when native fullscreen methods fail.
476
+ * @param page - The Puppeteer page object for keyboard input.
477
+ * @param context - The frame or page containing the video element.
478
+ * @param profile - The site profile indicating fullscreen method.
479
+ * @param selectorType - The video selector type for finding the element.
480
+ */
481
+ export async function triggerFullscreen(page, context, profile, selectorType) {
482
+ // Try clicking a fullscreen button if configured. This fires before keyboard and API methods because clicking the site's own fullscreen control is the most
483
+ // reliable approach — it uses the site's native mechanism. The element existence check guards against toggle buttons that have changed state or disappeared
484
+ // (e.g., after the player is already maximized). Keyboard and API methods serve as fallbacks below.
485
+ if (profile.fullscreenSelector) {
486
+ try {
487
+ const buttonExists = await page.$(profile.fullscreenSelector);
488
+ if (buttonExists) {
489
+ await page.click(profile.fullscreenSelector);
490
+ // Brief delay for the site's fullscreen animation to complete before subsequent checks.
491
+ await delay(300);
492
+ }
493
+ }
494
+ catch (error) {
495
+ LOG.warn("Could not click fullscreen button %s: %s.", profile.fullscreenSelector, formatError(error));
496
+ }
497
+ }
498
+ // Try keyboard shortcut if configured. The fullscreenKey is typically "f" for most video players.
499
+ if (profile.fullscreenKey) {
500
+ await page.keyboard.type(profile.fullscreenKey);
501
+ }
502
+ // Try JavaScript Fullscreen API if configured. This calls video.requestFullscreen() which may trigger browser fullscreen mode. We await the promise to ensure
503
+ // the fullscreen transition has started before returning to the verification step. The catch suppresses errors (the retry logic in ensureFullscreen handles
504
+ // failures via document.fullscreenElement checking).
505
+ if (profile.useRequestFullscreen) {
506
+ try {
507
+ await evaluateWithAbort(context, async (type) => {
508
+ // Find the video element.
509
+ let video;
510
+ if (type === "selectReadyVideo") {
511
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
512
+ return v.readyState >= 3;
513
+ });
514
+ }
515
+ else {
516
+ video = document.querySelector("video");
517
+ }
518
+ // Request fullscreen if the API is available. Await the promise so the transition begins before we return.
519
+ if (video?.requestFullscreen) {
520
+ try {
521
+ await video.requestFullscreen();
522
+ }
523
+ catch {
524
+ // Fullscreen may be blocked by browser policy, CSP, or missing user activation. The retry logic in ensureFullscreen handles this.
525
+ }
526
+ }
527
+ }, [selectorType]);
528
+ }
529
+ catch (error) {
530
+ LOG.debug("browser:video", "Could not trigger fullscreen: %s.", formatError(error));
531
+ }
532
+ }
533
+ }
534
+ /**
535
+ * Verifies that the video element is filling the viewport, indicating that fullscreen styling was successfully applied. This function checks the video element's
536
+ * bounding rectangle against the viewport dimensions to determine if the video appears fullscreen.
537
+ *
538
+ * The verification allows for some tolerance because:
539
+ * - The video may have letterboxing/pillarboxing due to aspect ratio differences
540
+ * - Some browsers report slightly smaller dimensions due to scrollbars or UI chrome
541
+ * - CSS rounding may cause small discrepancies
542
+ *
543
+ * We require the video to fill at least 85% of the viewport in at least one dimension (the constraining dimension for aspect ratio) and at least 50% in the
544
+ * other dimension to catch obviously broken cases.
545
+ * @param context - The frame or page containing the video element.
546
+ * @param selectorType - The video selector type for finding the element.
547
+ * @returns True if the video appears to be fullscreen, false if it does not, or null if the check could not be performed (e.g. context destroyed).
548
+ */
549
+ export async function verifyFullscreen(context, selectorType) {
550
+ try {
551
+ return await evaluateWithAbort(context, (type) => {
552
+ // Find the video element using the appropriate selection strategy.
553
+ let video;
554
+ if (type === "selectReadyVideo") {
555
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
556
+ return v.readyState >= 3;
557
+ });
558
+ }
559
+ else {
560
+ video = document.querySelector("video");
561
+ }
562
+ if (!video) {
563
+ return false;
564
+ }
565
+ const rect = video.getBoundingClientRect();
566
+ const viewportWidth = window.innerWidth;
567
+ const viewportHeight = window.innerHeight;
568
+ // Calculate how much of the viewport the video fills in each dimension.
569
+ const widthRatio = rect.width / viewportWidth;
570
+ const heightRatio = rect.height / viewportHeight;
571
+ // The video should fill at least 85% in at least one dimension (accounting for aspect ratio letterboxing) and at least 50% in the other dimension (to
572
+ // catch obviously broken cases where the video is tiny or off-screen).
573
+ const fillsWidth = widthRatio >= 0.85;
574
+ const fillsHeight = heightRatio >= 0.85;
575
+ const minimumCoverage = (widthRatio >= 0.5) && (heightRatio >= 0.5);
576
+ return (fillsWidth || fillsHeight) && minimumCoverage;
577
+ }, [selectorType]);
578
+ }
579
+ catch (_error) {
580
+ // If we can't evaluate (page closed, frame detached), return null to signal that the check was inconclusive rather than reporting a false layout change.
581
+ return null;
582
+ }
583
+ }
584
+ /**
585
+ * Checks whether the browser's native fullscreen mode is active by examining document.fullscreenElement. This is a stronger signal than CSS dimension checking
586
+ * because it confirms the browser has actually entered fullscreen mode, which hides the site's player chrome, overlays, and navigation. Used to verify
587
+ * requestFullscreen() succeeded for profiles that rely on the JavaScript Fullscreen API.
588
+ * @param context - The frame or page to check.
589
+ * @returns True if native fullscreen is active, false otherwise.
590
+ */
591
+ async function isNativeFullscreenActive(context) {
592
+ try {
593
+ return await evaluateWithAbort(context, () => {
594
+ return document.fullscreenElement !== null;
595
+ });
596
+ }
597
+ catch {
598
+ // If we can't evaluate (page closed, frame detached), assume fullscreen is not active.
599
+ return false;
600
+ }
601
+ }
602
+ /**
603
+ * Clicks the center of the video element to establish user activation in the browser. The Fullscreen API requires a recent user gesture (transient activation)
604
+ * to succeed. Without it, requestFullscreen() is silently rejected. This function provides that activation by clicking the video via page.mouse.click(), which
605
+ * dispatches real pointer events that Chrome recognizes as user gestures. The click may toggle play/pause on some players — the health monitor handles
606
+ * re-starting playback if needed.
607
+ *
608
+ * Note: page.mouse.click() uses page-level coordinates, while getBoundingClientRect() in an iframe returns iframe-relative coordinates. This function works
609
+ * correctly because all profiles with useRequestFullscreen are non-iframe (needsIframeHandling is false).
610
+ * @param page - The Puppeteer page object.
611
+ * @param context - The frame or page containing the video element.
612
+ * @param selectorType - The video selector type for finding the element.
613
+ */
614
+ async function clickVideoForActivation(page, context, selectorType) {
615
+ try {
616
+ const coords = await evaluateWithAbort(context, (type) => {
617
+ let video;
618
+ if (type === "selectReadyVideo") {
619
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
620
+ return v.readyState >= 3;
621
+ });
622
+ }
623
+ else {
624
+ video = document.querySelector("video");
625
+ }
626
+ if (!video) {
627
+ return null;
628
+ }
629
+ const rect = video.getBoundingClientRect();
630
+ return { x: rect.left + (rect.width / 2), y: rect.top + (rect.height / 2) };
631
+ }, [selectorType]);
632
+ if (coords) {
633
+ await page.mouse.click(coords.x, coords.y);
634
+ await delay(100);
635
+ }
636
+ }
637
+ catch {
638
+ // Click failure is non-fatal — the fullscreen retry will continue without activation.
639
+ }
640
+ }
641
+ /**
642
+ * Applies aggressive fullscreen styling when standard styling fails. This function uses multiple techniques to force the video to fill the viewport:
643
+ *
644
+ * 1. CSS !important flags: Overrides any site CSS that might be constraining the video element.
645
+ *
646
+ * 2. Hide sibling elements: Sets display: none on sibling elements in the video's parent container, removing player controls, overlays, and other UI that might
647
+ * be obscuring the video.
648
+ *
649
+ * 3. Expand parent containers: Walks up the DOM tree and applies fullscreen styling to parent elements, breaking out of any constrained containers.
650
+ *
651
+ * This is more invasive than standard styling and may break site functionality, but ensures the video fills the viewport for capture.
652
+ * @param context - The frame or page containing the video element.
653
+ * @param selectorType - The video selector type for finding the element.
654
+ */
655
+ async function applyAggressiveFullscreen(context, selectorType) {
656
+ await evaluateWithAbort(context, (type) => {
657
+ // Find the video element using the appropriate selection strategy.
658
+ let video;
659
+ if (type === "selectReadyVideo") {
660
+ video = Array.from(document.querySelectorAll("video")).find((v) => {
661
+ return v.readyState >= 3;
662
+ });
663
+ }
664
+ else {
665
+ video = document.querySelector("video");
666
+ }
667
+ if (!video) {
668
+ return;
669
+ }
670
+ // Apply fullscreen styling with !important flags to override any site CSS. Using cssText replaces all existing inline styles, ensuring a clean slate.
671
+ video.style.cssText = [
672
+ "background: black !important",
673
+ "cursor: none !important",
674
+ "height: 100% !important",
675
+ "left: 0 !important",
676
+ "object-fit: contain !important",
677
+ "position: fixed !important",
678
+ "top: 0 !important",
679
+ "width: 100% !important",
680
+ "z-index: 999999 !important"
681
+ ].join("; ");
682
+ // Hide sibling elements that might be overlaying the video (player controls, progress bars, channel logos, etc.).
683
+ const parent = video.parentElement;
684
+ if (parent) {
685
+ for (const sibling of Array.from(parent.children)) {
686
+ if ((sibling !== video) && (sibling instanceof HTMLElement)) {
687
+ sibling.style.setProperty("display", "none", "important");
688
+ }
689
+ }
690
+ }
691
+ // Expand parent containers up the DOM tree. Sites often wrap videos in multiple container divs with constrained dimensions. We need to break out of these.
692
+ let container = video.parentElement;
693
+ while (container && (container !== document.body)) {
694
+ container.style.cssText = [
695
+ "height: 100% !important",
696
+ "left: 0 !important",
697
+ "position: fixed !important",
698
+ "top: 0 !important",
699
+ "width: 100% !important",
700
+ "z-index: 999998 !important"
701
+ ].join("; ");
702
+ container = container.parentElement;
703
+ }
704
+ }, [selectorType]);
705
+ }
706
+ /**
707
+ * Ensures the video is displayed fullscreen with verification and retry logic. This function orchestrates the fullscreen process:
708
+ *
709
+ * 1. Initial attempt: Apply CSS styles and trigger fullscreen API
710
+ * 2. Verify: Check video dimensions and, for fullscreenApi profiles, confirm document.fullscreenElement is set
711
+ * 3. Simple retry: If verification fails, click the video for user activation and retry (the Fullscreen API requires a recent user gesture)
712
+ * 4. Escalate: If simple retries fail, apply aggressive fullscreen techniques with a final Fullscreen API re-trigger
713
+ *
714
+ * The retry approach handles both timing issues (page still initializing) and user activation issues (requestFullscreen requires a recent user gesture).
715
+ * On retry, clicking the video provides fresh activation so the subsequent requestFullscreen() call can succeed. Escalation to aggressive techniques is a
716
+ * last resort that may break site functionality but ensures video fills the viewport.
717
+ * @param page - The Puppeteer page object for keyboard input.
718
+ * @param context - The frame or page containing the video element.
719
+ * @param profile - The site profile indicating fullscreen method.
720
+ * @param selectorType - The video selector type for finding the element.
721
+ * @param skipNativeFullscreen - When true, skips Fullscreen API-specific actions (click-for-activation, native fullscreen verification, API retries). CSS styling
722
+ * and keyboard shortcuts still run. Used during monitor recovery where user activation is unavailable and click-for-activation can interfere with playback.
723
+ */
724
+ export async function ensureFullscreen(page, context, profile, selectorType, skipNativeFullscreen) {
725
+ // Configuration for retry behavior. These values are tuned for typical page load timing.
726
+ const maxSimpleRetries = 3;
727
+ const retryDelay = 500;
728
+ const verifyDelay = 200;
729
+ // When skipNativeFullscreen is set, we skip all Fullscreen API-specific actions: click-for-activation (which can toggle playback state and interfere with
730
+ // recovery), native fullscreen verification (which always fails without user activation), and API retries. CSS styling + keyboard shortcuts still run normally.
731
+ // This is used during monitor recovery where the Fullscreen API cannot succeed (no user activation context) and the monitor's own lightweight fullscreen
732
+ // maintenance loop handles ongoing CSS reapplication.
733
+ const useNativeFullscreen = profile.useRequestFullscreen && !skipNativeFullscreen;
734
+ for (let attempt = 1; attempt <= maxSimpleRetries; attempt++) {
735
+ // On retry for fullscreenApi profiles, click the video to provide fresh user activation. The Fullscreen API requires a recent user gesture (transient
736
+ // activation) to succeed — without it, requestFullscreen() is silently rejected. The initial attempt relies on activation from page navigation, but retries
737
+ // need an explicit click.
738
+ if ((attempt > 1) && useNativeFullscreen) {
739
+ // eslint-disable-next-line no-await-in-loop
740
+ await clickVideoForActivation(page, context, selectorType);
741
+ }
742
+ // Apply CSS styles to make the video fill the viewport.
743
+ // eslint-disable-next-line no-await-in-loop
744
+ await applyVideoStyles(context, selectorType);
745
+ // Trigger native fullscreen using the site's preferred method (keyboard shortcut or JavaScript API).
746
+ // eslint-disable-next-line no-await-in-loop
747
+ await triggerFullscreen(page, context, profile, selectorType);
748
+ // Wait a moment for fullscreen to take effect. The browser needs time to process the style changes and any fullscreen API calls.
749
+ // eslint-disable-next-line no-await-in-loop
750
+ await delay(verifyDelay);
751
+ // Verify that fullscreen succeeded by checking video dimensions.
752
+ // eslint-disable-next-line no-await-in-loop
753
+ const isFullscreen = await verifyFullscreen(context, selectorType);
754
+ if (isFullscreen) {
755
+ // For profiles that use the Fullscreen API, also verify that native fullscreen is active. CSS styling alone makes verifyFullscreen() pass based on
756
+ // dimensions, but the browser's native fullscreen mode is needed to hide the site's player chrome and overlays.
757
+ if (useNativeFullscreen) {
758
+ // eslint-disable-next-line no-await-in-loop
759
+ const nativeActive = await isNativeFullscreenActive(context);
760
+ if (!nativeActive) {
761
+ if (attempt < maxSimpleRetries) {
762
+ LOG.debug("browser:video", "Native fullscreen not active (attempt %s/%s). Retrying with user activation.", attempt, maxSimpleRetries);
763
+ // eslint-disable-next-line no-await-in-loop
764
+ await delay(retryDelay);
765
+ }
766
+ continue;
767
+ }
768
+ }
769
+ if (attempt > 1) {
770
+ LOG.debug("browser:video", "Fullscreen succeeded on attempt %s.", attempt);
771
+ }
772
+ return;
773
+ }
774
+ // Fullscreen verification failed. If we have retries remaining, wait and try again.
775
+ if (attempt < maxSimpleRetries) {
776
+ LOG.debug("browser:video", "Fullscreen verification failed (attempt %s/%s). Retrying after %sms.", attempt, maxSimpleRetries, retryDelay);
777
+ // eslint-disable-next-line no-await-in-loop
778
+ await delay(retryDelay);
779
+ }
780
+ }
781
+ // All simple retries exhausted. Escalate to aggressive fullscreen techniques.
782
+ LOG.warn("Fullscreen failed after %s attempts. Escalating to aggressive fullscreen.", maxSimpleRetries);
783
+ // Click for user activation before the aggressive attempt for fullscreenApi profiles.
784
+ if (useNativeFullscreen) {
785
+ await clickVideoForActivation(page, context, selectorType);
786
+ }
787
+ await applyAggressiveFullscreen(context, selectorType);
788
+ // Also try keyboard "f" as a last resort if the profile doesn't already use it. Many players respond to the "f" key for fullscreen.
789
+ if (!profile.fullscreenKey) {
790
+ await page.keyboard.type("f");
791
+ }
792
+ // Re-trigger the Fullscreen API after aggressive styling — the aggressive CSS ensures the video fills the viewport, and the API call hides site UI.
793
+ if (useNativeFullscreen) {
794
+ await triggerFullscreen(page, context, profile, selectorType);
795
+ }
796
+ // Final verification after aggressive techniques.
797
+ await delay(verifyDelay);
798
+ const finalCheck = await verifyFullscreen(context, selectorType);
799
+ if (!finalCheck) {
800
+ LOG.warn("Fullscreen could not be verified even after aggressive techniques. Video may not fill viewport.");
801
+ return;
802
+ }
803
+ // For fullscreenApi profiles, also verify native fullscreen is active after escalation.
804
+ if (useNativeFullscreen) {
805
+ const nativeActive = await isNativeFullscreenActive(context);
806
+ if (!nativeActive) {
807
+ LOG.warn("Video fills viewport but native fullscreen is not active. Site UI may be visible in capture.");
808
+ return;
809
+ }
810
+ }
811
+ LOG.debug("browser:video", "Fullscreen succeeded after aggressive techniques.");
812
+ }
813
+ /**
814
+ * Ensures the video is playing with proper audio settings. This is the core playback function that handles both initial setup and recovery from stalls. It is
815
+ * designed to be idempotent - safe to call multiple times without adverse effects.
816
+ *
817
+ * Recovery escalation levels (higher levels include all lower-level actions):
818
+ *
819
+ * LEVEL 1 - Basic recovery (default):
820
+ * - Set muted=false and volume=1
821
+ * - Call play() if video is paused
822
+ * - Ensure fullscreen with CSS styling, keyboard shortcuts, and dimension verification. When skipNativeFullscreen is set, Fullscreen API-specific actions are
823
+ * skipped because user activation is unavailable and click-for-activation can interfere with playback recovery.
824
+ * - Lock volume properties if profile requires it
825
+ *
826
+ * LEVEL 2 - Reload video source:
827
+ * - All level 1 actions, plus:
828
+ * - Reset video.src to empty, call load()
829
+ * - Restore original src, call load() again
830
+ * - Wait for source to reinitialize
831
+ * - This forces the player to completely reinitialize, fixing stuck players
832
+ *
833
+ * Level 3 (full page navigation) is handled by the playback monitor, not this function.
834
+ * @param page - The Puppeteer page object.
835
+ * @param context - The frame or page containing the video element.
836
+ * @param profile - The site profile containing all behavior flags.
837
+ * @param options - Optional recovery configuration. Omit for initial tune (full fullscreen behavior, level 1).
838
+ */
839
+ export async function ensurePlayback(page, context, profile, options) {
840
+ const selectorType = buildVideoSelectorType(profile);
841
+ const level = options?.recoveryLevel ?? 1;
842
+ // LEVEL 2: Reload video source. This forces the player to completely reinitialize by clearing and restoring the src attribute. This can fix players stuck in
843
+ // error states or with corrupted internal state.
844
+ if (level >= 2) {
845
+ try {
846
+ await reloadVideoSource(context, selectorType);
847
+ // Wait for the source to reload. The player needs time to parse the manifest, establish connections, and buffer initial data.
848
+ await delay(CONFIG.playback.sourceReloadDelay);
849
+ }
850
+ catch (_error) {
851
+ // Source reload errors are non-fatal - we continue with basic recovery actions.
852
+ }
853
+ }
854
+ // LEVEL 1: Basic play/unmute recovery. This is the minimum recovery action - ensure the video is playing with audio enabled. We do this before fullscreen so
855
+ // the video is playing when we verify dimensions.
856
+ try {
857
+ await startVideoPlayback(context, selectorType);
858
+ }
859
+ catch (_error) {
860
+ // Basic recovery errors are non-fatal - we continue with other actions.
861
+ }
862
+ // Ensure fullscreen with verification and retry. This applies CSS styling, triggers native fullscreen, verifies the video fills the viewport, and retries with
863
+ // escalating techniques if needed.
864
+ await ensureFullscreen(page, context, profile, selectorType, options?.skipNativeFullscreen);
865
+ // Apply volume locking if the profile requires it. This prevents the site from muting the video after we've set volume.
866
+ if (profile.lockVolumeProperties) {
867
+ await lockVolumeProperties(context, selectorType);
868
+ }
869
+ }
870
+ /**
871
+ * Dismisses any stale overlay or modal that may be covering the guide grid. After a failed click attempt on the on-now cell, the playback overlay or entity modal
872
+ * can remain open, obscuring the guide and preventing subsequent channel selection attempts from locating guide rows. Pressing Escape closes most modal overlays
873
+ * in React-based SPAs.
874
+ * @param page - The Puppeteer page object.
875
+ */
876
+ async function dismissGuideOverlay(page) {
877
+ try {
878
+ await page.keyboard.press("Escape");
879
+ // Brief delay for the overlay dismiss animation to complete and the guide grid to re-render.
880
+ await delay(500);
881
+ }
882
+ catch (error) {
883
+ // Overlay dismissal is best-effort. The overlay may not exist, or the page may be in a state where keyboard input is ignored.
884
+ LOG.debug("browser:video", "Could not dismiss guide overlay: %s.", formatError(error));
885
+ }
886
+ }
887
+ /**
888
+ * Performs all post-navigation channel initialization: selects the channel, finds the video context, clicks to play if needed, waits for video readiness, and
889
+ * ensures playback with fullscreen styling. This function is separated from navigateToPage() so that retryOperation() in setup.ts can wrap only navigation with a
890
+ * timeout, while channel selection and video setup run with their own internal time budgets (click retry loops, videoTimeout, etc.) without being killed by the
891
+ * navigation timeout.
892
+ *
893
+ * For guideGrid channel selection failures, the function attempts a single retry after dismissing any stale overlay that may be covering the guide grid. This
894
+ * handles the case where a failed click attempt left an overlay open, causing subsequent locateOnNowCell calls to fail.
895
+ *
896
+ * @param page - The Puppeteer page object.
897
+ * @param profile - The site profile containing all behavior flags.
898
+ * @param skipChannelSelection - When true, skip the channel selection phase entirely. Used when navigating directly to a cached watch URL that already targets
899
+ * the correct channel — only video detection, playback, and fullscreen setup are needed.
900
+ * @returns The video context (frame or page) for subsequent monitoring.
901
+ */
902
+ export async function initializePlayback(page, profile, skipChannelSelection = false) {
903
+ const elapsed = startTimer();
904
+ // For multi-channel players (like usanetwork.com/live with multiple channels), select the desired channel from the UI. The selectChannel function checks the
905
+ // profile's channelSelection strategy and channelSelector to determine if/how to select a channel. Skipped when navigating directly to a cached watch URL,
906
+ // since the URL already targets the correct channel.
907
+ if (!skipChannelSelection) {
908
+ let channelResult = await selectChannel(page, profile);
909
+ if (!channelResult.success) {
910
+ // For guideGrid strategy, a stale overlay from a previous failed click attempt may be covering the guide. Dismiss it and retry channel selection once.
911
+ if (profile.channelSelection.strategy === "guideGrid") {
912
+ LOG.warn("Guide grid channel selection failed: %s. Dismissing overlay and retrying.", channelResult.reason ?? "Unknown reason");
913
+ await dismissGuideOverlay(page);
914
+ channelResult = await selectChannel(page, profile);
915
+ }
916
+ if (!channelResult.success) {
917
+ throw new Error("Channel selection failed: " + (channelResult.reason ?? "Unknown reason."));
918
+ }
919
+ }
920
+ LOG.debug("timing:tune", "Channel selection complete. (+%sms)", elapsed());
921
+ }
922
+ // Find the video context, which may be an iframe for embedded players. Some streaming sites embed their video player in an iframe, requiring us to search
923
+ // through frames to find the one containing the video element.
924
+ const context = await findVideoContext(page, profile);
925
+ LOG.debug("timing:tune", "Video context found. (+%sms)", elapsed());
926
+ // For clickToPlay sites, we need to click an element to start playback. These players require user interaction to begin playing, even with autoplay enabled. If
927
+ // clickSelector is set, we click that element (typically a play button overlay); otherwise we click the video element directly.
928
+ if (profile.clickToPlay) {
929
+ const clickTarget = profile.clickSelector ?? "video";
930
+ try {
931
+ // Wait for the click target to appear in the DOM. Play button overlays may be rendered after initial page load.
932
+ await context.waitForSelector(clickTarget, { timeout: CONFIG.streaming.videoTimeout });
933
+ await context.click(clickTarget);
934
+ LOG.debug("timing:tune", "Click-to-play complete. (+%sms)", elapsed());
935
+ }
936
+ catch (clickError) {
937
+ LOG.warn("Could not click %s to initiate playback: %s.", clickTarget, formatError(clickError));
938
+ }
939
+ }
940
+ // Wait for video to be ready (readyState >= 3). This ensures enough data is buffered for playback to begin smoothly.
941
+ await waitForVideoReady(context, profile);
942
+ LOG.debug("timing:tune", "Video ready. (+%sms)", elapsed());
943
+ // Ensure playback is started, unmuted, and fullscreen. This applies CSS styling, triggers native fullscreen, and enforces volume settings.
944
+ await ensurePlayback(page, context, profile);
945
+ LOG.debug("timing:tune", "Playback ensured. (+%sms)", elapsed());
946
+ return { context };
947
+ }
948
+ /**
949
+ * Tunes to a channel by navigating to the URL and initializing video playback. This is the single source of truth for channel initialization, used by both initial
950
+ * stream setup and recovery. Having one authoritative function ensures consistent behavior and prevents code divergence between setup and recovery paths.
951
+ *
952
+ * The tuning process:
953
+ * 0. Check cache: If a direct watch URL is cached, navigate to it and skip channel selection. On failure, invalidate and fall through.
954
+ * 1. Navigate: Load the target URL using site-appropriate wait conditions
955
+ * 2. Select channel: For multi-channel players, click the desired channel in the UI
956
+ * 3. Find video: Locate the video element (which may be in an iframe)
957
+ * 4. Click to play: For Brightcove-style players, click the video to start playback
958
+ * 5. Wait for ready: Ensure the video has buffered enough data to play
959
+ * 6. Ensure playback: Start playback, unmute, and apply fullscreen styling
960
+ *
961
+ * Note: Stream context for logging is automatically retrieved from AsyncLocalStorage. Callers should wrap their stream handling code in runWithStreamContext() to
962
+ * ensure log messages include the stream ID prefix.
963
+ *
964
+ * @param page - The Puppeteer page object.
965
+ * @param url - The URL to navigate to.
966
+ * @param profile - The site profile containing all behavior flags.
967
+ * @returns The video context (frame or page) for subsequent monitoring.
968
+ */
969
+ export async function tuneToChannel(page, url, profile) {
970
+ const tuneElapsed = startTimer();
971
+ // Check for a direct watch URL. If available, navigate directly to it and skip channel selection, avoiding guide page navigation entirely. On failure,
972
+ // invalidate the cache entry and fall through to the normal guide-based flow.
973
+ const cachedUrl = await resolveDirectUrl(profile, page);
974
+ if (cachedUrl) {
975
+ try {
976
+ LOG.debug("timing:tune", "Using cached direct URL for %s.", profile.channelSelector ?? "unknown");
977
+ await navigateToPage(page, cachedUrl, profile);
978
+ LOG.debug("timing:tune", "Direct URL navigation complete. (+%sms)", tuneElapsed());
979
+ const result = await initializePlayback(page, profile, true);
980
+ LOG.debug("timing:tune", "Tune complete (cached). Total: %sms.", tuneElapsed());
981
+ return result;
982
+ }
983
+ catch (error) {
984
+ invalidateDirectUrl(profile);
985
+ LOG.warn("Cached direct URL failed for %s: %s. Falling back to guide navigation.", profile.channelSelector ?? "unknown", formatError(error));
986
+ }
987
+ }
988
+ // Normal flow: navigate to the guide page URL and perform full channel selection.
989
+ await navigateToPage(page, url, profile);
990
+ LOG.debug("timing:tune", "Navigation complete. (+%sms)", tuneElapsed());
991
+ // Perform all post-navigation initialization: channel selection, video context resolution, click to play, video readiness, and fullscreen.
992
+ const result = await initializePlayback(page, profile);
993
+ LOG.debug("timing:tune", "Tune complete. Total: %sms.", tuneElapsed());
994
+ return result;
995
+ }
996
+ //# sourceMappingURL=video.js.map