const AUDIO_PLAYER_STATE_KEY = 'globalAudioPlayerState'; class SimpleAudioPlayer { constructor({ audioSelector = '#globalAudio', playBtnSelector = '.player-button', timelineSelector = '.timeline', infoSelector = '#timeInfo', nowPlayingSelector = '#nowPlayingInfo', containerSelector = '#audioPlayerContainer', footerSelector = 'footer', minimizeBtnSelector = '.minimize-button' } = {}) { // Elements this.audio = document.querySelector(audioSelector); this.playBtn = document.querySelector(playBtnSelector); this.timeline = document.querySelector(timelineSelector); this.timeInfo = document.querySelector(infoSelector); this.nowInfo = document.querySelector(nowPlayingSelector); this.container = document.querySelector(containerSelector); this.footer = document.querySelector(footerSelector); this.minBtn = document.querySelector(minimizeBtnSelector); // State this.isSeeking = false; this.rafId = null; this.abortCtrl = null; this._hasMetadata = false; this._posInterval = null; this._posIntervalMs = 1000; // Pre-compute icons once const fill = getComputedStyle(document.documentElement) .getPropertyValue('--dark-background').trim(); this.icons = { play : this.svg( 'M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z', fill ), pause: this.svg( 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z', fill ), sound: this.svg( 'M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z', fill ) }; // Seeked => refresh timeline if external seek (e.g. Android controls) this.audio.addEventListener('seeked', () => this.updateTimeline()); // Bind UI events this._bindEvents(); // Setup MediaSession handlers + heartbeat this._initMediaSession(); // Restore previous playback state (if any) and persist on navigation this._restoreState(); window.addEventListener('pagehide', () => this._saveState()); window.addEventListener('beforeunload', () => this._saveState()); } /** * Push the current position to MediaSession so lockscreen / BT UIs stay in sync. * Keeps values strict but avoids over-clamping that can confuse some platforms. */ _pushPositionState(force = false) { if (!navigator.mediaSession?.setPositionState) return; if (!this._hasMetadata) return; const duration = this.audio.duration; const position = this.audio.currentTime; if (!Number.isFinite(duration) || duration <= 0) return; if (!Number.isFinite(position) || position < 0) return; let playbackRate = Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1; if (!playbackRate) playbackRate = 1; try { if ('playbackState' in navigator.mediaSession) { navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing'; } navigator.mediaSession.setPositionState({ duration, playbackRate, position }); } catch (err) { if (force) console.warn('setPositionState failed:', err); } } // Minimal SVG helper svg(pathD, fill) { return ` `; } _startPositionSync() { if (this._posInterval) return; this._posInterval = setInterval(() => { if (!this.audio.paused) { this._pushPositionState(); } }, this._posIntervalMs); } _stopPositionSync() { if (!this._posInterval) return; clearInterval(this._posInterval); this._posInterval = null; } _bindEvents() { this.playBtn.addEventListener('click', () => this.togglePlay()); if (this.minBtn) { this.minBtn.addEventListener('click', () => this.toggleCollapse()); } this.audio.addEventListener('loadstart', () => { this._hasMetadata = false; this._stopPositionSync(); this.timeline.min = 0; this.timeline.max = 0; this.timeline.value = 0; this.timeline.style.backgroundSize = '0% 100%'; this.timeInfo.textContent = '00:00 / --:--'; }); this.audio.addEventListener('loadedmetadata', () => { this._hasMetadata = true; this.timeline.min = 0; this.timeline.max = this.audio.duration; this.timeline.value = 0; this.timeline.style.backgroundSize = '0% 100%'; this._pushPositionState(true); }); this.audio.addEventListener('play', () => { this._updatePlayState(); this._pushPositionState(true); this._startPositionSync(); }); this.audio.addEventListener('playing', () => this._pushPositionState(true)); this.audio.addEventListener('pause', () => { this._updatePlayState(); this._pushPositionState(true); this._stopPositionSync(); }); this.audio.addEventListener('ratechange', () => this._pushPositionState(true)); this.audio.addEventListener('ended', () => { this.playBtn.innerHTML = this.icons.play; this._pushPositionState(true); this._stopPositionSync(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; }); // Native timeupdate => update timeline this.audio.addEventListener('timeupdate', () => this.updateTimeline()); // Keep position in sync when page visibility changes document.addEventListener('visibilitychange', () => { this._pushPositionState(true); if (document.visibilityState === 'visible' && !this.audio.paused) { this._startPositionSync(); } }); window.addEventListener('pageshow', () => this._pushPositionState(true)); window.addEventListener('focus', () => this._pushPositionState(true)); // Unified seek input this.timeline.addEventListener('input', () => { this.isSeeking = true; this.audio.currentTime = this.timeline.value; this._pushPositionState(true); // immediate Android sync this.updateTimeline(); this.isSeeking = false; }); } togglePlay() { this.audio.paused ? this.audio.play() : this.audio.pause(); } _updatePlayState() { this.playBtn.innerHTML = this.audio.paused ? this.icons.play : this.icons.pause; this._pushPositionState(true); // lock‑screen refresh on play/pause toggle } _formatTime(sec) { const m = Math.floor(sec / 60), s = Math.floor(sec % 60); return `${m < 10 ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s}`; } updateTimeline() { if (!this._hasMetadata || !this.audio.duration || this.isSeeking) return; // 1) Move the thumb this.timeline.value = this.audio.currentTime; // 2) Resize fill const pct = (this.audio.currentTime / this.audio.duration) * 100; this.timeline.style.backgroundSize = `${pct}% 100%`; // 3) Update text this.timeInfo.textContent = `${this._formatTime(this.audio.currentTime)} / ${this._formatTime(this.audio.duration)}`; // 4) Push to Android widget this._pushPositionState(); } toggleCollapse() { const collapsed = !this.container.classList.contains('collapsed'); this._setCollapsed(collapsed); this._saveState(); } _setCollapsed(collapsed) { if (!this.container) return; this.container.classList.toggle('collapsed', collapsed); if (this.minBtn) { this.minBtn.setAttribute('aria-expanded', (!collapsed).toString()); this.minBtn.setAttribute('aria-label', collapsed ? 'Player ausklappen' : 'Player einklappen'); } } _saveState() { try { if (!this.currentRelUrl) { sessionStorage.removeItem(AUDIO_PLAYER_STATE_KEY); return; } const state = { relUrl: this.currentRelUrl, currentTime: Number.isFinite(this.audio.currentTime) ? this.audio.currentTime : 0, paused: this.audio.paused, collapsed: this.container?.classList.contains('collapsed') || false }; sessionStorage.setItem(AUDIO_PLAYER_STATE_KEY, JSON.stringify(state)); } catch { // Best-effort persistence only. } } _restoreState() { let stateRaw; try { stateRaw = sessionStorage.getItem(AUDIO_PLAYER_STATE_KEY); } catch { return; } if (!stateRaw) return; let state; try { state = JSON.parse(stateRaw); } catch { return; } if (!state || !state.relUrl) return; this._setCollapsed(Boolean(state.collapsed)); const resumeTime = Number.isFinite(state.currentTime) ? state.currentTime : 0; const autoplay = !state.paused; this.loadTrack(state.relUrl, { autoplay, resumeTime }); } async fileDownload() { let relUrl = this.currentRelUrl || ''; const src = this.audio.currentSrc || this.audio.src; if (!relUrl && src) { try { const urlObj = new URL(src, window.location.href); if (urlObj.pathname.startsWith('/media/')) { relUrl = urlObj.pathname.slice('/media/'.length); } } catch { // ignore parsing errors } } if (!relUrl) return; const encodedSubpath = relUrl .replace(/^\/+/, '') .split('/') .map(segment => encodeURIComponent(decodeURIComponent(segment))) .join('/'); // Fetch the tokenized URL from your backend let tokenizedUrl; try { const resp = await fetch(`/create_dltoken/${encodedSubpath}`); if (!resp.ok) return; tokenizedUrl = (await resp.text()).trim(); } catch { return; } if (!tokenizedUrl) return; // Build the URL with cache-buster const downloadUrl = new URL(tokenizedUrl, window.location.href); // Create a link and click it const a = document.createElement('a'); a.href = downloadUrl.toString(); document.body.appendChild(a); a.click(); document.body.removeChild(a); } async loadTrack(relUrl, reqId, options = {}) { if (reqId && typeof reqId === 'object') { options = reqId; reqId = null; } const { autoplay = true, resumeTime = 0 } = options; this.currentRelUrl = relUrl; const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2))); const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`; // Reset any residual state from the previous track before we fetch the new one this._hasMetadata = false; this.audio.pause(); this.audio.removeAttribute('src'); this.audio.load(); this.timeline.min = 0; this.timeline.max = 0; this.timeline.value = 0; this.timeline.style.backgroundSize = '0% 100%'; this.timeInfo.textContent = '00:00 / --:--'; if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = 'paused'; } this.container.style.display = 'block'; this.nowInfo.textContent = 'Loading…'; this.abortCtrl?.abort(); this.abortCtrl = new AbortController(); try { const head = await fetch(urlWithReq, { method: 'HEAD', signal: this.abortCtrl.signal }); if (!head.ok) throw new Error(`Status ${head.status}`); this.audio.src = urlWithReq; this.audio.load(); const wantsResume = Number.isFinite(resumeTime) && resumeTime > 0; if (wantsResume) { this.audio.addEventListener('loadedmetadata', () => { const safeTime = Number.isFinite(this.audio.duration) ? Math.min(resumeTime, this.audio.duration) : resumeTime; this.audio.currentTime = safeTime; this.updateTimeline(); if (autoplay) { this.audio.play().catch(() => this._updatePlayState()); } else { this._updatePlayState(); } }, { once: true }); } if (!wantsResume) { if (autoplay) { try { await this.audio.play(); } catch { this._updatePlayState(); } } else { this._updatePlayState(); } } // Full breadcrumb const parts = relUrl.split('/'); const file = parts.pop(); const folderPath = parts.join(' › ').replace('Gottesdienste S','S'); // Special case this.nowInfo.innerHTML = `${folderPath}
${file.replace(/\.[^/.]+$/, '')}`; if ('mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ title : file.replace(/\.[^/.]+$/, ''), artist: parts.pop(), artwork: [{ src:'/icon/logo-192x192.png', sizes:'192x192', type:'image/png' }] }); } this._saveState(); } catch (err) { if (err.name !== 'AbortError') { this.nowInfo.textContent = 'Error loading track'; console.error(err); } } } _initMediaSession() { if (!('mediaSession' in navigator)) return; const wrap = fn => details => { fn(details); this.updateTimeline(); }; // Core controls navigator.mediaSession.setActionHandler('play', wrap(() => this.audio.play())); navigator.mediaSession.setActionHandler('pause', wrap(() => this.audio.pause())); navigator.mediaSession.setActionHandler('seekbackward', wrap(({seekOffset=10}) => { this.audio.currentTime = Math.max(0, this.audio.currentTime - seekOffset); })); navigator.mediaSession.setActionHandler('seekforward', wrap(({seekOffset=10}) => { this.audio.currentTime = Math.min(this.audio.duration, this.audio.currentTime + seekOffset); })); navigator.mediaSession.setActionHandler('seekto', wrap(({seekTime, fastSeek}) => { if (fastSeek && this.audio.fastSeek) this.audio.fastSeek(seekTime); else this.audio.currentTime = seekTime; })); // Prev & Next navigator.mediaSession.setActionHandler('previoustrack', wrap(() => { if (typeof currentMusicIndex !== 'number' || !Array.isArray(currentMusicFiles)) return; if (currentMusicIndex > 0) { const prevFile = currentMusicFiles[currentMusicIndex - 1]; const prevLink = document.querySelector(`.play-file[data-url="${prevFile.path}"]`); if (prevLink) { prevLink.click(); } } })); navigator.mediaSession.setActionHandler('nexttrack', wrap(() => { if (typeof currentMusicIndex !== 'number' || !Array.isArray(currentMusicFiles)) return; if (currentMusicIndex < currentMusicFiles.length - 1) { const nextFile = currentMusicFiles[currentMusicIndex + 1]; const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`); if (nextLink) { nextLink.click(); } } })); } } // Initialize instance const player = new SimpleAudioPlayer();