PageSourceSearch

https://www.myownmusic.de/include/js/mom-audio.js?v=1786365394

js myownmusic.de collected 2026-10-02 04:46:34 UTC 28,941 bytes, 676 lines download raw bytes

1/**
2 * MOM Audio Engine (Player V3)
3 * ---------------------------------------------------------------------------
4 * Ein globaler, persistenter HTML5-Audio-Player:
5 *  - eine <audio>-Instanz pro Tab, Queue, Token-Handling (deliver.php),
6 *  - Zustand in localStorage (überlebt Seitenwechsel → Auto-Resume),
7 *  - Stats-Heartbeats 1:1 wie player-stats-1.0.js (Charts-Parität!),
8 *  - Media Session API (Lockscreen-Steuerung),
9 *  - BroadcastChannel: nur ein Tab spielt gleichzeitig,
10 *  - Kompat-Shim: window.MOMPlayer.seekTo(min, sec) (Links in Bewertungen).
11 *
12 * API: window.MOM.audio  (siehe unten, Abschnitt "public API")
13 */
14(function (window, $) {
15    'use strict';
16
17    if (window.MOM && window.MOM.audio) return;
18    window.MOM = window.MOM || {};
19
20    /* Platzhalter-Cover für die JS-Fallbacks (Bild-404, fehlende imageUrl).
21     * Serverseitig macht song_no_cover() dasselbe per Datei-mtime; hier muss der
22     * Token von Hand hochgezählt werden, wenn sich das Motiv ändert — /img/*
23     * liefert Nginx mit "expires 30d". */
24    window.MOM.noCover = function (size) {
25        return '/img/no_cover_' + size + 'x' + size + '.png?v=3';
26    };
27
28    var STATE_KEY   = 'mom.player.state.v1';
29    var TOKEN_MAX_AGE = 50 * 60 * 1000;   // Token nach 50min erneuern (Lebensdauer 60min)
30    var HISTORY_CAP = 200;                // Anti-Repeat-Liste für "ähnliche Songs"
31    var PREV_CAP = 25;                    // Tiefe des Zurück-Verlaufs
32
33    /* ----------------------------------------------------------------------
34     * Stats-Kontrakt — bug-für-bug-Port von player/js/player-stats-1.0.js.
35     * Heartbeat alle 10 gespielte Sekunden, Abschlussmeldung bei 'ended'.
36     * ------------------------------------------------------------------- */
37    var statsGuid = (function randomString(length) {
38        var chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
39        var result = '';
40        for (var i = length; i > 0; --i) result += chars[Math.round(Math.random() * (chars.length - 1))];
41        return result;
42    })(24);
43    var lastStatCheck = null;
44
45    function statCheckSaving() {
46        var now = new Date().getTime();
47        if (lastStatCheck) {
48            if (now - lastStatCheck > 1000) { lastStatCheck = now; return true; }
49            return false;
50        }
51        lastStatCheck = now;
52        return true;
53    }
54
55    function statSave(songId, time, ended) {
56        if (!songId) return;
57        var playedSeconds = 10, playedComplete = 0;
58        if (ended === true) {
59            playedComplete = 1;
60            playedSeconds = (Math.round(time / 10) % 10); // Rest speichern (wie Altplayer)
61        }
62        $.ajax({
63            type: 'POST', async: true, url: '/player/stats/playtime.php',
64            data: { songid: songId, uid: statsGuid, played_seconds: playedSeconds, played_complete: playedComplete, extern: 0 }
65        });
66    }
67
68    function statHeartbeat(songId, time) {
69        if ((Math.round(time / 1) % 10) === 0 && time > 1 && statCheckSaving()) statSave(songId, time, false);
70    }
71
72    /* ----------------------------------------------------------------------
73     * State
74     * ------------------------------------------------------------------- */
75    function defaultState() {
76        return {
77            songId: null, position: 0, paused: true, wasPlaying: false,
78            queue: [], queueIndex: -1,
79            autoSimilar: false, history: [],
80            prevStack: [],       // zuletzt gespielte Songs (ganze Items) für den Zurück-Knopf
81            volume: 1, muted: false, updatedAt: 0,
82            dismissed: false,   // Nutzer hat den Mini-Player geschlossen (übersteht Seitenwechsel)
83            tokens: {}   // songId -> {token, mintedAt}
84        };
85    }
86
87    function loadState() {
88        try {
89            var raw = window.localStorage && localStorage.getItem(STATE_KEY);
90            if (!raw) return defaultState();
91            var s = JSON.parse(raw);
92            var st = $.extend(defaultState(), s);
93            // Queue und Zurück-Stapel überleben im localStorage — und zwar mit
94            // genau den Namen, die beim Speichern hineingingen. Ein Browser, der
95            // vor der Korrektur der Produzenten schon einmal gespielt hat, trägt
96            // also weiterhin "Black &amp; White" mit sich herum, ohne dass es je
97            // wieder über normalizeItem liefe. Deshalb hier beim Einlesen.
98            st.queue     = (st.queue     || []).map(normalizeItem);
99            st.prevStack = (st.prevStack || []).map(normalizeItem);
100            return st;
101        } catch (e) { return defaultState(); }
102    }
103
104    var state = loadState();
105    var persistTimer = null;
106
107    function persist(immediate) {
108        state.updatedAt = new Date().getTime();
109        if (immediate) { doPersist(); return; }
110        if (persistTimer) return;
111        persistTimer = setTimeout(doPersist, 1000);
112    }
113    function doPersist() {
114        persistTimer = null;
115        try { if (window.localStorage) localStorage.setItem(STATE_KEY, JSON.stringify(state)); } catch (e) {}
116    }
117
118    /* ----------------------------------------------------------------------
119     * Events (pub/sub)
120     * ------------------------------------------------------------------- */
121    var listeners = {};
122    function on(evt, fn) { (listeners[evt] = listeners[evt] || []).push(fn); }
123    function emit(evt, data, meta) {
124        (listeners[evt] || []).forEach(function (fn) { try { fn(data, meta); } catch (e) { if (window.console) console.error(e); } });
125    }
126
127    /* ----------------------------------------------------------------------
128     * Audio-Element
129     * ------------------------------------------------------------------- */
130    var audio = new Audio();
131    audio.preload = 'auto';
132    audio.volume = (typeof state.volume === 'number') ? state.volume : 1;
133    audio.muted = !!state.muted;
134
135    var pendingSeek = null;      // Position, die nach loadedmetadata gesetzt wird
136    var tokenRetryDone = false;  // pro Song nur EIN automatischer Token-Retry
137
138    audio.addEventListener('loadedmetadata', function () {
139        if (pendingSeek !== null) {
140            try { audio.currentTime = pendingSeek; } catch (e) {}
141            pendingSeek = null;
142        }
143    });
144
145    audio.addEventListener('timeupdate', function () {
146        var t = audio.currentTime;
147        statHeartbeat(state.songId, t);
148        state.position = t;
149        persist(false);
150        emit('timeupdate', { position: t, duration: currentDuration() });
151    });
152
153    audio.addEventListener('play', function () {
154        state.paused = false; state.wasPlaying = true;
155        state.dismissed = false;   // hörbare Wiedergabe hebt "geschlossen" immer auf
156        persist(true);
157        broadcastPlaying();
158        updateMediaSessionPlaybackState();
159        emit('play', currentItem());
160    });
161
162    audio.addEventListener('pause', function () {
163        state.paused = true;
164        if (!audio.ended) state.wasPlaying = false;
165        persist(true);
166        updateMediaSessionPlaybackState();
167        emit('pause', currentItem());
168    });
169
170    audio.addEventListener('ended', function () {
171        statSave(state.songId, currentDuration(), true);   // Abschluss VOR dem Weiterschalten (wie Altplayer)
172        emit('ended', currentItem());
173        next(true);
174    });
175
176    var errorSkips = 0;   // aufeinanderfolgende unspielbare Songs nicht endlos überspringen
177    audio.addEventListener('error', function () {
178        if (!state.songId) return;
179        if (!tokenRetryDone) {
180            // Wahrscheinlich abgelaufener/rotierter Token → einmalig neu holen und Position wiederherstellen
181            tokenRetryDone = true;
182            var resumeAt = state.position || 0;
183            delete state.tokens[state.songId];
184            mintToken(state.songId).then(function (token) {
185                setSource(token, resumeAt);
186                if (state.wasPlaying) tryPlay();
187            }, function () { emit('error', { songId: state.songId }); });
188            return;
189        }
190        // Song bleibt unspielbar (gelöschte Datei o. ä.) → überspringen, aber max. 3 in Folge
191        emit('error', { songId: state.songId });
192        if (state.wasPlaying && errorSkips < 3) {
193            errorSkips++;
194            next(true);
195        } else {
196            state.wasPlaying = false; persist(true);
197        }
198    });
199    audio.addEventListener('playing', function () { errorSkips = 0; });
200
201    // Eine Quelle der Wahrheit: egal wer die Lautstärke ändert (Mini-Player,
202    // später der Maxi-Player, Media-Keys), gemerkt und weitergesagt wird sie hier
203    audio.addEventListener('volumechange', function () {
204        state.volume = audio.volume;
205        state.muted = audio.muted;
206        persist(false);
207        emit('volumechange', { volume: audio.volume, muted: audio.muted });
208    });
209
210    /* Lautstärke lässt sich nicht überall setzen: iOS/Safari hält
211     * HTMLMediaElement.volume schreibgeschützt und überlässt sie den
212     * Hardware-Tasten. An einem Wegwerf-Element prüfen, damit die laufende
213     * Wiedergabe dabei nichts abbekommt. */
214    var volumeSupport = null;
215    function volumeSupported() {
216        if (volumeSupport === null) {
217            try { var t = new Audio(); t.volume = 0.5; volumeSupport = (t.volume === 0.5); }
218            catch (e) { volumeSupport = false; }
219        }
220        return volumeSupport;
221    }
222
223    function setVolume(v) {
224        v = Math.min(1, Math.max(0, parseFloat(v)));
225        if (isNaN(v)) return;
226        if (v > 0 && audio.muted) audio.muted = false;   // am Regler ziehen hebt stumm auf
227        audio.volume = v;
228    }
229
230    function setMuted(onoff) { audio.muted = !!onoff; }
231
232    /* ----------------------------------------------------------------------
233     * Token-Handling
234     * ------------------------------------------------------------------- */
235    function mintToken(songId) {
236        var d = $.Deferred();
237        var cached = state.tokens[songId];
238        if (cached && (new Date().getTime() - cached.mintedAt) < TOKEN_MAX_AGE) {
239            d.resolve(cached.token);
240            return d.promise();
241        }
242        $.post('/player/get/token', { songid: songId }, null, 'json').done(function (resp) {
243            if (resp && resp.token) {
244                state.tokens = {};   // nur den aktuellen Token behalten (Speicher klein halten)
245                state.tokens[songId] = { token: resp.token, mintedAt: new Date().getTime() };
246                persist(true);
247                d.resolve(resp.token);
248            } else d.reject();
249        }).fail(function () { d.reject(); });
250        return d.promise();
251    }
252
253    function setSource(token, position) {
254        pendingSeek = (position && position > 0.5) ? position : null;
255        audio.src = '/player/deliver.php?token=' + encodeURIComponent(token);
256    }
257
258    /* ----------------------------------------------------------------------
259     * Queue
260     * ------------------------------------------------------------------- */
261    /* Klartext aus Altbestand — das Gegenstück zu mom_textout() auf dem Server.
262     * In der Datenbank stecken HTML-Entities in den Songnamen ("Black &amp;
263     * White"), teils mehrfach kodiert. Die Anzeige setzt die Namen per .text(),
264     * erwartet also Klartext; kam etwas Kodiertes an, stand es wörtlich auf dem
265     * Schirm.
266     * Die Produzenten liefern das inzwischen aufgelöst, aber hier ist der eine
267     * Punkt, durch den JEDES Queue-Item läuft — gespeicherter Zustand von
268     * früher, künftige Aufrufer, alles. Deshalb zur Sicherheit auch hier.
269     * <textarea> ist dafür der sichere Weg: dessen Inhalt wird als reiner Text
270     * gelesen (RCDATA), es entsteht also kein Markup und nichts wird
271     * ausgeführt — anders als bei innerHTML an einem <div>.
272     * Wie auf dem Server abwechselnd auflösen, bis sich nichts mehr ändert
273     * (Deckel bei 4), damit auch mehrfach kodierte Altdaten sauber werden. */
274    var decoder = null;
275    function decodeEntities(s) {
276        s = (s == null) ? '' : String(s);
277        if (s.indexOf('&') === -1) return s;          // der Normalfall, ohne DOM-Umweg
278        if (!decoder) decoder = document.createElement('textarea');
279        for (var i = 0; i < 4; i++) {
280            decoder.innerHTML = s;
281            var out = decoder.value;
282            if (out === s) break;
283            s = out;
284        }
285        return s;
286    }
287
288    function normalizeItem(it) {
289        return {
290            songId: String(it.songId), songName: decodeEntities(it.songName),
291            artistNick: decodeEntities(it.artistNick), artistUrlNick: it.artistUrlNick || '',
292            imageUrl: it.imageUrl || '', timeSec: parseInt(it.timeSec, 10) || 0
293        };
294    }
295
296    function findIndex(songId) {
297        songId = String(songId);
298        for (var i = 0; i < state.queue.length; i++) if (state.queue[i].songId === songId) return i;
299        return -1;
300    }
301
302    var queue = {
303        seed: function (items, startIndex) {
304            state.queue = (items || []).map(normalizeItem);
305            state.queueIndex = (typeof startIndex === 'number') ? startIndex : (state.queue.length ? 0 : -1);
306            persist(true);
307            emit('queuechange', queue.list());
308        },
309        add: function (item) {
310            item = normalizeItem(item);
311            if (findIndex(item.songId) !== -1) return false;
312            state.queue.push(item);
313            if (state.queueIndex === -1) state.queueIndex = 0;
314            persist(true);
315            emit('queuechange', queue.list());
316            return true;
317        },
318        remove: function (songId) {
319            var idx = findIndex(songId);
320            if (idx === -1) return;
321            state.queue.splice(idx, 1);
322            if (idx < state.queueIndex) state.queueIndex--;
323            else if (idx === state.queueIndex) state.queueIndex = Math.min(state.queueIndex, state.queue.length - 1);
324            persist(true);
325            emit('queuechange', queue.list());
326        },
327        move: function (from, to) {
328            if (from < 0 || from >= state.queue.length || to < 0 || to >= state.queue.length) return;
329            var it = state.queue.splice(from, 1)[0];
330            state.queue.splice(to, 0, it);
331            if (state.queueIndex === from) state.queueIndex = to;
332            else if (from < state.queueIndex && to >= state.queueIndex) state.queueIndex--;
333            else if (from > state.queueIndex && to <= state.queueIndex) state.queueIndex++;
334            persist(true);
335            emit('queuechange', queue.list());
336        },
337        clear: function () {
338            state.queue = []; state.queueIndex = -1; persist(true);
339            emit('queuechange', []);
340        },
341        has: function (songId) { return findIndex(songId) !== -1; },
342        list: function () { return state.queue.slice(); },
343        index: function () { return state.queueIndex; }
344    };
345
346    function currentItem() {
347        return (state.queueIndex >= 0 && state.queue[state.queueIndex]) ? state.queue[state.queueIndex] : null;
348    }
349
350    function currentDuration() {
351        if (audio.duration && isFinite(audio.duration)) return audio.duration;
352        var it = currentItem();
353        return it ? it.timeSec : 0;
354    }
355
356    /* ----------------------------------------------------------------------
357     * Playback
358     * ------------------------------------------------------------------- */
359    /* Zurück-Knopf: Die Queue taugt dafür nicht — playIndex() schneidet alles vor
360     * dem laufenden Song weg ("Up Next"), es gibt also kein Davor mehr. Deshalb ein
361     * eigener Stapel mit vollständigen Items. Er wandert in den localStorage-State,
362     * bleibt aber klein: 25 Einträge decken jede realistische Rückwärts-Navigation
363     * ab und kosten nur wenige Kilobyte. (state.history bleibt davon unberührt —
364     * das ist die Anti-Wiederholungs-Liste für "ähnliche Songs".) */
365    function pushPrev(item) {
366        if (!item || !item.songId) return;
367        var st = state.prevStack || (state.prevStack = []);
368        if (st.length && String(st[st.length - 1].songId) === String(item.songId)) return;
369        st.push(normalizeItem(item));
370        if (st.length > PREV_CAP) state.prevStack = st.slice(-PREV_CAP);
371    }
372
373    function rememberHistory(songId) {
374        songId = String(songId);
375        var h = state.history;
376        if (h[h.length - 1] !== songId) h.push(songId);
377        if (h.length > HISTORY_CAP) state.history = h.slice(-HISTORY_CAP);
378    }
379
380    function playIndex(idx, position, meta) {
381        if (idx < 0 || idx >= state.queue.length) return;
382        var item = state.queue[idx];
383
384        // Bisherigen Song merken, damit der Zurück-Knopf ihn wiederfindet — muss VOR
385        // dem Beschneiden der Queue passieren, danach zeigt queueIndex ins Leere
386        var leaving = currentItem();
387        if (leaving && String(leaving.songId) !== String(item.songId) && !(meta && meta.back))
388            pushPrev(leaving);
389
390        // "Up Next"-Queue: bereits gespielte/übersprungene Einträge fliegen raus,
391        // der aktuelle Song steht immer oben (Index 0). Die Song-Seite reseedet
392        // ihre Tracklist bei Interaktion ohnehin (seedIfForeign).
393        if (idx > 0) {
394            state.queue.splice(0, idx);
395            idx = 0;
396        }
397        state.queueIndex = idx;
398        state.songId = item.songId;
399        state.position = position || 0;
400        state.dismissed = false;   // bewusste Songwahl macht den Player wieder sichtbar
401        tokenRetryDone = false;
402        rememberHistory(item.songId);
403        persist(true);
404        updateMediaSessionMetadata(item);
405        emit('songchange', item, meta || {});
406        emit('queuechange', queue.list());
407        mintToken(item.songId).then(function (token) {
408            setSource(token, position || 0);
409            tryPlay();
410        }, function () { emit('error', { songId: item.songId }); });
411    }
412
413    function tryPlay() {
414        var p = audio.play();
415        if (p && p.then) {
416            p.then(function () { emit('resumed'); }, function () {
417                // Autoplay-Policy (iOS/Safari): Nutzer muss einmal tippen
418                state.paused = true; persist(true);
419                emit('resume-blocked', currentItem());
420            });
421        }
422    }
423
424    function play(songId, opts) {
425        opts = opts || {};
426        if (songId === undefined || songId === null) {           // einfach fortsetzen
427            if (audio.src) { if (audio.ended) seek(0); tryPlay(); return; }
428            if (currentItem()) {
429                // gespeicherte Position am/nach Songende → von vorne beginnen
430                var it = currentItem(), pos = state.position || 0;
431                if (it.timeSec && pos >= it.timeSec - 1) pos = 0;
432                playIndex(state.queueIndex, pos);
433                return;
434            }
435            return;
436        }
437        var idx = findIndex(songId);
438        if (idx === -1 && opts.item) {                            // unbekannt → hinter aktueller Position einreihen
439            var insertAt = state.queueIndex + 1;
440            state.queue.splice(insertAt, 0, normalizeItem(opts.item));
441            emit('queuechange', queue.list());
442            idx = insertAt;
443        }
444        if (idx === -1) return;
445        playIndex(idx, opts.position || 0);
446    }
447
448    function pause() { audio.pause(); }
449
450    function toggle() {
451        if (audio.paused) play(); else pause();
452    }
453
454    function seek(seconds) {
455        seconds = Math.max(0, parseFloat(seconds) || 0);
456        if (audio.readyState >= 1) {
457            try { audio.currentTime = seconds; } catch (e) { pendingSeek = seconds; }
458        } else {
459            pendingSeek = seconds;
460        }
461        state.position = seconds; persist(false);
462    }
463
464    function next(auto) {
465        if (state.queueIndex + 1 < state.queue.length) {
466            playIndex(state.queueIndex + 1, 0, { auto: !!auto });
467            return;
468        }
469        // Queue zu Ende
470        if (state.autoSimilar && state.songId) {
471            fetchSimilarAndPlay(!!auto);
472        } else {
473            // Position zurücksetzen, sonst startet ein späteres Play am Songende
474            state.wasPlaying = false; state.position = 0; persist(true);
475            emit('queue-ended');
476        }
477    }
478
479    function prev() {
480        if (audio.currentTime > 3) { seek(0); return; }           // wie üblich: erst an Songanfang
481
482        var back = (state.prevStack && state.prevStack.length) ? state.prevStack.pop() : null;
483        if (!back) { seek(0); return; }                            // nichts davor -> an den Anfang
484
485        // Den vorherigen Song vor den aktuellen setzen: so spielt er sofort und
486        // "weiter" führt anschließend wieder zum Song, von dem man kam.
487        state.queue.splice(0, 0, normalizeItem(back));
488        emit('queuechange', queue.list());
489        playIndex(0, 0, { back: true });
490    }
491
492    /* ----------------------------------------------------------------------
493     * Autoplay "ähnliche Songs"
494     * ------------------------------------------------------------------- */
495    var similarPending = false;
496    function fetchSimilarAndPlay(auto) {
497        if (similarPending) return;
498        similarPending = true;
499        var exclude = state.history.slice(-HISTORY_CAP);
500        state.queue.forEach(function (it) { exclude.push(it.songId); });
501        $.ajax({
502            type: 'POST', url: '/player/get/similar', dataType: 'json',
503            data: { songid: state.songId, exclude: JSON.stringify(exclude) }
504        }).done(function (items) {
505            similarPending = false;
506            if (items && items.length) {
507                var pick = items[Math.floor(Math.random() * Math.min(items.length, 15))];
508                queue.add(pick);
509                playIndex(state.queue.length - 1, 0, { auto: auto });
510                emit('autosimilar', pick);
511            } else {
512                emit('queue-ended');
513            }
514        }).fail(function () { similarPending = false; emit('queue-ended'); });
515    }
516
517    function setAutoSimilar(onoff) {
518        state.autoSimilar = !!onoff; persist(true);
519        emit('autosimilar-toggle', state.autoSimilar);
520    }
521
522    /* ----------------------------------------------------------------------
523     * Media Session (Lockscreen)
524     * ------------------------------------------------------------------- */
525    function absoluteUrl(u) {
526        if (!u) return '';
527        if (u.indexOf('//') === 0) return window.location.protocol + u;
528        if (u.indexOf('/') === 0) return window.location.origin + u;
529        return u;
530    }
531
532    function updateMediaSessionMetadata(item) {
533        if (!('mediaSession' in navigator) || !item) return;
534        try {
535            var art = absoluteUrl(item.imageUrl);
536            navigator.mediaSession.metadata = new MediaMetadata({
537                title: item.songName,
538                artist: item.artistNick,
539                album: 'MyOwnMusic',
540                artwork: art ? [
541                    { src: art, sizes: '512x512', type: 'image/jpeg' },
542                    { src: art, sizes: '96x96',  type: 'image/jpeg' }
543                ] : []
544            });
545        } catch (e) {}
546    }
547
548    function updateMediaSessionPlaybackState() {
549        if (!('mediaSession' in navigator)) return;
550        try { navigator.mediaSession.playbackState = audio.paused ? 'paused' : 'playing'; } catch (e) {}
551    }
552
553    function setupMediaSessionHandlers() {
554        if (!('mediaSession' in navigator)) return;
555        var ms = navigator.mediaSession;
556        var handlers = {
557            play: function () { play(); },
558            pause: function () { pause(); },
559            previoustrack: function () { prev(); },
560            nexttrack: function () { next(); },
561            seekto: function (d) { if (d && typeof d.seekTime === 'number') seek(d.seekTime); },
562            seekbackward: function (d) { seek(Math.max(0, audio.currentTime - ((d && d.seekOffset) || 10))); },
563            seekforward: function (d) { seek(audio.currentTime + ((d && d.seekOffset) || 10)); }
564        };
565        for (var k in handlers) {
566            try { ms.setActionHandler(k, handlers[k]); } catch (e) {}
567        }
568        // Positionsanzeige auf dem Lockscreen aktuell halten
569        if ('setPositionState' in ms) {
570            audio.addEventListener('timeupdate', function () {
571                try {
572                    if (audio.duration && isFinite(audio.duration)) {
573                        ms.setPositionState({ duration: audio.duration, playbackRate: audio.playbackRate, position: audio.currentTime });
574                    }
575                } catch (e) {}
576            });
577        }
578    }
579
580    /* ----------------------------------------------------------------------
581     * Multi-Tab-Schutz: nur ein Tab spielt
582     * ------------------------------------------------------------------- */
583    var tabId = statsGuid;
584    var channel = null;
585    try { channel = ('BroadcastChannel' in window) ? new BroadcastChannel('mom-player') : null; } catch (e) {}
586
587    function broadcastPlaying() {
588        if (channel) { try { channel.postMessage({ type: 'playing', tab: tabId }); } catch (e) {} }
589    }
590    var otherTabPlaying = false;
591    if (channel) {
592        channel.onmessage = function (msg) {
593            var d = msg && msg.data;
594            if (!d || d.tab === tabId) return;
595            if (d.type === 'playing') {
596                otherTabPlaying = true;
597                if (!audio.paused) pause();
598            } else if (d.type === 'anyone-playing') {
599                if (!audio.paused) { try { channel.postMessage({ type: 'playing-here', tab: tabId }); } catch (e) {} }
600            } else if (d.type === 'playing-here') {
601                otherTabPlaying = true;
602            }
603        };
604    }
605
606    /* ----------------------------------------------------------------------
607     * Auto-Resume nach Seitenwechsel
608     * ------------------------------------------------------------------- */
609    function resumeFromState() {
610        if (!state.songId || state.queueIndex < 0 || !state.queue[state.queueIndex]) {
611            emit('idle');
612            return;
613        }
614        var item = currentItem();
615        updateMediaSessionMetadata(item);
616        emit('songchange', item);
617        emit('queuechange', queue.list());
618        if (state.wasPlaying && (new Date().getTime() - state.updatedAt) < 60000) {
619            // Erst fragen, ob ein anderer Tab bereits spielt (sonst Ping-Pong-Pause) …
620            if (channel) { try { channel.postMessage({ type: 'anyone-playing', tab: tabId }); } catch (e) {} }
621            setTimeout(function () {
622                if (otherTabPlaying) { emit('paused-restore', item); return; }
623                // … frisch & allein → weiterspielen (Autoplay-Policy kann blocken → resume-blocked)
624                mintToken(item.songId).then(function (token) {
625                    setSource(token, state.position || 0);
626                    tryPlay();
627                }, function () { emit('error', { songId: item.songId }); });
628            }, channel ? 200 : 0);
629        } else {
630            emit('paused-restore', item);
631        }
632    }
633
634    window.addEventListener('pagehide', function () { doPersist(); });
635
636    /* ----------------------------------------------------------------------
637     * public API
638     * ------------------------------------------------------------------- */
639    window.MOM.audio = {
640        play: play, pause: pause, toggle: toggle, seek: seek, next: next, prev: prev,
641        queue: queue,
642        setAutoSimilar: setAutoSimilar,
643        setVolume: setVolume, setMuted: setMuted, volumeSupported: volumeSupported,
644        setDismissed: function (onoff) { state.dismissed = !!onoff; persist(true); },
645        token: function (songId) { return mintToken(songId); },
646        on: on,
647        element: function () { return audio; },
648        state: function () {
649            return {
650                songId: state.songId, position: audio.currentTime || state.position,
651                paused: audio.paused, queue: queue.list(), queueIndex: state.queueIndex,
652                autoSimilar: state.autoSimilar, duration: currentDuration(),
653                volume: audio.volume, muted: audio.muted,
654                prevCount: (state.prevStack || []).length,
655                dismissed: state.dismissed
656            };
657        },
658        current: currentItem
659    };
660
661    /* Kompat-Shim: Timestamps in Bewertungen linken auf MOMPlayer.seekTo('m','ss') */
662    if (!window.MOMPlayer) {
663        window.MOMPlayer = {
664            seekTo: function (min, sec) {
665                var t = (parseInt(min, 10) || 0) * 60 + (parseInt(sec, 10) || 0);
666                window.MOM.audio.seek(t);
667                window.MOM.audio.play();
668            },
669            pause: function () { window.MOM.audio.pause(); }
670        };
671    }
672
673    setupMediaSessionHandlers();
674    $(function () { resumeFromState(); });
675
676})(window, jQuery);

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.