bethaus-app/static/audioplayer.js
2026-01-26 19:02:43 +00:00

458 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${fill}">
<path fill-rule="evenodd" d="${pathD}" clip-rule="evenodd"/>
</svg>`;
}
_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); // lockscreen 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}<br><strong>${file.replace(/\.[^/.]+$/, '')}</strong>`;
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();