258 lines
9.0 KiB
JavaScript
258 lines
9.0 KiB
JavaScript
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 + cache‑buster
|
||
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();
|
||
|
||
|