fuck your svelte kit
This commit is contained in:
parent
d189d9e0a2
commit
b5cb97149f
13 changed files with 3041 additions and 1504 deletions
125
index.html
125
index.html
|
|
@ -229,25 +229,25 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Last.fm Scrobbling</span>
|
||||
<span class="description" id="lastfm-status">Connect your Last.fm account to scrobble tracks</span>
|
||||
</div>
|
||||
<div id="lastfm-controls">
|
||||
<button id="lastfm-connect-btn" class="btn-secondary">Connect Last.fm</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info">
|
||||
<span class="label">Last.fm Scrobbling</span>
|
||||
<span class="description" id="lastfm-status">Connect your Last.fm account to scrobble tracks</span>
|
||||
</div>
|
||||
<div id="lastfm-controls">
|
||||
<button id="lastfm-connect-btn" class="btn-secondary">Connect Last.fm</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="lastfm-toggle-setting" style="display: none;">
|
||||
<div class="info">
|
||||
<span class="label">Enable Scrobbling</span>
|
||||
<span class="description">Automatically scrobble played tracks</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="lastfm-toggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item" id="lastfm-toggle-setting" style="display: none;">
|
||||
<div class="info">
|
||||
<span class="label">Enable Scrobbling</span>
|
||||
<span class="description">Automatically scrobble played tracks</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="lastfm-toggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Audio Quality</span>
|
||||
|
|
@ -261,21 +261,25 @@
|
|||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Crossfade</span>
|
||||
<span class="description">Fade between tracks smoothly</span>
|
||||
<span class="label">Now Playing View Mode</span>
|
||||
<span class="description">Choose what shows when you click the album art</span>
|
||||
</div>
|
||||
<select id="now-playing-mode">
|
||||
<option value="cover">Album Cover</option>
|
||||
<option value="lyrics">Lyrics Panel</option>
|
||||
<option value="karaoke">Karaoke Mode</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Download Lyrics</span>
|
||||
<span class="description">Include .lrc files when downloading tracks/albums</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="crossfade-toggle">
|
||||
<input type="checkbox" id="download-lyrics-toggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item" id="crossfade-duration-setting" style="display: none;">
|
||||
<div class="info">
|
||||
<span class="label">Crossfade Duration</span>
|
||||
<span class="description">Duration in seconds</span>
|
||||
</div>
|
||||
<input type="number" id="crossfade-duration" min="1" max="12" value="5">
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Gapless Playback</span>
|
||||
|
|
@ -288,13 +292,17 @@
|
|||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Normalize Volume</span>
|
||||
<span class="description">Set the same volume level for all tracks</span>
|
||||
<span class="label">Filename Template</span>
|
||||
<span class="description">Customize download filenames. Available: {trackNumber}, {artist}, {title}, {album}</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<input type="text" id="filename-template" class="template-input" placeholder="{trackNumber} - {artist} - {title}">
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">ZIP Folder Template</span>
|
||||
<span class="description">Customize album folder names. Available: {albumTitle}, {albumArtist}, {year}</span>
|
||||
</div>
|
||||
<input type="text" id="zip-folder-template" class="template-input" placeholder="{albumTitle} - {albumArtist} - monochrome.tf">
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
|
|
@ -304,15 +312,15 @@
|
|||
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
|
||||
</div>
|
||||
<div id="api-instance-manager">
|
||||
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
|
||||
<div class="info">
|
||||
<span class="label">API Instances</span>
|
||||
<span class="description">Manage and prioritize API instances. Automatically sorted by speed.</span>
|
||||
</div>
|
||||
<button id="refresh-speed-test-btn" class="btn-secondary">Refresh Speed Test</button>
|
||||
</div>
|
||||
<ul id="api-instance-list"></ul>
|
||||
</div>
|
||||
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
|
||||
<div class="info">
|
||||
<span class="label">API Instances</span>
|
||||
<span class="description">Manage and prioritize API instances. Automatically sorted by speed.</span>
|
||||
</div>
|
||||
<button id="refresh-speed-test-btn" class="btn-secondary">Refresh Speed Test</button>
|
||||
</div>
|
||||
<ul id="api-instance-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -327,6 +335,7 @@
|
|||
<h4>Features</h4>
|
||||
<ul>
|
||||
<li>High-quality lossless audio streaming</li>
|
||||
<li>Lyrics support with karaoke mode</li>
|
||||
<li>Intelligent API caching for improved performance</li>
|
||||
<li>Offline-capable Progressive Web App (PWA)</li>
|
||||
<li>Media Session API integration for system controls</li>
|
||||
|
|
@ -334,7 +343,8 @@
|
|||
<li>Track downloads with automatic metadata embedding</li>
|
||||
<li>Multiple API instance support with failover</li>
|
||||
<li>Dark, minimalist interface optimized for focus</li>
|
||||
<li>Customizable themes and crossfade support</li>
|
||||
<li>Customizable themes</li>
|
||||
<li>Keyboard shortcuts for power users</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="about-tech">
|
||||
|
|
@ -348,7 +358,7 @@
|
|||
</a>
|
||||
</div>
|
||||
<div class="about-footer">
|
||||
<p class="version">Version 1.1.0</p>
|
||||
<p class="version">Version 1.2.0</p>
|
||||
<p class="disclaimer">This is an independent client and is not affiliated with or endorsed by TIDAL or any music streaming service.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -369,10 +379,12 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shuffle-icon lucide-shuffle"><path d="m18 14 4 4-4 4"/><path d="m18 2 4 4-4 4"/><path d="M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22"/><path d="M2 6h1.972a4 4 0 0 1 3.6 2.2"/><path d="M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45"/></svg>
|
||||
</button>
|
||||
<button id="prev-btn" title="Previous">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-to-line-icon lucide-arrow-left-to-line"><path d="M3 19V5"/><path d="m13 6-6 6 6 6"/><path d="M7 12h14"/></svg> </button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-to-line-icon lucide-arrow-left-to-line"><path d="M3 19V5"/><path d="m13 6-6 6 6 6"/><path d="M7 12h14"/></svg>
|
||||
</button>
|
||||
<button class="play-pause-btn" title="Play"></button>
|
||||
<button id="next-btn" title="Next">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-to-line-icon lucide-arrow-right-to-line"><path d="M17 12H3"/><path d="m11 18 6-6-6-6"/><path d="M21 5v14"/></svg> </button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-to-line-icon lucide-arrow-right-to-line"><path d="M17 12H3"/><path d="m11 18 6-6-6-6"/><path d="M21 5v14"/></svg>
|
||||
</button>
|
||||
<button id="repeat-btn" title="Repeat">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-repeat-icon lucide-repeat"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>
|
||||
</button>
|
||||
|
|
@ -386,11 +398,26 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="volume-controls">
|
||||
<button id="download-current-btn" title="Download current track" class="desktop-only">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="cast-btn" title="Cast" class="desktop-only">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6"></path>
|
||||
<path d="M2 12a9 9 0 0 1 9 9"></path>
|
||||
<path d="M2 17a5 5 0 0 1 5 5"></path>
|
||||
<path d="M2 22h.01"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="queue-btn" title="Queue">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-icon lucide-list"><path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
|
||||
</button>
|
||||
<button id="volume-btn" title="Mute"></button>
|
||||
<div id="volume-bar" class="volume-bar">
|
||||
<button id="volume-btn" title="Mute" class="desktop-only"></button>
|
||||
<div id="volume-bar" class="volume-bar desktop-only">
|
||||
<div id="volume-fill" class="volume-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
407
js/downloads.js
Normal file
407
js/downloads.js
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate } from './utils.js';
|
||||
import { lyricsSettings } from './storage.js';
|
||||
|
||||
const downloadTasks = new Map();
|
||||
let downloadNotificationContainer = null;
|
||||
|
||||
async function loadJSZip() {
|
||||
try {
|
||||
const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm');
|
||||
return module.default;
|
||||
} catch (error) {
|
||||
console.error('Failed to load JSZip:', error);
|
||||
throw new Error('Failed to load ZIP library');
|
||||
}
|
||||
}
|
||||
|
||||
function createDownloadNotification() {
|
||||
if (!downloadNotificationContainer) {
|
||||
downloadNotificationContainer = document.createElement('div');
|
||||
downloadNotificationContainer.id = 'download-notifications';
|
||||
document.body.appendChild(downloadNotificationContainer);
|
||||
}
|
||||
return downloadNotificationContainer;
|
||||
}
|
||||
|
||||
export function addDownloadTask(trackId, track, filename, api) {
|
||||
const container = createDownloadNotification();
|
||||
|
||||
const taskEl = document.createElement('div');
|
||||
taskEl.className = 'download-task';
|
||||
taskEl.dataset.trackId = trackId;
|
||||
const trackTitle = getTrackTitle(track);
|
||||
taskEl.innerHTML = `
|
||||
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
||||
<img src="${api.getCoverUrl(track.album?.cover, '80')}"
|
||||
style="width: 40px; height: 40px; border-radius: 4px; flex-shrink: 0;">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 500; font-size: 0.9rem; margin-bottom: 0.25rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${trackTitle}</div>
|
||||
<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-bottom: 0.5rem;">${track.artist?.name || 'Unknown'}</div>
|
||||
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
|
||||
<div class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
|
||||
</div>
|
||||
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem;">Starting...</div>
|
||||
</div>
|
||||
<button class="download-cancel" style="background: transparent; border: none; color: var(--muted-foreground); cursor: pointer; padding: 4px; border-radius: 4px; transition: all 0.2s;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(taskEl);
|
||||
|
||||
const abortController = new AbortController();
|
||||
downloadTasks.set(trackId, { taskEl, abortController });
|
||||
|
||||
taskEl.querySelector('.download-cancel').addEventListener('click', () => {
|
||||
abortController.abort();
|
||||
removeDownloadTask(trackId);
|
||||
});
|
||||
|
||||
return { taskEl, abortController };
|
||||
}
|
||||
|
||||
export function updateDownloadProgress(trackId, progress) {
|
||||
const task = downloadTasks.get(trackId);
|
||||
if (!task) return;
|
||||
|
||||
const { taskEl } = task;
|
||||
const progressFill = taskEl.querySelector('.download-progress-fill');
|
||||
const statusEl = taskEl.querySelector('.download-status');
|
||||
|
||||
if (progress.stage === 'downloading') {
|
||||
const percent = progress.totalBytes
|
||||
? Math.round((progress.receivedBytes / progress.totalBytes) * 100)
|
||||
: 0;
|
||||
|
||||
progressFill.style.width = `${percent}%`;
|
||||
|
||||
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
||||
const totalMB = progress.totalBytes
|
||||
? (progress.totalBytes / (1024 * 1024)).toFixed(1)
|
||||
: '?';
|
||||
|
||||
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
export function completeDownloadTask(trackId, success = true, message = null) {
|
||||
const task = downloadTasks.get(trackId);
|
||||
if (!task) return;
|
||||
|
||||
const { taskEl } = task;
|
||||
const progressFill = taskEl.querySelector('.download-progress-fill');
|
||||
const statusEl = taskEl.querySelector('.download-status');
|
||||
const cancelBtn = taskEl.querySelector('.download-cancel');
|
||||
|
||||
if (success) {
|
||||
progressFill.style.width = '100%';
|
||||
progressFill.style.background = '#10b981';
|
||||
statusEl.textContent = '✓ Downloaded';
|
||||
statusEl.style.color = '#10b981';
|
||||
cancelBtn.remove();
|
||||
|
||||
setTimeout(() => removeDownloadTask(trackId), 3000);
|
||||
} else {
|
||||
progressFill.style.background = '#ef4444';
|
||||
statusEl.textContent = message || '✗ Download failed';
|
||||
statusEl.style.color = '#ef4444';
|
||||
cancelBtn.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
`;
|
||||
cancelBtn.onclick = () => removeDownloadTask(trackId);
|
||||
|
||||
setTimeout(() => removeDownloadTask(trackId), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function removeDownloadTask(trackId) {
|
||||
const task = downloadTasks.get(trackId);
|
||||
if (!task) return;
|
||||
|
||||
const { taskEl } = task;
|
||||
taskEl.style.animation = 'slideOut 0.3s ease';
|
||||
|
||||
setTimeout(() => {
|
||||
taskEl.remove();
|
||||
downloadTasks.delete(trackId);
|
||||
|
||||
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
|
||||
downloadNotificationContainer.remove();
|
||||
downloadNotificationContainer = null;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function downloadTrackBlob(track, quality, api, lyricsManager = null) {
|
||||
const lookup = await api.getTrack(track.id, quality);
|
||||
let streamUrl;
|
||||
|
||||
if (lookup.originalTrackUrl) {
|
||||
streamUrl = lookup.originalTrackUrl;
|
||||
} else {
|
||||
streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
|
||||
if (!streamUrl) {
|
||||
throw new Error('Could not resolve stream URL');
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(streamUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch track: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
|
||||
const JSZip = await loadJSZip();
|
||||
const zip = new JSZip();
|
||||
|
||||
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
|
||||
const folderName = formatTemplate(template, {
|
||||
albumTitle: album.title,
|
||||
albumArtist: album.artist?.name,
|
||||
year: new Date(album.releaseDate).getFullYear()
|
||||
});
|
||||
|
||||
const notification = createBulkDownloadNotification('album', album.title, tracks.length);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const track = tracks[i];
|
||||
const filename = buildTrackFilename(track, quality);
|
||||
const trackTitle = getTrackTitle(track);
|
||||
|
||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
||||
|
||||
const blob = await downloadTrackBlob(track, quality, api);
|
||||
zip.file(`${folderName}/${filename}`, blob);
|
||||
|
||||
// Add LRC to zip if enabled
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
const lyricsData = await lyricsManager.fetchLyrics(track.id);
|
||||
if (lyricsData) {
|
||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
||||
if (lrcContent) {
|
||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||
zip.file(`${folderName}/${lrcFilename}`, lrcContent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not add lyrics for:', trackTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...');
|
||||
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${folderName}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
completeBulkDownload(notification, true);
|
||||
} catch (error) {
|
||||
completeBulkDownload(notification, false, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadDiscography(artist, api, quality, lyricsManager = null) {
|
||||
const JSZip = await loadJSZip();
|
||||
const zip = new JSZip();
|
||||
|
||||
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
|
||||
const rootFolder = `${sanitizeForFilename(artist.name)} discography - monochrome.tf`;
|
||||
|
||||
const totalAlbums = artist.albums.length;
|
||||
const notification = createBulkDownloadNotification('discography', artist.name, totalAlbums);
|
||||
|
||||
try {
|
||||
for (let albumIndex = 0; albumIndex < artist.albums.length; albumIndex++) {
|
||||
const album = artist.albums[albumIndex];
|
||||
|
||||
updateBulkDownloadProgress(notification, albumIndex, totalAlbums, album.title);
|
||||
|
||||
try {
|
||||
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
|
||||
const albumFolder = formatTemplate(template, {
|
||||
albumTitle: fullAlbum.title,
|
||||
albumArtist: fullAlbum.artist?.name,
|
||||
year: new Date(fullAlbum.releaseDate).getFullYear()
|
||||
});
|
||||
|
||||
for (const track of tracks) {
|
||||
const filename = buildTrackFilename(track, quality);
|
||||
const blob = await downloadTrackBlob(track, quality, api);
|
||||
zip.file(`${rootFolder}/${albumFolder}/${filename}`, blob);
|
||||
|
||||
// Add LRC to zip if enabled
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
const lyricsData = await lyricsManager.fetchLyrics(track.id);
|
||||
if (lyricsData) {
|
||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
||||
if (lrcContent) {
|
||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||
zip.file(`${rootFolder}/${albumFolder}/${lrcFilename}`, lrcContent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not add lyrics for:', track.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to download album ${album.title}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
updateBulkDownloadProgress(notification, totalAlbums, totalAlbums, 'Creating ZIP...');
|
||||
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${rootFolder}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
completeBulkDownload(notification, true);
|
||||
} catch (error) {
|
||||
completeBulkDownload(notification, false, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createBulkDownloadNotification(type, name, totalItems) {
|
||||
const container = createDownloadNotification();
|
||||
|
||||
const notifEl = document.createElement('div');
|
||||
notifEl.className = 'download-task bulk-download';
|
||||
|
||||
notifEl.innerHTML = `
|
||||
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 600; font-size: 0.95rem; margin-bottom: 0.25rem;">
|
||||
Downloading ${type === 'album' ? 'Album' : 'Discography'}
|
||||
</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted-foreground); margin-bottom: 0.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${name}</div>
|
||||
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
|
||||
<div class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
|
||||
</div>
|
||||
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem;">Starting...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(notifEl);
|
||||
return notifEl;
|
||||
}
|
||||
|
||||
function updateBulkDownloadProgress(notifEl, current, total, currentItem) {
|
||||
const progressFill = notifEl.querySelector('.download-progress-fill');
|
||||
const statusEl = notifEl.querySelector('.download-status');
|
||||
|
||||
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
progressFill.style.width = `${percent}%`;
|
||||
statusEl.textContent = `${current}/${total} - ${currentItem}`;
|
||||
}
|
||||
|
||||
function completeBulkDownload(notifEl, success = true, message = null) {
|
||||
const progressFill = notifEl.querySelector('.download-progress-fill');
|
||||
const statusEl = notifEl.querySelector('.download-status');
|
||||
|
||||
if (success) {
|
||||
progressFill.style.width = '100%';
|
||||
progressFill.style.background = '#10b981';
|
||||
statusEl.textContent = '✓ Download complete';
|
||||
statusEl.style.color = '#10b981';
|
||||
|
||||
setTimeout(() => {
|
||||
notifEl.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => notifEl.remove(), 300);
|
||||
}, 3000);
|
||||
} else {
|
||||
progressFill.style.background = '#ef4444';
|
||||
statusEl.textContent = message || '✗ Download failed';
|
||||
statusEl.style.color = '#ef4444';
|
||||
|
||||
setTimeout(() => {
|
||||
notifEl.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => notifEl.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadCurrentTrack(track, quality, api, lyricsManager = null) {
|
||||
if (!track) {
|
||||
alert('No track is currently playing');
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Download LRC if enabled
|
||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||
try {
|
||||
const lyricsData = await lyricsManager.fetchLyrics(track.id);
|
||||
if (lyricsData) {
|
||||
lyricsManager.downloadLRC(lyricsData, track);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not download lyrics for track');
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
388
js/events.js
Normal file
388
js/events.js
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
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 } from './downloads.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');
|
||||
|
||||
audioPlayer.addEventListener('play', () => {
|
||||
if (scrobbler.isAuthenticated() && lastFMStorage.isEnabled() && player.currentTrack) {
|
||||
scrobbler.updateNowPlaying(player.currentTrack);
|
||||
}
|
||||
playPauseBtn.innerHTML = SVG_PAUSE;
|
||||
player.updateMediaSessionPlaybackState();
|
||||
updateTabTitle(player);
|
||||
});
|
||||
|
||||
audioPlayer.addEventListener('pause', () => {
|
||||
playPauseBtn.innerHTML = SVG_PLAY;
|
||||
player.updateMediaSessionPlaybackState();
|
||||
});
|
||||
|
||||
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);
|
||||
player.updateMediaSessionPositionState();
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
let contextTrack = null;
|
||||
|
||||
mainContent.addEventListener('click', e => {
|
||||
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();
|
||||
contextMenu.style.top = `${rect.bottom + 5}px`;
|
||||
contextMenu.style.left = `${rect.left}px`;
|
||||
contextMenu.style.display = 'block';
|
||||
}
|
||||
}
|
||||
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) {
|
||||
contextMenu.style.top = `${e.pageY}px`;
|
||||
contextMenu.style.left = `${e.pageX}px`;
|
||||
contextMenu.style.display = 'block';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
contextMenu.style.display = 'none';
|
||||
});
|
||||
|
||||
contextMenu.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const action = e.target.dataset.action;
|
||||
|
||||
if (action === 'add-to-queue' && contextTrack) {
|
||||
player.addToQueue(contextTrack);
|
||||
renderQueue(player);
|
||||
} else if (action === 'download' && contextTrack) {
|
||||
const quality = player.quality;
|
||||
const filename = buildTrackFilename(contextTrack, quality);
|
||||
|
||||
try {
|
||||
const { taskEl, abortController } = addDownloadTask(
|
||||
contextTrack.id,
|
||||
contextTrack,
|
||||
filename,
|
||||
api
|
||||
);
|
||||
|
||||
await api.downloadTrack(contextTrack.id, quality, filename, {
|
||||
signal: abortController.signal,
|
||||
onProgress: (progress) => {
|
||||
updateDownloadProgress(contextTrack.id, progress);
|
||||
}
|
||||
});
|
||||
|
||||
completeDownloadTask(contextTrack.id, true);
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
|
||||
? error.message
|
||||
: 'Download failed. Please try again.';
|
||||
completeDownloadTask(contextTrack.id, false, errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
213
js/lyrics.js
Normal file
213
js/lyrics.js
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { getTrackTitle, getTrackArtists } from './utils.js';
|
||||
|
||||
export class LyricsManager {
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
this.currentLyrics = null;
|
||||
this.syncedLyrics = [];
|
||||
this.lyricsCache = new Map();
|
||||
}
|
||||
|
||||
async fetchLyrics(trackId) {
|
||||
if (this.lyricsCache.has(trackId)) {
|
||||
return this.lyricsCache.get(trackId);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.api.fetchWithRetry(`/lyrics/?id=${trackId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
const lyricsData = data[0];
|
||||
this.lyricsCache.set(trackId, lyricsData);
|
||||
return lyricsData;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch lyrics:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
parseSyncedLyrics(subtitles) {
|
||||
if (!subtitles) return [];
|
||||
|
||||
const lines = subtitles.split('\n').filter(line => line.trim());
|
||||
return lines.map(line => {
|
||||
const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/);
|
||||
if (match) {
|
||||
const [, minutes, seconds, centiseconds, text] = match;
|
||||
const timeInSeconds = parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
|
||||
return { time: timeInSeconds, text: text.trim() };
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
generateLRCContent(lyricsData, track) {
|
||||
if (!lyricsData || !lyricsData.subtitles) return null;
|
||||
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const trackArtist = getTrackArtists(track);
|
||||
|
||||
let lrc = `[ti:${trackTitle}]\n`;
|
||||
lrc += `[ar:${trackArtist}]\n`;
|
||||
lrc += `[al:${track.album?.title || 'Unknown Album'}]\n`;
|
||||
lrc += `[by:${lyricsData.lyricsProvider || 'Unknown'}]\n`;
|
||||
lrc += '\n';
|
||||
lrc += lyricsData.subtitles;
|
||||
|
||||
return lrc;
|
||||
}
|
||||
|
||||
downloadLRC(lyricsData, track) {
|
||||
const lrcContent = this.generateLRCContent(lyricsData, track);
|
||||
if (!lrcContent) {
|
||||
alert('No synced lyrics available for this track');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([lrcContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${getTrackArtists(track)} - ${getTrackTitle(track)}.lrc`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
getCurrentLine(currentTime) {
|
||||
if (!this.syncedLyrics || this.syncedLyrics.length === 0) return -1;
|
||||
|
||||
let currentIndex = -1;
|
||||
for (let i = 0; i < this.syncedLyrics.length; i++) {
|
||||
if (currentTime >= this.syncedLyrics[i].time) {
|
||||
currentIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLyricsPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'lyrics-panel';
|
||||
panel.className = 'lyrics-panel hidden';
|
||||
panel.innerHTML = `
|
||||
<div class="lyrics-header">
|
||||
<h3>Lyrics</h3>
|
||||
<div class="lyrics-controls">
|
||||
<button id="download-lrc-btn" class="btn-icon" title="Download LRC">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="close-lyrics-btn" class="btn-icon" title="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lyrics-content">
|
||||
<div class="lyrics-loading">Loading lyrics...</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
export function showKaraokeView(track, lyricsData, audioPlayer) {
|
||||
const view = document.createElement('div');
|
||||
view.id = 'karaoke-view';
|
||||
view.className = 'karaoke-view';
|
||||
|
||||
const syncedLyrics = lyricsData.subtitles
|
||||
? parseSyncedLyricsSimple(lyricsData.subtitles)
|
||||
: [];
|
||||
|
||||
view.innerHTML = `
|
||||
<div class="karaoke-header">
|
||||
<button id="close-karaoke-btn" class="btn-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="karaoke-track-info">
|
||||
<div class="karaoke-title">${getTrackTitle(track)}</div>
|
||||
<div class="karaoke-artist">${getTrackArtists(track)}</div>
|
||||
</div>
|
||||
<div class="karaoke-lyrics-container" id="karaoke-lyrics"></div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(view);
|
||||
|
||||
const lyricsContainer = view.querySelector('#karaoke-lyrics');
|
||||
syncedLyrics.forEach((line, index) => {
|
||||
const lineEl = document.createElement('div');
|
||||
lineEl.className = 'karaoke-line';
|
||||
lineEl.textContent = line.text;
|
||||
lineEl.dataset.index = index;
|
||||
lineEl.dataset.time = line.time;
|
||||
lyricsContainer.appendChild(lineEl);
|
||||
});
|
||||
|
||||
let updateInterval = setInterval(() => {
|
||||
const currentTime = audioPlayer.currentTime;
|
||||
const currentIndex = getCurrentLineIndex(syncedLyrics, currentTime);
|
||||
|
||||
document.querySelectorAll('.karaoke-line').forEach((line, index) => {
|
||||
line.classList.toggle('active', index === currentIndex);
|
||||
line.classList.toggle('past', index < currentIndex);
|
||||
});
|
||||
|
||||
if (currentIndex >= 0) {
|
||||
const activeLine = lyricsContainer.children[currentIndex];
|
||||
if (activeLine) {
|
||||
activeLine.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
view.querySelector('#close-karaoke-btn').addEventListener('click', () => {
|
||||
clearInterval(updateInterval);
|
||||
view.remove();
|
||||
});
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function parseSyncedLyricsSimple(subtitles) {
|
||||
const lines = subtitles.split('\n').filter(line => line.trim());
|
||||
return lines.map(line => {
|
||||
const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/);
|
||||
if (match) {
|
||||
const [, minutes, seconds, centiseconds, text] = match;
|
||||
const timeInSeconds = parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
|
||||
return { time: timeInSeconds, text };
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function getCurrentLineIndex(syncedLyrics, currentTime) {
|
||||
let currentIndex = -1;
|
||||
for (let i = 0; i < syncedLyrics.length; i++) {
|
||||
if (currentTime >= syncedLyrics[i].time) {
|
||||
currentIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return currentIndex;
|
||||
}
|
||||
196
js/player.js
196
js/player.js
|
|
@ -1,4 +1,3 @@
|
|||
//player.js
|
||||
import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle} from './utils.js';
|
||||
|
||||
export class Player {
|
||||
|
|
@ -15,23 +14,8 @@ export class Player {
|
|||
this.preloadCache = new Map();
|
||||
this.preloadAbortController = null;
|
||||
this.currentTrack = null;
|
||||
this.crossfadeEnabled = false;
|
||||
this.crossfadeDuration = 5;
|
||||
this.nextAudioElement = null;
|
||||
this.isCrossfading = false;
|
||||
|
||||
this.setupMediaSession();
|
||||
this.setupCrossfade();
|
||||
}
|
||||
|
||||
setupCrossfade() {
|
||||
this.nextAudioElement = document.createElement('audio');
|
||||
this.nextAudioElement.preload = 'auto';
|
||||
}
|
||||
|
||||
setCrossfade(enabled, duration = 5) {
|
||||
this.crossfadeEnabled = enabled;
|
||||
this.crossfadeDuration = Math.max(1, Math.min(12, duration));
|
||||
}
|
||||
|
||||
setupMediaSession() {
|
||||
|
|
@ -97,7 +81,7 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
for (const { track, index } of tracksToPreload) {
|
||||
for (const { track } of tracksToPreload) {
|
||||
if (this.preloadCache.has(track.id)) continue;
|
||||
const trackTitle = getTrackTitle(track);
|
||||
try {
|
||||
|
|
@ -106,11 +90,6 @@ export class Player {
|
|||
if (this.preloadAbortController.signal.aborted) break;
|
||||
|
||||
this.preloadCache.set(track.id, streamUrl);
|
||||
|
||||
if (index === this.currentQueueIndex + 1 && this.crossfadeEnabled) {
|
||||
this.nextAudioElement.src = streamUrl;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.debug('Failed to get stream URL for preload:', trackTitle);
|
||||
|
|
@ -119,134 +98,51 @@ export class Player {
|
|||
}
|
||||
}
|
||||
|
||||
async playTrackFromQueue() {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
this.currentTrack = track;
|
||||
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const trackArtists = getTrackArtists(track);
|
||||
|
||||
document.querySelector('.now-playing-bar .cover').src =
|
||||
this.api.getCoverUrl(track.album?.cover, '1280');
|
||||
document.querySelector('.now-playing-bar .title').textContent = trackTitle;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = trackArtists;
|
||||
document.title = `${trackTitle} • ${track.artist?.name || 'Unknown'}`;
|
||||
|
||||
this.updatePlayingTrackIndicator();
|
||||
this.updateMediaSession(track);
|
||||
|
||||
try {
|
||||
let streamUrl;
|
||||
|
||||
if (this.preloadCache.has(track.id)) {
|
||||
streamUrl = this.preloadCache.get(track.id);
|
||||
} else {
|
||||
const trackData = await this.api.getTrack(track.id, this.quality);
|
||||
|
||||
// Store replayGain for normalization
|
||||
if (trackData.track?.replayGain !== undefined) {
|
||||
window.currentGain = trackData.track.replayGain;
|
||||
} else {
|
||||
window.currentGain = track.replayGain || null;
|
||||
}
|
||||
|
||||
if (trackData.originalTrackUrl) {
|
||||
streamUrl = trackData.originalTrackUrl;
|
||||
} else {
|
||||
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
|
||||
const temp = this.audio;
|
||||
this.audio = this.nextAudioElement;
|
||||
this.nextAudioElement = temp;
|
||||
|
||||
this.nextAudioElement.pause();
|
||||
this.nextAudioElement.currentTime = 0;
|
||||
} else {
|
||||
this.audio.src = streamUrl;
|
||||
}
|
||||
|
||||
// Apply normalization if enabled
|
||||
this.applyNormalization();
|
||||
|
||||
await this.audio.play();
|
||||
this.isCrossfading = false;
|
||||
|
||||
this.updateMediaSessionPlaybackState();
|
||||
this.preloadNextTracks();
|
||||
this.setupCrossfadeListener();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Could not play track: ${trackTitle}`, error);
|
||||
document.querySelector('.now-playing-bar .title').textContent = `Error: ${trackTitle}`;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
|
||||
}
|
||||
}
|
||||
|
||||
setupCrossfadeListener() {
|
||||
if (!this.crossfadeEnabled) return;
|
||||
|
||||
const checkCrossfade = () => {
|
||||
const timeRemaining = this.audio.duration - this.audio.currentTime;
|
||||
|
||||
if (timeRemaining <= this.crossfadeDuration && timeRemaining > 0 && !this.isCrossfading) {
|
||||
this.startCrossfade();
|
||||
}
|
||||
};
|
||||
|
||||
this.audio.removeEventListener('timeupdate', this.crossfadeCheck);
|
||||
this.crossfadeCheck = checkCrossfade;
|
||||
this.audio.addEventListener('timeupdate', this.crossfadeCheck);
|
||||
}
|
||||
|
||||
async startCrossfade() {
|
||||
if (this.repeatMode === REPEAT_MODE.ONE) return;
|
||||
|
||||
async playTrackFromQueue() {
|
||||
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
|
||||
const nextIndex = this.currentQueueIndex + 1;
|
||||
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const track = currentQueue[this.currentQueueIndex];
|
||||
this.currentTrack = track;
|
||||
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const trackArtists = getTrackArtists(track);
|
||||
|
||||
if (nextIndex >= currentQueue.length && this.repeatMode !== REPEAT_MODE.ALL) return;
|
||||
document.querySelector('.now-playing-bar .cover').src =
|
||||
this.api.getCoverUrl(track.album?.cover, '1280');
|
||||
document.querySelector('.now-playing-bar .title').textContent = trackTitle;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = trackArtists;
|
||||
document.title = `${trackTitle} • ${track.artist?.name || 'Unknown'}`;
|
||||
|
||||
this.isCrossfading = true;
|
||||
const targetIndex = nextIndex >= currentQueue.length ? 0 : nextIndex;
|
||||
const nextTrack = currentQueue[targetIndex];
|
||||
|
||||
if (this.nextAudioElement.src && this.preloadCache.has(nextTrack.id)) {
|
||||
try {
|
||||
await this.nextAudioElement.play();
|
||||
this.nextAudioElement.volume = 0;
|
||||
this.updatePlayingTrackIndicator();
|
||||
this.updateMediaSession(track);
|
||||
|
||||
try {
|
||||
let streamUrl;
|
||||
|
||||
if (this.preloadCache.has(track.id)) {
|
||||
streamUrl = this.preloadCache.get(track.id);
|
||||
} else {
|
||||
const trackData = await this.api.getTrack(track.id, this.quality);
|
||||
|
||||
const fadeSteps = 20;
|
||||
const fadeInterval = (this.crossfadeDuration * 1000) / fadeSteps;
|
||||
|
||||
let step = 0;
|
||||
const fadeTimer = setInterval(() => {
|
||||
step++;
|
||||
const progress = step / fadeSteps;
|
||||
|
||||
this.audio.volume = Math.max(0, 1 - progress);
|
||||
this.nextAudioElement.volume = Math.min(1, progress);
|
||||
|
||||
if (step >= fadeSteps) {
|
||||
clearInterval(fadeTimer);
|
||||
this.audio.pause();
|
||||
this.audio.volume = 1;
|
||||
this.currentQueueIndex = targetIndex;
|
||||
this.playTrackFromQueue();
|
||||
}
|
||||
}, fadeInterval);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Crossfade failed:', error);
|
||||
this.isCrossfading = false;
|
||||
if (trackData.originalTrackUrl) {
|
||||
streamUrl = trackData.originalTrackUrl;
|
||||
} else {
|
||||
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
|
||||
}
|
||||
}
|
||||
|
||||
this.audio.src = streamUrl;
|
||||
await this.audio.play();
|
||||
|
||||
this.updateMediaSessionPlaybackState();
|
||||
this.preloadNextTracks();
|
||||
} catch (error) {
|
||||
console.error(`Could not play track: ${trackTitle}`, error);
|
||||
document.querySelector('.now-playing-bar .title').textContent = `Error: ${trackTitle}`;
|
||||
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -435,16 +331,6 @@ async playTrackFromQueue() {
|
|||
this.updateMediaSessionPlaybackState();
|
||||
this.updateMediaSessionPositionState();
|
||||
}
|
||||
applyNormalization() {
|
||||
const normalizeEnabled = localStorage.getItem('normalize-volume') === 'true';
|
||||
|
||||
if (normalizeEnabled && window.currentGain !== null && window.currentGain !== undefined) {
|
||||
const baseVolume = parseFloat(localStorage.getItem('base-volume') || '0.7');
|
||||
const replayGain = parseFloat(window.currentGain);
|
||||
const adjustment = Math.pow(10, replayGain / 20);
|
||||
this.audio.volume = Math.min(1, Math.max(0, baseVolume * adjustment));
|
||||
}
|
||||
}
|
||||
|
||||
updateMediaSessionPlaybackState() {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
|
|
|
|||
40
js/router.js
Normal file
40
js/router.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export function createRouter(ui) {
|
||||
const router = () => {
|
||||
const path = window.location.hash.substring(1) || "home";
|
||||
const [page, param] = path.split('/');
|
||||
|
||||
switch (page) {
|
||||
case 'search':
|
||||
ui.renderSearchPage(decodeURIComponent(param));
|
||||
break;
|
||||
case 'album':
|
||||
ui.renderAlbumPage(param);
|
||||
break;
|
||||
case 'artist':
|
||||
ui.renderArtistPage(param);
|
||||
break;
|
||||
case 'home':
|
||||
ui.renderHomePage();
|
||||
break;
|
||||
default:
|
||||
ui.showPage(page);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export function updateTabTitle(player) {
|
||||
if (player.currentTrack) {
|
||||
const track = player.currentTrack;
|
||||
document.title = `${track.title} • ${track.artist?.name || 'Unknown'} - Monochrome`;
|
||||
} else {
|
||||
const hash = window.location.hash;
|
||||
if (hash.includes('#album/')) {
|
||||
// Will be updated by album render
|
||||
return;
|
||||
}
|
||||
document.title = 'Monochrome Music';
|
||||
}
|
||||
}
|
||||
273
js/settings.js
Normal file
273
js/settings.js
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings } from './storage.js';
|
||||
|
||||
export function initializeSettings(scrobbler, player, api, ui) {
|
||||
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn');
|
||||
const lastfmStatus = document.getElementById('lastfm-status');
|
||||
const lastfmToggle = document.getElementById('lastfm-toggle');
|
||||
const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
|
||||
|
||||
function updateLastFMUI() {
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
|
||||
lastfmConnectBtn.textContent = 'Disconnect';
|
||||
lastfmConnectBtn.classList.add('danger');
|
||||
lastfmToggleSetting.style.display = 'flex';
|
||||
lastfmToggle.checked = lastFMStorage.isEnabled();
|
||||
} else {
|
||||
lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks';
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.classList.remove('danger');
|
||||
lastfmToggleSetting.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateLastFMUI();
|
||||
|
||||
lastfmConnectBtn?.addEventListener('click', async () => {
|
||||
if (scrobbler.isAuthenticated()) {
|
||||
if (confirm('Disconnect from Last.fm?')) {
|
||||
scrobbler.disconnect();
|
||||
updateLastFMUI();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const authWindow = window.open('', '_blank');
|
||||
lastfmConnectBtn.disabled = true;
|
||||
lastfmConnectBtn.textContent = 'Opening Last.fm...';
|
||||
|
||||
try {
|
||||
const { token, url } = await scrobbler.getAuthUrl();
|
||||
|
||||
if (authWindow) {
|
||||
authWindow.location.href = url;
|
||||
} else {
|
||||
alert('Popup blocked! Please allow popups.');
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
lastfmConnectBtn.textContent = 'Waiting for authorization...';
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
|
||||
const checkAuth = setInterval(async () => {
|
||||
attempts++;
|
||||
|
||||
if (attempts > maxAttempts) {
|
||||
clearInterval(checkAuth);
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
alert('Authorization timed out. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await scrobbler.completeAuthentication(token);
|
||||
|
||||
if (result.success) {
|
||||
clearInterval(checkAuth);
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
updateLastFMUI();
|
||||
lastfmConnectBtn.disabled = false;
|
||||
lastFMStorage.setEnabled(true);
|
||||
lastfmToggle.checked = true;
|
||||
alert(`Successfully connected to Last.fm as ${result.username}!`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Still waiting
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Last.fm connection failed:', error);
|
||||
alert('Failed to connect to Last.fm: ' + error.message);
|
||||
lastfmConnectBtn.textContent = 'Connect Last.fm';
|
||||
lastfmConnectBtn.disabled = false;
|
||||
if (authWindow && !authWindow.closed) authWindow.close();
|
||||
}
|
||||
});
|
||||
|
||||
lastfmToggle?.addEventListener('change', (e) => {
|
||||
lastFMStorage.setEnabled(e.target.checked);
|
||||
});
|
||||
|
||||
// Theme picker
|
||||
const themePicker = document.getElementById('theme-picker');
|
||||
const currentTheme = themeManager.getTheme();
|
||||
|
||||
themePicker.querySelectorAll('.theme-option').forEach(option => {
|
||||
if (option.dataset.theme === currentTheme) {
|
||||
option.classList.add('active');
|
||||
}
|
||||
|
||||
option.addEventListener('click', () => {
|
||||
const theme = option.dataset.theme;
|
||||
|
||||
themePicker.querySelectorAll('.theme-option').forEach(opt => opt.classList.remove('active'));
|
||||
option.classList.add('active');
|
||||
|
||||
if (theme === 'custom') {
|
||||
document.getElementById('custom-theme-editor').classList.add('show');
|
||||
renderCustomThemeEditor();
|
||||
} else {
|
||||
document.getElementById('custom-theme-editor').classList.remove('show');
|
||||
themeManager.setTheme(theme);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function renderCustomThemeEditor() {
|
||||
const grid = document.getElementById('theme-color-grid');
|
||||
const customTheme = themeManager.getCustomTheme() || {
|
||||
background: '#000000',
|
||||
foreground: '#fafafa',
|
||||
primary: '#ffffff',
|
||||
secondary: '#27272a',
|
||||
muted: '#27272a',
|
||||
border: '#27272a',
|
||||
highlight: '#ffffff'
|
||||
};
|
||||
|
||||
grid.innerHTML = Object.entries(customTheme).map(([key, value]) => `
|
||||
<div class="theme-color-input">
|
||||
<label>${key}</label>
|
||||
<input type="color" data-color="${key}" value="${value}">
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('apply-custom-theme')?.addEventListener('click', () => {
|
||||
const colors = {};
|
||||
document.querySelectorAll('#theme-color-grid input[type="color"]').forEach(input => {
|
||||
colors[input.dataset.color] = input.value;
|
||||
});
|
||||
themeManager.setCustomTheme(colors);
|
||||
});
|
||||
|
||||
document.getElementById('reset-custom-theme')?.addEventListener('click', () => {
|
||||
renderCustomThemeEditor();
|
||||
});
|
||||
|
||||
// Quality setting
|
||||
const qualitySetting = document.getElementById('quality-setting');
|
||||
if (qualitySetting) {
|
||||
const savedQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
|
||||
qualitySetting.value = savedQuality;
|
||||
player.setQuality(savedQuality);
|
||||
|
||||
qualitySetting.addEventListener('change', (e) => {
|
||||
const newQuality = e.target.value;
|
||||
player.setQuality(newQuality);
|
||||
localStorage.setItem('playback-quality', newQuality);
|
||||
});
|
||||
}
|
||||
|
||||
// Now Playing Mode
|
||||
const nowPlayingMode = document.getElementById('now-playing-mode');
|
||||
if (nowPlayingMode) {
|
||||
nowPlayingMode.value = nowPlayingSettings.getMode();
|
||||
nowPlayingMode.addEventListener('change', (e) => {
|
||||
nowPlayingSettings.setMode(e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Download Lyrics Toggle
|
||||
const downloadLyricsToggle = document.getElementById('download-lyrics-toggle');
|
||||
if (downloadLyricsToggle) {
|
||||
downloadLyricsToggle.checked = lyricsSettings.shouldDownloadLyrics();
|
||||
downloadLyricsToggle.addEventListener('change', (e) => {
|
||||
lyricsSettings.setDownloadLyrics(e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
// Filename template setting
|
||||
const filenameTemplate = document.getElementById('filename-template');
|
||||
if (filenameTemplate) {
|
||||
filenameTemplate.value = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}';
|
||||
filenameTemplate.addEventListener('change', (e) => {
|
||||
localStorage.setItem('filename-template', e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// ZIP folder template
|
||||
const zipFolderTemplate = document.getElementById('zip-folder-template');
|
||||
if (zipFolderTemplate) {
|
||||
zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
|
||||
zipFolderTemplate.addEventListener('change', (e) => {
|
||||
localStorage.setItem('zip-folder-template', e.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
// API settings
|
||||
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('refresh-speed-test-btn');
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Testing...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await api.settings.refreshSpeedTests();
|
||||
ui.renderApiSettings();
|
||||
btn.textContent = 'Done!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh speed tests:', error);
|
||||
btn.textContent = 'Error';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('api-instance-list')?.addEventListener('click', async (e) => {
|
||||
const button = e.target.closest('button');
|
||||
if (!button) return;
|
||||
|
||||
const li = button.closest('li');
|
||||
const index = parseInt(li.dataset.index, 10);
|
||||
const instances = await api.settings.getInstances();
|
||||
|
||||
if (button.classList.contains('move-up') && index > 0) {
|
||||
[instances[index], instances[index - 1]] = [instances[index - 1], instances[index]];
|
||||
} else if (button.classList.contains('move-down') && index < instances.length - 1) {
|
||||
[instances[index], instances[index + 1]] = [instances[index + 1], instances[index]];
|
||||
}
|
||||
|
||||
api.settings.saveInstances(instances);
|
||||
ui.renderApiSettings();
|
||||
});
|
||||
|
||||
document.getElementById('clear-cache-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('clear-cache-btn');
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Clearing...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await api.clearCache();
|
||||
btn.textContent = 'Cleared!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
if (window.location.hash.includes('settings')) {
|
||||
ui.renderApiSettings();
|
||||
}
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error);
|
||||
btn.textContent = 'Error';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
//storage.js
|
||||
export const apiSettings = {
|
||||
STORAGE_KEY: 'monochrome-api-instances',
|
||||
INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json',
|
||||
|
|
@ -293,4 +292,36 @@ export const lastFMStorage = {
|
|||
setEnabled(enabled) {
|
||||
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
};
|
||||
|
||||
export const nowPlayingSettings = {
|
||||
STORAGE_KEY: 'now-playing-mode',
|
||||
|
||||
getMode() {
|
||||
try {
|
||||
return localStorage.getItem(this.STORAGE_KEY) || 'cover';
|
||||
} catch (e) {
|
||||
return 'cover';
|
||||
}
|
||||
},
|
||||
|
||||
setMode(mode) {
|
||||
localStorage.setItem(this.STORAGE_KEY, mode);
|
||||
}
|
||||
};
|
||||
|
||||
export const lyricsSettings = {
|
||||
DOWNLOAD_WITH_TRACKS: 'lyrics-download-with-tracks',
|
||||
|
||||
shouldDownloadLyrics() {
|
||||
try {
|
||||
return localStorage.getItem(this.DOWNLOAD_WITH_TRACKS) === 'true';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setDownloadLyrics(enabled) {
|
||||
localStorage.setItem(this.DOWNLOAD_WITH_TRACKS, enabled ? 'true' : 'false');
|
||||
}
|
||||
};
|
||||
210
js/ui-interactions.js
Normal file
210
js/ui-interactions.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { formatTime, trackDataStore, getTrackTitle, getTrackArtists } from './utils.js';
|
||||
|
||||
export function initializeUIInteractions(player, api) {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
const hamburgerBtn = document.getElementById('hamburger-btn');
|
||||
const queueBtn = document.getElementById('queue-btn');
|
||||
const queueModalOverlay = document.getElementById('queue-modal-overlay');
|
||||
const closeQueueBtn = document.getElementById('close-queue-btn');
|
||||
const queueList = document.getElementById('queue-list');
|
||||
|
||||
let draggedQueueIndex = null;
|
||||
|
||||
// Sidebar mobile
|
||||
hamburgerBtn.addEventListener('click', () => {
|
||||
sidebar.classList.add('is-open');
|
||||
sidebarOverlay.classList.add('is-visible');
|
||||
});
|
||||
|
||||
const closeSidebar = () => {
|
||||
sidebar.classList.remove('is-open');
|
||||
sidebarOverlay.classList.remove('is-visible');
|
||||
};
|
||||
|
||||
sidebarOverlay.addEventListener('click', closeSidebar);
|
||||
|
||||
sidebar.addEventListener('click', e => {
|
||||
if (e.target.closest('a')) {
|
||||
closeSidebar();
|
||||
}
|
||||
});
|
||||
|
||||
// Queue modal
|
||||
queueBtn.addEventListener('click', () => {
|
||||
renderQueue();
|
||||
queueModalOverlay.style.display = 'flex';
|
||||
});
|
||||
|
||||
closeQueueBtn.addEventListener('click', () => {
|
||||
queueModalOverlay.style.display = 'none';
|
||||
});
|
||||
|
||||
queueModalOverlay.addEventListener('click', e => {
|
||||
if (e.target === queueModalOverlay) {
|
||||
queueModalOverlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function renderQueue() {
|
||||
const currentQueue = player.getCurrentQueue();
|
||||
|
||||
if (currentQueue.length === 0) {
|
||||
queueList.innerHTML = '<div class="placeholder-text">Queue is empty.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = currentQueue.map((track, index) => {
|
||||
const isPlaying = index === player.currentQueueIndex;
|
||||
const trackTitle = getTrackTitle(track);
|
||||
const trackArtists = getTrackArtists(track, { fallback: "Unknown" });
|
||||
|
||||
return `
|
||||
<div class="queue-track-item ${isPlaying ? 'playing' : ''}" data-queue-index="${index}" data-track-id="${track.id}" draggable="true">
|
||||
<div class="drag-handle">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="8" x2="19" y2="8"></line>
|
||||
<line x1="5" y1="16" x2="19" y2="16"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="track-item-info">
|
||||
<img src="${api.getCoverUrl(track.album?.cover, '80')}"
|
||||
class="track-item-cover" loading="lazy">
|
||||
<div class="track-item-details">
|
||||
<div class="title">${trackTitle}</div>
|
||||
<div class="artist">${trackArtists}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
||||
<button class="track-menu-btn" data-track-index="${index}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
queueList.innerHTML = html;
|
||||
|
||||
queueList.querySelectorAll('.queue-track-item').forEach((item) => {
|
||||
const index = parseInt(item.dataset.queueIndex);
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.track-menu-btn')) return;
|
||||
player.playAtIndex(index);
|
||||
renderQueue();
|
||||
});
|
||||
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
draggedQueueIndex = index;
|
||||
item.style.opacity = '0.5';
|
||||
});
|
||||
|
||||
item.addEventListener('dragend', () => {
|
||||
item.style.opacity = '1';
|
||||
});
|
||||
|
||||
item.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
item.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (draggedQueueIndex !== null && draggedQueueIndex !== index) {
|
||||
player.moveInQueue(draggedQueueIndex, index);
|
||||
renderQueue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
queueList.querySelectorAll('.track-menu-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const index = parseInt(btn.dataset.trackIndex);
|
||||
showQueueTrackMenu(e, index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showQueueTrackMenu(e, trackIndex) {
|
||||
const menu = document.getElementById('queue-track-menu');
|
||||
menu.style.top = `${e.pageY}px`;
|
||||
menu.style.left = `${e.pageX}px`;
|
||||
menu.classList.add('show');
|
||||
menu.dataset.trackIndex = trackIndex;
|
||||
positionContextMenu(menu, e.pageX, e.pageY, true);
|
||||
document.addEventListener('click', hideQueueTrackMenu);
|
||||
}
|
||||
|
||||
function hideQueueTrackMenu() {
|
||||
const menu = document.getElementById('queue-track-menu');
|
||||
menu.classList.remove('show');
|
||||
document.removeEventListener('click', hideQueueTrackMenu);
|
||||
}
|
||||
|
||||
document.getElementById('queue-track-menu').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const action = e.target.dataset.action;
|
||||
const menu = document.getElementById('queue-track-menu');
|
||||
const trackIndex = parseInt(menu.dataset.trackIndex);
|
||||
|
||||
if (action === 'remove') {
|
||||
player.removeFromQueue(trackIndex);
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
hideQueueTrackMenu();
|
||||
});
|
||||
|
||||
function positionContextMenu(menu, x, y, preferLeft = false) {
|
||||
menu.style.display = 'block';
|
||||
menu.style.visibility = 'hidden';
|
||||
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let finalX = x;
|
||||
let finalY = y;
|
||||
|
||||
if (preferLeft || (x + menuRect.width > viewportWidth)) {
|
||||
finalX = x - menuRect.width;
|
||||
if (finalX < 0) {
|
||||
finalX = Math.min(x, viewportWidth - menuRect.width - 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (finalX < 10) finalX = 10;
|
||||
if (finalX + menuRect.width > viewportWidth - 10) {
|
||||
finalX = viewportWidth - menuRect.width - 10;
|
||||
}
|
||||
if (y + menuRect.height > viewportHeight) {
|
||||
finalY = Math.max(10, y - menuRect.height);
|
||||
}
|
||||
if (finalY + menuRect.height > viewportHeight - 10) {
|
||||
finalY = viewportHeight - menuRect.height - 10;
|
||||
}
|
||||
if (finalY < 10) finalY = 10;
|
||||
|
||||
menu.style.left = `${finalX}px`;
|
||||
menu.style.top = `${finalY}px`;
|
||||
menu.style.visibility = 'visible';
|
||||
}
|
||||
|
||||
// Make renderQueue available globally for other modules
|
||||
window.renderQueueFunction = renderQueue;
|
||||
|
||||
// Search tabs
|
||||
document.querySelectorAll('.search-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.search-tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
tab.classList.add('active');
|
||||
document.getElementById(`search-tab-${tab.dataset.tab}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
240
js/ui.js
240
js/ui.js
|
|
@ -1,5 +1,4 @@
|
|||
//ui.js
|
||||
import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle } from './utils.js';
|
||||
import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js';
|
||||
import { recentActivityManager } from './storage.js';
|
||||
|
||||
export class UIRenderer {
|
||||
|
|
@ -12,47 +11,48 @@ export class UIRenderer {
|
|||
}
|
||||
|
||||
createTrackMenuButton() {
|
||||
return `
|
||||
<button class="track-menu-btn" onclick="event.stopPropagation();" title="More options">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
createTrackItemHTML(track, index, showCover = false) {
|
||||
const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
|
||||
const trackNumberHTML = `<div class="track-number">${showCover ? playIconSmall : index + 1}</div>`;
|
||||
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||
const trackArtists = getTrackArtists(track);
|
||||
const trackTitle = getTrackTitle(track);
|
||||
|
||||
return `
|
||||
<div class="track-item" data-track-id="${track.id}">
|
||||
${trackNumberHTML}
|
||||
<div class="track-item-info">
|
||||
${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''}
|
||||
<div class="track-item-details">
|
||||
<div class="title">
|
||||
${trackTitle}
|
||||
${explicitBadge}
|
||||
</div>
|
||||
<div class="artist">${trackArtists}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
||||
<button class="track-menu-btn" type="button" title="More options">
|
||||
return `
|
||||
<button class="track-menu-btn" onclick="event.stopPropagation();" title="More options">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
createTrackItemHTML(track, index, showCover = false) {
|
||||
const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
|
||||
const trackNumberHTML = `<div class="track-number">${showCover ? playIconSmall : index + 1}</div>`;
|
||||
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
|
||||
const trackArtists = getTrackArtists(track);
|
||||
const trackTitle = getTrackTitle(track);
|
||||
|
||||
return `
|
||||
<div class="track-item" data-track-id="${track.id}">
|
||||
${trackNumberHTML}
|
||||
<div class="track-item-info">
|
||||
${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''}
|
||||
<div class="track-item-details">
|
||||
<div class="title">
|
||||
${trackTitle}
|
||||
${explicitBadge}
|
||||
</div>
|
||||
<div class="artist">${trackArtists}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-item-duration">${formatTime(track.duration)}</div>
|
||||
<button class="track-menu-btn" type="button" title="More options">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
createAlbumCardHTML(album) {
|
||||
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
|
||||
|
|
@ -150,71 +150,21 @@ export class UIRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
async renderHomePage() {
|
||||
this.showPage('home');
|
||||
const recents = recentActivityManager.getRecents();
|
||||
|
||||
const albumsContainer = document.getElementById('home-recent-albums');
|
||||
const artistsContainer = document.getElementById('home-recent-artists');
|
||||
|
||||
if (recents.albums.length > 0 || recents.artists.length > 0) {
|
||||
async renderHomePage() {
|
||||
this.showPage('home');
|
||||
const recents = recentActivityManager.getRecents();
|
||||
|
||||
const albumsContainer = document.getElementById('home-recent-albums');
|
||||
const artistsContainer = document.getElementById('home-recent-artists');
|
||||
|
||||
albumsContainer.innerHTML = recents.albums.length
|
||||
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
|
||||
: createPlaceholder("You haven't viewed any albums yet.");
|
||||
: createPlaceholder("You haven't viewed any albums yet. Search for music to get started!");
|
||||
|
||||
artistsContainer.innerHTML = recents.artists.length
|
||||
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
|
||||
: createPlaceholder("You haven't viewed any artists yet.");
|
||||
} else {
|
||||
// Load from API
|
||||
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||
|
||||
const homeData = await window.loadHomeFeed(this.api, this);
|
||||
|
||||
if (homeData && homeData.rows) {
|
||||
let albums = [];
|
||||
let playlists = [];
|
||||
|
||||
homeData.rows.forEach(row => {
|
||||
row.modules?.forEach(module => {
|
||||
if (module.type === 'ALBUM_LIST' && module.pagedList?.items) {
|
||||
albums.push(...module.pagedList.items);
|
||||
} else if (module.type === 'PLAYLIST_LIST' && module.pagedList?.items) {
|
||||
playlists.push(...module.pagedList.items);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (albums.length > 0) {
|
||||
albumsContainer.innerHTML = albums.slice(0, 10).map(album =>
|
||||
this.createAlbumCardHTML(album)
|
||||
).join('');
|
||||
} else {
|
||||
albumsContainer.innerHTML = createPlaceholder("No albums available.");
|
||||
}
|
||||
|
||||
if (playlists.length > 0) {
|
||||
document.querySelector('#home-recent-artists').parentElement.querySelector('.section-title').textContent = 'Featured Playlists';
|
||||
artistsContainer.innerHTML = playlists.slice(0, 10).map(playlist => `
|
||||
<a href="#playlist/${playlist.uuid}" class="card">
|
||||
<div class="card-image-wrapper">
|
||||
<img src="${this.api.getCoverUrl(playlist.image || playlist.squareImage, '320')}"
|
||||
alt="${playlist.title}" class="card-image" loading="lazy">
|
||||
</div>
|
||||
<h3 class="card-title">${playlist.title}</h3>
|
||||
<p class="card-subtitle">${playlist.numberOfTracks} tracks</p>
|
||||
</a>
|
||||
`).join('');
|
||||
} else {
|
||||
artistsContainer.innerHTML = createPlaceholder("No playlists available.");
|
||||
}
|
||||
} else {
|
||||
albumsContainer.innerHTML = createPlaceholder("Unable to load content.");
|
||||
artistsContainer.innerHTML = createPlaceholder("Unable to load content.");
|
||||
}
|
||||
: createPlaceholder("You haven't viewed any artists yet. Search for music to get started!");
|
||||
}
|
||||
}
|
||||
|
||||
async renderSearchPage(query) {
|
||||
this.showPage('search');
|
||||
|
|
@ -319,8 +269,18 @@ async renderHomePage() {
|
|||
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
|
||||
titleEl.innerHTML = `${album.title} ${explicitBadge}`;
|
||||
|
||||
// Calculate total duration
|
||||
const totalDuration = calculateTotalDuration(tracks);
|
||||
const releaseDate = new Date(album.releaseDate);
|
||||
const year = releaseDate.getFullYear();
|
||||
|
||||
// Desktop: full date, Mobile: year only
|
||||
const dateDisplay = window.innerWidth > 768
|
||||
? releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
: year;
|
||||
|
||||
metaEl.innerHTML =
|
||||
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${new Date(album.releaseDate).getFullYear()}`;
|
||||
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${dateDisplay} • ${tracks.length} tracks • ${formatDuration(totalDuration)}`;
|
||||
|
||||
tracklistContainer.innerHTML = `
|
||||
<div class="track-list-header">
|
||||
|
|
@ -334,6 +294,9 @@ async renderHomePage() {
|
|||
this.renderListWithTracks(tracklistContainer, tracks, false);
|
||||
|
||||
recentActivityManager.addAlbum(album);
|
||||
|
||||
// Update tab title when no song is playing
|
||||
document.title = `${album.title} - ${album.artist.name} - Monochrome`;
|
||||
} catch (error) {
|
||||
console.error("Failed to load album:", error);
|
||||
tracklistContainer.innerHTML = createPlaceholder(`Could not load album details. ${error.message}`);
|
||||
|
|
@ -370,6 +333,9 @@ async renderHomePage() {
|
|||
).join('');
|
||||
|
||||
recentActivityManager.addArtist(artist);
|
||||
|
||||
// Update tab title
|
||||
document.title = `${artist.name} - Monochrome`;
|
||||
} catch (error) {
|
||||
console.error("Failed to load artist:", error);
|
||||
tracksContainer.innerHTML = albumsContainer.innerHTML =
|
||||
|
|
@ -378,46 +344,46 @@ async renderHomePage() {
|
|||
}
|
||||
|
||||
renderApiSettings() {
|
||||
const container = document.getElementById('api-instance-list');
|
||||
this.api.settings.getInstances().then(instances => {
|
||||
const cachedData = this.api.settings.getCachedSpeedTests();
|
||||
const speeds = cachedData?.speeds || {};
|
||||
|
||||
container.innerHTML = instances.map((url, index) => {
|
||||
const speedInfo = speeds[url];
|
||||
const speedText = speedInfo
|
||||
? (speedInfo.speed === Infinity
|
||||
? `<span style="color: var(--muted-foreground); font-size: 0.8rem;">Failed</span>`
|
||||
: `<span style="color: var(--muted-foreground); font-size: 0.8rem;">${speedInfo.speed.toFixed(0)}ms</span>`)
|
||||
: '';
|
||||
const container = document.getElementById('api-instance-list');
|
||||
this.api.settings.getInstances().then(instances => {
|
||||
const cachedData = this.api.settings.getCachedSpeedTests();
|
||||
const speeds = cachedData?.speeds || {};
|
||||
|
||||
return `
|
||||
<li data-index="${index}">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div class="instance-url">${url}</div>
|
||||
${speedText}
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
container.innerHTML = instances.map((url, index) => {
|
||||
const speedInfo = speeds[url];
|
||||
const speedText = speedInfo
|
||||
? (speedInfo.speed === Infinity
|
||||
? `<span style="color: var(--muted-foreground); font-size: 0.8rem;">Failed</span>`
|
||||
: `<span style="color: var(--muted-foreground); font-size: 0.8rem;">${speedInfo.speed.toFixed(0)}ms</span>`)
|
||||
: '';
|
||||
|
||||
return `
|
||||
<li data-index="${index}">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div class="instance-url">${url}</div>
|
||||
${speedText}
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const stats = this.api.getCacheStats();
|
||||
const cacheInfo = document.getElementById('cache-info');
|
||||
if (cacheInfo) {
|
||||
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
|
||||
}
|
||||
});
|
||||
}
|
||||
const stats = this.api.getCacheStats();
|
||||
const cacheInfo = document.getElementById('cache-info');
|
||||
if (cacheInfo) {
|
||||
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
63
js/utils.js
63
js/utils.js
|
|
@ -1,4 +1,5 @@
|
|||
//utils.js
|
||||
// utils.js
|
||||
|
||||
export const QUALITY = 'LOSSLESS';
|
||||
|
||||
export const REPEAT_MODE = {
|
||||
|
|
@ -62,17 +63,17 @@ export const getExtensionForQuality = (quality) => {
|
|||
};
|
||||
|
||||
export const buildTrackFilename = (track, quality) => {
|
||||
const template = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}';
|
||||
const extension = getExtensionForQuality(quality);
|
||||
const trackNumber = Number(track.trackNumber);
|
||||
const padded = Number.isFinite(trackNumber) && trackNumber > 0
|
||||
? `${trackNumber}`.padStart(2, '0')
|
||||
: '00';
|
||||
|
||||
const artistName = sanitizeForFilename(track.artist?.name);
|
||||
const albumTitle = sanitizeForFilename(track.album?.title);
|
||||
const trackTitle = sanitizeForFilename(getTrackTitle(track));
|
||||
const data = {
|
||||
trackNumber: track.trackNumber,
|
||||
artist: track.artist?.name,
|
||||
title: getTrackTitle(track),
|
||||
album: track.album?.title
|
||||
};
|
||||
|
||||
return `${artistName} - ${albumTitle} - ${padded} ${trackTitle}.${extension}`;
|
||||
return formatTemplate(template, data) + '.' + extension;
|
||||
};
|
||||
|
||||
const sanitizeToken = (value) => {
|
||||
|
|
@ -156,15 +157,45 @@ export const debounce = (func, wait) => {
|
|||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
export const getTrackTitle = (track, { fallback = 'Unknown Title' } = {}) => {
|
||||
if (!track?.title) return fallback;
|
||||
return track?.version ? `${track.title} (${track.version})` : track.title;
|
||||
if (!track?.title) return fallback;
|
||||
return track?.version ? `${track.title} (${track.version})` : track.title;
|
||||
};
|
||||
|
||||
export const getTrackArtists = (track = {}, { fallback = 'Unknown Artist' } = {}) => {
|
||||
if (track?.artists?.length) {
|
||||
return track.artists.map(artist => artist?.name).join(', ');
|
||||
}
|
||||
if (track?.artists?.length) {
|
||||
return track.artists.map(artist => artist?.name).join(', ');
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const formatTemplate = (template, data) => {
|
||||
let result = template;
|
||||
result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00');
|
||||
result = result.replace(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist'));
|
||||
result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title'));
|
||||
result = result.replace(/\{album\}/g, sanitizeForFilename(data.album || 'Unknown Album'));
|
||||
result = result.replace(/\{albumArtist\}/g, sanitizeForFilename(data.albumArtist || 'Unknown Artist'));
|
||||
result = result.replace(/\{albumTitle\}/g, sanitizeForFilename(data.albumTitle || 'Unknown Album'));
|
||||
result = result.replace(/\{year\}/g, data.year || 'Unknown');
|
||||
return result;
|
||||
};
|
||||
|
||||
export const calculateTotalDuration = (tracks) => {
|
||||
if (!Array.isArray(tracks) || tracks.length === 0) return 0;
|
||||
return tracks.reduce((total, track) => total + (track.duration || 0), 0);
|
||||
};
|
||||
|
||||
export const formatDuration = (seconds) => {
|
||||
if (!seconds || isNaN(seconds)) return '0 min';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours} hr ${minutes} min`;
|
||||
}
|
||||
return `${minutes} min`;
|
||||
};
|
||||
|
|
|
|||
824
styles.css
824
styles.css
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue