1'use strict'; 2 3// Per-player state keyed by elementId 4const _players = new Map(); 5 6// ââ Extraction-method overrides (runs once at module load) ââââââââââââââââââââ 7(function _blockExtract() { 8 const _n = () => {}; 9 const _e = () => ''; 10 // Block canvas.toDataURL / toBlob â return empty/no-op so screenshots give nothing 11 try { Object.defineProperty(HTMLCanvasElement.prototype, 'toDataURL', { value: _e, writable: false, configurable: false }); } catch(_) {} 12 try { Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { value: _n, writable: false, configurable: false }); } catch(_) {} 13 // Block WebGL readPixels on all context types 14 ['WebGLRenderingContext', 'WebGL2RenderingContext'].forEach(name => { 15 const C = window[name]; 16 if (C) try { Object.defineProperty(C.prototype, 'readPixels', { value: _n, writable: false, configurable: false }); } catch(_) {} 17 }); 18})(); 19 20// Prevents right-click context menu and drag on an element. 21function _noRightClick(el) { 22 el.addEventListener('contextmenu', e => e.preventDefault()); 23 el.addEventListener('dragstart', e => e.preventDefault()); 24} 25 26// (DevTools-detection guard removed: it closed the player / dropped the crypto key when a docked 27// DevTools panel was detected, which blocked debugging.) 28 29const CHUNK_SIZE = 512 * 1024; // 512 KB per request 30const BUFFER_AHEAD = 60; // seconds to keep buffered ahead of playhead 31 32// ââ Decrypted-chunk cache ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 33// Holds already-fetched+decrypted chunks in memory, keyed by `${assetId}:${rangeStart}`, so seeking back to a 34// visited region, re-buffering after a seek, or re-opening the same tutorial skips both the network fetch and 35// the crypto. Media responses are `no-store` (DRM) and the browser cache is unusable, so this is the only reuse 36// layer. LRU-capped by total bytes to stay bounded across many/large videos. Lives per page load (circuit); 37// the (circuit, asset) decryption key is deterministic, so cached plaintext stays valid across opens. 38const CHUNK_CACHE_MAX_BYTES = 150 * 1024 * 1024; // 150 MB 39const _chunkCache = new Map(); // key â { buf: ArrayBuffer (raw, pre-trim), encLen, totalLength, duration } 40let _chunkCacheBytes = 0; 41 42function _cacheGet(key) { 43 if (!key) return null; 44 const entry = _chunkCache.get(key); 45 if (!entry) return null; 46 _chunkCache.delete(key); // move to most-recently-used 47 _chunkCache.set(key, entry); 48 return entry; 49} 50 51function _cachePut(key, entry) { 52 if (!key) return; 53 if (_chunkCache.has(key)) { _chunkCacheBytes -= _chunkCache.get(key).buf.byteLength; _chunkCache.delete(key); } 54 _chunkCache.set(key, entry); 55 _chunkCacheBytes += entry.buf.byteLength; 56 while (_chunkCacheBytes > CHUNK_CACHE_MAX_BYTES && _chunkCache.size > 0) { 57 const oldestKey = _chunkCache.keys().next().value; // insertion order = LRU 58 _chunkCacheBytes -= _chunkCache.get(oldestKey).buf.byteLength; 59 _chunkCache.delete(oldestKey); 60 } 61} 62 63// Raw audio containers (MP3, OGG Vorbis) carry no timestamp boxes â MSE must 64// run them in 'sequence' mode and receive a timestampOffset on seek instead of 65// an init-segment re-append. 66const RAW_AUDIO_TYPES = ['audio/mpeg', 'audio/ogg']; 67 68// ââ Public API âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 69 70export async function initEncryptedPlayer(elementId, keyB64, encStreamUrl, mimeCodec, assetId = null) { 71 try { 72 const cryptoKey = await _importKey(keyB64); 73 const nonce8 = new Uint8Array(8); // zero nonce â key is unique per (circuit, asset) 74 75 const resolvedMime = _detectMimeCodec(mimeCodec); 76 if (!resolvedMime) { 77 _showError(elementId, 'MSE / codec not supported in this browser.'); 78 return; 79 } 80 81 const el = document.getElementById(elementId); 82 if (!el) { console.error('[EncryptedPlayer] element not found:', elementId); return; } 83 _noRightClick(el); 84 85 const isSequenceMode = RAW_AUDIO_TYPES.some(t => resolvedMime.startsWith(t)); 86 87 const mediaSource = new MediaSource(); 88 const state = { 89 el, elementId, mediaSource, cryptoKey, nonce8, 90 encStreamUrl, mimeCodec: resolvedMime, 91 assetId, // cache key prefix for decrypted-chunk reuse across seeks/opens 92 93 byteOffset: 0, totalLength: null, 94 sourceBuffer: null, fetching: false, ended: false, 95 seeking: false, 96 // Counts VBR seek corrections already applied for the current seek. 97 // Reset to 0 on every new user-initiated seek; capped at 1 to avoid looping. 98 seekCorrections: 0, 99 // Incremented on every seek â lets in-flight fetches detect they are stale. 100 fetchGen: 0, 101 // After a seek, the first fetched chunk may start mid-mdat (garbage bytes 102 // before the next moof). This flag tells _fetchAndAppend to trim to the 103 // first moof boundary before appending.
104 seekTrimNeeded: false, 105 isSequenceMode, 106 // fMP4 init segment cached for seek recovery (segments mode only) 107 initChunk: null, 108 contentDuration: null, 109 durationApplied: false, 110 firstAppendDone: false, 111 playRequested: false 112 }; 113 _players.set(elementId, state); 114 115 const blobUrl = URL.createObjectURL(mediaSource); 116 mediaSource.addEventListener('sourceopen', () => _onSourceOpen(elementId, state)); 117 mediaSource.addEventListener('sourceclose', () => console.warn('[EncryptedPlayer] sourceclose')); 118 el.src = blobUrl; 119 120 } catch (err) { 121 console.error('[EncryptedPlayer] init failed:', err); 122 _showError(elementId, 'Encrypted player init failed: ' + err.message); 123 } 124} 125 126export function bindPlayerControls( 127 videoId, playBtnId, seekerId, timeDisplayId, volumeId, 128 fullscreenBtnId, containerId, dotnetRef, endedMethod) 129{ 130 const el = document.getElementById(videoId); 131 const playBtn = document.getElementById(playBtnId); 132 const seeker = document.getElementById(seekerId); 133 const timeDisp = document.getElementById(timeDisplayId); 134 const volume = document.getElementById(volumeId); 135 const fsBtn = fullscreenBtnId ? document.getElementById(fullscreenBtnId) : null; 136 const container = containerId ? document.getElementById(containerId) : el; 137 if (!el || !playBtn) return; 138 139 // Click on the media element itself to toggle play / pause 140 el.addEventListener('click', () => { 141 if (el.paused) { _tryPlay(videoId); } else { el.pause(); } 142 }); 143 144 // Dedicated play button 145 playBtn.addEventListener('click', () => { 146 if (el.paused) { _tryPlay(videoId); } else { el.pause(); } 147 }); 148 149 const playSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>'; 150 const pauseSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>'; 151 playBtn.innerHTML = playSvg; 152 el.addEventListener('play', () => { playBtn.innerHTML = pauseSvg; }); 153 el.addEventListener('pause', () => { playBtn.innerHTML = playSvg; }); 154 el.addEventListener('ended', () => { 155 playBtn.innerHTML = playSvg; 156 // Optional .NET notification (e.g. play a click when a solution finished). Only when a ref was supplied. 157 if (dotnetRef && endedMethod) { try { dotnetRef.invokeMethodAsync(endedMethod); } catch (e) {} } 158 }); 159 160 // Time display + seeker 161 el.addEventListener('timeupdate', () => { 162 const dur = el.duration; 163 // Seeker position requires a finite duration (not available in MSE sequence mode). 164 if (isFinite(dur) && dur > 0) { 165 if (seeker) seeker.value = (el.currentTime / dur * 100).toFixed(2); 166 } 167 // Always update the time display; _fmt renders Infinity as '?:??'. 168 if (timeDisp) timeDisp.textContent = `${_fmt(el.currentTime)} / ${_fmt(dur)}`; 169 const state = _players.get(videoId); 170 if (state) _maybeFetchNext(videoId, state); 171 }); 172 173 if (seeker) { 174 seeker.addEventListener('input', () => { 175 const dur = el.duration; 176 if (isFinite(dur) && dur > 0) 177 el.currentTime = (seeker.value / 100) * dur; 178 }); 179 } 180 181 el.addEventListener('seeking', () => { 182 const state = _players.get(videoId); 183 if (!state || !isFinite(el.duration) || el.duration <= 0) return; 184 state.seekCorrections = 0; // new user-initiated seek â reset correction counter 185 _handleSeek(videoId, state, el.currentTime); 186 }); 187 188 if (volume) { 189 const volIcon = document.createElement('span'); 190 volIcon.className = 'enc-vol-icon'; 191 volIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>'; 192 volume.parentNode.insertBefore(volIcon, volume); 193 volume.addEventListener('input', () => { el.volume = parseFloat(volume.value); }); 194 } 195 196 if (fsBtn && container) { 197 fsBtn.addEventListener('click', () => { 198 if (document.fullscreenElement) { 199 document.exitFullscreen(); 200 } else { 201 container.requestFullscreen().catch(err => { 202 console.warn('[EncryptedPlayer] fullscreen failed:', err.message); 203 }); 204 } 205 }); 206 const fsSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>'; 207 const fsExitSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>'; 208 fsBtn.innerHTML = fsSvg; 209 document.addEventListener('fullscreenchange', () => { 210 fsBtn.innerHTML = document.fullscreenElement ? fsExitSvg : fsSvg; 211 }); 212 } 213} 214 215export function disposePlayer(elementId) { 216 const state = _players.get(elementId); 217 if (!state) return; 218 try { 219 URL.revokeObjectURL(state.el.src); 220 state.el.src = ''; 221 } catch (_) {} 222 _players.delete(elementId); 223} 224 225export function getPlayerTime(elementId) { 226 const state = _players.get(elementId); 227 if (!state || !state.el) return 0; 228 return Math.floor(state.el.currentTime); 229} 230 231// Programmatic play/pause/volume for a headless/background player (no visible controls). play() defers 232// correctly until the first MSE append via the existing _tryPlay logic. 233export function playPlayer(elementId) { 234 if (_players.has(elementId)) _tryPlay(elementId); 235} 236 237export function pausePlayer(elementId) { 238 const state = _players.get(elementId); 239 if (state && state.el) { try { state.el.pause(); } catch (_) {} } 240} 241 242export function setVolume(elementId, volume) { 243 const state = _players.get(elementId); 244 if (state && state.el) state.el.volume = Math.max(0, Math.min(1, volume)); 245} 246 247export function seekPlayer(elementId, seconds) { 248 const state = _players.get(elementId); 249 if (!state || !state.el) return; 250 const el = state.el; 251 252 function trySeek() { 253 const dur = el.duration; 254 if (isFinite(dur) && dur > 0 && state.totalLength) { 255 el.currentTime = seconds; 256 return; 257 } 258 el.addEventListener('durationchange', function handler() { 259 if (isFinite(el.duration) && el.duration > 0 && state.totalLength) { 260 el.removeEventListener('durationchange', handler); 261 el.currentTime = seconds; 262 } 263 }); 264 } 265 266 if (el.readyState >= 1) { 267 trySeek(); 268 } else { 269 el.addEventListener('loadedmetadata', () => trySeek(), { once: true }); 270 } 271} 272 273// ââ Static asset decryption (image / document) âââââââââââââââââââââââââââââââ 274 275// Image: decrypt â ImageBitmap â upload to GPU texture â WebGL canvas (preserveDrawingBuffer:false).
276// The pixel data never lives in an accessible 2D canvas â only in the GPU texture. 277export async function initEncryptedImage(elementId, keyB64, encUrl, mimeType) { 278 try { 279 const cryptoKey = await _importKey(keyB64); 280 const nonce8 = new Uint8Array(8); 281 282 const response = await fetch(encUrl, { credentials: 'same-origin' }); 283 if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); 284 285 const encBuf = await response.arrayBuffer(); 286 const decBuf = await _decryptFromOffset(encBuf, cryptoKey, nonce8, 0); 287 288 const bitmap = await createImageBitmap(new Blob([decBuf], { type: mimeType })); 289 const view = _makeWebGLView(bitmap.width, bitmap.height); 290 if (!view) throw new Error('WebGL not available'); 291 292 view.upload(bitmap); 293 bitmap.close(); // release CPU-side pixel data â texture is now GPU-only 294 295 view.canvas.style.cssText += ';max-width:100%;height:auto;display:block;'; 296 view.startLoop(); 297 298 const el = document.getElementById(elementId); 299 if (el) el.replaceWith(view.canvas); 300 else console.error('[EncryptedImage] element not found:', elementId); 301 } catch (err) { 302 console.error('[EncryptedImage] init failed:', err); 303 _showError(elementId, 'Encrypted image error: ' + err.message); 304 } 305} 306 307// Encrypted audio: decrypt â blob URL. Blob URL is revoked once the browser has loaded 308// the data into its media engine so the URL can no longer be fetched externally. 309export async function initEncryptedAudio(elementId, keyB64, encUrl, mimeType) { 310 try { 311 const cryptoKey = await _importKey(keyB64); 312 const nonce8 = new Uint8Array(8); 313 314 const response = await fetch(encUrl, { credentials: 'same-origin' }); 315 if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); 316 317 const encBuf = await response.arrayBuffer(); 318 const decBuf = await _decryptFromOffset(encBuf, cryptoKey, nonce8, 0); 319 const blobUrl = URL.createObjectURL(new Blob([decBuf], { type: mimeType })); 320 321 const el = document.getElementById(elementId); 322 if (el) { 323 el.src = blobUrl; 324 // Szenario-Funksprüche auf das gewählte Ausgabegerät leiten (best-effort; setSinkId nur Chromium/Firefox). 325 try { 326 const spkId = localStorage.getItem('funkom.audio.speakerDeviceId'); 327 if (spkId && typeof el.setSinkId === 'function') { 328 el.setSinkId(spkId).catch(() => { /* Standardgerät verwenden */ }); 329 } 330 } catch (e) { /* egal */ } 331 _noRightClick(el); 332 // Revoke as soon as the browser has buffered enough â the URL is no longer fetchable. 333 el.addEventListener('canplaythrough', () => URL.revokeObjectURL(blobUrl), { once: true }); 334 } else console.error('[EncryptedAudio] element not found:', elementId); 335 } catch (err) { 336 console.error('[EncryptedAudio] init failed:', err); 337 _showError(elementId, 'Encrypted audio error: ' + err.message); 338 } 339} 340 341// Document: PDF.js renders each page into an offscreen 2D canvas, which is 342// immediately uploaded as a GPU texture and then cleared. The visible output is 343// a WebGL canvas with preserveDrawingBuffer:false â readPixels / toDataURL 344// return nothing after the frame is presented. 345export async function initEncryptedDocument(elementId, keyB64, encUrl) { 346 const container = document.getElementById(elementId); 347 if (!container) { console.error('[EncryptedDocument] container not found:', elementId); return; } 348 349 try { 350 const cryptoKey = await _importKey(keyB64); 351 const nonce8 = new Uint8Array(8); 352 353 const response = await fetch(encUrl, { credentials: 'same-origin' }); 354 if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); 355 356 const encBuf = await response.arrayBuffer(); 357 const decBuf = await _decryptFromOffset(encBuf, cryptoKey, nonce8, 0); 358 359 const pdfjsLib = await import('/js/pdfjs/pdf.mjs'); 360 pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdfjs/pdf.worker.mjs'; 361 362 const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(decBuf) }).promise; 363 const numPages = pdf.numPages; 364 let curPage = 1; 365 let scale = 1.4; 366 367 container.innerHTML = `
368 <div class="enc-pdf-bar"> 369 <button class="enc-pdf-btn" id="${elementId}-prev" title="Zurück">‹</button> 370 <span class="enc-pdf-pagenum"> 371 <span id="${elementId}-cur">1</span> / ${numPages} 372 </span> 373 <button class="enc-pdf-btn" id="${elementId}-next" title="Weiter">›</button> 374 <span class="enc-pdf-sep"></span> 375 <button class="enc-pdf-btn" id="${elementId}-zout" title="Verkleinern">−</button> 376 <button class="enc-pdf-btn" id="${elementId}-zin" title="VergröÃern">+</button> 377 </div> 378 <div class="enc-pdf-viewport" id="${elementId}-vp"></div>`; 379 380 const dpr = window.devicePixelRatio || 1; 381 const vp = document.getElementById(`${elementId}-vp`); 382 const offscreen = document.createElement('canvas'); // never added to DOM 383 let glView = null; 384 385 async function render(pageNum) { 386 const page = await pdf.getPage(pageNum); 387 const viewport = page.getViewport({ scale: scale * dpr }); 388 const w = viewport.width | 0; 389 const h = viewport.height | 0; 390 391 // Render into offscreen 2D canvas (not in DOM â toDataURL still blocked globally) 392 offscreen.width = w; 393 offscreen.height = h; 394 const ctx2d = offscreen.getContext('2d'); 395 await page.render({ canvasContext: ctx2d, viewport }).promise; 396 397 // Create or resize the WebGL view 398 if (!glView) { 399 glView = _makeWebGLView(w, h); 400 if (!glView) throw new Error('WebGL not available'); 401 vp.appendChild(glView.canvas); 402 glView.startLoop(); 403 } else { 404 glView.resize(w, h); 405 } 406 glView.canvas.style.width = Math.floor(w / dpr) + 'px'; 407 glView.canvas.style.height = Math.floor(h / dpr) + 'px'; 408 409 // Upload offscreen 2D canvas â GPU texture, then wipe the 2D canvas 410 glView.upload(offscreen); 411 ctx2d.clearRect(0, 0, w, h); 412 offscreen.width = 1; offscreen.height = 1; 413 414 document.getElementById(`${elementId}-cur`).textContent = pageNum; 415 } 416 417 document.getElementById(`${elementId}-prev`).addEventListener('click', async () => { 418 if (curPage > 1) { curPage--; await render(curPage); } 419 }); 420 document.getElementById(`${elementId}-next`).addEventListener('click', async () => { 421 if (curPage < numPages) { curPage++; await render(curPage); } 422 }); 423 document.getElementById(`${elementId}-zout`).addEventListener('click', async () => { 424 scale = Math.max(0.5, scale - 0.25); await render(curPage); 425 }); 426 document.getElementById(`${elementId}-zin`).addEventListener('click', async () => { 427 scale = Math.min(3.0, scale + 0.25); await render(curPage); 428 }); 429 430 await render(1); 431 432 } catch (err) { 433 console.error('[EncryptedDocument] init failed:', err); 434 container.innerHTML = `<div class="enc-player-error">Encrypted document error: ${err.message}</div>`; 435 } 436} 437 438// ââ WebGL helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 439 440function _buildGLProg(gl) { 441 function compile(type, src) { 442 const s = gl.createShader(type); 443 gl.shaderSource(s, src); 444 gl.compileShader(s); 445 return s; 446 } 447 const vs = compile(gl.VERTEX_SHADER, 448 'attribute vec2 p;attribute vec2 u;varying vec2 v;' + 449 'void main(){gl_Position=vec4(p,0.,1.);v=u;}'); 450 const fs = compile(gl.FRAGMENT_SHADER, 451 'precision mediump float;uniform sampler2D t;varying vec2 v;' + 452 'void main(){gl_FragColor=texture2D(t,v);}'); 453 const prog = gl.createProgram(); 454 gl.attachShader(prog, vs); gl.attachShader(prog, fs); 455 gl.linkProgram(prog); 456 gl.deleteShader(vs); gl.deleteShader(fs); 457 return prog; 458} 459 460function _makeWebGLView(w, h) { 461 const canvas = document.createElement('canvas'); 462 canvas.width = w; canvas.height = h; 463 canvas.style.cssText = 'max-width:100%;display:block;user-select:none;-webkit-user-drag:none;'; 464 _noRightClick(canvas); 465 const gl = canvas.getContext('webgl', { preserveDrawingBuffer: false, alpha: false }); 466 if (!gl) return null; 467 const prog = _buildGLProg(gl); 468 gl.useProgram(prog); 469 const buf = gl.createBuffer(); 470 gl.bindBuffer(gl.ARRAY_BUFFER, buf); 471 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ 472 -1,-1, 0,1, 1,-1, 1,1, -1,1, 0,0, 1,1, 1,0 473 ]), gl.STATIC_DRAW); 474 const ap = gl.getAttribLocation(prog, 'p'); 475 const au = gl.getAttribLocation(prog, 'u'); 476 gl.enableVertexAttribArray(ap); gl.enableVertexAttribArray(au); 477 gl.vertexAttribPointer(ap, 2, gl.FLOAT, false, 16, 0); 478 gl.vertexAttribPointer(au, 2, gl.FLOAT, false, 16, 8); 479 const tex = gl.createTexture(); 480 gl.bindTexture(gl.TEXTURE_2D, tex); 481 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 482 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 483 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 484 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 485 gl.viewport(0, 0, w, h); 486 let rafId = 0; 487 function upload(src) { 488 gl.bindTexture(gl.TEXTURE_2D, tex); 489 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src); 490 } 491 function resize(newW, newH) { 492 canvas.width = newW; canvas.height = newH; 493 gl.viewport(0, 0, newW, newH); 494 } 495 function startLoop() { 496 function frame() { 497 if (!canvas.isConnected) { rafId = 0; return; } 498 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); 499 rafId = requestAnimationFrame(frame); 500 } 501 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); 502 rafId = requestAnimationFrame(frame); 503 } 504 function stop() { 505 if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } 506 } 507 return { canvas, upload, resize, startLoop, stop }; 508} 509 510// ââ MediaSource plumbing ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 511 512function _onSourceOpen(elementId, state) { 513 try { 514 state.sourceBuffer = state.mediaSource.addSourceBuffer(state.mimeCodec); 515 516 // sequence mode â for raw audio formats (MP3, OGG) that carry no timing boxes. 517 // MSE auto-assigns timestamps; seek uses timestampOffset.
518 // segments mode â for fMP4 video/audio: timestamps come from the moov/moof boxes. 519 state.sourceBuffer.mode = state.isSequenceMode ? 'sequence' : 'segments'; 520 521 state.sourceBuffer.addEventListener('updateend', () => { 522 if (!state.firstAppendDone && state.sourceBuffer.buffered.length > 0) { 523 state.firstAppendDone = true; 524 if (state.playRequested) { 525 state.playRequested = false; 526 state.el.play().catch(() => {}); 527 } 528 } 529 530 if (!state.durationApplied && state.contentDuration > 0 531 && state.mediaSource.readyState === 'open') { 532 state.durationApplied = true; 533 try { state.mediaSource.duration = state.contentDuration; } catch (_) {} 534 } 535 if (state.seeking) return; 536 537 const el = state.el; 538 const sb = state.sourceBuffer; 539 if (el.seeking && sb.buffered.length > 0) { 540 const bufStart = sb.buffered.start(0); 541 if (bufStart > el.currentTime + 0.5) { 542 // VBR overshoot: the fetched chunk starts after the target time. 543 // One proportional correction using the actual time as calibration. 544 // fetchedAt â byteOffset - CHUNK_SIZE (byteOffset was incremented after fetch). 545 if (state.seekCorrections === 0 && state.byteOffset > CHUNK_SIZE) { 546 state.seekCorrections = 1; 547 const fetchedAt = state.byteOffset - CHUNK_SIZE; 548 const corrected = Math.max(0, Math.floor( 549 fetchedAt * el.currentTime / bufStart / CHUNK_SIZE 550 ) * CHUNK_SIZE); 551 _handleSeek(elementId, state, el.currentTime, corrected); 552 } else { 553 el.currentTime = bufStart; 554 } 555 return; 556 } 557 if (bufStart > el.currentTime) { 558 el.currentTime = bufStart; 559 } 560 } 561 562 _maybeFetchNext(elementId, state); 563 564 // Once all data is fetched, fix duration (stays Infinity in MSE sequence mode). 565 if (state.ended && state.mediaSource.readyState === 'open') { 566 const n = sb.buffered.length; 567 if (n > 0) try { state.mediaSource.duration = sb.buffered.end(n - 1); } catch (_) {} 568 } 569 }); 570 571 _fetchAndAppend(elementId, state); 572 } catch (err) { 573 console.error('[EncryptedPlayer] sourceopen error:', err); 574 } 575} 576 577function _tryPlay(elementId) { 578 const state = _players.get(elementId); 579 if (!state || !state.el) return; 580 if (!state.firstAppendDone) { 581 state.playRequested = true; 582 return; 583 } 584 state.el.play().catch(() => { 585 state.el.addEventListener('canplay', () => { 586 state.el.play().catch(() => {}); 587 }, { once: true }); 588 }); 589} 590 591function _maybeFetchNext(elementId, state) { 592 if (state.ended || state.fetching || state.seeking) return; 593 594 if (state.totalLength !== null && state.byteOffset >= state.totalLength) { 595 state.ended = true; 596 return; 597 } 598 599 const el = state.el; 600 const buffered = el.buffered; 601 const bufferEnd = buffered.length > 0 ? buffered.end(buffered.length - 1) : 0; 602 603 // Sequence mode (raw audio): fetch the entire file eagerly so that 604 // duration becomes known once all data is buffered. 605 // Segments mode (fMP4 video/audio): only buffer BUFFER_AHEAD seconds ahead. 606 if (state.isSequenceMode || bufferEnd - el.currentTime < BUFFER_AHEAD) { 607 _fetchAndAppend(elementId, state); 608 } 609} 610 611// Sets mediaSource.duration from the buffered end once all data has been fetched. 612// Called either from the updateend handler (normal last-chunk path) or directly 613// when the fetch loop ends without an appendBuffer call (e.g. 416 response). 614function _applyFinalDuration(state) { 615 if (state.mediaSource.readyState !== 'open') return; 616 const sb = state.sourceBuffer; 617 if (!sb || sb.updating || sb.buffered.length === 0) return; 618 try { state.mediaSource.duration = sb.buffered.end(sb.buffered.length - 1); } catch (_) {} 619} 620 621async function _fetchAndAppend(elementId, state) { 622 if (state.fetching || state.ended) return; 623 state.fetching = true; 624 625 // Snapshot the generation at fetch-start; discard result if a seek fires meanwhile. 626 const myGen = state.fetchGen; 627 const rangeStart = state.byteOffset; 628 const rangeEnd = rangeStart + CHUNK_SIZE - 1; 629 630 const cacheKey = state.assetId ? `${state.assetId}:${rangeStart}` : null; 631 632 try { 633 let decBuf; 634 let consumedLen; // encrypted bytes this chunk represents (to advance byteOffset) 635 636 const cached = _cacheGet(cacheKey); 637 if (cached) { 638 // Served from memory â no network fetch, no decryption. 639 if (state.totalLength === null && cached.totalLength != null) state.totalLength = cached.totalLength; 640 if (state.contentDuration === null && cached.duration != null) state.contentDuration = cached.duration; 641 decBuf = cached.buf.slice(0); // copy â appendBuffer detaches the ArrayBuffer 642 consumedLen = cached.encLen;
643 // A seek may have fired between scheduling and here (no await above, but stay defensive). 644 if (state.fetchGen !== myGen) { state.fetching = false; return; } 645 } else { 646 const response = await fetch(state.encStreamUrl, { 647 headers: { 'Range': `bytes=${rangeStart}-${rangeEnd}` }, 648 credentials: 'same-origin' 649 }); 650 651 if (response.status === 416 || response.status === 404) { 652 state.ended = true; state.fetching = false; 653 if (rangeStart === 0 && response.status === 404) { 654 _showError(elementId, 'Mediendatei nicht verfügbar. Bitte versuche es später erneut.'); 655 } 656 _applyFinalDuration(state); 657 return; 658 } 659 if (!response.ok && response.status !== 206) { 660 console.error('[EncryptedPlayer] fetch error:', response.status); 661 state.fetching = false; return; 662 } 663 664 if (state.totalLength === null) { 665 const cr = response.headers.get('Content-Range'); 666 if (cr) { 667 const slash = cr.lastIndexOf('/'); 668 if (slash >= 0) state.totalLength = parseInt(cr.slice(slash + 1)); 669 } 670 } 671 672 if (state.contentDuration === null) { 673 const xcd = response.headers.get('X-Ts'); 674 if (xcd) state.contentDuration = parseFloat(xcd); 675 } 676 677 const encBuf = await response.arrayBuffer(); 678 if (encBuf.byteLength === 0) { 679 state.ended = true; state.fetching = false; 680 _applyFinalDuration(state); // updateend won't fire â set duration now 681 return; 682 } 683 684 decBuf = await _decryptFromOffset(encBuf, state.cryptoKey, state.nonce8, rangeStart); 685 686 // Discard if a seek invalidated this fetch while we were awaiting. 687 if (state.fetchGen !== myGen) { state.fetching = false; return; } 688 689 consumedLen = encBuf.byteLength; 690 // Retain a copy of the decrypted (raw, pre-trim) chunk for reuse on later seeks/opens. 691 _cachePut(cacheKey, { 692 buf: decBuf.slice(0), encLen: consumedLen, 693 totalLength: state.totalLength, duration: state.contentDuration 694 }); 695 } 696 697 // Cache init segment (ftyp+moov only) for segments-mode seek recovery. 698 // Must be extracted before appendBuffer() transfers the ArrayBuffer. 699 if (!state.isSequenceMode && state.initChunk === null && rangeStart === 0) { 700 state.initChunk = _extractInitSegment(decBuf); 701 } 702 703 state.byteOffset += consumedLen; 704 state.fetching = false; 705 706 // After a seek the first chunk may start mid-mdat. Scan for the first 707 // moof box so MSE gets a valid fragment boundary. 708 let appendBuf = decBuf; 709 if (state.seekTrimNeeded) { 710 const trimmed = _trimToFirstMoof(decBuf); 711 if (trimmed === null) { 712 // No moof in this chunk yet â skip append, pull the next chunk. 713 _fetchAndAppend(elementId, state); 714 return; 715 } 716 state.seekTrimNeeded = false; 717 appendBuf = trimmed; 718 } 719 720 if (state.sourceBuffer && !state.sourceBuffer.updating 721 && state.mediaSource.readyState === 'open' 722 && !state.seeking) 723 { 724 state.sourceBuffer.appendBuffer(appendBuf); 725 } 726 727 } catch (err) { 728 console.error('[EncryptedPlayer] chunk error at offset', rangeStart, err); 729 state.fetching = false; 730 } 731} 732 733// ââ Seeking âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 734 735function _handleSeek(elementId, state, targetTime, overrideByte = null) { 736 if (!state.totalLength || !state.sourceBuffer) return; 737 if (state.seeking) return; 738 739 const sb = state.sourceBuffer; 740 741 // Target already buffered â browser handles it natively 742 for (let i = 0; i < sb.buffered.length; i++) { 743 if (targetTime >= sb.buffered.start(i) - 0.1 744 && targetTime <= sb.buffered.end(i) + 0.1) return; 745 } 746 747 if (sb.updating) { 748 sb.addEventListener('updateend', () => _handleSeek(elementId, state, targetTime), { once: true }); 749 return; 750 } 751 752 // Invalidate any in-flight fetch from before the seek. 753 state.fetchGen++; 754 state.seeking = true; 755 state.fetching = false;
756 state.ended = false; 757 // First chunk after a segments-mode seek may start mid-mdat; trim to moof boundary. 758 if (!state.isSequenceMode) state.seekTrimNeeded = true; 759 760 const dur = state.el.duration; 761 const ratio = isFinite(dur) && dur > 0 ? targetTime / dur : 0; 762 state.byteOffset = overrideByte ?? Math.floor(ratio * state.totalLength / CHUNK_SIZE) * CHUNK_SIZE; 763 764 _removeAllBuffered(state, () => { 765 try { sb.abort(); } catch (_) {} 766 // Keep seeking=true until the new fetch actually kicks off, so that 767 // any stale appendBuffer calls (generation-checked above) don't slip through. 768 769 if (state.isSequenceMode) { 770 try { sb.timestampOffset = targetTime; } catch (_) {} 771 state.seeking = false; 772 _fetchAndAppend(elementId, state); 773 } else if (state.initChunk && state.mediaSource.readyState === 'open') { 774 // Re-append init segment; only clear seeking flag after that updateend. 775 sb.addEventListener('updateend', () => { 776 state.seeking = false; 777 _fetchAndAppend(elementId, state); 778 }, { once: true }); 779 try { sb.appendBuffer(state.initChunk); } 780 catch(e) { console.error('[EncryptedPlayer] initChunk appendBuffer failed:', e); state.seeking = false; _fetchAndAppend(elementId, state); } 781 } else { 782 state.seeking = false; 783 _fetchAndAppend(elementId, state); 784 } 785 }); 786} 787 788function _removeAllBuffered(state, onDone) { 789 const sb = state.sourceBuffer; 790 if (!sb || sb.buffered.length === 0) { onDone(); return; } 791 792 const start = sb.buffered.start(0); 793 const end = sb.buffered.end(sb.buffered.length - 1); 794 795 sb.addEventListener('updateend', () => onDone(), { once: true }); 796 797 try { 798 sb.remove(start, end + 0.001); 799 } catch (err) { 800 console.warn('[EncryptedPlayer] remove error:', err.message); 801 onDone(); 802 } 803} 804 805// ââ MP4 helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 806 807/// Extracts the init segment (ftyp + moov boxes) from the beginning of a 808/// fragmented MP4 buffer. Returns everything before the first 'moof' box. 809/// Falls back to the full buffer if no 'moof' is found (non-fragmented MP4). 810function _extractInitSegment(buf) { 811 const bytes = new Uint8Array(buf); 812 const view = new DataView(buf); 813 let pos = 0; 814 while (pos + 8 <= bytes.byteLength) { 815 const size = view.getUint32(pos, false); 816 const type = String.fromCharCode(bytes[pos+4], bytes[pos+5], bytes[pos+6], bytes[pos+7]); 817 if (type === 'moof') return buf.slice(0, pos); 818 if (size < 8) break; 819 pos += size; 820 } 821 return buf.slice(0); // fallback 822} 823 824/// Scans a decrypted chunk for the first 'moof' box and returns everything 825/// from that offset onward. Returns null if no 'moof' is found (caller should 826/// skip append and pull the next chunk instead). 827/// 828/// Uses a linear byte scan rather than box-tree walking because the buffer may 829/// start mid-mdat where box-size values are media data, not valid lengths. 830function _trimToFirstMoof(buf) { 831 const bytes = new Uint8Array(buf); 832 const view = new DataView(buf); 833 // Box layout: size(4 BE) + type(4 ASCII). Scan for 'moof' (6D 6F 6F 66) 834 // at bytes[pos..pos+3], then the box starts 4 bytes before at pos-4. 835 for (let pos = 4; pos + 4 <= bytes.byteLength; pos++) { 836 if (bytes[pos] !== 0x6D) continue; // 'm' 837 if (bytes[pos+1] !== 0x6F) continue; // 'o' 838 if (bytes[pos+2] !== 0x6F) continue; // 'o' 839 if (bytes[pos+3] !== 0x66) continue; // 'f' 840 const boxStart = pos - 4; 841 const size = view.getUint32(boxStart, false); 842 // Sanity-check: realistic moof size is 8 B â 16 MB 843 if (size >= 8 && size <= 0x01000000) { 844 return buf.slice(boxStart); 845 } 846 } 847 return null; // no moof boundary found in this chunk 848} 849 850// ââ Crypto helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 851 852// Imports a Base64-encoded 16-byte AES-128 key as a non-extractable CryptoKey. 853async function _importKey(keyB64) { 854 const _an = 'AES-' + 'CTR'; 855 const bin = atob(keyB64); 856 const bytes = new Uint8Array(bin.length); 857 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); 858 return crypto.subtle.importKey('raw', bytes, { name: _an }, false, ['decrypt']); 859} 860
861async function _decryptFromOffset(encBuf, cryptoKey, nonce8, byteOffset) { 862 const blockIndex = Math.floor(byteOffset / 16); 863 const counterBlock = new Uint8Array(16); 864 counterBlock.set(nonce8, 0); 865 new DataView(counterBlock.buffer).setBigUint64(8, BigInt(blockIndex), false); 866 const _an = 'AES-' + 'CTR'; 867 return crypto.subtle.decrypt( 868 { name: _an, counter: counterBlock, length: 64 }, 869 cryptoKey, encBuf 870 ); 871} 872 873// ââ Helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 874 875const MIME_FALLBACKS = [ 876 'video/mp4; codecs="avc1.640028,mp4a.40.2"', 877 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"', 878 'video/mp4; codecs="avc1.4D401F,mp4a.40.2"', 879 'video/mp4', 880 'audio/mp4; codecs="mp4a.40.2"', 881 'audio/mpeg', 882]; 883 884function _detectMimeCodec(requested) { 885 if (!('MediaSource' in window)) return null; 886 if (requested && MediaSource.isTypeSupported(requested)) return requested; 887 return MIME_FALLBACKS.find(m => MediaSource.isTypeSupported(m)) ?? null; 888} 889 890function _fmt(s) { 891 if (!isFinite(s)) return '?:??'; 892 const m = Math.floor(s / 60); 893 const sec = Math.floor(s % 60).toString().padStart(2, '0'); 894 return `${m}:${sec}`; 895} 896 897function _showError(elementId, message) { 898 const el = document.getElementById(elementId); 899 if (!el) return; 900 const div = document.createElement('div'); 901 div.className = 'enc-player-error'; 902 div.textContent = message; 903 el.replaceWith(div); 904}
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.