kv-music/js/events.js
Julien Maille 67b920c8eb feat: improve player UI and Android Auto integration
- Toggle enlarged cover on click and improved its readability
- Move track action visibility logic from JS to CSS for better performance
- Fix Android Auto progress bar and seeking by improving MediaSession sync
- Replace queue menu with a direct remove button for faster management
- Fix visual artifacts in light mode and lyrics panel ghost shadow
2025-12-24 17:58:15 +01:00

473 lines
17 KiB
JavaScript

//js/events.js
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename } from './utils.js';
import { lastFMStorage } from './storage.js';
import { addDownloadTask, updateDownloadProgress, completeDownloadTask, showNotification, downloadTrackWithMetadata } from './downloads.js';
import { lyricsSettings } from './storage.js';
import { updateTabTitle } from './router.js';
export function initializePlayerEvents(player, audioPlayer, scrobbler) {
const playPauseBtn = document.querySelector('.play-pause-btn');
const nextBtn = document.getElementById('next-btn');
const prevBtn = document.getElementById('prev-btn');
const shuffleBtn = document.getElementById('shuffle-btn');
const repeatBtn = document.getElementById('repeat-btn');
// Sync UI with player state on load
if (player.shuffleActive) {
shuffleBtn.classList.add('active');
}
if (player.repeatMode !== REPEAT_MODE.OFF) {
repeatBtn.classList.add('active');
if (player.repeatMode === REPEAT_MODE.ONE) {
repeatBtn.classList.add('repeat-one');
}
repeatBtn.title = player.repeatMode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One';
} else {
repeatBtn.title = 'Repeat';
}
audioPlayer.addEventListener('play', () => {
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
scrobbler.updateNowPlaying(player.currentTrack);
}
playPauseBtn.innerHTML = SVG_PAUSE;
player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState();
updateTabTitle(player);
});
audioPlayer.addEventListener('pause', () => {
playPauseBtn.innerHTML = SVG_PLAY;
player.updateMediaSessionPlaybackState();
player.updateMediaSessionPositionState();
});
audioPlayer.addEventListener('ended', () => {
player.playNext();
});
audioPlayer.addEventListener('timeupdate', () => {
const { currentTime, duration } = audioPlayer;
if (duration) {
const progressFill = document.getElementById('progress-fill');
const currentTimeEl = document.getElementById('current-time');
progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime);
}
});
audioPlayer.addEventListener('loadedmetadata', () => {
const totalDurationEl = document.getElementById('total-duration');
totalDurationEl.textContent = formatTime(audioPlayer.duration);
player.updateMediaSessionPositionState();
});
audioPlayer.addEventListener('error', (e) => {
console.error('Audio playback error:', e);
document.querySelector('.now-playing-bar .artist').textContent = 'Playback error. Try another track.';
playPauseBtn.innerHTML = SVG_PLAY;
});
playPauseBtn.addEventListener('click', () => player.handlePlayPause());
nextBtn.addEventListener('click', () => player.playNext());
prevBtn.addEventListener('click', () => player.playPrev());
shuffleBtn.addEventListener('click', () => {
player.toggleShuffle();
shuffleBtn.classList.toggle('active', player.shuffleActive);
renderQueue(player);
});
repeatBtn.addEventListener('click', () => {
const mode = player.toggleRepeat();
repeatBtn.classList.toggle('active', mode !== REPEAT_MODE.OFF);
repeatBtn.classList.toggle('repeat-one', mode === REPEAT_MODE.ONE);
repeatBtn.title = mode === REPEAT_MODE.OFF
? 'Repeat'
: (mode === REPEAT_MODE.ALL ? 'Repeat Queue' : 'Repeat One');
});
// Volume controls
const volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill');
const volumeBtn = document.getElementById('volume-btn');
const updateVolumeUI = () => {
const { volume, muted } = audioPlayer;
volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
const effectiveVolume = muted ? 0 : volume * 100;
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
};
volumeBtn.addEventListener('click', () => {
audioPlayer.muted = !audioPlayer.muted;
});
audioPlayer.addEventListener('volumechange', updateVolumeUI);
// Initialize volume from localStorage
const savedVolume = parseFloat(localStorage.getItem('volume') || '0.7');
audioPlayer.volume = savedVolume;
volumeFill.style.width = `${savedVolume * 100}%`;
volumeBar.style.setProperty('--volume-level', `${savedVolume * 100}%`);
updateVolumeUI();
initializeSmoothSliders(audioPlayer, player);
}
function initializeSmoothSliders(audioPlayer, player) {
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
const volumeBar = document.getElementById('volume-bar');
const volumeFill = document.getElementById('volume-fill');
let isSeeking = false;
let wasPlaying = false;
let isAdjustingVolume = false;
const seek = (bar, event, setter) => {
const rect = bar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
setter(position);
};
// Progress bar with smooth dragging
progressBar.addEventListener('mousedown', (e) => {
isSeeking = true;
wasPlaying = !audioPlayer.paused;
if (wasPlaying) audioPlayer.pause();
seek(progressBar, e, position => {
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
progressFill.style.width = `${position * 100}%`;
}
});
});
// Touch events for mobile
progressBar.addEventListener('touchstart', (e) => {
e.preventDefault();
isSeeking = true;
wasPlaying = !audioPlayer.paused;
if (wasPlaying) audioPlayer.pause();
const touch = e.touches[0];
const rect = progressBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
progressFill.style.width = `${position * 100}%`;
}
});
document.addEventListener('mousemove', (e) => {
if (isSeeking) {
seek(progressBar, e, position => {
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
progressFill.style.width = `${position * 100}%`;
}
});
}
if (isAdjustingVolume) {
seek(volumeBar, e, position => {
audioPlayer.volume = position;
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
});
}
});
document.addEventListener('touchmove', (e) => {
if (isSeeking) {
const touch = e.touches[0];
const rect = progressBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
progressFill.style.width = `${position * 100}%`;
}
}
if (isAdjustingVolume) {
const touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
audioPlayer.volume = position;
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
}
});
document.addEventListener('mouseup', (e) => {
if (isSeeking) {
seek(progressBar, e, position => {
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
player.updateMediaSessionPositionState();
if (wasPlaying) audioPlayer.play();
}
});
isSeeking = false;
}
if (isAdjustingVolume) {
isAdjustingVolume = false;
}
});
document.addEventListener('touchend', (e) => {
if (isSeeking) {
if (!isNaN(audioPlayer.duration)) {
player.updateMediaSessionPositionState();
if (wasPlaying) audioPlayer.play();
}
isSeeking = false;
}
if (isAdjustingVolume) {
isAdjustingVolume = false;
}
});
progressBar.addEventListener('click', e => {
if (!isSeeking) {
seek(progressBar, e, position => {
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
player.updateMediaSessionPositionState();
}
});
}
});
volumeBar.addEventListener('mousedown', (e) => {
isAdjustingVolume = true;
seek(volumeBar, e, position => {
audioPlayer.volume = position;
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
});
});
volumeBar.addEventListener('touchstart', (e) => {
e.preventDefault();
isAdjustingVolume = true;
const touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
audioPlayer.volume = position;
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
});
volumeBar.addEventListener('click', e => {
if (!isAdjustingVolume) {
seek(volumeBar, e, position => {
audioPlayer.volume = position;
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
});
}
});
}
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager) {
let contextTrack = null;
mainContent.addEventListener('click', e => {
const actionBtn = e.target.closest('.track-action-btn');
if (actionBtn) {
e.stopPropagation();
const trackItem = actionBtn.closest('.track-item');
if (trackItem) {
const track = trackDataStore.get(trackItem);
const action = actionBtn.dataset.action;
if (action === 'add-to-queue' && track) {
player.addToQueue(track);
renderQueue(player);
showNotification(`Added to queue: ${track.title}`);
} else if (action === 'play-next' && track) {
player.addNextToQueue(track);
renderQueue(player);
showNotification(`Playing next: ${track.title}`);
} else if (action === 'download' && track) {
handleDownload(track, player, api);
}
}
return;
}
const menuBtn = e.target.closest('.track-menu-btn');
if (menuBtn) {
e.stopPropagation();
const trackItem = menuBtn.closest('.track-item');
if (trackItem && !trackItem.dataset.queueIndex) {
contextTrack = trackDataStore.get(trackItem);
if (contextTrack) {
const rect = menuBtn.getBoundingClientRect();
positionMenu(contextMenu, rect.left, rect.bottom + 5, rect);
}
}
return;
}
const trackItem = e.target.closest('.track-item');
if (trackItem && !trackItem.dataset.queueIndex) {
const parentList = trackItem.closest('.track-list');
const allTrackElements = Array.from(parentList.querySelectorAll('.track-item'));
const trackList = allTrackElements.map(el => trackDataStore.get(el)).filter(Boolean);
if (trackList.length > 0) {
const clickedTrackId = trackItem.dataset.trackId;
const startIndex = trackList.findIndex(t => t.id == clickedTrackId);
player.setQueue(trackList, startIndex);
document.getElementById('shuffle-btn').classList.remove('active');
player.playTrackFromQueue();
}
}
});
mainContent.addEventListener('contextmenu', e => {
const trackItem = e.target.closest('.track-item');
if (trackItem && !trackItem.dataset.queueIndex) {
e.preventDefault();
contextTrack = trackDataStore.get(trackItem);
if (contextTrack) {
positionMenu(contextMenu, e.pageX, e.pageY);
}
}
});
document.addEventListener('click', () => {
contextMenu.style.display = 'none';
});
contextMenu.addEventListener('click', async e => {
e.stopPropagation();
const action = e.target.dataset.action;
if (action === 'play-next' && contextTrack) {
player.addNextToQueue(contextTrack);
renderQueue(player);
showNotification(`Playing next: ${contextTrack.title}`);
} else if (action === 'add-to-queue' && contextTrack) {
player.addToQueue(contextTrack);
renderQueue(player);
showNotification(`Added to queue: ${contextTrack.title}`);
} else if (action === 'download' && contextTrack) {
await downloadTrackWithMetadata(contextTrack, player.quality, api, lyricsManager);
}
contextMenu.style.display = 'none';
});
// Now playing bar interactions
document.querySelector('.now-playing-bar .title').addEventListener('click', () => {
const track = player.currentTrack;
if (track?.album?.id) {
window.location.hash = `#album/${track.album.id}`;
}
});
document.querySelector('.now-playing-bar .artist').addEventListener('click', () => {
const track = player.currentTrack;
if (track?.artist?.id) {
window.location.hash = `#artist/${track.artist.id}`;
}
});
}
function renderQueue(player) {
// This will be called from queue module
if (window.renderQueueFunction) {
window.renderQueueFunction();
}
}
function formatTime(seconds) {
if (isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
function positionMenu(menu, x, y, anchorRect = null) {
// Temporarily show to measure dimensions
menu.style.visibility = 'hidden';
menu.style.display = 'block';
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let left = x;
let top = y;
if (anchorRect) {
// Adjust horizontal position if it overflows right
if (left + menuWidth > windowWidth - 10) { // 10px buffer
left = anchorRect.right - menuWidth;
if (left < 10) left = 10;
}
// Adjust vertical position if it overflows bottom
if (top + menuHeight > windowHeight - 10) {
top = anchorRect.top - menuHeight - 5;
}
} else {
// Adjust horizontal position if it overflows right
if (left + menuWidth > windowWidth - 10) {
left = windowWidth - menuWidth - 10;
}
// Adjust vertical position if it overflows bottom
if (top + menuHeight > windowHeight - 10) {
top = y - menuHeight;
}
}
menu.style.top = `${top}px`;
menu.style.left = `${left}px`;
menu.style.visibility = 'visible';
}
async function handleDownload(track, player, api) {
const quality = player.quality;
const filename = buildTrackFilename(track, quality);
try {
const { taskEl, abortController } = addDownloadTask(
track.id,
track,
filename,
api
);
await api.downloadTrack(track.id, quality, filename, {
signal: abortController.signal,
onProgress: (progress) => {
updateDownloadProgress(track.id, progress);
}
});
completeDownloadTask(track.id, true);
} catch (error) {
if (error.name !== 'AbortError') {
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
? error.message
: 'Download failed. Please try again.';
completeDownloadTask(track.id, false, errorMsg);
}
}
}