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.