Merge branch 'main' of github.com:SamidyFR/monochrome

This commit is contained in:
Samidy 2026-01-10 17:38:38 +03:00
commit 349bba2ab0
8 changed files with 499 additions and 355 deletions

View file

@ -68,38 +68,115 @@
</div>
</div>
<div id="playlist-modal" class="modal" style="display: none;">
<div class="modal-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000;">
<div class="modal-content" style="background: var(--card); padding: 2rem; border-radius: var(--radius); max-width: 400px; width: 90%;">
<h3 id="playlist-modal-title">Create Playlist</h3>
<input type="text" id="playlist-name-input" placeholder="Playlist name" style="width: 100%; margin: 1rem 0; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--background); color: var(--foreground);">
<div id="csv-import-section" style="display: none; margin: 1rem 0; padding: 1rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--background-secondary);">
<p style="margin-bottom: 0.5rem; font-size: 0.9rem;">Import from CSV</p>
<p style="font-size: 0.8rem; margin: 0;">Only Spotify Is Supported for now. please use <a href="https://exportify.app/" style="text-decoration: underline;">Exportify</a> to export your playlist into a csv. Also Make sure its headers are in english.</p>
<br>
<input type="file" id="csv-file-input" class="btn-secondary" accept=".csv" style="width: 100%; margin-bottom: 0.5rem;">
<p style="font-size: 0.8rem; margin: 0;">This Feature Isnt Perfect And is Prone To Errors! Please check Your Playlist After To Remove Weird Songs That Were Added By The System.</p>
</div>
<div id="playlist-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<h3 id="playlist-modal-title">Create Playlist</h3>
<input type="text" id="playlist-name-input" class="template-input" placeholder="Playlist name" style="margin: 1rem 0;">
<div id="csv-import-section" style="display: none; margin: 1rem 0; padding: 1rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--background-secondary);">
<p style="margin-bottom: 0.5rem; font-size: 0.9rem;">Import from CSV</p>
<p style="font-size: 0.8rem; margin: 0;">Only Spotify Is Supported for now. please use <a href="https://exportify.app/" style="text-decoration: underline;">Exportify</a> to export your playlist into a csv. Also Make sure its headers are in english.</p>
<br>
<input type="file" id="csv-file-input" class="btn-secondary" accept=".csv" style="width: 100%; margin-bottom: 0.5rem;">
<p style="font-size: 0.8rem; margin: 0;">This Feature Isnt Perfect And is Prone To Errors! Please check Your Playlist After To Remove Weird Songs That Were Added By The System.</p>
</div>
<div class="setting-item" style="margin-bottom: 1rem; padding: 0; border: none; background: transparent;">
<div class="info">
<span class="label">Public Playlist</span>
<span class="description">Visible to anyone with the link</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="playlist-public-toggle">
<span class="slider"></span>
</label>
<div class="setting-item" style="margin-bottom: 1rem; padding: 0; border: none; background: transparent;">
<div class="info">
<span class="label">Public Playlist</span>
<span class="description">Visible to anyone with the link</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="playlist-public-toggle">
<span class="slider"></span>
</label>
</div>
<div class="modal-actions" style="display: flex; gap: 0.5rem; justify-content: space-between; align-items: center;">
<button id="playlist-share-btn" class="btn-secondary" style="display: none;">Share</button>
<div style="display: flex; gap: 0.5rem; margin-left: auto;">
<button id="playlist-modal-cancel" class="btn-secondary">Cancel</button>
<button id="playlist-modal-save" class="btn-primary">Save</button>
</div>
<div class="modal-actions">
<button id="playlist-share-btn" class="btn-secondary" style="display: none;">Share</button>
<button id="playlist-modal-cancel" class="btn-secondary">Cancel</button>
<button id="playlist-modal-save" class="btn-primary">Save</button>
</div>
</div>
</div>
<div id="playlist-select-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content">
<h3>Add to Playlist</h3>
<div id="playlist-select-list" class="modal-list">
<!-- Options will be injected here -->
</div>
<div class="modal-actions">
<button id="playlist-select-cancel" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
<div id="shortcuts-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content medium">
<div class="shortcuts-header">
<h3>Keyboard Shortcuts</h3>
<button class="close-shortcuts">&times;</button>
</div>
<div class="shortcuts-content">
<div class="shortcut-item"><kbd>Space</kbd><span>Play / Pause</span></div>
<div class="shortcut-item"><kbd></kbd><span>Seek forward 10s</span></div>
<div class="shortcut-item"><kbd></kbd><span>Seek backward 10s</span></div>
<div class="shortcut-item"><kbd>Shift</kbd> + <kbd></kbd><span>Next track</span></div>
<div class="shortcut-item"><kbd>Shift</kbd> + <kbd></kbd><span>Previous track</span></div>
<div class="shortcut-item"><kbd></kbd><span>Volume up</span></div>
<div class="shortcut-item"><kbd></kbd><span>Volume down</span></div>
<div class="shortcut-item"><kbd>M</kbd><span>Mute / Unmute</span></div>
<div class="shortcut-item"><kbd>S</kbd><span>Toggle shuffle</span></div>
<div class="shortcut-item"><kbd>R</kbd><span>Toggle repeat</span></div>
<div class="shortcut-item"><kbd>Q</kbd><span>Open queue</span></div>
<div class="shortcut-item"><kbd>L</kbd><span>Toggle lyrics</span></div>
<div class="shortcut-item"><kbd>/</kbd><span>Focus search</span></div>
<div class="shortcut-item"><kbd>Esc</kbd><span>Close modals</span></div>
</div>
</div>
</div>
<div id="missing-tracks-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content wide">
<div class="missing-tracks-header">
<h3>Note</h3>
<button class="close-missing-tracks">&times;</button>
</div>
<div class="missing-tracks-content">
<p>Unfortunately some songs weren't able to be added. This could be an issue with our import system, try searching for the song and adding it. But it could also be due to Monochrome not having it sadly :(</p>
<div class="missing-tracks-list">
<h4>Missing Tracks:</h4>
<ul id="missing-tracks-list-ul"></ul>
</div>
</div>
<div class="missing-tracks-actions">
<button class="btn-secondary" id="close-missing-tracks-btn">OK</button>
</div>
</div>
</div>
<div id="sleep-timer-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-content" style="max-width: 300px;">
<h3 style="text-align: center; margin-bottom: 1.5rem;">Sleep Timer</h3>
<div class="timer-options">
<button class="timer-option btn-secondary" data-minutes="5">5 minutes</button>
<button class="timer-option btn-secondary" data-minutes="15">15 minutes</button>
<button class="timer-option btn-secondary" data-minutes="30">30 minutes</button>
<button class="timer-option btn-secondary" data-minutes="60">1 hour</button>
<button class="timer-option btn-secondary" data-minutes="120">2 hours</button>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="number" id="custom-minutes" class="template-input" placeholder="Custom" min="1" max="480">
<button class="timer-option btn-primary" id="custom-timer-btn" style="padding: 0.5rem 1rem;">Set</button>
</div>
</div>
<div class="modal-actions" style="justify-content: center;">
<button id="cancel-sleep-timer" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
@ -112,6 +189,7 @@
</div>
<div class="progress-content">
<div class="current-track">Preparing import...</div>
<div class="current-artist" style="font-size: 0.9em; color: var(--text-secondary); margin-bottom: 0.5rem;"></div>
<div class="progress-bar">
<div class="progress-fill" id="csv-progress-fill"></div>
</div>
@ -569,6 +647,24 @@
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">ReplayGain Mode</span>
<span class="description">Normalize volume across tracks</span>
</div>
<select id="replay-gain-mode">
<option value="off">Off</option>
<option value="track">Track</option>
<option value="album">Album</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">ReplayGain Pre-Amp</span>
<span class="description">Adjust gain manually (dB)</span>
</div>
<input type="number" id="replay-gain-preamp" value="3" step="0.5" style="width: 80px;">
</div>
</div>
<div class="settings-group">

208
js/app.js
View file

@ -120,11 +120,11 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
break;
case 'arrowup':
e.preventDefault();
audioPlayer.volume = Math.min(1, audioPlayer.volume + 0.1);
player.setVolume(player.userVolume + 0.1);
break;
case 'arrowdown':
e.preventDefault();
audioPlayer.volume = Math.max(0, audioPlayer.volume - 0.1);
player.setVolume(player.userVolume - 0.1);
break;
case 'm':
audioPlayer.muted = !audioPlayer.muted;
@ -421,7 +421,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (publicToggle) publicToggle.checked = false;
if (shareBtn) shareBtn.style.display = 'none';
modal.style.display = 'flex';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
@ -465,7 +465,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (window.location.hash === `#userplaylist/${editingId}`) {
ui.renderPlaylistPage(editingId, 'user');
}
modal.style.display = 'none';
modal.classList.remove('active');
delete modal.dataset.editingId;
}
});
@ -482,6 +482,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const progressCurrent = document.getElementById('csv-progress-current');
const progressTotal = document.getElementById('csv-progress-total');
const currentTrackElement = progressElement.querySelector('.current-track');
const currentArtistElement = progressElement.querySelector('.current-artist');
try {
// Show progress bar
@ -489,6 +490,7 @@ document.addEventListener('DOMContentLoaded', async () => {
progressFill.style.width = '0%';
progressCurrent.textContent = '0';
currentTrackElement.textContent = 'Reading CSV file...';
if (currentArtistElement) currentArtistElement.textContent = '';
const csvText = await file.text();
const lines = csvText.trim().split('\n');
@ -500,6 +502,7 @@ document.addEventListener('DOMContentLoaded', async () => {
progressFill.style.width = `${Math.min(percentage, 100)}%`;
progressCurrent.textContent = progress.current.toString();
currentTrackElement.textContent = progress.currentTrack;
if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || '';
});
tracks = result.tracks;
@ -537,14 +540,14 @@ document.addEventListener('DOMContentLoaded', async () => {
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
syncManager.syncUserPlaylist(playlist, 'create');
ui.renderLibraryPage();
modal.style.display = 'none';
modal.classList.remove('active');
});
}
}
}
if (e.target.closest('#playlist-modal-cancel')) {
document.getElementById('playlist-modal').style.display = 'none';
document.getElementById('playlist-modal').classList.remove('active');
}
if (e.target.closest('.edit-playlist-btn')) {
@ -574,7 +577,7 @@ document.addEventListener('DOMContentLoaded', async () => {
modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none';
modal.style.display = 'flex';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
});
@ -613,7 +616,7 @@ document.addEventListener('DOMContentLoaded', async () => {
modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none';
modal.style.display = 'flex';
modal.classList.add('active');
document.getElementById('playlist-name-input').focus();
}
});
@ -970,37 +973,24 @@ function showInstallPrompt(deferredPrompt) {
}
function showMissingTracksNotification(missingTracks) {
const modal = document.createElement('div');
modal.className = 'missing-tracks-modal-overlay';
modal.innerHTML = `
<div class="missing-tracks-modal">
<div class="missing-tracks-header">
<h3>Note</h3>
<button class="close-missing-tracks">&times;</button>
</div>
<div class="missing-tracks-content">
<p>Unfortunately some songs weren't able to be added. This could be an issue with our import system, try searching for the song and adding it. But it could also be due to Monochrome not having it sadly :(</p>
<div class="missing-tracks-list">
<h4>Missing Tracks:</h4>
<ul>
${missingTracks.map(track => `<li>${track}</li>`).join('')}
</ul>
</div>
</div>
<div class="missing-tracks-actions">
<button class="btn-secondary" id="close-missing-tracks-btn">OK</button>
</div>
</div>
`;
document.body.appendChild(modal);
const modal = document.getElementById('missing-tracks-modal');
const listUl = document.getElementById('missing-tracks-list-ul');
listUl.innerHTML = missingTracks.map(track => `<li>${track}</li>`).join('');
const closeModal = () => modal.classList.remove('active');
const closeModal = () => modal.remove();
modal.addEventListener('click', (e) => {
if (e.target === modal || e.target.classList.contains('close-missing-tracks') || e.target.id === 'close-missing-tracks-btn') {
// Remove old listeners if any (though usually these functions are called once per instance,
// but since we reuse the same modal element we should be careful or use a one-time listener)
const handleClose = (e) => {
if (e.target === modal || e.target.closest('.close-missing-tracks') || e.target.id === 'close-missing-tracks-btn' || e.target.classList.contains('modal-overlay')) {
closeModal();
modal.removeEventListener('click', handleClose);
}
});
};
modal.addEventListener('click', handleClose);
modal.classList.add('active');
}
async function parseCSV(csvText, api, onProgress) {
@ -1070,7 +1060,8 @@ async function parseCSV(csvText, api, onProgress) {
onProgress({
current: i,
total: totalTracks,
currentTrack: trackTitle || 'Unknown track'
currentTrack: trackTitle || 'Unknown track',
currentArtist: artistNames || ''
});
}
@ -1080,12 +1071,45 @@ async function parseCSV(csvText, api, onProgress) {
await new Promise(resolve => setTimeout(resolve, 300));
try {
const searchQuery = `${trackTitle} ${artistNames}`;
const searchResults = await api.searchTracks(searchQuery);
let foundTrack = null;
// 1. Initial Search: Title + All Artists
let searchQuery = `${trackTitle} ${artistNames}`;
let searchResults = await api.searchTracks(searchQuery);
if (searchResults.items && searchResults.items.length > 0) {
// Use the first result
const foundTrack = searchResults.items[0];
foundTrack = searchResults.items[0];
}
// 2. Retry with Main Artist only
if (!foundTrack) {
const mainArtist = artistNames.split(',')[0].trim();
// Only retry if mainArtist is actually different from artistNames (e.g. multiple artists)
if (mainArtist && mainArtist !== artistNames) {
searchQuery = `${trackTitle} ${mainArtist}`;
console.log(`Retry 1 (Main Artist): ${searchQuery}`);
searchResults = await api.searchTracks(searchQuery);
if (searchResults.items && searchResults.items.length > 0) {
foundTrack = searchResults.items[0];
}
}
}
// 3. Retry with Cleaned Title (if " - " exists) + Main Artist
if (!foundTrack && trackTitle.includes(' - ')) {
const mainArtist = artistNames.split(',')[0].trim();
const cleanedTitle = trackTitle.split(' - ')[0].trim();
if (cleanedTitle) {
searchQuery = `${cleanedTitle} ${mainArtist}`;
console.log(`Retry 2 (Cleaned Title): ${searchQuery}`);
searchResults = await api.searchTracks(searchQuery);
if (searchResults.items && searchResults.items.length > 0) {
foundTrack = searchResults.items[0];
}
}
}
if (foundTrack) {
tracks.push(foundTrack);
console.log(`Found track: "${trackTitle}" by ${artistNames}`);
} else {
@ -1113,79 +1137,35 @@ async function parseCSV(csvText, api, onProgress) {
}
function showKeyboardShortcuts() {
const modal = document.createElement('div');
modal.className = 'shortcuts-modal-overlay';
modal.innerHTML = `
<div class="shortcuts-modal">
<div class="shortcuts-header">
<h3>Keyboard Shortcuts</h3>
<button class="close-shortcuts">&times;</button>
</div>
<div class="shortcuts-content">
<div class="shortcut-item">
<kbd>Space</kbd>
<span>Play / Pause</span>
</div>
<div class="shortcut-item">
<kbd></kbd>
<span>Seek forward 10s</span>
</div>
<div class="shortcut-item">
<kbd></kbd>
<span>Seek backward 10s</span>
</div>
<div class="shortcut-item">
<kbd>Shift</kbd> + <kbd></kbd>
<span>Next track</span>
</div>
<div class="shortcut-item">
<kbd>Shift</kbd> + <kbd></kbd>
<span>Previous track</span>
</div>
<div class="shortcut-item">
<kbd></kbd>
<span>Volume up</span>
</div>
<div class="shortcut-item">
<kbd></kbd>
<span>Volume down</span>
</div>
<div class="shortcut-item">
<kbd>M</kbd>
<span>Mute / Unmute</span>
</div>
<div class="shortcut-item">
<kbd>S</kbd>
<span>Toggle shuffle</span>
</div>
<div class="shortcut-item">
<kbd>R</kbd>
<span>Toggle repeat</span>
</div>
<div class="shortcut-item">
<kbd>Q</kbd>
<span>Open queue</span>
</div>
<div class="shortcut-item">
<kbd>L</kbd>
<span>Toggle lyrics</span>
</div>
<div class="shortcut-item">
<kbd>/</kbd>
<span>Focus search</span>
</div>
<div class="shortcut-item">
<kbd>Esc</kbd>
<span>Close modals</span>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if (e.target === modal || e.target.classList.contains('close-shortcuts')) {
modal.remove();
const modal = document.getElementById('shortcuts-modal');
const closeModal = () => {
modal.classList.remove('active');
modal.removeEventListener('click', handleClose);
};
const handleClose = (e) => {
if (e.target === modal || e.target.classList.contains('close-shortcuts') || e.target.classList.contains('modal-overlay')) {
closeModal();
}
});
};
modal.addEventListener('click', handleClose);
modal.classList.add('active');
}

View file

@ -250,7 +250,8 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
});
const updateVolumeUI = () => {
const { volume, muted } = audioPlayer;
const { muted } = audioPlayer;
const volume = player.userVolume;
volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
const effectiveVolume = muted ? 0 : volume * 100;
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`);
@ -268,7 +269,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) {
const savedVolume = parseFloat(localStorage.getItem('volume') || '0.7');
const savedMuted = localStorage.getItem('muted') === 'true';
audioPlayer.volume = savedVolume;
player.setVolume(savedVolume);
audioPlayer.muted = savedMuted;
volumeFill.style.width = `${savedVolume * 100}%`;
@ -337,10 +338,9 @@ function initializeSmoothSliders(audioPlayer, player) {
if (isAdjustingVolume) {
seek(volumeBar, e, position => {
audioPlayer.volume = position;
player.setVolume(position);
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
});
}
});
@ -360,10 +360,9 @@ function initializeSmoothSliders(audioPlayer, player) {
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;
player.setVolume(position);
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
}
});
@ -417,10 +416,9 @@ function initializeSmoothSliders(audioPlayer, player) {
volumeBar.addEventListener('mousedown', (e) => {
isAdjustingVolume = true;
seek(volumeBar, e, position => {
audioPlayer.volume = position;
player.setVolume(position);
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
});
});
@ -430,52 +428,48 @@ function initializeSmoothSliders(audioPlayer, player) {
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;
player.setVolume(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;
player.setVolume(position);
volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
});
}
});
volumeBar.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
const newVolume = Math.max(0, Math.min(1, audioPlayer.volume + delta));
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
if (delta > 0 && audioPlayer.muted) {
audioPlayer.muted = false;
localStorage.setItem('muted', false);
}
audioPlayer.volume = newVolume;
player.setVolume(newVolume);
volumeFill.style.width = `${newVolume * 100}%`;
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
localStorage.setItem('volume', newVolume);
}, { passive: false });
volumeBtn?.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
const newVolume = Math.max(0, Math.min(1, audioPlayer.volume + delta));
const newVolume = Math.max(0, Math.min(1, player.userVolume + delta));
if (delta > 0 && audioPlayer.muted) {
audioPlayer.muted = false;
localStorage.setItem('muted', false);
}
audioPlayer.volume = newVolume;
player.setVolume(newVolume);
volumeFill.style.width = `${newVolume * 100}%`;
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
localStorage.setItem('volume', newVolume);
}, { passive: false });
}
@ -615,39 +609,43 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
return;
}
const modal = document.createElement('div');
modal.className = 'playlist-select-modal';
modal.innerHTML = `
<div class="modal-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center;">
<div class="modal-content" style="background: var(--card); padding: 2rem; border-radius: var(--radius); max-width: 400px; width: 90%;">
<h3>Add to Playlist</h3>
<div id="playlist-list" style="margin: 1rem 0; max-height: 200px; overflow-y: auto;">
${playlists.map(p => `<div class="playlist-option" data-id="${p.id}" style="padding: 0.5rem; cursor: pointer; border-bottom: 1px solid var(--border);">${p.name}</div>`).join('')}
</div>
<div class="modal-actions" style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button id="cancel-add-playlist" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const modal = document.getElementById('playlist-select-modal');
const list = document.getElementById('playlist-select-list');
const cancelBtn = document.getElementById('playlist-select-cancel');
const overlay = modal.querySelector('.modal-overlay');
modal.addEventListener('click', async (e) => {
if (e.target.id === 'cancel-add-playlist') {
modal.remove();
return;
}
list.innerHTML = playlists.map(p => `
<div class="modal-option" data-id="${p.id}">${p.name}</div>
`).join('');
const option = e.target.closest('.playlist-option');
const closeModal = () => {
modal.classList.remove('active');
cleanup();
};
const handleOptionClick = async (e) => {
const option = e.target.closest('.modal-option');
if (option) {
const playlistId = option.dataset.id;
await db.addTrackToPlaylist(playlistId, item);
const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Added to playlist: ${option.textContent}`);
modal.remove();
closeModal();
}
});
};
const cleanup = () => {
cancelBtn.removeEventListener('click', closeModal);
overlay.removeEventListener('click', closeModal);
list.removeEventListener('click', handleOptionClick);
};
cancelBtn.addEventListener('click', closeModal);
overlay.addEventListener('click', closeModal);
list.addEventListener('click', handleOptionClick);
modal.classList.add('active');
}
}
@ -853,34 +851,6 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
});
}
// cast is back working woo :P
const castBtn = document.getElementById('cast-btn');
if (castBtn) {
castBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const audioPlayer = document.getElementById('audio-player');
if (!audioPlayer.src) {
alert('Please play a track first to enable casting.');
return;
}
if ('remote' in audioPlayer) {
audioPlayer.remote.prompt().catch(err => {
if (err.name === 'NotAllowedError') return;
if (err.name === 'NotFoundError') {
alert('No remote playback devices (Chromecast/AirPlay) were found on your network.');
return;
}
console.log('Cast prompt error:', err);
});
} else if (audioPlayer.webkitShowPlaybackTargetPicker) {
audioPlayer.webkitShowPlaybackTargetPicker();
} else {
alert('Casting is not supported in this browser. Try Chrome for Chromecast or Safari for AirPlay.');
}
});
}
@ -900,61 +870,52 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
function showSleepTimerModal(player) {
if (document.querySelector('.sleep-timer-modal')) return;
const modal = document.getElementById('sleep-timer-modal');
if (!modal) return;
const modal = document.createElement('div');
modal.className = 'sleep-timer-modal';
modal.innerHTML = `
<div class="modal-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center;">
<div class="modal-content" style="background: var(--card); padding: 2rem; border-radius: var(--radius); max-width: 300px; width: 90%;">
<h3 style="text-align: center; margin-bottom: 1.5rem;">Sleep Timer</h3>
<div class="timer-options" style="display: flex; flex-direction: column; gap: 0.5rem;">
<button class="timer-option btn-secondary" data-minutes="5">5 minutes</button>
<button class="timer-option btn-secondary" data-minutes="15">15 minutes</button>
<button class="timer-option btn-secondary" data-minutes="30">30 minutes</button>
<button class="timer-option btn-secondary" data-minutes="60">1 hour</button>
<button class="timer-option btn-secondary" data-minutes="120">2 hours</button>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<input type="number" id="custom-minutes" placeholder="Custom" min="1" max="480" style="flex: 1; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--background); color: var(--foreground);">
<button class="timer-option btn-primary" id="custom-timer-btn" style="padding: 0.5rem 1rem;">Set</button>
</div>
</div>
<div class="modal-actions" style="display: flex; gap: 0.5rem; justify-content: center; margin-top: 1.5rem;">
<button id="cancel-sleep-timer" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if (e.target.id === 'cancel-sleep-timer' || e.target.classList.contains('modal-overlay')) {
modal.remove();
return;
}
const closeModal = () => {
modal.classList.remove('active');
cleanup();
};
const handleOptionClick = (e) => {
const timerOption = e.target.closest('.timer-option');
if (timerOption) {
const minutes = parseInt(timerOption.dataset.minutes);
let minutes;
if (timerOption.id === 'custom-timer-btn') {
const customInput = document.getElementById('custom-minutes');
minutes = parseInt(customInput.value);
if (!minutes || minutes < 1) {
showNotification('Please enter a valid number of minutes');
return;
}
} else {
minutes = parseInt(timerOption.dataset.minutes);
}
if (minutes) {
player.setSleepTimer(minutes);
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
modal.remove();
closeModal();
}
}
};
if (e.target.id === 'custom-timer-btn') {
const customInput = document.getElementById('custom-minutes');
const minutes = parseInt(customInput.value);
if (minutes && minutes > 0 && minutes <= 480) {
player.setSleepTimer(minutes);
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
modal.remove();
} else {
showNotification('Please enter a valid number of minutes (1-480)');
}
const handleCancel = (e) => {
if (e.target.id === 'cancel-sleep-timer' || e.target.classList.contains('modal-overlay')) {
closeModal();
}
});
};
const cleanup = () => {
modal.removeEventListener('click', handleOptionClick);
modal.removeEventListener('click', handleCancel);
};
modal.addEventListener('click', handleOptionClick);
modal.addEventListener('click', handleCancel);
modal.classList.add('active');
}
function positionMenu(menu, x, y, anchorRect = null) {

View file

@ -1,6 +1,6 @@
//js/player.js
import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle, getTrackArtistsHTML } from './utils.js';
import { queueManager } from './storage.js';
import { queueManager, replayGainSettings } from './storage.js';
export class Player {
constructor(audioElement, api, quality = 'LOSSLESS') {
@ -16,6 +16,8 @@ export class Player {
this.preloadCache = new Map();
this.preloadAbortController = null;
this.currentTrack = null;
this.currentRgValues = null;
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
// Sleep timer properties
this.sleepTimer = null;
@ -30,6 +32,47 @@ export class Player {
});
}
setVolume(value) {
this.userVolume = Math.max(0, Math.min(1, value));
localStorage.setItem('volume', this.userVolume);
this.applyReplayGain();
}
applyReplayGain() {
const mode = replayGainSettings.getMode(); // 'off', 'track', 'album'
let gainDb = 0;
let peak = 1.0;
if (mode !== 'off' && this.currentRgValues) {
const { trackReplayGain, trackPeakAmplitude, albumReplayGain, albumPeakAmplitude } = this.currentRgValues;
if (mode === 'album' && albumReplayGain !== undefined) {
gainDb = albumReplayGain;
peak = albumPeakAmplitude || 1.0;
} else if (trackReplayGain !== undefined) {
gainDb = trackReplayGain;
peak = trackPeakAmplitude || 1.0;
}
// Apply Pre-Amp
gainDb += replayGainSettings.getPreamp();
}
// Convert dB to linear scale: 10^(dB/20)
let scale = Math.pow(10, gainDb / 20);
// Peak protection (prevent clipping)
if (scale * peak > 1.0) {
scale = 1.0 / peak;
}
// Calculate effective volume
const effectiveVolume = this.userVolume * scale;
// Apply to audio element
this.audio.volume = Math.max(0, Math.min(1, effectiveVolume));
}
loadQueueState() {
const savedState = queueManager.getQueue();
if (savedState) {
@ -212,18 +255,29 @@ export class Player {
this.updatePlayingTrackIndicator();
try {
// Get track data for ReplayGain (should be cached by API)
const trackData = await this.api.getTrack(track.id, this.quality);
if (trackData && trackData.info) {
this.currentRgValues = {
trackReplayGain: trackData.info.trackReplayGain,
trackPeakAmplitude: trackData.info.trackPeakAmplitude,
albumReplayGain: trackData.info.albumReplayGain,
albumPeakAmplitude: trackData.info.albumPeakAmplitude
};
} else {
this.currentRgValues = null;
}
this.applyReplayGain();
let streamUrl;
if (this.preloadCache.has(track.id)) {
streamUrl = this.preloadCache.get(track.id);
} else if (trackData.originalTrackUrl) {
streamUrl = trackData.originalTrackUrl;
} else {
const trackData = await this.api.getTrack(track.id, this.quality);
if (trackData.originalTrackUrl) {
streamUrl = trackData.originalTrackUrl;
} else {
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
}
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
}
this.audio.src = streamUrl;

View file

@ -8,7 +8,8 @@ import {
trackListSettings,
cardSettings,
waveformSettings,
smoothScrollingSettings,
replayGainSettings,
smoothScrollingSettings
} from "./storage.js";
import { db } from "./db.js";
import { authManager } from "./firebase/auth.js";
@ -273,6 +274,25 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
// ReplayGain Settings
const replayGainMode = document.getElementById("replay-gain-mode");
if (replayGainMode) {
replayGainMode.value = replayGainSettings.getMode();
replayGainMode.addEventListener("change", (e) => {
replayGainSettings.setMode(e.target.value);
player.applyReplayGain();
});
}
const replayGainPreamp = document.getElementById("replay-gain-preamp");
if (replayGainPreamp) {
replayGainPreamp.value = replayGainSettings.getPreamp();
replayGainPreamp.addEventListener("change", (e) => {
replayGainSettings.setPreamp(parseFloat(e.target.value) || 3);
player.applyReplayGain();
});
}
// Now Playing Mode
const nowPlayingMode = document.getElementById("now-playing-mode");
if (nowPlayingMode) {

View file

@ -493,6 +493,24 @@ export const cardSettings = {
}
};
export const replayGainSettings = {
STORAGE_KEY_MODE: 'replay-gain-mode', // 'off', 'track', 'album'
STORAGE_KEY_PREAMP: 'replay-gain-preamp',
getMode() {
return localStorage.getItem(this.STORAGE_KEY_MODE) || 'track';
},
setMode(mode) {
localStorage.setItem(this.STORAGE_KEY_MODE, mode);
},
getPreamp() {
const val = parseFloat(localStorage.getItem(this.STORAGE_KEY_PREAMP));
return isNaN(val) ? 3 : val;
},
setPreamp(db) {
localStorage.setItem(this.STORAGE_KEY_PREAMP, db);
}
};
export const waveformSettings = {
STORAGE_KEY: 'waveform-seekbar-enabled',

View file

@ -67,29 +67,26 @@ export function getVibrantColorFromImage(imgElement) {
const ctx = canvas.getContext('2d');
if (!ctx) return null;
canvas.width = imgElement.naturalWidth || imgElement.width;
canvas.height = imgElement.naturalHeight || imgElement.height;
try {
ctx.drawImage(imgElement, 0, 0);
// Downscale for performance if image is large
const maxDimension = 64;
if (canvas.width > maxDimension || canvas.height > maxDimension) {
const scale = Math.min(maxDimension / canvas.width, maxDimension / canvas.height);
const w = Math.floor(canvas.width * scale);
const h = Math.floor(canvas.height * scale);
const smallCanvas = document.createElement('canvas');
smallCanvas.width = w;
smallCanvas.height = h;
smallCanvas.getContext('2d').drawImage(imgElement, 0, 0, w, h);
ctx.drawImage(smallCanvas, 0, 0, canvas.width, canvas.height);
// Actually, better to just use the small canvas data
var imageData = smallCanvas.getContext('2d').getImageData(0, 0, w, h);
} else {
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const maxDimension = 64;
let w = imgElement.naturalWidth || imgElement.width;
let h = imgElement.naturalHeight || imgElement.height;
if (w > maxDimension || h > maxDimension) {
const scale = Math.min(maxDimension / w, maxDimension / h);
w = Math.floor(w * scale);
h = Math.floor(h * scale);
}
canvas.width = w;
canvas.height = h;
// Draw image directly at small size
// Note: For best quality downscaling, one might step down, but for color extraction,
// direct browser downscaling is sufficient and much faster/lighter.
ctx.drawImage(imgElement, 0, 0, w, h);
const imageData = ctx.getImageData(0, 0, w, h);
const pixels = imageData.data;
const candidates = [];
@ -144,4 +141,4 @@ export function getVibrantColorFromImage(imgElement) {
} catch (e) {
throw e; // Re-throw to allow UI to handle CORS retry
}
}
}

View file

@ -2408,41 +2408,6 @@ input:checked + .slider::before {
align-items: stretch;
}
.shortcuts-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
animation: fadeIn 0.2s ease;
}
.shortcuts-modal {
background: var(--card);
border-radius: var(--radius);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
animation: scaleIn 0.2s ease;
}
.shortcuts-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.shortcuts-header h3 {
margin: 0;
}
.close-shortcuts {
background: transparent;
border: none;
@ -2943,7 +2908,91 @@ img:not([src]), img[src=''] {
background: var(--primary);
}
/* Modals */
.modal {
position: fixed;
inset: 0;
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: -1;
}
.modal-content {
background: var(--card);
padding: 2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-xl);
animation: scaleIn 0.2s ease;
max-height: 90vh;
overflow-y: auto;
}
.modal-content.wide {
max-width: 600px;
}
.modal-content.medium {
max-width: 500px;
}
.modal-list {
margin: 1rem 0;
max-height: 200px;
overflow-y: auto;
}
.modal-option {
padding: 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border);
transition: background 0.2s ease;
}
.modal-option:hover {
background: var(--secondary);
}
.modal-option:last-child {
border-bottom: none;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.timer-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.timer-option {
text-align: center;
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
#playlist-modal {
opacity: 1;
@ -3060,33 +3109,6 @@ img:not([src]), img[src=''] {
/* OH NO SOME SONGS WERENT FOUND FUCK ME FUCK ME */
.missing-tracks-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease;
}
.missing-tracks-modal {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
max-width: 600px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
animation: scaleIn 0.2s ease;
}
.missing-tracks-header {
display: flex;
justify-content: space-between;
@ -3668,15 +3690,11 @@ img:not([src]), img[src=''] {
font-size: 1.75rem;
}
.fullscreen-cover-content {
.fullscreen-cover-content {
flex-direction: column;
}
.fullscreen-lyrics-toggle {
right: 3.5rem;
}
.csv-import-progress {
.csv-import-progress {
bottom: 10px;
right: 10px;
left: 10px;
@ -3684,7 +3702,7 @@ img:not([src]), img[src=''] {
min-width: 0;
}
.missing-tracks-modal {
.missing-tracks-modal {
width: 95%;
max-height: 90vh;
margin: 1rem;
@ -3729,7 +3747,7 @@ img:not([src]), img[src=''] {
font-size: 0.9rem;
}
.mobile-only {
.mobile-only {
display: flex !important;
}