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,11 +68,11 @@
</div> </div>
</div> </div>
<div id="playlist-modal" class="modal" style="display: none;"> <div id="playlist-modal" class="modal">
<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-overlay"></div>
<div class="modal-content" style="background: var(--card); padding: 2rem; border-radius: var(--radius); max-width: 400px; width: 90%;"> <div class="modal-content">
<h3 id="playlist-modal-title">Create Playlist</h3> <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);"> <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);"> <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="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> <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>
@ -92,14 +92,91 @@
</label> </label>
</div> </div>
<div class="modal-actions" style="display: flex; gap: 0.5rem; justify-content: space-between; align-items: center;"> <div class="modal-actions">
<button id="playlist-share-btn" class="btn-secondary" style="display: none;">Share</button> <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-cancel" class="btn-secondary">Cancel</button>
<button id="playlist-modal-save" class="btn-primary">Save</button> <button id="playlist-modal-save" class="btn-primary">Save</button>
</div> </div>
</div> </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>
</div> </div>
@ -112,6 +189,7 @@
</div> </div>
<div class="progress-content"> <div class="progress-content">
<div class="current-track">Preparing import...</div> <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-bar">
<div class="progress-fill" id="csv-progress-fill"></div> <div class="progress-fill" id="csv-progress-fill"></div>
</div> </div>
@ -569,6 +647,24 @@
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </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>
<div class="settings-group"> <div class="settings-group">

206
js/app.js
View file

@ -120,11 +120,11 @@ function initializeKeyboardShortcuts(player, audioPlayer) {
break; break;
case 'arrowup': case 'arrowup':
e.preventDefault(); e.preventDefault();
audioPlayer.volume = Math.min(1, audioPlayer.volume + 0.1); player.setVolume(player.userVolume + 0.1);
break; break;
case 'arrowdown': case 'arrowdown':
e.preventDefault(); e.preventDefault();
audioPlayer.volume = Math.max(0, audioPlayer.volume - 0.1); player.setVolume(player.userVolume - 0.1);
break; break;
case 'm': case 'm':
audioPlayer.muted = !audioPlayer.muted; audioPlayer.muted = !audioPlayer.muted;
@ -421,7 +421,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (publicToggle) publicToggle.checked = false; if (publicToggle) publicToggle.checked = false;
if (shareBtn) shareBtn.style.display = 'none'; if (shareBtn) shareBtn.style.display = 'none';
modal.style.display = 'flex'; modal.classList.add('active');
document.getElementById('playlist-name-input').focus(); document.getElementById('playlist-name-input').focus();
} }
@ -465,7 +465,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (window.location.hash === `#userplaylist/${editingId}`) { if (window.location.hash === `#userplaylist/${editingId}`) {
ui.renderPlaylistPage(editingId, 'user'); ui.renderPlaylistPage(editingId, 'user');
} }
modal.style.display = 'none'; modal.classList.remove('active');
delete modal.dataset.editingId; delete modal.dataset.editingId;
} }
}); });
@ -482,6 +482,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const progressCurrent = document.getElementById('csv-progress-current'); const progressCurrent = document.getElementById('csv-progress-current');
const progressTotal = document.getElementById('csv-progress-total'); const progressTotal = document.getElementById('csv-progress-total');
const currentTrackElement = progressElement.querySelector('.current-track'); const currentTrackElement = progressElement.querySelector('.current-track');
const currentArtistElement = progressElement.querySelector('.current-artist');
try { try {
// Show progress bar // Show progress bar
@ -489,6 +490,7 @@ document.addEventListener('DOMContentLoaded', async () => {
progressFill.style.width = '0%'; progressFill.style.width = '0%';
progressCurrent.textContent = '0'; progressCurrent.textContent = '0';
currentTrackElement.textContent = 'Reading CSV file...'; currentTrackElement.textContent = 'Reading CSV file...';
if (currentArtistElement) currentArtistElement.textContent = '';
const csvText = await file.text(); const csvText = await file.text();
const lines = csvText.trim().split('\n'); const lines = csvText.trim().split('\n');
@ -500,6 +502,7 @@ document.addEventListener('DOMContentLoaded', async () => {
progressFill.style.width = `${Math.min(percentage, 100)}%`; progressFill.style.width = `${Math.min(percentage, 100)}%`;
progressCurrent.textContent = progress.current.toString(); progressCurrent.textContent = progress.current.toString();
currentTrackElement.textContent = progress.currentTrack; currentTrackElement.textContent = progress.currentTrack;
if (currentArtistElement) currentArtistElement.textContent = progress.currentArtist || '';
}); });
tracks = result.tracks; tracks = result.tracks;
@ -537,14 +540,14 @@ document.addEventListener('DOMContentLoaded', async () => {
await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); await db.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
syncManager.syncUserPlaylist(playlist, 'create'); syncManager.syncUserPlaylist(playlist, 'create');
ui.renderLibraryPage(); ui.renderLibraryPage();
modal.style.display = 'none'; modal.classList.remove('active');
}); });
} }
} }
} }
if (e.target.closest('#playlist-modal-cancel')) { 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')) { if (e.target.closest('.edit-playlist-btn')) {
@ -574,7 +577,7 @@ document.addEventListener('DOMContentLoaded', async () => {
modal.dataset.editingId = playlistId; modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none'; document.getElementById('csv-import-section').style.display = 'none';
modal.style.display = 'flex'; modal.classList.add('active');
document.getElementById('playlist-name-input').focus(); document.getElementById('playlist-name-input').focus();
} }
}); });
@ -613,7 +616,7 @@ document.addEventListener('DOMContentLoaded', async () => {
modal.dataset.editingId = playlistId; modal.dataset.editingId = playlistId;
document.getElementById('csv-import-section').style.display = 'none'; document.getElementById('csv-import-section').style.display = 'none';
modal.style.display = 'flex'; modal.classList.add('active');
document.getElementById('playlist-name-input').focus(); document.getElementById('playlist-name-input').focus();
} }
}); });
@ -970,37 +973,24 @@ function showInstallPrompt(deferredPrompt) {
} }
function showMissingTracksNotification(missingTracks) { function showMissingTracksNotification(missingTracks) {
const modal = document.createElement('div'); const modal = document.getElementById('missing-tracks-modal');
modal.className = 'missing-tracks-modal-overlay'; const listUl = document.getElementById('missing-tracks-list-ul');
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 closeModal = () => modal.remove(); listUl.innerHTML = missingTracks.map(track => `<li>${track}</li>`).join('');
modal.addEventListener('click', (e) => { const closeModal = () => modal.classList.remove('active');
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(); closeModal();
modal.removeEventListener('click', handleClose);
} }
}); };
modal.addEventListener('click', handleClose);
modal.classList.add('active');
} }
async function parseCSV(csvText, api, onProgress) { async function parseCSV(csvText, api, onProgress) {
@ -1070,7 +1060,8 @@ async function parseCSV(csvText, api, onProgress) {
onProgress({ onProgress({
current: i, current: i,
total: totalTracks, 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)); await new Promise(resolve => setTimeout(resolve, 300));
try { try {
const searchQuery = `${trackTitle} ${artistNames}`; let foundTrack = null;
const searchResults = await api.searchTracks(searchQuery);
// 1. Initial Search: Title + All Artists
let searchQuery = `${trackTitle} ${artistNames}`;
let searchResults = await api.searchTracks(searchQuery);
if (searchResults.items && searchResults.items.length > 0) { if (searchResults.items && searchResults.items.length > 0) {
// Use the first result foundTrack = searchResults.items[0];
const 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); tracks.push(foundTrack);
console.log(`Found track: "${trackTitle}" by ${artistNames}`); console.log(`Found track: "${trackTitle}" by ${artistNames}`);
} else { } else {
@ -1113,79 +1137,35 @@ async function parseCSV(csvText, api, onProgress) {
} }
function showKeyboardShortcuts() { 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) => { const modal = document.getElementById('shortcuts-modal');
if (e.target === modal || e.target.classList.contains('close-shortcuts')) {
modal.remove();
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 updateVolumeUI = () => {
const { volume, muted } = audioPlayer; const { muted } = audioPlayer;
const volume = player.userVolume;
volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME; volumeBtn.innerHTML = (muted || volume === 0) ? SVG_MUTE : SVG_VOLUME;
const effectiveVolume = muted ? 0 : volume * 100; const effectiveVolume = muted ? 0 : volume * 100;
volumeFill.style.setProperty('--volume-level', `${effectiveVolume}%`); 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 savedVolume = parseFloat(localStorage.getItem('volume') || '0.7');
const savedMuted = localStorage.getItem('muted') === 'true'; const savedMuted = localStorage.getItem('muted') === 'true';
audioPlayer.volume = savedVolume; player.setVolume(savedVolume);
audioPlayer.muted = savedMuted; audioPlayer.muted = savedMuted;
volumeFill.style.width = `${savedVolume * 100}%`; volumeFill.style.width = `${savedVolume * 100}%`;
@ -337,10 +338,9 @@ function initializeSmoothSliders(audioPlayer, player) {
if (isAdjustingVolume) { if (isAdjustingVolume) {
seek(volumeBar, e, position => { seek(volumeBar, e, position => {
audioPlayer.volume = position; player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${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 touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect(); const rect = volumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); 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}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${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) => { volumeBar.addEventListener('mousedown', (e) => {
isAdjustingVolume = true; isAdjustingVolume = true;
seek(volumeBar, e, position => { seek(volumeBar, e, position => {
audioPlayer.volume = position; player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${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 touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect(); const rect = volumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width)); 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}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
}); });
volumeBar.addEventListener('click', e => { volumeBar.addEventListener('click', e => {
if (!isAdjustingVolume) { if (!isAdjustingVolume) {
seek(volumeBar, e, position => { seek(volumeBar, e, position => {
audioPlayer.volume = position; player.setVolume(position);
volumeFill.style.width = `${position * 100}%`; volumeFill.style.width = `${position * 100}%`;
volumeBar.style.setProperty('--volume-level', `${position * 100}%`); volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
}); });
} }
}); });
volumeBar.addEventListener('wheel', e => { volumeBar.addEventListener('wheel', e => {
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05; 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) { if (delta > 0 && audioPlayer.muted) {
audioPlayer.muted = false; audioPlayer.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
} }
audioPlayer.volume = newVolume; player.setVolume(newVolume);
volumeFill.style.width = `${newVolume * 100}%`; volumeFill.style.width = `${newVolume * 100}%`;
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`); volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
localStorage.setItem('volume', newVolume);
}, { passive: false }); }, { passive: false });
volumeBtn?.addEventListener('wheel', e => { volumeBtn?.addEventListener('wheel', e => {
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05; 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) { if (delta > 0 && audioPlayer.muted) {
audioPlayer.muted = false; audioPlayer.muted = false;
localStorage.setItem('muted', false); localStorage.setItem('muted', false);
} }
audioPlayer.volume = newVolume; player.setVolume(newVolume);
volumeFill.style.width = `${newVolume * 100}%`; volumeFill.style.width = `${newVolume * 100}%`;
volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`); volumeBar.style.setProperty('--volume-level', `${newVolume * 100}%`);
localStorage.setItem('volume', newVolume);
}, { passive: false }); }, { passive: false });
} }
@ -615,39 +609,43 @@ export async function handleTrackAction(action, item, player, api, lyricsManager
return; return;
} }
const modal = document.createElement('div'); const modal = document.getElementById('playlist-select-modal');
modal.className = 'playlist-select-modal'; const list = document.getElementById('playlist-select-list');
modal.innerHTML = ` const cancelBtn = document.getElementById('playlist-select-cancel');
<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;"> const overlay = modal.querySelector('.modal-overlay');
<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);
modal.addEventListener('click', async (e) => { list.innerHTML = playlists.map(p => `
if (e.target.id === 'cancel-add-playlist') { <div class="modal-option" data-id="${p.id}">${p.name}</div>
modal.remove(); `).join('');
return;
}
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) { if (option) {
const playlistId = option.dataset.id; const playlistId = option.dataset.id;
await db.addTrackToPlaylist(playlistId, item); await db.addTrackToPlaylist(playlistId, item);
const updatedPlaylist = await db.getPlaylist(playlistId); const updatedPlaylist = await db.getPlaylist(playlistId);
syncManager.syncUserPlaylist(updatedPlaylist, 'update'); syncManager.syncUserPlaylist(updatedPlaylist, 'update');
showNotification(`Added to playlist: ${option.textContent}`); 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) { 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'); const closeModal = () => {
modal.className = 'sleep-timer-modal'; modal.classList.remove('active');
modal.innerHTML = ` cleanup();
<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 handleOptionClick = (e) => {
const timerOption = e.target.closest('.timer-option'); const timerOption = e.target.closest('.timer-option');
if (timerOption) { 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) { if (minutes) {
player.setSleepTimer(minutes); player.setSleepTimer(minutes);
showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`); showNotification(`Sleep timer set for ${minutes} minute${minutes === 1 ? '' : 's'}`);
modal.remove(); closeModal();
} }
} }
};
if (e.target.id === 'custom-timer-btn') { const handleCancel = (e) => {
const customInput = document.getElementById('custom-minutes'); if (e.target.id === 'cancel-sleep-timer' || e.target.classList.contains('modal-overlay')) {
const minutes = parseInt(customInput.value); closeModal();
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 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) { function positionMenu(menu, x, y, anchorRect = null) {

View file

@ -1,6 +1,6 @@
//js/player.js //js/player.js
import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle, getTrackArtistsHTML } from './utils.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 { export class Player {
constructor(audioElement, api, quality = 'LOSSLESS') { constructor(audioElement, api, quality = 'LOSSLESS') {
@ -16,6 +16,8 @@ export class Player {
this.preloadCache = new Map(); this.preloadCache = new Map();
this.preloadAbortController = null; this.preloadAbortController = null;
this.currentTrack = null; this.currentTrack = null;
this.currentRgValues = null;
this.userVolume = parseFloat(localStorage.getItem('volume') || '0.7');
// Sleep timer properties // Sleep timer properties
this.sleepTimer = null; 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() { loadQueueState() {
const savedState = queueManager.getQueue(); const savedState = queueManager.getQueue();
if (savedState) { if (savedState) {
@ -212,19 +255,30 @@ export class Player {
this.updatePlayingTrackIndicator(); this.updatePlayingTrackIndicator();
try { 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; let streamUrl;
if (this.preloadCache.has(track.id)) { if (this.preloadCache.has(track.id)) {
streamUrl = this.preloadCache.get(track.id); streamUrl = this.preloadCache.get(track.id);
} else { } else if (trackData.originalTrackUrl) {
const trackData = await this.api.getTrack(track.id, this.quality);
if (trackData.originalTrackUrl) {
streamUrl = trackData.originalTrackUrl; streamUrl = trackData.originalTrackUrl;
} else { } else {
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest); streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
} }
}
this.audio.src = streamUrl; this.audio.src = streamUrl;
if (startTime > 0) { if (startTime > 0) {

View file

@ -8,7 +8,8 @@ import {
trackListSettings, trackListSettings,
cardSettings, cardSettings,
waveformSettings, waveformSettings,
smoothScrollingSettings, replayGainSettings,
smoothScrollingSettings
} from "./storage.js"; } from "./storage.js";
import { db } from "./db.js"; import { db } from "./db.js";
import { authManager } from "./firebase/auth.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 // Now Playing Mode
const nowPlayingMode = document.getElementById("now-playing-mode"); const nowPlayingMode = document.getElementById("now-playing-mode");
if (nowPlayingMode) { 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 = { export const waveformSettings = {
STORAGE_KEY: 'waveform-seekbar-enabled', STORAGE_KEY: 'waveform-seekbar-enabled',

View file

@ -67,29 +67,26 @@ export function getVibrantColorFromImage(imgElement) {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return null; if (!ctx) return null;
canvas.width = imgElement.naturalWidth || imgElement.width;
canvas.height = imgElement.naturalHeight || imgElement.height;
try { try {
ctx.drawImage(imgElement, 0, 0);
// Downscale for performance if image is large
const maxDimension = 64; const maxDimension = 64;
if (canvas.width > maxDimension || canvas.height > maxDimension) { let w = imgElement.naturalWidth || imgElement.width;
const scale = Math.min(maxDimension / canvas.width, maxDimension / canvas.height); let h = imgElement.naturalHeight || imgElement.height;
const w = Math.floor(canvas.width * scale);
const h = Math.floor(canvas.height * scale); if (w > maxDimension || h > maxDimension) {
const smallCanvas = document.createElement('canvas'); const scale = Math.min(maxDimension / w, maxDimension / h);
smallCanvas.width = w; w = Math.floor(w * scale);
smallCanvas.height = h; h = Math.floor(h * scale);
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);
} }
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 pixels = imageData.data;
const candidates = []; const candidates = [];

View file

@ -2408,41 +2408,6 @@ input:checked + .slider::before {
align-items: stretch; 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 { .close-shortcuts {
background: transparent; background: transparent;
border: none; border: none;
@ -2943,7 +2908,91 @@ img:not([src]), img[src=''] {
background: var(--primary); 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 { #playlist-modal {
opacity: 1; 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 { .missing-tracks-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -3672,10 +3694,6 @@ img:not([src]), img[src=''] {
flex-direction: column; flex-direction: column;
} }
.fullscreen-lyrics-toggle {
right: 3.5rem;
}
.csv-import-progress { .csv-import-progress {
bottom: 10px; bottom: 10px;
right: 10px; right: 10px;