This commit is contained in:
eduardprigoana 2025-11-17 21:43:33 +02:00
parent 34c6c45439
commit ae7fae9b3d
15 changed files with 666 additions and 653 deletions

View file

@ -1,3 +1,4 @@
//js/api.js
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
import { APICache } from './cache.js';
@ -11,7 +12,7 @@ export class LosslessAPI {
ttl: 1000 * 60 * 30
});
this.streamCache = new Map();
setInterval(() => {
this.cache.clearExpired();
this.pruneStreamCache();
@ -36,8 +37,8 @@ export class LosslessAPI {
let lastError = null;
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/')
? `${baseUrl}${relativePath.substring(1)}`
const url = baseUrl.endsWith('/')
? `${baseUrl}${relativePath.substring(1)}`
: `${baseUrl}${relativePath}`;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
@ -79,9 +80,9 @@ export class LosslessAPI {
if (error.name === 'AbortError') {
throw error;
}
lastError = error;
if (attempt < maxRetries) {
await delay(200 * attempt);
}
@ -94,7 +95,7 @@ export class LosslessAPI {
findSearchSection(source, key, visited) {
if (!source || typeof source !== 'object') return;
if (Array.isArray(source)) {
for (const e of source) {
const f = this.findSearchSection(e, key, visited);
@ -102,17 +103,17 @@ export class LosslessAPI {
}
return;
}
if (visited.has(source)) return;
visited.add(source);
if ('items' in source && Array.isArray(source.items)) return source;
if (key in source) {
const f = this.findSearchSection(source[key], key, visited);
if (f) return f;
}
for (const v of Object.values(source)) {
const f = this.findSearchSection(v, key, visited);
if (f) return f;
@ -136,7 +137,7 @@ export class LosslessAPI {
prepareTrack(track) {
let normalized = track;
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
normalized = { ...track, artist: track.artists[0] };
}
@ -169,17 +170,17 @@ export class LosslessAPI {
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!track && 'duration' in entry) {
track = entry;
continue;
}
if (!info && 'manifest' in entry) {
info = entry;
continue;
}
if (!originalTrackUrl && 'OriginalTrackUrl' in entry) {
const candidate = entry.OriginalTrackUrl;
if (typeof candidate === 'string') {
@ -198,7 +199,7 @@ export class LosslessAPI {
extractStreamUrlFromManifest(manifest) {
try {
const decoded = atob(manifest);
try {
const parsed = JSON.parse(decoded);
if (parsed?.urls?.[0]) {
@ -286,14 +287,14 @@ export class LosslessAPI {
const entries = Array.isArray(data) ? data : [data];
let album, tracksSection;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!album && 'numberOfTracks' in entry) {
album = this.prepareAlbum(entry);
}
if (!tracksSection && 'items' in entry) {
tracksSection = entry;
}
@ -317,14 +318,14 @@ export class LosslessAPI {
const entries = Array.isArray(data) ? data : [data];
let playlist, tracksSection;
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
if (!playlist && ('uuid' in entry || 'numberOfTracks' in entry)) {
playlist = entry;
}
if (!tracksSection && 'items' in entry) {
tracksSection = entry;
}
@ -347,53 +348,53 @@ export class LosslessAPI {
this.fetchWithRetry(`/artist/?id=${artistId}`),
this.fetchWithRetry(`/artist/?f=${artistId}`)
]);
const primaryData = await primaryResponse.json();
const rawArtist = Array.isArray(primaryData) ? primaryData[0] : primaryData;
if (!rawArtist) throw new Error('Primary artist details not found.');
const artist = {
...this.prepareArtist(rawArtist),
picture: rawArtist.picture || null,
name: rawArtist.name || 'Unknown Artist'
};
const contentData = await contentResponse.json();
const entries = Array.isArray(contentData) ? contentData : [contentData];
const albumMap = new Map();
const trackMap = new Map();
const isTrack = v => v?.id && v.duration && v.album;
const isAlbum = v => v?.id && 'numberOfTracks' in v;
const scan = (value, visited = new Set()) => {
if (!value || typeof value !== 'object' || visited.has(value)) return;
visited.add(value);
if (Array.isArray(value)) {
value.forEach(item => scan(item, visited));
return;
}
const item = value.item || value;
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
Object.values(value).forEach(nested => scan(nested, visited));
};
entries.forEach(entry => scan(entry));
const albums = Array.from(albumMap.values()).sort((a, b) =>
const albums = Array.from(albumMap.values()).sort((a, b) =>
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
);
const tracks = Array.from(trackMap.values())
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
.slice(0, 10);
const result = { ...artist, albums, tracks };
await this.cache.set('artist', artistId, result);
@ -414,13 +415,13 @@ export class LosslessAPI {
async getStreamUrl(id, quality = 'LOSSLESS') {
const cacheKey = `stream_${id}_${quality}`;
if (this.streamCache.has(cacheKey)) {
return this.streamCache.get(cacheKey);
}
const lookup = await this.getTrack(id, quality);
let streamUrl;
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
@ -437,7 +438,7 @@ export class LosslessAPI {
async downloadTrack(id, quality = 'LOSSLESS', filename, options = {}) {
const { onProgress } = options;
try {
const lookup = await this.getTrack(id, quality);
let streamUrl;
@ -451,18 +452,18 @@ export class LosslessAPI {
}
}
const response = await fetch(streamUrl, {
const response = await fetch(streamUrl, {
cache: 'no-store',
signal: options.signal
signal: options.signal
});
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
}
const contentLength = response.headers.get('Content-Length');
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
let receivedBytes = 0;
if (response.body && onProgress) {
@ -472,11 +473,11 @@ export class LosslessAPI {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
receivedBytes += value.byteLength;
onProgress({
stage: 'downloading',
receivedBytes,
@ -525,7 +526,7 @@ export class LosslessAPI {
if (!id) {
return `https://picsum.photos/seed/${Math.random()}/${size}`;
}
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
@ -534,7 +535,7 @@ export class LosslessAPI {
if (!id) {
return `https://picsum.photos/seed/${Math.random()}/${size}`;
}
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
@ -550,4 +551,4 @@ export class LosslessAPI {
streamUrls: this.streamCache.size
};
}
}
}

134
js/app.js
View file

@ -1,3 +1,5 @@
//js/app.js
import { LosslessAPI } from './api.js';
import { apiSettings, themeManager, nowPlayingSettings } from './storage.js';
import { UIRenderer } from './ui.js';
@ -13,7 +15,7 @@ import { debounce, SVG_PLAY } from './utils.js';
function initializeCasting(audioPlayer, castBtn) {
if (!castBtn) return;
if ('remote' in audioPlayer) {
audioPlayer.remote.watchAvailability((available) => {
if (available) {
@ -26,39 +28,39 @@ function initializeCasting(audioPlayer, castBtn) {
castBtn.style.display = 'flex';
}
});
castBtn.addEventListener('click', () => {
audioPlayer.remote.prompt().catch(err => {
console.log('Cast prompt error:', err);
});
});
audioPlayer.addEventListener('playing', () => {
if (audioPlayer.remote && audioPlayer.remote.state === 'connected') {
castBtn.classList.add('connected');
}
});
audioPlayer.addEventListener('pause', () => {
if (audioPlayer.remote && audioPlayer.remote.state === 'disconnected') {
castBtn.classList.remove('connected');
}
});
}
}
else if (audioPlayer.webkitShowPlaybackTargetPicker) {
castBtn.style.display = 'flex';
castBtn.classList.add('available');
castBtn.addEventListener('click', () => {
audioPlayer.webkitShowPlaybackTargetPicker();
});
audioPlayer.addEventListener('webkitplaybacktargetavailabilitychanged', (e) => {
if (e.availability === 'available') {
castBtn.classList.add('available');
}
});
audioPlayer.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', () => {
if (audioPlayer.webkitCurrentPlaybackTargetIsWireless) {
castBtn.classList.add('connected');
@ -78,7 +80,7 @@ function initializeCasting(audioPlayer, castBtn) {
function initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel) {
document.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea')) return;
switch(e.key.toLowerCase()) {
case ' ':
e.preventDefault();
@ -89,7 +91,7 @@ function initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel) {
player.playNext();
} else {
audioPlayer.currentTime = Math.min(
audioPlayer.duration,
audioPlayer.duration,
audioPlayer.currentTime + 10
);
}
@ -146,7 +148,7 @@ function initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel) {
function initializeMediaSessionHandlers(player) {
if (!('mediaSession' in navigator)) return;
try {
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime !== undefined && details.fastSeek !== undefined && details.fastSeek) {
@ -171,7 +173,7 @@ function showOfflineNotification() {
<span>You are offline. Some features may not work.</span>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease forwards';
setTimeout(() => notification.remove(), 300);
@ -189,40 +191,40 @@ function hideOfflineNotification() {
document.addEventListener('DOMContentLoaded', async () => {
const api = new LosslessAPI(apiSettings);
const ui = new UIRenderer(api);
const audioPlayer = document.getElementById('audio-player');
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
const player = new Player(audioPlayer, api, currentQuality);
const scrobbler = new LastFMScrobbler();
const lyricsManager = new LyricsManager(api);
const lyricsPanel = createLyricsPanel();
const currentTheme = themeManager.getTheme();
themeManager.setTheme(currentTheme);
initializeSettings(scrobbler, player, api, ui);
initializePlayerEvents(player, audioPlayer, scrobbler);
initializeTrackInteractions(player, api, document.querySelector('.main-content'), document.getElementById('context-menu'));
initializeUIInteractions(player, api);
initializeKeyboardShortcuts(player, audioPlayer, lyricsPanel);
initializeMediaSessionHandlers(player);
const castBtn = document.getElementById('cast-btn');
initializeCasting(audioPlayer, castBtn);
document.querySelector('.now-playing-bar .cover').addEventListener('click', async () => {
if (!player.currentTrack) {
alert('No track is currently playing');
return;
}
const mode = nowPlayingSettings.getMode();
if (mode === 'karaoke') {
lyricsPanel.classList.add('hidden');
clearLyricsPanelSync(audioPlayer, lyricsPanel);
const lyricsData = await lyricsManager.fetchLyrics(player.currentTrack.id);
if (lyricsData) {
showKaraokeView(player.currentTrack, lyricsData, audioPlayer);
@ -232,13 +234,13 @@ document.addEventListener('DOMContentLoaded', async () => {
} else if (mode === 'lyrics') {
const isHidden = lyricsPanel.classList.contains('hidden');
lyricsPanel.classList.toggle('hidden');
if (isHidden) {
const content = lyricsPanel.querySelector('.lyrics-content');
content.innerHTML = '<div class="lyrics-loading">Loading lyrics...</div>';
const lyricsData = await lyricsManager.fetchLyrics(player.currentTrack.id);
if (lyricsData) {
lyricsManager.currentLyrics = lyricsData;
showSyncedLyricsPanel(lyricsData, audioPlayer, lyricsPanel);
@ -251,42 +253,42 @@ document.addEventListener('DOMContentLoaded', async () => {
}
}
});
document.getElementById('close-lyrics-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
lyricsPanel.classList.add('hidden');
clearLyricsPanelSync(audioPlayer, lyricsPanel);
});
document.getElementById('download-lrc-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
if (lyricsManager.currentLyrics && player.currentTrack) {
lyricsManager.downloadLRC(lyricsManager.currentLyrics, player.currentTrack);
}
});
document.getElementById('download-current-btn')?.addEventListener('click', () => {
downloadCurrentTrack(player.currentTrack, player.quality, api, lyricsManager);
});
// Auto-update lyrics when track changes
let previousTrackId = null;
audioPlayer.addEventListener('play', async () => {
if (!player.currentTrack) return;
const currentTrackId = player.currentTrack.id;
if (currentTrackId === previousTrackId) return;
previousTrackId = currentTrackId;
// Update lyrics panel if it's open
if (!lyricsPanel.classList.contains('hidden')) {
const mode = nowPlayingSettings.getMode();
if (mode === 'lyrics') {
const content = lyricsPanel.querySelector('.lyrics-content');
content.innerHTML = '<div class="lyrics-loading">Loading lyrics...</div>';
const lyricsData = await lyricsManager.fetchLyrics(player.currentTrack.id);
if (lyricsData) {
lyricsManager.currentLyrics = lyricsData;
// Clear old sync before showing new
@ -298,15 +300,15 @@ document.addEventListener('DOMContentLoaded', async () => {
}
}
});
document.addEventListener('click', async (e) => {
if (e.target.closest('#play-album-btn')) {
const btn = e.target.closest('#play-album-btn');
if (btn.disabled) return;
const albumId = window.location.hash.split('/')[1];
if (!albumId) return;
try {
const { tracks } = await api.getAlbum(albumId);
if (tracks.length > 0) {
@ -322,14 +324,14 @@ document.addEventListener('DOMContentLoaded', async () => {
if (e.target.closest('#download-playlist-btn')) {
const btn = e.target.closest('#download-playlist-btn');
if (btn.disabled) return;
const playlistId = window.location.hash.split('/')[1];
if (!playlistId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
try {
const { playlist, tracks } = await api.getPlaylist(playlistId);
await downloadPlaylistAsZip(playlist, tracks, api, player.quality, lyricsManager);
@ -344,10 +346,10 @@ document.addEventListener('DOMContentLoaded', async () => {
if (e.target.closest('#play-playlist-btn')) {
const btn = e.target.closest('#play-playlist-btn');
if (btn.disabled) return;
const playlistId = window.location.hash.split('/')[1];
if (!playlistId) return;
try {
const { tracks } = await api.getPlaylist(playlistId);
if (tracks.length > 0) {
@ -360,18 +362,18 @@ document.addEventListener('DOMContentLoaded', async () => {
alert('Failed to play playlist: ' + error.message);
}
}
if (e.target.closest('#download-album-btn')) {
const btn = e.target.closest('#download-album-btn');
if (btn.disabled) return;
const albumId = window.location.hash.split('/')[1];
if (!albumId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
try {
const { album, tracks } = await api.getAlbum(albumId);
await downloadAlbumAsZip(album, tracks, api, player.quality, lyricsManager);
@ -383,18 +385,18 @@ document.addEventListener('DOMContentLoaded', async () => {
btn.innerHTML = originalHTML;
}
}
if (e.target.closest('#download-discography-btn')) {
const btn = e.target.closest('#download-discography-btn');
if (btn.disabled) return;
const artistId = window.location.hash.split('/')[1];
if (!artistId) return;
btn.disabled = true;
const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="animate-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg><span>Downloading...</span>';
try {
const artist = await api.getArtist(artistId);
await downloadDiscography(artist, api, player.quality, lyricsManager);
@ -407,23 +409,23 @@ document.addEventListener('DOMContentLoaded', async () => {
}
}
});
const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-input');
const performSearch = debounce((query) => {
if (query) {
window.location.hash = `#search/${encodeURIComponent(query)}`;
}
}, 300);
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (query.length > 2) {
performSearch(query);
}
});
searchForm.addEventListener('submit', e => {
e.preventDefault();
const query = searchInput.value.trim();
@ -431,33 +433,33 @@ document.addEventListener('DOMContentLoaded', async () => {
window.location.hash = `#search/${encodeURIComponent(query)}`;
}
});
window.addEventListener('online', () => {
hideOfflineNotification();
console.log('Back online');
});
window.addEventListener('offline', () => {
showOfflineNotification();
console.log('Gone offline');
});
document.querySelector('.play-pause-btn').innerHTML = SVG_PLAY;
const router = createRouter(ui);
router();
window.addEventListener('hashchange', router);
audioPlayer.addEventListener('play', () => {
updateTabTitle(player);
});
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then(reg => {
console.log('Service worker registered');
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker.addEventListener('statechange', () => {
@ -470,14 +472,14 @@ document.addEventListener('DOMContentLoaded', async () => {
.catch(err => console.log('Service worker not registered', err));
});
}
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallPrompt(deferredPrompt);
});
if (!localStorage.getItem('shortcuts-shown')) {
setTimeout(() => {
showKeyboardShortcuts();
@ -501,7 +503,7 @@ function showUpdateNotification() {
function showInstallPrompt(deferredPrompt) {
if (!deferredPrompt) return;
const notification = document.createElement('div');
notification.className = 'install-prompt';
notification.innerHTML = `
@ -515,7 +517,7 @@ function showInstallPrompt(deferredPrompt) {
</div>
`;
document.body.appendChild(notification);
document.getElementById('install-btn').addEventListener('click', async () => {
notification.remove();
deferredPrompt.prompt();
@ -523,7 +525,7 @@ function showInstallPrompt(deferredPrompt) {
console.log(`User response to install prompt: ${outcome}`);
deferredPrompt = null;
});
document.getElementById('dismiss-install').addEventListener('click', () => {
notification.remove();
});
@ -599,10 +601,10 @@ function showKeyboardShortcuts() {
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
if (e.target === modal || e.target.classList.contains('close-shortcuts')) {
modal.remove();
}
});
}
}

View file

@ -1,4 +1,4 @@
//cache.js
//js/cache.js
export class APICache {
constructor(options = {}) {
this.memoryCache = new Map();
@ -24,7 +24,7 @@ export class APICache {
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('responses')) {
const store = db.createObjectStore('responses', { keyPath: 'key' });
store.createIndex('timestamp', 'timestamp', { unique: false });
@ -34,15 +34,15 @@ export class APICache {
}
generateKey(type, params) {
const paramString = typeof params === 'object'
? JSON.stringify(params)
const paramString = typeof params === 'object'
? JSON.stringify(params)
: String(params);
return `${type}:${paramString}`;
}
async get(type, params) {
const key = this.generateKey(type, params);
if (this.memoryCache.has(key)) {
const cached = this.memoryCache.get(key);
if (Date.now() - cached.timestamp < this.ttl) {
@ -177,4 +177,4 @@ export class APICache {
ttl: this.ttl
};
}
}
}

View file

@ -1,3 +1,4 @@
//js/downloads.js
import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate } from './utils.js';
import { lyricsSettings } from './storage.js';
@ -25,14 +26,14 @@ function createDownloadNotification() {
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')}"
<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>
@ -50,40 +51,40 @@ export function addDownloadTask(trackId, track, filename, api) {
</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
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
const totalMB = progress.totalBytes
? (progress.totalBytes / (1024 * 1024)).toFixed(1)
: '?';
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
}
}
@ -91,19 +92,19 @@ export function updateDownloadProgress(trackId, progress) {
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';
@ -116,7 +117,7 @@ export function completeDownloadTask(trackId, success = true, message = null) {
</svg>
`;
cancelBtn.onclick = () => removeDownloadTask(trackId);
setTimeout(() => removeDownloadTask(trackId), 5000);
}
}
@ -124,14 +125,14 @@ export function completeDownloadTask(trackId, success = true, message = null) {
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;
@ -156,7 +157,7 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null) {
if (!response.ok) {
throw new Error(`Failed to fetch track: ${response.status}`);
}
const blob = await response.blob();
return blob;
}
@ -164,27 +165,27 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null) {
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);
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
const lyricsData = await lyricsManager.fetchLyrics(track.id);
@ -200,15 +201,15 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
}
}
}
updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...');
const zipBlob = await zip.generateAsync({
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;
@ -217,7 +218,7 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
completeBulkDownload(notification, true);
} catch (error) {
completeBulkDownload(notification, false, error.message);
@ -228,27 +229,27 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
export async function downloadPlaylistAsZip(playlist, 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: playlist.title,
albumArtist: 'Playlist',
year: new Date().getFullYear()
});
const notification = createBulkDownloadNotification('playlist', playlist.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);
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
const lyricsData = await lyricsManager.fetchLyrics(track.id);
@ -264,15 +265,15 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
}
}
}
updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...');
const zipBlob = await zip.generateAsync({
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;
@ -281,7 +282,7 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
completeBulkDownload(notification, true);
} catch (error) {
completeBulkDownload(notification, false, error.message);
@ -292,19 +293,19 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
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, {
@ -312,12 +313,12 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
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);
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
const lyricsData = await lyricsManager.fetchLyrics(track.id);
@ -337,15 +338,15 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
console.error(`Failed to download album ${album.title}:`, error);
}
}
updateBulkDownloadProgress(notification, totalAlbums, totalAlbums, 'Creating ZIP...');
const zipBlob = await zip.generateAsync({
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;
@ -354,7 +355,7 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
completeBulkDownload(notification, true);
} catch (error) {
completeBulkDownload(notification, false, error.message);
@ -364,12 +365,12 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
function createBulkDownloadNotification(type, name, totalItems) {
const container = createDownloadNotification();
const notifEl = document.createElement('div');
notifEl.className = 'download-task bulk-download';
const typeLabel = type === 'album' ? 'Album' : type === 'playlist' ? 'Playlist' : 'Discography';
notifEl.innerHTML = `
<div style="display: flex; align-items: start; gap: 0.75rem;">
<div style="flex: 1; min-width: 0;">
@ -384,7 +385,7 @@ function createBulkDownloadNotification(type, name, totalItems) {
</div>
</div>
`;
container.appendChild(notifEl);
return notifEl;
}
@ -392,7 +393,7 @@ function createBulkDownloadNotification(type, name, totalItems) {
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}`;
@ -401,13 +402,13 @@ function updateBulkDownloadProgress(notifEl, 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);
@ -416,7 +417,7 @@ function completeBulkDownload(notifEl, success = true, message = null) {
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);
@ -429,9 +430,9 @@ export async function downloadCurrentTrack(track, quality, api, lyricsManager =
alert('No track is currently playing');
return;
}
const filename = buildTrackFilename(track, quality);
try {
const { taskEl, abortController } = addDownloadTask(
track.id,
@ -439,16 +440,16 @@ export async function downloadCurrentTrack(track, quality, api, lyricsManager =
filename,
api
);
await api.downloadTrack(track.id, quality, filename, {
signal: abortController.signal,
onProgress: (progress) => {
updateDownloadProgress(track.id, progress);
}
});
completeDownloadTask(track.id, true);
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
const lyricsData = await lyricsManager.fetchLyrics(track.id);
@ -461,10 +462,10 @@ export async function downloadCurrentTrack(track, quality, api, lyricsManager =
}
} catch (error) {
if (error.name !== 'AbortError') {
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
? error.message
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
? error.message
: 'Download failed. Please try again.';
completeDownloadTask(track.id, false, errorMsg);
}
}
}
}

View file

@ -1,3 +1,4 @@
//js/events.js
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename } from './utils.js';
import { lastFMStorage } from './storage.js';
import { addDownloadTask, updateDownloadProgress, completeDownloadTask } from './downloads.js';
@ -9,7 +10,7 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) {
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);
@ -18,16 +19,16 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) {
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) {
@ -38,63 +39,63 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler) {
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'
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);
}
@ -103,23 +104,23 @@ function initializeSmoothSliders(audioPlayer, player) {
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;
@ -127,14 +128,14 @@ function initializeSmoothSliders(audioPlayer, player) {
}
});
});
// 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));
@ -143,7 +144,7 @@ function initializeSmoothSliders(audioPlayer, player) {
progressFill.style.width = `${position * 100}%`;
}
});
document.addEventListener('mousemove', (e) => {
if (isSeeking) {
seek(progressBar, e, position => {
@ -153,7 +154,7 @@ function initializeSmoothSliders(audioPlayer, player) {
}
});
}
if (isAdjustingVolume) {
seek(volumeBar, e, position => {
audioPlayer.volume = position;
@ -163,7 +164,7 @@ function initializeSmoothSliders(audioPlayer, player) {
});
}
});
document.addEventListener('touchmove', (e) => {
if (isSeeking) {
const touch = e.touches[0];
@ -174,7 +175,7 @@ function initializeSmoothSliders(audioPlayer, player) {
progressFill.style.width = `${position * 100}%`;
}
}
if (isAdjustingVolume) {
const touch = e.touches[0];
const rect = volumeBar.getBoundingClientRect();
@ -185,7 +186,7 @@ function initializeSmoothSliders(audioPlayer, player) {
localStorage.setItem('volume', position);
}
});
document.addEventListener('mouseup', (e) => {
if (isSeeking) {
seek(progressBar, e, position => {
@ -197,12 +198,12 @@ function initializeSmoothSliders(audioPlayer, player) {
});
isSeeking = false;
}
if (isAdjustingVolume) {
isAdjustingVolume = false;
}
});
document.addEventListener('touchend', (e) => {
if (isSeeking) {
if (!isNaN(audioPlayer.duration)) {
@ -211,12 +212,12 @@ function initializeSmoothSliders(audioPlayer, player) {
}
isSeeking = false;
}
if (isAdjustingVolume) {
isAdjustingVolume = false;
}
});
progressBar.addEventListener('click', e => {
if (!isSeeking) {
seek(progressBar, e, position => {
@ -227,7 +228,7 @@ function initializeSmoothSliders(audioPlayer, player) {
});
}
});
volumeBar.addEventListener('mousedown', (e) => {
isAdjustingVolume = true;
seek(volumeBar, e, position => {
@ -237,7 +238,7 @@ function initializeSmoothSliders(audioPlayer, player) {
localStorage.setItem('volume', position);
});
});
volumeBar.addEventListener('touchstart', (e) => {
e.preventDefault();
isAdjustingVolume = true;
@ -249,7 +250,7 @@ function initializeSmoothSliders(audioPlayer, player) {
volumeBar.style.setProperty('--volume-level', `${position * 100}%`);
localStorage.setItem('volume', position);
});
volumeBar.addEventListener('click', e => {
if (!isAdjustingVolume) {
seek(volumeBar, e, position => {
@ -264,7 +265,7 @@ function initializeSmoothSliders(audioPlayer, player) {
export function initializeTrackInteractions(player, api, mainContent, contextMenu) {
let contextTrack = null;
mainContent.addEventListener('click', e => {
const menuBtn = e.target.closest('.track-menu-btn');
if (menuBtn) {
@ -281,30 +282,30 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
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`;
@ -312,22 +313,22 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
}
});
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,
@ -335,28 +336,28 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
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
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;
@ -364,7 +365,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
window.location.hash = `#album/${track.album.id}`;
}
});
document.querySelector('.now-playing-bar .artist').addEventListener('click', () => {
const track = player.currentTrack;
if (track?.artist?.id) {
@ -385,4 +386,4 @@ function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
}

View file

@ -1,4 +1,4 @@
//lastfm.js
//js/lastfm.js
import { delay } from './utils.js';
export class LastFMScrobbler {
@ -6,14 +6,14 @@ export class LastFMScrobbler {
this.API_KEY = '0fc32c426d943d34a662977b31b98b67';
this.API_SECRET = '53acf2466be726db021e7fdfd0ad1084';
this.API_URL = 'https://ws.audioscrobbler.com/2.0/';
this.sessionKey = null;
this.username = null;
this.currentTrack = null;
this.scrobbleTimer = null;
this.scrobbleThreshold = 0;
this.hasScrobbled = false;
this.loadSession();
}
@ -53,15 +53,15 @@ export class LastFMScrobbler {
const filteredParams = { ...params };
delete filteredParams.format;
delete filteredParams.callback;
const sortedKeys = Object.keys(filteredParams).sort();
const signatureString = sortedKeys
.map(key => `${key}${filteredParams[key]}`)
.join('') + this.API_SECRET;
console.log('Signature string:', signatureString);
try {
const { default: md5 } = await import('https://cdn.jsdelivr.net/npm/md5@2.3.0/+esm');
return md5(signatureString);
@ -83,7 +83,7 @@ export class LastFMScrobbler {
}
const signature = await this.generateSignature(requestParams);
const formData = new URLSearchParams({
...requestParams,
api_sig: signature,
@ -116,7 +116,7 @@ export class LastFMScrobbler {
try {
const data = await this.makeRequest('auth.getToken');
const token = data.token;
return {
token,
url: `https://www.last.fm/api/auth/?api_key=${this.API_KEY}&token=${token}`
@ -130,7 +130,7 @@ export class LastFMScrobbler {
async completeAuthentication(token) {
try {
const data = await this.makeRequest('auth.getSession', { token });
if (data.session) {
this.saveSession(data.session.key, data.session.name);
return {
@ -138,7 +138,7 @@ export class LastFMScrobbler {
username: data.session.name
};
}
throw new Error('No session returned');
} catch (error) {
console.error('Authentication failed:', error);
@ -158,19 +158,19 @@ export class LastFMScrobbler {
artist: track.artist?.name || 'Unknown Artist',
track: track.title
};
if (track.album?.title) {
params.album = track.album.title;
}
if (track.duration) {
params.duration = Math.floor(track.duration);
}
if (track.trackNumber) {
params.trackNumber = track.trackNumber;
}
await this.makeRequest('track.updateNowPlaying', params, true);
console.log('Now playing updated:', track.title);
@ -185,7 +185,7 @@ export class LastFMScrobbler {
scheduleScrobble(delay) {
this.clearScrobbleTimer();
this.scrobbleTimer = setTimeout(() => {
this.scrobbleCurrentTrack();
}, delay);
@ -203,25 +203,25 @@ export class LastFMScrobbler {
try {
const timestamp = Math.floor(Date.now() / 1000);
const params = {
artist: this.currentTrack.artist?.name || 'Unknown Artist',
track: this.currentTrack.title,
timestamp: timestamp
};
if (this.currentTrack.album?.title) {
params.album = this.currentTrack.album.title;
}
if (this.currentTrack.duration) {
params.duration = Math.floor(this.currentTrack.duration);
}
if (this.currentTrack.trackNumber) {
params.trackNumber = this.currentTrack.trackNumber;
}
await this.makeRequest('track.scrobble', params, true);
this.hasScrobbled = true;
@ -246,4 +246,4 @@ export class LastFMScrobbler {
this.clearScrobbleTimer();
this.currentTrack = null;
}
}
}

View file

@ -1,3 +1,4 @@
//js/lyrics.js
import { getTrackTitle, getTrackArtists } from './utils.js';
export class LyricsManager {
@ -16,13 +17,13 @@ export class LyricsManager {
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);
@ -32,7 +33,7 @@ export class LyricsManager {
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*(.+)/);
@ -47,17 +48,17 @@ export class LyricsManager {
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;
}
@ -67,7 +68,7 @@ export class LyricsManager {
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');
@ -81,7 +82,7 @@ export class LyricsManager {
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) {
@ -127,11 +128,11 @@ export function createLyricsPanel() {
export function showSyncedLyricsPanel(lyricsData, audioPlayer, panel) {
const content = panel.querySelector('.lyrics-content');
const syncedLyrics = lyricsData.subtitles
const syncedLyrics = lyricsData.subtitles
? parseSyncedLyricsSimple(lyricsData.subtitles)
: null;
if (syncedLyrics && syncedLyrics.length > 0) {
// Render synced lyrics
content.innerHTML = '';
@ -143,19 +144,19 @@ export function showSyncedLyricsPanel(lyricsData, audioPlayer, panel) {
lineEl.dataset.time = line.time;
content.appendChild(lineEl);
});
let currentLineIndex = -1;
const updateLyrics = () => {
const currentTime = audioPlayer.currentTime;
const newIndex = getCurrentLineIndex(syncedLyrics, currentTime);
if (newIndex !== currentLineIndex) {
currentLineIndex = newIndex;
content.querySelectorAll('.synced-line').forEach((line, index) => {
line.classList.remove('active', 'upcoming', 'past');
if (index === currentLineIndex) {
line.classList.add('active');
// Smooth scroll to active line
@ -168,17 +169,17 @@ export function showSyncedLyricsPanel(lyricsData, audioPlayer, panel) {
});
}
};
// Store the update function so we can remove it later
panel.lyricsUpdateHandler = updateLyrics;
audioPlayer.addEventListener('timeupdate', updateLyrics);
// Initial update
updateLyrics();
} else if (lyricsData.lyrics) {
// Fallback to static lyrics
const lines = lyricsData.lyrics.split('\n');
content.innerHTML = lines.map(line =>
content.innerHTML = lines.map(line =>
`<p class="lyrics-line">${line || '&nbsp;'}</p>`
).join('');
} else {
@ -197,11 +198,11 @@ export function showKaraokeView(track, lyricsData, audioPlayer) {
const view = document.createElement('div');
view.id = 'karaoke-view';
view.className = 'karaoke-view';
const syncedLyrics = lyricsData.subtitles
const syncedLyrics = lyricsData.subtitles
? parseSyncedLyricsSimple(lyricsData.subtitles)
: [];
view.innerHTML = `
<div class="karaoke-header">
<button id="close-karaoke-btn" class="btn-icon">
@ -217,9 +218,9 @@ export function showKaraokeView(track, lyricsData, audioPlayer) {
</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');
@ -229,19 +230,19 @@ export function showKaraokeView(track, lyricsData, audioPlayer) {
lineEl.dataset.time = line.time;
lyricsContainer.appendChild(lineEl);
});
let currentLineIndex = -1;
const updateLyrics = () => {
const currentTime = audioPlayer.currentTime;
const newIndex = getCurrentLineIndex(syncedLyrics, currentTime);
if (newIndex !== currentLineIndex) {
currentLineIndex = newIndex;
document.querySelectorAll('.karaoke-line').forEach((line, index) => {
line.classList.remove('active', 'upcoming', 'past');
if (index === currentLineIndex) {
line.classList.add('active');
} else if (index === currentLineIndex + 1) {
@ -250,7 +251,7 @@ export function showKaraokeView(track, lyricsData, audioPlayer) {
line.classList.add('past');
}
});
if (currentLineIndex >= 0) {
const activeLine = lyricsContainer.children[currentLineIndex];
if (activeLine) {
@ -259,18 +260,18 @@ export function showKaraokeView(track, lyricsData, audioPlayer) {
}
}
};
// Use timeupdate event for better sync
audioPlayer.addEventListener('timeupdate', updateLyrics);
// Initial update
updateLyrics();
view.querySelector('#close-karaoke-btn').addEventListener('click', () => {
audioPlayer.removeEventListener('timeupdate', updateLyrics);
view.remove();
});
return view;
}
@ -297,4 +298,4 @@ function getCurrentLineIndex(syncedLyrics, currentTime) {
}
}
return currentIndex;
}
}

View file

@ -1,3 +1,4 @@
//js/player.js
import { REPEAT_MODE, formatTime, getTrackArtists, getTrackTitle} from './utils.js';
export class Player {
@ -69,26 +70,26 @@ export class Player {
if (this.preloadAbortController) {
this.preloadAbortController.abort();
}
this.preloadAbortController = new AbortController();
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
const tracksToPreload = [];
for (let i = 1; i <= 2; i++) {
const nextIndex = this.currentQueueIndex + i;
if (nextIndex < currentQueue.length) {
tracksToPreload.push({ track: currentQueue[nextIndex], index: nextIndex });
}
}
for (const { track } of tracksToPreload) {
if (this.preloadCache.has(track.id)) continue;
const trackTitle = getTrackTitle(track);
try {
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
if (this.preloadAbortController.signal.aborted) break;
this.preloadCache.set(track.id, streamUrl);
} catch (error) {
if (error.name !== 'AbortError') {
@ -107,36 +108,36 @@ export class Player {
const track = currentQueue[this.currentQueueIndex];
this.currentTrack = track;
const trackTitle = getTrackTitle(track);
const trackTitle = getTrackTitle(track);
const trackArtists = getTrackArtists(track);
document.querySelector('.now-playing-bar .cover').src =
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);
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) {
@ -187,7 +188,7 @@ export class Player {
handlePlayPause() {
if (!this.audio.src) return;
if (this.audio.paused) {
this.audio.play().catch(console.error);
} else {
@ -216,7 +217,7 @@ export class Player {
const currentTrack = this.queue[this.currentQueueIndex];
this.shuffledQueue = [...this.queue].sort(() => Math.random() - 0.5);
this.currentQueueIndex = this.shuffledQueue.findIndex(t => t.id === currentTrack?.id);
if (this.currentQueueIndex === -1 && currentTrack) {
this.shuffledQueue.unshift(currentTrack);
this.currentQueueIndex = 0;
@ -226,7 +227,7 @@ export class Player {
this.queue = [...this.originalQueueBeforeShuffle];
this.currentQueueIndex = this.queue.findIndex(t => t.id === currentTrack?.id);
}
this.preloadCache.clear();
this.preloadNextTracks();
}
@ -245,7 +246,7 @@ export class Player {
addToQueue(track) {
this.queue.push(track);
if (!this.currentTrack || this.currentQueueIndex === -1) {
this.currentQueueIndex = this.queue.length - 1;
this.playTrackFromQueue();
@ -254,15 +255,15 @@ export class Player {
removeFromQueue(index) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (index < 0 || index >= currentQueue.length) return;
if (this.shuffleActive) {
this.shuffledQueue.splice(index, 1);
} else {
this.queue.splice(index, 1);
}
if (index < this.currentQueueIndex) {
this.currentQueueIndex--;
} else if (index === this.currentQueueIndex) {
@ -274,13 +275,13 @@ export class Player {
moveInQueue(fromIndex, toIndex) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (fromIndex < 0 || fromIndex >= currentQueue.length) return;
if (toIndex < 0 || toIndex >= currentQueue.length) return;
const [track] = currentQueue.splice(fromIndex, 1);
currentQueue.splice(toIndex, 0, track);
if (this.currentQueueIndex === fromIndex) {
this.currentQueueIndex = toIndex;
} else if (fromIndex < this.currentQueueIndex && toIndex >= this.currentQueueIndex) {
@ -297,7 +298,7 @@ export class Player {
updatePlayingTrackIndicator() {
const currentTrack = this.getCurrentQueue()[this.currentQueueIndex];
document.querySelectorAll('.track-item').forEach(item => {
item.classList.toggle('playing',
item.classList.toggle('playing',
currentTrack && item.dataset.trackId == currentTrack.id
);
});
@ -305,12 +306,12 @@ export class Player {
updateMediaSession(track) {
if (!('mediaSession' in navigator)) return;
const artwork = [];
const sizes = ['1280'];
const coverId = track.album?.cover;
const trackTitle = getTrackTitle(track);
if (coverId) {
sizes.forEach(size => {
artwork.push({
@ -320,7 +321,7 @@ export class Player {
});
});
}
navigator.mediaSession.metadata = new MediaMetadata({
title: trackTitle || 'Unknown Title',
artist: track.artist?.name || 'Unknown Artist',
@ -340,9 +341,9 @@ export class Player {
updateMediaSessionPositionState() {
if (!('mediaSession' in navigator)) return;
if (!('setPositionState' in navigator.mediaSession)) return;
const duration = this.audio.duration;
if (!duration || isNaN(duration) || !isFinite(duration)) {
return;
}
@ -357,4 +358,4 @@ export class Player {
console.debug('Failed to update Media Session position:', error);
}
}
}
}

View file

@ -1,8 +1,9 @@
//router.js
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));
@ -24,7 +25,7 @@ export function createRouter(ui) {
break;
}
};
return router;
}
@ -39,4 +40,4 @@ export function updateTabTitle(player) {
}
document.title = 'Monochrome Music';
}
}
}

View file

@ -1,3 +1,4 @@
//js/settings
import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings } from './storage.js';
export function initializeSettings(scrobbler, player, api, ui) {
@ -5,7 +6,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
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}`;
@ -20,9 +21,9 @@ export function initializeSettings(scrobbler, player, api, ui) {
lastfmToggleSetting.style.display = 'none';
}
}
updateLastFMUI();
lastfmConnectBtn?.addEventListener('click', async () => {
if (scrobbler.isAuthenticated()) {
if (confirm('Disconnect from Last.fm?')) {
@ -31,14 +32,14 @@ export function initializeSettings(scrobbler, player, api, ui) {
}
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 {
@ -47,15 +48,15 @@ export function initializeSettings(scrobbler, player, api, ui) {
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';
@ -64,10 +65,10 @@ export function initializeSettings(scrobbler, player, api, ui) {
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();
@ -81,7 +82,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
// Still waiting
}
}, 2000);
} catch (error) {
console.error('Last.fm connection failed:', error);
alert('Failed to connect to Last.fm: ' + error.message);
@ -90,26 +91,26 @@ export function initializeSettings(scrobbler, player, api, ui) {
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();
@ -119,7 +120,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
}
});
});
function renderCustomThemeEditor() {
const grid = document.getElementById('theme-color-grid');
const customTheme = themeManager.getCustomTheme() || {
@ -131,7 +132,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
border: '#27272a',
highlight: '#ffffff'
};
grid.innerHTML = Object.entries(customTheme).map(([key, value]) => `
<div class="theme-color-input">
<label>${key}</label>
@ -139,7 +140,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
</div>
`).join('');
}
document.getElementById('apply-custom-theme')?.addEventListener('click', () => {
const colors = {};
document.querySelectorAll('#theme-color-grid input[type="color"]').forEach(input => {
@ -147,25 +148,25 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
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) {
@ -174,7 +175,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
nowPlayingSettings.setMode(e.target.value);
});
}
// Download Lyrics Toggle
const downloadLyricsToggle = document.getElementById('download-lyrics-toggle');
if (downloadLyricsToggle) {
@ -183,7 +184,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
lyricsSettings.setDownloadLyrics(e.target.checked);
});
}
// Filename template setting
const filenameTemplate = document.getElementById('filename-template');
if (filenameTemplate) {
@ -192,7 +193,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
localStorage.setItem('filename-template', e.target.value);
});
}
// ZIP folder template
const zipFolderTemplate = document.getElementById('zip-folder-template');
if (zipFolderTemplate) {
@ -201,14 +202,14 @@ export function initializeSettings(scrobbler, player, api, ui) {
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();
@ -226,31 +227,31 @@ export function initializeSettings(scrobbler, player, api, ui) {
}, 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!';
@ -270,4 +271,4 @@ export function initializeSettings(scrobbler, player, api, ui) {
}, 1500);
}
});
}
}

View file

@ -1,3 +1,4 @@
//storage.js
export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances',
INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json',
@ -5,28 +6,28 @@ export const apiSettings = {
SPEED_TEST_CACHE_DURATION: 1000 * 60 * 60,
defaultInstances: [],
instancesLoaded: false,
async loadInstancesFromGitHub() {
if (this.instancesLoaded) {
return this.defaultInstances;
}
try {
const response = await fetch(this.INSTANCES_URL);
if (!response.ok) throw new Error('Failed to fetch instances');
const data = await response.json();
const allInstances = [];
for (const [provider, config] of Object.entries(data.api)) {
if (config.cors === false && Array.isArray(config.urls)) {
allInstances.push(...config.urls);
}
}
this.defaultInstances = allInstances;
this.instancesLoaded = true;
return allInstances;
} catch (error) {
console.error('Failed to load instances from GitHub:', error);
@ -50,61 +51,61 @@ export const apiSettings = {
return this.defaultInstances;
}
},
async speedTestInstance(url) {
const testUrl = url.endsWith('/')
? `${url}track/?id=204567804&quality=HIGH`
const testUrl = url.endsWith('/')
? `${url}track/?id=204567804&quality=HIGH`
: `${url}/track/?id=204567804&quality=HIGH`;
const startTime = performance.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(testUrl, {
signal: controller.signal,
cache: 'no-store'
});
clearTimeout(timeout);
if (!response.ok) {
return { url, speed: Infinity, error: `HTTP ${response.status}` };
}
const endTime = performance.now();
const speed = endTime - startTime;
return { url, speed, error: null };
} catch (error) {
return { url, speed: Infinity, error: error.message };
}
},
async runSpeedTests(instances) {
console.log('[SpeedTest] Testing', instances.length, 'instances...');
const results = await Promise.all(
instances.map(url => this.speedTestInstance(url))
);
const validResults = results.filter(r => r.speed !== Infinity);
const failedResults = results.filter(r => r.speed === Infinity);
if (failedResults.length > 0) {
console.log('[SpeedTest] Failed instances:', failedResults.map(r => `${r.url} (${r.error})`));
}
validResults.sort((a, b) => a.speed - b.speed);
console.log('[SpeedTest] Results:', validResults.map(r => `${r.url}: ${r.speed.toFixed(0)}ms`));
const sortedInstances = [
...validResults.map(r => r.url),
...failedResults.map(r => r.url)
];
const cacheData = {
timestamp: Date.now(),
speeds: results.reduce((acc, r) => {
@ -112,82 +113,82 @@ export const apiSettings = {
return acc;
}, {})
};
try {
localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(cacheData));
} catch (e) {
console.warn('[SpeedTest] Failed to cache results');
}
return sortedInstances;
},
getCachedSpeedTests() {
try {
const cached = localStorage.getItem(this.SPEED_TEST_CACHE_KEY);
if (!cached) return null;
const data = JSON.parse(cached);
if (Date.now() - data.timestamp > this.SPEED_TEST_CACHE_DURATION) {
return null;
}
return data;
} catch (e) {
return null;
}
},
sortInstancesByCache(instances, cachedData) {
const speeds = cachedData.speeds;
const sorted = [...instances].sort((a, b) => {
const speedA = speeds[a]?.speed ?? Infinity;
const speedB = speeds[b]?.speed ?? Infinity;
return speedA - speedB;
});
console.log('[SpeedTest] Using cached results (age:',
console.log('[SpeedTest] Using cached results (age:',
Math.round((Date.now() - cachedData.timestamp) / 1000 / 60), 'minutes)');
return sorted;
},
async getInstances() {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
const instances = await this.loadInstancesFromGitHub();
const cachedSpeedTests = this.getCachedSpeedTests();
let sortedInstances;
if (cachedSpeedTests) {
sortedInstances = this.sortInstancesByCache(instances, cachedSpeedTests);
} else {
sortedInstances = await this.runSpeedTests(instances);
}
this.saveInstances(sortedInstances);
return sortedInstances;
} catch (e) {
const instances = await this.loadInstancesFromGitHub();
return instances;
}
},
async refreshSpeedTests() {
const instances = await this.loadInstancesFromGitHub();
const sortedInstances = await this.runSpeedTests(instances);
this.saveInstances(sortedInstances);
return sortedInstances;
},
saveInstances(instances) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(instances));
}
@ -196,7 +197,7 @@ export const apiSettings = {
export const recentActivityManager = {
STORAGE_KEY: 'monochrome-recent-activity',
LIMIT: 10,
_get() {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
@ -205,15 +206,15 @@ export const recentActivityManager = {
return { artists: [], albums: [] };
}
},
_save(data) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
},
getRecents() {
return this._get();
},
_add(type, item) {
const data = this._get();
data[type] = data[type].filter(i => i.id !== item.id);
@ -221,11 +222,11 @@ export const recentActivityManager = {
data[type] = data[type].slice(0, this.LIMIT);
this._save(data);
},
addArtist(artist) {
this._add('artists', artist);
},
addAlbum(album) {
this._add('albums', album);
}
@ -234,7 +235,7 @@ export const recentActivityManager = {
export const themeManager = {
STORAGE_KEY: 'monochrome-theme',
CUSTOM_THEME_KEY: 'monochrome-custom-theme',
defaultThemes: {
monochrome: {},
dark: {},
@ -242,7 +243,7 @@ export const themeManager = {
purple: {},
forest: {}
},
getTheme() {
try {
return localStorage.getItem(this.STORAGE_KEY) || 'monochrome';
@ -250,12 +251,12 @@ export const themeManager = {
return 'monochrome';
}
},
setTheme(theme) {
localStorage.setItem(this.STORAGE_KEY, theme);
document.documentElement.setAttribute('data-theme', theme);
},
getCustomTheme() {
try {
const stored = localStorage.getItem(this.CUSTOM_THEME_KEY);
@ -264,12 +265,12 @@ export const themeManager = {
return null;
}
},
setCustomTheme(colors) {
localStorage.setItem(this.CUSTOM_THEME_KEY, JSON.stringify(colors));
this.applyCustomTheme(colors);
},
applyCustomTheme(colors) {
const root = document.documentElement;
for (const [key, value] of Object.entries(colors)) {
@ -280,7 +281,7 @@ export const themeManager = {
export const lastFMStorage = {
STORAGE_KEY: 'lastfm-enabled',
isEnabled() {
try {
return localStorage.getItem(this.STORAGE_KEY) === 'true';
@ -288,7 +289,7 @@ export const lastFMStorage = {
return false;
}
},
setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
}
@ -296,7 +297,7 @@ export const lastFMStorage = {
export const nowPlayingSettings = {
STORAGE_KEY: 'now-playing-mode',
getMode() {
try {
return localStorage.getItem(this.STORAGE_KEY) || 'cover';
@ -304,7 +305,7 @@ export const nowPlayingSettings = {
return 'cover';
}
},
setMode(mode) {
localStorage.setItem(this.STORAGE_KEY, mode);
}
@ -312,7 +313,7 @@ export const nowPlayingSettings = {
export const lyricsSettings = {
DOWNLOAD_WITH_TRACKS: 'lyrics-download-with-tracks',
shouldDownloadLyrics() {
try {
return localStorage.getItem(this.DOWNLOAD_WITH_TRACKS) === 'true';
@ -320,8 +321,8 @@ export const lyricsSettings = {
return false;
}
},
setDownloadLyrics(enabled) {
localStorage.setItem(this.DOWNLOAD_WITH_TRACKS, enabled ? 'true' : 'false');
}
};
};

View file

@ -1,3 +1,4 @@
//js/ui-interactions.js
import { formatTime, trackDataStore, getTrackTitle, getTrackArtists } from './utils.js';
export function initializeUIInteractions(player, api) {
@ -8,57 +9,57 @@ export function initializeUIInteractions(player, api) {
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">
@ -68,7 +69,7 @@ export function initializeUIInteractions(player, api) {
</svg>
</div>
<div class="track-item-info">
<img src="${api.getCoverUrl(track.album?.cover, '80')}"
<img src="${api.getCoverUrl(track.album?.cover, '80')}"
class="track-item-cover" loading="lazy">
<div class="track-item-details">
<div class="title">${trackTitle}</div>
@ -86,31 +87,31 @@ export function initializeUIInteractions(player, api) {
</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) {
@ -119,7 +120,7 @@ export function initializeUIInteractions(player, api) {
}
});
});
queueList.querySelectorAll('.track-menu-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
@ -128,7 +129,7 @@ export function initializeUIInteractions(player, api) {
});
});
}
function showQueueTrackMenu(e, trackIndex) {
const menu = document.getElementById('queue-track-menu');
menu.style.top = `${e.pageY}px`;
@ -138,45 +139,45 @@ export function initializeUIInteractions(player, api) {
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;
@ -188,23 +189,23 @@ export function initializeUIInteractions(player, api) {
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');
});
});
}
}

123
js/ui.js
View file

@ -1,3 +1,4 @@
//js/ui.js
import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js';
import { recentActivityManager } from './storage.js';
@ -29,7 +30,7 @@ export class UIRenderer {
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}
@ -116,17 +117,17 @@ export class UIRenderer {
const fragment = document.createDocumentFragment();
const tempDiv = document.createElement('div');
tempDiv.innerHTML = tracks.map((track, i) =>
tempDiv.innerHTML = tracks.map((track, i) =>
this.createTrackItemHTML(track, i, showCover)
).join('');
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
container.innerHTML = '';
container.appendChild(fragment);
tracks.forEach(track => {
const element = container.querySelector(`[data-track-id="${track.id}"]`);
if (element) trackDataStore.set(element, track);
@ -137,13 +138,13 @@ export class UIRenderer {
document.querySelectorAll('.page').forEach(page => {
page.classList.toggle('active', page.id === `page-${pageId}`);
});
document.querySelectorAll('.sidebar-nav a').forEach(link => {
link.classList.toggle('active', link.hash === `#${pageId}`);
});
document.querySelector('.main-content').scrollTop = 0;
if (pageId === 'settings') {
this.renderApiSettings();
}
@ -152,14 +153,14 @@ 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');
albumsContainer.innerHTML = recents.albums.length
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
: 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. Search for music to get started!");
@ -168,26 +169,26 @@ export class UIRenderer {
async renderSearchPage(query) {
this.showPage('search');
document.getElementById('search-results-title').textContent = `Search Results for "${query}"`;
const tracksContainer = document.getElementById('search-tracks-container');
const artistsContainer = document.getElementById('search-artists-container');
const albumsContainer = document.getElementById('search-albums-container');
tracksContainer.innerHTML = this.createSkeletonTracks(8, true);
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
try {
const [tracksResult, artistsResult, albumsResult] = await Promise.all([
this.api.searchTracks(query),
this.api.searchArtists(query),
this.api.searchAlbums(query)
]);
let finalTracks = tracksResult.items;
let finalArtists = artistsResult.items;
let finalAlbums = albumsResult.items;
if (finalArtists.length === 0 && finalTracks.length > 0) {
const artistMap = new Map();
finalTracks.forEach(track => {
@ -204,7 +205,7 @@ export class UIRenderer {
});
finalArtists = Array.from(artistMap.values());
}
if (finalAlbums.length === 0 && finalTracks.length > 0) {
const albumMap = new Map();
finalTracks.forEach(track => {
@ -214,21 +215,21 @@ export class UIRenderer {
});
finalAlbums = Array.from(albumMap.values());
}
if (finalTracks.length) {
this.renderListWithTracks(tracksContainer, finalTracks, true);
} else {
tracksContainer.innerHTML = createPlaceholder('No tracks found.');
}
artistsContainer.innerHTML = finalArtists.length
? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('')
: createPlaceholder('No artists found.');
albumsContainer.innerHTML = finalAlbums.length
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder('No albums found.');
} catch (error) {
console.error("Search failed:", error);
const errorMsg = createPlaceholder(`Error during search. ${error.message}`);
@ -240,12 +241,12 @@ export class UIRenderer {
async renderAlbumPage(albumId) {
this.showPage('album');
const imageEl = document.getElementById('album-detail-image');
const titleEl = document.getElementById('album-detail-title');
const metaEl = document.getElementById('album-detail-meta');
const tracklistContainer = document.getElementById('album-detail-tracklist');
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
titleEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
@ -258,27 +259,27 @@ export class UIRenderer {
</div>
${this.createSkeletonTracks(10, false)}
`;
try {
const { album, tracks } = await this.api.getAlbum(albumId);
imageEl.src = this.api.getCoverUrl(album.cover, '1280');
imageEl.style.backgroundColor = '';
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
titleEl.innerHTML = `${album.title} ${explicitBadge}`;
const totalDuration = calculateTotalDuration(tracks);
const releaseDate = new Date(album.releaseDate);
const year = releaseDate.getFullYear();
const dateDisplay = window.innerWidth > 768
const dateDisplay = window.innerWidth > 768
? releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: year;
metaEl.innerHTML =
metaEl.innerHTML =
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a> • ${dateDisplay}${tracks.length} tracks • ${formatDuration(totalDuration)}`;
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
@ -286,12 +287,12 @@ export class UIRenderer {
<span class="duration-header">Duration</span>
</div>
`;
tracks.sort((a, b) => a.trackNumber - b.trackNumber);
this.renderListWithTracks(tracklistContainer, tracks, false);
recentActivityManager.addAlbum(album);
document.title = `${album.title} - ${album.artist.name} - Monochrome`;
} catch (error) {
console.error("Failed to load album:", error);
@ -301,13 +302,13 @@ export class UIRenderer {
async renderPlaylistPage(playlistId) {
this.showPage('playlist');
const imageEl = document.getElementById('playlist-detail-image');
const titleEl = document.getElementById('playlist-detail-title');
const metaEl = document.getElementById('playlist-detail-meta');
const descEl = document.getElementById('playlist-detail-description');
const tracklistContainer = document.getElementById('playlist-detail-tracklist');
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
titleEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
@ -321,22 +322,22 @@ async renderPlaylistPage(playlistId) {
</div>
${this.createSkeletonTracks(10, true)}
`;
try {
const { playlist, tracks } = await this.api.getPlaylist(playlistId);
const imageId = playlist.squareImage || playlist.image;
imageEl.src = this.api.getCoverUrl(imageId, '1080');
imageEl.style.backgroundColor = '';
titleEl.textContent = playlist.title;
const totalDuration = calculateTotalDuration(tracks);
metaEl.textContent = `${playlist.numberOfTracks} tracks • ${formatDuration(totalDuration)}`;
descEl.textContent = playlist.description || '';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
@ -344,9 +345,9 @@ async renderPlaylistPage(playlistId) {
<span class="duration-header">Duration</span>
</div>
`;
this.renderListWithTracks(tracklistContainer, tracks, true);
document.title = `${playlist.title} - Monochrome`;
} catch (error) {
console.error("Failed to load playlist:", error);
@ -356,39 +357,39 @@ async renderPlaylistPage(playlistId) {
async renderArtistPage(artistId) {
this.showPage('artist');
const imageEl = document.getElementById('artist-detail-image');
const nameEl = document.getElementById('artist-detail-name');
const metaEl = document.getElementById('artist-detail-meta');
const tracksContainer = document.getElementById('artist-detail-tracks');
const albumsContainer = document.getElementById('artist-detail-albums');
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
nameEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 150px;"></div>';
tracksContainer.innerHTML = this.createSkeletonTracks(5, true);
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
try {
const artist = await this.api.getArtist(artistId);
imageEl.src = this.api.getArtistPictureUrl(artist.picture, '750');
imageEl.style.backgroundColor = '';
nameEl.textContent = artist.name;
metaEl.textContent = `${artist.popularity} popularity`;
this.renderListWithTracks(tracksContainer, artist.tracks, true);
albumsContainer.innerHTML = artist.albums.map(album =>
albumsContainer.innerHTML = artist.albums.map(album =>
this.createAlbumCardHTML(album)
).join('');
recentActivityManager.addArtist(artist);
document.title = `${artist.name} - Monochrome`;
} catch (error) {
console.error("Failed to load artist:", error);
tracksContainer.innerHTML = albumsContainer.innerHTML =
tracksContainer.innerHTML = albumsContainer.innerHTML =
createPlaceholder(`Could not load artist details. ${error.message}`);
}
}
@ -398,15 +399,15 @@ async renderPlaylistPage(playlistId) {
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>`
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;">
@ -436,4 +437,4 @@ async renderPlaylistPage(playlistId) {
}
});
}
}
}

View file

@ -1,4 +1,4 @@
// utils.js
//js/utils.js
export const QUALITY = 'LOSSLESS';
@ -65,14 +65,14 @@ export const getExtensionForQuality = (quality) => {
export const buildTrackFilename = (track, quality) => {
const template = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}';
const extension = getExtensionForQuality(quality);
const data = {
trackNumber: track.trackNumber,
artist: track.artist?.name,
title: getTrackTitle(track),
album: track.album?.title
};
return formatTemplate(template, data) + '.' + extension;
};
@ -83,21 +83,21 @@ const sanitizeToken = (value) => {
export const normalizeQualityToken = (value) => {
if (!value) return null;
const token = sanitizeToken(value);
for (const [quality, aliases] of Object.entries(QUALITY_TOKENS)) {
if (aliases.includes(token)) {
return quality;
}
}
return null;
};
export const deriveQualityFromTags = (rawTags) => {
if (!Array.isArray(rawTags)) return null;
const candidates = [];
for (const tag of rawTags) {
if (typeof tag !== 'string') continue;
@ -106,37 +106,37 @@ export const deriveQualityFromTags = (rawTags) => {
candidates.push(normalized);
}
}
return pickBestQuality(candidates);
};
export const pickBestQuality = (candidates) => {
let best = null;
let bestRank = Infinity;
for (const candidate of candidates) {
if (!candidate) continue;
const rank = QUALITY_PRIORITY.indexOf(candidate);
const currentRank = rank === -1 ? Infinity : rank;
if (currentRank < bestRank) {
best = candidate;
bestRank = currentRank;
}
}
return best;
};
export const deriveTrackQuality = (track) => {
if (!track) return null;
const candidates = [
deriveQualityFromTags(track.mediaMetadata?.tags),
deriveQualityFromTags(track.album?.mediaMetadata?.tags),
normalizeQualityToken(track.audioQuality)
];
return pickBestQuality(candidates);
};
@ -190,10 +190,10 @@ export const calculateTotalDuration = (tracks) => {
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`;
}

View file

@ -1,3 +1,4 @@
:root {
--spacing-xs: 0.5rem;
--spacing-sm: 0.75rem;
@ -163,7 +164,7 @@ kbd {
display: grid;
height: 100vh;
height: 100dvh;
grid-template:
grid-template:
"sidebar main" 1fr
"player player" auto / 280px 1fr;
}
@ -318,11 +319,11 @@ kbd {
}
@keyframes fadeIn {
from {
from {
opacity: 0;
transform: translateY(4px);
}
to {
to {
opacity: 1;
transform: translateY(0);
}
@ -1806,16 +1807,16 @@ input:checked + .slider::before {
.app-container {
grid-template-columns: 240px 1fr;
}
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--spacing-md);
}
.detail-header-info .title {
font-size: 3rem;
}
.main-content {
padding: var(--spacing-lg);
}
@ -1823,25 +1824,25 @@ input:checked + .slider::before {
@media (max-width: 768px) {
.app-container {
grid-template:
grid-template:
"header" auto
"main" 1fr
"player" auto / 1fr;
height: 100vh;
height: 100dvh;
}
.main-content {
padding: var(--spacing-md);
grid-area: main;
}
.main-header {
grid-area: header;
padding: var(--spacing-md) var(--spacing-md) 0;
margin-bottom: var(--spacing-md);
}
.sidebar {
position: fixed;
top: 0;
@ -1850,37 +1851,37 @@ input:checked + .slider::before {
transform: translateX(-100%);
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
.sidebar.is-open {
transform: translateX(0);
}
.hamburger-menu {
display: block;
}
#sidebar-overlay.is-visible {
display: block;
}
.search-bar {
max-width: none;
}
.content-section {
margin-bottom: var(--spacing-xl);
}
.section-title {
font-size: 1.5rem;
margin-bottom: var(--spacing-md);
}
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--spacing-md);
}
.detail-header {
flex-direction: column;
align-items: flex-start;
@ -1888,229 +1889,229 @@ input:checked + .slider::before {
padding-bottom: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.detail-header-image {
width: 150px;
height: 150px;
}
.detail-header-info .title {
font-size: 2rem;
line-height: 1.2;
}
.detail-header-info .meta {
font-size: 0.85rem;
gap: 0.35rem;
}
.detail-header-actions,
.btn-primary {
width: 100%;
}
.now-playing-bar {
grid-template:
grid-template:
"track controls" auto
"progress progress" auto / 1fr auto;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
height: auto;
}
.now-playing-bar .track-info {
grid-area: track;
min-width: 0;
}
.track-info {
gap: var(--spacing-sm);
}
.track-info .cover {
width: 48px;
height: 48px;
}
.track-info .details {
min-width: 0;
flex: 1;
}
.track-info .details .title {
font-size: 0.9rem;
}
.track-info .details .artist {
font-size: 0.75rem;
}
.now-playing-bar .volume-controls {
grid-area: controls;
display: flex;
gap: 0.25rem;
justify-content: flex-end;
}
.now-playing-bar .player-controls {
grid-area: progress;
width: 100%;
}
.player-controls .buttons {
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
.player-controls .buttons button {
width: 28px;
height: 28px;
}
.player-controls .buttons .play-pause-btn {
width: 32px;
height: 32px;
}
.player-controls .progress-container {
max-width: none;
font-size: 0.75rem;
gap: 0.5rem;
}
.desktop-only {
display: none !important;
}
.volume-controls button:not(.desktop-only) {
display: flex;
}
.volume-controls button {
padding: 0.375rem;
min-width: 32px;
min-height: 32px;
}
.volume-controls button svg {
width: 18px;
height: 18px;
}
#download-notifications {
bottom: 10px;
right: 10px;
left: 10px;
max-width: none;
}
.track-menu-btn {
opacity: 1;
}
.about-links {
flex-direction: column;
}
.github-link {
width: 100%;
justify-content: center;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-md);
}
.setting-item .info {
width: 100%;
}
.template-input {
max-width: none;
font-size: 0.85rem;
}
.track-item {
grid-template-columns: 28px 1fr 45px 32px;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
}
.track-number {
font-size: 0.8rem;
width: 28px;
}
.track-item-info {
gap: var(--spacing-sm);
min-width: 0;
overflow: hidden;
}
.track-item-cover {
width: 36px;
height: 36px;
}
.track-item-details {
min-width: 0;
overflow: hidden;
}
.track-item-details .title {
font-size: 0.85rem;
}
.track-item-details .artist {
font-size: 0.75rem;
}
.track-item-duration {
font-size: 0.75rem;
text-align: right;
white-space: nowrap;
}
.track-menu-btn {
padding: 0.5rem;
margin: 0;
}
.track-menu-btn svg {
width: 18px;
height: 18px;
}
.queue-track-item {
grid-template-columns: 24px 1fr 40px 28px;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
}
.queue-track-item .drag-handle {
width: 24px;
}
.queue-track-item .drag-handle svg {
width: 14px;
height: 14px;
}
.queue-track-item .track-item-cover {
width: 36px;
height: 36px;
}
.queue-track-item .track-menu-btn {
padding: 0.5rem;
}
.sidebar-nav .nav-item a {
padding: 1rem 0.75rem;
}
.offline-notification,
.update-notification,
.install-prompt {
@ -2126,102 +2127,102 @@ input:checked + .slider::before {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--spacing-sm);
}
.section-title {
font-size: 1.25rem;
}
.detail-header-info .title {
font-size: 1.75rem;
}
.search-tab {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.9rem;
}
.player-controls .buttons {
gap: 0.25rem;
}
.player-controls .buttons button {
width: 24px;
height: 24px;
}
.player-controls .buttons button svg {
width: 16px;
height: 16px;
}
.player-controls .buttons .play-pause-btn {
width: 28px;
height: 28px;
}
.player-controls .buttons .play-pause-btn svg {
width: 18px;
height: 18px;
}
.volume-controls button {
padding: 0.25rem;
min-width: 28px;
min-height: 28px;
}
.volume-controls button svg {
width: 16px;
height: 16px;
}
.track-item {
grid-template-columns: 24px 1fr 40px 28px;
gap: 0.375rem;
padding: 0.5rem;
}
.track-number {
font-size: 0.75rem;
width: 24px;
}
.track-item-cover {
width: 32px;
height: 32px;
}
.track-item-details .title {
font-size: 0.8rem;
}
.track-item-details .artist {
font-size: 0.7rem;
}
.track-item-duration {
font-size: 0.7rem;
}
.track-menu-btn {
padding: 0.25rem;
}
.track-menu-btn svg {
width: 16px;
height: 16px;
}
.queue-track-item {
grid-template-columns: 20px 1fr 36px 24px;
gap: 0.375rem;
padding: 0.5rem;
}
.queue-track-item .drag-handle {
width: 20px;
}
.queue-track-item .track-item-cover {
width: 32px;
height: 32px;
@ -2233,7 +2234,7 @@ input:checked + .slider::before {
justify-content: space-between;
width: 100%;
}
#shuffle-btn,
#repeat-btn {
display: none;
@ -2245,11 +2246,11 @@ input:checked + .slider::before {
grid-template-columns: 1fr 2fr auto;
padding: var(--spacing-md);
}
.volume-controls {
display: flex;
}
.desktop-only {
display: flex;
}
@ -2272,7 +2273,7 @@ input:checked + .slider::before {
.volume-bar {
height: 8px;
}
.progress-bar .progress-fill::after,
.volume-bar .volume-fill::after {
content: '';
@ -2286,17 +2287,17 @@ input:checked + .slider::before {
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.track-item,
.queue-track-item {
padding: var(--spacing-md) var(--spacing-sm);
}
button {
min-height: 44px;
min-width: 44px;
}
.player-controls .buttons button {
min-height: 36px;
min-width: 36px;
@ -2307,11 +2308,11 @@ input:checked + .slider::before {
.main-header {
padding-top: max(var(--spacing-md), env(safe-area-inset-top));
}
.now-playing-bar {
padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom));
}
.sidebar {
padding-top: max(1.5rem, env(safe-area-inset-top));
}
@ -2514,27 +2515,27 @@ input:checked + .slider::before {
.lyrics-panel {
width: 100vw;
}
.synced-line {
font-size: 1rem;
}
.synced-line.active {
font-size: 1.125rem;
}
.karaoke-title {
font-size: 1.5rem;
}
.karaoke-artist {
font-size: 1rem;
}
.karaoke-line {
font-size: 1.25rem;
}
.karaoke-line.active {
font-size: 1.75rem;
}
@ -2714,32 +2715,32 @@ input:checked + .slider::before {
padding-bottom: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
#playlist-detail-image {
width: 150px;
height: 150px;
}
#playlist-detail-title {
font-size: 2rem;
line-height: 1.2;
}
#playlist-detail-meta {
font-size: 0.85rem;
gap: 0.35rem;
}
#playlist-detail-description {
font-size: 0.85rem;
max-width: none;
}
#page-playlist .detail-actions {
width: 100%;
flex-direction: column;
}
#play-playlist-btn,
#download-playlist-btn {
width: 100%;
@ -2756,4 +2757,4 @@ input:checked + .slider::before {
#playlist-detail-title {
font-size: 4rem;
}
}
}