bethaus-app/static/audioplayer.js
2025-06-01 20:30:22 +00:00

258 lines
9.0 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.

class SimpleAudioPlayer {
constructor({
audioSelector = '#globalAudio',
playBtnSelector = '.player-button',
timelineSelector = '.timeline',
infoSelector = '#timeInfo',
nowPlayingSelector = '#nowPlayingInfo',
containerSelector = '#audioPlayerContainer',
footerSelector = 'footer'
} = {}) {
// 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);
// State
this.isSeeking = false;
this.rafId = null;
this.abortCtrl = null;
this._posInterval = null;
// 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();
}
// 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>`;
}
_bindEvents() {
this.playBtn.addEventListener('click', () => this.togglePlay());
this.audio.addEventListener('loadedmetadata', () => {
this.timeline.min = 0;
this.timeline.max = this.audio.duration;
this.timeline.value = 0;
this.timeline.style.backgroundSize = '0% 100%';
});
this.audio.addEventListener('play', () => this._updatePlayState());
this.audio.addEventListener('pause', () => this._updatePlayState());
this.audio.addEventListener('ended', () => this.playBtn.innerHTML = this.icons.play);
// Native timeupdate => update timeline
this.audio.addEventListener('timeupdate', () => this.updateTimeline());
// Fallback interval for throttled mobile
this._interval = setInterval(() => {
if (!this.audio.paused && !this.isSeeking) {
this.updateTimeline();
}
}, 500);
// Unified seek input
this.timeline.addEventListener('input', () => {
this.isSeeking = true;
this.audio.currentTime = this.timeline.value;
// immediate Android sync
if (navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) {
navigator.mediaSession.setPositionState({
duration: this.audio.duration,
playbackRate: this.audio.playbackRate,
position: this.audio.currentTime
});
}
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;
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
}
}
_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.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
if (navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) {
navigator.mediaSession.setPositionState({
duration: this.audio.duration,
playbackRate: this.audio.playbackRate,
position: this.audio.currentTime
});
}
}
async fileDownload() {
const src = this.audio.currentSrc || this.audio.src;
if (!src) return;
// Build the URL with your download flag + cachebuster
const downloadUrl = new URL(src, window.location.href);
downloadUrl.searchParams.set('download', 'true');
downloadUrl.searchParams.set('_', Date.now());
// Create a “real” link to that URL and click it
const a = document.createElement('a');
a.href = downloadUrl.toString();
// NOTE: do NOT set a.download here we want the server's Content-Disposition to drive it
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async loadTrack(relUrl) {
this.audio.pause();
this.container.style.display = 'block';
this.nowInfo.textContent = 'Loading…';
this.abortCtrl?.abort();
this.abortCtrl = new AbortController();
try {
const head = await fetch(`/media/${relUrl}`, {
method: 'HEAD',
signal: this.abortCtrl.signal
});
if (!head.ok) throw new Error(`Status ${head.status}`);
this.audio.src = `/media/${relUrl}`;
await this.audio.play();
// 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:'/icons/logo-192x192.png', sizes:'192x192', type:'image/png' }]
});
}
} 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 (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 (currentMusicIndex < currentMusicFiles.length - 1) {
const nextFile = currentMusicFiles[currentMusicIndex + 1];
const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`);
if (nextLink) {
nextLink.click();
}
}
}));
// Heartbeat for widget
this._posInterval = setInterval(() => {
if (!this.audio.paused && navigator.mediaSession?.setPositionState && Number.isFinite(this.audio.duration)) {
navigator.mediaSession.setPositionState({
duration: this.audio.duration,
playbackRate: this.audio.playbackRate,
position: this.audio.currentTime
});
}
}, 1000);
}
}
// Initialize instance
const player = new SimpleAudioPlayer();