PageSourceSearch

https://tokero.com/_content/Tokero.Shared.Blazor/js/sharedInterop.js?v=5&m=8

js tokero.com collected 2026-10-06 10:23:38 UTC 349,374 bytes, 8,690 lines download raw bytes

1// Externalized from SharedScripts.razor (TKR-10273) so this ~60 KB interop bundle is browser-cached
2// instead of inlined into every page's HTML and re-compressed on every request.
3
4   // IN-APP HISTORY DEPTH — what the SocialFi topbar's back arrow is allowed to appear for.
5   //
6   // history.length is not the question. It counts every entry in the tab, including the ones that belong to
7   // whoever sent the visitor here, so a shared /post/{id} link opened straight from a search result looked
8   // like a page with somewhere to go back to and the arrow walked the visitor off the site.
9   //
10   // So each history entry carries its own depth, stamped into history.state: 0 for the entry the visitor
11   // arrived on, one more for each page they then opened from inside the app. Depth > 0 is the answer to "is
12   // the entry behind this one one of ours". Stamping the ENTRY rather than counting navigations is what makes
13   // back and forward read correctly with no extra bookkeeping - the browser hands the value back with the
14   // entry - and history.state survives a reload and a bfcache restore.
15   //
16   // Blazor keeps its own keys in that same state object (userState / _index in blazor.web.js, where _index is
17   // how it replays a navigation past a NavigationLock), so the stamp is added to a COPY of whatever is there
18   // and never replaces it.
19   const tokeroHistoryDepthStateKey = 'tkHistoryDepth';
20   const tokeroHistoryEntrySessionKey = 'tokero.history.entry';
21
22   // All three per-document, so all three are null exactly once per real page load. That is the signal that
23   // separates a same-document Blazor navigation - enhanced nav or NavigateTo, no page load, and the visitor
24   // was already inside the app - from arriving at a document from somewhere else, where the referrer decides.
25   let tokeroLastEntryDepth = null;
26   let tokeroLastEntryUrl = null;
27   let tokeroLastEntryHistoryLength = null;
28
29   const tokeroReadStampedHistoryDepth = () => {
30     const historyState = window.history.state;
31     if (!historyState || typeof historyState !== 'object') {
32       return null;
33     }
34     const stampedDepth = historyState[tokeroHistoryDepthStateKey];
35     if (typeof stampedDepth !== 'number') {
36       return null;
37     }
38     return stampedDepth;
39   };
40
41   const tokeroStampHistoryDepth = (depth) => {
42     try {
43       const existingState = window.history.state;
44       let stampedState = {};
45       if (existingState && typeof existingState === 'object') {
46         stampedState = Object.assign({}, existingState);
47       }
48       stampedState[tokeroHistoryDepthStateKey] = depth;
49       window.history.replaceState(stampedState, '');
50     } catch (err) {
51       // Safari rate-limits replaceState (100 per 30s). The in-memory depth below still carries the answer for
52       // the rest of this document; only a later back/forward onto this entry loses it.
53       console.error('tokeroStampHistoryDepth failed', err);
54     }
55   };
56
57   // sessionStorage is per-tab and survives full page loads, which is how the entry the visitor came FROM is
58   // still known after a navigation that replaced the document. history.length rides along with the depth
59   // because it is what tells a push apart from a replace across that boundary.
60   const tokeroReadSessionHistoryEntry = () => {
61     try {
62       const storedEntry = window.sessionStorage.getItem(tokeroHistoryEntrySessionKey);
63       if (!storedEntry) {
64         return null;
65       }
66       const parsedEntry = JSON.parse(storedEntry);
67       if (typeof parsedEntry?.depth !== 'number' || typeof parsedEntry?.length !== 'number') {
68         return null;
69       }
70       return parsedEntry;
71     } catch (err) {
72       // Storage blocked, or a value written by an older build. Treated as unknown, which lands the visitor on
73       // the referrer branch below.
74       return null;
75     }
76   };
77
78   const tokeroWriteSessionHistoryEntry = (depth, historyLength) => {
79     try {
80       const entryToStore = { depth: depth, length: historyLength };
81       window.sessionStorage.setItem(tokeroHistoryEntrySessionKey, JSON.stringify(entryToStore));
82     } catch (err) {
83       // Storage blocked (private mode, third-party context). The referrer branch below still resolves the
84       // entry the visitor arrived on, which is the case that matters.
85     }
86   };
87
88   // Deliberately same-ORIGIN, not "some Tokero host": community apps run on their own subdomain and on
89   // customer custom domains, and the browser cannot be asked which of those are ours. A walk from the main
90   // site into a community app therefore starts that app at depth 0 - the app has its own navigation, and an
91   // arrow that jumps the visitor to a different host is the behaviour being removed here, not kept.
92   const tokeroHasSameOriginReferrer = () => {
93     const referrer = document.referrer;
94     if (!referrer) {
95       return false;
96     }
97     try {
98       const referrerOrigin = new URL(referrer).origin;
99       return referrerOrigin === window.location.origin;
100     } catch (err) {
101       return false;
102     }
103   };
104
105   // A REPLACED entry is the trap this has to handle: NavigateTo(replace: true) and location.replace() swap the
106   // current entry instead of adding one - the Marketplace category filters and the CmsPage canonical rewrite
107   // both do it - so the visitor is one URL further along with nothing new behind them. history.length is what
108   // separates the two: a push moves it, a replace does not.
109   //
110   // The one case it reads wrong is a push that lands on top of forward entries it truncates, where the length
111   // can come out unchanged and the depth stops one short. That direction is the safe one: it can only hide an
112   // arrow that would have worked, never offer one that walks off the site.
113   const tokeroResolveCurrentHistoryDepth = () => {
114     const currentUrl = window.location.href;
115     const currentHistoryLength = window.history.length;
116
117     const stampedDepth = tokeroReadStampedHistoryDepth();
118     if (stampedDepth !== null) {
119       tokeroLastEntryDepth = stampedDepth;
120       tokeroLastEntryUrl = currentUrl;
121       tokeroLastEntryHistoryLength = currentHistoryLength;
122       tokeroWriteSessionHistoryEntry(stampedDepth, currentHistoryLength);
123       return stampedDepth;
124     }
125
126     // Unstamped, but the same URL this document last stamped: the stamp was dropped by a framework
127     // replaceState (Blazor writes state: null on an enhanced navigation), so this is the same entry rather
128     // than a new one. Put the depth it already had back instead of counting the page a second time.
129     if (tokeroLastEntryDepth !== null && tokeroLastEntryUrl === currentUrl) {
130       tokeroStampHistoryDepth(tokeroLastEntryDepth);
131       return tokeroLastEntryDepth;
132     }
133
134     let resolvedDepth = 0;
135     if (tokeroLastEntryDepth !== null) {
136       // Same document, different URL: a navigation the visitor made from inside the app.
137       resolvedDepth = tokeroLastEntryDepth;
138       const pushedRatherThanReplaced = currentHistoryLength !== tokeroLastEntryHistoryLength;
139       if (pushedRatherThanReplaced) {
140         resolvedDepth = tokeroLastEntryDepth + 1;
141       }
142     } else {
143       // First resolve in this document, so the visitor got here by a real page navigation. Only a same-origin
144       // referrer proves the entry behind them is one of ours - a search result, an ad, a shared link and a
145       // typed URL all leave them with nothing of ours to go back to.
146       const arrivedFromOurSite = tokeroHasSameOriginReferrer();
147       if (arrivedFromOurSite) {
148         // No stored entry (first page of the tab after a cache flush, or storage blocked) still means one of
149         // our pages is behind this one, since the referrer says so.
150         resolvedDepth = 1;
151         const previousEntry = tokeroReadSessionHistoryEntry();
152         if (previousEntry !== null) {
153           resolvedDepth = previousEntry.depth;
154           const pushedRatherThanReplaced = currentHistoryLength !== previousEntry.length;
155           if (pushedRatherThanReplaced) {
156             resolvedDepth = previousEntry.depth + 1;
157           }
158         }
159       }
160     }
161
162     tokeroStampHistoryDepth(resolvedDepth);
163     tokeroLastEntryDepth = resolvedDepth;
164     tokeroLastEntryUrl = currentUrl;
165     tokeroLastEntryHistoryLength = currentHistoryLength;
166     tokeroWriteSessionHistoryEntry(resolvedDepth, currentHistoryLength);
167     return resolvedDepth;
168   };
169
170   // Resolves the current entry as a side effect, so C# calling it once per navigation is also what keeps the
171   // stamps up to date.
172   window.tokeroHasInAppHistory = function () {
173     try {
174       const currentDepth = tokeroResolveCurrentHistoryDepth();
175       if (currentDepth < 1) {
176         return false;
177       }
178       // A tab with a single entry has nothing behind it whatever the depth says. Chrome copies sessionStorage
179       // into a tab opened with target="_blank", so an in-app link o
179pened in a new tab arrives with a
180       // same-origin referrer AND a stored depth, and this is what stops it claiming a back entry it lacks.
181       return window.history.length > 1;
182     } catch (err) {
183       console.error('tokeroHasInAppHistory failed', err);
184       // The coarse version of the same question, so a failure degrades instead of hiding a working arrow.
185       return tokeroHasSameOriginReferrer();
186     }
187   };
188
189   window.tokeroGoBack = function (fallbackUrl) {
190     // Re-asked at click time rather than trusted from render time: the control may have been painted before
191     // the visitor navigated, and this is the guard that keeps a stale arrow from leaving the site at all.
192     const hasInAppHistory = window.tokeroHasInAppHistory();
193     if (hasInAppHistory) {
194       window.history.back();
195       return;
196     }
197     if (fallbackUrl) {
198       window.location.assign(fallbackUrl);
199     }
200   };
201
202   window.getUtcOffset = () => {
203     try{
204       const offsetInMinutes = new Date().getTimezoneOffset();
205       return -offsetInMinutes;
206     } catch (err) {
207       console.error("getUtcOffset failed", err);
208       return 0;
209     }
210   };
211
212   window.getUserTimeZone = function () {
213     return Intl.DateTimeFormat().resolvedOptions().timeZone;
214   };
215
216   window.getUserTime = function () {
217     return new Date().toLocaleString();
218   };
219
220   const waitForVideoEvent = (element, eventName, timeoutMs) => {
221     return new Promise((resolve) => {
222       let settled = false;
223       const finish = (didFire) => {
224         if (settled) {
225           return;
226         }
227         settled = true;
228         element.removeEventListener(eventName, onEvent);
229         element.removeEventListener('error', onError);
230         clearTimeout(timer);
231         resolve(didFire);
232       };
233       const onEvent = () => finish(true);
234       const onError = () => finish(false);
235       element.addEventListener(eventName, onEvent);
236       element.addEventListener('error', onError);
237       const timer = setTimeout(() => finish(false), timeoutMs);
238     });
239   };
240
241   // Sized and opaque on purpose. WebKit treats a 1px, opacity:0 video as not worth decoding, so it would load
242   // metadata and never produce a frame — drawImage then painted nothing and the filmstrip came out white. It is
243   // kept off-viewport instead of hidden, and asks for real media data rather than just metadata.
244   const createOffscreenVideo = (objectUrl) => {
245     const video = document.createElement('video');
246     video.preload = 'auto';
247     video.muted = true;
248     video.defaultMuted = true;
249     video.playsInline = true;
250     video.setAttribute('playsinline', '');
251     video.setAttribute('webkit-playsinline', '');
252     video.setAttribute('muted', '');
253     video.style.position = 'fixed';
254     video.style.left = '-10000px';
255     video.style.top = '0';
256     video.style.width = '160px';
257     video.style.height = '90px';
258     video.style.pointerEvents = 'none';
259     document.body.appendChild(video);
260     video.src = objectUrl;
261     try { video.load(); } catch (e) { }
262     const dispose = () => {
263       try { video.removeAttribute('src'); video.load(); } catch (e) { }
264       try { document.body.removeChild(video); } catch (e) { }
265     };
266     return { video: video, dispose: dispose };
267   };
268
269   const VIDEO_FRAME_MAX_EDGE_PIXELS = 720;
270   const VIDEO_FRAME_JPEG_QUALITY = 0.75;
271
272   const encodeVideoFrameToJpeg = (video) => {
273     if (!video || !video.videoWidth || !video.videoHeight) {
274       return '';
275     }
276
277     let scale = 1;
278     if (video.videoWidth >= video.videoHeight && video.videoWidth > VIDEO_FRAME_MAX_EDGE_PIXELS) {
279       scale = VIDEO_FRAME_MAX_EDGE_PIXELS / video.videoWidth;
280     }
281     if (video.videoHeight > video.videoWidth && video.videoHeight > VIDEO_FRAME_MAX_EDGE_PIXELS) {
282       scale = VIDEO_FRAME_MAX_EDGE_PIXELS / video.videoHeight;
283     }
284
285     const width = Math.max(1, Math.round(video.videoWidth * scale));
286     const height = Math.max(1, Math.round(video.videoHeight * scale));
287     const canvas = document.createElement('canvas');
288     canvas.width = width;
289     canvas.height = height;
290     const ctx = canvas.getContext('2d');
291     ctx.drawImage(video, 0, 0, width, height);
292     return canvas.toDataURL('image/jpeg', VIDEO_FRAME_JPEG_QUALITY);
293   };
294
295   const readVideoDurationSeconds = async (video) => {
296     if (isFinite(video.duration) && video.duration > 0) {
297       return video.duration;
298     }
299
300     try {
301       video.currentTime = 1e7;
302       await waitForVideoEvent(video, 'durationchange', 3000);
303       video.currentTime = 0;
304     } catch (e) {
305       return 0;
306     }
307
308     if (isFinite(video.duration) && video.duration > 0) {
309       return video.duration;
310     }
311     return 0;
312   };
313
314   // Reads the selected video file straight from a DOM <input type=file> (Blazor InputFile renders one)
315   window.getVideoMetaAndPoster = async (inputId, previewVideoElementId) => {
316     const input = document.getElementById(inputId);
317     if (!input || !input.files || input.files.length === 0) {
318       return null;
319     }
320
321     const file = input.files[0];
322     let video = null;
323     let disposeVideo = null;
324
325     const preview = document.getElementById(previewVideoElementId);
326     if (preview && preview.src) {
327       video = preview;
328     }
329     if (video === null) {
330       const objectUrl = URL.createObjectURL(file);
331       const offscreen = createOffscreenVideo(objectUrl);
332       video = offscreen.video;
333       disposeVideo = () => { offscreen.dispose(); URL.revokeObjectURL(objectUrl); };
334     }
335
336     try {
337       if (video.readyState < 1) {
338         await waitForVideoEvent(video, 'loadedmetadata', 15000);
339       }
340
341       const durationSeconds = await readVideoDurationSeconds(video);
342       if (durationSeconds <= 0) {
343         console.error('getVideoMetaAndPoster: duration unavailable');
344         return null;
345       }
346
347       let posterDataUrl = '';
348       try {
349         if (video.readyState < 2) {
350           await waitForVideoEvent(video, 'loadeddata', 8000);
351         }
352
353         let seekTarget = 3;
354         if (durationSeconds < 3) {
355           seekTarget = 0.1;
356           if (durationSeconds < 0.1) {
357             seekTarget = 0;
358           }
359         }
360         video.currentTime = seekTarget;
361         await waitForVideoEvent(video, 'seeked', 3000);
362
363         posterDataUrl = encodeVideoFrameToJpeg(video);
364       } catch (posterErr) {
365         console.error('poster capture failed', posterErr);
366         posterDataUrl = '';
367       }
368
369       // Dimensions come off the element once metadata loads; the per-platform blockers check the clip against them.
370       return {
371         durationSeconds: durationSeconds,
372         sizeBytes: file.size,
373         posterDataUrl: posterDataUrl,
374         widthPixels: video.videoWidth,
375         heightPixels: video.videoHeight
376       };
377     } catch (err) {
378       console.error('getVideoMetaAndPoster failed', err);
379       return null;
380     } finally {
381       if (disposeVideo !== null) {
382         disposeVideo();
383       }
384     }
385   };
386
387   // Reads the frame size and a cover shot straight off a STASHED clip. getVideoMetaAndPoster above does the same for
388   // a file still sitting in an <input>; a clip the browser produced itself - a refit - never passes through one, so
389   // there is no input left to read it from. Duration is read too, but only to place the cover seek, as that one does.
390   window.getStashedVideoMeta = async (token) => {
391     const file = window.__pendingVideoUploads[token];
392     if (!file) {
393       return null;
394     }
395
396     const objectUrl = URL.createObjectURL(file);
397     const offscreen = createOffscreenVideo(objectUrl);
398     const video = offscreen.video;
399
400     try {
401       if (video.readyState < 1) {
402         await waitForVideoEvent(video, 'loadedmetadata', 15000);
403       }
404
405       const durationSeconds = await readVideoDurationSeconds(video);
406
407       let posterDataUrl = '';
408       try {
409         if (video.readyState < 2) {
410           await waitForVideoEvent(video, 'loadeddata', 8000);
411         }
412
413         let seekTarget = 3;
414         if (durationSeconds < 3) {
415           seekTarget = 0.1;
416           if (durationSeconds < 0.1) {
417             seekTarget = 0;
418           }
419         }
420         video.currentTime = seekTarget;
421         await waitForVideoEvent(video, 'seeked', 3000);
422
423         posterDataUrl = encodeVideoFrameToJpeg(video);
424       } catch (posterErr) {
425         console.error('getStashedVideoMeta: poster capture failed', posterErr);
426         posterDataUrl = '';
427       }
428
429       return {
430         posterDataUrl: posterDataUrl,
431         widthPixels: video.videoWidth,
432         heightPixels: video.videoHeight,
433         durationSeconds: durationSeconds
434       };
435     } catch (err) {
436       console.error('getStashedVideoMeta failed', err);
437       return null;
438     } finally {
439       offscreen.dispose();
440       URL.revokeObjectURL(objectUrl);
441     }
442   };
443
444   // Points a <video> element at an object URL of the file chosen in the given InputFile so the user can
445   // play back exactly what they picked. The object URL is stashed on the element dataset so
446   // revokeVideoPreview can release it later and we don't leak a blob per re-pick.
447   window.setVideoPreviewFromInput = (inputId, videoElementId) => {
448     const input = document.getElementById(inputId);
449     const video = document.getElementById(videoElementId);
450     if (!input || !input.files || input.files.length === 0 || !video) {
451       return;
452     }
453
454     if (video.dataset.objectUrl) {
455       URL.revokeObjectURL(video.dataset.objectUrl);
456     }
457
458     const objectUrl = URL.createObjectURL(input.files[0]);
459     video.dataset.objectUrl = objectUrl;
460     video.src = objectUrl;
461     video.load();
462   };
463
464   // Snapshots whatever frame the preview <video> is currently showing into a JPEG data URL, so the user
465   // can scrub the native controls to the frame they want and set it as the post's cover (poster). The
466   // preview is fed from a same-origin object URL of the picked file, so the canvas is not tainted and
467   // toDataURL succeeds. Returns '' when the element is missing or the frame can't be captured.
468   window.captureVideoFrame = (videoElementId) => {
469     const video = document.getElementById(videoElementId);
470     if (!video || !video.videoWidth || !video.videoHeight) {
471       return '';
472     }
473
474     try {
475       return encodeVideoFrameToJpeg(video);
476     } catch (err) {
477       console.error('captureVideoFrame failed', err);
478       return '';
479     }
480   };
481
482   // Builds an Instagram-style trim + cover scrubber under the video. The strip is filled with a row of
483   // evenly-spaced thumbnails (decoded offscreen from the picked file so the visible preview is not disturbed)
484   // and three draggable handles: a left and right TRIM handle that mark the kept [start, end] window (the
485   // regions outside it are dimmed), and a COVER marker constrained to sit inside that window. Pointer drag
486   // (mouse or touch) grabs the nearest handle, maps x to a timestamp, and seeks the visible preview so the
487   // frame shows over the video. On release: dragging a trim edge reports the new range to .NET
488   // (OnTrimRangeChanged) so it recomputes the effective duration/size and the over-limit gate; dragging the
489   // cover captures that frame and hands it back (OnCoverFrameSelected). Idempotent: re-calling rebuilds.
490   window.initCoverScrubber = async (videoElementId, stripElementId, inputId, dotNetRef) => {
491     const strip = document.getElementById(stripElementId);
492     const video = document.getElementById(videoElementId);
493     const input = document.getElementById(inputId);
494     if (!strip || !video || !input || !input.files || input.files.length === 0) {
495       return;
496     }
497
498     // Tear down any scrubber previously wired on this element before rebuilding for the new clip.
499     if (strip._coverCleanup) {
500       try { strip._coverCleanup(); } catch (e) { }
501     }
502     strip.innerHTML = '';
503
504     const track = document.createElement('div');
505     track.className = 'post-media-filmstrip-track';
506     // Dim overlays paint the trimmed-out regions (before start / after end).
507     const dimLeft = document.createElement('div');
508     dimLeft.className = 'post-media-filmstrip-dim';
509     const dimRight = document.createElement('div');
510     dimRight.className = 'post-media-filmstrip-dim';
511     // Trim edges + cover marker. Cover keeps the original handle class so its styling is unchanged.
512     const trimLeft = document.createElement('div');
513     trimLeft.className = 'post-media-filmstrip-trim post-media-filmstrip-trim-left';
514     const trimRight = document.createElement('div');
515     trimRight.className = 'post-media-filmstrip-trim post-media-filmstrip-trim-right';
516     const handle = document.createElement('div');
517     handle.className = 'post-media-filmstrip-handle';
518     strip.appendChild(track);
519     strip.appendChild(dimLeft);
520     strip.appendChild(dimRight);
521     strip.appendChild(trimLeft);
522     strip.appendChild(trimRight);
523     strip.appendChild(handle);
524
525     // Thumbnails: decode the picked file in an offscreen video and snapshot a handful of evenly-spaced
526     // frames. A per-seek timeout keeps a stubborn browser from hanging the build; on failure the bar still
527     // works as a plain scrub track (just without the filmstrip preview).
528     const objectUrl = URL.createObjectURL(input.files[0]);
529     const offscreen = createOffscreenVideo(objectUrl);
530     const off = offscreen.video;
531     const frameCount = 8;
532     let frameSource = off;
533     let isUsingPreview = false;
534     let previewResumeTime = 0;
535     try {
536       await waitForVideoEvent(off, 'loadedmetadata', 15000);
537       if (off.readyState < 2) {
538         await waitForVideoEvent(off, 'loadeddata', 8000);
539       }
540
541       // readyState < 2 means no frame has been decoded, so drawImage would paint nothing — the white strip.
542       // Mobile WebKit does this routinely for a video it considers off-screen. The on-screen preview is the one
543       // element the browser is definitely decoding, so borrow it and put its playhead back afterwards.
544       if (off.readyState < 2 || off.videoWidth === 0) {
545         frameSource = video;
546         isUsingPreview = true;
547         if (video.readyState < 1) {
548           await waitForVideoEvent(video, 'loadedmetadata', 15000);
549         }
550         if (video.readyState < 2) {
551           await waitForVideoEvent(video, 'loadeddata', 10000);
552         }
553         previewResumeTime = video.currentTime;
554       }
555
556       if (frameSource.readyState < 2 || frameSource.videoWidth === 0) {
557         throw new Error('filmstrip: no decodable frame source');
558       }
559
560       let stripDuration = 0;
561       if (isFinite(frameSource.duration) && frameSource.duration > 0) {
562         stripDuration = frameSource.duration;
563       }
564
565       const cellCanvas = document.createElement('canvas');
566       const cellWidth = 80;
567       let aspect = 0.5625;
568       if (frameSource.videoWidth && frameSource.videoHeight) {
569         aspect = frameSource.videoHeight / frameSource.videoWidth;
570       }
571       cellCanvas.width = cellWidth;
572       cellCanvas.height = Math.round(cellWidth * aspect);
573       const cellCtx = cellCanvas.getContext('2d');
574
575       for (let i = 0; i < frameCount; i++) {
576         let seekTime = 0;
577         if (stripDuration > 0) {
578           seekTime = stripDuration * (i + 0.5) / frameCount;
579         }
580         frameSource.currentTime = seekTime;
581         // A seek that merely runs slow on a busy phone must not cost the whole strip, so a miss skips its own
582         // cell and the build carries on.
583         await waitForVideoEvent(frameSource, 'seeked', 6000);
584         if (frameSource.readyState < 2 || frameSource.videoWidth === 0) {
585           continue;
586         }
587         cellCtx.drawImage(frameSource, 0, 0, cellCanvas.width, cellCanvas.height);
588         const cell = document.createElement('img');
589         cell.src = cellCanvas.toDataURL('image/jpeg', 0.6);
590         cell.className = 'post-media-filmstrip-cell';
591         cell.draggable = false;
592         track.appendChild(cell);
593       }
594     } catch (err) {
595       console.error('filmstrip build failed', err);
596     } finally {
597       if (isUsingPreview) {
598         try { video.currentTime = previewResumeTime; } catch (e) { }
599       }
600       offscreen.dispose();
601       URL.revokeObjectURL(objectUrl);
602     }
603
604     // Total clip duration drives all ratio<->seconds maths for both the trim range and the cover frame.
605     if (video.readyState < 1) {
606       await waitForVideoEvent(video, 'loadedmetadata', 15000);
607     }
608     const clipDuration = await readVideoDurationSeconds(video);
609
610     // Trim range + cover position, all kept as 0..1 ratios along the strip. Defaults: the full clip is
611     // selected, cover parked at the representative 3s frame (clamped into the clip for very short videos).
612     let startRatio = 0;
613     let endRatio = 1;
614     let coverRatio = 0;
615     if (clipDuration > 0) {
616       let coverSeconds = 3;
617       if (coverSeconds > clipDuration) {
618         coverSeconds = clipDuration;
619       }
620       coverRatio = coverSeconds / clipDuration;
621     }
622
623     // Smallest selectable window so the two trim handles can never cross or produce a zero-length clip: one
624     // second where the duration is known, otherwise a small fixed fraction of the bar.
625     let minGapRatio = 0.02;
626     if (clipDuration > 0) {
627       minGapRatio = 1 / clipDuration;
628       if (minGapRatio > 0.5) {
629         minGapRatio = 0.5;
630       }
631     }
632
633     // Map a pointer x to a clamped 0..1 ratio along the strip.
634     const ratioFromClientX = (clientX) => {
635       const rect = strip.getBoundingClientRect();
636       let ratio = 0;
637       if (rect.width > 0) {
638         ratio = (clientX - rect.left) / rect.width;
639       }
640       if (ratio < 0) { ratio = 0; }
641       if (ratio > 1) { ratio = 1; }
642       return ratio;
643     };
644
645     // Push the current ratios onto the DOM: handle/marker positions and the dimmed out-of-range regions.
646     const placeHandle = (element, ratio) => {
647       const stripWidth = strip.clientWidth;
648       if (stripWidth <= 0) {
649         element.style.left = (ratio * 100) + '%';
650         return;
651       }
652
653       const halfWidth = (element.offsetWidth / 2) + 2;
654       let x = ratio * stripWidth;
655       if (x < halfWidth) {
656         x = halfWidth;
657       }
658       if (x > stripWidth - halfWidth) {
659         x = stripWidth - halfWidth;
660       }
661       element.style.left = x + 'px';
662     };
663
664     const renderPositions = () => {
665       placeHandle(trimLeft, startRatio);
666       placeHandle(trimRight, endRatio);
667       placeHandle(handle, coverRatio);
668       dimLeft.style.left = '0%';
669       dimLeft.style.width = (startRatio * 100) + '%';
670       dimRight.style.left = (endRatio * 100) + '%';
671       dimRight.style.width = ((1 - endRatio) * 100) + '%';
672     };
673
674     // Seek the visible preview to a ratio so the frame at that position shows over the video.
675     const seekToRatio = (ratio) => {
676       if (clipDuration > 0) {
677         video.currentTime = ratio * clipDuration;
678       }
679     };
680
681     // Snapshot the frame now showing in the preview and hand it to .NET as the chosen cover, together with its
682     // position on the original timeline (seconds). .NET keeps the frame as the thumbnail and the timestamp so a
683     // platform that sets its cover by time (e.g. TikTok's video_cover_timestamp_ms) can match the picked frame.
684     const captureAndCommitCover = () => {
685       const dataUrl = window.captureVideoFrame(videoElementId);
686       if (dataUrl && dotNetRef) {
687         dotNetRef.invokeMethodAsync('OnCoverFrameSelected', dataUrl, coverRatio * clipDuration);
688       }
689     };
690
691     // Report the current trim range (in seconds) to .NET so it recomputes the effective duration / size and
692     // the over-limit save gate. Called on release of either trim handle.
693     const commitTrimRange = () => {
694       if (dotNetRef) {
695         dotNetRef.invokeMethodAsync('OnTrimRangeChanged', startRatio * clipDuration, endRatio * clipDuration);
696       }
697     };
698
699     // Decide which of the three handles a pointer press grabs: the nearest of left-trim, right-trim, or the
700     // cover marker. Trim edges win ties so the range stays adjustable even when the cover sits on an edge.
701     const pickHandle = (ratio) => {
702       const distanceToLeft = Math.abs(ratio - startRatio);
703       const distanceToRight = Math.abs(ratio - endRatio);
704       const distanceToCover = Math.abs(ratio - coverRatio);
705       let target = 'cover';
706       let best = distanceToCover;
707       if (distanceToLeft <= best) {
708         target = 'left';
709         best = distanceToLeft;
710       }
711       if (distanceToRight <= best) {
712         target = 'right';
713         best = distanceToRight;
714       }
715       return target;
716     };
717
718     let dragging = false;
719     let dragTarget = 'cover';
720
721     // Apply a drag to whichever handle was grabbed, keeping the invariants (cover stays inside [start, end],
722     // the two trim edges keep at least minGapRatio between them).
723     const applyDrag = (ratio) => {
724       if (dragTarget === 'left') {
725         let maxStart = endRatio - minGapRatio;
726         if (ratio > maxStart) { ratio = maxStart; }
727         if (ratio < 0) { ratio = 0; }
728         startRatio = ratio;
729         if (coverRatio < startRatio) {
730           coverRatio = startRatio;
731         }
732         renderPositions();
733         seekToRatio(startRatio);
734         return;
735       }
736       if (dragTarget === 'right') {
737         let minEnd = startRatio + minGapRatio;
738         if (ratio < minEnd) { ratio = minEnd; }
739         if (ratio > 1) { ratio = 1; }
740         endRatio = ratio;
741         if (coverRatio > endRatio) {
742           coverRatio = endRatio;
743         }
744         renderPositions();
745         seekToRatio(endRatio);
746         return;
747       }
748       // Cover marker: constrained to stay inside the selected window.
749       if (ratio < startRatio) { ratio = startRatio; }
750       if (ratio > endRatio) { ratio = endRatio; }
751       coverRatio = ratio;
752       renderPositions();
753       seekToRatio(coverRatio);
754     };
755
756     const onDown = (e) => {
757       dragging = true;
758       const ratio = ratioFromClientX(e.clientX);
759       dragTarget = pickHandle(ratio);
760       if (e.pointerId != null && strip.setPointerCapture) {
761         try { strip.setPointerCapture(e.pointerId); } catch (err) { }
762       }
763       applyDrag(ratio);
764       e.preventDefault();
765     };
766     const onMove = (e) => {
767       if (!dragging) {
768         return;
769       }
770       applyDrag(ratioFromClientX(e.clientX));
771     };
772     const onUp = (e) => {
773       if (!dragging) {
774         return;
775       }
776       dragging = false;
777       applyDrag(ratioFromClientX(e.clientX));
778       // Trimming an edge re-reports the range (and may have shoved the cover, so re-capture too); scrubbing
779       // the cover re-captures once the final seek settles. The timeout is the fallback for browsers that skip
780       let coverFallbackTimer = null;
781       let isCoverCaptured = false;
782       const captureCoverOnce = () => {
783         if (isCoverCaptured) {
784           return;
785         }
786         isCoverCaptured = true;
787         video.onseeked = null;
788         clearTimeout(coverFallbackTimer);
789         captureAndCommitCover();
790       };
791       video.onseeked = captureCoverOnce;
792       coverFallbackTimer = setTimeout(captureCoverOnce, 300);
793       if (dragTarget === 'left' || dragTarget === 'right') {
794         commitTrimRange();
795       }
796     };
797
798     strip.addEventListener('pointerdown', onDown);
799     strip.addEventListener('pointermove', onMove);
800     strip.addEventListener('pointerup', onUp);
801     strip.addEventListener('pointercancel', onUp);
802
803     // On mobile a drag on the LEFT trim handle starts at ratio 0 — i.e. in the screen's edge zone — where the
804     // OS claims it as the interactive swipe-back gesture and navigates away mid-cut. touch-action:none on the
805     // strip stops scrolling but not that system gesture, so also consume the touch sequence directly: a
806     // non-passive touchmove that preventDefaults keeps the slide on the strip (left/right along the filmstrip)
807     // instead of the browser back-navigation. Same pattern the photo cropper uses; we preventDefault only on
808     // touchmove (never touchstart — that cancels the pointer stream on iOS and would kill the drag).
809     const swallowTouchMove = (e) => { e.preventDefault(); };
810     strip.addEventListener('touchmove', swallowTouchMove, { passive: false });
811
812     const onStripResize = () => {
813       renderPositions();
814     };
815     window.addEventListener('resize', onStripResize);
816     window.addEventListener('orientationchange', onStripResize);
817
818     strip._coverCleanup = () => {
819       strip.removeEventListener('pointerdown', onDown);
820       strip.removeEventListener('pointermove', onMove);
821       strip.removeEventListener('pointerup', onUp);
822       strip.removeEventListener('pointercancel', onUp);
823       strip.removeEventListener('touchmove', swallowTouchMove);
824       window.removeEventListener('resize', onStripResize);
825       window.removeEventListener('orientationchange', onStripResize);
826       strip._coverCleanup = null;
827     };
828
829     // Paint the initial full-range selection with the cover parked at its default 3s frame.
830     renderPositions();
831   };
832
833   // Removes the scrubber's pointer listeners and clears the strip element (mode switch / re-pick / dispose).
834   window.disposeCoverScrubber = (stripElementId) => {
835     const strip = document.getElementById(stripElementId);
836     if (!strip) {
837       return;
838     }
839     if (strip._coverCleanup) {
840       try { strip._coverCleanup(); } catch (e) { }
841     }
842     strip.innerHTML = '';
843   };
844
845   // Releases the preview object URL and (when an inputId is given) clears the file input so the same
846   // file can be re-picked and a mode switch leaves no stale selection behind.
847   window.revokeVideoPreview = (videoElementId, inputId) => {
848     const video = document.getElementById(videoElementId);
849     if (video && video.dataset.objectUrl) {
850       URL.revokeObjectURL(video.dataset.objectUrl);
851       video.dataset.objectUrl = '';
852       video.removeAttribute('src');
853       video.load();
854     }
855     if (inputId) {
856       const input = document.getElementById(inputId);
857       try { if (input) { input.value = ''; } } catch (e) { }
858     }
859   };
860
861   // SocialFi video upload goes browser -> HTTP endpoint -> blob storage, NOT over the SignalR circuit. The
862   // picked File is stashed here when the attach dialog commits (so the reference survives the dialog closing,
863   // which removes its <input>), then POSTed directly to the upload endpoint when the composer creates the
864   // post. Keyed by an opaque token handed back to .NET; the File object is a lightweight handle, not a copy.
865   window.__pendingVideoUploads = window.__pendingVideoUploads || {};
866   let __pendingVideoSeq = 0;
867
868   window.stashSelectedVideo = (inputId) => {
869     const input = document.getElementById(inputId);
870     if (!input || !input.files || input.files.length === 0) {
871       return '';
872     }
873     __pendingVideoSeq = __pendingVideoSeq + 1;
874     const token = 'vid_' + __pendingVideoSeq;
875     window.__pendingVideoUploads[token] = input.files[0];
876     return token;
877   };
878
879   window.discardStashedVideo = (token) => {
880     if (token && window.__pendingVideoUploads[token]) {
881       delete window.__pendingVideoUploads[token];
882     }
883   };
884
885   // Copies a stash entry under a NEW token, so an edit made for one destination (a refit into the frame that one
886   // platform demands) cannot reach the clip the other destinations are still pointing at. The bytes are not copied:
887   // both tokens reference the same Blob until one of them is replaced.
888   window.cloneStashedVideo = (token) => {
889     const file = window.__pendingVideoUploads[token];
890     if (!file) {
891       return '';
892     }
893     __pendingVideoSeq = __pendingVideoSeq + 1;
894     const clonedToken = 'vid_' + __pendingVideoSeq;
895     window.__pendingVideoUploads[clonedToken] = file;
896     return clonedToken;
897   };
898
899   // Initializes TikTok preview video controls: progress tracking, click-to-seek, drag-to-seek, and pause/play state.
900   // The progress dot only appears when the video is paused or being dragged.
901   window.tkpInitVideoControls = (videoEl, progressFillEl, progressBarEl) => {
902     if (!videoEl || !progressFillEl || !progressBarEl) return;
903
904     // Blazor sets the muted/autoplay attributes after the element is created, so the live muted property is still
905     // false and browsers block (or voice) the unmuted autoplay. Mute the property for real, then start playback.
906     videoEl.muted = true;
907     videoEl.play().catch(() => {});
908
909     let seeking = false;
910
911     const updateFill = () => {
912       if (!document.body.contains(videoEl)) return;
913       if (!seeking && videoEl.duration > 0) {
914         progressFillEl.style.width = (videoEl.currentTime / videoEl.duration * 100) + '%';
915       }
916       requestAnimationFrame(updateFill);
917     };
918     requestAnimationFrame(updateFill);
919
920     const seekToX = (clientX) => {
921       const rect = progressBarEl.getBoundingClientRect();
922       let ratio = (clientX - rect.left) / rect.width;
923       if (ratio < 0) ratio = 0;
924       if (ratio > 1) ratio = 1;
925       videoEl.currentTime = ratio * videoEl.duration;
926       progressFillEl.style.width = (ratio * 100) + '%';
927     };
928
929     progressBarEl.addEventListener('mousedown', (e) => {
930       if (!videoEl.paused) {
931         videoEl.pause();
932         progressBarEl.classList.add('tkp-paused');
933       }
934       seeking = true;
935       progressBarEl.classList.add('tkp-seeking');
936       seekToX(e.clientX);
937
938       const onMove = (ev) => seekToX(ev.clientX);
939       const onUp = () => {
940         seeking = false;
941         progressBarEl.classList.remove('tkp-seeking');
942         document.removeEventListener('mousemove', onMove);
943         document.removeEventListener('mouseup', onUp);
944       };
945       document.addEventListener('mousemove', onMove);
946       document.addEventListener('mouseup', onUp);
947     });
948   };
949
950   // Toggles play/pause on the TikTok preview video and syncs the progress bar paused state.
951   window.tkpTogglePlayPause = (videoEl, progressBarEl) => {
952     if (!videoEl || !progressBarEl) return;
953     if (videoEl.paused) {
954       videoEl.play();
955       progressBarEl.classList.remove('tkp-paused');
956     } else {
957       videoEl.pause();
958       progressBarEl.classList.add('tkp-paused');
959     }
960   };
961
962   const VIDEO_UPLOAD_PROBE_ANCHOR_MS = 2000;
963   const VIDEO_UPLOAD_SPEED_PROBE_MS = 5000;
964   const VIDEO_UPLOAD_SLOW_BYTES_PER_SECOND = 128 * 1024;
965   const VIDEO_UPLOAD_SLOW_PROJECTED_SECONDS = 300;
966   const VIDEO_UPLOAD_STALL_MS = 20000;
967   const VIDEO_UPLOAD_PROJECTION_MARGIN_MS = 30000;
968   const VIDEO_UPLOAD_RESPONSE_WAIT_MS = 120000;
969   const VIDEO_UPLOAD_FLOOR_BYTES_PER_SECOND = 2 * 1024;
970   const VIDEO_UPLOAD_MAX_DRAIN_MS = 600000;
971
972   const VIDEO_UPLOAD_SINGLE_SHOT_MAX_BYTES = 25 * 1024 * 1024;
973
974   const VIDEO_UPLOAD_TARGET_SLICE_SECONDS = 25;
975   const VIDEO_UPLOAD_MIN_SLICE_BYTES = 256 * 1024;
976   const VIDEO_UPLOAD_MAX_SLICE_BYTES = 1 * 1024 * 1024;
977   const VIDEO_UPLOAD_DEFAULT_SLICE_BYTES = 1 * 1024 * 1024;
978
979   const videoUploadSlicedMediaIds = new Set();
980
981   let isVideoJobCancelled = false;
982
983   const uploadWholeFile = (file, baseUrl, mediaId, reportProgress, deadlineAt) => {
984     return new Promise((resolve) => {
985       const xhr = new XMLHttpRequest();
986       xhr.open('POST', baseUrl, true);
987       xhr.setRequestHeader('X-Media-Id', mediaId);
988       xhr.setRequestHeader('Content-Type', file.type || 'video/mp4');
989
990       const startedAt = performance.now();
991       let lastProgressAt = startedAt;
992       let loadedBytes = 0;
993       let settled = false;
994
995       let anchorAt = startedAt;
996       let anchorBytes = 0;
997       let isAnchorSet = false;
998       const measureBytesPerSecond = () => {
999         const elapsedMs = performance.now() - anchorAt;
1000         if (elapsedMs <= 0) {
1001           return 0;
1002         }
1003         const bytesPerSecond = ((loadedBytes - anchorBytes) * 1000) / elapsedMs;
1004         return bytesPerSecond;
1005       };
1006       const settle = (verdict) => {
1007         if (settled) {
1008           return;
1009         }
1010         settled = true;
1011         clearInterval(watchdogTimer);
1012         resolve({ verdict: verdict, bytesPerSecond: measureBytesPerSecond() });
1013       };
1014
1015       const watchdogTimer = setInterval(() => {
1016         const now = performance.now();
1017         if (isVideoJobCancelled) {
1018           settle('cancelled');
1019           xhr.abort();
1020           return;
1021         }
1022         if (deadlineAt > 0 && now > deadlineAt) {
1023           settle('too-slow');
1024           xhr.abort();
1025           return;
1026         }
1027         if (now - lastProgressAt > VIDEO_UPLOAD_STALL_MS) {
1028           settle('slow');
1029           xhr.abort();
1030           return;
1031         }
1032
1033         const elapsedSinceStartMs = now - startedAt;
1034         if (elapsedSinceStartMs < VIDEO_UPLOAD_PROBE_ANCHOR_MS) {
1035           return;
1036         }
1037         if (!isAnchorSet) {
1038           isAnchorSet = true;
1039           anchorAt = now;
1040           anchorBytes = loadedBytes;
1041           return;
1042         }
1043         if (now - anchorAt < VIDEO_UPLOAD_SPEED_PROBE_MS) {
1044           return;
1045         }
1046
1047         const bytesPerSecond = measureBytesPerSecond();
1048
1049         // Projected finish vs the optimise cut-off: bail to the transcode now instead of burning the full window.
1050         const remainingBytes = file.size - loadedBytes;
1051         const projectedRemainingMs = (remainingBytes / Math.max(1, bytesPerSecond)) * 1000;
1052         const projectedFinishAt = now + projectedRemainingMs;
1053         const willClearlyMissDeadline = deadlineAt > 0
1054           && projectedFinishAt > deadlineAt + VIDEO_UPLOAD_PROJECTION_MARGIN_MS;
1055         if (willClearlyMissDeadline) {
1056           settle('too-slow');
1057           xhr.abort();
1058           return;
1059         }
1060
1061         const projectedSeconds = file.size / Math.max(1, bytesPerSecond);
1062         const isSlowLink = bytesPerSecond < VIDEO_UPLOAD_SLOW_BYTES_PER_SECOND
1063           || projectedSeconds > VIDEO_UPLOAD_SLOW_PROJECTED_SECONDS;
1064         if (isSlowLink) {
1065           settle('slow');
1066           xhr.abort();
1067         }
1068       }, 1000);
1069
1070       xhr.upload.onprogress = (e) => {
1071         if (!e.lengthComputable) {
1072           return;
1073         }
1074         loadedBytes = e.loaded;
1075         lastProgressAt = performance.now();
1076         reportProgress(e.loaded);
1077       };
1078       xhr.onload = () => {
1079         if (xhr.status >= 200 && xhr.status < 300) {
1080           settle('ok');
1081           return;
1082         }
1083         if (xhr.status === 401 || xhr.status === 403) {
1084           settle('rejected');
1085           return;
1086         }
1087         settle('slow');
1088       };
1089       xhr.onerror = () => settle('slow');
1090       xhr.ontimeout = () => settle('slow');
1091       xhr.onabort = () => settle('slow');
1092       xhr.send(file);
1093     });
1094   };
1095
1096   const chooseSliceBytes = (bytesPerSecond) => {
1097     if (!(bytesPerSecond > 0)) {
1098       return VIDEO_UPLOAD_DEFAULT_SLICE_BYTES;
1099     }
1100
1101     let sliceBytes = Math.floor(bytesPerSecond * VIDEO_UPLOAD_TARGET_SLICE_SECONDS);
1102     if (sliceBytes < VIDEO_UPLOAD_MIN_SLICE_BYTES) {
1103       sliceBytes = VIDEO_UPLOAD_MIN_SLICE_BYTES;
1104     }
1105     if (sliceBytes > VIDEO_UPLOAD_MAX_SLICE_BYTES) {
1106       sliceBytes = VIDEO_UPLOAD_MAX_SLICE_BYTES;
1107     }
1108     return sliceBytes;
1109   };
1110
1111   const uploadInSlices = async (file, baseUrl, mediaId, reportProgress, measuredBytesPerSecond, deadlineAt, skipResume) => {
1112     const totalSize = file.size;
1113     const result = { committed: false, tooSlow: false, cancelled: false };
1114
1115     const retryDelays = [0, 3000, 5000, 10000, 20000];
1116     const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1117
1118     let chunkSize = chooseSliceBytes(measuredBytesPerSecond);
1119
1120     let staged = [];
1121     let isAlreadyCommitted = false;
1122     for (let attempt = 0; attempt < retryDelays.length && !skipResume; attempt++) {
1123       if (attempt > 0) {
1124         await sleep(retryDelays[attempt]);
1125       }
1126       try {
1127         const statusResp = await fetch(baseUrl + 'status/', {
1128           method: 'GET',
1129           headers: { 'X-Media-Id': mediaId, 'X-Total-Size': String(totalSize) }
1130         });
1131         if (statusResp.ok) {
1132           const data = await statusResp.json();
1133           staged = (data && data.staged) || [];
1134           isAlreadyCommitted = !!(data && data.committed);
1135           break;
1136         }
1137         if (statusResp.status === 401 || statusResp.status === 403) {
1138           return result;
1139         }
1140       } catch (e) {
1141         staged = [];
1142       }
1143     }
1144
1145     if (isAlreadyCommitted) {
1146       reportProgress(totalSize);
1147       result.committed = true;
1148       return result;
1149     }
1150
1151     if (staged.length > 0) {
1152       let maxStaged = 0;
1153       for (const s of staged) {
1154         if (s.size > maxStaged) {
1155           maxStaged = s.size;
1156         }
1157       }
1158
1159       if (maxStaged > VIDEO_UPLOAD_MAX_SLICE_BYTES) {
1160         staged = [];
1161       }
1162       if (maxStaged > 0 && maxStaged <= VIDEO_UPLOAD_MAX_SLICE_BYTES) {
1163         chunkSize = maxStaged;
1164       }
1165     }
1166
1167     const chunkTotal = Math.max(1, Math.ceil(totalSize / chunkSize));
1168     const stagedIndices = new Set(staged.map((s) => s.index));
1169     const contentType = file.type || 'video/mp4';
1170
1171     let confirmedBytes = 0;
1172     for (const s of staged) {
1173       confirmedBytes += s.size;
1174     }
1175     let maxReportedBytes = 0;
1176     const reportSliceProgress = (extraLoaded) => {
1177       const uploadedBytes = confirmedBytes + extraLoaded;
1178       if (uploadedBytes > maxReportedBytes) {
1179         maxReportedBytes = uploadedBytes;
1180       }
1181       reportProgress(maxReportedBytes);
1182     };
1183     reportSliceProgress(0);
1184
1185     const postOneChunk = (index, blob) => {
1186       return new Promise((resolve) => {
1187         const xhr = new XMLHttpRequest();
1188         xhr.open('POST', baseUrl + 'chunk/', true);
1189         xhr.setRequestHeader('X-Media-Id', mediaId);
1190         xhr.setRequestHeader('X-Chunk-Index', String(index));
1191         xhr.setRequestHeader('X-Chunk-Total', String(chunkTotal));
1192         xhr.setRequestHeader('X-Total-Size', String(totalSize));
1193         xhr.setRequestHeader('Content-Type', contentType);
1194
1195         let lastProgressAt = performance.now();
1196         let silenceAllowanceMs = Math.max(
1197           VIDEO_UPLOAD_RESPONSE_WAIT_MS,
1198           Math.ceil((blob.size / VIDEO_UPLOAD_FLOOR_BYTES_PER_SECOND) * 1000));
1199         if (silenceAllowanceMs > VIDEO_UPLOAD_MAX_DRAIN_MS) {
1200           silenceAllowanceMs = VIDEO_UPLOAD_MAX_DRAIN_MS;
1201         }
1202         let settled = false;
1203         const settle = (isUploaded) => {
1204           if (settled) {
1205             return;
1206           }
1207           settled = true;
1208           clearInterval(stallTimer);
1209           resolve(isUploaded);
1210         };
1211         const stallTimer = setInterval(() => {
1212           if (isVideoJobCancelled) {
1213             settle(false);
1214             xhr.abort();
1215             return;
1216           }
1217           if (performance.now() - lastProgressAt > silenceAllowanceMs) {
1218             settle(false);
1219             xhr.abort();
1220           }
1221         }, 1000);
1222
1223         xhr.upload.onprogress = (e) => {
1224           lastProgressAt = performance.now();
1225           if (e.lengthComputable) {
1226             reportSliceProgress(e.loaded);
1227           }
1228         };
1229         xhr.upload.onload = () => {
1230           lastProgressAt = performance.now();
1231         };
1232         xhr.onload = () => settle(xhr.status >= 200 && xhr.status < 300);
1233         xhr.onerror = () => settle(false);
1234         xhr.ontimeout = () => settle(false);
1235         xhr.onabort = () => settle(false);
1236         xhr.send(blob);
1237       });
1238     };
1239
1240     const postChunkWithRetry = async (index) => {
1241       const start = index * chunkSize;
1242       let end = start + chunkSize;
1243       if (end > totalSize) {
1244         end = totalSize;
1245       }
1246       const blob = file.slice(start, end);
1247       for (let attempt = 0; attempt < retryDelays.length; attempt++) {
1248         if (isVideoJobCancelled) {
1249           return -1;
1250         }
1251         if (attempt > 0) {
1252           await sleep(retryDelays[attempt]);
1253         }
1254         const ok = await postOneChunk(index, blob);
1255         if (ok) {
1256           return end - start;
1257         }
1258       }
1259       return -1;
1260     };
1261
1262     const slicingStartedAt = performance.now();
1263     let bytesUploadedThisRun = 0;
1264
1265     for (let index = 0; index < chunkTotal; index++) {
1266       if (isVideoJobCancelled) {
1267         result.cancelled = true;
1268         return result;
1269       }
1270       if (deadlineAt > 0 && performance.now() > deadlineAt) {
1271         result.tooSlow = true;
1272         return result;
1273       }
1274       if (stagedIndices.has(index)) {
1275         continue;
1276       }
1277       const uploadedChunkBytes = await postChunkWithRetry(index);
1278       if (uploadedChunkBytes < 0) {
1279         if (isVideoJobCancelled) {
1280           result.cancelled = true;
1281         }
1282         return result;
1283       }
1284       confirmedBytes += uploadedChunkBytes;
1285       bytesUploadedThisRun += uploadedChunkBytes;
1286       reportSliceProgress(0);
1287
1288       // Same projection as the single-shot watchdog, measured over the slices actually sent this run.
1289       const elapsedMs = performance.now() - slicingStartedAt;
1290       const hasUsableMeasurement = deadlineAt > 0 && elapsedMs >= VIDEO_UPLOAD_SPEED_PROBE_MS;
1291       if (hasUsableMeasurement) {
1292         const bytesPerSecond = (bytesUploadedThisRun * 1000) / elapsedMs;
1293         const remainingBytes = totalSize - confirmedBytes;
1294         const projectedRemainingMs = (remainingBytes / Math.max(1, bytesPerSecond)) * 1000;
1295         const projectedFinishAt = performance.now() + projectedRemainingMs;
1296         if (projectedFinishAt > deadlineAt + VIDEO_UPLOAD_PROJECTION_MARGIN_MS) {
1297           result.tooSlow = true;
1298           return result;
1299         }
1300       }
1301     }
1302
1303     for (let attempt = 0; attempt < retryDelays.length; attempt++) {
1304       if (isVideoJobCancelled) {
1305         result.cancelled = true;
1306         return result;
1307       }
1308       if (attempt > 0) {
1309         await sleep(retryDelays[attempt]);
1310       }
1311       try {
1312         const resp = await fetch(baseUrl + 'complete/', {
1313           method: 'POST',
1314           headers: { 'X-Media-Id': mediaId, 'X-Chunk-Total': String(chunkTotal), 'X-Total-Size': String(totalSize) }
1315         });
1316         if (resp.ok) {
1317           result.committed = true;
1318           return result;
1319         }
1320         if (resp.status === 409) {
1321           let missing = [];
1322           try {
1323             const data = await resp.json();
1324             missing = (data && data.missingIndices) || [];
1325           } catch (e) {
1326             missing = [];
1327           }
1328           for (const missingIndex of missing) {
1329             const uploadedChunkBytes = await postChunkWithRetry(missingIndex);
1330             if (uploadedChunkBytes < 0) {
1331               return result;
1332             }
1333             reportSliceProgress(0);
1334           }
1335           continue;
1336         }
1337         if (resp.status === 413) {
1338           return result;
1339         }
1340       } catch (e) {
1341       }
1342     }
1343     return result;
1344   };
1345
1346   // ---------------------------------------------------------------------------------------------------------
1347   // Direct-to-storage upload. The pod hands out a SAS scoped to this one blob and the bytes go straight to Azure
1348   // Blob Storage, so a long clip no longer occupies a request for its whole transfer. Everything below degrades
1349   // to the proxied path above on ANY failure — a disabled setting, an unsignable credential, a storage account
1350   // with no CORS rule for this origin — because a slower upload is always better than a lost post.
1351   // ---------------------------------------------------------------------------------------------------------
1352
1353   const DIRECT_BLOCK_ID_DIGITS = 8;
1354   const DIRECT_UPLOAD_FORBIDDEN_STATUS = 403;
1355
1356   const requestDirectUploadGrant = async (baseUrl, mediaId) => {
1357     try {
1358       const response = await fetch(baseUrl + 'direct/', {
1359         method: 'GET',
1360         headers: { 'X-Media-Id': mediaId }
1361       });
1362       if (!response.ok) {
1363         return null;
1364       }
1365
1366       const grant = await response.json();
1367       if (!grant || !grant.enabled) {
1368         return null;
1369       }
1370       if (!grant.uploadUrl) {
1371         return null;
1372       }
1373       return grant;
1374     } catch (e) {
1375       return null;
1376     }
1377   };
1378
1379   // Same fixed-width, base64 shape the server uses on the proxied paths. Azure refuses a Put Block 
1379whose id
1380   // length disagrees with the blocks already staged against the blob, so every path must produce ids of one
1381   // length; the prefix is what keeps a resume scan from adopting a block the other path sized differently.
1382   const generateDirectBlockId = (blockIdPrefix, index) => {
1383     let indexText = String(index);
1384     while (indexText.length < DIRECT_BLOCK_ID_DIGITS) {
1385       indexText = '0' + indexText;
1386     }
1387     return btoa(blockIdPrefix + indexText);
1388   };
1389
1390   const decodeDirectBlockIndex = (blockIdPrefix, blockId) => {
1391     try {
1392       const raw = atob(blockId);
1393       if (raw.indexOf(blockIdPrefix) !== 0) {
1394         return -1;
1395       }
1396       const indexText = raw.substring(blockIdPrefix.length, raw.length);
1397       const index = parseInt(indexText, 10);
1398       if (isNaN(index)) {
1399         return -1;
1400       }
1401       return index;
1402     } catch (e) {
1403       return -1;
1404     }
1405   };
1406
1407   // Blocks this browser (or a previous session) already staged against the blob, so a resumed upload re-sends
1408   // only what is missing. A blob that was never staged answers 404, which is a clean empty list, not an error.
1409   // isStorageReachable separates "storage answered" from "the request never got a response at all". A storage
1410   // account with no CORS rule for this origin produces the second, and it refuses every later request the same
1411   // way, so the caller treats it as "direct is not available here" instead of retrying into the same wall.
1412   const retrieveDirectStagedBlocks = async (uploadUrl, blockIdPrefix) => {
1413     const scan = { isStorageReachable: true, blocks: [] };
1414
1415     let response = null;
1416     try {
1417       response = await fetch(uploadUrl + '&comp=blocklist&blocklisttype=uncommitted', { method: 'GET' });
1418     } catch (e) {
1419       scan.isStorageReachable = false;
1420       return scan;
1421     }
1422     if (!response.ok) {
1423       return scan;
1424     }
1425
1426     try {
1427       const xmlText = await response.text();
1428       const parser = new DOMParser();
1429       const blockListDocument = parser.parseFromString(xmlText, 'application/xml');
1430       const blockNodes = blockListDocument.getElementsByTagName('Block');
1431       for (let i = 0; i < blockNodes.length; i++) {
1432         const nameNodes = blockNodes[i].getElementsByTagName('Name');
1433         const sizeNodes = blockNodes[i].getElementsByTagName('Size');
1434         if (nameNodes.length === 0 || sizeNodes.length === 0) {
1435           continue;
1436         }
1437         const index = decodeDirectBlockIndex(blockIdPrefix, nameNodes[0].textContent);
1438         if (index < 0) {
1439           continue;
1440         }
1441         const sizeBytes = parseInt(sizeNodes[0].textContent, 10);
1442         if (isNaN(sizeBytes)) {
1443           continue;
1444         }
1445         scan.blocks.push({ index: index, size: sizeBytes });
1446       }
1447     } catch (e) {
1448       scan.blocks = [];
1449     }
1450     return scan;
1451   };
1452
1453   const putDirectBlock = (uploadUrl, blockId, blob, onProgress) => {
1454     return new Promise((resolve) => {
1455       const xhr = new XMLHttpRequest();
1456       xhr.open('PUT', uploadUrl + '&comp=block&blockid=' + encodeURIComponent(blockId), true);
1457
1458       let lastProgressAt = performance.now();
1459       let silenceAllowanceMs = Math.max(
1460         VIDEO_UPLOAD_RESPONSE_WAIT_MS,
1461         Math.ceil((blob.size / VIDEO_UPLOAD_FLOOR_BYTES_PER_SECOND) * 1000));
1462       if (silenceAllowanceMs > VIDEO_UPLOAD_MAX_DRAIN_MS) {
1463         silenceAllowanceMs = VIDEO_UPLOAD_MAX_DRAIN_MS;
1464       }
1465
1466       let settled = false;
1467       const settle = (status) => {
1468         if (settled) {
1469           return;
1470         }
1471         settled = true;
1472         clearInterval(stallTimer);
1473         resolve(status);
1474       };
1475       const stallTimer = setInterval(() => {
1476         if (isVideoJobCancelled) {
1477           settle(0);
1478           xhr.abort();
1479           return;
1480         }
1481         if (performance.now() - lastProgressAt > silenceAllowanceMs) {
1482           settle(0);
1483           xhr.abort();
1484         }
1485       }, 1000);
1486
1487       xhr.upload.onprogress = (e) => {
1488         lastProgressAt = performance.now();
1489         if (e.lengthComputable) {
1490           onProgress(e.loaded);
1491         }
1492       };
1493       xhr.upload.onload = () => {
1494         lastProgressAt = performance.now();
1495       };
1496       xhr.onload = () => settle(xhr.status);
1497       xhr.onerror = () => settle(0);
1498       xhr.ontimeout = () => settle(0);
1499       xhr.onabort = () => settle(0);
1500       xhr.send(blob);
1501     });
1502   };
1503
1504   // Commits the staged blocks in order. The content type is stated here so the blob is already servable the
1505   // instant it exists; the server re-stamps it at direct-complete/ because this value is client-controlled.
1506   const putDirectBlockList = async (uploadUrl, blockIds) => {
1507     let blockListXml = '<?xml version="1.0" encoding="utf-8"?><BlockList>';
1508     for (const blockId of blockIds) {
1509       blockListXml += '<Latest>' + blockId + '</Latest>';
1510     }
1511     blockListXml += '</BlockList>';
1512
1513     try {
1514       const response = await fetch(uploadUrl + '&comp=blocklist', {
1515         method: 'PUT',
1516         headers: {
1517           'Content-Type': 'application/xml',
1518           'x-ms-blob-content-type': 'video/mp4'
1519         },
1520         body: blockListXml
1521       });
1522       return response.status;
1523     } catch (e) {
1524       return 0;
1525     }
1526   };
1527
1528   const uploadDirectToBlob = async (file, grant, baseUrl, mediaId, reportProgress, deadlineAt, skipResume) => {
1529     const result = { committed: false, tooSlow: false, cancelled: false, failureReason: '' };
1530
1531     const retryDelays = [0, 3000, 5000, 10000, 20000];
1532     const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1533
1534     const totalSize = file.size;
1535     let blockSize = grant.blockSizeBytes;
1536     if (!(blockSize > 0)) {
1537       blockSize = VIDEO_UPLOAD_DEFAULT_SLICE_BYTES;
1538     }
1539     const blockIdPrefix = grant.blockIdPrefix;
1540     const blockTotal = Math.max(1, Math.ceil(totalSize / blockSize));
1541
1542     // The SAS expires; a refresh is one cheap call and is the difference between a resumable transfer and a lost
1543     // one on a link slow enough to outlive the grant.
1544     let uploadUrl = grant.uploadUrl;
1545     const refreshUploadUrlAsync = async () => {
1546       const refreshedGrant = await requestDirectUploadGrant(baseUrl, mediaId);
1547       if (!refreshedGrant) {
1548         return false;
1549       }
1550       uploadUrl = refreshedGrant.uploadUrl;
1551       return true;
1552     };
1553
1554     const expectedBlockSize = (index) => {
1555       const start = index * blockSize;
1556       let end = start + blockSize;
1557       if (end > totalSize) {
1558         end = totalSize;
1559       }
1560       return end - start;
1561     };
1562
1563     // Flipped by the first request in this run that comes back with an HTTP status of any kind. Until it is true,
1564     // a status of 0 means the request never reached storage, which no amount of retrying changes.
1565     let hasStorageAnswered = false;
1566
1567     // A staged block is only reusable when its size is exactly what this run would send for that index —
1568     // anything else was staged for different bytes (a re-encode, another block size) and must be overwritten.
1569     const stagedIndices = new Set();
1570     let confirmedBytes = 0;
1571     if (!skipResume) {
1572       const stagedScan = await retrieveDirectStagedBlocks(uploadUrl, blockIdPrefix);
1573       // Storage did not answer the scan at all. Staging blocks from here would walk the whole retry ladder — some
1574       // forty seconds with the progress bar pinned at zero — only to land on the proxied upload anyway, so hand
1575       // over now and let the caller log the reason.
1576       if (!stagedScan.isStorageReachable) {
1577         result.failureReason = 'storage did not answer the resume scan — no CORS rule for this origin, or no network';
1578         return result;
1579       }
1580       hasStorageAnswered = true;
1581
1582       for (const stagedBlock of stagedScan.blocks) {
1583         if (stagedBlock.index < 0 || stagedBlock.index >= blockTotal) {
1584           continue;
1585         }
1586         const expectedSize = expectedBlockSize(stagedBlock.index);
1587         if (stagedBlock.size !== expectedSize) {
1588           continue;
1589         }
1590         stagedIndices.add(stagedBlock.index);
1591         confirmedBytes += stagedBlock.size;
1592       }
1593     }
1594
1595     let maxReportedBytes = 0;
1596     const reportBlockProgress = (extraLoaded) => {
1597       const uploadedBytes = confirmedBytes + extraLoaded;
1598       if (uploadedBytes > maxReportedBytes) {
1599         maxReportedBytes = uploadedBytes;
1600       }
1601       reportProgress(maxReportedBytes);
1602     };
1603     reportBlockProgress(0);
1604
1605     const putBlockWithRetry = async (index) => {
1606       const start = index * blockSize;
1607       let end = start + blockSize;
1608       if (end > totalSize) {
1609         end = totalSize;
1610       }
1611       const blob = file.slice(start, end);
1612
1613       for (let attempt = 0; attempt < retryDelays.length; attempt++) {
1614         if (isVideoJobCancelled) {
1615           return -1;
1616         }
1617         if (attempt > 0) {
1618           await sleep(retryDelays[attempt]);
1619         }
1620
1621         const blockId = generateDirectBlockId(blockIdPrefix, index);
1622         const onBlockProgress = (loadedBytes) => reportBlockProgress(loadedBytes);
1623         const status = await putDirectBlock(uploadUrl, blockId, blob, onBlockProgress);
1624         if (status > 0) {
1625           hasStorageAnswered = true;
1626         }
1627         if (status >= 200 && status < 300) {
1628           return end - start;
1629         }
1630         // Nothing in this run has ever reached storage and this attempt got no response either — the same wall the
1631         // resume scan hits when the origin has no CORS rule. The remaining attempts would only sleep through it.
1632         if (status === 0 && !hasStorageAnswered) {
1633           return -1;
1634         }
1635         if (status === DIRECT_UPLOAD_FORBIDDEN_STATUS) {
1636           // Expired or revoked credential — one refresh, then the remaining attempts run against the new url.
1637           const refreshed = await refreshUploadUrlAsync();
1638           if (!refreshed) {
1639             return -1;
1640           }
1641         }
1642       }
1643       return -1;
1644     };
1645
1646     const directStartedAt = performance.now();
1647     let bytesUploadedThisRun = 0;
1648
1649     for (let index = 0; index < blockTotal; index++) {
1650       if (isVideoJobCancelled) {
1651         result.cancelled = true;
1652         return result;
1653       }
1654       if (deadlineAt > 0 && performance.now() > deadlineAt) {
1655         result.tooSlow = true;
1656         return result;
1657       }
1658       if (stagedIndices.has(index)) {
1659         continue;
1660       }
1661
1662       const uploadedBlockBytes = await putBlockWithRetry(index);
1663       if (uploadedBlockBytes < 0) {
1664         if (isVideoJobCancelled) {
1665           result.cancelled = true;
1666           return result;
1667         }
1668         result.failureReason = 'put-block failed for index ' + index;
1669         return result;
1670       }
1671       confirmedBytes += uploadedBlockBytes;
1672       bytesUploadedThisRun += uploadedBlockBytes;
1673       reportBlockProgress(0);
1674
1675       // Same projection the proxied paths run: bail to the optimise stage rather than burn the whole window.
1676       const elapsedMs = performance.now() - directStartedAt;
1677       const hasUsableMeasurement = deadlineAt > 0 && elapsedMs >= VIDEO_UPLOAD_SPEED_PROBE_MS;
1678       if (hasUsableMeasurement) {
1679         const bytesPerSecond = (bytesUploadedThisRun * 1000) / elapsedMs;
1680         const remainingBytes = totalSize - confirmedBytes;
1681         const projectedRemainingMs = (remainingBytes / Math.max(1, bytesPerSecond)) * 1000;
1682         const projectedFinishAt = performance.now() + projectedRemainingMs;
1683         if (projectedFinishAt > deadlineAt + VIDEO_UPLOAD_PROJECTION_MARGIN_MS) {
1684           result.tooSlow = true;
1685           return result;
1686         }
1687       }
1688     }
1689
1690     const blockIds = [];
1691     for (let index = 0; index < blockTotal; index++) {
1692       blockIds.push(generateDirectBlockId(blockIdPrefix, index));
1693     }
1694
1695     let isBlockListCommitted = false;
1696     for (let attempt = 0; attempt < retryDelays.length && !isBlockListCommitted; attempt++) {
1697       if (isVideoJobCancelled) {
1698         result.cancelled = true;
1699         return result;
1700       }
1701       if (attempt > 0) {
1702         await sleep(retryDelays[attempt]);
1703       }
1704
1705       const status = await putDirectBlockList(uploadUrl, blockIds);
1706       if (status >= 200 && status < 300) {
1707         isBlockListCommitted = true;
1708       }
1709       else if (status === DIRECT_UPLOAD_FORBIDDEN_STATUS) {
1710         const refreshed = await refreshUploadUrlAsync();
1711         if (!refreshed) {
1712           result.failureReason = 'block list commit forbidden and the grant could not be refreshed';
1713           return result;
1714         }
1715       }
1716     }
1717
1718     if (!isBlockListCommitted) {
1719       result.failureReason = 'block list commit failed';
1720       return result;
1721     }
1722
1723     // The blob exists but nothing has vetted it yet: the byte cap and the stored content type are the server's
1724     // to decide, and a post must not be written against a blob that failed either check.
1725     for (let attempt = 0; attempt < retryDelays.length; attempt++) {
1726       if (attempt > 0) {
1727         await sleep(retryDelays[attempt]);
1728       }
1729       try {
1730         const response = await fetch(baseUrl + 'direct-complete/', {
1731           method: 'POST',
1732           headers: { 'X-Media-Id': mediaId, 'X-Total-Size': String(totalSize) }
1733         });
1734         if (response.ok) {
1735           reportBlockProgress(0);
1736           result.committed = true;
1737           return result;
1738         }
1739         if (response.status === 413 || response.status === 401 || response.status === 403) {
1740           result.failureReason = 'direct-complete refused with ' + response.status;
1741           return result;
1742         }
1743       } catch (e) {
1744       }
1745     }
1746
1747     result.failureReason = 'direct-complete never answered';
1748     return result;
1749   };
1750
1751   window.cancelVideoJob = () => {
1752     isVideoJobCancelled = true;
1753     const instance = window.__ffmpegInstance;
1754     window.__ffmpegInstance = null;
1755     window.__ffmpegLoadPromise = null;
1756     if (instance) {
1757       try { instance.terminate(); } catch (e) { }
1758     }
1759   };
1760
1761   window.uploadStashedVideo = async (token, baseUrl, mediaId, dotNetRef, progressMethod, options) => {
1762     const outcome = { committed: false, tooSlow: false, cancelled: false, usedDirectUpload: false, directFailureReason: '' };
1763     isVideoJobCancelled = false;
1764
1765     const switchAfterMs = (options && options.switchToOptimiseAfterMs) || 0;
1766     const skipResume = !!(options && options.skipResume);
1767     // VideoUploadMode, as resolved server-side. False means the grant endpoint would refuse anyway, so asking it
1768     // would only cost a round trip before every proxied upload.
1769     const allowDirectUpload = !!(options && options.allowDirectUpload);
1770     let deadlineAt = 0;
1771     if (switchAfterMs > 0) {
1772       deadlineAt = performance.now() + switchAfterMs;
1773     }
1774
1775     const file = window.__pendingVideoUploads[token];
1776     if (!file) {
1777       return outcome;
1778     }
1779     if (!(file.size > 0)) {
1780       return outcome;
1781     }
1782
1783     let lastReportedPercent = -1;
1784     const reportProgress = (uploadedBytes) => {
1785       if (!dotNetRef || !progressMethod) {
1786         return;
1787       }
1788
1789       const percent = Math.floor((uploadedBytes * 100) / file.size);
1790       if (percent === lastReportedPercent) {
1791         return;
1792       }
1793       lastReportedPercent = percent;
1794
1795       try { dotNetRef.invokeMethodAsync(progressMethod, uploadedBytes); } catch (err) { }
1796     };
1797
1798     const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
1799     let isKnownSlowLink = false;
1800     let assumedBytesPerSecond = 0;
1801     if (conn) {
1802       if (conn.effectiveType === 'slow-2g' || conn.effectiveType === '2g') {
1803         isKnownSlowLink = true;
1804         assumedBytesPerSecond = 5 * 1024;
1805       }
1806       if (conn.effectiveType === '3g') {
1807         isKnownSlowLink = true;
1808         assumedBytesPerSecond = 15 * 1024;
1809       }
1810       if (conn.saveData === true) {
1811         isKnownSlowLink = true;
1812       }
1813     }
1814
1815     // Known-slow link: the projection is available before any byte is sent, so optimise first and skip the probe.
1816     if (deadlineAt > 0 && assumedBytesPerSecond > 0) {
1817       const projectedUploadMs = (file.size / assumedBytesPerSecond) * 1000;
1818       if (projectedUploadMs > switchAfterMs + VIDEO_UPLOAD_PROJECTION_MARGIN_MS) {
1819         outcome.tooSlow = true;
1820         return outcome;
1821       }
1822     }
1823
1824     // Direct to storage first, when the mode selects it and the pod says it is available. A clip already over the
1825     // caller's tier cap is left to the proxied path, which refuses it with the same 413 it always did instead of
1826     // having the bytes travel to storage only to be deleted at direct-complete/.
1827     let directGrant = null;
1828     if (allowDirectUpload) {
1829       directGrant = await requestDirectUploadGrant(baseUrl, mediaId);
1830     }
1831     let isWithinDirectUploadCap = true;
1832     if (directGrant && directGrant.maxUploadBytes > 0 && file.size > directGrant.maxUploadBytes) {
1833       isWithinDirectUploadCap = false;
1834     }
1835     if (directGrant && isWithinDirectUploadCap) {
1836       const directOutcome = await uploadDirectToBlob(file, directGrant, baseUrl, mediaId, reportProgress,
1837         deadlineAt, skipResume);
1838
1839       if (directOutcome.committed) {
1840         outcome.committed = true;
1841         outcome.usedDirectUpload = true;
1842         return outcome;
1843       }
1844       if (directOutcome.cancelled) {
1845         outcome.cancelled = true;
1846         outcome.usedDirectUpload = true;
1847         return outcome;
1848       }
1849       if (directOutcome.tooSlow) {
1850         outcome.tooSlow = true;
1851         outcome.usedDirectUpload = true;
1852         return outcome;
1853       }
1854
1855       // Anything else falls through to the proxied upload below, which is the whole point of not making the
1856       // direct path load-bearing: a missing CORS rule or a revoked key costs speed, never the post. The reason
1857       // rides back to .NET so the failure is in the pod log instead of only in this browser's console.
1858       outcome.directFailureReason = directOutcome.failureReason;
1859       reportProgress(0);
1860     }
1861
1862     const isTooLargeForOneRequest = file.size > VIDEO_UPLOAD_SINGLE_SHOT_MAX_BYTES;
1863
1864     const hasSlicedBefore = videoUploadSlicedMediaIds.has(mediaId);
1865     let measuredBytesPerSecond = assumedBytesPerSecond;
1866     if (!isKnownSlowLink && !hasSlicedBefore && !isTooLargeForOneRequest) {
1867       const attempt = await uploadWholeFile(file, baseUrl, mediaId, reportProgress, deadlineAt);
1868       if (attempt.verdict === 'ok') {
1869         outcome.committed = true;
1870         return outcome;
1871       }
1872       if (attempt.verdict === 'rejected') {
1873         return outcome;
1874       }
1875       if (attempt.verdict === 'too-slow') {
1876         outcome.tooSlow = true;
1877         return outcome;
1878       }
1879       if (attempt.verdict === 'cancelled') {
1880         outcome.cancelled = true;
1881         return outcome;
1882       }
1883       measuredBytesPerSecond = attempt.bytesPerSecond;
1884       reportProgress(0);
1885     }
1886
1887     videoUploadSlicedMediaIds.add(mediaId);
1888     const sliced = await uploadInSlices(file, baseUrl, mediaId, reportProgress, measuredBytesPerSecond, deadlineAt, skipResume);
1889     outcome.committed = sliced.committed;
1890     outcome.tooSlow = sliced.tooSlow;
1891     outcome.cancelled = sliced.cancelled;
1892     return outcome;
1893   };
1894
1895   // Instagram Reel preview — video element helpers invoked from InstagramPostPreview.razor via ElementReference.
1896   // A Blazor ElementReference whose element was NOT rendered this pass still marshals across as a plain,
1897   // truthy object - so a bare `if (el)` guard passes and the first real DOM call throws a JSException at the
1898   // caller. Every igp* video helper takes an element straight from an ElementReference, so they all check that
1899   // what arrived is actually a media element and no-op otherwise: a preview nicety must never break a render.
1900   const isPlayableMediaElement = (el) => el instanceof HTMLMediaElement;
1901
1902   window.igpPlayVideo = (el) => { if (isPlayableMediaElement(el)) el.play(); };
1903   window.igpPauseVideo = (el) => { if (isPlayableMediaElement(el)) el.pause(); };
1904
1905   // In-preview photo framing for PostPreviewFramedPhoto.razor: drag to pan, pinch or slide to zoom. Applies the
1906   // geometry live in the browser (no Blazor round-trip per pointer move) and reports the committed framing to .NET
1907   // once the gesture ends. The size/offset formula below is the exact mirror of ImageHelpers.DisplayedSizePercent —
1908   // that parity is what makes the preview equal to the uploaded crop, so change both or neither.
1909   //
1910   // Zoom 1 = the whole photo fitted inside the frame (bars on the two short sides). Above the cover zoom the photo
1911   // overflows the frame and the overflow is what panning traverses; an axis that does not overflow stays centred and
1912   // ignores drags, so a zoomed-out photo cannot be nudged off-centre into a lie about what will be posted.
1913   window.cpInitFraming = (imgElement, sliderElement, dotNetRef,
1914                           sourceAspect, frameAspect, zoom, minZoom, maxZoom, focalX, focalY) => {
1915     if (!imgElement) { return; }
1916     // The frame is whatever the host positioned the photo inside (Instagram's .igp-image-wrap, TikTok's .tkp-frame),
1917     // so it needs no ElementReference plumbing of its own — and gestures bind to the frame rather than the image so
1918     // a drag that leaves the photo's own bounds (easy at low zoom, where the photo is smaller than the frame) still
1919     // tracks.
1920     const frameElement = imgElement.parentElement;
1921     if (!frameElement) { return; }
1922     // Re-initialising over a live wiring (same frame, new photo) would stack a second listener set and double every
1923     // gesture, so drop the previous one first.
1924     window.cpDisposeFraming(imgElement);
1925
1926     let currentZoom = zoom;
1927     let currentFocalX = focalX;
1928     let currentFocalY = focalY;
1929
1930     let panning = false;
1931     let panStartX = 0;
1932     let panStartY = 0;
1933     let panStartFocalX = 0.5;
1934     let panStartFocalY = 0.5;
1935
1936     // Live pointers, so a second finger switches the gesture from pan to pinch.
1937     const activePointers = new Map();
1938     let pinchStartDistance = 0;
1939     let pinchStartZoom = 1;
1940
1941     const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
1942
1943     const displayedSizePercent = () => {
1944       let widthFraction = 1;
1945       let heightFraction = 1;
1946       if (sourceAspect >= frameAspect) {
1947         heightFraction = frameAspect / sourceAspect;
1948       } else {
1949         widthFraction = sourceAspect / frameAspect;
1950       }
1951       return { w: widthFraction * currentZoom * 100, h: heightFraction * currentZoom * 100 };
1952     };
1953
1954     const apply = () => {
1955       const size = displayedSizePercent();
1956
1957       let left = (100 - size.w) / 2;
1958       if (size.w > 100) { left = -(size.w - 100) * currentFocalX; }
1959       let top = (100 - size.h) / 2;
1960       if (size.h > 100) { top = -(size.h - 100) * currentFocalY; }
1961
1962       imgElement.style.width = size.w + '%';
1963       imgElement.style.height = size.h + '%';
1964       imgElement.style.left = left + '%';
1965       imgElement.style.top = top + '%';
1966
1967       // Only an overflowing photo can be panned; say so with the cursor rather than offering a dead grab.
1968       const canPan = size.w > 100 || size.h > 100;
1969       if (canPan) {
1970         frameElement.style.cursor = 'grab';
1971       } else {
1972         frameElement.style.cursor = '';
1973       }
1974     };
1975
1976     const commit = () => {
1977       dotNetRef.invokeMethodAsync('OnFramingCommitted', currentFocalX, currentFocalY, currentZoom).catch(() => { });
1978     };
1979
1980     const onPointerDown = (e) => {
1981       if (e.target !== imgElement) { return; }
1982       activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
1983
1984       if (activePointers.size === 2) {
1985         // Second finger down: abandon the pan and start a pinch from the current separation.
1986         panning = false;
1987         const points = Array.from(activePointers.values());
1988         const dx = points[0].x - points[1].x;
1989         const dy = points[0].y - points[1].y;
1990         pinchStartDistance = Math.max(1, Math.sqrt(dx * dx + dy * dy));
1991         pinchStartZoom = currentZoom;
1992         return;
1993       }
1994
1995       if (activePointers.size !== 1) { return; }
1996       panning = true;
1997       panStartX = e.clientX;
1998       panStartY = e.clientY;
1999       panStartFocalX = currentFocalX;
2000       panStartFocalY = currentFocalY;
2001       try { frameElement.setPointerCapture(e.pointerId); } catch (err) { }
2002       frameElement.style.cursor = 'grabbing';
2003     };
2004
2005     const onPointerMove = (e) => {
2006       if (!activePointers.has(e.pointerId)) { return; }
2007       activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
2008
2009       if (activePointers.size === 2) {
2010         e.preventDefault();
2011         const points = Array.from(activePointers.values());
2012         const dx = points[0].x - points[1].x;
2013         const dy = points[0].y - points[1].y;
2014         const distance = Math.max(1, Math.sqrt(dx * dx + dy * dy));
2015         currentZoom = clamp(pinchStartZoom * (distance / pinchStartDistance), minZoom, maxZoom);
2016         if (sliderElement) { sliderElement.value = currentZoom; }
2017         apply();
2018         return;
2019       }
2020
2021       if (!panning) { return; }
2022       e.preventDefault();
2023
2024       const rect = frameElement.getBoundingClientRect();
2025       const size = displayedSizePercent();
2026
2027       // Focal 0..1 spans the OVERFLOW, not the frame, so a drag of N pixels must move the focal point by N over the
2028       // overflow in pixels. Dividing by the frame instead would make panning progressively too fast as zoom grows.
2029       const overflowX = Math.max(0, (size.w - 100) / 100) * Math.max(rect.width, 1);
2030       const overflowY = Math.max(0, (size.h - 100) / 100) * Math.max(rect.height, 1);
2031
2032       // Dragging right reveals more of the photo's left edge, i.e. lowers the focal point.
2033       if (overflowX > 0) {
2034         currentFocalX = clamp(panStartFocalX - (e.clientX - panStartX) / overflowX, 0, 1);
2035       }
2036       if (overflowY > 0) {
2037         currentFocalY = clamp(panStartFocalY - (e.clientY - panStartY) / overflowY, 0, 1);
2038       }
2039       apply();
2040     };
2041
2042     const onPointerUp = (e) => {
2043       const wasTracked = activePointers.delete(e.pointerId);
2044       if (!wasTracked) { return; }
2045       try { frameElement.releasePointerCapture(e.pointerId); } catch (err) { }
2046
2047       // Commit once the last finger lifts, so a pinch reports a single result rather than one per finger.
2048       if (activePointers.size === 0) {
2049         panning = false;
2050         apply();
2051         commit();
2052       }
2053     };
2054
2055     // The slider drives the zoom directly for pointer users. 'input' is live (no round-trip), 'change' commits.
2056     const onSliderInput = () => {
2057       currentZoom = clamp(parseFloat(sliderElement.value), minZoom, maxZoom);
2058       apply();
2059     };
2060     const onSliderChange = () => {
2061       onSliderInput();
2062       commit();
2063     };
2064     // A slider drag starts on the image's frame; stop it from also panning the photo underneath.
2065     const onSliderPointerDown = (e) => { e.stopPropagation(); };
2066
2067     frameElement.addEventListener('pointerdown', onPointerDown);
2068     frameElement.addEventListener('pointermove', onPointerMove);
2069     frameElement.addEventListener('pointerup', onPointerUp);
2070     frameElement.addEventListener('pointercancel', onPointerUp);
2071     if (sliderElement) {
2072       sliderElement.addEventListener('input', onSliderInput);
2073       sliderElement.addEventListener('change', onSliderChange);
2074       sliderElement.addEventListener('pointerdown', onSliderPointerDown);
2075     }
2076     imgElement.style.touchAction = 'none';
2077     apply();
2078
2079     frameElement._cpFraming = {
2080       onPointerDown, onPointerMove, onPointerUp,
2081       sliderElement, onSliderInput, onSliderChange, onSliderPointerDown
2082     };
2083   };
2084
2085   window.cpDisposeFraming = (imgElement) => {
2086     if (!imgElement) { return; }
2087     const frameElement = imgElement.parentElement;
2088     if (!frameElement || !frameElement._cpFraming) { return; }
2089     const h = frameElement._cpFraming;
2090     frameElement.removeEventListener('pointerdown', h.onPointerDown);
2091     frameElement.removeEventListener('pointermove', h.onPointerMove);
2092     frameElement.removeEventListener('pointerup', h.onPointerUp);
2093     frameElement.removeEventListener('pointercancel', h.onPointerUp);
2094     if (h.sliderElement) {
2095       h.sliderElement.removeEventListener('input', h.onSliderInput);
2096       h.sliderElement.removeEventListener('change', h.onSliderChange);
2097       h.sliderElement.removeEventListener('pointerdown', h.onSliderPointerDown);
2098     }
2099     frameElement.style.cursor = '';
2100     delete frameElement._cpFraming;
2101   };
2102
2103   // Facebook preview — bottom control bar (time readout + seekable progress) plus the mobile chrome (muted
2104   // autoplay + thin non-interactive progress track). Pure JS like the TikTok preview's controls, so per-frame
2105   // updates and scrubbing never round-trip the Blazor circuit.
2106   window.fbpInitVideoControls = (videoEl, timeEl, progressFillEl, progressBarEl, mobileTrackFillEl, dotNetRef) => {
2107     // Same unresolved-ElementReference hazard as the igp* helpers above: the chrome refs only exist alongside
2108     // the <video>, so all of them are checked for a real node rather than mere truthiness.
2109     if (!isPlayableMediaElement(videoEl)) return;
2110     if (!(timeEl instanceof Element) || !(progressFillEl instanceof Element) || !(progressBarEl instanceof Element)) return;
2111     let seeking = false;
2112
2113     // Mobile Facebook autoplays feed clips muted; mirror that under the same breakpoint that swaps the CSS chrome.
2114     // The .NET side is told so its play state matches the element.
2115     if (window.matchMedia('(max-width: 640px)').matches) {
2116       videoEl.muted = true;
2117       videoEl.play().then(() => {
2118         if (dotNetRef) dotNetRef.invokeMethodAsync('OnVideoAutoPlayed');
2119       }).catch(() => {});
2120     }
2121
2122     const formatTime = (seconds) => {
2123       if (!isFinite(seconds) || seconds < 0) seconds = 0;
2124       const total = Math.floor(seconds);
2125       const minutes = Math.floor(total / 60);
2126       const secs = String(total % 60).padStart(2, '0');
2127       return minutes + ':' + secs;
2128     };
2129
2130     const update = () => {
2131       if (!document.body.contains(videoEl)) return;
2132       if (!seeking && videoEl.duration > 0) {
2133         const fillWidth = (videoEl.currentTime / videoEl.duration * 100) + '%';
2134         progressFillEl.style.width = fillWidth;
2135         if (mobileTrackFillEl) mobileTrackFillEl.style.width = fillWidth;
2136       }
2137       timeEl.textContent = formatTime(videoEl.currentTime) + ' / ' + formatTime(videoEl.duration);
2138       requestAnimationFrame(update);
2139     };
2140     requestAnimationFrame(update);
2141
2142     const seekToX = (clientX) => {
2143       const rect = progressBarEl.getBoundingClientRect();
2144       let ratio = (clientX - rect.left) / rect.width;
2145       if (ratio < 0) ratio = 0;
2146       if (ratio > 1) ratio = 1;
2147       if (videoEl.duration > 0) {
2148         videoEl.currentTime = ratio * videoEl.duration;
2149       }
2150       progressFillEl.style.width = (ratio * 100) + '%';
2151     };
2152
2153     progressBarEl.addEventListener('mousedown', (e) => {
2154       seeking = true;
2155       seekToX(e.clientX);
2156
2157       const onMove = (ev) => seekToX(ev.clientX);
2158       const onUp = () => {
2159         seeking = false;
2160         document.removeEventListener('mousemove', onMove);
2161         document.removeEventListener('mouseup', onUp);
2162       };
2163       document.addEventListener('mousemove', onMove);
2164       document.addEventListener('mouseup', onUp);
2165     });
2166   };
2167   window.igpSetMuted = (el, muted) => { if (isPlayableMediaElement(el)) el.muted = muted; };
2168   window.igpReplayVideo = (el) => { if (isPlayableMediaElement(el)) { el.currentTime = 0; el.play(); } };
2169   window.igpWireVideoEnded = (el, dotNetRef) => {
2170     if (!isPlayableMediaElement(el)) return;
2171     el.addEventListener('ended', () => {
2172       dotNetRef.invokeMethodAsync('OnVideoEnded');
2173     });
2174   };
2175
2176   // Returns a blob URL for the stashed video so it can be played inline in a <video> preview. The URL lives until
2177   // the stash entry is discarded or the page unloads — no manual revoke needed since the File is already in memory.
2178   window.getStashedVideoBlobUrl = (token) => {
2179     const file = window.__pendingVideoUploads[token];
2180     if (!file) {
2181       return '';
2182     }
2183     return URL.createObjectURL(file);
2184   };
2185
2186   // Drag-and-drop bridge for the create-post media dialog. A file dropped anywhere on the dialog is routed
2187   // through the SAME hidden <InputFile> the "Choose media" button opens: the dropped file is assigned to the
2188   // input and a synthetic change event is dispatched, so Blazor's OnMediaSelected runs the exact same
2189   // photo-vs-video classification / cropper / preview path — no logic is duplicated here. Idempotent per
2190   // element (a re-render never double-wires); the active class highlights the target while a file is over it.
2191   window.wireMediaDropZone = (dropZoneId, inputId) => {
2192     const zone = document.getElementById(dropZoneId);
2193     const input = document.getElementById(inputId);
2194     if (!zone || !input || zone._mediaDropWired) {
2195       return;
2196     }
2197     zone._mediaDropWired = true;
2198
2199     const stop = (e) => {
2200       e.preventDefault();
2201       e.stopPropagation();
2202     };
2203
2204     ['dragenter', 'dragover'].forEach((evt) => zone.addEventListener(evt, (e) => {
2205       stop(e);
2206       zone.classList.add('post-media-dropzone-active');
2207     }));
2208
2209     ['dragleave', 'dragend'].forEach((evt) => zone.addEventListener(evt, (e) => {
2210       stop(e);
2211       zone.classList.remove('post-media-dropzone-active');
2212     }));
2213
2214     zone.addEventListener('drop', (e) => {
2215       stop(e);
2216       zone.classList.remove('post-media-dropzone-active');
2217       const files = e.dataTransfer && e.dataTransfer.files;
2218       if (!files || files.length === 0) {
2219         return;
2220       }
2221       // One file at a time — the dialog stages a single photo or clip, same as the picker. Assigning via a
2222       // DataTransfer then dispatching 'change' is how Blazor's InputFile picks up a programmatically-set file.
2223       const transfer = new DataTransfer();
2224       transfer.items.add(files[0]);
2225       input.files = transfer.files;
2226       input.dispatchEvent(new Event('change', { bubbles: true }));
2227     });
2228   };
2229
2230   // Recovers a selection made before Blazor finished arming the picker.
2231   //
2232   // <InputFile> only becomes functional once the framework's shim is installed, and that happens on a
2233   // separate OnAfterRenderAsync -> Blazor._internal.InputFile.init interop round-trip - strictly AFTER the
2234   // render batch that puts the input (and the "drag and drop a file here" hint) in the DOM. A file assigned
2235   // in that window fires 'change' with nothing listening yet and is dropped on the floor. It cannot be
2236   // recovered by picking the same file again either: the browser only dispatches 'change' when the FileList
2237   // actually differs, so re-assigning an identical file is a no-op and the dialog sits on its empty hint with
2238   // Save disabled forever. Measured window on a loaded circuit is a few hundred ms, which a drag-and-drop
2239   // landing right as the dialog opens - and every automated run - is well inside of. See TKR-10946.
2240   //
2241   // The shim writes _blazorInputFileNextFileId on init and creates _blazorFilesById the first time its change
2242   // handler runs, so "armed but never fired, with a file already sitting on it" is exactly detectable, and the
2243   // replay below can never fire for a selection Blazor already saw.
2244   window.ensureMediaPickerArmed = (inputId) => {
2245     const input = document.getElementById(inputId);
2246     if (!input) {
2247       return;
2248     }
2249
2250     let attemptsLeft = 100;
2251
2252     const replayEarlySelection = () => {
2253       if (input._blazorInputFileNextFileId === undefined) {
2254         attemptsLeft = attemptsLeft - 1;
2255         if (attemptsLeft > 0) {
2256           window.setTimeout(replayEarlySelection, 50);
2257         }
2258         return;
2259       }
2260
2261       if (input._blazorFilesById !== undefined) {
2262         return;
2263       }
2264
2265       if (!input.files || input.files.length === 0) {
2266         return;
2267       }
2268
2269       input.dispatchEvent(new Event('change', { bubbles: true }));
2270     };
2271
2272     replayEarlySelection();
2273   };
2274
2275   // Lazily loads the self-hosted single-thread ffmpeg.wasm build the first time a clip actually needs
2276   // trimming. The single-thread core needs no SharedArrayBuffer, so the site does NOT require cross-origin
2277   // isolation (COOP/COEP) — important because enabling that would break third-party embeds (YouTube, etc.).
2278   // The wasm core is fetched only on the first trim; an in-limits, untrimmed clip never triggers this path.
2279   window.__ffmpegInstance = window.__ffmpegInstance || null;
2280   window.__ffmpegLoadPromise = window.__ffmpegLoadPromise || null;
2281
2282   // Memoised by URL rather than by a DOM lookup: two callers racing on the same src must await the SAME load,
2283   // and a <script> that is merely present in the head has not necessarily executed yet.
2284   const scriptLoadPromises = {};
2285
2286   const loadScriptOnce = (src) => {
2287     if (scriptLoadPromises[src]) {
2288       return scriptLoadPromises[src];
2289     }
2290     scriptLoadPromises[src] = new Promise((resolve, reject) => {
2291       const script = document.createElement('script');
2292       script.src = src;
2293       script.async = true;
2294       script.setAttribute('data-script-once', src);
2295       script.onload = () => resolve();
2296       script.onerror = () => reject(new Error('failed to load ' + src));
2297       document.head.appendChild(script);
2298     });
2299     // Drop the memo on failure so a later attempt retries instead of replaying the rejection forever.
2300     scriptLoadPromises[src].catch(() => { scriptLoadPromises[src] = null; });
2301     return scriptLoadPromises[src];
2302   };
2303
2304   const ensureFfmpegLoaded = () => {
2305     if (window.__ffmpegInstance) {
2306       return Promise.resolve(window.__ffmpegInstance);
2307     }
2308     if (window.__ffmpegLoadPromise) {
2309       return window.__ffmpegLoadPromise;
2310     }
2311
2312     const base = '/_content/ffmpeg';
2313     window.__ffmpegLoadPromise = (async () => {
2314       // The UMD build attaches FFmpegWASM to window; its worker chunk (814.ffmpeg.js) is loaded automatically
2315       // from the same folder via the bundle's public path, so no classWorkerURL is needed. (@ffmpeg/util is
2316       // deliberately NOT loaded — its UMD build references a bare `exports` and throws in a plain <script>; the
2317       // one helper we'd use from it, fetchFile, is inlined in trimStashedVideo instead.)
2318       await loadScriptOnce(base + '/ffmpeg/ffmpeg.js');
2319       const ffmpeg = new window.FFmpegWASM.FFmpeg();
2320       await ffmpeg.load({
2321         coreURL: base + '/core/ffmpeg-core.js',
2322         wasmURL: base + '/core/ffmpeg-core.wasm'
2323       });
2324       window.__ffmpegInstance = ffmpeg;
2325       return ffmpeg;
2326     })();
2327     // Drop the cached promise on failure so a later trim can retry instead of being stuck on a rejected load.
2328     window.__ffmpegLoadPromise.catch(() => { window.__ffmpegLoadPromise = null; });
2329     return window.__ffmpegLoadPromise;
2330   };
2331
2332   // Preferred transcode path: WebCodecs drives the platform's own (usually hardware) H.264 decoder and encoder,
2333   // which runs roughly an order of magnitude faster than the single-thread wasm encoder and needs ~230 KB of
2334   // script instead of the 32 MB ffmpeg core. WebCodecs handles raw codec access only — it neither demuxes nor
2335   // muxes MP4 — so mp4box.js reads the source samples and mp4-muxer writes the output container.
2336   const VIDEO_VENDOR_BASE = '/_content/Tokero.Shared.Blazor/js/vendor';
2337
2338   const isWebCodecsTranscodeSupported = () => {
2339     if (typeof window.VideoEncoder !== 'function') {
2340       return false;
2341     }
2342     if (typeof window.VideoDecoder !== 'function') {
2343       return false;
2344     }
2345     if (typeof window.EncodedVideoChunk !== 'function') {
2346       return false;
2347     }
2348     if (typeof window.VideoFrame !== 'function') {
2349       return false;
2350     }
2351     if (typeof window.OffscreenCanvas !== 'function') {
2352       return false;
2353     }
2354     return true;
2355   };
2356
2357   const ensureVideoContainerToolsLoaded = async () => {
2358     await Promise.all([
2359       loadScriptOnce(VIDEO_VENDOR_BASE + '/mp4box.all.min.js'),
2360       loadScriptOnce(VIDEO_VENDOR_BASE + '/mp4-muxer.js')
2361     ]);
2362     if (typeof window.MP4Box === 'undefined' || typeof window.Mp4Muxer === 'undefined') {
2363       throw new Error('mp4 container tools did not register their globals');
2364     }
2365   };
2366
2367   const VIDEO_COMPRESS_PHASE_LOADING_TOOLS = 0;
2368   const VIDEO_COMPRESS_PHASE_PREPARING = 1;
2369   const VIDEO_COMPRESS_PHASE_ENCODING = 2;
2370
2371   const VIDEO_TOOLS_WASM_URL = '/_content/ffmpeg/core/ffmpeg-core.wasm';
2372   window.__videoToolsWarmPromise = window.__videoToolsWarmPromise || null;
2373   window.__videoToolsWarmPercent = 0;
2374
2375   const warmVideoToolsCache = () => {
2376     if (window.__videoToolsWarmPromise) {
2377       return window.__videoToolsWarmPromise;
2378     }
2379     window.__videoToolsWarmPromise = (async () => {
2380       try {
2381         const response = await fetch(VIDEO_TOOLS_WASM_URL);
2382         if (!response.ok || !response.body) {
2383           window.__videoToolsWarmPercent = 100;
2384           return;
2385         }
2386         let totalBytes = 0;
2387         const totalHeader = response.headers.get('Content-Length');
2388         if (totalHeader) {
2389           totalBytes = parseInt(totalHeader, 10);
2390         }
2391         const reader = response.body.getReader();
2392         let receivedBytes = 0;
2393         for (; ;) {
2394           const step = await reader.read();
2395           if (step.done) {
2396             break;
2397           }
2398           receivedBytes += step.value.length;
2399           if (totalBytes > 0) {
2400             window.__videoToolsWarmPercent = Math.floor((receivedBytes * 100) / totalBytes);
2401           }
2402         }
2403         window.__videoToolsWarmPercent = 100;
2404       } catch (e) {
2405         window.__videoToolsWarmPercent = 100;
2406       }
2407     })();
2408     return window.__videoToolsWarmPromise;
2409   };
2410
2411   // Warmed when the media dialog opens. On a WebCodecs browser this pulls ~230 KB of demux/mux script and
2412   // nothing else — deliberately NOT the 32 MB wasm core, which the optimise path only ever reaches as a
2413   // fallback and which would otherwise be fetched over the very link we are optimising for.
2414   window.preloadVideoTools = () => {
2415     if (isWebCodecsTranscodeSupported()) {
2416       ensureVideoContainerToolsLoaded().catch(() => { });
2417       return;
2418     }
2419     warmVideoToolsCache().then(() => { ensureFfmpegLoaded().catch(() => { }); });
2420   };
2421
2422   const VIDEO_PLAN_UPLINK_BY_EFFECTIVE_TYPE = {
2423     'slow-2g': 4 * 1024,
2424     '2g': 5 * 1024,
2425     '3g': 15 * 1024,
2426     '4g': 300 * 1024
2427   };
2428   const VIDEO_PLAN_UPLINK_ASSUMED_MOBILE = 80 * 1024;
2429   const VIDEO_PLAN_UPLINK_ASSUMED_DESKTOP = 1024 * 1024;
2430
2431   // Encode speed as a multiple of realtime (1.0 = a 60 s clip takes 60 s to transcode). The WebCodecs rate is
2432   // a deliberately conservative floor for a hardware encoder; the wasm rates are what a SINGLE-THREAD software
2433   // x264 actually manages, which is well below realtime and unaffected by core count because the wasm core
2434   // cannot use more than one thread. Over-estimating here is what used to make the plan promise a one-minute
2435   // optimise that then ran for ten, and made it pick an encode that was slower than just uploading.
2436   // Measured ~5x realtime for 720p -> 480p on a desktop hardware encoder (demux and file read included); 4.0
2437   // leaves headroom for slower mobile encoders, and under-promising is the safe direction for an ETA.
2438   const VIDEO_PLAN_ENCODE_RATE_WEBCODECS = 4.0;
2439   const VIDEO_PLAN_ENCODE_RATE_WASM_DESKTOP = 0.5;
2440   const VIDEO_PLAN_ENCODE_RATE_WASM_MOBILE = 0.2;
2441
2442   // Cost of the ffmpeg.wasm core, charged to the plan when the fallback path would have to fetch it.
2443   const VIDEO_PLAN_TOOLS_DOWNLOAD_BYTES = 32 * 1024 * 1024;
2444
2445   const VIDEO_PLAN_OUTPUT_BYTES_PER_SECOND_720 = 210 * 1024;
2446   const VIDEO_PLAN_OUTPUT_BYTES_PER_SECOND_480 = 105 * 1024;
2447
2448   // Fallback for the OptimiseVideoMaxEdgePixels setting, used when the caller passes nothing usable. The C#
2449   // side supplies the configured value, so this only covers a plan requested before the setting is readable.
2450   const VIDEO_PLAN_MAX_EDGE_PIXELS_FALLBACK = 1080;
2451   // Edges the planner picks for itself once it decides a constrained uplink is worth an encode. Both are
2452   // clamped to the configured ceiling, so lowering that lowers every optimise output.
2453   const VIDEO_PLAN_MAX_EDGE_PIXELS_CONSTRAINED = 720;
2454   const VIDEO_PLAN_MAX_EDGE_PIXELS_CONSTRAINED_SLOW = 480;
2455
2456   const VIDEO_PLAN_MIN_UPLOAD_SECONDS_TO_BOTHER = 25;
2457   const VIDEO_PLAN_REQUIRED_SAVING = 0.7;
2458
2459   const isMobileDevice = () => {
2460     if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
2461       return navigator.userAgentData.mobile;
2462     }
2463     return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || '');
2464   };
2465
2466   const VIDEO_PLAN_LOW_DOWNLINK_MBPS = 3;
2467
2468   const evaluateLink = () => {
2469     const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
2470     const isMobile = isMobileDevice();
2471
2472     if (conn) {
2473       if (conn.saveData === true) {
2474         return { uplinkBytesPerSecond: VIDEO_PLAN_UPLINK_BY_EFFECTIVE_TYPE['2g'], isConstrained: true, label: 'save-data' };
2475       }
2476
2477       const known = VIDEO_PLAN_UPLINK_BY_EFFECTIVE_TYPE[conn.effectiveType];
2478       const isSlowType = conn.effectiveType === 'slow-2g' || conn.effectiveType === '2g' || conn.effectiveType === '3g';
2479       if (isSlowType && known) {
2480         return { uplinkBytesPerSecond: known, isConstrained: true, label: conn.effectiveType };
2481       }
2482
2483       const isLowDownlink = conn.downlink > 0 && conn.downlink < VIDEO_PLAN_LOW_DOWNLINK_MBPS;
2484       if (isLowDownlink) {
2485         return { uplinkBytesPerSecond: VIDEO_PLAN_UPLINK_BY_EFFECTIVE_TYPE['3g'], isConstrained: true, label: 'low-bandwidth' };
2486       }
2487       if (isMobile) {
2488         return { uplinkBytesPerSecond: VIDEO_PLAN_UPLINK_BY_EFFECTIVE_TYPE['4g'], isConstrained: true, label: '4g-mobile' };
2489       }
2490       return { uplinkBytesPerSecond: VIDEO_PLAN_UPLINK_ASSUMED_DESKTOP, isConstrained: false, label: 'fast' };
2491     }
2492
2493     if (isMobile) {
2494       return { uplinkBytesPerSecond: VIDEO_PLAN_UPLINK_ASSUMED_MOBILE, isConstrained: true, label: 'mobile-assumed' };
2495     }
2496     return { uplinkBytesPerSecond: VIDEO_PLAN_UPLINK_ASSUMED_DESKTOP, isConstrained: fal
2496se, label: 'desktop-assumed' };
2497   };
2498
2499   const estimateEncodeRate = () => {
2500     if (isWebCodecsTranscodeSupported()) {
2501       return VIDEO_PLAN_ENCODE_RATE_WEBCODECS;
2502     }
2503     if (isMobileDevice()) {
2504       return VIDEO_PLAN_ENCODE_RATE_WASM_MOBILE;
2505     }
2506     return VIDEO_PLAN_ENCODE_RATE_WASM_DESKTOP;
2507   };
2508
2509   // Seconds the optimise stage must spend fetching its own tooling before it can encode a single frame. Zero on
2510   // the WebCodecs path (the demux/mux scripts are ~230 KB and already warmed when the dialog opened) and zero
2511   // once the wasm core has been warmed this session; otherwise it is 32 MB over the same link we are trying to
2512   // spare, which is routinely more expensive than uploading the clip untouched.
2513   const estimateToolsDownloadSeconds = (uplinkBytesPerSecond) => {
2514     if (isWebCodecsTranscodeSupported()) {
2515       return 0;
2516     }
2517     if (window.__videoToolsWarmPercent >= 100) {
2518       return 0;
2519     }
2520     const downloadSeconds = VIDEO_PLAN_TOOLS_DOWNLOAD_BYTES / uplinkBytesPerSecond;
2521     return downloadSeconds;
2522   };
2523
2524   // configuredMaxEdgePixels is the OptimiseVideoMaxEdgePixels setting, supplied by the caller. It is the edge a
2525   // FORCED optimise uses (rescueMaxEdgePixels) and the ceiling clamping the edge the planner picks for itself.
2526   window.planVideoProcessing = (sizeBytes, durationSeconds, configuredMaxEdgePixels) => {
2527     const link = evaluateLink();
2528     const uplinkBytesPerSecond = link.uplinkBytesPerSecond;
2529     const encodeRate = estimateEncodeRate();
2530     const uploadAsIsSeconds = sizeBytes / uplinkBytesPerSecond;
2531
2532     let rescueMaxEdgePixels = VIDEO_PLAN_MAX_EDGE_PIXELS_FALLBACK;
2533     if (configuredMaxEdgePixels > 0) {
2534       rescueMaxEdgePixels = configuredMaxEdgePixels;
2535     }
2536
2537     const plan = {
2538       shouldCompress: false,
2539       maxEdgePixels: rescueMaxEdgePixels,
2540       rescueMaxEdgePixels: rescueMaxEdgePixels,
2541       quality: 28,
2542       uplinkBytesPerSecond: Math.round(uplinkBytesPerSecond),
2543       uploadAsIsSeconds: Math.round(uploadAsIsSeconds),
2544       estimatedTotalSeconds: Math.round(uploadAsIsSeconds),
2545       isMobile: isMobileDevice(),
2546       linkLabel: link.label,
2547       reason: 'upload-as-is'
2548     };
2549
2550     if (!link.isConstrained) {
2551       plan.reason = 'link-not-constrained';
2552       return plan;
2553     }
2554
2555     if (!(durationSeconds > 0) || !(sizeBytes > 0)) {
2556       plan.reason = 'unknown-duration';
2557       return plan;
2558     }
2559
2560     if (uploadAsIsSeconds < VIDEO_PLAN_MIN_UPLOAD_SECONDS_TO_BOTHER) {
2561       plan.reason = 'upload-already-short';
2562       return plan;
2563     }
2564
2565     let maxEdgePixels = VIDEO_PLAN_MAX_EDGE_PIXELS_CONSTRAINED;
2566     let quality = 28;
2567     let outputBytesPerSecond = VIDEO_PLAN_OUTPUT_BYTES_PER_SECOND_720;
2568     if (uplinkBytesPerSecond <= VIDEO_PLAN_UPLINK_BY_EFFECTIVE_TYPE['3g']) {
2569       maxEdgePixels = VIDEO_PLAN_MAX_EDGE_PIXELS_CONSTRAINED_SLOW;
2570       quality = 30;
2571       outputBytesPerSecond = VIDEO_PLAN_OUTPUT_BYTES_PER_SECOND_480;
2572     }
2573     // A ceiling below the constrained edge wins — the setting is the hard cap on what the optimise stage emits.
2574     if (maxEdgePixels > rescueMaxEdgePixels) {
2575       maxEdgePixels = rescueMaxEdgePixels;
2576     }
2577
2578     const compressedBytes = durationSeconds * outputBytesPerSecond;
2579     if (compressedBytes >= sizeBytes) {
2580       plan.reason = 'already-efficient';
2581       return plan;
2582     }
2583
2584     const encodeSeconds = durationSeconds / encodeRate;
2585     const uploadAfterSeconds = compressedBytes / uplinkBytesPerSecond;
2586     const toolsDownloadSeconds = estimateToolsDownloadSeconds(uplinkBytesPerSecond);
2587     const totalWithCompression = toolsDownloadSeconds + encodeSeconds + uploadAfterSeconds;
2588
2589     if (totalWithCompression > uploadAsIsSeconds * VIDEO_PLAN_REQUIRED_SAVING) {
2590       plan.reason = 'encode-not-worth-it';
2591       plan.estimatedTotalSeconds = Math.round(uploadAsIsSeconds);
2592       return plan;
2593     }
2594
2595     plan.shouldCompress = true;
2596     plan.maxEdgePixels = maxEdgePixels;
2597     plan.quality = quality;
2598     plan.estimatedTotalSeconds = Math.round(totalWithCompression);
2599     plan.reason = 'compress';
2600     return plan;
2601   };
2602
2603   const VIDEO_OPTIMISED_CACHE_NAME = 'tokero-optimised-video-v1';
2604   const VIDEO_CACHE_VARIANT_ORIGINAL = 'original';
2605   const VIDEO_CACHE_VARIANT_OPTIMISED = 'optimised';
2606   const videoCacheUrl = (mediaId, variant) => {
2607     return '/__tokero-video/' + encodeURIComponent(mediaId) + '/' + variant;
2608   };
2609
2610   window.cacheStashedVideo = async (mediaId, token, variant) => {
2611     const blob = window.__pendingVideoUploads[token];
2612     if (!blob || !window.caches) {
2613       return false;
2614     }
2615     try {
2616       const cache = await window.caches.open(VIDEO_OPTIMISED_CACHE_NAME);
2617       await cache.put(videoCacheUrl(mediaId, variant), new Response(blob, {
2618         headers: { 'Content-Type': blob.type || 'video/mp4' }
2619       }));
2620       return true;
2621     } catch (e) {
2622       console.error('cacheStashedVideo failed', e);
2623       return false;
2624     }
2625   };
2626
2627   window.restoreStashedVideoFromCache = async (mediaId, token, variant) => {
2628     if (!window.caches) {
2629       return -1;
2630     }
2631     try {
2632       const cache = await window.caches.open(VIDEO_OPTIMISED_CACHE_NAME);
2633       const hit = await cache.match(videoCacheUrl(mediaId, variant));
2634       if (!hit) {
2635         return -1;
2636       }
2637       const blob = await hit.blob();
2638       if (!(blob.size > 0)) {
2639         return -1;
2640       }
2641       window.__pendingVideoUploads[token] = blob;
2642       return blob.size;
2643     } catch (e) {
2644       console.error('restoreStashedVideoFromCache failed', e);
2645       return -1;
2646     }
2647   };
2648
2649   window.stashVideoFromUrl = async (url, token) => {
2650     try {
2651       const response = await fetch(url, { mode: 'cors', credentials: 'omit' });
2652       if (!response.ok) {
2653         return -1;
2654       }
2655       const blob = await response.blob();
2656       if (!(blob.size > 0)) {
2657         return -1;
2658       }
2659       window.__pendingVideoUploads[token] = blob;
2660       return blob.size;
2661     } catch (e) {
2662       console.error('stashVideoFromUrl failed', e);
2663       return -1;
2664     }
2665   };
2666
2667   window.hasStashedVideo = (token) => {
2668     const file = window.__pendingVideoUploads[token];
2669     return !!(file && file.size > 0);
2670   };
2671
2672   window.restoreVideoForResume = async (mediaId, token) => {
2673     const optimised = await window.restoreStashedVideoFromCache(mediaId, token, VIDEO_CACHE_VARIANT_OPTIMISED);
2674     if (optimised > 0) {
2675       return { sizeBytes: optimised, isOptimised: true };
2676     }
2677     const original = await window.restoreStashedVideoFromCache(mediaId, token, VIDEO_CACHE_VARIANT_ORIGINAL);
2678     return { sizeBytes: original, isOptimised: false };
2679   };
2680
2681   const VIDEO_PENDING_POST_KEY = 'tokero-pending-video-post';
2682
2683   window.savePendingVideoPost = (recordJson) => {
2684     try {
2685       window.localStorage.setItem(VIDEO_PENDING_POST_KEY, recordJson);
2686       return true;
2687     } catch (e) {
2688       return false;
2689     }
2690   };
2691
2692   window.loadPendingVideoPost = () => {
2693     try {
2694       return window.localStorage.getItem(VIDEO_PENDING_POST_KEY);
2695     } catch (e) {
2696       return null;
2697     }
2698   };
2699
2700   window.clearPendingVideoPost = () => {
2701     try {
2702       window.localStorage.removeItem(VIDEO_PENDING_POST_KEY);
2703     } catch (e) {
2704     }
2705   };
2706
2707   window.clearVideoCache = async (mediaId) => {
2708     if (!window.caches) {
2709       return;
2710     }
2711     try {
2712       const cache = await window.caches.open(VIDEO_OPTIMISED_CACHE_NAME);
2713       await cache.delete(videoCacheUrl(mediaId, VIDEO_CACHE_VARIANT_ORIGINAL));
2714       await cache.delete(videoCacheUrl(mediaId, VIDEO_CACHE_VARIANT_OPTIMISED));
2715     } catch (e) {
2716     }
2717   };
2718
2719   let videoJobWakeLock = null;
2720   let isVideoJobWakeLockWanted = false;
2721
2722   const requestVideoJobWakeLock = async () => {
2723     if (!isVideoJobWakeLockWanted || videoJobWakeLock) {
2724       return;
2725     }
2726     if (!navigator.wakeLock) {
2727       return;
2728     }
2729     if (document.visibilityState !== 'visible') {
2730       return;
2731     }
2732     try {
2733       videoJobWakeLock = await navigator.wakeLock.request('screen');
2734       videoJobWakeLock.addEventListener('release', () => { videoJobWakeLock = null; });
2735     } catch (e) {
2736       videoJobWakeLock = null;
2737     }
2738   };
2739
2740   const onVideoJobVisibilityChange = () => {
2741     if (document.visibilityState === 'visible') {
2742       requestVideoJobWakeLock();
2743     }
2744   };
2745
2746   window.acquireVideoJobWakeLock = () => {
2747     if (isVideoJobWakeLockWanted) {
2748       return;
2749     }
2750     isVideoJobWakeLockWanted = true;
2751     document.addEventListener('visibilitychange', onVideoJobVisibilityChange);
2752     requestVideoJobWakeLock();
2753   };
2754
2755   window.releaseVideoJobWakeLock = async () => {
2756     isVideoJobWakeLockWanted = false;
2757     document.removeEventListener('visibilitychange', onVideoJobVisibilityChange);
2758     if (!videoJobWakeLock) {
2759       return;
2760     }
2761     try {
2762       await videoJobWakeLock.release();
2763     } catch (e) {
2764     }
2765     videoJobWakeLock = null;
2766   };
2767
2768   // Cuts the stashed clip to [startSeconds, endSeconds] in the browser and REPLACES the stash entry with the
2769   // (-c copy) keeps it near-instant and lossless; the start snaps to the nearest prior keyframe (~1-2s), which
2770   // is acceptable for social clips. -movflags faststart relocates the moov atom to the front so the clip
2771   // streams/plays immediately from the CDN. Returns the trimmed byte size, or -1 on failure (the caller then
2772   // keeps the original stashed file and lets the server size cap be the final gate).
2773   window.trimStashedVideo = async (token, startSeconds, endSeconds) => {
2774     const file = window.__pendingVideoUploads[token];
2775     if (!file) {
2776       return -1;
2777     }
2778
2779     let clipSeconds = endSeconds - startSeconds;
2780     if (!(clipSeconds > 0)) {
2781       return -1;
2782     }
2783
2784     try {
2785       const ffmpeg = await ensureFfmpegLoaded();
2786       const inputName = 'in.mp4';
2787       const outputName = 'out.mp4';
2788
2789       // Read the picked File/Blob into ffmpeg's virtual FS. (This is what @ffmpeg/util's fetchFile does, but
2790       // that package's UMD build isn't browser-global safe, so we inline the single line we need.)
2791       const inputData = new Uint8Array(await file.arrayBuffer());
2792       await ffmpeg.writeFile(inputName, inputData);
2793       // -ss / -t as INPUT-side options (before -i) do a fast keyframe seek; -t is an explicit duration so the
2794       // end point is unambiguous regardless of ffmpeg version.
2795       await ffmpeg.exec([
2796         '-ss', String(startSeconds),
2797         '-t', String(clipSeconds),
2798         '-i', inputName,
2799         '-c', 'copy',
2800         '-movflags', 'faststart',
2801         outputName
2802       ]);
2803       const data = await ffmpeg.readFile(outputName);
2804
2805       // Free the in-memory virtual FS entries so repeated trims in one session don't accumulate memory.
2806       try { await ffmpeg.deleteFile(inputName); } catch (e) { }
2807       try { await ffmpeg.deleteFile(outputName); } catch (e) { }
2808
2809       const trimmedBlob = new Blob([data], { type: 'video/mp4' });
2810       window.__pendingVideoUploads[token] = trimmedBlob;
2811       return trimmedBlob.size;
2812     } catch (err) {
2813       console.error('trimStashedVideo failed', err);
2814       return -1;
2815     }
2816   };
2817
2818   // ── Academy course importer: the author's own transcript or recording ──
2819   // Reads a subtitle or plain-text transcript straight off the picker - a read, not a conversion.
2820   // Returns { ok, text, truncated, error }; a rejected file surfaces as a dialog message, not an exception.
2821   window.academyReadTranscriptFile = async (inputId, maximumCharacters) => {
2822     try {
2823       const input = document.getElementById(inputId);
2824       if (!input || !input.files || input.files.length === 0) {
2825         return { ok: false, text: '', truncated: false, error: 'no-file' };
2826       }
2827
2828       const file = input.files[0];
2829       const raw = await file.text();
2830       if (!raw || raw.trim().length === 0) {
2831         return { ok: false, text: '', truncated: false, error: 'empty' };
2832       }
2833
2834       // Cut to the same ceiling the textarea enforces; the server normalises and bounds again afterwards.
2835       let text = raw;
2836       let truncated = false;
2837       if (maximumCharacters > 0 && text.length > maximumCharacters) {
2838         text = text.substring(0, maximumCharacters);
2839         truncated = true;
2840       }
2841
2842       return { ok: true, text: text, truncated: truncated, error: '' };
2843     } catch (err) {
2844       console.error('academyReadTranscriptFile failed', err);
2845       return { ok: false, text: '', truncated: false, error: 'exception' };
2846     }
2847   };
2848
2849   // Strips the audio to 16 kHz mono mp3 (~14 MB) and posts only that, so a 2 GB source never enters C#.
2850   // Returns { ok, transcript, error } and never throws at the C# side.
2851   window.academyTranscribeMediaFile = async (inputId, endpointUrl, maximumAudioBytes, dotNetRef, phaseMethod) => {
2852     const reportPhase = (phase) => {
2853       if (!dotNetRef || !phaseMethod) {
2854         return;
2855       }
2856       try {
2857         dotNetRef.invokeMethodAsync(phaseMethod, phase);
2858       } catch (e) {
2859         /* The dialog may already be gone; progress is not worth failing the run over. */
2860       }
2861     };
2862
2863     try {
2864       const input = document.getElementById(inputId);
2865       if (!input || !input.files || input.files.length === 0) {
2866         return { ok: false, transcript: '', error: 'no-file' };
2867       }
2868
2869       const file = input.files[0];
2870
2871       // Phase 0: preparing the audio. The ffmpeg core alone is a 32 MB fetch on first use.
2872       reportPhase(0);
2873
2874       const ffmpeg = await ensureFfmpegLoaded();
2875
2876       // Keep the original extension: ffmpeg picks its demuxer from the name.
2877       let extension = 'mp4';
2878       const dotIndex = file.name.lastIndexOf('.');
2879       if (dotIndex > -1 && dotIndex < file.name.length - 1) {
2880         extension = file.name.substring(dotIndex + 1).toLowerCase();
2881       }
2882
2883       const inputName = 'academy-in.' + extension;
2884       const outputName = 'academy-out.mp3';
2885
2886       const inputData = new Uint8Array(await file.arrayBuffer());
2887       await ffmpeg.writeFile(inputName, inputData);
2888
2889       // -vn drops video, -ac 1 mono, -ar 16000 resample, -b:a 32k - about as low as speech survives.
2890       await ffmpeg.exec([
2891         '-i', inputName,
2892         '-vn',
2893         '-ac', '1',
2894         '-ar', '16000',
2895         '-b:a', '32k',
2896         outputName
2897       ]);
2898
2899       const audioData = await ffmpeg.readFile(outputName);
2900
2901       try { await ffmpeg.deleteFile(inputName); } catch (e) { }
2902       try { await ffmpeg.deleteFile(outputName); } catch (e) { }
2903
2904       const audioBlob = new Blob([audioData], { type: 'audio/mpeg' });
2905
2906       // Checked before the upload so an oversized recording costs a transcode, not a transcode plus a round trip.
2907       if (maximumAudioBytes > 0 && audioBlob.size > maximumAudioBytes) {
2908         return { ok: false, transcript: '', error: 'too-large' };
2909       }
2910
2911       // Phase 1: uploading and transcribing. This is the slow half on a long recording.
2912       reportPhase(1);
2913
2914       const formData = new FormData();
2915       formData.append('audio', audioBlob, 'academy-audio.mp3');
2916
2917       const response = await fetch(endpointUrl, {
2918         method: 'POST',
2919         body: formData,
2920         credentials: 'same-origin'
2921       });
2922
2923       if (!response.ok) {
2924         return { ok: false, transcript: '', error: 'http-' + response.status };
2925       }
2926
2927       const payload = await response.json();
2928       if (!payload || !payload.transcript) {
2929         return { ok: false, transcript: '', provider: '', model: '', error: 'empty' };
2930       }
2931
2932       // provider/model come back so the dialog can attribute the transcript to the model, not the author.
2933       return {
2934         ok: true,
2935         transcript: payload.transcript,
2936         provider: payload.provider || '',
2937         model: payload.model || '',
2938         error: ''
2939       };
2940     } catch (err) {
2941       console.error('academyTranscribeMediaFile failed', err);
2942       return { ok: false, transcript: '', provider: '', model: '', error: 'exception' };
2943     }
2944   };
2945
2946   // Progress plumbing shared by both transcode paths, so the C# side sees one phase/percent/ETA contract
2947   // regardless of which encoder actually ran.
2948   const createVideoCompressReporter = (dotNetRef, progressMethod) => {
2949     let encodeStartedAt = 0;
2950     let lastReportedPhase = -1;
2951     let lastReportedPercent = -1;
2952
2953     const reportPhase = (phase, percent, secondsRemaining) => {
2954       if (!dotNetRef || !progressMethod) {
2955         return;
2956       }
2957       if (phase === lastReportedPhase && percent === lastReportedPercent) {
2958         return;
2959       }
2960       lastReportedPhase = phase;
2961       lastReportedPercent = percent;
2962       try { dotNetRef.invokeMethodAsync(progressMethod, phase, percent, secondsRemaining); } catch (err) { }
2963     };
2964
2965     const startEncodeTimer = () => {
2966       encodeStartedAt = performance.now();
2967     };
2968
2969     const reportEncodeProgress = (ratio) => {
2970       let percent = Math.floor(ratio * 100);
2971       if (percent < 0) {
2972         percent = 0;
2973       }
2974       if (percent > 100) {
2975         percent = 100;
2976       }
2977
2978       let secondsRemaining = -1;
2979       if (ratio > 0.01) {
2980         const elapsedMs = performance.now() - encodeStartedAt;
2981         secondsRemaining = Math.ceil(((elapsedMs / ratio) - elapsedMs) / 1000);
2982       }
2983
2984       reportPhase(VIDEO_COMPRESS_PHASE_ENCODING, percent, secondsRemaining);
2985     };
2986
2987     return {
2988       reportPhase: reportPhase,
2989       startEncodeTimer: startEncodeTimer,
2990       reportEncodeProgress: reportEncodeProgress
2991     };
2992   };
2993
2994   // Output bitrate is derived from pixels x frames, because H.264 in WebCodecs exposes rate control only as a
2995   // bitrate; the plan's CRF is translated into this scale by computeBitsPerPixelForQuality. 0.06 bits per pixel
2996   // per frame lands within a few percent of the sizes the planner already assumes for 720p and 480p
2997   // (VIDEO_PLAN_OUTPUT_BYTES_PER_SECOND_*), so the plan's estimate and the real output stay consistent.
2998   const VIDEO_WEBCODECS_BITS_PER_PIXEL = 0.06;
2999   const VIDEO_WEBCODECS_MIN_BITS_PER_SECOND = 300 * 1000;
3000   const VIDEO_WEBCODECS_MAX_BITS_PER_SECOND = 6 * 1000 * 1000;
3001   // The plan's quality is an x264 CRF, which the WebCodecs encoder cannot take. VIDEO_WEBCODECS_BITS_PER_PIXEL is
3002   // calibrated for this CRF, so the value below is the anchor the translation scales away from.
3003   const VIDEO_WEBCODECS_BASELINE_CRF = 28;
3004   // x264 rule of thumb: every 6 points of CRF roughly halves (or doubles) the bitrate.
3005   const VIDEO_WEBCODECS_CRF_HALVING_STEP = 6;
3006   // Guard rails for the translated value, so an out-of-range CRF row in the DB cannot ask for an unwatchable
3007   // clip or for one bigger than the source. 0.02 is roughly CRF 37, 0.24 roughly CRF 16.
3008   const VIDEO_WEBCODECS_MIN_BITS_PER_PIXEL = 0.02;
3009   const VIDEO_WEBCODECS_MAX_BITS_PER_PIXEL = 0.24;
3010   const VIDEO_WEBCODECS_KEYFRAME_SECONDS = 2;
3011   const VIDEO_WEBCODECS_CODEC_QUEUE_LIMIT = 8;
3012   const VIDEO_WEBCODECS_SAMPLE_HIGH_WATER = 90;
3013   const VIDEO_WEBCODECS_VIDEO_BATCH_SAMPLES = 30;
3014   const VIDEO_WEBCODECS_AUDIO_BATCH_SAMPLES = 100;
3015   const VIDEO_WEBCODECS_DEFAULT_FRAME_RATE = 30;
3016   const VIDEO_WEBCODECS_MAX_FRAME_RATE = 120;
3017   const VIDEO_WEBCODECS_AAC_LC_CODEC = 'mp4a.40.2';
3018   const VIDEO_WEBCODECS_AAC_LC_OBJECT_TYPE = 2;
3019   const VIDEO_WEBCODECS_QUEUE_POLL_MS = 5;
3020   const VIDEO_WEBCODECS_LEVEL_31_MACROBLOCKS = 3600;
3021   const MICROSECONDS_PER_SECOND = 1000000;
3022
3023   // Holds the feed loop back while the codecs are saturated. The 'dequeue' event is not available everywhere
3024   // WebCodecs is, so this polls the queue depth instead.
3025   const waitForCodecQueue = async (readQueueDepth) => {
3026     while (readQueueDepth() > VIDEO_WEBCODECS_CODEC_QUEUE_LIMIT) {
3027       if (isVideoJobCancelled) {
3028         return;
3029       }
3030       await new Promise((resolve) => { window.setTimeout(resolve, VIDEO_WEBCODECS_QUEUE_POLL_MS); });
3031     }
3032   };
3033
3034   // The decoder needs the codec-private bytes (AVCDecoderConfigurationRecord / HEVCDecoderConfigurationRecord).
3035   // mp4box exposes them only as a writable box, so serialise the box and drop its 8-byte size+type header.
3036   const readVideoDecoderDescription = (mp4boxFile, trackId) => {
3037     const trak = mp4boxFile.getTrackById(trackId);
3038     if (!trak) {
3039       return null;
3040     }
3041     const entries = trak.mdia.minf.stbl.stsd.entries;
3042     for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
3043       let configurationBox = entries[entryIndex].avcC;
3044       if (!configurationBox) {
3045         configurationBox = entries[entryIndex].hvcC;
3046       }
3047       if (!configurationBox) {
3048         continue;
3049       }
3050       const stream = new window.DataStream(undefined, 0, window.DataStream.BIG_ENDIAN);
3051       configurationBox.write(stream);
3052       const description = new Uint8Array(stream.buffer.slice(8));
3053       return description;
3054     }
3055     return null;
3056   };
3057
3058   const MP4_ES_DESCRIPTOR_TAG = 0x03;
3059   const MP4_DECODER_CONFIG_DESCRIPTOR_TAG = 0x04;
3060   const MP4_DECODER_SPECIFIC_INFO_TAG = 0x05;
3061   const MP4_OBJECT_TYPE_MPEG4_AUDIO = 0x40;
3062
3063   // MPEG-4 descriptor lengths are 7 bits per byte, with the top bit marking "another length byte follows".
3064   const readDescriptorLength = (bytes, cursor) => {
3065     let length = 0;
3066     let offset = cursor;
3067     for (let step = 0; step < 4; step++) {
3068       const current = bytes[offset];
3069       offset = offset + 1;
3070       length = (length << 7) | (current & 0x7f);
3071       if ((current & 0x80) === 0) {
3072         break;
3073       }
3074     }
3075     return { length: length, cursor: offset };
3076   };
3077
3078   // AudioSpecificConfig for the copied-through AAC track, read straight out of the raw stsd bytes rather than
3079   // from mp4box's parsed tree: QuickTime .mov files use a sound sample entry layout mp4box does not descend
3080   // into, so the parsed tree exposes no esds at all for them even though the box is present. Returns null for
3081   // anything that is not MPEG-4 audio, which is what keeps MP3-in-MP4 off this path.
3082   const readAacAudioSpecificConfig = (mp4boxFile, trackId, sourceBuffer) => {
3083     const trak = mp4boxFile.getTrackById(trackId);
3084     if (!trak) {
3085       return null;
3086     }
3087     const sampleDescriptionBox = trak.mdia.minf.stbl.stsd;
3088     if (!sampleDescriptionBox || !(sampleDescriptionBox.size > 0)) {
3089       return null;
3090     }
3091
3092     const bytes = new Uint8Array(sourceBuffer, sampleDescriptionBox.start, sampleDescriptionBox.size);
3093     let esdsAt = -1;
3094     for (let index = 0; index + 4 <= bytes.length; index++) {
3095       if (bytes[index] === 0x65 && bytes[index + 1] === 0x73 && bytes[index + 2] === 0x64 && bytes[index + 3] === 0x73) {
3096         esdsAt = index;
3097         break;
3098       }
3099     }
3100     if (esdsAt < 0) {
3101       return null;
3102     }
3103
3104     // Skip the 'esds' fourcc and the full-box version/flags word, then walk ES -> DecoderConfig -> SpecificInfo.
3105     let cursor = esdsAt + 4 + 4;
3106     if (bytes[cursor] !== MP4_ES_DESCRIPTOR_TAG) {
3107       return null;
3108     }
3109     cursor = cursor + 1;
3110     const esDescriptorLength = readDescriptorLength(bytes, cursor);
3111     cursor = esDescriptorLength.cursor + 2;
3112     const esFlags = bytes[cursor];
3113     cursor = cursor + 1;
3114     if ((esFlags & 0x80) !== 0) {
3115       cursor = cursor + 2;
3116     }
3117     if ((esFlags & 0x40) !== 0) {
3118       cursor = cursor + 1 + bytes[cursor];
3119     }
3120     if ((esFlags & 0x20) !== 0) {
3121       cursor = cursor + 2;
3122     }
3123
3124     if (bytes[cursor] !== MP4_DECODER_CONFIG_DESCRIPTOR_TAG) {
3125       return null;
3126     }
3127     cursor = cursor + 1;
3128     const decoderConfigLength = readDescriptorLength(bytes, cursor);
3129     cursor = decoderConfigLength.cursor;
3130     if (bytes[cursor] !== MP4_OBJECT_TYPE_MPEG4_AUDIO) {
3131       return null;
3132     }
3133     // objectTypeIndication(1) + streamType(1) + bufferSizeDB(3) + maxBitrate(4) + avgBitrate(4)
3134     cursor = cursor + 13;
3135
3136     if (bytes[cursor] !== MP4_DECODER_SPECIFIC_INFO_TAG) {
3137       return null;
3138     }
3139     cursor = cursor + 1;
3140     const specificInfoLength = readDescriptorLength(bytes, cursor);
3141     cursor = specificInfoLength.cursor;
3142     if (!(specificInfoLength.length > 0) || cursor + specificInfoLength.length > bytes.length) {
3143       return null;
3144     }
3145
3146     const audioSpecificConfig = bytes.slice(cursor, cursor + specificInfoLength.length);
3147     return audioSpecificConfig;
3148   };
3149
3150   // tkhd display matrix, 16.16 fixed point. Phones store an upright clip as landscape frames plus a rotation,
3151   // and atan2 is scale-invariant so the raw fixed-point values can be fed in directly.
3152   const readTrackRotationDegrees = (videoTrack) => {
3153     const matrix = videoTrack.matrix;
3154     if (!matrix || matrix.length < 5) {
3155       return 0;
3156     }
3157     const radians = Math.atan2(matrix[1], matrix[0]);
3158     let degrees = Math.round(radians * 180 / Math.PI);
3159     if (degrees < 0) {
3160       degrees = degrees + 360;
3161     }
3162     if (degrees === 90 || degrees === 180 || degrees === 270) {
3163       return degrees;
3164     }
3165     return 0;
3166   };
3167
3168   // The optimise stage stores a portrait clip in the same 9:16 canvas the feed shows it in, so the file matches the
3169   // frame and the pixels the player would have cropped away are never encoded, uploaded, stored or served. Shared
3170   // with the ffmpeg fallback below through the same ratio, so both transcode paths emit the same shape.
3171   const VIDEO_TRANSCODE_PORTRAIT_ASPECT = 9 / 16;
3172
3173   // Largest 9:16 rectangle that fits inside a portrait source, in source pixels. A clip already at 9:16 comes back
3174   // untouched; a 4:5 loses width, a 1:3 loses height. Landscape and square sources are passed straight through —
3175   // only vertical video is normalised.
3176   const composePortraitCropSize = (displayWidth, displayHeight) => {
3177     if (displayHeight <= displayWidth) {
3178       return { width: displayWidth, height: displayHeight };
3179     }
3180
3181     let width = displayWidth;
3182     let height = Math.round(displayWidth / VIDEO_TRANSCODE_PORTRAIT_ASPECT);
3183     if (height > displayHeight) {
3184       height = displayHeight;
3185       width = Math.round(displayHeight * VIDEO_TRANSCODE_PORTRAIT_ASPECT);
3186     }
3187     return { width: width, height: height };
3188   };
3189
3190   // Destination rectangle that makes the source frame COVER the target box, centered — the canvas clips whatever
3191   // overflows. Cropping this way instead of with a source rectangle keeps the rotation transforms below untouched:
3192   // they already draw into a destination rect, so the crop rides along whatever matrix is in effect.
3193   const composeCoverDrawRect = (sourceWidth, sourceHeight, targetWidth, targetHeight) => {
3194     let scale = targetWidth / sourceWidth;
3195     const scaleToFillHeight = targetHeight / sourceHeight;
3196     if (scaleToFillHeight > scale) {
3197       scale = scaleToFillHeight;
3198     }
3199
3200     const width = sourceWidth * scale;
3201     const height = sourceHeight * scale;
3202     const x = (targetWidth - width) / 2;
3203     const y = (targetHeight - height) / 2;
3204     return { x: x, y: y, width: width, height: height };
3205   };
3206
3207   // Destination rectangle that makes the source frame FIT ENTIRELY inside the target box, centered, leaving bars on
3208   // whichever axis it does not fill. The refit path draws with this instead of the cover rect above: it re-frames a
3209   // clip the author already composed, so reaching a platform's minimum frame must not cut content out of the shot.
3210   const composeContainDrawRect = (sourceWidth, sourceHeight, targetWidth, targetHeight) => {
3211     let scale = targetWidth / sourceWidth;
3212     const scaleToFitHeight = targetHeight / sourceHeight;
3213     if (scaleToFitHeight < scale) {
3214       scale = scaleToFitHeight;
3215     }
3216
3217     const width = sourceWidth * scale;
3218     const height = sourceHeight * scale;
3219     const x = (targetWidth - width) / 2;
3220     const y = (targetHeight - height) / 2;
3221     return { x: x, y: y, width: width, height: height };
3222   };
3223
3224   const computeTranscodeTargetSize = (displayWidth, displayHeight, maxEdgePixels) => {
3225     let scale = 1;
3226     let longestEdge = displayWidth;
3227     if (displayHeight > longestEdge) {
3228       longestEdge = displayHeight;
3229     }
3230     // Only ever downscale — an in-limits clip must not be blown up just because a target edge was requested.
3231     if (longestEdge > maxEdgePixels) {
3232       scale = maxEdgePixels / longestEdge;
3233     }
3234
3235     let width = Math.round(displayWidth * scale);
3236     let height = Math.round(displayHeight * scale);
3237     // 4:2:0 chroma needs even dimensions on both axes, the same thing ffmpeg's -2 does in the scale filter.
3238     width = width - (width % 2);
3239     height = height - (height % 2);
3240     if (width < 2) {
3241       width = 2;
3242     }
3243     if (height < 2) {
3244       height = 2;
3245     }
3246     return { width: width, height: height };
3247   };
3248
3249   // Translates the plan's x264 CRF into the bits-per-pixel figure this path encodes with, so the same quality
3250   // knob drives both encoders instead of being silently dropped here. Lower CRF = more bits.
3251   const computeBitsPerPixelForQuality = (quality) => {
3252     if (!(quality > 0)) {
3253       return VIDEO_WEBCODECS_BITS_PER_PIXEL;
3254     }
3255
3256     const crfStepsBelowBaseline = (VIDEO_WEBCODECS_BASELINE_CRF - quality) / VIDEO_WEBCODECS_CRF_HALVING_STEP;
3257     const bitrateFactor = Math.pow(2, crfStepsBelowBaseline);
3258     let bitsPerPixel = VIDEO_WEBCODECS_BITS_PER_PIXEL * bitrateFactor;
3259     if (bitsPerPixel < VIDEO_WEBCODECS_MIN_BITS_PER_PIXEL) {
3260       bitsPerPixel = VIDEO_WEBCODECS_MIN_BITS_PER_PIXEL;
3261     }
3262     if (bitsPerPixel > VIDEO_WEBCODECS_MAX_BITS_PER_PIXEL) {
3263       bitsPerPixel = VIDEO_WEBCODECS_MAX_BITS_PER_PIXEL;
3264     }
3265     return bitsPerPixel;
3266   };
3267
3268   // Main profile first for its better compression, baseline as the compatibility fallback.
3269   const chooseVideoEncoderConfiguration = async (width, height, frameRate, bitsPerSecond) => {
3270     const macroblocks = Math.ceil(width / 16) * Math.ceil(height / 16);
3271     let levelSuffix = '1f';
3272     if (macroblocks > VIDEO_WEBCODECS_LEVEL_31_MACROBLOCKS) {
3273       levelSuffix = '28';
3274     }
3275
3276     const candidateCodecs = ['avc1.4d00' + levelSuffix, 'avc1.4200' + levelSuffix];
3277     for (let index = 0; index < candidateCodecs.length; index++) {
3278       const configuration = {
3279         codec: candidateCodecs[index],
3280         width: width,
3281         height: height,
3282         bitrate: bitsPerSecond,
3283         bitrateMode: 'variable',
3284         framerate: frameRate,
3285         avc: { format: 'avc' }
3286       };
3287       try {
3288         const support = await window.VideoEncoder.isConfigSupported(configuration);
3289         if (support && support.supported) {
3290           return configuration;
3291         }
3292       } catch (e) {
3293       }
3294     }
3295     return null;
3296   };
3297
3298   // Bakes the source rotation into the pixels rather than passing a matrix through to the output, matching
3299   // what ffmpeg's autorotation produces so both transcode paths yield an upright file. sourceSize is the frame's
3300   // size in DISPLAY orientation. Cover fit (the optimise path): when the source is a wider ratio than the target box
3301   // - a portrait clip being normalised to 9:16 - the rect overflows the canvas and the excess is clipped, which is
3302   // the crop. Contain fit (the refit path): the rect never overflows, and the unfilled remainder stays black.
3303   const drawFrameIntoTarget = (canvasContext, frame, sourceSize, targetSize, rotationDegrees, isContainFit) => {
3304     let composeDrawRect = composeCoverDrawRect;
3305     if (isContainFit) {
3306       composeDrawRect = composeContainDrawRect;
3307       // A contained frame leaves bars the drawImage below never touches, so without this they keep whatever the
3308       // previous frame left behind. Painted in canvas space, before the rotation branches install their transform.
3309       canvasContext.fillStyle = '#000000';
3310       canvasContext.fillRect(0, 0, targetSize.width, targetSize.height);
3311     }
3312
3313     // The 90 / 270 branches rotate the canvas, so their local axes are swapped relative to the display ones — both
3314     // the source and the target have to be handed over swapped as well for the fit maths to land the same way.
3315     if (rotationDegrees === 90) {
3316       const rotatedRect = composeDrawRect(sourceSize.height, sourceSize.width, targetSize.height, targetSize.width);
3317       canvasContext.save();
3318       canvasContext.translate(targetSize.width, 0);
3319       canvasContext.rotate(Math.PI / 2);
3320       canvasContext.drawImage(frame, rotatedRect.x, rotatedRect.y, rotatedRect.width, rotatedRect.height);
3321       canvasContext.restore();
3322       return;
3323     }
3324     if (rotationDegrees === 180) {
3325       const flippedRect = composeDrawRect(sourceSize.width, sourceSize.height, targetSize.width, targetSize.height);
3326       canvasContext.save();
3327       canvasContext.translate(targetSize.width, targetSize.height);
3328       canvasContext.rotate(Math.PI);
3329       canvasContext.drawImage(frame, flippedRect.x, flippedRect.y, flippedRect.width, flippedRect.height);
3330       canvasContext.restore();
3331       return;
3332     }
3333     if (rotationDegrees === 270) {
3334       const rotatedRect = composeDrawRect(sourceSize.height, sourceSize.width, targetSize.height, targetSize.width);
3335       canvasContext.save();
3336       canvasContext.translate(0, targetSize.height);
3337       canvasContext.rotate(-Math.PI / 2);
3338       canvasContext.drawImage(frame, rotatedRect.x, rotatedRect.y, rotatedRect.width, rotatedRect.height);
3339       canvasContext.restore();
3340       return;
3341     }
3342
3343     const fittedRect = composeDrawRect(sourceSize.width, sourceSize.height, targetSize.width, targetSize.height);
3344     canvasContext.drawImage(frame, fittedRect.x, fittedRect.y, fittedRect.width, fittedRect.height);
3345   };
3346
3347   // Transcodes the stashed clip with the platform codecs: mp4box demuxes, VideoDecoder decodes, an OffscreenCanvas
3348   // rescales (and un-rotates), VideoEncoder re-encodes to H.264 and mp4-muxer writes the container. The AAC track
3349   // is copied through sample-for-sample — it is a tiny share of the bitrate and re-encoding it would only cost
3350   // quality and time. Returns a status so the caller can tell "this browser/file needs ffmpeg" apart from
3351   // "transcoded, but the result was not smaller", which ffmpeg would not improve on either.
3352   const compressStashedVideoWithWebCodecs = async (file, token, maxEdgePixels, quality, reporter, refitFrame) => {
3353     const unsupportedResult = { status: 'unsupported', sizeBytes: -1 };
3354     const cancelledResult = { status: 'cancelled', sizeBytes: -1 };
3355
3356     let decoder = null;
3357     let encoder = null;
3358     let mp4boxFile = null;
3359
3360     try {
3361       reporter.reportPhase(VIDEO_COMPRESS_PHASE_LOADING_TOOLS, 0, -1);
3362       await ensureVideoContainerToolsLoaded();
3363       reporter.reportPhase(VIDEO_COMPRESS_PHASE_LOADING_TOOLS, 100, -1);
3364
3365       reporter.reportPhase(VIDEO_COMPRESS_PHASE_PREPARING, 0, -1);
3366       const sourceBuffer = await file.arrayBuffer();
3367       if (isVideoJobCancelled) {
3368         return cancelledResult;
3369       }
3370
3371       let readyInfo = null;
3372       let demuxErrorMessage = null;
3373       mp4boxFile = window.MP4Box.createFile();
3374       mp4boxFile.onError = (message) => { demuxErrorMessage = message; };
3375       mp4boxFile.onReady = (info) => { readyInfo = info; };
3376
3377       // The whole file is appended in one go, so onReady and the full sample list are available synchronously.
3378       // flush() is deliberately not called: it cleans up parsed buffers that getSample still needs to read from.
3379       sourceBuffer.fileStart = 0;
3380       mp4boxFile.appendBuffer(sourceBuffer);
3381       if (demuxErrorMessage || !readyInfo) {
3382         return unsupportedResult;
3383       }
3384
3385       const videoTracks = readyInfo.videoTracks || [];
3386       if (videoTracks.length !== 1) {
3387         return unsupportedResult;
3388       }
3389       const videoTrack = videoTracks[0];
3390       if (!(videoTrack.nb_samples > 0) || !videoTrack.video) {
3391         return unsupportedResult;
3392       }
3393       const isAvcSource = videoTrack.codec.indexOf('avc1.') === 0;
3394       const isHevcSource = videoTrack.codec.indexOf('hvc1.') === 0 || videoTrack.codec.indexOf('hev1.') === 0;
3395       if (!isAvcSource && !isHevcSource) {
3396         return unsupportedResult;
3397       }
3398
3399       // Audio rides through untouched, so the source has to be AAC-LC. The codec string cannot settle that on its
3400       // own — QuickTime .mov files, which the picker accepts, report a bare 'mp4a' with no profile suffix — so the
3401       // AudioSpecificConfig decides: its top 5 bits are the MPEG-4 audio object type, and 2 is AAC-LC. A track
3402       // with no readable config (MP3-in-MP4 carries none) goes to ffmpeg rather than being guessed at.
3403       const audioTracks = readyInfo.audioTracks || [];
3404       let audioTrack = null;
3405       let audioSpecificConfig = null;
3406       if (audioTracks.length > 0) {
3407         audioTrack = audioTracks[0];
3408         if (audioTrack.codec.indexOf('mp4a') !== 0) {
3409           return unsupportedResult;
3410         }
3411         if (!audioTrack.audio || !(audioTrack.audio.sample_rate >
3411 0) || !(audioTrack.audio.channel_count > 0)) {
3412           return unsupportedResult;
3413         }
3414         audioSpecificConfig = readAacAudioSpecificConfig(mp4boxFile, audioTrack.id, sourceBuffer);
3415         if (!audioSpecificConfig || audioSpecificConfig.length === 0) {
3416           return unsupportedResult;
3417         }
3418         const audioObjectType = audioSpecificConfig[0] >> 3;
3419         if (audioObjectType !== VIDEO_WEBCODECS_AAC_LC_OBJECT_TYPE) {
3420           return unsupportedResult;
3421         }
3422       }
3423
3424       const rotationDegrees = readTrackRotationDegrees(videoTrack);
3425       let displayWidth = videoTrack.video.width;
3426       let displayHeight = videoTrack.video.height;
3427       if (rotationDegrees === 90 || rotationDegrees === 270) {
3428         displayWidth = videoTrack.video.height;
3429         displayHeight = videoTrack.video.width;
3430       }
3431       // Optimising, the output frame is sized from the 9:16 crop rather than from the source: a portrait clip is
3432       // encoded at the shape the feed presents it in, so the cropped-away pixels never cost bitrate, and landscape
3433       // sources crop to nothing so their target is exactly what it was before.
3434       const sourceSize = { width: displayWidth, height: displayHeight };
3435       let targetSize = null;
3436       let isContainFit = false;
3437       if (refitFrame) {
3438         // A refit encodes into the exact box the caller asked for, so the source shape decides nothing here. The
3439         // fit is CONTAIN rather than cover: whatever the box does not match is barred, never cropped, because the
3440         // clip is one the author already composed. Callers size the box to the clip's own shape - the smallest
3441         // enlargement clearing a platform floor - so in practice the bar is a rounded pixel and nothing shows.
3442         targetSize = { width: refitFrame.width, height: refitFrame.height };
3443         isContainFit = true;
3444       }
3445       else {
3446         const croppedSize = composePortraitCropSize(displayWidth, displayHeight);
3447         targetSize = computeTranscodeTargetSize(croppedSize.width, croppedSize.height, maxEdgePixels);
3448       }
3449
3450       let frameRate = VIDEO_WEBCODECS_DEFAULT_FRAME_RATE;
3451       const trackSeconds = videoTrack.duration / videoTrack.timescale;
3452       if (trackSeconds > 0) {
3453         frameRate = videoTrack.nb_samples / trackSeconds;
3454       }
3455       if (!(frameRate > 0) || frameRate > VIDEO_WEBCODECS_MAX_FRAME_RATE) {
3456         frameRate = VIDEO_WEBCODECS_DEFAULT_FRAME_RATE;
3457       }
3458
3459       const bitsPerPixel = computeBitsPerPixelForQuality(quality);
3460       let bitsPerSecond = Math.round(targetSize.width * targetSize.height * frameRate * bitsPerPixel);
3461       if (bitsPerSecond < VIDEO_WEBCODECS_MIN_BITS_PER_SECOND) {
3462         bitsPerSecond = VIDEO_WEBCODECS_MIN_BITS_PER_SECOND;
3463       }
3464       if (bitsPerSecond > VIDEO_WEBCODECS_MAX_BITS_PER_SECOND) {
3465         bitsPerSecond = VIDEO_WEBCODECS_MAX_BITS_PER_SECOND;
3466       }
3467
3468       const decoderDescription = readVideoDecoderDescription(mp4boxFile, videoTrack.id);
3469       if (!decoderDescription) {
3470         return unsupportedResult;
3471       }
3472       const decoderConfiguration = {
3473         codec: videoTrack.codec,
3474         codedWidth: videoTrack.video.width,
3475         codedHeight: videoTrack.video.height,
3476         description: decoderDescription,
3477         hardwareAcceleration: 'no-preference',
3478         optimizeForLatency: false
3479       };
3480       const decoderSupport = await window.VideoDecoder.isConfigSupported(decoderConfiguration);
3481       if (!decoderSupport || !decoderSupport.supported) {
3482         return unsupportedResult;
3483       }
3484
3485       const encoderConfiguration = await chooseVideoEncoderConfiguration(targetSize.width, targetSize.height, frameRate, bitsPerSecond);
3486       if (!encoderConfiguration) {
3487         return unsupportedResult;
3488       }
3489
3490       const muxerOptions = {
3491         target: new window.Mp4Muxer.ArrayBufferTarget(),
3492         video: { codec: 'avc', width: targetSize.width, height: targetSize.height },
3493         fastStart: 'in-memory',
3494         firstTimestampBehavior: 'cross-track-offset'
3495       };
3496       let audioChunkMetadata = null;
3497       if (audioTrack) {
3498         muxerOptions.audio = {
3499           codec: 'aac',
3500           numberOfChannels: audioTrack.audio.channel_count,
3501           sampleRate: audioTrack.audio.sample_rate
3502         };
3503         audioChunkMetadata = {
3504           decoderConfig: {
3505             codec: VIDEO_WEBCODECS_AAC_LC_CODEC,
3506             numberOfChannels: audioTrack.audio.channel_count,
3507             sampleRate: audioTrack.audio.sample_rate,
3508             description: audioSpecificConfig
3509           }
3510         };
3511       }
3512       const muxer = new window.Mp4Muxer.Muxer(muxerOptions);
3513
3514       const canvas = new window.OffscreenCanvas(targetSize.width, targetSize.height);
3515       const canvasContext = canvas.getContext('2d', { alpha: false });
3516       if (!canvasContext) {
3517         return unsupportedResult;
3518       }
3519
3520       let codecErrorMessage = null;
3521       let encodedFrameCount = 0;
3522       encoder = new window.VideoEncoder({
3523         output: (chunk, metadata) => {
3524           muxer.addVideoChunk(chunk, metadata);
3525           encodedFrameCount++;
3526           reporter.reportEncodeProgress(encodedFrameCount / videoTrack.nb_samples);
3527         },
3528         error: (error) => { codecErrorMessage = String(error); }
3529       });
3530       encoder.configure(encoderConfiguration);
3531
3532       const keyFrameInterval = Math.max(1, Math.round(frameRate * VIDEO_WEBCODECS_KEYFRAME_SECONDS));
3533       let decodedFrameCount = 0;
3534       decoder = new window.VideoDecoder({
3535         output: (frame) => {
3536           try {
3537             drawFrameIntoTarget(canvasContext, frame, sourceSize, targetSize, rotationDegrees, isContainFit);
3538             const scaledFrameInit = { timestamp: frame.timestamp };
3539             if (frame.duration > 0) {
3540               scaledFrameInit.duration = frame.duration;
3541             }
3542             const scaledFrame = new window.VideoFrame(canvas, scaledFrameInit);
3543             const isKeyFrame = (decodedFrameCount % keyFrameInterval) === 0;
3544             encoder.encode(scaledFrame, { keyFrame: isKeyFrame });
3545             scaledFrame.close();
3546             decodedFrameCount++;
3547           } catch (error) {
3548             codecErrorMessage = String(error);
3549           } finally {
3550             frame.close();
3551           }
3552         },
3553         error: (error) => { codecErrorMessage = String(error); }
3554       });
3555       decoder.configure(decoderConfiguration);
3556
3557       // Video samples are queued and drained under backpressure so only a bounded slice of the clip is ever
3558       // held as decoded-but-unencoded work. Audio samples are handed straight to the muxer as they surface.
3559       const pendingVideoSamples = [];
3560       let emittedVideoSampleCount = 0;
3561       let emittedAudioSampleCount = 0;
3562       mp4boxFile.onSamples = (trackId, user, samples) => {
3563         if (samples.length === 0) {
3564           return;
3565         }
3566         if (trackId === videoTrack.id) {
3567           for (let index = 0; index < samples.length; index++) {
3568             pendingVideoSamples.push(samples[index]);
3569           }
3570           emittedVideoSampleCount = emittedVideoSampleCount + samples.length;
3571           if (pendingVideoSamples.length >= VIDEO_WEBCODECS_SAMPLE_HIGH_WATER) {
3572             mp4boxFile.stop();
3573           }
3574           return;
3575         }
3576         if (audioTrack && trackId === audioTrack.id) {
3577           for (let index = 0; index < samples.length; index++) {
3578             const sample = samples[index];
3579             const timestampMicroseconds = (sample.cts * MICROSECONDS_PER_SECOND) / sample.timescale;
3580             const durationMicroseconds = (sample.duration * MICROSECONDS_PER_SECOND) / sample.timescale;
3581             muxer.addAudioChunkRaw(sample.data, 'key', timestampMicroseconds, durationMicroseconds, audioChunkMetadata);
3582           }
3583           emittedAudioSampleCount = emittedAudioSampleCount + samples.length;
3584           mp4boxFile.releaseUsedSamples(audioTrack.id, samples[samples.length - 1].number + 1);
3585         }
3586       };
3587
3588       mp4boxFile.setExtractionOptions(videoTrack.id, null, { nbSamples: VIDEO_WEBCODECS_VIDEO_BATCH_SAMPLES });
3589       if (audioTrack) {
3590         mp4boxFile.setExtractionOptions(audioTrack.id, null, { nbSamples: VIDEO_WEBCODECS_AUDIO_BATCH_SAMPLES });
3591       }
3592
3593       reporter.startEncodeTimer();
3594       reporter.reportPhase(VIDEO_COMPRESS_PHASE_ENCODING, 0, -1);
3595       mp4boxFile.start();
3596
3597       for (; ;) {
3598         if (isVideoJobCancelled) {
3599           return cancelledResult;
3600         }
3601         if (codecErrorMessage) {
3602           throw new Error(codecErrorMessage);
3603         }
3604
3605         if (pendingVideoSamples.length === 0) {
3606           if (emittedVideoSampleCount >= videoTrack.nb_samples) {
3607             break;
3608           }
3609           // Resume the extractor that stopped itself at the high-water mark.
3610           mp4boxFile.start();
3611           if (pendingVideoSamples.length === 0) {
3612             break;
3613           }
3614         }
3615
3616         const sample = pendingVideoSamples.shift();
3617         const chunkInit = {
3618           type: 'delta',
3619           timestamp: (sample.cts * MICROSECONDS_PER_SECOND) / sample.timescale,
3620           data: sample.data
3621         };
3622         if (sample.is_sync) {
3623           chunkInit.type = 'key';
3624         }
3625         if (sample.duration > 0) {
3626           chunkInit.duration = (sample.duration * MICROSECONDS_PER_SECOND) / sample.timescale;
3627         }
3628         decoder.decode(new window.EncodedVideoChunk(chunkInit));
3629         mp4boxFile.releaseUsedSamples(videoTrack.id, sample.number + 1);
3630
3631         await waitForCodecQueue(() => { return decoder.decodeQueueSize + encoder.encodeQueueSize; });
3632       }
3633
3634       if (isVideoJobCancelled) {
3635         return cancelledResult;
3636       }
3637
3638       // The extractor serves the video track first, so a clip whose video ends on the high-water mark leaves its
3639       // audio tail unextracted — the video loop above would have exited before restarting it. Drop the video
3640       // track from the extractor (so it can no longer trip the high-water stop) and drain the audio tail.
3641       if (audioTrack) {
3642         mp4boxFile.unsetExtractionOptions(videoTrack.id);
3643       }
3644       while (audioTrack && emittedAudioSampleCount < audioTrack.nb_samples) {
3645         const audioSampleCountBefore = emittedAudioSampleCount;
3646         mp4boxFile.start();
3647         if (emittedAudioSampleCount === audioSampleCountBefore) {
3648           break;
3649         }
3650       }
3651
3652       await decoder.flush();
3653       await encoder.flush();
3654       if (codecErrorMessage) {
3655         throw new Error(codecErrorMessage);
3656       }
3657       if (encodedFrameCount === 0) {
3658         return unsupportedResult;
3659       }
3660       muxer.finalize();
3661
3662       const transcodedBlob = new Blob([muxer.target.buffer], { type: 'video/mp4' });
3663       if (!(transcodedBlob.size > 0)) {
3664         return unsupportedResult;
3665       }
3666       // A refit exists to change the FRAME, not the file size: upscaling to reach a platform's floor legitimately
3667       // grows the file, so only the optimise path holds out for a smaller result.
3668       if (!refitFrame && transcodedBlob.size >= file.size) {
3669         return { status: 'not-smaller', sizeBytes: -1 };
3670       }
3671
3672       window.__pendingVideoUploads[token] = transcodedBlob;
3673       reporter.reportEncodeProgress(1);
3674       return { status: 'ok', sizeBytes: transcodedBlob.size };
3675     } catch (err) {
3676       console.error('compressStashedVideo: WebCodecs path failed, falling back to ffmpeg.wasm', err);
3677       return unsupportedResult;
3678     } finally {
3679       if (decoder && decoder.state !== 'closed') {
3680         try { decoder.close(); } catch (e) { }
3681       }
3682       if (encoder && encoder.state !== 'closed') {
3683         try { encoder.close(); } catch (e) { }
3684       }
3685       if (mp4boxFile) {
3686         try { mp4boxFile.stop(); } catch (e) { }
3687         mp4boxFile.onSamples = null;
3688       }
3689     }
3690   };
3691
3692   // Fallback transcode for browsers without WebCodecs and for sources the WebCodecs path cannot handle
3693   // (unexpected codec, unreadable sample tables). Single-threaded and preceded by a 32 MB core download, so it
3694   // is slow by construction — planVideoProcessing charges both against the decision to optimise at all.
3695   const compressStashedVideoWithFfmpeg = async (file, token, maxEdgePixels, quality, reporter, refitFrame) => {
3696     let ffmpeg = null;
3697     const onFfmpegProgress = (event) => {
3698       reporter.reportEncodeProgress(event && event.progress);
3699     };
3700
3701     try {
3702       reporter.reportPhase(VIDEO_COMPRESS_PHASE_LOADING_TOOLS, window.__videoToolsWarmPercent, -1);
3703       const warmPoll = setInterval(() => {
3704         reporter.reportPhase(VIDEO_COMPRESS_PHASE_LOADING_TOOLS, window.__videoToolsWarmPercent, -1);
3705       }, 500);
3706       try {
3707         await warmVideoToolsCache();
3708         ffmpeg = await ensureFfmpegLoaded();
3709       } finally {
3710         clearInterval(warmPoll);
3711       }
3712
3713       reporter.reportPhase(VIDEO_COMPRESS_PHASE_PREPARING, 0, -1);
3714       const inputName = 'cin.mp4';
3715       const outputName = 'cout.mp4';
3716
3717       const inputData = new Uint8Array(await file.arrayBuffer());
3718       await ffmpeg.writeFile(inputName, inputData);
3719
3720       if (isVideoJobCancelled) {
3721         return -1;
3722       }
3723
3724       reporter.startEncodeTimer();
3725       reporter.reportPhase(VIDEO_COMPRESS_PHASE_ENCODING, 0, -1);
3726       ffmpeg.on('progress', onFfmpegProgress);
3727
3728       let videoFilterChain = '';
3729       if (refitFrame) {
3730         // The contain-and-pad the WebCodecs path draws, expressed as filters: scale to fit inside the frame keeping
3731         // the source ratio, then centre it in the exact frame over black. force_divisible_by=2 keeps both scaled
3732         // edges even for 4:2:0 chroma, and setsar=1 stops a non-square source SAR re-stretching the padded frame.
3733         const refitWidth = String(refitFrame.width);
3734         const refitHeight = String(refitFrame.height);
3735         const refitScaleFilter = "scale=w=" + refitWidth + ":h=" + refitHeight
3736           + ":force_original_aspect_ratio=decrease:force_divisible_by=2";
3737         const refitPadFilter = "pad=" + refitWidth + ":" + refitHeight + ":(ow-iw)/2:(oh-ih)/2:black";
3738         videoFilterChain = refitScaleFilter + "," + refitPadFilter + ",setsar=1";
3739       }
3740       else {
3741         // Same 9:16 normalisation composePortraitCropSize does on the WebCodecs path, expressed as an ffmpeg
3742         // expression because this path never learns the clip's dimensions on the JS side. gt(ih,iw) keeps it a
3743         // no-op for landscape and square sources; for portrait it takes the largest 9:16 rectangle that fits,
3744         // centered (crop defaults x/y to centre). trunc(../2)*2 keeps both edges even, which 4:2:0 chroma requires.
3745         const portraitCropWidth = "if(gt(ih,iw),trunc(min(iw,ih*9/16)/2)*2,iw)";
3746         const portraitCropHeight = "if(gt(ih,iw),trunc(min(ih,iw*16/9)/2)*2,ih)";
3747         const cropFilter = "crop=w='" + portraitCropWidth + "':h='" + portraitCropHeight + "'";
3748         const scaleFilter = "scale=w='if(gte(iw,ih),min(iw," + maxEdgePixels + "),-2)':h='if(gte(iw,ih),-2,min(ih," + maxEdgePixels + "))'";
3749         videoFilterChain = cropFilter + "," + scaleFilter;
3750       }
3751
3752       await ffmpeg.exec([
3753         '-i', inputName,
3754         '-vf', videoFilterChain,
3755         '-c:v', 'libx264',
3756         '-preset', 'veryfast',
3757         '-crf', String(quality),
3758         '-c:a', 'aac',
3759         '-b:a', '96k',
3760         '-movflags', 'faststart',
3761         outputName
3762       ]);
3763       const data = await ffmpeg.readFile(outputName);
3764
3765       try { await ffmpeg.deleteFile(inputName); } catch (e) { }
3766       try { await ffmpeg.deleteFile(outputName); } catch (e) { }
3767
3768       const compressedBlob = new Blob([data], { type: 'video/mp4' });
3769       if (!(compressedBlob.size > 0)) {
3770         return -1;
3771       }
3772       // Same reasoning as the WebCodecs path: a refit is allowed to come back bigger, an optimise is not.
3773       if (!refitFrame && compressedBlob.size >= file.size) {
3774         return -1;
3775       }
3776
3777       window.__pendingVideoUploads[token] = compressedBlob;
3778       reporter.reportEncodeProgress(1);
3779       return compressedBlob.size;
3780     } catch (err) {
3781       console.error('compressStashedVideo: ffmpeg.wasm path failed', err);
3782       return -1;
3783     } finally {
3784       if (ffmpeg) {
3785         try { ffmpeg.off('progress', onFfmpegProgress); } catch (e) { }
3786       }
3787     }
3788   };
3789
3790   window.compressStashedVideo = async (token, maxEdgePixels, quality, dotNetRef, progressMethod) => {
3791     const file = window.__pendingVideoUploads[token];
3792     if (!file) {
3793       return -1;
3794     }
3795
3796     // cancelVideoJob leaves the flag raised and only uploadStashedVideo lowers it again, so without this a compress
3797     // that follows a cancelled job bails out on its first check and the clip goes up unoptimised, silently.
3798     isVideoJobCancelled = false;
3799
3800     const reporter = createVideoCompressReporter(dotNetRef, progressMethod);
3801
3802     if (isWebCodecsTranscodeSupported()) {
3803       const webCodecsResult = await compressStashedVideoWithWebCodecs(file, token, maxEdgePixels, quality, reporter, null);
3804       if (webCodecsResult.status === 'ok') {
3805         return webCodecsResult.sizeBytes;
3806       }
3807       // A clip the platform codecs already failed to shrink will not shrink under ffmpeg either, and the job
3808       // was cancelled means cancelled — neither is worth a second, far slower pass.
3809       if (webCodecsResult.status === 'not-smaller' || webCodecsResult.status === 'cancelled') {
3810         return -1;
3811       }
3812     }
3813
3814     if (isVideoJobCancelled) {
3815       return -1;
3816     }
3817
3818     const ffmpegSizeBytes = await compressStashedVideoWithFfmpeg(file, token, maxEdgePixels, quality, reporter, null);
3819     return ffmpegSizeBytes;
3820   };
3821
3822   // The CRF a refit encodes at. Below the optimise baseline because a refit exists to make a clip acceptable, not to
3823   // make it smaller - and because upscaling to a platform floor is exactly where a thin bitrate shows as mush.
3824   const VIDEO_REFIT_QUALITY_CRF = 24;
3825
3826   // Re-encodes the stashed clip into an EXACT frame, the inverse of the optimise path's "same shape, fewer pixels".
3827   // The clip is fitted WHOLE inside that frame and any remainder padded black, so nothing the author framed is ever
3828   // cropped away; a caller passing the clip's own shape scaled up therefore gets a clean enlargement and no bars.
3829   // Returns the new byte size, or -1. The stash is replaced in place: clone it first if anything else shares it.
3830   window.refitStashedVideoToFrame = async (token, targetWidth, targetHeight) => {
3831     const file = window.__pendingVideoUploads[token];
3832     if (!file) {
3833       return -1;
3834     }
3835
3836     // 4:2:0 chroma needs both edges even, the same constraint computeTranscodeTargetSize enforces on the other path.
3837     const roundedWidth = Math.round(targetWidth);
3838     const roundedHeight = Math.round(targetHeight);
3839     const frameWidth = Math.max(2, roundedWidth - (roundedWidth % 2));
3840     const frameHeight = Math.max(2, roundedHeight - (roundedHeight % 2));
3841     const refitFrame = { width: frameWidth, height: frameHeight };
3842
3843     // A refit the user just asked for is a new job, not the cancelled one whose flag is still raised.
3844     isVideoJobCancelled = false;
3845
3846     // No progress target: the cross-post layout that offers the refit has no video-job indicator to drive, and the
3847     // button it sits on carries the spinner instead.
3848     const reporter = createVideoCompressReporter(null, null);
3849
3850     if (isWebCodecsTranscodeSupported()) {
3851       const webCodecsResult = await compressStashedVideoWithWebCodecs(file, token, 0, VIDEO_REFIT_QUALITY_C
3851RF, reporter, refitFrame);
3852       if (webCodecsResult.status === 'ok') {
3853         return webCodecsResult.sizeBytes;
3854       }
3855       // Cancelled means cancelled; every other verdict is "these codecs could not do it", which ffmpeg may still.
3856       // 'not-smaller' cannot occur here - the refit path does not apply that rule.
3857       if (webCodecsResult.status === 'cancelled') {
3858         return -1;
3859       }
3860     }
3861
3862     if (isVideoJobCancelled) {
3863       return -1;
3864     }
3865
3866     const ffmpegSizeBytes = await compressStashedVideoWithFfmpeg(file, token, 0, VIDEO_REFIT_QUALITY_CRF, reporter, refitFrame);
3867     return ffmpegSizeBytes;
3868   };
3869
3870   // ── Video orientation / aspect ──────────────────────────────────────────────────────────────────────────
3871   // A feed clip is shown in a box that follows its own frame instead of a fixed 16:9 one. A landscape clip stays
3872   // width-driven as before; a portrait clip gets the TikTok / Reels treatment — a tall box driven by a height cap,
3873   // centered, with the card's black gutters either side instead of pillarbox bars around a letterboxed strip.
3874
3875   // EVERY portrait clip is shown in an exact 9:16 frame — the TikTok canvas — whatever it was shot at, so the feed
3876   // reads as one column of identically-shaped vertical videos instead of a ragged mix of 9:16, 4:5 and 3:4.
3877   // object-fit: cover absorbs whatever the source is taller or narrower than that. Landscape and square clips
3878   // keep their own frame; only vertical video is normalised.
3879   const VIDEO_ASPECT_PORTRAIT = 9 / 16;
3880   // Widest box allowed. Guards against a banner-shaped clip collapsing the card to a few pixels of height.
3881   const VIDEO_ASPECT_LANDSCAPE_MAXIMUM = 3;
3882   const VIDEO_ASPECT_CONTAINER_SELECTOR = '.video-player-container';
3883   const VIDEO_ASPECT_PORTRAIT_CLASS = 'tk-video-portrait';
3884   const VIDEO_ASPECT_VARIABLE = '--tk-video-aspect';
3885   const VIDEO_FLOAT_FRAME_CLASS = 'tk-video-float-frame';
3886   const VIDEO_FLOAT_FRAME_PORTRAIT_CLASS = 'tk-video-float-frame tk-video-float-frame-portrait';
3887
3888   // Reads the natural frame size off whichever element carries it: the <video> once its metadata lands, or the
3889   // poster <img>, which is a still of the same frame and normally resolves first (it is what the card paints).
3890   const readMediaFrameSize = (element) => {
3891     if (!element) {
3892       return null;
3893     }
3894     if (element.videoWidth > 0 && element.videoHeight > 0) {
3895       return { width: element.videoWidth, height: element.videoHeight };
3896     }
3897     if (element.naturalWidth > 0 && element.naturalHeight > 0) {
3898       return { width: element.naturalWidth, height: element.naturalHeight };
3899     }
3900     return null;
3901   };
3902
3903   // Wired from the markup (VideoPlayer.razor) on the poster's onload and on the video's onloadedmetadata. Both
3904   // describe the same clip, so whichever fires last just rewrites the same value — the video wins on a mismatch.
3905   window.tkApplyVideoAspect = (element) => {
3906     const frameSize = readMediaFrameSize(element);
3907     if (!frameSize) {
3908       return;
3909     }
3910
3911     const container = element.closest(VIDEO_ASPECT_CONTAINER_SELECTOR);
3912     if (!container) {
3913       return;
3914     }
3915
3916     const isPortrait = frameSize.height > frameSize.width;
3917
3918     // Portrait snaps to the 9:16 canvas outright — that is what makes an already-uploaded 4:5 or 3:4 clip present
3919     // as a TikTok video without re-encoding a thing. Landscape keeps its own ratio but is still capped, so a
3920     // banner-shaped clip cannot collapse the card to a few pixels of height. object-fit takes the overflow.
3921     let aspect = frameSize.width / frameSize.height;
3922     if (isPortrait) {
3923       aspect = VIDEO_ASPECT_PORTRAIT;
3924     }
3925     if (aspect > VIDEO_ASPECT_LANDSCAPE_MAXIMUM) {
3926       aspect = VIDEO_ASPECT_LANDSCAPE_MAXIMUM;
3927     }
3928
3929     container.style.setProperty(VIDEO_ASPECT_VARIABLE, aspect.toString());
3930     container.classList.toggle(VIDEO_ASPECT_PORTRAIT_CLASS, isPortrait);
3931   };
3932
3933   // Class list for the floating mini-player frame. The frame is built in <body>, outside the player's container,
3934   // so its shape is resolved from the video element itself — the only thing that travels with it when it detaches.
3935   const composeVideoFloatFrameClass = (videoElement) => {
3936     const frameSize = readMediaFrameSize(videoElement);
3937     if (frameSize && frameSize.height > frameSize.width) {
3938       return VIDEO_FLOAT_FRAME_PORTRAIT_CLASS;
3939     }
3940     return VIDEO_FLOAT_FRAME_CLASS;
3941   };
3942
3943   // In-page floating mini-player: native OS PiP needs a user gesture, and a scroll isn't one.
3944   window.initScrollPictureInPicture = (videoElement, closeLabel) => {
3945     if (!videoElement) {
3946       return;
3947     }
3948     if (videoElement._pipScrollWired) {
3949       return;
3950     }
3951     videoElement._pipScrollWired = true;
3952
3953     // The wrapper holds the reserved inline space the video leaves behind while floating.
3954     const wrapper = videoElement.closest('.video-wrapper') || videoElement.parentElement;
3955     let floatFrame = null;
3956
3957     const floatVideo = () => {
3958       if (floatFrame) {
3959         return;
3960       }
3961       // Only float a clip the user is watching, and never one already in the native OS PiP window.
3962       if (videoElement.paused || videoElement.ended) {
3963         return;
3964       }
3965       if (document.pictureInPictureElement === videoElement) {
3966         return;
3967       }
3968
3969       floatFrame = document.createElement('div');
3970       floatFrame.className = composeVideoFloatFrameClass(videoElement);
3971
3972       const closeButton = document.createElement('button');
3973       closeButton.type = 'button';
3974       closeButton.className = 'tk-video-float-frame-close';
3975       closeButton.setAttribute('aria-label', closeLabel || 'Close');
3976       closeButton.title = closeLabel || 'Close';
3977       closeButton.textContent = '✕';
3978       // Closing pauses and docks; floatVideo bails on a paused clip, so it cannot immediately re-float.
3979       closeButton.addEventListener('click', (e) => {
3980         e.preventDefault();
3981         e.stopPropagation();
3982         try { videoElement.pause(); } catch (err) { }
3983         dockVideo();
3984       });
3985
3986       // Append to body, not the feed card: an ancestor transform/filter/overflow would break position:fixed.
3987       floatFrame.appendChild(videoElement);
3988       floatFrame.appendChild(closeButton);
3989       document.body.appendChild(floatFrame);
3990     };
3991
3992     const dockVideo = () => {
3993       if (!floatFrame) {
3994         return;
3995       }
3996       // Back as the wrapper's first child so the inline sibling rules (PiP button / placeholder) line up again.
3997       if (wrapper) {
3998         wrapper.insertBefore(videoElement, wrapper.firstChild);
3999       }
4000       floatFrame.remove();
4001       floatFrame = null;
4002     };
4003
4004     // Observe the WRAPPER, not the video: a floating video sits in the viewport and would dock/re-float forever.
4005     const anchor = wrapper || videoElement;
4006
4007     // threshold 0: float only once the inline spot is fully scrolled away, dock the moment it reappears.
4008     const observer = new IntersectionObserver((entries) => {
4009       for (let i = 0; i < entries.length; i++) {
4010         const entry = entries[i];
4011         if (!entry.isIntersecting) {
4012           floatVideo();
4013         } else {
4014           dockVideo();
4015         }
4016       }
4017     }, { threshold: 0 });
4018
4019     observer.observe(anchor);
4020
4021     // Listened here, not just on our button, so PiP opened from the browser's own controls also marks the element.
4022     const onEnterPip = () => { videoElement.classList.add('tk-video-in-pip'); };
4023     const onLeavePip = () => { videoElement.classList.remove('tk-video-in-pip'); };
4024     videoElement.addEventListener('enterpictureinpicture', onEnterPip);
4025     videoElement.addEventListener('leavepictureinpicture', onLeavePip);
4026
4027     const onPause = () => {
4028       if (videoElement.ended) {
4029         return;
4030       }
4031       dockVideo();
4032     };
4033     videoElement.addEventListener('pause', onPause);
4034
4035     // Dock directly in JS when the clip ends: onPause above deliberately skips ended clips, so without this the
4036     // fixed float frame's removal would depend entirely on the OnVideoEndedAsync circuit round-trip — a busy or
4037     // dropped circuit left the frame orphaned over the page, swallowing clicks. The round-trip still restores
4038     // the poster when it lands.
4039     const onEnded = () => {
4040       dockVideo();
4041     };
4042     videoElement.addEventListener('ended', onEnded);
4043
4044     videoElement._pipScrollCleanup = () => {
4045       observer.disconnect();
4046       // Dock before Blazor tears the component down, else the video is orphaned in <body> and keeps playing.
4047       dockVideo();
4048       videoElement.removeEventListener('pause', onPause);
4049       videoElement.removeEventListener('ended', onEnded);
4050       videoElement.removeEventListener('enterpictureinpicture', onEnterPip);
4051       videoElement.removeEventListener('leavepictureinpicture', onLeavePip);
4052       videoElement.classList.remove('tk-video-in-pip');
4053       videoElement._pipScrollWired = false;
4054       videoElement._pipScrollCleanup = null;
4055     };
4056   };
4057
4058   window.disposeScrollPictureInPicture = (videoElement) => {
4059     if (videoElement && videoElement._pipScrollCleanup) {
4060       try { videoElement._pipScrollCleanup(); } catch (e) { }
4061     }
4062   };
4063
4064   // Closes the native OS PiP window, returning the video to its inline spot. exitPictureInPicture() needs no
4065   // user gesture, so this is safe from a Blazor @onclick (the inline "playing in picture-in-picture"
4066   // placeholder).
4067   window.exitNativePictureInPicture = (videoElement) => {
4068     if (videoElement && document.pictureInPictureElement === videoElement) {
4069       document.exitPictureInPicture().catch(() => { });
4070     }
4071   };
4072
4073   // Wires the "Open PiP" button to the native OS Picture-in-Picture window. requestPictureInPicture() requires
4074   // a transient user gesture, and in Blazor Server a C# @onclick round-trips through the server and loses that
4075   // activation — so the listener is attached here in pure JS and fires synchronously inside the real click.
4076   // The button is hidden where the standard PiP API is unavailable (e.g. iOS Safari). Idempotent per button.
4077   window.wireNativePipButton = (button, videoElement) => {
4078     if (!button || !videoElement) {
4079       return;
4080     }
4081     if (!document.pictureInPictureEnabled || videoElement.disablePictureInPicture) {
4082       button.style.display = 'none';
4083       return;
4084     }
4085     if (button._pipWired) {
4086       return;
4087     }
4088     button._pipWired = true;
4089
4090     button.addEventListener('click', async (e) => {
4091       e.preventDefault();
4092       e.stopPropagation();
4093       try {
4094         if (document.pictureInPictureElement === videoElement) {
4095           await document.exitPictureInPicture();
4096         } else {
4097           await videoElement.requestPictureInPicture();
4098         }
4099       } catch (err) {
4100         // e.g. NotAllowedError — leave the video inline.
4101       }
4102     });
4103   };
4104
4105   // Existence / decodability probe for a feed clip. A video removed from the CDN (or an undecodable file) must
4106   // show the host's "no longer available" placeholder instead of a dead play button. Blazor can't wire the
4107   // <video> element's non-bubbling `error` event (its @onerror never fires), and the real player element only
4108   // mounts on play, so this loads just the metadata through a throwaway detached <video>. Media elements load
4109   // cross-origin without CORS, so this works against the CDN host. It calls back into .NET ONLY on failure
4110   // (success stays silent) and releases the probe either way;
4110 guarded so it settles at most once.
4111   window.probeVideoExists = (url, dotNetRef, methodName) => {
4112     if (!url || !dotNetRef || !methodName) {
4113       return;
4114     }
4115
4116     const probe = document.createElement('video');
4117     probe.preload = 'metadata';
4118     probe.muted = true;
4119     let settled = false;
4120
4121     const release = () => {
4122       probe.removeAttribute('src');
4123       try {
4124         probe.load();
4125       } catch (e) {
4126       }
4127     };
4128
4129     probe.addEventListener('loadedmetadata', () => {
4130       if (settled) {
4131         return;
4132       }
4133       settled = true;
4134       release();
4135     });
4136
4137     probe.addEventListener('error', () => {
4138       if (settled) {
4139         return;
4140       }
4141       settled = true;
4142       release();
4143       try {
4144         dotNetRef.invokeMethodAsync(methodName);
4145       } catch (e) {
4146       }
4147     });
4148
4149     probe.src = url;
4150     probe.load();
4151   };
4152
4153   // Starts playback on a feed <video> element. Shows native controls, attaches a one-time error listener
4154   // (guarded so a re-render doesn't stack duplicates), then calls play(). Invoked from VideoPlayer.razor.
4155   window.playVideo = (videoElement, previewLimitSeconds, dotNetRef, muted, loop) => {
4156     // isPlayableMediaElement — declared above with the igp* helpers — covers null AND the truthy-but-not-an-element
4157     // case its comment describes, so it is the whole entry guard: an unpopulated ElementReference arrives as a plain
4158     // object, silently absorbs the property writes below, and only fails on .play(). Here that TypeError travelled
4159     // back into .NET, out of OnAfterRenderAsync and into the top-level ErrorBoundary, which replaced the whole routed
4160     // page with the 500 error page. VideoPlayer no longer calls in without a mounted element; this is what keeps any
4161     // future caller's mistake a dead player instead of a dead page.
4162     if (!isPlayableMediaElement(videoElement)) {
4163       console.error('playVideo: the reference did not resolve to a <video> element', videoElement);
4164       return;
4165     }
4166
4167     videoElement.controls = true;
4168
4169     // Apply muted/loop here (not only via the Blazor attribute) so muted autoplay is reliable — browsers block
4170     // unmuted programmatic playback, and the reflected `muted` attribute doesn't always stick through render.
4171     if (typeof muted === 'boolean') {
4172       videoElement.muted = muted;
4173     }
4174     if (typeof loop === 'boolean') {
4175       videoElement.loop = loop;
4176     }
4177
4178     if (videoElement.dataset && !videoElement.dataset.listenerAdded) {
4179       videoElement.addEventListener('error', (e) => {
4180         console.error('Video error:', e);
4181       });
4182
4183       // On end (non-looping clips only), tell Blazor to drop back to the poster so the finished clip shows its
4184       // cover image again instead of freezing on the last frame.
4185       videoElement.addEventListener('ended', () => {
4186         if (dotNetRef && !videoElement.loop) {
4187           dotNetRef.invokeMethodAsync('OnVideoEndedAsync');
4188         }
4189       });
4190
4191       videoElement.dataset.listenerAdded = 'true';
4192     }
4193
4194     // Guest preview gating: an unauthenticated viewer may watch only previewLimitSeconds before playback is
4195     // pinned. Once the limit is reached we pause the clip and call back into Blazor (OnPreviewLimitReachedAsync)
4196     // so it raises the join prompt. Wired once per element. A resume attempt past the limit pauses and re-prompts
4197     // (the dialog host guards against stacking), so the clip can never run past the preview window.
4198     const hasPreviewLimit = typeof previewLimitSeconds === 'number' && previewLimitSeconds > 0 && dotNetRef;
4199     if (hasPreviewLimit && videoElement.dataset && !videoElement.dataset.previewGateAdded) {
4200       videoElement.dataset.previewGateAdded = 'true';
4201
4202       videoElement.addEventListener('timeupdate', () => {
4203         if (videoElement.currentTime < previewLimitSeconds) {
4204           return;
4205         }
4206
4207         videoElement.pause();
4208
4209         // Latch so the burst of timeupdate events around the pause raises the prompt only once; it's cleared on
4210         // the next play (below) so a resume attempt past the limit re-prompts.
4211         if (!videoElement.dataset.previewPrompted) {
4212           videoElement.dataset.previewPrompted = 'true';
4213           dotNetRef.invokeMethodAsync('OnPreviewLimitReachedAsync');
4214         }
4215       });
4216
4217       videoElement.addEventListener('play', () => {
4218         delete videoElement.dataset.previewPrompted;
4219       });
4220     }
4221
4222     videoElement.play()
4223       .then(() => console.log('Video playing successfully'))
4224       .catch((e) => console.error('Error playing video:', e));
4225   };
4226
4227   // ── Autoplay feed coordinator ───────────────────────────────────────────────────────────────────────────
4228   // Only ONE feed clip plays at a time: the one whose wrapper is most in view. It floats when scrolled away.
4229   window.tkAutoplayFeed = (function () {
4230     const items = [];                 // { video, wrapper, ratio, closeLabel, floatFrame, userPaused, suppressPauseEvent, onPause, onPlay, onEnded }
4231     let activeVideo = null;
4232     let observer = null;
4233     let rafScheduled = false;
4234
4235     const findItem = (video) => {
4236       for (let i = 0; i < items.length; i++) {
4237         if (items[i].video === video) {
4238           return items[i];
4239         }
4240       }
4241       return null;
4242     };
4243
4244     const ensureObserver = () => {
4245       if (observer) {
4246         return;
4247       }
4248       // Several thresholds so the visibility ratio tracks smoothly as a clip scrolls through the viewport.
4249       const thresholds = [0, 0.25, 0.5, 0.6, 0.75, 1];
4250       observer = new IntersectionObserver((entries) => {
4251         for (let i = 0; i < entries.length; i++) {
4252           const entry = entries[i];
4253           for (let j = 0; j < items.length; j++) {
4254             if (items[j].wrapper !== entry.target) {
4255               continue;
4256             }
4257
4258             const item = items[j];
4259             let ratio = 0;
4260             if (entry.isIntersecting) {
4261               ratio = entry.intersectionRatio;
4262             }
4263             item.ratio = ratio;
4264
4265             if (ratio === 0 && item.userPaused) {
4266               item.userPaused = false;
4267             }
4268             break;
4269           }
4270         }
4271         schedule();
4272       }, { threshold: thresholds });
4273     };
4274
4275     // Coalesce bursts of intersection callbacks into one evaluate() per frame.
4276     const schedule = () => {
4277       if (rafScheduled) {
4278         return;
4279       }
4280       rafScheduled = true;
4281       window.requestAnimationFrame(() => {
4282         rafScheduled = false;
4283         evaluate();
4284       });
4285     };
4286
4287     const floatVideo = (item) => {
4288       if (item.floatFrame) {
4289         return;
4290       }
4291       if (item.video.paused || item.video.ended) {
4292         return;
4293       }
4294       if (document.pictureInPictureElement === item.video) {
4295         return;
4296       }
4297
4298       const frame = document.createElement('div');
4299       frame.className = composeVideoFloatFrameClass(item.video);
4300
4301       const closeButton = document.createElement('button');
4302       closeButton.type = 'button';
4303       closeButton.className = 'tk-video-float-frame-close';
4304       closeButton.setAttribute('aria-label', item.closeLabel || 'Close');
4305       closeButton.title = item.closeLabel || 'Close';
4306       closeButton.textContent = '✕';
4307       closeButton.addEventListener('click', (e) => {
4308         e.preventDefault();
4309         e.stopPropagation();
4310         try { item.video.pause(); } catch (err) { }
4311         dockVideo(item);
4312         if (activeVideo === item.video) {
4313           activeVideo = null;
4314         }
4315       });
4316
4317       // Append to body, not the feed card: an ancestor transform/filter/overflow would break position:fixed.
4318       frame.appendChild(item.video);
4319       frame.appendChild(closeButton);
4320       document.body.appendChild(frame);
4321       item.floatFrame = frame;
4322     };
4323
4324     const dockVideo = (item) => {
4325       if (!item.floatFrame) {
4326         return;
4327       }
4328       // Back as the wrapper's first child so the inline sibling rules (PiP button / placeholder) line up again.
4329       if (item.wrapper) {
4330         item.wrapper.insertBefore(item.video, item.wrapper.firstChild);
4331       }
4332       item.floatFrame.remove();
4333       item.floatFrame = null;
4334     };
4335
4336     const playItem = (item) => {
4337       dockVideo(item);
4338       if (item.userPaused) {
4339         return;
4340       }
4341       // Only on a (re)start: muting is what autoplay policy needs for play() to be allowed on a paused clip.
4342       // Doing it unconditionally re-muted a clip the viewer had unmuted from the native controls, on every
4343       // evaluate() the scroll produced.
4344       if (item.video.paused) {
4345         item.video.muted = true;
4346       }
4347       const playPromise = item.video.play();
4348       if (playPromise && playPromise.catch) {
4349         playPromise.catch(() => { });
4350       }
4351     };
4352
4353     const pauseItem = (item) => {
4354       try {
4355         if (!item.video.paused) {
4356           item.suppressPauseEvent = true;
4357           item.video.pause();
4358         }
4359       } catch (e) {
4360         item.suppressPauseEvent = false;
4361       }
4362       dockVideo(item);
4363     };
4364
4365     const evaluate = () => {
4366       // Require a solid majority in view, else the active clip thrashes between two half-visible neighbours.
4367       let best = null;
4368       for (let i = 0; i < items.length; i++) {
4369         const item = items[i];
4370         if (item.ratio >= 0.6) {
4371           if (!best || item.ratio > best.ratio) {
4372             best = item;
4373           }
4374         }
4375       }
4376
4377       if (best) {
4378         if (activeVideo !== best.video) {
4379           // Switch: pause + dock the previously active clip (removing its float), then play the new one.
4380           const previous = findItem(activeVideo);
4381           if (previous) {
4382             pauseItem(previous);
4383           }
4384           activeVideo = best.video;
4385         }
4386         playItem(best);
4387
4388         // Belt-and-braces: pause any other registered clip that somehow ended up playing.
4389         for (let i = 0; i < items.length; i++) {
4390           if (items[i].video !== activeVideo && !items[i].video.paused) {
4391             pauseItem(items[i]);
4392           }
4393         }
4394         return;
4395       }
4396
4397       // Mid-scroll between clips: keep the active one playing, but float it until the next clip takes over.
4398       const active = findItem(activeVideo);
4399       if (active && !active.video.paused) {
4400         floatVideo(active);
4401       }
4402     };
4403
4404     return {
4405       register: function (video, closeLabel) {
4406         if (!video || findItem(video)) {
4407           return;
4408         }
4409         const wrapper = video.closest('.video-wrapper') || video.parentElement;
4410         const item = {
4411           video: video,
4412           wrapper: wrapper,
4413           ratio: 0,
4414           closeLabel: closeLabel,
4415           floatFrame: null,
4416           userPaused: false,
4417           suppressPauseEvent: false,
4418           onPause: null,
4419           onPlay: null,
4420           onEnded: null
4421         };
4422
4423         item.onPause = () => {
4424           if (item.suppressPauseEvent) {
4425             item.suppressPauseEvent = false;
4426             return;
4427           }
4428           if (item.video.ended) {
4429             return;
4430           }
4431
4432           item.userPaused = true;
4433           dockVideo(item);
4434           if (activeVideo === item.video) {
4435             activeVideo = null;
4436           }
4437           schedule();
4438         };
4439
4440         item.onPlay = () => {
4441           item.userPaused = false;
4442         };
4443
4444         // Dock directly in JS when the clip ends: onPause above deliberately skips ended clips, so without this
4445         // the fixed float frame's removal would depend entirely on the OnVideoEndedAsync circuit round-trip — a
4446         // busy or dropped circuit left the frame orphaned over the page, swallowing clicks. The round-trip still
4447         // restores the poster and unregisters the item when it lands.
4448         item.onEnded = () => {
4449           dockVideo(item);
4450           // The item stays registered until the round-trip unregisters it; without this flag the next evaluate()
4451           // could pick the ended clip as best and playItem() would restart it from zero. A real user replay fires
4452           // 'play', which clears the flag via onPlay.
4453           item.userPaused = true;
4454           if (activeVideo === item.video) {
4455             activeVideo = null;
4456           }
4457           // No schedule() here: unregister already schedules when the round-trip lands, and forcing an immediate
4458           // re-evaluate at end-time buys nothing.
4459         };
4460
4461         video.addEventListener('pause', item.onPause);
4462         video.addEventListener('play', item.onPlay);
4463         video.addEventListener('ended', item.onEnded);
4464
4465         items.push(item);
4466         ensureObserver();
4467         if (wrapper) {
4468           observer.observe(wrapper);
4469         }
4470         schedule();
4471       },
4472       unregister: function (video) {
4473         const item = findItem(video);
4474         if (!item) {
4475           return;
4476         }
4477         // Dock before Blazor tears the component down, else the video is orphaned in <body> and keeps playing.
4478         dockVideo(item);
4479         video.removeEventListener('pause', item.onPause);
4480         video.removeEventListener('play', item.onPlay);
4481         video.removeEventListener('ended', item.onEnded);
4482         if (observer && item.wrapper) {
4483           observer.unobserve(item.wrapper);
4484         }
4485         const index = items.indexOf(item);
4486         if (index >= 0) {
4487           items.splice(index, 1);
4488         }
4489         if (activeVideo === video) {
4490           activeVideo = null;
4491         }
4492         schedule();
4493       }
4494     };
4495   })();
4496
4497   window.httpGet = async (url) => {
4498     const fetchCallback = async () => {
4499       const requestInit = {
4500         method: 'GET',
4501         headers: {
4502           'Is-Corproate-State': window.isCorporate() ? 'true' : 'false',
4503           'Is-Corporate-State': window.isCorporate() ? 'true' : 'false',
4504           'tokero-version': 'V5'
4505         },
4506         credentials: 'include',
4507       };
4508       const response = await fetch(url, requestInit);
4509
4510       return response;
4511     };
4512
4513     const httpWrapperResult = await window.httpWrapper(fetchCallback);
4514     return httpWrapperResult;
4515   }
4516
4517   window.httpPost = async (url, input) => {
4518     const fetchCallback = async () => {
4519       const requestInit = {
4520         method: 'POST',
4521         headers: {
4522           'Content-Type': 'application/json',
4523           'Is-Corproate-State': window.isCorporate() ? 'true' : 'false',
4524           'Is-Corporate-State': window.isCorporate() ? 'true' : 'false',
4525           'tokero-version': 'V5'
4526         },
4527         credentials: 'include',
4528       };
4529       if (input) {
4530         requestInit.body = JSON.stringify(input);
4531       }
4532
4533       const response = await fetch(url, requestInit);
4534
4535       return response;
4536     };
4537
4538     const httpWrapperResult = await window.httpWrapper(fetchCallback);
4539     return httpWrapperResult;
4540   }
4541
4542   window.isCorporate = function() {
4543     const urlPath = window.location.pathname;
4544     const segments = urlPath.split('/').filter(Boolean);
4545
4546     return segments.length >= 3 && segments[1] === 'corporate';
4547   }
4548
4549   window.getUserAgent = () => {
4550     return window.navigator.userAgent;
4551   };
4552
4553    window.copyToClipboard = async function (text, dotNetHelper, delayMS) {
4554       try {
4555           await navigator.clipboard.writeText(text);
4556
4557           if (dotNetHelper && delayMS > 0) {
4558               setTimeout(() => {
4559                   dotNetHelper.invokeMethodAsync("OnCopyCompleted");
4560               }, delayMS);
4561           }
4562       } catch (err) {
4563           console.error("Clipboard copy failed", err);
4564       }
4565   };
4566
4567   window.initializeFancybox = () => {
4568     if (window.Fancybox) {
4569       // TKR-10418: load the lightbox stylesheet on demand, only when fancybox actually binds. This
4570       // keeps it off the critical path entirely and skips the download completely on any circuit
4571       // that never wires a lightbox. Injected once into <head>, where it persists for the whole
4572       // circuit and styles any lightbox opened later. Guarded by id so repeat calls are no-ops.
4573       if (!document.getElementById("fancybox-css")) {
4574         const fancyboxCss = document.createElement("link");
4575         fancyboxCss.id = "fancybox-css";
4576         fancyboxCss.rel = "stylesheet";
4577         fancyboxCss.href = "https://cdn.tokero.com/content/utils/lightbox/fancybox.min.css";
4578         document.head.appendChild(fancyboxCss);
4579       }
4580       // TKR-10603: users read the default plain magnifier as "search" — put a plus in the lens on the Toolbar
4581       // plugin defaults so both binding paths inherit it; guarded so a future bundle swap degrades to the old icon.
4582       if (Fancybox.Plugins?.Toolbar?.defaults?.items?.zoom) {
4583         Fancybox.Plugins.Toolbar.defaults.items.zoom.html =
4584           '<svg viewBox="0 0 24 24"><circle cx="10" cy="10" r="7"></circle><path d="M10 7v6M7 10h6"></path><path d="M16 16 L21 21"></path></svg>';
4585       }
4586       Fancybox.bind(".lightbox");
4587     }
4588   };
4589
4590   window.setCookie = function (name, value, days = 30) {
4591      let expires = "";
4592      if (days) {
4593          let date = new Date();
4594          date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
4595          expires = "; expires=" + date.toUTCString();
4596      }
4597      document.cookie = name + "=" + encodeURIComponent(JSON.stringify(value)) + "; path=/" + expires;
4598    };
4599
4600    window.getCookie = function (name) {
4601      let nameEQ = name + "=";
4602      try {
4603        let ca = document.cookie.split(';');
4604        for (let i = 0; i < ca.length; i++) {
4605            let c = ca[i].trim();
4606            if (c.indexOf(nameEQ) === 0) {
4607                 try {
4608                   return decodeURIComponent(c.substring(nameEQ.length, c.length));
4609                 } catch (e) {
4610                     console.error("Cookie value could not be decoded", e);
4611                     return null;
4612                 }
4613            }
4614        }
4615      }
4616      catch(e){
4617        return null
4618      }
4619      return null;
4620    };
4621
4622    // Writes the value verbatim, unlike setCookie above which JSON.stringify's it — that one round-trips
4623    // only through a JSON.parse the getCookie below does not do, so a plain string written with it comes
4624    // back wrapped in quotes. Needed for cookies shared with the server, where the value has to be the exact
4625    // bytes ASP.NET Core wrote: the pending discount code (TKR-10902) is written by
4626    // PendingDiscountCodeMiddleware on a campaign-link hit and here when an anonymous visitor types a code,
4627    // and a quoted copy would simply not resolve. Attributes mirror the middleware's CookieOptions so the
4628    // two writers produce ONE cookie rather than a pair that differ only by path/SameSite.
4629    window.setRawCookie = function (name, value, days) {
4630        let expires = "";
4631        if (days) {
4632            let date = new Date();
4633            date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
4634            expires = "; expires=" + date.toUTCString();
4635        }
4636        document.cookie = name + "=" + encodeURIComponent(value) + "; path=/" + expires + "; SameSite=Lax; Secure";
4637    };
4638
4639    window.checkCookie = function (name) {
4640        return document.cookie.split(';').some((c) => c.trim().startsWith(name + "="));
4641    };
4642
4643    window.deleteCookie = function (name) {
4644        document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
4645    };
4646
4647      // The inline color-scheme has to move with the class. App.razor stamps it on <html> at SSR so the browser
4648      // knows which palette the document paints itself in before anything else loads, and an inline declaration
4649      // outranks the .socialfi-theme-* rules in App.css — so a switch that only swapped the class would leave the
4650      // browser on the previous palette: UA canvas, scrollbars and native form controls stuck on the old theme,
4651      // and a page still declared light getting force-darkened by a dark-mode WebView's auto-darkening.
4652      window.applySocialFiTheme = function (isDark) {
4653      var html = document.documentElement;
4654      if (isDark) {
4655        html.classList.add('socialfi-theme-dark');
4656        html.classList.remove('socialfi-theme-light');
4657        html.style.setProperty('color-scheme', 'dark');
4658      } else {
4659        html.classList.add('socialfi-theme-light');
4660        html.classList.remove('socialfi-theme-dark');
4661        html.style.setProperty('color-scheme', 'light');
4662      }
4663    };
4664
4665    // Toggles device-theme following (TKR-11206) across an in-circuit navigation — a Blazor route change between a
4666    // signed-out entry surface and a page with a palette of its own, which the pre-paint script in App.razor's head
4667    // cannot see. ON re-reads the media query and re-arms the listener; OFF detaches it, and the caller then pushes
4668    // the resolved palette through applySocialFiTheme above. Idempotent: always detached before being re-attached.
4669    window.setSocialFiDeviceThemeFollowing = function (isFollowing) {
4670      try {
4671        var darkQuery = window._tokeroDeviceThemeQuery;
4672        if (!darkQuery) {
4673          darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
4674          window._tokeroDeviceThemeQuery = darkQuery;
4675        }
4676
4677        if (window._tokeroDeviceThemeListener) {
4678          darkQuery.removeEventListener('change', window._tokeroDeviceThemeListener);
4679        }
4680
4681        if (!isFollowing) {
4682          return;
4683        }
4684
4685        window._tokeroDeviceThemeListener = function (e) { window.applySocialFiTheme(e.matches); };
4686        darkQuery.addEventListener('change', window._tokeroDeviceThemeListener);
4687        window.applySocialFiTheme(darkQuery.matches);
4688      } catch (e) { /* no matchMedia — whichever palette is on the document stands */ }
4689    };
4690
4691    window.hasHtmlClass = function (className) {
4692      return document.documentElement.classList.contains(className);
4693    };
4694
4695    // "Highlight dev internals" (TKR-11456). Delegated from document rather than bound per control, and driven
4696    // by a data attribute rather than an inline onclick, for three reasons: the toggle renders in three places
4697    // (the topbar burger menu, the marketing footer, the SocialFi auth footer), a MudMenu popover mounts and
4698    // unmounts its copy on every open, and an inline handler would be a script-src violation under the
4699    // report-only CSP the app is measuring before enforcing.
4700    //
4701    // It is JS rather than an @onclick because the work is a cookie write, which C# c
4701annot do after prerender
4702    // (no HttpContext on the circuit), and because this way the control works during prerender, on a static
4703    // render and after the circuit has dropped — the states a developer diagnosing a gate is most likely to be
4704    // looking at.
4705    //
4706    // The reload is not optional: DevInternalsMarker emits its marker element SERVER-side from the cookie, so
4707    // the markers for the gated blocks only exist in a document rendered after the cookie changed.
4708    // Wired at most once per document: a second listener would flip the cookie twice per click and the toggle
4709    // would look dead rather than broken.
4710    //
4711    // THIS BLOCK IS THE WHOLE HANDLER FOR THE SWITCH — DevInternalsHighlightToggle.razor renders markup and
4712    // nothing else, so deleting it here leaves a switch that renders, opens with the menu and does nothing at
4713    // all. TKR-11472 replaced it wholesale with the on-page-edit block below and it shipped dead for a day;
4714    // the two are neighbours by accident of position, not alternatives. Restored in TKR-11472 phase 2.
4715    if (!window._tokeroDevInternalsToggleWired) {
4716      window._tokeroDevInternalsToggleWired = true;
4717
4718      document.addEventListener('click', function (clickEvent) {
4719        var eventTarget = clickEvent.target;
4720        if (!eventTarget || !eventTarget.closest) {
4721          return;
4722        }
4723
4724        var toggleTrigger = eventTarget.closest('[data-tk-dev-internals-toggle]');
4725        if (!toggleTrigger) {
4726          return;
4727        }
4728
4729        clickEvent.preventDefault();
4730
4731        // Read exactly the way DevInternalsHighlightCookie reads it server-side — the whole value has to be
4732        // "1" — so the control and the server can never disagree about which state the click is leaving.
4733        // The name is the literal of CookiesConstants.Names.DevInternalsHighlight; keep the two in sync.
4734        var highlightCookieMatch = document.cookie.match(/(?:^|;\s*)tkr_dev_highlight=([^;]*)/);
4735        var isCurrentlyEnabled = false;
4736        if (highlightCookieMatch !== null && highlightCookieMatch[1] === '1') {
4737          isCurrentlyEnabled = true;
4738        }
4739
4740        // setRawCookie writes no Domain attribute, which is what scopes this to the exact hostname: the mode is
4741        // switched on for one review env without following the developer to staging or production. 400 days is
4742        // CookiesConstants.MaxBrowserCookieLifetimeDays, repeated here because JS cannot read the constant.
4743        if (isCurrentlyEnabled) {
4744          window.deleteCookie('tkr_dev_highlight');
4745        } else {
4746          window.setRawCookie('tkr_dev_highlight', '1', 400);
4747        }
4748
4749        window.location.reload();
4750      });
4751    }
4752
4753    // "On-page edit double-click" (TKR-11472). Two calls, driven from OnPageEditable: put the stored value
4754    // into the editing surface, and read back what was typed.
4755    //
4756    // It is JS at all because contenteditable is the browser's own rich-text editor — the thing that makes
4757    // Ctrl+B, paste-with-formatting, drag-to-move and undo work without a library — and Blazor has no binding
4758    // for it. Nothing here decides anything: which block, whether this viewer may edit it, and what is finally
4759    // written are all decided in C#.
4760    //
4761    // THE SURFACE IS THE ONLY ELEMENT EITHER FUNCTION TOUCHES, and it is an element Blazor deliberately renders
4762    // with no children, ever. That is not a convention, it is the fix for a circuit-killing bug: the first
4763    // version replaced the innerHTML of the page's own block, Blazor's virtual copy of those nodes went stale,
4764    // and the next render that touched the subtree patched nodes that no longer existed — which throws inside
4765    // the renderer, drops the circuit, and leaves a page where nothing at all is clickable. Never point these
4766    // at anything but the surface, and never let Blazor render children into it.
4767
4768    function resolveOnPageEditSurface(surfaceElementID) {
4769      var surfaceElement = document.getElementById(surfaceElementID);
4770      if (!surfaceElement) {
4771        return null;
4772      }
4773      return surfaceElement;
4774    }
4775
4776    // editableHtml is the column as it sits in the database, minus any <style> / <script> blocks, which C# held
4777    // back — see OnPageEditService.SplitProtectedBlocks. It is NOT what the page rendered.
4778    window.tokeroOnPageEditFill = function (surfaceElementID, editableHtml, isHtml) {
4779      var surfaceElement = resolveOnPageEditSurface(surfaceElementID);
4780      if (!surfaceElement) {
4781        return;
4782      }
4783
4784      if (isHtml) {
4785        surfaceElement.innerHTML = editableHtml;
4786      } else {
4787        surfaceElement.textContent = editableHtml;
4788      }
4789
4790      surfaceElement.focus();
4791    };
4792
4793    window.tokeroOnPageEditRead = function (surfaceElementID, isHtml) {
4794      var surfaceElement = resolveOnPageEditSurface(surfaceElementID);
4795      if (!surfaceElement) {
4796        return '';
4797      }
4798
4799      if (isHtml) {
4800        return surfaceElement.innerHTML;
4801      }
4802
4803      // innerText and not textContent: it collapses the whitespace the way the reader saw it, so a title typed
4804      // across a soft wrap is stored as one line rather than with the line break the DOM happens to hold.
4805      return surfaceElement.innerText;
4806    };
4807
4808    // Embeddable event widget. Called from the RSVP popup (the full event page opened with
4809    // ?embedRsvp=1) after a successful RSVP, to notify the parent widget iframe so it can flip its card button.
4810    // window.opener is the iframe that opened this popup; both are on the Tokero origin, so we post to that
4811    // exact origin (never '*'). Silently no-ops when there is no opener (page opened directly, not as a popup).
4812    window.tokeroEmbedNotifyRsvpSuccess = function (slug, status) {
4813      try {
4814        if (!window.opener || window.opener.closed) {
4815          return;
4816        }
4817        window.opener.postMessage(
4818          { type: 'tokero:event-rsvp', slug: slug, status: status },
4819          window.location.origin);
4820      } catch (e) {
4821        /* Cross-origin/opener access can throw in odd embedding setups — the RSVP itself already succeeded. */
4822      }
4823    };
4824
4825    // Live preview for the group brand accent: overrides the single brand CSS variable on <html> (inline
4826    // styles beat both :root and .socialfi-theme-dark). Empty value removes the override so the accent
4827    // falls back to the default --tk-brand-purple from App.css. Mirrors applySocialFiTheme above.
4828    // The "on" (legible text) and "soft" (tint) variables are NOT set here — App.css derives both off
4829    // --tk-brand-purple (color-mix for soft, an OKLCH auto-contrast pick for on), so the accent only ever
4830    // needs this one value. The .socialfi-branded marker tracks whether an accent is active: light mode
4831    // has no brand wiring of its own, so the .socialfi-theme-light.socialfi-branded rules in App.css key
4832    // off this class to route the primary surfaces (buttons, text-primary, links) to --tk-brand-purple.
4833    window.applySocialFiBrandColor = function (primary) {
4834      var html = document.documentElement;
4835      if (primary) {
4836        html.style.setProperty('--tk-brand-purple', primary);
4837        html.classList.add('socialfi-branded');
4838      } else {
4839        html.style.removeProperty('--tk-brand-purple');
4840        html.classList.remove('socialfi-branded');
4841      }
4842    };
4843
4844    // Resolves the element an id should hand focus to. The id is usually on the text control itself, but several
4845    // MudBlazor field variants put it on the wrapper instead, hence the nested lookup; an id on anything else
4846    // focusable (a tabindex'd div) is returned as-is. Null means the id is not in the DOM at all.
4847    function resolveFocusTargetById(id) {
4848      var baseElement = document.getElementById(id);
4849      if (!baseElement) {
4850        return null;
4851      }
4852
4853      if (baseElement instanceof HTMLInputElement || baseElement instanceof HTMLTextAreaElement) {
4854        return baseElement;
4855      }
4856
4857      var nestedInputElement = baseElement.querySelector("textarea, input");
4858      if (nestedInputElement) {
4859        return nestedInputElement;
4860      }
4861
4862      return baseElement;
4863    }
4864
4865    // Returns whether the element was found and focused, so callers can tell a missing element apart from an
4866    // interop failure (which surfaces as a thrown JSException on the .NET side) instead of guessing.
4867    window.focusElementById = function (id) {
4868      var focusTarget = resolveFocusTargetById(id);
4869      if (!focusTarget) {
4870        return false;
4871      }
4872
4873      focusTarget.scrollIntoView({ behavior: "smooth", block: "center" });
4874      focusTarget.focus();
4875      return true;
4876    };
4877
4878    // Focuses a post composer and rings its card with the fading highlight. Returns whether the input was actually
4879    // focused, so a caller that cares (the Blazor fallback below) can report a composer it could not find.
4880    //
4881    // Called from two places for the same tap, which is why it is a function and not inlined into the listener:
4882    // the native click listener wired below (synchronous, inside the gesture — the only path that can raise a
4883    // mobile keyboard) and, one SignalR round trip later, the button's Blazor handler as a fallback for taps that
4884    // beat the wiring. Both arriving is the normal fast path, so this has to be safe to run twice per tap.
4885    window.socialfiFocusComposerWithHighlight = function (composerCardId, composerInputId) {
4886      // Resolved per call rather than captured at wire-up time: a re-render can replace these nodes, and the
4887      // description field in particular is absent while the poll editor stands in for it.
4888      var didFocus = window.focusElementById(composerInputId);
4889      var composerCard = document.getElementById(composerCardId);
4890      if (!composerCard || !didFocus) {
4891        // No ring around a composer that was never focused — it would point the eye at the wrong thing.
4892        return didFocus;
4893      }
4894
4895      // Already ringing, which means the native listener handled this same tap and the Blazor fallback is now
4896      // arriving behind it. Leaving the running animation alone is the point: re-adding the class would restart
4897      // it and read as a second flash a fifth of a second after the first.
4898      if (composerCard.classList.contains("tk-composer-highlight")) {
4899        return didFocus;
4900      }
4901
4902      composerCard.classList.add("tk-composer-highlight");
4903
4904      // Matches the animation length in App.css. The animation already ends on a transparent ring, so this is
4905      // only about not leaving a spent class on the element.
4906      window.setTimeout(function () {
4907        composerCard.classList.remove("tk-composer-highlight");
4908      }, 1800);
4909
4910      return didFocus;
4911    };
4912
4913    // Wires a button so that clicking it focuses a post composer, from inside the browser's own click handler.
4914    //
4915    // This exists instead of doing the work from the button's Blazor OnClick because both consumer apps render
4916    // Interactive Server: an @onclick is a SignalR round trip, so any interop call it makes reaches the browser
4917    // well after the transient user-activation window opened by the tap has closed. iOS Safari and Android Chrome
4918    // only raise the soft keyboard for a .focus() that runs inside the gesture task itself, so a native listener
4919    // is the only place the keyboard can come up. Reordering the C# does not help, and a desktop browser cannot
4920    // show the difference because desktop focus does not depend on the gesture at all.
4921    //
4922    // Returns false when the button or the composer card is missing, so the caller can log a genuinely broken
4923    // path rather than silently wiring nothing. The description field is deliberately NOT part of that check:
4924    // it is legitimately absent while the poll editor replaces it, so treating it as a wire-up failure would
4925    // report a working page as broken. Whether the focus lands is reported per click instead, by the return of
4926    // socialfiFocusComposerWithHighlight to the Blazor fallback.
4927    window.socialfiWireComposerFocusOnClick = function (triggerId, composerCardId, composerInputId) {
4928      var triggerElement = document.getElementById(triggerId);
4929      var composerCardElement = document.getElementById(composerCardId);
4930      if (!triggerElement || !composerCardElement) {
4931        return false;
4932      }
4933
4934      // Blazor patches this button in place across re-renders, so the element (and this flag with it) survives;
4935      // the guard just keeps a repeated wire-up call from stacking duplicate listeners.
4936      if (triggerElement.dataset.composerFocusWired === "true") {
4937        return true;
4938      }
4939
4940      triggerElement.addEventListener("click", function () {
4941        // Synchronous on purpose: awaiting anything here, or deferring into a timeout, ends the gesture task and
4942        // the keyboard stays down.
4943        window.socialfiFocusComposerWithHighlight(composerCardId, composerInputId);
4944      });
4945
4946      triggerElement.dataset.composerFocusWired = "true";
4947      return true;
4948    };
4949
4950    // Raises the mobile soft keyboard for a search box that is not in the DOM yet.
4951    //
4952    // Same constraint as socialfiWireComposerFocusOnClick above — iOS Safari and Android Chrome only raise the
4953    // keyboard for a .focus() that runs inside the tap's own gesture task — but a harder version of it. That one
4954    // can focus its target from the click listener because the composer is already on the page. The search box is
4955    // not: it lives in ProfileSearchDialog, which the topbar's magnifier opens through a Blazor @onclick, i.e. a
4956    // SignalR round trip. At the moment of the tap there is nothing to focus, and by the time the dialog has
4957    // rendered the gesture window has closed, so focusing the box then only moves the caret — it looks active and
4958    // the phone shows no keyboard until it is tapped a second time.
4959    //
4960    // So the tap focuses a stand-in. A 1px, fully transparent text input is created and focused synchronously
4961    // inside the gesture, which brings the keyboard straight up; it stays up across the round trip, and the moment
4962    // the dialog's own input lands in the DOM the focus is handed over to it. Moving focus from one text input to
4963    // another needs no fresh gesture, so the keyboard never drops — it just ends up attached to the real box,
4964    // which is where the typing goes.
4965    //
4966    // Opt in from the markup by putting data-tk-primes-keyboard-for="<input id>" on the button that opens the
4967    // dialog; see the mobile magnifier in TopbarMenuSocialFi.razor. Nothing here is needed on a desktop, where
4968    // focus does not depend on the gesture at all — the box's own AutoFocus covers that.
4969    window.socialfiSearchKeyboardPrimer = (function () {
4970      // How long the stand-in holds the keyboard open waiting for the dialog. A round trip plus the dialog's first
4971      // render is tens of milliseconds on a usable connection; past a few seconds of an unusable one, a keyboard
4972      // hovering over a dialog that never opened is worse than no keyboard, so the stand-in blurs itself instead.
4973      var handoverTimeoutMs = 4000;
4974
4975      // The stand-in currently holding the keyboard, and the watcher waiting for the real box. Module state rather
4976      // than closure state because a second tap has to REPLACE both, not stack a second pair on top of them.
4977      var standInInput = null;
4978      var handoverObserver = null;
4979      var handoverGiveUpTimeoutId = 0;
4980
4981      function stopWatchingForSearchInput() {
4982        if (handoverObserver) {
4983          handoverObserver.disconnect();
4984          handoverObserver = null;
4985        }
4986
4987        if (handoverGiveUpTimeoutId) {
4988          window.clearTimeout(handoverGiveUpTimeoutId);
4989          handoverGiveUpTimeoutId = 0;
4990        }
4991      }
4992
4993      function removeStandInInput() {
4994        if (!standInInput) {
4995          return;
4996        }
4997
4998        var inputToRemove = standInInput;
4999        standInInput = null;
5000
5001        if (inputToRemove.parentNode) {
5002          inputToRemove.parentNode.removeChild(inputToRemove);
5003        }
5004      }
5005
5006      function createStandInInput() {
5007        var createdInput = document.createElement("input");
5008        // Plain text, matching the real search box rather than type="search": the keyboard raised here is the one
5009        // the user keeps, and a search input asks iOS for a different return key, which would visibly swap on the
5010        // handover.
5011        createdInput.type = "text";
5012        createdInput.setAttribute("autocomplete", "off");
5013        // Out of the tab order and out of the accessibility tree: this is a keyboard trick, not a form field, and
5014        // it is in the document for about one round trip.
5015        createdInput.setAttribute("tabindex", "-1");
5016        createdInput.setAttribute("aria-hidden", "true");
5017        // Focusable means actually rendered, so this has to be a real 1px box that is merely invisible —
5018        // display:none, visibility:hidden and a zero-size box are all skipped by the browsers' keyboard
5019        // heuristics, and so is anything moved off-screen far enough. position:fixed keeps it inside the viewport
5020        // so focusing it cannot scroll the feed away underneath the dialog that is about to cover it,
5021        // pointer-events:none keeps that 1px from swallowing a tap meant for what is behind it, and 16px is the
5022        // smallest font size iOS will focus without zooming the page in.
5023        createdInput.style.cssText = "position:fixed;top:8px;left:8px;width:1px;height:1px;padding:0;border:0;"
5024          + "opacity:0;background:transparent;font-size:16px;pointer-events:none;";
5025        document.body.appendChild(createdInput);
5026        return createdInput;
5027      }
5028
5029      // Hands the keyboard from the stand-in to the real search box. Returns whether the box had rendered yet.
5030      function tryHandOverToSearchInput(searchInputId) {
5031        var searchInput = resolveFocusTargetById(searchInputId);
5032        if (!searchInput) {
5033          return false;
5034        }
5035
5036        // Focused directly rather than through focusElementById, whose scrollIntoView is unwanted here: the box
5037        // is at the top of a dialog that just opened, so there is nothing to scroll to, and a smooth scroll while
5038        // the keyboard is animating in is exactly the jank iOS handles worst.
5039        //
5040        // The real box is focused FIRST and the stand-in dropped after: removing a focused element blurs it, and a
5041        // blur with nothing else focused is precisely what closes the keyboard.
5042        searchInput.focus();
5043        stopWatchingForSearchInput();
5044        removeStandInInput();
5045        return true;
5046      }
5047
5048      function primeKeyboardFor(searchInputId) {
5049        // A previous tap whose dialog never arrived leaves a stand-in and a watcher behind for its timeout to
5050        // clear. A new tap supersedes both.
5051        stopWatchingForSearchInput();
5052        removeStandInInput();
5053
5054        standInInput = createStandInInput();
5055        // Synchronous on purpose: awaiting anything here, or deferring into a timeout, ends the gesture task and
5056        // the keyboard stays down.
5057        standInInput.focus();
5058
5059        var handedOver = tryHandOverToSearchInput(searchInputId);
5060        if (handedOver) {
5061          return;
5062        }
5063
5064        // The dialog is a round trip away, so the box arrives as a DOM mutation rather than on any schedule.
5065        handoverObserver = new MutationObserver(function () {
5066          tryHandOverToSearchInput(searchInputId);
5067        });
5068        handoverObserver.observe(document.body, { childList: true, subtree: true });
5069
5070        handoverGiveUpTimeoutId = window.setTimeout(function () {
5071          stopWatchingForSearchInput();
5072
5073          if (standInInput) {
5074            standInInput.blur();
5075          }
5076
5077          removeStandInInput();
5078        }, handoverTimeoutMs);
5079      }
5080
5081      return {
5082        enable: function () {
5083          // The listener is delegated from the document and never removed, so wiring it twice would prime twice
5084          // per tap. Blazor re-invokes this from AfterRenderAsync far more often than the page is loaded.
5085          if (window.tkSearchKeyboardPrimingWired === true) {
5086            return;
5087          }
5088          window.tkSearchKeyboardPrimingWired = true;
5089
5090          // Delegated from the document, in the capture phase. Delegated because the buttons that open the dialog
5091          // are conditionally rendered and can appear on a later render than the first, so there is no render pass
5092          // at which they are all present to be wired — and because the listener can then be registered before any
5093          // of them exists, which removes the window in which an eager tap lands before the wiring. Capture so it
5094          // runs inside the tap's own gesture task, ahead of Blazor's own document-level click listener.
5095          document.addEventListener("click", function (clickEvent) {
5096            var clickedElement = clickEvent.target;
5097            if (!clickedElement || typeof clickedElement.closest !== "function") {
5098              return;
5099            }
5100
5101            var primingTrigger = clickedElement.closest("[data-tk-primes-keyboard-for]");
5102            if (!primingTrigger) {
5103              return;
5104            }
5105
5106            var searchInputId = primingTrigger.getAttribute("data-tk-primes-keyboard-for");
5107            if (!searchInputId) {
5108              return;
5109            }
5110
5111            primeKeyboardFor(searchInputId);
5112          }, true);
5113        }
5114      };
5115    })();
5116
5117    // Closes the mobile soft keyboard when the user starts scrolling a search results list.
5118    //
5119    // The counterpart to socialfiSearchKeyboardPrimer above: that one raises the keyboard for the search box the
5120    // magnifier opens, this one gets it out of the way once the user is done typing and starts reading. On a phone
5121    // the keyboard covers roughly half the dialog, so the results the search just produced are scrolled behind it;
5122    // the reflex is to swipe the list, and every other app answers that swipe by dismissing the keyboard.
5123    //
5124    // It is a native listener rather than a Blazor @ontouchmove for the same reason as the primer: an @ontouchmove
5125    // is a SignalR round trip per touch event, which is both far too chatt
5125y for a scroll gesture and too late to
5126    // feel connected to it. Nothing here is needed on a desktop, and nothing here runs there — touch events are
5127    // the only trigger, so a mouse wheel or a scrollbar drag leaves focus exactly where it was.
5128    //
5129    // Enable it from the component that owns the box, passing the box's id: see ProfileSearchDialog.razor.
5130    window.socialfiSearchKeyboardScrollDismiss = (function () {
5131      // How far the finger must travel vertically before this counts as a scroll rather than a tap. A tap on a
5132      // result row carries a few pixels of jitter, and dismissing the keyboard on that would fight the tap it is
5133      // part of; a deliberate swipe passes this within the first frame or two, so the dismissal still reads as
5134      // instant.
5135      var dismissAfterVerticalDragPx = 12;
5136
5137      // The box whose keyboard this dismisses, and the drag being measured. Module state because the listeners are
5138      // delegated from the document and wired once, while the dialog holding the box comes and goes.
5139      var watchedSearchInputId = "";
5140      var isDragBeingMeasured = false;
5141      var dragStartY = 0;
5142
5143      function onTouchStart(touchEvent) {
5144        isDragBeingMeasured = false;
5145
5146        if (!watchedSearchInputId) {
5147          return;
5148        }
5149
5150        var firstTouch = touchEvent.touches[0];
5151        if (!firstTouch) {
5152          return;
5153        }
5154
5155        // A drag that begins on a text field is a text selection, not a scroll — including one on the search box
5156        // itself, where dismissing the keyboard would take away the field the user is still working in.
5157        var touchedElement = touchEvent.target;
5158        if (touchedElement && typeof touchedElement.closest === "function") {
5159          var touchedTextField = touchedElement.closest("input, textarea, [contenteditable='true']");
5160          if (touchedTextField) {
5161            return;
5162          }
5163        }
5164
5165        dragStartY = firstTouch.clientY;
5166        isDragBeingMeasured = true;
5167      }
5168
5169      function onTouchMove(touchEvent) {
5170        if (!isDragBeingMeasured) {
5171          return;
5172        }
5173
5174        var firstTouch = touchEvent.touches[0];
5175        if (!firstTouch) {
5176          return;
5177        }
5178
5179        var verticalDragPx = Math.abs(firstTouch.clientY - dragStartY);
5180        if (verticalDragPx < dismissAfterVerticalDragPx) {
5181          return;
5182        }
5183
5184        // One dismissal per drag: the rest of this gesture's touchmove events have nothing left to do, and
5185        // re-checking on each of them would only re-blur an already-blurred box.
5186        isDragBeingMeasured = false;
5187
5188        // Resolved through the shared helper, not getElementById: MudTextField puts the Id we are given on its
5189        // wrapper, so the element that actually holds focus is the <input> nested inside it.
5190        var searchInput = resolveFocusTargetById(watchedSearchInputId);
5191        if (!searchInput) {
5192          return;
5193        }
5194
5195        // Only ever blurs the box this was enabled for, and only while it actually holds focus. That is 
5195what makes
5196        // a permanent document-level listener harmless: with the dialog closed, or the caret anywhere else, this
5197        // does nothing at all.
5198        if (document.activeElement !== searchInput) {
5199          return;
5200        }
5201
5202        searchInput.blur();
5203      }
5204
5205      return {
5206        enable: function (searchInputId) {
5207          if (!searchInputId) {
5208            return;
5209          }
5210
5211          watchedSearchInputId = searchInputId;
5212
5213          // The listeners are delegated from the document and never removed, so wiring them twice would measure
5214          // every drag twice. Blazor re-invokes this from AfterRenderAsync far more often than the page is loaded.
5215          if (window.tkSearchScrollKeyboardDismissWired === true) {
5216            return;
5217          }
5218          window.tkSearchScrollKeyboardDismissWired = true;
5219
5220          // Passive because this never calls preventDefault: the browser must keep scrolling the list at native
5221          // speed while the keyboard comes down. Capture so the measurement is not lost to a handler further down
5222          // that stops propagation.
5223          document.addEventListener("touchstart", onTouchStart, { passive: true, capture: true });
5224          document.addEventListener("touchmove", onTouchMove, { passive: true, capture: true });
5225        }
5226      };
5227    })();
5228
5229  window.socialfiCommentMentions = {
5230    getActiveQuery: function (id, triggerChar) {
5231      var minimumMentionQueryLength = 3;
5232
5233      if (!triggerChar) {
5234        triggerChar = "@";
5235      }
5236
5237      if (!id) {
5238        return null;
5239      }
5240
5241      var baseElement = document.getElementById(id);
5242      if (!baseElement) {
5243        return null;
5244      }
5245
5246      var inputElement = baseElement;
5247      if (!(inputElement instanceof HTMLInputElement) && !(inputElement instanceof HTMLTextAreaElement)) {
5248        var nestedInputElement = baseElement.querySelector("textarea, input");
5249        if (!nestedInputElement) {
5250          return null;
5251        }
5252
5253        inputElement = nestedInputElement;
5254      }
5255
5256      var currentValue = inputElement.value || "";
5257      var caretPosition = currentValue.length;
5258      if (typeof inputElement.selectionStart === "number") {
5259        caretPosition = inputElement.selectionStart;
5260      }
5261
5262      var textBeforeCaret = currentValue.substring(0, caretPosition);
5263      var mentionStartIndex = textBeforeCaret.lastIndexOf(triggerChar);
5264      if (mentionStartIndex < 0) {
5265        return null;
5266      }
5267
5268      var prefixBeforeMention = "";
5269      if (mentionStartIndex > 0) {
5270        prefixBeforeMention = textBeforeCaret.substring(mentionStartIndex - 1, mentionStartIndex);
5271      }
5272
5273      if (/[A-Za-z0-9_]/.test(prefixBeforeMention)) {
5274        return null;
5275      }
5276
5277      var mentionQuery = textBeforeCaret.substring(mentionStartIndex + 1);
5278      if (/[^A-Za-z0-9._-]/.test(mentionQuery)) {
5279        return null;
5280      }
5281
5282      if (mentionQuery.length < minimumMentionQueryLength) {
5283        return null;
5284      }
5285
5286      return {
5287        startIndex: mentionStartIndex,
5288        endIndex: caretPosition,
5289        query: mentionQuery,
5290        triggerChar: triggerChar
5291      };
5292    },
5293
5294    setCaretPosition: function (id, position) {
5295      if (!id) {
5296        return;
5297      }
5298
5299      var baseElement = document.getElementById(id);
5300      if (!baseElement) {
5301        return;
5302      }
5303
5304      var inputElement = baseElement;
5305      if (!(inputElement instanceof HTMLInputElement) && !(inputElement instanceof HTMLTextAreaElement)) {
5306        var nestedInputElement = baseElement.querySelector("textarea, input");
5307        if (!nestedInputElement) {
5308          return;
5309        }
5310
5311        inputElement = nestedInputElement;
5312      }
5313
5314      inputElement.focus();
5315
5316      if (typeof inputElement.setSelectionRange === "function") {
5317        inputElement.setSelectionRange(position, position);
5318      }
5319    }
5320  };
5321
5322  // Keyboard docking for the comment composer (SocialCommentComposer.razor): while the field has focus, the
5323  // composer leaves the post card and pins itself, with its action row, above the soft keyboard - otherwise it is
5324  // scrolled away with the card or covered outright and people type blind.
5325  //
5326  // Phones only, gated twice because neither gate is sufficient alone:
5327  //   1. The caller - SocialCommentComposer attaches only when UserAgentHelper.IsMobileSurface says so. That keeps
5328  //      a desktop window dragged narrow out, which the viewport test alone would read as a keyboard.
5329  //   2. The keyboard - handleViewportChanged waits for the visual viewport to actually lose height. That keeps
5330  //      DEVICE EMULATION out: it fakes the user agent, pointer, screen size and pixel ratio, but not a keyboard.
5331  //
5332  // Hence no viewport-size or pointer-type test anywhere here or in the CSS: everywhere the two gates do not both
5333  // hold, the composer renders exactly as it did before this existed.
5334  window.socialfiComposerKeyboardDock = (function () {
5335    // focusedComposerElement is the composer the caret is in; dockedComposerElement is the one currently lifted
5336    // above the keyboard. Separate because focus alone does NOT dock - see the test in handleViewportChanged.
5337    var focusedComposerElement = null;
5338    var dockedComposerElement = null;
5339    var pendingUndockTimeoutId = 0;
5340    var areViewportListenersWired = false;
5341
5342    // Tallest visual viewport seen at the current orientation, and the width it was seen at. Only a keyboard takes
5343    // a large bite out of the viewport, so "well below the tallest" answers "is the keyboard up" - and it is the
5344    // only form of that question device emulation cannot fake.
5345    var tallestViewportHeightInPixels = 0;
5346    var tallestViewportWidthInPixels = 0;
5347
5348    // A viewport that loses more than this is showing a keyboard - clear of the 40-60px collapsing URL bar.
5349    var minimumKeyboardHeightInPixels = 120;
5350
5351    // Grace period before the panel drops back into the card: a tap on a control blurs the text field before the
5352    // click is dispatched, and undocking in between would move the button out from under the finger.
5353    var undockGraceInMilliseconds = 160;
5354
5355    function computeKeyboardInsetInPixels() {
5356      var viewport = window.visualViewport;
5357      if (!viewport) {
5358        return 0;
5359      }
5360
5361      // Where the keyboard shrinks only the visual viewport (iOS Safari, Chrome's default) this is its height.
5362      // Where it shrinks the layout viewport too, innerHeight shrank with it, the difference is 0, and bottom:0
5363      // is already right.
5364      var keyboardInset = window.innerHeight - viewport.height - viewport.offsetTop;
5365      if (keyboardInset < 0) {
5366        keyboardInset = 0;
5367      }
5368
5369      return keyboardInset;
5370    }
5371
5372    function applyKeyboardInset() {
5373      if (!dockedComposerElement) {
5374        return;
5375      }
5376
5377      var keyboardInset = computeKeyboardInsetInPixels();
5378      dockedComposerElement.style.setProperty("--tk-composer-keyboard-inset", keyboardInset + "px");
5379    }
5380
5381    // While the panel is docked the page behind it must not scroll. The panel is pinned to the keyboard rather
5382    // than to the page, so a background that scrolls slides the post and the thread out from behind the comment
5383    // being written while the composer itself stays put - and on iOS the drag also carries Safari's own scroll
5384    // anchoring for the focused field, which lands the caret somewhere nobody asked for.
5385    //
5386    // Cancelling the touch gesture, rather than `overflow: hidden` on <html>: document-element overflow is the
5387    // one lock iOS Safari has never honoured consistently, and where it is honoured it clamps the scroll offset
5388    // and jumps the page as the class lands. It also has to stay a gesture-level lock because the composer's own
5389    // scrolling must survive - the field grows to 6rem and then scrolls, and the mention list scrolls - which
5390    // rules out `touch-action: none` on an ancestor, since the effective value is the intersection down the tree
5391    // and a descendant cannot opt back in. A touch that starts inside the composer is let through instead.
5392    //
5393    // Programmatic scrolling is deliberately left working: socialfiReplyComposer.focusInputById keeps calling
5394    // scrollIntoView for up to 1.2s after focus to bring the thread above the keyboard, and preventDefault on a
5395    // gesture does not touch it.
5396    var isBackgroundScrollLocked = false;
5397
5398    function handleDocumentTouchMove(event) {
5399      // Cheap first: a leaked listener must never be able to freeze the page once nothing is docked.
5400      if (!dockedComposerElement) {
5401        return;
5402      }
5403
5404      // Two fingers is a pinch, not a background scroll. Cancelling it would take zoom away from somebody who is
5405      // only trying to read the post they are answering.
5406      if (event.touches && event.touches.length > 1) {
5407        return;
5408      }
5409
5410      var touchedElement = event.target;
5411      if (touchedElement && typeof touchedElement.closest === "function") {
5412        // Three things are not "the page behind the panel". The composer keeps its own scrolling, and a dialog or
5413        // a popover is a layer ABOVE the panel: the photo editor opens from the attach button, whose mousedown is
5414        // cancelled by handleComposerMouseDown to keep the caret - so the field never blurs, the panel stays
5415        // docked for as long as the dialog is up, and a lock that ignored it would leave the editor unscrollable.
5416        var exemptElement = touchedElement.closest(
5417          ".social-comment-composer, .mud-dialog-container, .mud-popover");
5418        if (exemptElement) {
5419          return;
5420        }
5421      }
5422
5423      // A gesture already claimed by the browser (a scroll it started before this listener saw it) is not
5424      // cancelable, and calling preventDefault would only log an intervention warning.
5425      if (!event.cancelable) {
5426        return;
5427      }
5428
5429      event.preventDefault();
5430    }
5431
5432    function lockBackgroundScroll() {
5433      if (isBackgroundScrollLocked) {
5434        return;
5435      }
5436
5437      // Non-passive on purpose: a passive listener is not allowed to cancel the scroll, which is the whole point.
5438      document.addEventListener("touchmove", handleDocumentTouchMove, { passive: false });
5439      isBackgroundScrollLocked = true;
5440    }
5441
5442    function unlockBackgroundScroll() {
5443      if (!isBackgroundScrollLocked) {
5444        return;
5445      }
5446
5447      document.removeEventListener("touchmove", handleDocumentTouchMove);
5448      isBackgroundScrollLocked = false;
5449    }
5450
5451    // A rotation changes the width and legitimately shrinks the height, so the portrait maximum has to be
5452    // forgotten - kept, it would read landscape as a keyboard that never closes.
5453    function trackTallestViewport(viewport) {
5454      if (viewport.width !== tallestViewportWidthInPixels) {
5455        tallestViewportWidthInPixels = viewport.width;
5456        tallestViewportHeightInPixels = 0;
5457      }
5458
5459      if (viewport.height > tallestViewportHeightInPixels) {
5460        tallestViewportHeightInPixels = viewport.height;
5461      }
5462    }
5463
5464    function isKeyboardOpen(viewport) {
5465      var lostViewportHeight = tallestViewportHeightInPixels - viewport.height;
5466      if (lostViewportHeight > minimumKeyboardHeightInPixels) {
5467        return true;
5468      }
5469
5470      return false;
5471    }
5472
5473    function handleViewportChanged() {
5474      var viewport = window.visualViewport;
5475      if (!viewport) {
5476        return;
5477      }
5478
5479      trackTallestViewport(viewport);
5480
5481      var keyboardIsOpen = isKeyboardOpen(viewport);
5482      if (keyboardIsOpen && focusedComposerElement) {
5483        // Covers the first open and every later change alike (emoji panel, accessory bar, pinned-zoom scroll):
5484        // dock() re-applies the inset when handed the composer that is already docked.
5485        dock(focusedComposerElement);
5486        return;
5487      }
5488
5489      // Keyboard gone. On Android the field keeps focus when it is dismissed with the back gesture, so focusout
5490      // never fires and this is the only signal that the panel belongs back in the card.
5491      if (!keyboardIsOpen && dockedComposerElement) {
5492        undock(dockedComposerElement);
5493      }
5494    }
5495
5496    // Wired on the first attach and never removed: the listeners sit on window.visualViewport, not on anything
5497    // Blazor owns, and the handler is a no-op while no composer has focus. Attach time rather than focus time
5498    // because the tallest-viewport baseline has to be measured with no keyboard up.
5499    function wireViewportListeners() {
5500      if (areViewportListenersWired) {
5501        return;
5502      }
5503
5504      var viewport = window.visualViewport;
5505      if (!viewport) {
5506        return;
5507      }
5508
5509      trackTallestViewport(viewport);
5510      viewport.addEventListener("resize", handleViewportChanged);
5511      viewport.addEventListener("scroll", handleViewportChanged);
5512      areViewportListenersWired = true;
5513    }
5514
5515    function dock(composerElement) {
5516      if (dockedComposerElement === composerElement) {
5517        applyKeyboardInset();
5518        return;
5519      }
5520
5521      if (dockedComposerElement) {
5522        undock(dockedComposerElement);
5523      }
5524
5525      // Hold the space the composer took inside the card, or the comment list jumps up the instant the shell goes
5526      // fixed and jumps back on undock. Measured before the class is added, so it is the in-card height.
5527      var reservedHeightInPixels = composerElement.offsetHeight;
5528      composerElement.style.height = reservedHeightInPixels + "px";
5529      composerElement.classList.add("social-comment-composer--docked");
5530
5531      dockedComposerElement = composerElement;
5532      applyKeyboardInset();
5533      lockBackgroundScroll();
5534    }
5535
5536    function undock(composerElement) {
5537      window.clearTimeout(pendingUndockTimeoutId);
5538      pendingUndockTimeoutId = 0;
5539
5540      composerElement.classList.remove("social-comment-composer--docked");
5541      composerElement.style.removeProperty("height");
5542      composerElement.style.removeProperty("--tk-composer-keyboard-inset");
5543
5544      if (dockedComposerElement === composerElement) {
5545        dockedComposerElement = null;
5546      }
5547
5548      // Guarded on nothing being docked, and placed after the field is cleared above: dock() undocks the outgoing
5549      // composer before adopting the incoming one, and both happen in the same turn - so a swap releases and
5550      // re-arms with no gesture in between, while a real undock is the only thing that leaves the page free.
5551      if (!dockedComposerElement) {
5552        unlockBackgroundScroll();
5553      }
5554    }
5555
5556    function scheduleUndock(composerElement) {
5557      window.clearTimeout(pendingUndockTimeoutId);
5558      pendingUndockTimeoutId = window.setTimeout(function () {
5559        // Focus moved within the composer (attach button, mention suggestion), so the person is still writing.
5560        var focusedElement = document.activeElement;
5561        if (focusedElement && composerElement.contains(focusedElement)) {
5562          return;
5563        }
5564
5565        if (focusedComposerElement === composerElement) {
5566          focusedComposerElement = null;
5567        }
5568
5569        undock(composerElement);
5570      }, undockGraceInMilliseconds);
5571    }
5572
5573    function handleComposerFocusIn(event) {
5574      var composerElement = event.currentTarget;
5575
5576      window.clearTimeout(pendingUndockTimeoutId);
5577      pendingUndockTimeoutId = 0;
5578      focusedComposerElement = composerElement;
5579
5580      var viewport = window.visualViewport;
5581      if (!viewport) {
5582        return;
5583      }
5584
5585      trackTallestViewport(viewport);
5586
5587      // Nothing docks on focus by itself: on a phone the keyboard animates in and handleViewportChanged docks the
5588      // instant it has, and on a desktop it never arrives - which is the case this ordering exists to leave alone.
5589      // The exception is focus arriving while the keyboard is ALREADY up, where no further viewport change comes.
5590      var keyboardIsOpen = isKeyboardOpen(viewport);
5591      if (keyboardIsOpen) {
5592        dock(composerElement);
5593      }
5594    }
5595
5596    function handleComposerFocusOut(event) {
5597      var composerElement = event.currentTarget;
5598      scheduleUndock(composerElement);
5599    }
5600
5601    // Keeps the caret - and with it the keyboard - in place when anything but the text field is tapped inside the
5602    // docked panel; otherwise the field blurs on mousedown and the panel slides out from under the finger before
5603    // the click lands. Same trick as the mention list's @onmousedown:preventDefault, and just as free for buttons.
5604    function handleComposerMouseDown(event) {
5605      var composerElement = event.currentTarget;
5606      if (!composerElement.classList.contains("social-comment-composer--docked")) {
5607        return;
5608      }
5609
5610      var tappedElement = event.target;
5611      if (!tappedElement || typeof tappedElement.closest !== "function") {
5612        return;
5613      }
5614
5615      var textEntryElement = tappedElement.closest("input, textarea, [contenteditable=\"true\"]");
5616      if (textEntryElement) {
5617        return;
5618      }
5619
5620      event.preventDefault();
5621    }
5622
5623    return {
5624      // Returns whether the composer was found, so a broken wire-up can be logged rather than silently ignored.
5625      attach: function (composerElementId) {
5626        if (!composerElementId) {
5627          return false;
5628        }
5629
5630        var composerElement = document.getElementById(composerElementId);
5631        if (!composerElement) {
5632          return false;
5633        }
5634
5635        // Blazor patches this element in place across re-renders, so the listeners and this flag survive; the
5636        // guard only keeps a repeated attach from stacking duplicates.
5637        if (composerElement.dataset.keyboardDockWired === "true") {
5638          return true;
5639        }
5640
5641        composerElement.addEventListener("focusin", handleComposerFocusIn);
5642        composerElement.addEventListener("focusout", handleComposerFocusOut);
5643        composerElement.addEventListener("mousedown", handleComposerMouseDown);
5644        composerElement.dataset.keyboardDockWired = "true";
5645
5646        // Here rather than at first focus: the earliest point the viewport is known to be at its full, no-keyboard
5647        // height, which is the baseline every later "is the keyboard up" answer is compared against.
5648        wireViewportListeners();
5649        return true;
5650      },
5651
5652      detach: function (composerElementId) {
5653        if (!composerElementId) {
5654          return;
5655        }
5656
5657        var composerElement = document.getElementById(composerElementId);
5658        if (!composerElement) {
5659          return;
5660        }
5661
5662        composerElement.removeEventListener("focusin", handleComposerFocusIn);
5663        composerElement.removeEventListener("focusout", handleComposerFocusOut);
5664        composerElement.removeEventListener("mousedown", handleComposerMouseDown);
5665        delete composerElement.dataset.keyboardDockWired;
5666
5667        if (focusedComposerElement === composerElement) {
5668          focusedComposerElement = null;
5669        }
5670
5671        // Also clears the inline height and the class, so a composer disposed while docked does not leave a fixed
5672        // panel behind on the next page.
5673        undock(composerElement);
5674      }
5675    };
5676  })();
5677
5678    function arrayBufferToBase64(blob) {
5679    return new Promise((resolve, reject) => {
5680      let reader = new FileReader();
5681      reader.readAsDataURL(blob);
5682      reader.onloadend = () => {
5683          let base64String = reader.result.split(',')[1]; // Remove the Data URL prefix
5684          resolve(base64String);
5685      };
5686      reader.onerror = reject;
5687    });
5688  }
5689
5690   async function downloadFileFromStream(fileName, contentStreamReference, mimeType = 'application/pdf') {
5691     //TODO: see if we can use only this and remove the downloadFileFromStream from the Lottie component
5692     const arrayBuffer = await contentStreamReference.arrayBuffer();
5693     const blob = new Blob([arrayBuffer]);
5694
5695
5696     if (isMobileApp() && window.ReactNativeWebView !== undefined) {
5697       const base64data = await arrayBufferToBase64(blob);
5698
5699       //call like it is on react => handleDownloadDiploma
5700       window.ReactNativeWebView?.postMessage(
5701         JSON.stringify({
5702           type: 'downloadFile',
5703           data: base64data,
5704           fileName: fileName,
5705           mimeType,
5706         })
5707       );
5708     } else {
5709
5710       const url = URL.createObjectURL(blob);
5711       const anchorElement = document.createElement('a');
5712       anchorElement.href = url;
5713       anchorElement.download = fileName ?? '';
5714       anchorElement.click();
5715       anchorElement.remove();
5716       URL.revokeObjectURL(url);
5717     }
5718   }
5719
5720   window.tokeroUserAgents = {
5721     android: 'tokero-android-app',
5722     ios: 'tokero-ios-app',
5723     v36: 'LDVMobileApp',
5724     expo: 'tokero-expo-app',
5725     tokeroandroid: 'tokero-android',
5726     tokeroios: 'tokero-ios',
5727     app: 'tokero-app',
5728     blogwebview: 'tokero-blog'
5729   };
5730
5731     window.isMobileBrowser = () => {
5732      const userAgent = navigator.userAgent;
5733      const mobileBrowser = userAgent.includes('Android') || userAgent.includes('Iphone') || userAgent.includes('iPhone');
5734      return mobileBrowser;
5735     }
5736
5737      window.isIOSMobileApp = () => {
5738      const userAgent = navigator.userAgent;
5739      const IOSmobileApp = userAgent.includes('tokero-ios-app');
5740      return IOSmobileApp;
5741     }
5742
5743     window.isMobileBrowserAndroid = () => {
5744      const userAgent = navigator.userAgent;
5745      const mobileBrowser = userAgent.includes('Android');
5746      return mobileBrowser;
5747     }
5748
5749     window.isMobileBrowserIos = () => {
5750      const userAgent = navigator.userAgent;
5751      const mobileBrowser = userAgent.includes('Iphone') || userAgent.includes('iPhone') || userAgent.includes('Ipad') || userAgent.includes('iPad') || userAgent.includes('Ipod') || userAgent.includes('iPod');
5752      // iPadOS 13+ requests desktop-class sites by default and reports a Macintosh user agent with no
5753      // 'iPad' in it, so the string test above misses those. A real Mac reports maxTouchPoints 0; an
5754      // iPad reports 5. Without this an iPad reads as a desktop, which is how the OAuth buttons ended
5755      // up calling window.open inside an ASWebAuthenticationSession sheet that silently drops popups.
5756      const desktopClassIpad = userAgent.includes('Macintosh') && navigator.maxTouchPoints > 1;
5757      return mobileBrowser || desktopClassIpad;
5758     }
5759
5760     /* Stamps the visitor's platform on <html> as a single tk-os-* class, so CSS can show exactly the install
5761        affordance they can act on — a store badge on the phone that will install the app, a QR on the machine
5762        that cannot install it but can be scanned from one. Used by CommunityAppsLanding's store row and by the
5763        CommunityAppInstallQrCode component.
5764
5765        Here rather than in a per-page <script> for two reasons. It is stamped on the <html> element, which
5766        survives every Blazor enhanced navigation, so one pass at document load answers the question for the
5767        whole session — whereas a script inside a routed page is NOT executed when that page arrives through
5768        enhanced navigation, and the class would simply never appear. And it is decided in the browser rather
5769        than on the server because the pages that read it are served from the anonymous output cache: one
5770        response is reused for every visitor, so a User-Agent branch on the server would let the first visitor
5771        of the TTL window decide what every later one sees.
5772
5773        Runs at load, immediately after the helpers above are defined — this is a plain blocking script, so the
5774        class is in place before anything that reads it can run. Detection goes through those shared helpers
5775        rather than a private copy of the same regexes: they already carry the cases that catch people out,
5776        notably that iPadOS 13+ asks for desktop-class sites and reports a "Macintosh" UA with no "iPad" in it,
5777        distinguishable only by having touch points. */
5778     (function stampPlatformOnDocumentElement() {
5779       let platformClass = 'tk-os-desktop';
5780       try {
5781         /* Android first: an Android user agent can also carry "like Mac OS X" wording, and no device is both. */
5782         if (window.isMobileBrowserAndroid()) {
5783           platformClass = 'tk-os-android';
5784         } else if (window.isMobileBrowserIos()) {
5785           platformClass = 'tk-os-ios';
5786         } else if (window.isMobileBrowser()) {
5787           platformClass = 'tk-os-mobile';
5788         }
5789       } catch (platformDetectionError) {
5790         /* Fail towards the arrangement that works everywhere: both store badges are ordinary links a reader
5791            can pick between, while a QR drawn on a phone would be useless. Never leave them with no way in. */
5792         platformClass = 'tk-os-mobile';
5793       }
5794       document.documentElement.classList.add(platformClass);
5795     })();
5796
5797    window.isMobileApp = () => {
5798     const userAgent = navigator.userAgent;
5799
5800     const knownUA = Object.values(window.tokeroUserAgents || {});
5801     const hasKnownMobileAppUserAgent = knownUA.some(ua => userAgent.includes(ua));
5802
5803     if (hasKnownMobileAppUserAgent) {
5804       return true;
5805     }
5806
5807     // if there is no custom user agent, is not mobile app
5808     return false;
5809   };
5810
5811   window.setMobileStatusBarColor = (color, textStyle) => {
5812     if (!window.ReactNativeWebView || !window.isMobileApp || !window.isMobileApp()) return;
5813     if (!color) return;
5814     window.ReactNativeWebView.postMessage(JSON.stringify({
5815       type: 'setStatusBarColor',
5816       color: color,
5817       textStyle: textStyle === 'light' ? 'light' : 'dark'
5818     }));
5819   };
5820
5821   window.resetMobileStatusBar = () => {
5822     if (!window.ReactNativeWebView || !window.isMobileApp || !window.isMobileApp()) return;
5823     window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'resetStatusBar' }));
5824   };
5825
5826   // Switches the shell's swipe-down refresh gesture off while a drag surface is open (the photo cropper).
5827   // On Android the WebView sits inside a SwipeRefreshLayout, which decides in onInterceptTouchEvent —
5828   // natively, before the WebView ever sees the move — whether a downward drag is a refresh, and its test is
5829   // "can the child scroll up?". A full-screen overlay sits on a document still at scroll top, so every drag
5830   // inside the cropper read as a pull-to-refresh and reloaded the page mid-edit. Nothing the page itself can
5831   // do prevents that, which is why the native side exposes this switch. iOS ignores the message: its
5832   // RefreshView only triggers on a genuine scroll-view overscroll.
5833   //
5834   // Anything other than an explicit false re-enables, matching the native default — losing a refresh gesture
5835   // is recoverable, leaving it dead for the rest of the session is not.
5836   window.setMobileWebViewPullToRefresh = (enabled) => {
5837     if (!window.ReactNativeWebView || !window.isMobileApp || !window.isMobileApp()) return;
5838     window.ReactNativeWebView.postMessage(JSON.stringify({
5839       type: 'setPullToRefresh',
5840       enabled: enabled !== false
5841     }));
5842   };
5843
5844   // Auth messages for the native shell. Both live here rather than 
5844inline on a page: three pages send one of
5845   // them (Logout, the Exchange AccountDashboard duplicate-account flow, and the login stuck-session watchdog),
5846   // and while they were page-local AccountDashboard's call hit an undefined function. `path` is where the shell
5847   // should take the WebView next; callers with no better destination omit it and get the generic mobile app's
5848   // own login screen, which is the only path the shell used to be given.
5849   window.postWebViewAuthMessage = function (messageType, path) {
5850     if (!window.ReactNativeWebView || !window.ReactNativeWebView.postMessage) {
5851       return;
5852     }
5853
5854     let targetPath = path;
5855     if (!targetPath) {
5856       targetPath = '/login';
5857     }
5858
5859     const message = {
5860       type: messageType,
5861       isAuthenticated: false,
5862       path: targetPath,
5863       hasMobileScreen: false
5864     };
5865
5866     window.ReactNativeWebView.postMessage(JSON.stringify(message));
5867   };
5868
5869   // The web session has just been signed out; the shell must drop its own auth state to match.
5870   //
5871   // Android injects the bridge after page load. Wait for its callable receiver before handing navigation to the
5872   // shell, which clears its persisted credentials and WebView cookies before displaying the signed-out screen.
5873   window.sendLogoutMessageToWebView = async function (path) {
5874     const bridgeWaitLimitMs = 10000;
5875     const bridgeWaitStepMs = 100;
5876     let bridgeWaitedMs = 0;
5877     while (bridgeWaitedMs < bridgeWaitLimitMs) {
5878       if (typeof window.ReactNativeWebView?.postMessage === 'function') {
5879         window.postWebViewAuthMessage('logout', path);
5880         return true;
5881       }
5882       await new Promise(resolve => setTimeout(resolve, bridgeWaitStepMs));
5883       bridgeWaitedMs += bridgeWaitStepMs;
5884     }
5885
5886     console.error('Native logout bridge did not become available.');
5887     return false;
5888   };
5889
5890   // Keep the POST, native notification and navigation in the browser: changing the cookie can outlive the
5891   // circuit that initiated logout. The latch also stops circuit recovery from reloading during this sequence.
5892   window.completeLocalLogout = async function (postLogoutPath, isNativeMobileAppRequest, webViewPostLogoutPath) {
5893     if (window.tokeroLogoutInProgress) {
5894       return;
5895     }
5896     window.tokeroLogoutInProgress = true;
5897
5898     const abortController = new AbortController();
5899     const requestTimeout = setTimeout(function () { abortController.abort(); }, 15000);
5900     let nativeOwnsNavigation = false;
5901     try {
5902       const response = await fetch('/api/auth/logout/', {
5903         method: 'POST',
5904         credentials: 'same-origin',
5905         signal: abortController.signal
5906       });
5907       if (!response.ok) {
5908         console.error('Local logout failed with HTTP status', response.status);
5909         return;
5910       }
5911
5912       const signedOut = await response.json();
5913       if (signedOut !== true) {
5914         console.error('Local logout did not confirm sign-out.');
5915         return;
5916       }
5917
5918       clearTimeout(requestTimeout);
5919       // A sibling tab or a closed channel must not prevent the native shell from clearing its own session.
5920       try {
5921         window.broadcastAuth();
5922       } catch (error) {
5923         console.error('Logout auth broadcast failed.', error);
5924       }
5925
5926       if (isNativeMobileAppRequest) {
5927         nativeOwnsNavigation = await window.sendLogoutMessageToWebView(webViewPostLogoutPath);
5928       }
5929     } catch (error) {
5930       console.error('Local logout failed.', error);
5931     } finally {
5932       clearTimeout(requestTimeout);
5933       if (!nativeOwnsNavigation) {
5934         window.location.replace(postLogoutPath);
5935       }
5936     }
5937   };
5938
5939   // Same channel, different type: the login page's watchdog reports a session that never became usable, so the
5940   // shell restarts its sign-in instead of leaving the user on a spinner.
5941   window.sendSessionStuckMessageToWebView = function (path) {
5942     window.postWebViewAuthMessage('sessionStuck', path);
5943   };
5944
5945
5946   window.initiateInBrowserCountdown = (dotNetHelper, timeoutMs, onSecondPassedName, onCountdownEndName) =>
5947   {
5948     const intervalLengthMs = 1000; // 1 second
5949     let remainingSeconds = Math.ceil(timeoutMs / intervalLengthMs);
5950
5951     const tickingInterval = setInterval(() =>
5952     {
5953       remainingSeconds -= 1;
5954
5955       if (onSecondPassedName)
5956       {
5957         dotNetHelper.invokeMethodAsync(onSecondPassedName, remainingSeconds);
5958       }
5959
5960       if (remainingSeconds <= 0)
5961       {
5962         if (onCountdownEndName)
5963         {
5964           dotNetHelper.invokeMethodAsync(onCountdownEndName);
5965         }
5966
5967         clearInterval(tickingInterval);
5968       }
5969     }, intervalLengthMs)
5970   }
5971
5972   window.dispatchBlazorLoaded = function () {
5973     const event = new CustomEvent("blazorLoaded");
5974     window.dispatchEvent(event);
5975   };
5976
5977   window.isSafari = () =>
5978   {
5979     const userAgent = navigator.userAgent;
5980     const isChrome = userAgent.includes('Chrome') || userAgent.includes('CriOS');
5981     const isSafari = !isChrome && userAgent.includes('Safari');
5982
5983     if(isSafari)
5984     {
5985       return true;
5986     }
5987
5988     return false;
5989   }
5990
5991   window.getCurrentUrl = function() {
5992       if (typeof window.location === "undefined" || window.location === null || typeof window.location.href === "undefined" || window.location.href === null) {
5993           return "";
5994       }
5995       return window.location.href;
5996   };
5997
5998   window.getWindowName = function() {
5999       if (typeof window.name === "undefined" || window.name === null) {
6000           return "";
6001       }
6002       return window.name;
6003   };
6004
6005     window.getPageTitle = function() {
6006       if (typeof document.title === "undefined" || document.title === null) {
6007           return "";
6008       }
6009       return document.title;
6010   };
6011
6012   window.fromBase64 = (data) =>
6013   {
6014     const decodedData = atob(data);
6015     const chunks8 = new Uint8Array(decodedData.length);
6016
6017     for (let i = 0; i < chunks8.length; i += 1) {
6018       chunks8[i] = decodedData.charCodeAt(i);
6019     }
6020
6021     /**
6022      * What this does is it takes every two 8-bit chunks and at each index of the 16-bit array
6023      * it stores `256 * chunks8[oddIndex] + chunks8[evenIndex]`
6024      */
6025     const chunks16 = new Uint16Array(chunks8.buffer);
6026
6027     return String.fromCharCode(...chunks16);
6028   }
6029
6030    window.toBase64 = (data) =>
6031   {
6032     const chunks16 = new Uint16Array(data.length);
6033
6034     for (let i = 0; i < chunks16.length; i += 1) {
6035       chunks16[i] = data.charCodeAt(i);
6036     }
6037
6038     /**
6039      * What this does is it takes each 16-bit chunk and performs the following operations:
6040      * 1. At the even index, it stores `chunk16 % 256`
6041      * 2. At the odd index, it stores `Math.floor(chunk16 / 256)`
6042      */
6043     const chunks8 = new Uint8Array(chunks16.buffer);
6044     const safeData = String.fromCharCode(...chunks8);
6045
6046    return btoa(safeData);
6047   }
6048
6049   window.tryLoadFromLocalStorage = (key) => {
6050     const serializedValue = window.localStorage.getItem(key);
6051       if (!serializedValue) {
6052         return undefined;
6053       }
6054     const decodedData = fromBase64(serializedValue);
6055     return JSON.parse(decodedData);
6056    }
6057
6058   window.trySaveToLocalStorage = (key, value) => {
6059     const serializedValue = JSON.stringify(value);
6060     const encodedData = toBase64(serializedValue);
6061     window.localStorage.setItem(key, encodedData);
6062    }
6063
6064   window.authChannel = window.authChannel || new BroadcastChannel('auth-channel');
6065
6066
6067   window.broadcastAuth = function() {
6068       window.authChannel.postMessage({});
6069   }
6070
6071   window.subscribeToAuthChannel = function(dotnetRef) {
6072       window.authChannel.onmessage = function () {
6073           dotnetRef.invokeMethodAsync('OnAuthBroadcast');
6074       }
6075   }
6076
6077   // Called once per full page load by the layout. A server-side SSO auth-state change (login / account switch /
6078   // logout) sets this flag cookie on its redirect because it never reaches the Blazor components that call
6079   // broadcastAuth. The landing tab clears the flag and broadcasts so sibling tabs on THIS origin force-redirect
6080   // to the new auth state at once. Cookie name mirrors CookiesConstants.Names.AuthStateChangedBroadcast.
6081   window.consumeAuthBroadcastFlag = function() {
6082       const cookieName = 'tk_auth_broadcast';
6083       const hasFlag = document.cookie.split('; ').some(entry => entry.indexOf(cookieName + '=') === 0);
6084       if (!hasFlag) {
6085           return;
6086       }
6087       document.cookie = cookieName + '=; Max-Age=0; Path=/';
6088       if (typeof window.broadcastAuth === 'function') {
6089           window.broadcastAuth();
6090       }
6091   }
6092
6093   window.balanceChannel = window.balanceChannel || new BroadcastChannel('balance-channel');
6094
6095   window.broadcastBalance = function(coinId) {
6096       window.balanceChannel.postMessage({coinId : Number(coinId)});
6097   }
6098
6099   window.subscribeToBalanceChannel = function(dotnetRef) {
6100        window.balanceChannel.onmessage = function (event) {
6101          const coinId = event.data?.coinId || null;
6102            dotnetRef.invokeMethodAsync('OnBalanceChange', coinId);
6103        }
6104    }
6105
6106   window.corporateChannel = window.corporateChannel || new BroadcastChannel('corporate-channel');
6107
6108   window.broadcastCorporate = function(legalUserId) {
6109       window.corporateChannel.postMessage({ legalUserId: legalUserId });
6110   }
6111
6112   window.subscribeToCorporateChannel = function(dotnetRef) {
6113       window.corporateChannel.onmessage = function (event) {
6114           const legalUserId = event.data?.legalUserId || null;
6115           dotnetRef.invokeMethodAsync('OnCorporateBroadcast', legalUserId);
6116       }
6117   }
6118
6119   window.scrollToGridRowByClassId = function (itemId, objectTypeName, duration=3500) {
6120       try {
6121         let row = undefined;
6122         if (objectTypeName) {
6123           row = document.querySelector(`.grid-row-${objectTypeName}-id-${itemId}`);
6124         } else {
6125           const regex = new RegExp(`^grid-row-(\\w+)-id-${itemId}$`);
6126           const matchingRows = Array.from(document.querySelectorAll('[class]')).filter(el =>
6127             Array.from(el.classList).some(className => regex.test(className))
6128           );
6129
6130           if (matchingRows && matchingRows.length > 0) {
6131             row = matchingRows[0];
6132           }
6133         }
6134
6135         if (row) {
6136             row.scrollIntoView({ behavior: 'smooth', block: 'center' });
6137             row.classList.add('highlight-row');
6138               setTimeout(() => {
6139                   row.classList.remove('highlight-row');
6140               }, duration);
6141
6142               return true;
6143         }
6144       } catch (error) {
6145           console.error("Error scrolling to grid row by class ID:", error);
6146       }
6147
6148       return false;
6149   };
6150
6151   window.scrollToGridRowId = function (itemId, objectTypeName, duration=3500) {
6152       try {
6153
6154         let row = undefined;
6155         if (objectTypeName) {
6156           row = document.getElementById(`grid-row-${objectTypeName}-id-${itemId}`);
6157         } else {
6158           const regex = new RegExp(`^grid-row-(\\w+)-id-${itemId}$`);
6159           row = Array.from(document.querySelectorAll('[id]')).find(el =>
6160             regex.test(el.id)
6161           );
6162         }
6163
6164         if (row) {
6165             row.scrollIntoView({ behavior: 'smooth', block: 'center' });
6166             row.classList.add('highlight-row');
6167               setTimeout(() => {
6168                   row.classList.remove('highlight-row');
6169               }, duration);
6170
6171               return true;
6172         }
6173       } catch (error) {
6174           console.error("Error scrolling to grid row by ID:", error);
6175       }
6176       return false;
6177   };
6178
6179   window.scrollPageToTop = function () {
6180       window.scrollTo(0, 0);
6181       if (document.scrollingElement) { document.scrollingElement.scrollTop = 0; }
6182       if (document.documentElement) { document.documentElement.scrollTop = 0; }
6183       if (document.body) { document.body.scrollTop = 0; }
6184       // Some MudBlazor layouts make an inner div the scroll container — scroll any
6185       // element that is currently scrolled past the top.
6186       var scrollables = document.querySelectorAll('main, .mud-main-content, .layout, .blazor-element');
6187       for (var i = 0; i < scrollables.length; i++) {
6188         if (scrollables[i].scrollTop > 0) {
6189           scrollables[i].scrollTop = 0;
6190         }
6191       }
6192   };
6193
6194   //Pin the current scroll position for a short window so a programmatic re-render cannot bump the page
6195   window.tkPreserveScroll = function (durationMs) {
6196       try {
6197           var limit = 600;
6198           if (typeof durationMs === 'number' && durationMs > 0) {
6199               limit = durationMs;
6200           }
6201
6202           var targets = [];
6203           targets.push({ el: window, x: window.scrollX, y: window.scrollY });
6204
6205           var candidates = document.querySelectorAll('main, .mud-main-content, .layout, .blazor-element, .socialfi-content, .socialfi-canvas, html, body');
6206           for (var i = 0; i < candidates.length; i++) {
6207               var candidate = candidates[i];
6208               targets.push({ el: candidate, x: candidate.scrollLeft, y: candidate.scrollTop });
6209           }
6210
6211           var startTs = null;
6212           var step = function (ts) {
6213               if (startTs === null) {
6214                   startTs = ts;
6215               }
6216
6217               for (var j = 0; j < targets.length; j++) {
6218                   var target = targets[j];
6219                   if (target.el === window) {
6220                       if (Math.abs(window.scrollY - target.y) > 1 || Math.abs(window.scrollX - target.x) > 1) {
6221                           window.scrollTo(target.x, target.y);
6222                       }
6223                   }
6224                   else {
6225                       if (Math.abs(target.el.scrollTop - target.y) > 1) {
6226                           target.el.scrollTop = target.y;
6227                       }
6228                       if (Math.abs(target.el.scrollLeft - target.x) > 1) {
6229                           target.el.scrollLeft = target.x;
6230                       }
6231                   }
6232               }
6233
6234               if (ts - startTs < limit) {
6235                   window.requestAnimationFrame(step);
6236               }
6237           };
6238           window.requestAnimationFrame(step);
6239       }
6240       catch (e) {
6241           // Best-effort only: scroll preservation must never throw and break the caller.
6242       }
6243   };
6244
6245   window.scrollToGridRowById = function (itemId, objectTypeName, duration=3500) {
6246       let result = scrollToGridRowId(itemId, objectTypeName, duration);
6247       if (!result) {
6248         result = scrollToGridRowByClassId(itemId, objectTypeName, duration);
6249       }
6250
6251       return result;
6252   };
6253
6254     window.getWindowDimensions = () => {
6255       return {
6256         width: window.innerWidth,
6257         height: window.innerHeight
6258       }
6259     };
6260
6261     window.getScreenDimensions = () => {
6262       return {
6263         width: window.screen.width,
6264         height: window.screen.height
6265       }
6266     };
6267
6268     /* window.open for a connect popup that settles only once the popup is closed, so the connection switch that
6269        opened it stays on for as long as it is open (OpenCenteredWindow, isAwaitingClose). A connect that succeeds
6270        navigates this page from inside the popup just before closing it; beforeunload sees that navigation and the
6271        promise is left unsettled, so the switch does not drop to off while the page reloads connected. A popup the
6272        browser blocked settles at once. */
6273     window.openWindowUntilClosed = (url, windowFeatures) => {
6274       const popup = window.open(url, '_blank', windowFeatures);
6275       if (!popup) {
6276         return Promise.resolve();
6277       }
6278
6279       let isPageLeaving = false;
6280       const markPageLeaving = () => { isPageLeaving = true; };
6281       window.addEventListener('beforeunload', markPageLeaving, { once: true });
6282
6283       return new Promise((resolve) => {
6284         const closedPoll = setInterval(() => {
6285           if (!popup.closed) {
6286             return;
6287           }
6288
6289           clearInterval(closedPoll);
6290           /* The listener belongs to this popup: removed with it, or every popup opened and cancelled leaves one on
6291              window for the life of the document, which enhanced navigation keeps alive. */
6292           window.removeEventListener('beforeunload', markPageLeaving);
6293           if (!isPageLeaving) {
6294             resolve();
6295             return;
6296           }
6297
6298           /* A real unload never lets this fire. A beforeunload that did not end in one - a cancelled navigation -
6299              would otherwise leave the promise, and the switch waiting on it, pending for good. */
6300           setTimeout(resolve, 5000);
6301         }, 500);
6302       });
6303     };
6304
6305     /* One listener for the whole circuit. Every caller attaches this from AfterRenderAsync(firstRender), so
6306        without the swap each mount stacked another listener holding that mount's DotNetObjectReference: an OAuth
6307        error then fired all of them, and the ones whose component was already gone rejected with "no tracked
6308        object with id". The newest caller is the surface whose popup is open, so it is the one that keeps it. */
6309     window.oAuthErrorListener = function (dotnetRef) {
6310       if (window._oAuthErrorHandler) {
6311         window.removeEventListener('message', window._oAuthErrorHandler);
6312       }
6313
6314       window._oAuthErrorHandler = (event) => {
6315         if (event.origin !== window.location.origin) {
6316             return;
6317         }
6318
6319         if (event.data?.type === 'oauth-error' && typeof event.data.message === 'string') {
6320             const showErrorPromise = dotnetRef.invokeMethodAsync('ShowError', event.data.message);
6321             if (showErrorPromise && showErrorPromise.catch) {
6322               /* The circuit or the component can be gone by now; there is no one left to show the message to. */
6323               showErrorPromise.catch(() => { });
6324             }
6325         }
6326       };
6327
6328       window.addEventListener('message', window._oAuthErrorHandler);
6329     };
6330
6331     // NOTE: the SSO popup opener lives in the shared SsoLoginButton component (Tokero.Shared.Blazor),
6332     // kept self-contained there so the button has no load-order dependency on this file.
6333
6334     window.blockMinusEverywhere = (elementId) => {
6335     const el = document.getElementById(elementId);
6336     if (!el) return;
6337
6338     el.addEventListener('input', (e) => {
6339       const original = el.value;
6340       const sanitized = original.replace(/-/g, '');
6341       if (original !== sanitized) {
6342         el.value = sanitized;
6343         el.selectionStart = el.selectionEnd = sanitized.length;
6344         el.dispatchEvent(new Event('input', { bubbles: true }));
6345       }
6346     });
6347   };
6348
6349    // Focus behaviour for the right-aligned numeric fields (.right-aligned-input) of the portfolio add/edit asset
6350    // dialog and its bond / private-investment fragments. Two things happen when one is tapped:
6351    //
6352    //   * a field still holding the default the dialog opened it on is emptied, so the first digit typed replaces
6353    //     that default instead of being appended to it. Which value counts as the default is declared per field
6354    //     via data-tk-default-value, because it is NOT always zero — the amount field opens on 1 — and because a
6355    //     field that has no default at all (an optional decimal? that opens empty) must never be emptied;
6356    //   * a field holding any other value gets the caret dropped after its last digit instead of in front of the
6357    //     first one. Right-aligned text leaves the blank half of the box on the LEFT, and tapping that blank put
6358    //     the caret at position 0, so typing prefixed the existing number.
6359    //
6360    // Same behaviour as the exchange buy/sell amount fields (HandleFocus/HandleBlur in ExchangeBuy.razor and
6361    // ExchangeSell.razor), done once here rather than as a pair of Blazor @onfocus/@onblur handlers on each of the
6362    // dialog's numeric fields, for two reasons. The caret half is not expressible in C# at all: MudNumericField
6363    // renders <input type="number">, whose selection API throws InvalidStateError, so the only way to move the
6364    // cursor is the DOM's own rule that assigning .value on a focused input parks the cursor at the end of the
6365    // text. And several of those fields bind a non-nullable decimal/int, which has no way to represent "empty" —
6366    // the exchange trick of assigning null works there only because its amounts are decimal?.
6367    //
6368    // Nothing is reported back to Blazor, deliberately. The cleared text lives in the DOM only while the field is
6369    // focused and untouched, and blurring without typing puts it back, so Blazor's render tree is never out of
6370    // step with what is on screen — the mirror of the exchange fields' HandleBlur restoring 0. As soon as the user
6371    // types, the browser's own input event drives MudNumericField's binding as usual and this stops interfer
6371ing.
6372    window.enableRightAlignedNumericFieldFocusBehavior = function () {
6373      // The listeners are delegated from the document and never removed, so wiring them twice would run every
6374      // handler twice. Blazor re-renders (and re-invokes this from AfterRenderAsync) far more often than the page
6375      // is loaded.
6376      if (window.tkRightAlignedNumericFieldFocusWired === true) {
6377        return;
6378      }
6379      window.tkRightAlignedNumericFieldFocusWired = true;
6380
6381      // Capture phase, so this runs before the browser moves focus. A pointer press on a field that is NOT yet the
6382      // active element is the press that will focus it, and that is the only press whose caret position gets
6383      // overridden below — a later click inside an already-focused field is the user deliberately picking a spot to
6384      // edit, and must be left alone.
6385      document.addEventListener("mousedown", function (pressEvent) {
6386        markPointerGestureThatWillFocus(pressEvent.target);
6387      }, true);
6388
6389      document.addEventListener("touchstart", function (pressEvent) {
6390        markPointerGestureThatWillFocus(pressEvent.target);
6391      }, true);
6392
6393      // Delegated from the document rather than bound per element: the fields are conditionally rendered per asset
6394      // category and live in three different components, so there is no single render pass at which they all
6395      // exist to be wired.
6396      document.addEventListener("focusin", function (focusEvent) {
6397        var inputElement = resolveRightAlignedNumericInput(focusEvent.target);
6398        if (!inputElement) {
6399          return;
6400        }
6401
6402        var originalText = inputElement.value || "";
6403        if (originalText === "") {
6404          // An empty optional field. Nothing to clear, and the caret already sits at the right edge because the
6405          // text is right-aligned.
6406          return;
6407        }
6408
6409        // Compared numerically rather than as text, so a default of "1" still matches a field that MudNumericField
6410        // rendered as "1.00" under a Format. A field with no data-tk-default-value yields NaN here, and NaN never
6411        // equals anything — including itself — so it falls through to the caret move, which is what it wants.
6412        var declaredDefault = resolveDeclaredNumericDefault(inputElement);
6413        if (parseFloat(originalText) === declaredDefault) {
6414          inputElement.dataset.tkClearedDefault = originalText;
6415          inputElement.value = "";
6416          return;
6417        }
6418
6419        // Deferred to the next task, NOT done here. Focus is granted inside the pointer press's default action and
6420        // the caret is positioned from the press coordinates immediately afterwards, so a caret moved during
6421        // focusin is overwritten by the click that caused the focus — which is exactly how this shipped broken the
6422        // first time. Keyboard and programmatic focus have no such press, and this timeout is the only thing that
6423        // serves them.
6424        scheduleCaretMoveToEnd(inputElement);
6425      });
6426
6427      // Fires after mouseup / touchend, so every caret placement the browser does for this gesture is already
6428      // behind us. Belt-and-braces alongside the focusin timeout above: the two orderings differ between engines
6429      // and between a plain click and a click that drags a fraction of a pixel, and parking the caret at the end
6430      // twice is indistinguishable from doing it once.
6431      document.addEventListener("click", function (clickEvent) {
6432        var inputElement = resolveRightAlignedNumericInput(clickEvent.target);
6433        if (!inputElement) {
6434          return;
6435        }
6436
6437        if (inputElement.dataset.tkFocusingPointerGesture !== "1") {
6438          return;
6439        }
6440
6441        delete inputElement.dataset.tkFocusingPointerGesture;
6442        scheduleCaretMoveToEnd(inputElement);
6443      });
6444
6445      document.addEventListener("input", function (inputEvent) {
6446        var inputElement = resolveRightAlignedNumericInput(inputEvent.target);
6447        if (!inputElement) {
6448          return;
6449        }
6450
6451        // The user has taken over. From here MudNumericField's own binding owns the text, so the restore below
6452        // must not put the old default back over whatever was typed, and the caret must stay where they are typing.
6453        delete inputElement.dataset.tkClearedDefault;
6454        delete inputElement.dataset.tkFocusingPointerGesture;
6455      });
6456
6457      // Registered in the capture phase so it runs before Blazor's own document-level change listener. Chrome and
6458      // Firefox fire change only when the USER edited the field, so a field we cleared and nobody typed into fires
6459      // nothing and this never runs; WebKit has historically compared the value at focus against the value at
6460      // blur, which would hand MudNumericField an empty string for a field that was only tapped. Putting the
6461      // default back first means such a change carries the value Blazor already holds and lands as a no-op. It has
6462      // to happen here rather than in focusout below, because change fires before blur.
6463      document.addEventListener("change", function (changeEvent) {
6464        var inputElement = resolveRightAlignedNumericInput(changeEvent.target);
6465        if (!inputElement) {
6466          return;
6467        }
6468
6469        restoreClearedNumericFieldDefault(inputElement);
6470      }, true);
6471
6472      document.addEventListener("focusout", function (blurEvent) {
6473        var inputElement = resolveRightAlignedNumericInput(blurEvent.target);
6474        if (!inputElement) {
6475          return;
6476        }
6477
6478        // A press that never produced a click on this field (dragged off it, cancelled) leaves the marker behind
6479        // otherwise, and the next click would be treated as the focusing one.
6480        delete inputElement.dataset.tkFocusingPointerGesture;
6481        restoreClearedNumericFieldDefault(inputElement);
6482      });
6483    };
6484
6485    // The value the field opened on, as declared by the Razor markup, or NaN when it declares none — which is the
6486    // case for every optional decimal? field (they open empty) and for every field in an EDIT, where the number on
6487    // screen is the user's saved value and emptying it on a tap would look like losing it.
6488    //
6489    // closest() rather than a direct dataset read because it matches the element itself first and then walks up:
6490    // MudBlazor splats an unmatched attribute onto the <input> it renders, which is where this lands today, and the
6491    // walk keeps the behaviour working if a Mud upgrade moves user attributes to the outer control instead of
6492    // silently switching it off.
6493    function resolveDeclaredNumericDefault(inputElement) {
6494      var declaringElement = inputElement.closest("[data-tk-default-value]");
6495      if (!declaringElement) {
6496        return NaN;
6497      }
6498
6499      return parseFloat(declaringElement.dataset.tkDefaultValue);
6500    }
6501
6502    // Flags the pointer press that is about to focus a right-aligned numeric field, so the click it turns into can
6503    // tell itself apart from a click inside a field that already had focus.
6504    function markPointerGestureThatWillFocus(eventTarget) {
6505      var inputElement = resolveRightAlignedNumericInput(eventTarget);
6506      if (!inputElement) {
6507        return;
6508      }
6509
6510      if (document.activeElement === inputElement) {
6511        return;
6512      }
6513
6514      inputElement.dataset.tkFocusingPointerGesture = "1";
6515    }
6516
6517    // Parks the caret after the last character, on the next task rather than now — see the focusin handler for why
6518    // doing it synchronously loses to the browser's own caret placement.
6519    function scheduleCaretMoveToEnd(inputElement) {
6520      window.setTimeout(function () {
6521        moveNumericFieldCaretToEnd(inputElement);
6522      }, 0);
6523    }
6524
6525    function moveNumericFieldCaretToEnd(inputElement) {
6526      // Focus may have moved on, or Blazor may have replaced the node, in the task between scheduling and running.
6527      if (document.activeElement !== inputElement) {
6528        return;
6529      }
6530
6531      var currentText = inputElement.value || "";
6532      if (currentText === "") {
6533        return;
6534      }
6535
6536      // Assigning .value on a focused input parks the cursor at the end of the text; it has to actually change for
6537      // the browser to do that, hence the round trip through the empty string. Neither assignment raises an input
6538      // event, so Blazor sees none of this and the value it renders is unchanged.
6539      inputElement.value = "";
6540      inputElement.value = currentText;
6541
6542      // And then the selection API on top, for any engine that does not move the cursor on assignment. It does not
6543      // apply to type="number" and throws InvalidStateError there, which is why the result is discarded rather
6544      // than relied on — whichever of the two works, the caret ends up in the same place.
6545      try {
6546        inputElement.setSelectionRange(currentText.length, currentText.length);
6547      } catch (selectionNotSupported) {
6548        // type="number" in Chrome and Firefox. The assignment above is what does the work in those engines.
6549      }
6550    }
6551
6552    // Puts back the default that focusin hid, for a field the user never typed into, so the DOM goes back to
6553    // matching the value Blazor still holds — none of the clearing was ever reported to it.
6554    function restoreClearedNumericFieldDefault(inputElement) {
6555      var clearedText = inputElement.dataset.tkClearedDefault;
6556      if (clearedText === undefined) {
6557        return;
6558      }
6559
6560      delete inputElement.dataset.tkClearedDefault;
6561
6562      // Only restore into a field that is still empty. Blazor can legitimately have written a new value here while
6563      // the field was focused (changing the dialog's base coin reconverts the price fields), and that value has to
6564      // win over the default we hid.
6565      if (inputElement.value === "") {
6566        inputElement.value = clearedText;
6567      }
6568    }
6569
6570    // The marker class sits on MudBlazor's outer .mud-input-control wrapper (that is also how the text-align rule
6571    // in App.css is written), so the input itself has to be matched by walking up to it.
6572    function resolveRightAlignedNumericInput(eventTarget) {
6573      if (!(eventTarget instanceof HTMLInputElement)) {
6574        return null;
6575      }
6576
6577      // A field the user cannot type into must keep showing its value. The dialog's auto-priced current-value and
6578      // the bond yield are rendered Disabled as well, which already stops focusin, but readOnly on its own does
6579      // not — and emptying a display-only field on tap would read as the value having been lost.
6580      if (eventTarget.readOnly || eventTarget.disabled) {
6581        return null;
6582      }
6583
6584      var markedControl = eventTarget.closest(".right-aligned-input");
6585      if (!markedControl) {
6586        return null;
6587      }
6588
6589      return eventTarget;
6590    }
6591
6592    window.updateQueryParam = (key, value, replace = true) => {
6593          const url = new URL(window.location.href);
6594
6595         if (value === null || value === undefined || value === "") {
6596             // Remove the key if value is empty
6597             url.searchParams.delete(key);
6598         } else {
6599             // Update or add the key
6600             url.searchParams.set(key, value);
6601         }
6602
6603         // Only prefix "?" when params remain, so stripping the last one yields a clean path (no dangling "?").
6604         const queryString = url.searchParams.toString();
6605         let newUrl = url.pathname + url.hash;
6606         if (queryString) {
6607             newUrl = url.pathname + "?" + queryString + url.hash;
6608         }
6609
6610          if (replace) {
6611              history.replaceState(null, '', newUrl);
6612          } else {
6613              history.pushState(null, '', newUrl);
6614          }
6615      };
6616
6617    window.scrollToElementByIdWithOffset = async function (elementId, topOffset = 0, maxAttempts = 10, delayMs = 100) {
6618      let attemptsLeft = maxAttempts;
6619      let previousTargetTop = -1;
6620
6621      while (attemptsLeft > 0) {
6622        const element = document.getElementById(elementId);
6623        if (!element) {
6624          await new Promise(function (resolve) { window.setTimeout(resolve, delayMs); });
6625          attemptsLeft--;
6626          continue;
6627        }
6628
6629        const elementRect = element.getBoundingClientRect();
6630        let targetTop = window.pageYOffset + elementRect.top - topOffset;
6631        if (targetTop < 0) {
6632          targetTop = 0;
6633        }
6634
6635        window.scrollTo({
6636          top: targetTop,
6637          behavior: "smooth"
6638        });
6639
6640        if (Math.abs(targetTop - previousTargetTop) <= 2) {
6641          return true;
6642        }
6643
6644        previousTargetTop = targetTop;
6645        await new Promise(function (resolve) { window.setTimeout(resolve, delayMs); });
6646        attemptsLeft--;
6647      }
6648
6649      return false;
6650    };
6651
6652    // True on devices with a real hover-capable pointer (mouse/trackpad); false on touch-only devices.
6653    window.deviceSupportsHover = function () {
6654      return window.matchMedia('(hover: hover)').matches;
6655    };
6656
6657    window.scrollToUrlFragment = function () {
6658      if (!location.hash || location.hash.length <= 1) {
6659        return;
6660      }
6661      var targetId = location.hash.substring(1);
6662      window.scrollToElementByIdWithOffset(targetId, 80, 20, 150);
6663    };
6664
6665        // crop script start
6666    window._croppers = window._croppers || {};
6667
6668   window.initializePanZoomCropper = function (
6669     canvasId,
6670     _,
6671     fileInputId,
6672     aspectRatio = 1,
6673     initialSize = 260
6674   ) {
6675     if (window._croppers[canvasId]) {
6676       window.disposeCropper(canvasId, fileInputId);
6677     }
6678
6679     const canvas = document.getElementById(canvasId);
6680     const input = document.getElementById(fileInputId);
6681     if (!canvas || !input) {
6682       console.error("initializePanZoomCropper: missing elements", { canvasId, fileInputId });
6683       return;
6684     }
6685
6686     const ctx = canvas.getContext("2d");
6687
6688     const viewport = Math.min(window.innerWidth, 420);
6689     const size = Math.min(Math.max(initialSize, viewport - 32), 420);
6690
6691       const dpr = window.devicePixelRatio || 1;
6692
6693  canvas.width = size * dpr;
6694  canvas.height = size * dpr;
6695  canvas.style.width = size + "px";
6696  canvas.style.height = size + "px";
6697
6698  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
6699     canvas.style.touchAction = "none";
6700
6701     const state = {
6702       canvas,
6703       ctx,
6704       input,
6705       image: new Image(),
6706       scale: 1,
6707       minScale: 1,
6708       maxScale: 4,
6709       offsetX: 0,
6710       offsetY: 0,
6711       lastX: 0,
6712       lastY: 0,
6713       isDragging: false,
6714       pinchStartDistance: 0,
6715       pinchStartScale: 1,
6716     };
6717
6718     // After the declaration, not before it: `state` is a const in this same scope, so the assignment used to sit
6719     // in its temporal dead zone and threw before a single listener was attached.
6720     state.cssSize = size;
6721
6722     function draw() {
6723       ctx.clearRect(0, 0, canvas.width, canvas.height);
6724       if (!state.image.complete || !state.image.naturalWidth) return;
6725
6726       ctx.save();
6727       ctx.translate(canvas.width / 2 + state.offsetX, canvas.height / 2 + state.offsetY);
6728       ctx.scale(state.scale, state.scale);
6729       ctx.drawImage(state.image, -state.image.width / 2, -state.image.height / 2);
6730       ctx.restore();
6731     }
6732
6733     function onFileChange() {
6734       const file = input.files?.[0];
6735       if (!file) return;
6736
6737       const reader = new FileReader();
6738       reader.onload = (e) => {
6739         state.image.onload = () => {
6740           const scaleX = canvas.width / state.image.width;
6741           const scaleY = canvas.height / state.image.height;
6742           state.scale = Math.max(scaleX, scaleY);
6743           state.minScale = state.scale;
6744           state.offsetX = 0;
6745           state.offsetY = 0;
6746           draw();
6747           canvas.classList.remove("d-none");
6748         };
6749         state.image.src = e.target.result;
6750       };
6751       reader.readAsDataURL(file);
6752     }
6753
6754     function distance(a, b) {
6755       return Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
6756     }
6757
6758     function onTouchStart(e) {
6759       if (e.touches.length === 1) {
6760         state.isDragging = true;
6761         state.lastX = e.touches[0].clientX;
6762         state.lastY = e.touches[0].clientY;
6763       }
6764       if (e.touches.length === 2) {
6765         state.pinchStartDistance = distance(e.touches[0], e.touches[1]);
6766         state.pinchStartScale = state.scale;
6767       }
6768     }
6769
6770     function onTouchMove(e) {
6771       e.preventDefault();
6772
6773       if (e.touches.length === 1 && state.isDragging) {
6774         const dx = e.touches[0].clientX - state.lastX;
6775         const dy = e.touches[0].clientY - state.lastY;
6776         state.offsetX += dx;
6777         state.offsetY += dy;
6778         state.lastX = e.touches[0].clientX;
6779         state.lastY = e.touches[0].clientY;
6780         draw();
6781       }
6782
6783       if (e.touches.length === 2) {
6784         const d = distance(e.touches[0], e.touches[1]);
6785         const scale = state.pinchStartScale * (d / state.pinchStartDistance);
6786         state.scale = Math.min(state.maxScale, Math.max(state.minScale, scale));
6787         draw();
6788       }
6789     }
6790
6791     function onTouchEnd() {
6792       state.isDragging = false;
6793       state.pinchStartDistance = 0;
6794     }
6795
6796     input.addEventListener("change", onFileChange);
6797     canvas.addEventListener("touchstart", onTouchStart, { passive: false });
6798     canvas.addEventListener("touchmove", onTouchMove, { passive: false });
6799     canvas.addEventListener("touchend", onTouchEnd);
6800
6801     window._croppers[canvasId] = {
6802       state,
6803       handlers: { onFileChange, onTouchStart, onTouchMove, onTouchEnd },
6804       draw
6805     };
6806   };
6807
6808   /**
6809    * REAL CROPPER (crop box + move + resize).
6810    * Signature: initializeCropperFromUpload(CanvasId, dotNetRef, InputId, AspectRatio, CropWidth, EdgePadding).
6811    * dotNetRef is a per-instance Blazor handle (DotNetObjectReference<PhotoEditorDialog>) — we call
6812    * dotNetRef.invokeMethodAsync('OnCropImageLoaded') when the chosen image finishes decoding. The
6813    * old global window.onCropImageLoaded + static [JSInvokable] dispatch raced across circuits and
6814    * routed the callback to whichever dialog last assigned the static Instance slot.
6815    * edgePadding (default 64) is the total horizontal gap left around the canvas when the viewport is the
6816    * limiting factor. The canvas is written out below as a fixed px width and never shrinks to its container,
6817    * so pass at least the host dialog's chrome — see PostMediaDialog.CropperEdgePaddingPixels.
6818    */
6819   window.initializeCropperFromUpload = function (
6820     canvasId,
6821     dotNetRef,
6822     fileInputId,
6823     aspectRatio = 1,
6824     initialWidth = 200,
6825     edgePadding = 64
6826   ) {
6827     window._croppers = window._croppers || {};
6828
6829     // cleanup previous instance
6830     if (window._croppers[canvasId]) {
6831       window.disposeCropper(canvasId, fileInputId);
6832     }
6833
6834     const cropCanvas = document.getElementById(canvasId);
6835     const fileInput = document.getElementById(fileInputId);
6836
6837     if (!cropCanvas || !fileInput) {
6838       console.error("initializeCropperFromUpload: missing elements", { canvasId, fileInputId });
6839       return;
6840     }
6841
6842     const cropCtx = cropCanvas.getContext("2d");
6843     const resizeHandleSize = 28;
6844     const resizeHandleHitSize = 36;
6845
6846     // reset input so selecting same file triggers change
6847     try { fileInput.value = ""; } catch { }
6848
6849     const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);
6850     const MAX_CANVAS = Math.max(240, viewportWidth - edgePadding); // keep padding (edgePadding=0 => full width)
6851     const CANVAS_SIZE = Math.min(Math.max(initialWidth * 2, 260), MAX_CANVAS);
6852
6853      const state = {
6854      cropCanvas,
6855      cropCtx,
6856      fileInput,
6857      dotNetRef,
6858      aspectRatio: aspectRatio ? parseFloat(aspectRatio) : null,
6859      initialWidth,
6860
6861       cropX: 0,
6862       cropY: 0,
6863       cropWidth: initialWidth,
6864       cropHeight: aspectRatio ? initialWidth / aspectRatio : initialWidth,
6865
6866       isResizing: false,
6867       isMoving: false,
6868       dragOffsetX: 0,
6869       dragOffsetY: 0,
6870
6871       image: new Image(),
6872
6873       // where the image is drawn in canvas coords
6874       imageDrawX: 0,
6875       imageDrawY: 0,
6876       imageDrawWidth: 0,
6877       imageDrawHeight: 0,
6878
6879       activePointerId: null,
6880       resizeHandleSize,
6881       resizeHandleHitSize
6882     };
6883
6884     cropCanvas.style.touchAction = "none";
6885       const dpr = window.devicePixelRatio || 1;
6886
6887   // real pixel size
6888   cropCanvas.width = CANVAS_SIZE * dpr;
6889   cropCanvas.height = CANVAS_SIZE * dpr;
6890
6891   // visual size (CSS pixels)
6892   cropCanvas.style.width = CANVAS_SIZE + "px";
6893   cropCanvas.style.height = CANVAS_SIZE + "px";
6894
6895   // scale drawing to match DPI
6896   cropCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
6897   state.cropCssSize = CANVAS_SIZE;
6898
6899     function draw() {
6900       const s = state;
6901       const ctx = s.cropCtx;
6902       const size = s.cropCssSize;
6903
6904       ctx.clearRect(0, 0, size, size);
6905
6906       // background
6907       ctx.fillStyle = "#f4f4f5";
6908       ctx.fillRect(0, 0, size, size);
6909
6910       // image
6911       if (s.image && s.image.complete && s.image.naturalWidth) {
6912         ctx.drawImage(s.image, s.imageDrawX, s.imageDrawY, s.imageDrawWidth, s.imageDrawHeight);
6913       }
6914
6915       // overlay — four bands around the crop box. Everything in this draw() routine works in
6916       // CSS coordinates (size = cropCssSize) because the context is pre-scaled by setTransform(dpr).
6917       // The previous version subtracted the crop box with an evenodd two-rect path whose OUTER rect
6918       // used s.cropCanvas.width/height (device pixels = CANVAS_SIZE * d
6918pr). On a 1:1 display those
6919       // numbers equal `size`, so it looked correct; but on Edge at Windows display scaling (dpr 1.25/
6920       // 1.5) the outer rect was dpr-times oversized inside the already-scaled context and the evenodd
6921       // hole no longer registered, washing the whole image with the dark overlay. Painting explicit
6922       // bands in CSS coordinates avoids both the device/CSS mismatch and any evenodd cross-browser quirk.
6923       ctx.save();
6924       ctx.fillStyle = "rgba(0,0,0,0.45)";
6925       const cropRight = s.cropX + s.cropWidth;
6926       const cropBottom = s.cropY + s.cropHeight;
6927
6928       ctx.beginPath();
6929
6930       // top
6931       ctx.fillRect(0, 0, size, s.cropY);
6932       // bottom
6933       ctx.fillRect(0, cropBottom, size, size - cropBottom);
6934       // left
6935       ctx.fillRect(0, s.cropY, s.cropX, s.cropHeight);
6936       // right
6937       ctx.fillRect(cropRight, s.cropY, size - cropRight, s.cropHeight);
6938       ctx.restore();
6939
6940       // border
6941       ctx.save();
6942       ctx.setLineDash([6, 4]);
6943       ctx.strokeStyle = "white";
6944       ctx.lineWidth = 2;
6945       ctx.strokeRect(s.cropX, s.cropY, s.cropWidth, s.cropHeight);
6946       ctx.restore();
6947
6948       // handle
6949       const handleX = s.cropX + s.cropWidth - s.resizeHandleSize;
6950       const handleY = s.cropY + s.cropHeight - s.resizeHandleSize;
6951       ctx.fillStyle = "white";
6952       ctx.strokeStyle = "rgba(0,0,0,0.35)";
6953       ctx.lineWidth = 1;
6954       ctx.fillRect(handleX, handleY, s.resizeHandleSize, s.resizeHandleSize);
6955       ctx.strokeRect(handleX, handleY, s.resizeHandleSize, s.resizeHandleSize);
6956
6957       // diagonal grip lines
6958       ctx.strokeStyle = "rgba(0,0,0,0.55)";
6959       ctx.beginPath();
6960       ctx.moveTo(handleX + s.resizeHandleSize - 5, handleY + 5);
6961       ctx.lineTo(handleX + 5, handleY + s.resizeHandleSize - 5);
6962       ctx.moveTo(handleX + s.resizeHandleSize - 9, handleY + 5);
6963       ctx.lineTo(handleX + 5, handleY + s.resizeHandleSize - 9);
6964       ctx.stroke();
6965     }
6966
6967       function getCanvasPos(evt) {
6968    const rect = state.cropCanvas.getBoundingClientRect();
6969    return {
6970      x: evt.clientX - rect.left,
6971      y: evt.clientY - rect.top
6972    };
6973  }
6974
6975     // Bounded on both axes. With only the lower bound this matched the whole quadrant below and right of the
6976     // handle's top-left corner, so a press in the letterbox band outside the crop box started a resize and
6977     // dragged the box over padding the output must never include. The slop is what the hit size already adds
6978     // inward, mirrored outward, so the handle stays as easy to grab on touch as it was.
6979     function isInResizeHandle(x, y) {
6980       const s = state;
6981       const handleRight = s.cropX + s.cropWidth;
6982       const handleBottom = s.cropY + s.cropHeight;
6983       const outwardSlop = s.resizeHandleHitSize - s.resizeHandleSize;
6984       return (
6985         x >= handleRight - s.resizeHandleHitSize &&
6986         x <= handleRight + outwardSlop &&
6987         y >= handleBottom - s.resizeHandleHitSize &&
6988         y <= handleBottom + outwardSlop
6989       );
6990     }
6991
6992     function isInsideCropBox(x, y) {
6993       const s = state;
6994       return (
6995         x >= s.cropX &&
6996         x <= s.cropX + s.cropWidth &&
6997         y >= s.cropY &&
6998         y <= s.cropY + s.cropHeight
6999       );
7000     }
7001
7002     function updateCursor(x, y) {
7003       if (isInResizeHandle(x, y)) {
7004         cropCanvas.style.cursor = "pointer";
7005         return;
7006       }
7007       if (isInsideCropBox(x, y)) {
7008         cropCanvas.style.cursor = state.isMoving ? "grabbing" : "grab";
7009         return;
7010       }
7011       cropCanvas.style.cursor = "default";
7012     }
7013
7014     function onFileChange() {
7015       const file = state.fileInput.files?.[0];
7016       if (!file) return;
7017
7018       const reader = new FileReader();
7019       reader.onload = (e) => {
7020         state.image = new Image();
7021         state.image.onload = () => {
7022           const imgRatio = state.image.width / state.image.height;
7023           const canvasSize = state.cropCssSize;
7024           // fit INSIDE canvas, keep proportions
7025             if (imgRatio > 1) {
7026    state.imageDrawWidth = canvasSize;
7027    state.imageDrawHeight = canvasSize / imgRatio;
7028    state.imageDrawX = 0;
7029    state.imageDrawY = (canvasSize - state.imageDrawHeight) / 2;
7030  } else {
7031    state.imageDrawHeight = canvasSize;
7032    state.imageDrawWidth = canvasSize * imgRatio;
7033    state.imageDrawX = (canvasSize - state.imageDrawWidth) / 2;
7034    state.imageDrawY = 0;
7035  }
7036
7037           // Default crop = fit to the full image, not a smaller centered chip. Users no
7038           // longer need to click "Fit" after uploading; if they want to tighten the crop
7039           // they can drag-resize from the default. When an aspectRatio is set 
7039(e.g. 1.0
7040           // for profile photos) the crop snaps to the largest box of that ratio that
7041           // still fits inside the image so the output never picks up letterbox padding.
7042           if (state.aspectRatio) {
7043             let fitW = state.imageDrawWidth;
7044             let fitH = fitW / state.aspectRatio;
7045             if (fitH > state.imageDrawHeight) {
7046               fitH = state.imageDrawHeight;
7047               fitW = fitH * state.aspectRatio;
7048             }
7049             state.cropWidth = fitW;
7050             state.cropHeight = fitH;
7051             state.cropX = state.imageDrawX + (state.imageDrawWidth - fitW) / 2;
7052             state.cropY = state.imageDrawY + (state.imageDrawHeight - fitH) / 2;
7053           } else {
7054             state.cropWidth = state.imageDrawWidth;
7055             state.cropHeight = state.imageDrawHeight;
7056             state.cropX = state.imageDrawX;
7057             state.cropY = state.imageDrawY;
7058           }
7059
7060           draw();
7061           state.cropCanvas.classList.remove("d-none");
7062
7063           if (state.dotNetRef) {
7064             state.dotNetRef.invokeMethodAsync("OnCropImageLoaded")
7065               .catch((err) => console.error("OnCropImageLoaded failed", err));
7066           }
7067         };
7068         state.image.src = e.target.result;
7069       };
7070       reader.readAsDataURL(file);
7071     }
7072
7073     function onPointerDown(e) {
7074       if (e.button !== undefined && e.button !== 0) return;
7075
7076       const { x, y } = getCanvasPos(e);
7077
7078       if (isInResizeHandle(x, y)) {
7079         state.isResizing = true;
7080         cropCanvas.style.cursor = "pointer";
7081       } else if (isInsideCropBox(x, y)) {
7082         state.isMoving = true;
7083         state.dragOffsetX = x - state.cropX;
7084         state.dragOffsetY = y - state.cropY;
7085         cropCanvas.style.cursor = "grabbing";
7086       } else {
7087         updateCursor(x, y);
7088         return;
7089       }
7090
7091       e.preventDefault();
7092       state.activePointerId = e.pointerId;
7093       try { cropCanvas.setPointerCapture(e.pointerId); } catch { }
7094     }
7095
7096       function onPointerMove(e) {
7097    const { x, y } = getCanvasPos(e);
7098    const size = state.cropCssSize; // ✅ CSS pixels only
7099
7100    if (state.activePointerId === null) {
7101      updateCursor(x, y);
7102      return;
7103    }
7104    if (state.activePointerId !== e.pointerId) return;
7105
7106    // --------------------
7107    // RESIZE
7108    // --------------------
7109    if (state.isResizing) {
7110      let w = Math.max(60, x - state.cropX);
7111      let h = state.aspectRatio ? w / state.aspectRatio : Math.max(60, y - state.cropY);;
7112
7113      // clamp to canvas bounds
7114      if (state.cropX + w > size) {
7115        w = size - state.cropX;
7116        if(state.aspectRatio)
7117        {
7118          h = w / state.aspectRatio;
7119        }
7120      }
7121      if (state.cropY + h > size) {
7122        h = size - state.cropY;
7123        if(state.aspectRatio)
7124        {
7125          w = h * state.aspectRatio;
7126        }
7127      }
7128
7129      state.cropWidth = w;
7130      state.cropHeight = h;
7131
7132      draw();
7133    }
7134
7135    // --------------------
7136    // MOVE
7137    // --------------------
7138    if (state.isMoving) {
7139      state.cropX = Math.max(
7140        0,
7141        Math.min(x - state.dragOffsetX, size - state.cropWidth)
7142      );
7143
7144      state.cropY = Math.max(
7145        0,
7146        Math.min(y - state.dragOffsetY, size - state.cropHeight)
7147      );
7148
7149      draw();
7150    }
7151
7152    updateCursor(x, y);
7153  }
7154
7155
7156     function endPointer(e) {
7157       if (state.activePointerId === e.pointerId) {
7158         state.isResizing = false;
7159         state.isMoving = false;
7160         state.activePointerId = null;
7161         updateCursor(state.cropX + state.cropWidth, state.cropY + state.cropHeight);
7162         try { cropCanvas.releasePointerCapture(e.pointerId); } catch { }
7163       }
7164     }
7165
7166     function onPointerLeave() {
7167       cropCanvas.style.cursor = "default";
7168     }
7169
7170     fileInput.addEventListener("change", onFileChange);
7171     cropCanvas.addEventListener("pointerdown", onPointerDown);
7172     cropCanvas.addEventListener("pointermove", onPointerMove, { passive: false });
7173     cropCanvas.addEventListener("pointerup", endPointer);
7174     cropCanvas.addEventListener("pointercancel", endPointer);
7175     cropCanvas.addEventListener("pointerleave", onPointerLeave);
7176
7177     window._croppers[canvasId] = {
7178       state,
7179       handlers: { onFileChange, onPointerDown, onPointerMove, endPointer, onPointerLeave },
7180       draw
7181     };
7182   };
7183
7184    window.loadImageIntoCropper = function (canvasId, dataUrl) {
7185      const cropper = window._croppers?.[canvasId];
7186      if (!cropper) return;
7187
7188      const s = cropper.state;
7189
7190      s.image = new Image();
7191      s.image.onload = () => {
7192        const canvasSize = s.cropCssSize;
7193
7194        const imgRatio = s.image.width / s.image.height;
7195
7196        if (imgRatio > 1) {
7197          s.imageDrawWidth = canvasSize;
7198          s.imageDrawHeight = canvasSize / imgRatio;
7199          s.imageDrawX = 0;
7200          s.imageDrawY = (canvasSize - s.imageDrawHeight) / 2;
7201        } else {
7202          s.imageDrawHeight = canvasSize;
7203          s.imageDrawWidth = canvasSize * imgRatio;
7204          s.imageDrawX = (canvasSize - s.imageDrawWidth) / 2;
7205          s.imageDrawY = 0;
7206        }
7207
7208        // Default crop = fit the full image (no letterbox), matching the same fit-on-load
7209        // semantics applied in the internal file-pick branch above. Users no longer need
7210        // to click "Fit" after uploading; the aspect-ratio constraint (when set) snaps the
7211        // crop to the largest in-image box of that ratio so the saved output never picks
7212        // up white padding around the picture.
7213        if (s.aspectRatio) {
7214          let fitW = s.imageDrawWidth;
7215          let fitH = fitW / s.aspectRatio;
7216          if (fitH > s.imageDrawHeight) {
7217            fitH = s.imageDrawHeight;
7218            fitW = fitH * s.aspectRatio;
7219          }
7220          s.cropWidth = fitW;
7221          s.cropHeight = fitH;
7222          s.cropX = s.imageDrawX + (s.imageDrawWidth - fitW) / 2;
7223          s.cropY = s.imageDrawY + (s.imageDrawHeight - fitH) / 2;
7224        } else {
7225          s.cropWidth = s.imageDrawWidth;
7226          s.cropHeight = s.imageDrawHeight;
7227          s.cropX = s.imageDrawX;
7228          s.cropY = s.imageDrawY;
7229        }
7230
7231        cropper.draw();
7232
7233        if (s.dotNetRef) {
7234          s.dotNetRef.invokeMethodAsync("OnCropImageLoaded")
7235            .catch((err) => console.error("OnCropImageLoaded failed", err));
7236        }
7237      };
7238
7239      s.image.src = dataUrl;
7240    };
7241
7242        window.getCroppedImageData = function (canvasId) {
7243    const cropper = window._croppers?.[canvasId];
7244    if (!cropper) return null;
7245
7246    const s = cropper.state;
7247    if (!s.image || !s.image.complete || !s.image.naturalWidth) return null;
7248
7249    const cssScaleX = s.image.naturalWidth / s.imageDrawWidth;
7250    const cssScaleY = s.image.naturalHeight / s.imageDrawHeight;
7251
7252    const sx = (s.cropX - s.imageDrawX) * cssScaleX;
7253    const sy = (s.cropY - s.imageDrawY) * cssScaleY;
7254    const sw = s.cropWidth * cssScaleX;
7255    const sh = s.cropHeight * cssScaleY;
7256
7257    const MAX_SIZE = 1600;
7258    let outW = Math.round(sw);
7259    let outH = Math.round(sh);
7260
7261    if (outW > MAX_SIZE || outH > MAX_SIZE) {
7262      const ratio = outW / outH;
7263      if (outW > outH) {
7264        outW = MAX_SIZE;
7265        outH = Math.round(MAX_SIZE / ratio);
7266      } else {
7267        outH = MAX_SIZE;
7268        outW = Math.round(MAX_SIZE * ratio);
7269      }
7270    }
7271
7272    const out = document.createElement("canvas");
7273    out.width = outW;
7274    out.height = outH;
7275
7276    const ctx = out.getContext("2d");
7277    ctx.imageSmoothingEnabled = true;
7278    ctx.imageSmoothingQuality = "high";
7279
7280    ctx.fillStyle = "#ffffff";
7281    ctx.fillRect(0, 0, outW, outH);
7282
7283    ctx.drawImage(
7284      s.image,
7285      sx, sy, sw, sh,
7286      0, 0, outW, outH
7287    );
7288
7289    return out.toDataURL("image/jpeg", 0.92).split(",")[1];
7290  };
7291
7292
7293  // Builds the blurred companion of a post image. Takes bare base64 JPEG (no data: prefix) and returns the same,
7294  // matching getCroppedImageData above so the caller can hand either straight to the uploader.
7295  //
7296  // DESTRUCTIVE ON PURPOSE — this is not a CSS blur. The blurred file is uploaded to the CDN under a public URL, so
7297  // anyone can fetch it: it has to be a picture from which the original cannot be recovered, not the original with a
7298  // filter draped over it. A canvas blur alone would not be that. Applying a small-radius blur is a convolution and
7299  // is partially invertible (deconvolution / sharpening recovers a lot of it), so the pixels themselves must go.
7300  //
7301  // Hence the downscale first: the image is drawn into a canvas a few pixels wide, which throws away almost every
7302  // high-frequency detail for good, and only then scaled back up with a blur to smooth the resulting blocks into
7303  // something that reads as a frosted preview rather than a broken thumbnail. Information that is not in the small
7304  // canvas cannot come back out of the large one.
7305  //
7306  // Returns null on any failure — a decode error, a tainted canvas, an empty input. The caller treats null as "no
7307  // blurred companion" and carries on with the normal upload rather than losing the post.
7308  window.blurImageBase64 = function (base64Jpeg, downscaleWidth, blurPixels) {
7309    return new Promise((resolve) => {
7310      if (!base64Jpeg) {
7311        resolve(null);
7312        return;
7313      }
7314
7315      // Defaults live here rather than at the call site so every caller gets the same strength. 16px wide leaves
7316      // colour and rough composition and nothing else — no faces, no text, no logos — which is the point of a
7317      // teaser. The blur radius default is not a constant: see below.
7318      let targetSmallWidth = 16;
7319      if (downscaleWidth > 0) {
7320        targetSmallWidth = downscaleWidth;
7321      }
7322
7323      const image = new Image();
7324
7325      image.onload = function () {
7326        try {
7327          const fullWidth = image.naturalWidth;
7328          const fullHeight = image.naturalHeight;
7329          if (!fullWidth || !fullHeight) {
7330            resolve(null);
7331            return;
7332          }
7333
7334          // Blur radius is PROPORTIONAL to the output, not a fixed pixel count. The filter works in the destination
7335          // canvas's coordinate space, so one constant cannot serve both a 400px phone upload and a 4000px camera
7336          // one: the same 8px reads as heavy on the first and as a faint haze on the second, which is how a picture
7337          // could come out looking barely touched. 3.5% of the width holds the appearance steady at any size, with a
7338          // floor so tiny images still get a real blur. An explicit blurPixels argument still overrides it.
7339          const proportionalBlurRadius = Math.round(fullWidth * 0.035);
7340          let blurRadius = Math.max(12, proportionalBlurRadius);
7341          if (blurPixels > 0) {
7342            blurRadius = blurPixels;
7343          }
7344
7345          // Step 1 — throw the detail away. Aspect ratio is kept so the upscale does not distort, and the height is
7346          // floored at 1px for very wide banners.
7347          const smallWidth = Math.max(1, Math.min(targetSmallWidth, fullWidth));
7348          const smallHeight = Math.max(1, Math.round(smallWidth * (fullHeight / fullWidth)));
7349
7350          const smallCanvas = document.createElement("canvas");
7351          smallCanvas.width = smallWidth;
7352          smallCanvas.height = smallHeight;
7353
7354          const smallCtx = smallCanvas.getContext("2d");
7355          smallCtx.imageSmoothingEnabled = true;
7356          smallCtx.imageSmoothingQuality = "high";
7357          smallCtx.drawImage(image, 0, 0, smallWidth, smallHeight);
7358
7359          // Step 2 — back up to the original size. Same dimensions as the real picture so the two files are
7360          // interchangeable in any layout that already reserves space for one of them.
7361          const out = document.createElement("canvas");
7362          out.width = fullWidth;
7363          out.height = fullHeight;
7364
7365          const ctx = out.getContext("2d");
7366          ctx.imageSmoothingEnabled = true;
7367          ctx.imageSmoothingQuality = "high";
7368
7369          // Opaque white underneath, like getCroppedImageData: JPEG has no alpha, and without this a transparent
7370          // source would flatten to black.
7371          ctx.fillStyle = "#ffffff";
7372          ctx.fillRect(0, 0, fullWidth, fullHeight);
7373
7374          // The blur is applied while drawing the SMALL canvas up, not to the full-size image. Blurring at the edges
7375          // of a canvas samples outside it and leaves a pale border; scaling up first means the blur only has to
7376          // soften block boundaries, so a modest radius does the job and the frame stays clean.
7377          ctx.filter = "blur(" + blurRadius + "px)";
7378          ctx.drawImage(smallCanvas, 0, 0, smallWidth, smallHeight, 0, 0, fullWidth, fullHeight);
7379          ctx.filter = "none";
7380
7381          resolve(out.toDataURL("image/jpeg", 0.8).split(",")[1]);
7382        } catch (e) {
7383          console.error("blurImageBase64 failed", e);
7384          resolve(null);
7385        }
7386      };
7387
7388      image.onerror = function () {
7389        console.error("blurImageBase64 could not decode the source image");
7390        resolve(null);
7391      };
7392
7393      image.src = "data:image/jpeg;base64," + base64Jpeg;
7394    });
7395  };
7396
7397
7398   window.disposeCropper = function (canvasId, inputId) {
7399     const cropper = window._croppers?.[canvasId];
7400     if (!cropper) return;
7401
7402     const { state, handlers } = cropper;
7403     const canvas = state?.cropCanvas || state?.canvas;
7404     const input = state?.fileInput || state?.input || document.getElementById(inputId);
7405
7406     try { input && (input.value = ""); } catch { }
7407
7408     if (input && handlers?.onFileChange) input.removeEventListener("change", handlers.onFileChange);
7409
7410     if (canvas && handlers?.onPointerDown) canvas.removeEventListener("pointerdown", handlers.onPointerDown);
7411     if (canvas && handlers?.onPointerMove) canvas.removeEventListener("pointermove", handlers.onPointerMove);
7412     if (canvas && handlers?.endPointer) {
7413       canvas.removeEventListener("pointerup", handlers.endPointer);
7414       canvas.removeEventListener("pointercancel", handlers.endPointer);
7415     }
7416     if (canvas && handlers?.onPointerLeave) canvas.removeEventListener("pointerleave", handlers.onPointerLeave);
7417
7418     try {
7419       const ctx = canvas?.getContext?.("2d");
7420       if (ctx && canvas) ctx.clearRect(0, 0, canvas.width, canvas.height);
7421     } catch { }
7422
7423     delete window._croppers[canvasId];
7424   };
7425   // crop script end
7426
7427
7428   window.infiniteScrollIsElementValid = function (element) {
7429       return element instanceof Element;
7430   };
7431
7432   window.infiniteScrollInitialize = function (lastItemIndicator, componentInstance) {
7433       if (!(lastItemIndicator instanceof Element)) {
7434           return null;
7435       }
7436       const options = {
7437           root: window.infiniteScrollFindClosestScrollContainer(lastItemIndicator),
7438           rootMargin: '0px 0px 2400px 0px',
7439           threshold: 0,
7440       };
7441
7442       // Once the controller is disposed (tab switch / navigation) the observer must never invoke
7443       // back into .NET: a JS->.NET call that lands while the circuit is tearing down throws a
7444       // NullReferenceException server-side in RemoteJSRuntime.EndInvokeDotNet. Track a disposed flag,
7445       // bail early when set, and swallow the rejection the same way the YouTube/timer callbacks do.
7446       let disposed = false;
7447
7448       const observer = new IntersectionObserver((entries) => {
7449           if (disposed) {
7450               return;
7451           }
7452           for (const entry of entries) {
7453               if (entry.isIntersecting) {
7454                   observer.unobserve(lastItemIndicator);
7455                   componentInstance.invokeMethodAsync("LoadMoreItems").catch(() => {});
7456               }
7457           }
7458       }, options);
7459
7460       observer.observe(lastItemIndicator);
7461
7462       return {
7463           dispose: () => {
7464               disposed = true;
7465               window.infiniteScrollDispose(observer);
7466           },
7467           onNewItems: () => {
7468               if (disposed) {
7469                   return;
7470               }
7471               observer.unobserve(lastItemIndicator);
7472               observer.observe(lastItemIndicator);
7473           }
7474       };
7475   };
7476
7477   window.socialfiSeenPostsInitialize = function (componentInstance) {
7478       const observedPostIds = new Set();
7479       // See infiniteScrollInitialize: never invoke .NET after dispose, and swallow the rejection so a
7480       // mark-as-seen landing during teardown can't NRE in RemoteJSRuntime.EndInvokeDotNet.
7481       let disposed = false;
7482       const observer = new IntersectionObserver((entries) => {
7483           if (disposed) {
7484               return;
7485           }
7486           for (const entry of entries) {
7487               if (!entry.isIntersecting) {
7488                   continue;
7489               }
7490
7491               const seenPostId = entry.target.dataset.seenPostId;
7492               observer.unobserve(entry.target);
7493
7494               if (!seenPostId) {
7495                   continue;
7496               }
7497
7498               componentInstance.invokeMethodAsync("MarkPostAsSeen", Number(seenPostId)).catch(() => {});
7499           }
7500       }, {
7501           threshold: 0.5
7502       });
7503
7504       return {
7505           dispose: () => {
7506               disposed = true;
7507               window.infiniteScrollDispose(observer);
7508           },
7509           observePostById: (elementId, postId) => {
7510               if (disposed || observedPostIds.has(postId)) {
7511                   return;
7512               }
7513
7514               const postElement = document.getElementById(elementId);
7515               if (!(postElement instanceof Element)) {
7516                   return;
7517               }
7518
7519               postElement.dataset.seenPostId = postId;
7520               observedPostIds.add(postId);
7521               observer.observe(postElement);
7522           }
7523       };
7524   };
7525
7526  // Fit-to-image (not fit-to-canvas). The canvas is larger than the drawn image whenever
7527  // the picture is not square — the rest is empty letterbox. Snapping the crop to the
7528  // canvas would put those empty bars into the saved output. Snap to
7529  // imageDrawX/Y/Width/Height instead so the output is always pixels-only, respecting
7530  // aspectRatio when set.
7531  window.fitCropToCanvas = function (canvasId) {
7532    const cropper = window._croppers?.[canvasId];
7533    if (!cropper) return;
7534
7535    const s = cropper.state;
7536
7537    if (s.aspectRatio) {
7538      let cropW = s.imageDrawWidth;
7539      let cropH = cropW / s.aspectRatio;
7540      if (cropH > s.imageDrawHeight) {
7541        cropH = s.imageDrawHeight;
7542        cropW = cropH * s.aspectRatio;
7543      }
7544      s.cropWidth = cropW;
7545      s.cropHeight = cropH;
7546      s.cropX = s.imageDrawX + (s.imageDrawWidth - cropW) / 2;
7547      s.cropY = s.imageDrawY + (s.imageDrawHeight - cropH) / 2;
7548    } else {
7549      s.cropWidth = s.imageDrawWidth;
7550      s.cropHeight = s.imageDrawHeight;
7551      s.cropX = s.imageDrawX;
7552      s.cropY = s.imageDrawY;
7553    }
7554
7555    cropper.draw();
7556  };
7557
7558   window.infiniteScrollDispose = function (observer) {
7559       observer.disconnect();
7560   };
7561
7562   window.infiniteScrollFindClosestScrollContainer = function (element) {
7563       while (element) {
7564          if (!(element instanceof Element)) {
7565             return null; // defensive against generic 500 shown sometimes
7566         }
7567           const style = getComputedStyle(element);
7568           if (style.overflowY !== 'visible') {
7569               return element;
7570           }
7571           element = element.parentElement;
7572       }
7573       return null;
7574   };
7575
7576         (function () {
7577
7578     // Update UTC timestamps rendered by Blazor to local display text.
7579     function pad2(v) {
7580       var value = String(v);
7581       if (value.length < 2)
7582       {
7583         value = "0" + value;
7584       }
7585       return value;
7586     }
7587
7588     function normalizeFormat(format) {
7589       var trimmed = "";
7590       if (format) {
7591         var candidate = format.trim();
7592         if (candidate) {
7593           trimmed = candidate;
7594         }
7595       }
7596
7597       if (!trimmed) {
7598         return "dd/MM/yyyy HH:mm";
7599       }
7600
7601       return trimmed;
7602     }
7603
7604     function normalizeLocale(locale) {
7605       if (locale && locale.trim()) {
7606         return locale.trim();
7607       }
7608
7609       // No data-locale on the element — let the browser pick, which is still closer to the reader than
7610       // hardcoding English would be.
7611       return undefined;
7612     }
7613
7614     // Intl.DateTimeFormat construction is expensive and updateAllTimes re-runs on every Blazor mutation, so
7615     // formatters are built once per (locale, kind) pair. Values may be null when Intl rejects the locale;
7616     // hasOwnProperty is what distinguishes "cached a null" from "not cached yet".
7617     var intlFormatterCache = {};
7618
7619     function resolveIntlFormatter(locale, cacheKey, options) {
7620       var fullCacheKey = String(locale) + "|" + cacheKey;
7621       if (Object.prototype.hasOwnProperty.call(intlFormatterCache, fullCacheKey)) {
7622         return intlFormatterCache[fullCacheKey];
7623       }
7624
7625       var formatter = null;
7626       try {
7627         formatter = new Intl.DateTimeFormat(locale, options);
7628       } catch (error) {
7629         formatter = null;
7630       }
7631
7632       intlFormatterCache[fullCacheKey] = formatter;
7633       return formatter;
7634     }
7635
7636     // Fallbacks for when Intl is unavailable or rejects the locale. English only, deliberately — they are the
7637     // last resort, not the normal path.
7638     var fallbackMonthNamesShort = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
7639                                    "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
7640     var fallbackMonthNamesLong = ["January", "February", "March", "April", "May", "June",
7641                                   "July", "August", "September", "October", "
7641November", "December"];
7642     // 0 = Sunday, 1 = Monday, ... 6 = Saturday — matches JS Date.getDay()
7643     var fallbackWeekdayNamesShort = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
7644     var fallbackWeekdayNamesLong = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
7645
7646     function resolveMonthName(date, locale, width) {
7647       var formatter = resolveIntlFormatter(locale, "month-" + width, { month: width });
7648       if (formatter) {
7649         return formatter.format(date);
7650       }
7651
7652       if (width === "long") {
7653         return fallbackMonthNamesLong[date.getMonth()];
7654       }
7655
7656       return fallbackMonthNamesShort[date.getMonth()];
7657     }
7658
7659     function resolveWeekdayName(date, locale, width) {
7660       var formatter = resolveIntlFormatter(locale, "weekday-" + width, { weekday: width });
7661       if (formatter) {
7662         return formatter.format(date);
7663       }
7664
7665       if (width === "long") {
7666         return fallbackWeekdayNamesLong[date.getDay()];
7667       }
7668
7669       return fallbackWeekdayNamesShort[date.getDay()];
7670     }
7671
7672     function resolveHours12(date) {
7673       var hours = date.getHours() % 12;
7674       if (hours === 0) {
7675         return 12;
7676       }
7677
7678       return hours;
7679     }
7680
7681     function resolveDayPeriod(date, locale) {
7682       var formatter = resolveIntlFormatter(locale, "dayperiod", { hour: "numeric", hour12: true });
7683       if (formatter && typeof formatter.formatToParts === "function") {
7684         var parts = formatter.formatToParts(date);
7685         for (var i = 0; i < parts.length; i++) {
7686           if (parts[i].type === "dayPeriod") {
7687             return parts[i].value;
7688           }
7689         }
7690       }
7691
7692       if (date.getHours() < 12) {
7693         return "AM";
7694       }
7695
7696       return "PM";
7697     }
7698
7699     function formatToken(date, token, locale) {
7700       switch (token) {
7701         case "yyyy": return String(date.getFullYear());
7702         case "yy": return pad2(date.getFullYear() % 100);
7703         case "MMMM": return resolveMonthName(date, locale, "long");
7704         case "MMM": return resolveMonthName(date, locale, "short");
7705         case "MM": return pad2(date.getMonth() + 1);
7706         case "M": return String(date.getMonth() + 1);
7707         case "dddd": return resolveWeekdayName(date, locale, "long");
7708         case "ddd": return resolveWeekdayName(date, locale, "short");
7709         case "dd": return pad2(date.getDate());
7710         case "d": return String(date.getDate());
7711         case "HH": return pad2(date.getHours());
7712         case "H": return String(date.getHours());
7713         case "hh": return pad2(resolveHours12(date));
7714         case "h": return String(resolveHours12(date));
7715         case "mm": return pad2(date.getMinutes());
7716         case "m": return String(date.getMinutes());
7717         case "ss": return pad2(date.getSeconds());
7718         case "s": return String(date.getSeconds());
7719         case "tt": return resolveDayPeriod(date, locale);
7720         case "t": return resolveDayPeriod(date, locale).charAt(0);
7721       }
7722
7723       // A .NET-style quoted literal ('at'), emitted without the quotes.
7724       return token.replace(/'/g, "");
7725     }
7726
7727     // Longest token first, matched in ONE sweep. The previous implementation ran a chain of .replace() passes,
7728     // which has two failure modes this avoids: a token with no pass of its own survived into the output as a
7729     // literal (Format="MMM d, yyyy" rendered "Aug d, 2026"), and a substituted value containing token letters
7730     // could be re-substituted by a later pass — a hazard that gets much more likely now that month and weekday
7731     // names are localized and no longer a known-safe English set.
7732     var formatTokenPattern = /'[^']*'|yyyy|yy|MMMM|MMM|MM|M|dddd|ddd|dd|d|HH|H|hh|h|mm|m|ss|s|tt|t/g;
7733
7734     function formatLocal(date, format, locale) {
7735       var normalizedFormat = normalizeFormat(format);
7736       var normalizedLocale = normalizeLocale(locale);
7737
7738       var formattedValue = normalizedFormat.replace(formatTokenPattern, function (token) {
7739         var tokenValue = formatToken(date, token, normalizedLocale);
7740         return tokenValue;
7741       });
7742
7743       return formattedValue;
7744     }
7745
7746     function getUtcValue(el) {
7747       var utcValue = el.getAttribute("data-utc");
7748       if (utcValue && utcValue.trim()) {
7749         return utcValue;
7750       }
7751
7752       return null;
7753     }
7754
7755     function parseUtc(utcValue) {
7756       var parsedDate = new Date(utcValue);
7757       if (isNaN(parsedDate.getTime())) {
7758         return null;
7759       }
7760
7761       return parsedDate;
7762     }
7763
7764     function markReady(el) {
7765       el.setAttribute("data-utc-ready", "true");
7766       el.removeAttribute("hidden");
7767       var container = null;
7768       if (el && typeof el.closest === "function") {
7769         container = el.closest("[data-utc-container]");
7770       }
7771       if (container) {
7772         container.setAttribute("data-utc-ready", "true");
7773         container.setAttribute("aria-busy", "false");
7774       }
7775     }
7776
7777     function updateAllTimes() {
7778       if (window._utcTimeUpdating === true) {
7779         return;
7780       }
7781
7782       window._utcTimeUpdating = true;
7783       var nodes = document.querySelectorAll("time[data-utc]");
7784       try {
7785         for (var i = 0; i < nodes.length; i++) {
7786           var el = nodes[i];
7787           try {
7788             var utcValue = getUtcValue(el);
7789             if (!utcValue) {
7790               continue;
7791             }
7792
7793             var parsedDate = parseUtc(utcValue);
7794             if (!parsedDate) {
7795               continue;
7796             }
7797
7798             var fmt = el.getAttribute("data-format");
7799             var elementLocale = el.getAttribute("data-locale");
7800             var displayValue = formatLocal(parsedDate, fmt, elementLocale);
7801             el.textContent = displayValue;
7802             markReady(el);
7803           } catch (error) {
7804             console.error("Failed to format utc time element", error);
7805           }
7806         }
7807       } finally {
7808         window._utcTimeUpdating = false;
7809       }
7810     }
7811
7812     function handleMutations(mutations) {
7813       if (window._utcTimeUpdating === true) {
7814         return;
7815       }
7816
7817       var shouldUpdate = false;
7818       for (var i = 0; i < mutations.length; i++) {
7819         var mutation = mutations[i];
7820         if (mutation.addedNodes && mutation.addedNodes.length > 0) {
7821           for (var nodeIndex = 0; nodeIndex < mutation.addedNodes.length; nodeIndex++) {
7822             var node = mutation.addedNodes[nodeIndex];
7823             if (node && node.nodeType === 1) {
7824               if (node.matches && node.matches("time[data-utc]")) {
7825                 shouldUpdate = true;
7826                 break;
7827               }
7828               if (node.querySelector && node.querySelector("time[data-utc]")) {
7829                 shouldUpdate = true;
7830                 break;
7831               }
7832             }
7833           }
7834         }
7835         if (shouldUpdate) {
7836           break;
7837         }
7838       }
7839
7840       if (shouldUpdate) {
7841         updateAllTimes();
7842       }
7843     }
7844
7845     function initializeUtcObserver() {
7846       if (window._utcTimeObserver) {
7847         return;
7848       }
7849
7850       if (!document.body) {
7851         return;
7852       }
7853
7854       // Watch for Blazor re-renders or late-added time elements.
7855       var observer = new MutationObserver(handleMutations);
7856       observer.observe(document.body, { childList: true, subtree: true });
7857       window._utcTimeObserver = observer;
7858     }
7859
7860     function onBlazorLoaded() {
7861       updateAllTimes();
7862     }
7863
7864     window.updateUtcTimeElements = updateAllTimes;
7865     window.addEventListener("blazorLoaded", onBlazorLoaded);
7866
7867     if (document.readyState === "loading") {
7868       document.addEventListener("DOMContentLoaded", function () {
7869         updateAllTimes();
7870         initializeUtcObserver();
7871       });
7872     } else {
7873       updateAllTimes();
7874       initializeUtcObserver();
7875     }
7876
7877   })();
7878
7879  // Scroll-reveal for the marketing pages. The `tkca-reveal-ready` flag below is what ARMS the pages' own
7880  // `opacity: 0` rules — without it their [data-reveal] blocks are simply visible — so this function and
7881  // those rules are two halves of one mechanism and must cover exactly the same elements.
7882  //
7883  // The selector is therefore deliberately NOT a per-page allow-list. It used to be
7884  // '.tkca [data-reveal], .tkic [data-reveal], .tkgc [data-reveal]', and .tkms (the membership page) was
7885  // added to the CSS side without being added here. The failure that produced is worth spelling out, because
7886  // an allow-list will produce it again for the next page: with no match this returned early, so nothing was
7887  // ever observed and nothing ever got `is-revealed`. On a cold load of /membership/ that was invisible —
7888  // the flag was absent too, so the hiding rule never applied and the page looked correct by accident. But
7889  // the flag goes on <html>, is never removed, and SURVIVES enhanced navigation: arriving at /membership/
7890  // from any .tkca/.tkic/.tkgc page left it armed with no revealer behind it, and the hero, the tier grid
7891  // and six other blocks rendered at opacity 0 — present in the DOM, permanently invisible.
7892  //
7893  // Matching every [data-reveal] costs nothing on pages that have none, and a page carrying the markup
7894  // without the CSS just gains a class no rule reads.
7895  window.initTkcaScrollReveal = function () {
7896    try {
7897      var items = document.querySelectorAll('[data-reveal]');
7898      if (!items.length) {
7899        return;
7900      }
7901
7902      document.documentElement.classList.add('tkca-reveal-ready');
7903
7904      var prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
7905      if (prefersReduced || !('IntersectionObserver' in window)) {
7906        for (var k = 0; k < items.length; k++) {
7907          items[k].classList.add('is-revealed');
7908        }
7909        return;
7910      }
7911
7912      var observer = new IntersectionObserver(function (entries, obs) {
7913        for (var i = 0; i < entries.length; i++) {
7914          if (entries[i].isIntersecting) {
7915            entries[i].target.classList.add('is-revealed');
7916            obs.unobserve(entries[i].target);
7917          }
7918        }
7919      }
7919, { rootMargin: '0px 0px -10% 0px', threshold: 0.1 });
7920
7921      for (var j = 0; j < items.length; j++) {
7922        if (items[j].dataset.revealObserved === 'true') {
7923          continue;
7924        }
7925        items[j].dataset.revealObserved = 'true';
7926        observer.observe(items[j]);
7927      }
7928    } catch (err) {
7929      console.error('initTkcaScrollReveal failed', err);
7930    }
7931  };
7932
7933  window.hasOverflow = el =>
7934     el instanceof Element && el.scrollHeight > el.clientHeight;
7935
7936  // Tap-to-expand for ExpandableText: the clamped body becomes a second target for "See More". Deliberately
7937  // insensitive - a scroll starting on a post must never expand it - so a pointer counts as a tap only when it
7938  // barely moved, was released quickly, selected nothing and missed every link. Expands only.
7939  window.initExpandableTextTap = function (textElement, toggleId) {
7940    try {
7941      if (!(textElement instanceof Element)) {
7942        return;
7943      }
7944
7945      if (textElement.dataset.tapToExpandWired === 'true') {
7946        return;
7947      }
7948      textElement.dataset.tapToExpandWired = 'true';
7949
7950      // Movement past this many CSS pixels means the finger was dragging the page, not tapping the text.
7951      var maxTapMovementPx = 8;
7952      // A press held longer than this is a long-press - text selection or a context menu - and not a tap.
7953      var maxTapDurationMs = 500;
7954      // Everything here handles its own click: links, mentions, hashtags, buttons, the See More label.
7955      var interactiveSelector = 'a, button, label, input, select, textarea, ' +
7956        '[role="button"], [role="link"], .mud-button-root';
7957
7958      var pendingTap = null;
7959
7960      textElement.addEventListener('pointerdown', function (event) {
7961        pendingTap = null;
7962        if (event.button !== 0) {
7963          return;
7964        }
7965        pendingTap = { x: event.clientX, y: event.clientY, startedAt: Date.now() };
7966      });
7967
7968      textElement.addEventListener('pointermove', function (event) {
7969        if (pendingTap === null) {
7970          return;
7971        }
7972        var movedX = Math.abs(event.clientX - pendingTap.x);
7973        var movedY = Math.abs(event.clientY - pendingTap.y);
7974        if (movedX > maxTapMovementPx || movedY > maxTapMovementPx) {
7975          pendingTap = null;
7976        }
7977      });
7978
7979      textElement.addEventListener('pointercancel', function () {
7980          pendingTap = null;
7981      });
7982
7983      textElement.addEventListener('pointerup', function (event) {
7984        var tapStart = pendingTap;
7985        pendingTap = null;
7986        if (tapStart === null) {
7987          return;
7988        }
7989
7990        var movedX = Math.abs(event.clientX - tapStart.x);
7991        var movedY = Math.abs(event.clientY - tapStart.y);
7992        if (movedX > maxTapMovementPx || movedY > maxTapMovementPx) {
7993          return;
7994        }
7995
7996        var heldForMs = Date.now() - tapStart.startedAt;
7997        if (heldForMs > maxTapDurationMs) {
7998          return;
7999        }
8000
8001        var interactiveTarget = null;
8002        if (event.target && event.target.closest) {
8003          interactiveTarget = event.target.closest(interactiveSelector);
8004        }
8005        if (interactiveTarget !== null) {
8006          return;
8007        }
8008
8009        // A drag that selected words inside this text is a selection gesture, not a tap.
8010        var selection = window.getSelection();
8011        var selectedInsideText = false;
8012        if (selection !== null && selection.isCollapsed === false && selection.anchorNode !== null) {
8013          selectedInsideText = textElement.contains(selection.anchorNode);
8014        }
8015        if (selectedInsideText) {
8016          return;
8017        }
8018
8019        var toggle = document.getElementById(toggleId);
8020        if (toggle === null || toggle.checked === true) {
8021          return;
8022        }
8023        toggle.checked = true;
8024      });
8025    } catch (err) {
8026      console.error('initExpandableTextTap failed', err);
8027    }
8028  };
8029
8030  window.consoleMessage = function (message) {
8031    console.log(message);
8032  };
8033
8034  window.loadExternalScript = function (src) {
8035    return new Promise(function (resolve, reject) {
8036      if (document.querySelector('script[src="' + src + '"]')) {
8037        resolve();
8038        return;
8039      }
8040      var script = document.createElement('script');
8041      script.src = src;
8042      script.onload = resolve;
8043      script.onerror = reject;
8044      document.body.appendChild(script);
8045    });
8046  };
8047
8048  window.loadTradingViewScriptIfNeeded = function () {
8049    return new Promise(function (resolve, reject) {
8050      if (window.TradingView) {
8051        resolve();
8052        return;
8053      }
8054
8055      var existingScriptElement = document.querySelector('script[data-tokero-tradingview-script="true"]');
8056      if (!existingScriptElement) {
8057        existingScriptElement = document.querySelector('script[src="https://s3.tradingview.com/tv.js"]');
8058      }
8059      if (existingScriptElement) {
8060        existingScriptElement.setAttribute("data-tokero-tradingview-script", "true");
8061
8062        if (existingScriptElement.dataset.loaded === "true" || window.TradingView) {
8063          resolve();
8064          return;
8065        }
8066
8067        existingScriptElement.addEventListener("load", function () {
8068          if (typeof window.onTradingViewLoad === "function") {
8069            window.onTradingViewLoad();
8070          }
8071          resolve();
8072        }, { once: true });
8073
8074        existingScriptElement.addEventListener("error", function () {
8075          reject(new Error("Failed to load TradingView script."));
8076        }, { once: true });
8077
8078        return;
8079      }
8080
8081      var existingPreloadElement = document.querySelector('link[data-tokero-tradingview-preload="true"]');
8082      if (!existingPreloadElement) {
8083        var preloadElement = document.createElement("link");
8084        preloadElement.rel = "preload";
8085        preloadElement.href = "https://s3.tradingview.com/tv.js";
8086        preloadElement.as = "script";
8087        preloadElement.setAttribute("data-tokero-tradingview-preload", "true");
8088        document.head.appendChild(preloadElement);
8089      }
8090
8091      var scriptElement = document.createElement("script");
8092      scriptElement.src = "https://s3.tradingview.com/tv.js";
8093      scriptElement.defer = true;
8094      scriptElement.setAttribute("data-tokero-tradingview-script", "true");
8095
8096      scriptElement.addEventListener("load", function () {
8097        scriptElement.dataset.loaded = "true";
8098        if (typeof window.onTradingViewLoad === "function") {
8099          window.onTradingViewLoad();
8100        }
8101        resolve();
8102      }, { once: true });
8103
8104      scriptElement.addEventListener("error", function () {
8105        reject(new Error("Failed to load TradingView script."));
8106      }, { once: true });
8107
8108      document.body.appendChild(scriptElement);
8109    });
8110  };
8111
8112/* TKR-10855: hands the visitor's cookie decision, and the analytics buffer the page has been holding since it
8113   rendered, to the server so it can persist the identifiers and send the page view it withheld.
8114
8115   Runs only on Accept. A visitor who never answers the banner never reaches this, so nothing about them is ever
8116   stored or transmitted. Failures are swallowed on purpose: analytics must never be able to break the banner or
8117   block the click that dismisses it. */
8118window.tokeroSubmitCookiePreferences = async function (acceptedCookieTypes) {
8119  try {
8120    var pageBuffer = null;
8121    var bufferElement = document.getElementById('tk-session-buffer');
8122    if (bufferElement && bufferElement.textContent) {
8123      pageBuffer = JSON.parse(bufferElement.textContent);
8124      /* The server leaves the referrer out of the buffer: the page can be a cached copy served to other visitors. */
8125      pageBuffer.referrer = document.referrer;
8126    }
8127
8128    var payload = {
8129      acceptedCookieTypes: acceptedCookieTypes || [],
8130      pageBuffer: pageBuffer
8131    };
8132
8133    /* keepalive so the send survives the visitor immediately navigating away from the page they accepted on. */
8134    await fetch('/api/session/preferences/', {
8135      method: 'POST',
8136      headers: { 'Content-Type': 'application/json' },
8137      body: JSON.stringify(payload),
8138      credentials: 'same-origin',
8139      keepalive: true
8140    });
8141  } catch (err) {
8142    /* Intentionally silent - see above. */
8143  }
8144};
8145
8146/* TKR-10910: reports a completed webinar registration as a first-party conversion POST.
8147
8148   The registration form submits inside the Blazor circuit, which has no HttpContext, so the server-side
8149   handler cannot dispatch the analytics event itself - the browser posts the fact to our own origin instead,
8150   where consent, IP and user agent are real. The server re-anchors everything to the registration row it
8151   already wrote, so this call carries only the two ids. Failures are swallowed on purpose: reporting must
8152   never be able to break the signup. */
8153window.tokeroReportWebinarRegistration = async function (marketingEventId, registrationId) {
8154  try {
8155    var pageBuffer = null;
8156    var bufferElement = document.getElementById('tk-session-buffer');
8157    if (bufferElement && bufferElement.textContent) {
8158      pageBuffer = JSON.parse(bufferElement.textContent);
8159      /* Not in the server's buffer - see tokeroSubmitCookiePreferences. */
8160      pageBuffer.referrer = document.referrer;
8161    }
8162
8163    var payload = {
8164      marketingEventID: marketingEventId,
8165      registrationID: registrationId,
8166      pageBuffer: pageBuffer
8167    };
8168
8169    /* keepalive so the send survives the thank-you redirect that follows a successful registration. */
8170    await fetch('/api/session/webinar-registration/', {
8171      method: 'POST',
8172      headers: { 'Content-Type': 'application/json' },
8173      body: JSON.stringify(payload),
8174      credentials: 'same-origin',
8175      keepalive: true
8176    });
8177  } catch (err) {
8178    /* Intentionally silent - see above. */
8179  }
8180};
8181
8182/* TKR-10855: first-party engagement beacon.
8183
8184   Replicates what gtag.js does for time-on-page, because it is the one signal a server cannot produce: only the
8185   browser knows whether its tab is visible and focused. A counter accumulates ONLY while the page is both, and
8186   each report sends the delta since the last one - never a running total - which is the shape GA4 sums into
8187   "average engagement time".
8188
8189   It posts to our own origin. Filter lists block vendor hosts, not first-party fetches, so this keeps working in
8190   exactly the browsers that killed the tag we removed.
8191
8192   Nothing runs until the page has consent: startEngagementTracking is only called once cookies are accepted. */
8193window.tokeroEngagementTracker = (function () {
8194  var accumulatedMs = 0;          /* engaged time not yet reported */
8195  var lastResumedAt = null;       /* when the current engaged stretch began, null while paused */
8196  var trackedUrl = null;
8197  var trackedBufferText = null;   /* the buffer as it was at the last document render - see evaluateNavigation */
8198  var flushTimerId = null;
8199  var navigationTimerId = null;
8200  var historyHooksInstalled = false;
8201  var started = false;
8202
8203  var REPORT_INTERVAL_MS = 15000; /* backstop only: navigation is detected from history events, not this tick */
8204
8205  /* TKR-11105: how long to wait after a navigation signal before judging what kind of navigation it was.
8206
8207     Routes is rendered InteractiveServer, so the router lives in the circuit and moving between pages performs no
8208     HTTP request at all - the server never sees it and ServerSideAnalyticsMiddleware never counts it. The browser
8209     has to say so. But a navigation that DID reach the server must not be reported, or it counts twice, and the
8210     history event can fire before the new document has swapped in. Settling first is 
8210what makes the two
8211     distinguishable.
8212
8213     Long enough to outlast a same-origin enhanced-navigation fetch, because on a back/forward gesture the URL
8214     changes BEFORE the new document is patched in - judging too early there would report a page view the
8215     middleware also reported. The cost of the wait is that two navigations inside one window collapse to the
8216     later one; a page passed through in under a second is the right thing to lose if something must be. */
8217  var NAVIGATION_SETTLE_MS = 800;
8218
8219  function isEngaged() {
8220    if (document.visibilityState !== 'visible') {
8221      return false;
8222    }
8223    return document.hasFocus();
8224  }
8225
8226  function accumulate() {
8227    if (lastResumedAt !== null) {
8228      accumulatedMs += Date.now() - lastResumedAt;
8229      lastResumedAt = null;
8230    }
8231  }
8232
8233  function resume() {
8234    if (lastResumedAt === null && isEngaged()) {
8235      lastResumedAt = Date.now();
8236    }
8237  }
8238
8239  function readPageBuffer() {
8240    var bufferElement = document.getElementById('tk-session-buffer');
8241    if (!bufferElement || !bufferElement.textContent) {
8242      return null;
8243    }
8244    try {
8245      var pageBuffer = JSON.parse(bufferElement.textContent);
8246      /* Not in the server's buffer - see tokeroSubmitCookiePreferences. */
8247      pageBuffer.referrer = document.referrer;
8248      return pageBuffer;
8249    } catch (err) {
8250      return null;
8251    }
8252  }
8253
8254  /* useBeacon on the unload path: a normal fetch is cancelled when the page goes away, which is precisely the
8255     moment the largest and most valuable delta is pending. */
8256  function flush(useBeacon) {
8257    accumulate();
8258
8259    if (accumulatedMs < 1000) {
8260      return;                     /* sub-second noise is not worth a request */
8261    }
8262
8263    var pageBuffer = readPageBuffer();
8264    if (!pageBuffer) {
8265      accumulatedMs = 0;
8266      return;
8267    }
8268
8269    var payload = JSON.stringify({
8270      engagementTimeMilliseconds: Math.round(accumulatedMs),
8271      pageBuffer: pageBuffer
8272    });
8273    accumulatedMs = 0;            /* cleared before the send: a failed report must not double-count on the next one */
8274
8275    try {
8276      if (useBeacon && navigator.sendBeacon) {
8277        navigator.sendBeacon('/api/session/activity/', new Blob([payload], { type: 'application/json' }));
8278        return;
8279      }
8280
8281      fetch('/api/session/activity/', {
8282        method: 'POST',
8283        headers: { 'Content-Type': 'application/json' },
8284        body: payload,
8285        credentials: 'same-origin',
8286        keepalive: true
8287      });
8288    } catch (err) {
8289      /* Measurement must never surface to the visitor. */
8290    }
8291  }
8292
8293  function onVisibilityChange() {
8294    if (isEngaged()) {
8295      resume();
8296      return;
8297    }
8298    flush(true);                  /* hiding the tab is an unload-shaped moment */
8299  }
8300
8301  function onIntervalTick() {
8302    /* A navigation is already waiting to be judged - let it settle rather than forcing an early verdict, which
8303       is exactly the premature call NAVIGATION_SETTLE_MS exists to avoid. */
8304    if (navigationTimerId !== null) {
8305      return;
8306    }
8307
8308    /* Backstop for a navigation that produced no history event, or one whose signal was missed. Same decision
8309       path as the event-driven route, so the two can never disagree about what counts as a navigation. */
8310    if (window.location.href !== trackedUrl) {
8311      evaluateNavigation();
8312      return;
8313    }
8314
8315    /* flush() accumulates the stretch itself and leaves the clock stopped, so the restart has to come AFTER it -
8316       the ordering evaluateNavigation and onEnhancedLoad already use. Resuming first meant the stretch reported
8317       here was the ~0 ms since that resume, and every window after the first one measured nothing. */
8318    flush(false);
8319    resume();
8320  }
8321
8322  function readPageBufferText() {
8323    var bufferElement = document.getElementById('tk-session-buffer');
8324    if (!bufferElement || !bufferElement.textContent) {
8325      return null;
8326    }
8327    return bufferElement.textContent;
8328  }
8329
8330  /* Coalesces bursts: Blazor can emit several history calls for one navigation, and only the settled result
8331     matters. */
8332  function onNavigationSignal() {
8333    if (navigationTimerId !== null) {
8334      window.clearTimeout(navigationTimerId);
8335    }
8336    navigationTimerId = window.setTimeout(evaluateNavigation, NAVIGATION_SETTLE_MS);
8337  }
8338
8339  function evaluateNavigation() {
8340    if (navigationTimerId !== null) {
8341      window.clearTimeout(navigationTimerId);
8342      navigationTimerId = null;
8343    }
8344
8345    var currentUrl = window.location.href;
8346    if (currentUrl === trackedUrl) {
8347      return;
8348    }
8349
8350    var previousUrl = trackedUrl;
8351
8352    /* Engaged time belongs to the page it was spent on, so it is flushed against the outgoing page before the
8353       new URL is adopted. This is the behaviour the interval tick already had. */
8354    flush(false);
8355    trackedUrl = currentUrl;
8356    resume();                     /* the new page starts accruing now, not at the next tick */
8357
8358    var bufferText = readPageBufferText();
8359    if (bufferText === null) {
8360      return;
8361    }
8362
8363    /* Every document render mints a fresh event id, so a buffer that is byte-identical to the one this circuit
8364       started with proves no document was rendered - the router moved inside the circuit and the middleware
8365       never saw it. Comparing the whole buffer rather than one field keeps this immune to how the serializer
8366       happens to case its property names.
8367
8368       This comparison IS the double-count guard: a navigation the server did handle arrives with a new buffer,
8369       and we adopt it and stay quiet, because the middleware has already reported that page view itself. */
8370    if (bufferText !== trackedBufferText) {
8371      trackedBufferText = bufferText;
8372      return;
8373    }
8374
8375    reportPageView(currentUrl, previousUrl, bufferText);
8376  }
8377
8378  function reportPageView(pageUrl, referrer, bufferText) {
8379    var pageBuffer = null;
8380    try {
8381      pageBuffer = JSON.parse(bufferText);
8382      /* Not in the server's buffer - see tokeroSubmitCookiePreferences. */
8383      pageBuffer.referrer = document.referrer;
8384    } catch (err) {
8385      return;
8386    }
8387
8388    var payload = JSON.stringify({
8389      pageUrl: pageUrl,
8390      referrer: referrer,
8391      pageBuffer: pageBuffer
8392    });
8393
8394    try {
8395      /* keepalive so the report survives the visitor immediately navigating onward again. */
8396      fetch('/api/session/pageview/', {
8397        method: 'POST',
8398        headers: { 'Content-Type': 'application/json' },
8399        body: payload,
8400        credentials: 'same-origin',
8401        keepalive: true
8402      });
8403    } catch (err) {
8404      /* Measurement must never surface to the visitor. */
8405    }
8406  }
8407
8408  /* An enhanced navigation IS a document request, so the middleware reported that page view itself and this must
8409     not report it again. Blazor raises enhancedload once the new document has been patched in, which is the
8410     definitive "the server handled this one" signal - it cancels any pending judgement and adopts the new state
8411     silently. Belt and braces next to the buffer comparison, and the thing that closes the back/forward race
8412     NAVIGATION_SETTLE_MS can only narrow. */
8413  function onEnhancedLoad() {
8414    if (navigationTimerId !== null) {
8415      window.clearTimeout(navigationTimerId);
8416      navigationTimerId = null;
8417    }
8418
8419    if (window.location.href !== trackedUrl) {
8420      flush(false);
8421      trackedUrl = window.location.href;
8422      resume();
8423    }
8424
8425    trackedBufferText = readPageBufferText();
8426  }
8427
8428  /* Blazor routes in-circuit through history.pushState, and there is no event for that - patching is the only
8429     way to learn about a navigation at the moment it happens rather than up to REPORT_INTERVAL_MS later. The
8430     originals are always called first and their return value passed straight through, so this 
8430stays invisible to
8431     anything else using the History API. */
8432  function installHistoryHooks() {
8433    if (historyHooksInstalled) {
8434      return;
8435    }
8436    historyHooksInstalled = true;
8437
8438    var originalPushState = window.history.pushState;
8439    var originalReplaceState = window.history.replaceState;
8440
8441    window.history.pushState = function () {
8442      var pushResult = originalPushState.apply(this, arguments);
8443      onNavigationSignal();
8444      return pushResult;
8445    };
8446
8447    window.history.replaceState = function () {
8448      var replaceResult = originalReplaceState.apply(this, arguments);
8449      onNavigationSignal();
8450      return replaceResult;
8451    };
8452  }
8453
8454  return {
8455    start: function () {
8456      if (started) {
8457        return;
8458      }
8459      started = true;
8460      trackedUrl = window.location.href;
8461      /* The buffer as it stands right now is the one the middleware already counted a page view for. Recording it
8462         here is what makes the FIRST in-circuit navigation detectable. */
8463      trackedBufferText = readPageBufferText();
8464      resume();
8465
8466      document.addEventListener('visibilitychange', onVisibilityChange);
8467      window.addEventListener('focus', resume);
8468      window.addEventListener('blur', function () { flush(false); });
8469      /* pagehide as well as visibilitychange: iOS Safari is unreliable on the latter for real navigations. */
8470      window.addEventListener('pagehide', function () { flush(true); });
8471      window.addEventListener('popstate', onNavigationSignal);
8472      document.addEventListener('enhancedload', onEnhancedLoad);
8473      installHistoryHooks();
8474
8475      flushTimerId = window.setInterval(onIntervalTick, REPORT_INTERVAL_MS);
8476    }
8477  };
8478})();
8479
8480/* Called from the page when consent is already on record, and from the consent banner the moment it is granted. */
8481window.tokeroStartEngagementTracking = function () {
8482  try {
8483    window.tokeroEngagementTracker.start();
8484  } catch (err) {
8485    /* Never break the page for a measurement feature. */
8486  }
8487};
8488
8489/* Called from App.razor on load. Reads the visitor's own cookieControlPrefs the way the banner check in App.razor's
8490   head does, and CookieConsentReader.HasAnalyticsConsent on the server: a JSON array that names "analytics". The
8491   document itself can be a cached copy rendered for another visitor, so it cannot carry this decision. */
8492window.tokeroStartEngagementTrackingWhenConsented = function () {
8493  try {
8494    var match = document.cookie.match(/(?:^|;\s*)cookieControlPrefs=([^;]+)/);
8495    if (!match || !match[1]) {
8496      return;
8497    }
8498
8499    var acceptedCookieTypes = JSON.parse(decodeURIComponent(match[1]));
8500    if (!Array.isArray(acceptedCookieTypes)) {
8501      return;
8502    }
8503
8504    if (acceptedCookieTypes.indexOf('analytics') === -1) {
8505      return;
8506    }
8507
8508    window.tokeroStartEngagementTracking();
8509  } catch (err) {
8510    /* A malformed cookie is no consent, and a measurement feature never breaks the page. */
8511  }
8512};
8513
8514/* Versioned request/reply channel to the MAUI shell. Desktop and old app builds resolve absence as a normal
8515   result, so a StoreKit-only control never waits forever for a receiver that cannot answer. */
8516window.TokeroMauiBridgeClient = window.TokeroMauiBridgeClient || (function () {
8517  var protocolVersion = 1;
8518  var pending = {};
8519  var isSubscribed = false;
8520
8521  function canRequest() {
8522    var hasSender = window.ReactNativeWebView && window.ReactNativeWebView.postMessage;
8523    var supportedVersion = (window.__mauiBridgeVersion || 0) >= protocolVersion;
8524    return !!hasSender && supportedVersion;
8525  }
8526
8527  function acknowledge(requestID) {
8528    if (!canRequest()) {
8529      return;
8530    }
8531
8532    var envelope = {
8533      v: protocolVersion,
8534      id: requestID,
8535      type: 'bridge.ack'
8536    };
8537    var envelopeJson = JSON.stringify(envelope);
8538    window.ReactNativeWebView.postMessage(envelopeJson);
8539  }
8540
8541  function settle(envelope) {
8542    if (!envelope || !envelope.id) {
8543      return;
8544    }
8545
8546    var request = pending[envelope.id];
8547    if (!request) {
8548      return;
8549    }
8550
8551    delete pending[envelope.id];
8552    window.clearTimeout(request.timeoutHandle);
8553    acknowledge(envelope.id);
8554    request.resolve({
8555      ok: envelope.ok === true,
8556      code: envelope.code || 'INTERNAL',
8557      payload: envelope.payload || {}
8558    });
8559  }
8560
8561  function subscribe() {
8562    if (isSubscribed) {
8563      return;
8564    }
8565
8566    isSubscribed = true;
8567    window.addEventListener('maui:message', function (event) {
8568      settle(event.detail);
8569    });
8570
8571    if (window.__mauiSubscribe) {
8572      window.__mauiSubscribe();
8573    }
8574  }
8575
8576  function ready(timeoutMs) {
8577    if (canRequest()) {
8578      subscribe();
8579      return Promise.resolve(true);
8580    }
8581
8582    return new Promise(function (resolve) {
8583      var wasSettled = false;
8584
8585      function finish(result) {
8586        if (wasSettled) {
8587          return;
8588        }
8589
8590        wasSettled = true;
8591        window.removeEventListener('maui:bridgeready', onReady);
8592        if (result) {
8593          subscribe();
8594        }
8595        resolve(result);
8596      }
8597
8598      function onReady() {
8599        finish(canRequest());
8600      }
8601
8602      window.addEventListener('maui:bridgeready', onReady);
8603      window.setTimeout(function () {
8604        finish(canRequest());
8605      }, timeoutMs || 500);
8606    });
8607  }
8608
8609  function request(type, payload, timeoutMs) {
8610    if (!canRequest()) {
8611      return Promise.resolve({ ok: false, code: 'BRIDGE_ABSENT', payload: {} });
8612    }
8613
8614    subscribe();
8615    var requestID = 'r-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
8616    return new Promise(function (resolve) {
8617      var timeoutHandle = window.setTimeout(function () {
8618        delete pending[requestID];
8619        resolve({ ok: false, code: 'TIMEOUT', payload: {} });
8620      }, timeoutMs || 30000);
8621
8622      pending[requestID] = {
8623        resolve: resolve,
8624        timeoutHandle: timeoutHandle
8625      };
8626
8627      var envelope = {
8628        v: protocolVersion,
8629        id: requestID,
8630        type: type,
8631        payload: payload || {}
8632      };
8633      var envelopeJson = JSON.stringify(envelope);
8634      window.ReactNativeWebView.postMessage(envelopeJson);
8635    });
8636  }
8637
8638  return {
8639    canRequest: canRequest,
8640    ready: ready,
8641    request: request
8642  };
8643})();
8644
8645window.mauiBridgeReady = function () {
8646  return window.TokeroMauiBridgeClient.ready(500);
8647};
8648
8649window.mauiBridgeRequest = function (type, payload, timeoutMs) {
8650  return window.TokeroMauiBridgeClient.request(type, payload, timeoutMs);
8651};
8652
8653/* Content the OS share sheet handed to the MAUI shell, claimed by the page that can compose a post out of it
8654   (TKR-11477).
8655
8656   A PULL rather than a push, which is the whole reason this is three lines instead of a delivery mechanism.
8657   TokeroMauiBridgeClient.settle() drops any envelope that does not correlate to a request the page itself sent,
8658   so an unsolicited native push would need new delivery semantics on a channel that currently carries purchases.
8659   Asking for the payload when the composer is ready also removes the race the push would have created: the
8660   shell can receive a share long before this document exists, and a page that asks cannot ask too early.
8661
8662   One-shot by contract: the shell clears its inbox as it answers, so a reload does not re-attach the same
8663   pictures to a post the member has already started editing. Returns null off the app, on a build older than
8664   the bridge, and when nothing was shared - every caller treats all three the same way, which is to do nothing. */
8665window.tokeroTakeMauiSharedContent = function () {
8666  var bridgeClient = window.TokeroMauiBridgeClient;
8667  if (!bridgeClient || !bridgeClient.canRequest()) {
8668    return Promise.resolve(null);
8669  }
8670
8671  return bridgeClient.request('share.take', {}, 10000).then(function (result) {
8672    if (!result || result.ok !== true) {
8673      return null;
8674    }
8675
8676    var payload = result.payload || {};
8677    var sharedText = payload.text || '';
8678    var sharedUrl = payload.url || '';
8679    var sharedImages = payload.images || [];
8680    var hasAnything = sharedText.length > 0 || sharedUrl.length > 0 || sharedImages.length > 0;
8681    if (!hasAnything) {
8682      return null;
8683    }
8684
8685    return { text: sharedText, url: sharedUrl, images: sharedImages };
8686  }).catch(function () {
8687    /* Never let a share hand-off break the page it was supposed to fill. */
8688    return null;
8689  });
8690};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.