naming
This commit is contained in:
parent
34c6c45439
commit
ae7fae9b3d
15 changed files with 666 additions and 653 deletions
97
js/api.js
97
js/api.js
|
|
@ -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
134
js/app.js
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
js/cache.js
12
js/cache.js
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
143
js/downloads.js
143
js/downloads.js
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
js/events.js
107
js/events.js
|
|
@ -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')}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
js/lastfm.js
44
js/lastfm.js
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
js/lyrics.js
71
js/lyrics.js
|
|
@ -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 || ' '}</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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
js/player.js
61
js/player.js
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
125
js/storage.js
125
js/storage.js
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
123
js/ui.js
|
|
@ -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) {
|
|||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
js/utils.js
30
js/utils.js
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
215
styles.css
215
styles.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue