PageSourceSearch

https://funk.yachtschule-eichler.de/js/encryptedPlayer.iytzqm93f5.js

js yachtschule-eichler.de collected 2026-10-06 16:36:26 UTC 40,176 bytes, 904 lines download raw bytes

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">&#8249;</button>
370                <span class="enc-pdf-pagenum">
371                    <span id="${elementId}-cur">1</span>&thinsp;/&thinsp;${numPages}
372                </span>
373                <button class="enc-pdf-btn" id="${elementId}-next" title="Weiter">&#8250;</button>
374                <span class="enc-pdf-sep"></span>
375                <button class="enc-pdf-btn" id="${elementId}-zout" title="Verkleinern">&#8722;</button>
376                <button class="enc-pdf-btn" id="${elementId}-zin"  title="Vergrößern">&#43;</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.