300 lines
11 KiB
JavaScript
300 lines
11 KiB
JavaScript
// read the CSS variable from :root (or any selector)
|
||
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--dark-background').trim();
|
||
|
||
// player DOM elements
|
||
const nowPlayingInfo = document.getElementById('nowPlayingInfo');
|
||
const audio = document.getElementById('globalAudio');
|
||
const audioPlayerContainer = document.getElementById('audioPlayerContainer');
|
||
|
||
const playerButton = document.querySelector('.player-button'),
|
||
timeline = document.querySelector('.timeline'),
|
||
timeInfo = document.getElementById('timeInfo'),
|
||
playIcon = `
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${cssVar}">
|
||
<path fill-rule="evenodd" d="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" clip-rule="evenodd" />
|
||
</svg>
|
||
`,
|
||
pauseIcon = `
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${cssVar}">
|
||
<path fill-rule="evenodd" d="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" clip-rule="evenodd" />
|
||
</svg>
|
||
`,
|
||
soundIcon = `
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="${cssVar}">
|
||
<path fill-rule="evenodd" d="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" clip-rule="evenodd" />
|
||
</svg>
|
||
`
|
||
|
||
function toggleAudio () {
|
||
if (audio.paused) {
|
||
audio.play();
|
||
playerButton.innerHTML = pauseIcon;
|
||
} else {
|
||
audio.pause();
|
||
playerButton.innerHTML = playIcon;
|
||
}
|
||
}
|
||
playerButton.addEventListener('click', toggleAudio);
|
||
|
||
let isSeeking = false;
|
||
|
||
// --- Slider (Timeline) events ---
|
||
// Mouse events
|
||
timeline.addEventListener('mousedown', () => { isSeeking = true; });
|
||
timeline.addEventListener('mouseup', () => { isSeeking = false;
|
||
changeSeek(); // Update the audio currentTime based on the slider position
|
||
});
|
||
|
||
// Touch events
|
||
timeline.addEventListener('touchstart', () => { isSeeking = true; });
|
||
timeline.addEventListener('touchend', () => { isSeeking = false;
|
||
changeSeek();
|
||
});
|
||
timeline.addEventListener('touchcancel', () => { isSeeking = false; });
|
||
|
||
|
||
// --- Seek function: directly set audio.currentTime using slider's value (in seconds) ---
|
||
function changeSeek() {
|
||
audio.currentTime = timeline.value;
|
||
|
||
if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
|
||
navigator.mediaSession.setPositionState({
|
||
duration: audio.duration,
|
||
playbackRate: audio.playbackRate,
|
||
position: audio.currentTime
|
||
});
|
||
}
|
||
}
|
||
|
||
// --- Utility: Format seconds as mm:ss ---
|
||
function formatTime(seconds) {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
return `${minutes < 10 ? '0' : ''}${minutes}:${secs < 10 ? '0' : ''}${secs}`;
|
||
}
|
||
|
||
|
||
// --- Update timeline, time info, and media session on each time update ---
|
||
function updateTimeline() {
|
||
if (!isSeeking && audio.duration) {
|
||
timeline.value = audio.currentTime;
|
||
timeline.style.backgroundSize = `${(audio.currentTime / audio.duration) * 100}% 100%`;
|
||
timeInfo.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
||
}
|
||
}
|
||
|
||
// --- When metadata is loaded, set slider range in seconds ---
|
||
audio.addEventListener('loadedmetadata', () => {
|
||
timeline.min = 0;
|
||
timeline.max = audio.duration;
|
||
timeline.value = 0;
|
||
timeline.style.backgroundSize = '0% 100%';
|
||
});
|
||
|
||
// --- Update timeline and time info on time update ---
|
||
audio.addEventListener('timeupdate', () => {
|
||
updateTimeline();
|
||
|
||
if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
|
||
navigator.mediaSession.setPositionState({
|
||
duration: audio.duration,
|
||
playbackRate: audio.playbackRate,
|
||
position: audio.currentTime
|
||
});
|
||
}
|
||
});
|
||
|
||
// --- Update media session metadata when audio is loaded ---
|
||
audio.addEventListener('play', () => {
|
||
if ('mediaSession' in navigator)
|
||
navigator.mediaSession.playbackState = 'playing';
|
||
});
|
||
audio.addEventListener('pause', () => {
|
||
if ('mediaSession' in navigator)
|
||
navigator.mediaSession.playbackState = 'paused';
|
||
});
|
||
|
||
audio.ontimeupdate = updateTimeline;
|
||
|
||
// Fallback for mobile throttling
|
||
setInterval(() => {
|
||
if (!audio.paused && !isSeeking) updateTimeline();
|
||
}, 500);
|
||
|
||
|
||
// --- When audio ends ---
|
||
audio.onended = function() {
|
||
playerButton.innerHTML = playIcon;
|
||
};
|
||
|
||
async function downloadAudio() {
|
||
const src = audio.currentSrc || 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();
|
||
a.download = ''; // tell Safari “this is a download”
|
||
a.target = '_blank'; // force a real navigation on iOS
|
||
// 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);
|
||
}
|
||
|
||
// Global variable to store the current fetch's AbortController.
|
||
let currentFetchController = null;
|
||
|
||
async function startPlaying(relUrl) {
|
||
// Pause the audio and clear its source.
|
||
audio.pause();
|
||
audio.src = '';
|
||
|
||
// Display the audio player container.
|
||
audioPlayerContainer.style.display = "block";
|
||
|
||
// Set a timeout to display a loader message if needed.
|
||
const loaderTimeout = setTimeout(() => {
|
||
playerButton.innerHTML = playIcon;
|
||
nowPlayingInfo.textContent = "Wird geladen...";
|
||
}, 250);
|
||
const spinnerTimer = setTimeout(showSpinner, 500);
|
||
|
||
footer.style.display = 'flex';
|
||
|
||
// Abort any previous fetch if still running.
|
||
if (currentFetchController) {
|
||
currentFetchController.abort();
|
||
}
|
||
currentFetchController = new AbortController();
|
||
|
||
const mediaUrl = `/media/${relUrl}`;
|
||
|
||
try {
|
||
// Perform a HEAD request to verify media availability.
|
||
const response = await fetch(mediaUrl, { method: 'HEAD', signal: currentFetchController.signal });
|
||
clearTimeout(loaderTimeout);
|
||
|
||
if (response.status === 403) {
|
||
nowPlayingInfo.textContent = "Fehler: Zugriff verweigert.";
|
||
window.location.href = '/';
|
||
return;
|
||
} else if (!response.ok) {
|
||
nowPlayingInfo.textContent = `Fehler: Unerwarteter Status (${response.status}).`;
|
||
console.error('Unexpected response status:', response.status);
|
||
return;
|
||
}
|
||
|
||
// Set the media URL, load, and play the audio.
|
||
audio.src = mediaUrl;
|
||
audio.load();
|
||
await audio.play();
|
||
clearTimeout(spinnerTimer);
|
||
hideSpinner();
|
||
currentTrackPath = relUrl;
|
||
playerButton.innerHTML = pauseIcon;
|
||
|
||
// Process file path for display.
|
||
const pathParts = relUrl.split('/');
|
||
const folderName = pathParts[pathParts.length - 2];
|
||
const fileName = pathParts.pop();
|
||
const pathStr = pathParts.join('/');
|
||
|
||
// Update Media Session metadata if available.
|
||
if ('mediaSession' in navigator) {
|
||
navigator.mediaSession.metadata = new MediaMetadata({
|
||
title: fileName.replace(/\.[^/.]+$/, ''), // remove extension
|
||
artist: folderName,
|
||
artwork: [
|
||
{ src: '/icons/logo-192x192.png', sizes: '192x192', type: 'image/png' }
|
||
]
|
||
});
|
||
navigator.mediaSession.playbackState = 'playing';
|
||
navigator.mediaSession.setPositionState({
|
||
duration: audio.duration,
|
||
playbackRate: audio.playbackRate,
|
||
position: 0
|
||
});
|
||
}
|
||
|
||
nowPlayingInfo.innerHTML = pathStr.replace(/\//g, ' > ') +
|
||
'<br><span style="font-size: larger; font-weight: bold;">' +
|
||
fileName.replace('.mp3', '') + '</span>';
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') {
|
||
console.log('Previous fetch aborted.');
|
||
} else {
|
||
console.error('Error fetching media:', error);
|
||
nowPlayingInfo.textContent = "Fehler: Netzwerkproblem oder ungültige URL.";
|
||
}
|
||
};
|
||
}
|
||
|
||
|
||
if ('mediaSession' in navigator) {
|
||
|
||
// Handler for the play action
|
||
navigator.mediaSession.setActionHandler('play', () => {
|
||
navigator.mediaSession.playbackState = 'playing'
|
||
document.getElementById('globalAudio').play();
|
||
});
|
||
|
||
// Handler for the pause action
|
||
navigator.mediaSession.setActionHandler('pause', () => {
|
||
navigator.mediaSession.playbackState = 'paused'
|
||
document.getElementById('globalAudio').pause();
|
||
});
|
||
|
||
// Handler for the previous track action
|
||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||
if (currentMusicIndex > 0) {
|
||
const prevFile = currentMusicFiles[currentMusicIndex - 1];
|
||
const prevLink = document.querySelector(`.play-file[data-url="${prevFile.path}"]`);
|
||
if (prevLink) {
|
||
prevLink.click();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Handler for the next track action
|
||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||
if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) {
|
||
const nextFile = currentMusicFiles[currentMusicIndex + 1];
|
||
const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`);
|
||
if (nextLink) {
|
||
nextLink.click();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Handler for the seek backward and seek forward actions
|
||
navigator.mediaSession.setActionHandler('seekbackward', details => {
|
||
const offset = details.seekOffset || 10;
|
||
audio.currentTime = Math.max(0, audio.currentTime - offset);
|
||
});
|
||
navigator.mediaSession.setActionHandler('seekforward', details => {
|
||
const offset = details.seekOffset || 10;
|
||
audio.currentTime = Math.min(audio.duration, audio.currentTime + offset);
|
||
});
|
||
|
||
// Handler for the seek backward action
|
||
navigator.mediaSession.setActionHandler('seekto', ({seekTime, fastSeek}) => {
|
||
if (fastSeek && 'fastSeek' in audio) {
|
||
audio.fastSeek(seekTime);
|
||
} else {
|
||
audio.currentTime = seekTime;
|
||
}
|
||
// immediately update the remote clock
|
||
navigator.mediaSession.setPositionState({
|
||
duration: audio.duration,
|
||
playbackRate: audio.playbackRate,
|
||
position: audio.currentTime
|
||
});
|
||
});
|
||
|
||
} |