PLEASE work

This commit is contained in:
Eduard Prigoana 2025-10-19 15:07:00 +03:00
parent c588ac630a
commit 07345867b9
6 changed files with 1160 additions and 256 deletions

View file

@ -17,6 +17,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.jsdelivr.net/npm/@ffmpeg/ffmpeg@0.12.10/dist/umd/ffmpeg.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@ffmpeg/util@0.12.1/dist/umd/index.min.js"></script>
</head>
<body>
<audio id="audio-player"></audio>
@ -61,13 +63,27 @@
</li>
<li class="nav-item">
<a href="#settings">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 0 2l-.15.08a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l-.22-.38a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1 0 2l.15-.08a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
</svg>
<span>Settings</span>
</a>
</li>
<li class="nav-item">
<a href="https://github.com/eduardprigoana/monochrome" target="_blank" rel="noopener noreferrer">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>GitHub</span>
</a>
</li>
<li class="nav-item">
<a href="https://status.monochrome.tf" target="_blank" rel="noopener noreferrer">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
</svg>
<span>Status</span>
</a>
</li>
</ul>
</nav>
</div>
@ -127,6 +143,14 @@
<div class="type">Album</div>
<h1 class="title" id="album-detail-title"></h1>
<div class="meta" id="album-detail-meta"></div>
<button id="download-album-btn" class="btn-download" style="margin-top: 1rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>Download Album</span>
</button>
</div>
</header>
<div class="track-list" id="album-detail-tracklist"></div>
@ -139,6 +163,14 @@
<div class="type">Artist</div>
<h1 class="title" id="artist-detail-name"></h1>
<div class="meta" id="artist-detail-meta"></div>
<button id="download-discography-btn" class="btn-download" style="margin-top: 1rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>Download Discography</span>
</button>
</div>
</header>
<section class="content-section">
@ -157,8 +189,13 @@
<div class="setting-item">
<div class="info">
<span class="label">Audio Quality</span>
<span class="description">Set to LOSSLESS by default.</span>
<span class="description">Quality for streaming and downloads.</span>
</div>
<select id="quality-setting">
<option value="LOSSLESS">FLAC (Lossless)</option>
<option value="HIGH">AAC 320kbps</option>
<option value="LOW">AAC 96kbps</option>
</select>
</div>
<div class="setting-item">
<div class="info">
@ -239,22 +276,6 @@
<h4>Technology Stack</h4>
<p>Vanilla JavaScript • ES6 Modules • IndexedDB • Service Workers • Media Session API</p>
</div>
<div class="about-links">
<a href="https://github.com/eduardprigoana/monochrome" target="_blank" rel="noopener noreferrer" class="github-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span>View on GitHub</span>
</a>
<a href="https://github.com/eduardprigoana/monochrome/issues" target="_blank" rel="noopener noreferrer" class="github-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span>Report Issue</span>
</a>
</div>
<div class="about-footer">
<p class="version">Version 1.0.0</p>
<p class="disclaimer">This is an independent client and is not affiliated with or endorsed by TIDAL or any music streaming service.</p>

383
js/api.js
View file

@ -1,6 +1,8 @@
//js/api.js
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
import { APICache } from './cache.js';
import { MetadataEmbedder } from './metadata.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
export class LosslessAPI {
constructor(settings) {
@ -9,78 +11,88 @@ export class LosslessAPI {
maxSize: 200,
ttl: 1000 * 60 * 30
});
this.streamCache = new Map();
this.metadataEmbedder = new MetadataEmbedder();
setInterval(() => {
this.cache.clearExpired();
this.pruneStreamCache();
}, 1000 * 60 * 5);
}
async fetchWithRetry(relativePath, options = {}) {
const instances = this.settings.getInstances();
if (instances.length === 0) {
throw new Error("No API instances configured.");
}
const maxRetries = 1;
let lastError = null;
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/')
? `${baseUrl}${relativePath.substring(1)}`
: `${baseUrl}${relativePath}`;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, { signal: options.signal });
if (response.status === 429) {
throw new Error(RATE_LIMIT_ERROR_MESSAGE);
}
if (response.ok) {
return response;
}
if (response.status === 401) {
let errorData;
try {
errorData = await response.clone().json();
} catch {}
if (errorData?.subStatus === 11002) {
lastError = new Error(errorData?.userMessage || 'Authentication failed');
if (attempt < maxRetries) {
await delay(200);
continue;
}
}
}
if (response.status >= 500 && attempt < maxRetries) {
await delay(200);
continue;
}
lastError = new Error(`Request failed with status ${response.status}`);
break;
} catch (error) {
if (error.name === 'AbortError') {
throw error;
}
lastError = error;
console.log(`Failed for ${baseUrl}: ${error.message}`);
if (attempt < maxRetries) {
await delay(200);
}
}
pruneStreamCache() {
if (this.streamCache.size > 50) {
const entries = Array.from(this.streamCache.entries());
const toDelete = entries.slice(0, entries.length - 50);
toDelete.forEach(([key]) => this.streamCache.delete(key));
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
}
async fetchWithRetry(relativePath, options = {}) {
const instances = this.settings.getInstances();
if (instances.length === 0) {
throw new Error("No API instances configured.");
}
const maxRetries = 3;
let lastError = null;
for (const baseUrl of instances) {
const url = baseUrl.endsWith('/')
? `${baseUrl}${relativePath.substring(1)}`
: `${baseUrl}${relativePath}`;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, { signal: options.signal });
if (response.status === 429) {
throw new Error(RATE_LIMIT_ERROR_MESSAGE);
}
if (response.ok) {
return response;
}
if (response.status === 401) {
let errorData;
try {
errorData = await response.clone().json();
} catch {}
if (errorData?.subStatus === 11002) {
lastError = new Error(errorData?.userMessage || 'Authentication failed');
if (attempt < maxRetries) {
await delay(200 * attempt);
continue;
}
}
}
if (response.status >= 500 && attempt < maxRetries) {
await delay(200 * attempt);
continue;
}
lastError = new Error(`Request failed with status ${response.status}`);
break;
} catch (error) {
if (error.name === 'AbortError') {
throw error;
}
lastError = error;
if (attempt < maxRetries) {
await delay(200 * attempt);
}
}
}
}
throw lastError || new Error(`All API instances failed for: ${relativePath}`);
}
findSearchSection(source, key, visited) {
if (!source || typeof source !== 'object') return;
@ -124,25 +136,21 @@ async fetchWithRetry(relativePath, options = {}) {
return this.buildSearchResponse(section);
}
prepareTrack(track) {
let normalized = track;
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
normalized = { ...track, artist: track.artists[0] };
}
prepareTrack(track) {
let normalized = track;
if (!track.artist && Array.isArray(track.artists) && track.artists.length > 0) {
normalized = { ...track, artist: track.artists[0] };
}
if (normalized.album && !normalized.album.cover && normalized.album.id) {
console.warn('Track missing album cover, attempting to use album ID');
}
const derivedQuality = deriveTrackQuality(normalized);
if (derivedQuality && normalized.audioQuality !== derivedQuality) {
normalized = { ...normalized, audioQuality: derivedQuality };
}
const derivedQuality = deriveTrackQuality(normalized);
if (derivedQuality && normalized.audioQuality !== derivedQuality) {
normalized = { ...normalized, audioQuality: derivedQuality };
return normalized;
}
return normalized;
}
prepareAlbum(album) {
if (!album.artist && Array.isArray(album.artists) && album.artists.length > 0) {
return { ...album, artist: album.artists[0] };
@ -207,68 +215,69 @@ prepareTrack(track) {
return null;
}
}
async searchTracks(query) {
const cached = await this.cache.get('search_tracks', query);
if (cached) return cached;
try {
const response = await this.fetchWithRetry(`/search/?s=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'tracks');
const result = {
...normalized,
items: normalized.items.map(t => this.prepareTrack(t))
};
async searchTracks(query) {
const cached = await this.cache.get('search_tracks', query);
if (cached) return cached;
await this.cache.set('search_tracks', query, result);
return result;
} catch (error) {
console.error('Track search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
try {
const response = await this.fetchWithRetry(`/search/?s=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'tracks');
const result = {
...normalized,
items: normalized.items.map(t => this.prepareTrack(t))
};
await this.cache.set('search_tracks', query, result);
return result;
} catch (error) {
console.error('Track search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
}
}
}
async searchArtists(query) {
const cached = await this.cache.get('search_artists', query);
if (cached) return cached;
async searchArtists(query) {
const cached = await this.cache.get('search_artists', query);
if (cached) return cached;
try {
const response = await this.fetchWithRetry(`/search/?a=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'artists');
const result = {
...normalized,
items: normalized.items.map(a => this.prepareArtist(a))
};
try {
const response = await this.fetchWithRetry(`/search/?a=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'artists');
const result = {
...normalized,
items: normalized.items.map(a => this.prepareArtist(a))
};
await this.cache.set('search_artists', query, result);
return result;
} catch (error) {
console.error('Artist search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
await this.cache.set('search_artists', query, result);
return result;
} catch (error) {
console.error('Artist search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
}
}
}
async searchAlbums(query) {
const cached = await this.cache.get('search_albums', query);
if (cached) return cached;
async searchAlbums(query) {
const cached = await this.cache.get('search_albums', query);
if (cached) return cached;
try {
const response = await this.fetchWithRetry(`/search/?al=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'albums');
const result = {
...normalized,
items: normalized.items.map(a => this.prepareAlbum(a))
};
try {
const response = await this.fetchWithRetry(`/search/?al=${encodeURIComponent(query)}`);
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'albums');
const result = {
...normalized,
items: normalized.items.map(a => this.prepareAlbum(a))
};
await this.cache.set('search_albums', query, result);
return result;
} catch (error) {
console.error('Album search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
await this.cache.set('search_albums', query, result);
return result;
} catch (error) {
console.error('Album search failed:', error);
return { items: [], limit: 0, offset: 0, totalNumberOfItems: 0 };
}
}
}
async getAlbum(id) {
const cached = await this.cache.get('album', id);
@ -369,19 +378,31 @@ async searchAlbums(query) {
}
async getStreamUrl(id, quality = 'LOSSLESS') {
const lookup = await this.getTrack(id, quality);
const cacheKey = `stream_${id}_${quality}`;
if (lookup.originalTrackUrl) {
return lookup.originalTrackUrl;
if (this.streamCache.has(cacheKey)) {
return this.streamCache.get(cacheKey);
}
const url = this.extractStreamUrlFromManifest(lookup.info.manifest);
if (url) return url;
const lookup = await this.getTrack(id, quality);
let streamUrl;
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
} else {
streamUrl = this.extractStreamUrlFromManifest(lookup.info.manifest);
if (!streamUrl) {
throw new Error('Could not resolve stream URL');
}
}
throw new Error('Could not resolve stream URL');
this.streamCache.set(cacheKey, streamUrl);
return streamUrl;
}
async downloadTrack(id, quality = 'LOSSLESS', filename) {
async downloadTrack(id, quality = 'LOSSLESS', filename, options = {}) {
const { onProgress, embedMetadata = true, track, coverUrl } = options;
try {
const lookup = await this.getTrack(id, quality);
let streamUrl;
@ -395,23 +416,74 @@ async searchAlbums(query) {
}
}
const response = await fetch(streamUrl, { cache: 'no-store' });
const response = await fetch(streamUrl, {
cache: 'no-store',
signal: options.signal
});
if (!response.ok) {
throw new Error(`Fetch failed: ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const contentLength = response.headers.get('Content-Length');
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
let receivedBytes = 0;
if (response.body && onProgress) {
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
receivedBytes += value.byteLength;
onProgress({
stage: 'downloading',
receivedBytes,
totalBytes: totalBytes || undefined
});
}
}
let blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' });
if (embedMetadata && track && quality === 'LOSSLESS' && coverUrl) {
if (onProgress) {
onProgress({ stage: 'metadata', progress: 0 });
}
try {
blob = await this.metadataEmbedder.embedMetadata(blob, track, coverUrl, (progress) => {
if (onProgress) {
onProgress({ stage: 'metadata', progress });
}
});
} catch (metaError) {
console.warn('Metadata embedding failed, downloading without metadata:', metaError);
}
}
this.triggerDownload(blob, filename);
} else {
const blob = await response.blob();
if (onProgress) {
onProgress({
stage: 'downloading',
receivedBytes: blob.size,
totalBytes: blob.size
});
}
this.triggerDownload(blob, filename);
}
} catch (error) {
if (error.name === 'AbortError') {
throw error;
}
console.error("Download failed:", error);
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
throw error;
@ -420,6 +492,17 @@ async searchAlbums(query) {
}
}
triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
getCoverUrl(id, size = '1280') {
if (!id) {
return `https://picsum.photos/seed/${Math.random()}/${size}`;
@ -440,9 +523,13 @@ async searchAlbums(query) {
async clearCache() {
await this.cache.clear();
this.streamCache.clear();
}
getCacheStats() {
return this.cache.getCacheStats();
return {
...this.cache.getCacheStats(),
streamUrls: this.streamCache.size
};
}
}

607
js/app.js
View file

@ -3,17 +3,445 @@ import { apiSettings } from './storage.js';
import { UIRenderer } from './ui.js';
import { Player } from './player.js';
import {
QUALITY, REPEAT_MODE, SVG_PLAY, SVG_PAUSE,
REPEAT_MODE, SVG_PLAY, SVG_PAUSE,
SVG_VOLUME, SVG_MUTE, formatTime, trackDataStore,
buildTrackFilename, RATE_LIMIT_ERROR_MESSAGE, debounce
buildTrackFilename, RATE_LIMIT_ERROR_MESSAGE, debounce,
sanitizeForFilename
} from './utils.js';
const downloadTasks = new Map();
let downloadNotificationContainer = null;
async function loadJSZip() {
try {
const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm');
return module.default;
} catch (error) {
console.error('Failed to load JSZip:', error);
throw new Error('Failed to load ZIP library');
}
}
function createDownloadNotification() {
if (!downloadNotificationContainer) {
downloadNotificationContainer = document.createElement('div');
downloadNotificationContainer.id = 'download-notifications';
downloadNotificationContainer.style.cssText = `
position: fixed;
bottom: 120px;
right: 20px;
z-index: 9999;
max-width: 350px;
display: flex;
flex-direction: column;
gap: 0.5rem;
`;
document.body.appendChild(downloadNotificationContainer);
}
return downloadNotificationContainer;
}
function addDownloadTask(trackId, track, filename, api) {
const container = createDownloadNotification();
const taskEl = document.createElement('div');
taskEl.className = 'download-task';
taskEl.dataset.trackId = trackId;
taskEl.style.cssText = `
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
animation: slideIn 0.3s ease;
`;
taskEl.innerHTML = `
<div style="display: flex; align-items: start; gap: 0.75rem;">
<img src="${api.getCoverUrl(track.album?.cover, '80')}"
style="width: 40px; height: 40px; border-radius: 4px; flex-shrink: 0;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; font-size: 0.9rem; margin-bottom: 0.25rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${track.title}</div>
<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-bottom: 0.5rem;">${track.artist?.name || 'Unknown'}</div>
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
<div class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
</div>
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem;">Starting...</div>
</div>
<button class="download-cancel" style="background: transparent; border: none; color: var(--muted-foreground); cursor: pointer; padding: 4px; border-radius: 4px; transition: all 0.2s;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
`;
container.appendChild(taskEl);
const abortController = new AbortController();
downloadTasks.set(trackId, { taskEl, abortController });
taskEl.querySelector('.download-cancel').addEventListener('click', () => {
abortController.abort();
removeDownloadTask(trackId);
});
return { taskEl, abortController };
}
function updateDownloadProgress(trackId, progress) {
const task = downloadTasks.get(trackId);
if (!task) return;
const { taskEl } = task;
const progressFill = taskEl.querySelector('.download-progress-fill');
const statusEl = taskEl.querySelector('.download-status');
if (progress.stage === 'downloading') {
const percent = progress.totalBytes
? Math.round((progress.receivedBytes / progress.totalBytes) * 100)
: 0;
progressFill.style.width = `${percent}%`;
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
const totalMB = progress.totalBytes
? (progress.totalBytes / (1024 * 1024)).toFixed(1)
: '?';
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
} else if (progress.stage === 'metadata') {
const percent = Math.round(progress.progress * 100);
progressFill.style.width = `${percent}%`;
progressFill.style.background = '#a855f7';
statusEl.textContent = `Embedding metadata: ${percent}%`;
}
}
function completeDownloadTask(trackId, success = true, message = null) {
const task = downloadTasks.get(trackId);
if (!task) return;
const { taskEl } = task;
const progressFill = taskEl.querySelector('.download-progress-fill');
const statusEl = taskEl.querySelector('.download-status');
const cancelBtn = taskEl.querySelector('.download-cancel');
if (success) {
progressFill.style.width = '100%';
progressFill.style.background = '#10b981';
statusEl.textContent = '✓ Downloaded';
statusEl.style.color = '#10b981';
cancelBtn.remove();
setTimeout(() => removeDownloadTask(trackId), 3000);
} else {
progressFill.style.background = '#ef4444';
statusEl.textContent = message || '✗ Download failed';
statusEl.style.color = '#ef4444';
cancelBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
cancelBtn.onclick = () => removeDownloadTask(trackId);
setTimeout(() => removeDownloadTask(trackId), 5000);
}
}
function removeDownloadTask(trackId) {
const task = downloadTasks.get(trackId);
if (!task) return;
const { taskEl } = task;
taskEl.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
taskEl.remove();
downloadTasks.delete(trackId);
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
downloadNotificationContainer.remove();
downloadNotificationContainer = null;
}
}, 300);
}
async function downloadTrackBlob(track, quality, api, coverUrl = null) {
console.log('[Download] Starting download for:', track.title, 'Quality:', quality);
console.log('[Download] Cover URL:', coverUrl);
const lookup = await api.getTrack(track.id, quality);
let streamUrl;
if (lookup.originalTrackUrl) {
streamUrl = lookup.originalTrackUrl;
} else {
streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
if (!streamUrl) {
throw new Error('Could not resolve stream URL');
}
}
console.log('[Download] Fetching from:', streamUrl);
const response = await fetch(streamUrl);
if (!response.ok) {
throw new Error(`Failed to fetch track: ${response.status}`);
}
let blob = await response.blob();
console.log('[Download] Downloaded blob size:', blob.size, 'type:', blob.type);
if (quality === 'LOSSLESS' && coverUrl) {
console.log('[Download] Attempting to embed metadata...');
try {
const processedBlob = await api.metadataEmbedder.embedMetadata(blob, track, coverUrl, null);
console.log('[Download] Metadata embedded. New size:', processedBlob.size);
blob = processedBlob;
} catch (error) {
console.error('[Download] Metadata embedding failed:', error);
}
} else {
console.log('[Download] Skipping metadata - Quality:', quality, 'Has cover:', !!coverUrl);
}
return blob;
}
async function downloadAlbumAsZip(album, tracks, api, quality) {
const JSZip = await loadJSZip();
const zip = new JSZip();
const artistName = sanitizeForFilename(album.artist?.name || 'Unknown Artist');
const albumTitle = sanitizeForFilename(album.title || 'Unknown Album');
const folderName = `${albumTitle} - ${artistName} - monochrome.tf`;
const coverUrl = album.cover ? api.getCoverUrl(album.cover, '1280') : null;
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);
updateBulkDownloadProgress(notification, i, tracks.length, track.title);
const blob = await downloadTrackBlob(track, quality, api, coverUrl);
zip.file(`${folderName}/${filename}`, blob);
}
updateBulkDownloadProgress(notification, tracks.length, tracks.length, 'Creating ZIP...');
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
});
const url = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = `${folderName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
completeBulkDownload(notification, true);
} catch (error) {
completeBulkDownload(notification, false, error.message);
throw error;
}
}
async function downloadDiscography(artist, api, quality) {
const JSZip = await loadJSZip();
const zip = new JSZip();
const artistName = sanitizeForFilename(artist.name || 'Unknown Artist');
const rootFolder = `${artistName} 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 albumTitle = sanitizeForFilename(fullAlbum.title || 'Unknown Album');
const albumFolder = `${rootFolder}/${albumTitle}`;
const coverUrl = fullAlbum.cover ? api.getCoverUrl(fullAlbum.cover, '1280') : null;
for (const track of tracks) {
const filename = buildTrackFilename(track, quality);
const blob = await downloadTrackBlob(track, quality, api, coverUrl);
zip.file(`${albumFolder}/${filename}`, blob);
}
} catch (error) {
console.error(`Failed to download album ${album.title}:`, error);
}
}
updateBulkDownloadProgress(notification, totalAlbums, totalAlbums, 'Creating ZIP...');
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
});
const url = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = `${rootFolder}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
completeBulkDownload(notification, true);
} catch (error) {
completeBulkDownload(notification, false, error.message);
throw error;
}
}
function createBulkDownloadNotification(type, name, totalItems) {
const container = createDownloadNotification();
const notifEl = document.createElement('div');
notifEl.className = 'download-task bulk-download';
notifEl.style.cssText = `
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
animation: slideIn 0.3s ease;
`;
notifEl.innerHTML = `
<div style="display: flex; align-items: start; gap: 0.75rem;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; font-size: 0.95rem; margin-bottom: 0.25rem;">
Downloading ${type === 'album' ? 'Album' : 'Discography'}
</div>
<div style="font-size: 0.85rem; color: var(--muted-foreground); margin-bottom: 0.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${name}</div>
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
<div class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
</div>
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem;">Starting...</div>
</div>
</div>
`;
container.appendChild(notifEl);
return notifEl;
}
function updateBulkDownloadProgress(notifEl, current, total, currentItem) {
const progressFill = notifEl.querySelector('.download-progress-fill');
const statusEl = notifEl.querySelector('.download-status');
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
progressFill.style.width = `${percent}%`;
statusEl.textContent = `${current}/${total} - ${currentItem}`;
}
function completeBulkDownload(notifEl, success = true, message = null) {
const progressFill = notifEl.querySelector('.download-progress-fill');
const statusEl = notifEl.querySelector('.download-status');
if (success) {
progressFill.style.width = '100%';
progressFill.style.background = '#10b981';
statusEl.textContent = '✓ Download complete';
statusEl.style.color = '#10b981';
setTimeout(() => {
notifEl.style.animation = 'slideOut 0.3s ease';
setTimeout(() => notifEl.remove(), 300);
}, 3000);
} else {
progressFill.style.background = '#ef4444';
statusEl.textContent = message || '✗ Download failed';
statusEl.style.color = '#ef4444';
setTimeout(() => {
notifEl.style.animation = 'slideOut 0.3s ease';
setTimeout(() => notifEl.remove(), 300);
}, 5000);
}
}
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.download-cancel:hover {
background: var(--secondary) !important;
color: var(--foreground) !important;
}
.now-playing-bar .title,
.now-playing-bar .artist {
cursor: pointer;
transition: color 0.2s;
}
.now-playing-bar .title:hover,
.now-playing-bar .artist:hover {
color: var(--highlight);
text-decoration: underline;
}
`;
document.head.appendChild(style);
document.addEventListener('DOMContentLoaded', () => {
const api = new LosslessAPI(apiSettings);
const ui = new UIRenderer(api);
const audioPlayer = document.getElementById('audio-player');
const player = new Player(audioPlayer, api, QUALITY);
const currentQuality = localStorage.getItem('playback-quality') || 'LOSSLESS';
const player = new Player(audioPlayer, api, currentQuality);
const mainContent = document.querySelector('.main-content');
const playPauseBtn = document.querySelector('.play-pause-btn');
@ -41,6 +469,81 @@ document.addEventListener('DOMContentLoaded', () => {
let contextTrack = null;
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);
});
}
document.querySelector('.now-playing-bar .title').addEventListener('click', () => {
const track = player.currentTrack;
if (track?.album?.id) {
window.location.hash = `#album/${track.album.id}`;
}
});
document.querySelector('.now-playing-bar .artist').addEventListener('click', () => {
const track = player.currentTrack;
if (track?.artist?.id) {
window.location.hash = `#artist/${track.artist.id}`;
}
});
document.addEventListener('click', async (e) => {
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);
} catch (error) {
console.error('Album download failed:', error);
alert('Failed to download album: ' + error.message);
} finally {
btn.disabled = false;
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);
} catch (error) {
console.error('Discography download failed:', error);
alert('Failed to download discography: ' + error.message);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
});
document.querySelectorAll('.search-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.search-tab').forEach(t => t.classList.remove('active'));
@ -91,7 +594,7 @@ document.addEventListener('DOMContentLoaded', () => {
track.id === (currentQueue[player.currentQueueIndex] || {}).id;
return `
<div class="track-item ${isPlaying ? 'playing' : ''}" data-queue-index="${index}">
<div class="track-item ${isPlaying ? 'playing' : ''}" data-queue-index="${index}" data-track-id="${track.id}">
<div class="track-number">${index + 1}</div>
<div class="track-item-info">
<img src="${api.getCoverUrl(track.album?.cover, '80')}"
@ -107,12 +610,21 @@ document.addEventListener('DOMContentLoaded', () => {
}).join('');
queueList.innerHTML = html;
queueList.querySelectorAll('.track-item').forEach((item, index) => {
item.addEventListener('click', () => {
player.playAtIndex(index);
player.updatePlayingTrackIndicator();
renderQueue();
});
});
player.updatePlayingTrackIndicator();
};
mainContent.addEventListener('click', e => {
const trackItem = e.target.closest('.track-item');
if (trackItem) {
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);
@ -154,23 +666,39 @@ document.addEventListener('DOMContentLoaded', () => {
player.addToQueue(contextTrack);
renderQueue();
} else if (action === 'download' && contextTrack) {
const filename = buildTrackFilename(contextTrack, QUALITY);
const quality = player.quality;
const filename = buildTrackFilename(contextTrack, quality);
try {
const tempEl = document.createElement('div');
tempEl.textContent = `Downloading: ${contextTrack.title}...`;
tempEl.style.cssText = 'position:fixed;bottom:100px;right:20px;background:var(--card);padding:1rem 1.5rem;border-radius:var(--radius);border:1px solid var(--border);z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.5);';
document.body.appendChild(tempEl);
const { taskEl, abortController } = addDownloadTask(
contextTrack.id,
contextTrack,
filename,
api
);
await api.downloadTrack(contextTrack.id, QUALITY, filename);
const coverUrl = contextTrack.album?.cover
? api.getCoverUrl(contextTrack.album.cover, '1280')
: null;
tempEl.textContent = `✓ Downloaded: ${contextTrack.title}`;
setTimeout(() => tempEl.remove(), 3000);
await api.downloadTrack(contextTrack.id, quality, filename, {
signal: abortController.signal,
track: contextTrack,
coverUrl: coverUrl,
embedMetadata: true,
onProgress: (progress) => {
updateDownloadProgress(contextTrack.id, progress);
}
});
completeDownloadTask(contextTrack.id, true);
} catch (error) {
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
? error.message
: 'Download failed. Please try again.';
alert(errorMsg);
if (error.name !== 'AbortError') {
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
? error.message
: 'Download failed. Please try again.';
completeDownloadTask(contextTrack.id, false, errorMsg);
}
}
}
@ -217,6 +745,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (duration) {
progressFill.style.width = `${(currentTime / duration) * 100}%`;
currentTimeEl.textContent = formatTime(currentTime);
player.updateMediaSessionPositionState();
}
});
@ -251,6 +780,7 @@ document.addEventListener('DOMContentLoaded', () => {
seek(progressBar, progressFill, e, position => {
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
player.updateMediaSessionPositionState();
if (wasPlaying) audioPlayer.play();
}
});
@ -263,6 +793,7 @@ document.addEventListener('DOMContentLoaded', () => {
seek(progressBar, progressFill, e, position => {
if (!isNaN(audioPlayer.duration)) {
audioPlayer.currentTime = position * audioPlayer.duration;
player.updateMediaSessionPositionState();
}
});
}
@ -418,48 +949,6 @@ document.addEventListener('DOMContentLoaded', () => {
router();
window.addEventListener('hashchange', router);
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
player.handlePlayPause();
});
navigator.mediaSession.setActionHandler('pause', () => {
player.handlePlayPause();
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
player.playPrev();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
player.playNext();
});
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
const skipTime = details.seekOffset || 10;
player.seekBackward(skipTime);
});
navigator.mediaSession.setActionHandler('seekforward', (details) => {
const skipTime = details.seekOffset || 10;
player.seekForward(skipTime);
});
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.fastSeek && 'fastSeek' in audioPlayer) {
audioPlayer.fastSeek(details.seekTime);
} else {
audioPlayer.currentTime = details.seekTime;
}
player.updateMediaSessionPositionState();
});
navigator.mediaSession.setActionHandler('stop', () => {
audioPlayer.pause();
audioPlayer.currentTime = 0;
});
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')

210
js/metadata.js Normal file
View file

@ -0,0 +1,210 @@
export class MetadataEmbedder {
constructor() {
this.ffmpegLoaded = false;
this.ffmpeg = null;
this.fetchFile = null;
}
async loadFFmpeg() {
if (this.ffmpegLoaded) return;
try {
console.log('[FFmpeg] Loading FFmpeg...');
if (typeof FFmpegWASM === 'undefined' || typeof FFmpegUtil === 'undefined') {
throw new Error('FFmpeg libraries not loaded. Please check your internet connection.');
}
const { FFmpeg } = FFmpegWASM;
const { toBlobURL, fetchFile } = FFmpegUtil;
this.ffmpeg = new FFmpeg();
this.fetchFile = fetchFile;
const baseURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd';
this.ffmpeg.on('log', ({ message }) => {
console.log('[FFmpeg]', message);
});
await this.ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
});
this.ffmpegLoaded = true;
console.log('[FFmpeg] Loaded successfully');
} catch (error) {
console.error('[FFmpeg] Failed to load:', error);
throw error;
}
}
async embedMetadata(audioBlob, track, coverImageUrl, onProgress) {
console.log('[Metadata] Starting embedding for:', track.title);
if (!this.ffmpegLoaded) {
try {
await this.loadFFmpeg();
} catch (error) {
console.error('[Metadata] Cannot load FFmpeg, skipping metadata:', error);
return audioBlob;
}
}
if (!this.ffmpeg || !this.fetchFile) {
console.error('[Metadata] FFmpeg not properly initialized');
return audioBlob;
}
const inputName = 'input.flac';
const coverName = 'cover.jpg';
const outputName = 'output.flac';
try {
const arrayBuffer = await audioBlob.arrayBuffer();
await this.ffmpeg.writeFile(inputName, new Uint8Array(arrayBuffer));
console.log('[Metadata] Wrote input file:', inputName, 'size:', arrayBuffer.byteLength);
let hasCover = false;
if (coverImageUrl) {
try {
console.log('[Metadata] Fetching cover from:', coverImageUrl);
const coverData = await this.fetchFile(coverImageUrl);
await this.ffmpeg.writeFile(coverName, coverData);
hasCover = true;
console.log('[Metadata] Cover image written successfully, size:', coverData.length);
} catch (coverError) {
console.warn('[Metadata] Failed to fetch cover image:', coverError);
}
}
const metadata = this.buildMetadataArgs(track);
console.log('[Metadata] Metadata tags:', metadata.length / 2, 'fields');
let args;
if (hasCover) {
args = [
'-i', inputName,
'-i', coverName,
'-map', '0:a',
'-map', '1',
'-c:a', 'copy',
'-c:v', 'copy',
...metadata,
'-metadata:s:v', 'title=Album cover',
'-metadata:s:v', 'comment=Cover (front)',
'-disposition:v', 'attached_pic',
outputName
];
} else {
args = [
'-i', inputName,
...metadata,
'-c:a', 'copy',
outputName
];
}
console.log('[Metadata] Executing FFmpeg with', args.length, 'arguments');
if (onProgress) {
this.ffmpeg.on('progress', ({ progress }) => {
onProgress(progress);
});
}
await this.ffmpeg.exec(args);
console.log('[Metadata] FFmpeg exec completed');
const outputData = await this.ffmpeg.readFile(outputName);
const outputBlob = new Blob([outputData], { type: 'audio/flac' });
console.log('[Metadata] Output blob created - Input:', arrayBuffer.byteLength, 'Output:', outputBlob.size);
await this.ffmpeg.deleteFile(inputName);
await this.ffmpeg.deleteFile(outputName);
if (hasCover) {
await this.ffmpeg.deleteFile(coverName);
}
console.log('[Metadata] Cleanup complete');
return outputBlob;
} catch (error) {
console.error('[Metadata] Embedding failed:', error);
console.error('[Metadata] Error details:', {
name: error.name,
message: error.message,
stack: error.stack
});
return audioBlob;
}
}
buildMetadataArgs(track) {
const args = [];
if (track.title) {
args.push('-metadata', `title=${this.escapeMetadata(track.title)}`);
}
if (track.artist?.name) {
args.push('-metadata', `artist=${this.escapeMetadata(track.artist.name)}`);
}
if (track.album?.title) {
args.push('-metadata', `album=${this.escapeMetadata(track.album.title)}`);
}
if (track.album?.artist?.name) {
args.push('-metadata', `album_artist=${this.escapeMetadata(track.album.artist.name)}`);
}
if (track.trackNumber) {
const trackNum = Number(track.trackNumber);
if (Number.isFinite(trackNum) && trackNum > 0) {
const totalTracks = track.album?.numberOfTracks;
if (totalTracks && Number.isFinite(totalTracks) && totalTracks > 0) {
args.push('-metadata', `track=${trackNum}/${totalTracks}`);
} else {
args.push('-metadata', `track=${trackNum}`);
}
}
}
if (track.volumeNumber) {
const discNum = Number(track.volumeNumber);
if (Number.isFinite(discNum) && discNum > 0) {
const totalDiscs = track.album?.numberOfVolumes;
if (totalDiscs && Number.isFinite(totalDiscs) && totalDiscs > 0) {
args.push('-metadata', `disc=${discNum}/${totalDiscs}`);
} else {
args.push('-metadata', `disc=${discNum}`);
}
}
}
if (track.album?.releaseDate) {
const year = new Date(track.album.releaseDate).getFullYear();
if (!isNaN(year)) {
args.push('-metadata', `date=${year}`);
args.push('-metadata', `year=${year}`);
}
}
if (track.album?.upc) {
args.push('-metadata', `barcode=${track.album.upc}`);
}
if (track.isrc) {
args.push('-metadata', `isrc=${track.isrc}`);
}
args.push('-metadata', 'comment=https://monochrome.tf/');
return args;
}
escapeMetadata(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
}

View file

@ -1,4 +1,4 @@
import { REPEAT_MODE, SVG_PLAY, SVG_PAUSE, formatTime } from './utils.js';
import { REPEAT_MODE, formatTime } from './utils.js';
export class Player {
constructor(audioElement, api, quality = 'LOSSLESS') {
@ -13,6 +13,52 @@ export class Player {
this.repeatMode = REPEAT_MODE.OFF;
this.preloadCache = new Map();
this.preloadAbortController = null;
this.currentTrack = null;
this.setupMediaSession();
}
setupMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => {
this.audio.play().catch(console.error);
});
navigator.mediaSession.setActionHandler('pause', () => {
this.audio.pause();
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
this.playPrev();
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
this.playNext();
});
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
const skipTime = details.seekOffset || 10;
this.seekBackward(skipTime);
});
navigator.mediaSession.setActionHandler('seekforward', (details) => {
const skipTime = details.seekOffset || 10;
this.seekForward(skipTime);
});
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime !== undefined) {
this.audio.currentTime = Math.max(0, details.seekTime);
this.updateMediaSessionPositionState();
}
});
navigator.mediaSession.setActionHandler('stop', () => {
this.audio.pause();
this.audio.currentTime = 0;
this.updateMediaSessionPlaybackState();
});
}
setQuality(quality) {
@ -35,7 +81,7 @@ export class Player {
}
}
for (const { track, index } of tracksToPreload) {
for (const { track } of tracksToPreload) {
if (this.preloadCache.has(track.id)) continue;
try {
@ -45,13 +91,11 @@ export class Player {
fetch(streamUrl, {
signal: this.preloadAbortController.signal,
method: 'GET',
method: 'HEAD',
mode: 'cors',
cache: 'default'
}).then(response => {
if (response.ok) {
this.preloadCache.set(track.id, streamUrl);
}
}).then(() => {
this.preloadCache.set(track.id, streamUrl);
}).catch(err => {
if (err.name !== 'AbortError') {
console.debug('Preload failed for:', track.title);
@ -73,6 +117,7 @@ export class Player {
}
const track = currentQueue[this.currentQueueIndex];
this.currentTrack = track;
document.querySelector('.now-playing-bar .cover').src =
this.api.getCoverUrl(track.album?.cover, '160');
@ -95,13 +140,21 @@ export class Player {
this.audio.src = streamUrl;
await this.audio.play();
this.updateMediaSessionPlaybackState();
this.preloadNextTracks();
} catch (error) {
console.error(`Could not get track URL for: ${track.title}`, error);
console.error(`Could not play track: ${track.title}`, error);
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
document.querySelector('.play-pause-btn').innerHTML = SVG_PLAY;
}
}
playAtIndex(index) {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (index >= 0 && index < currentQueue.length) {
this.currentQueueIndex = index;
this.playTrackFromQueue();
}
}
@ -129,6 +182,7 @@ export class Player {
playPrev() {
if (this.audio.currentTime > 3) {
this.audio.currentTime = 0;
this.updateMediaSessionPositionState();
} else if (this.currentQueueIndex > 0) {
this.currentQueueIndex--;
this.playTrackFromQueue();
@ -137,18 +191,25 @@ export class Player {
handlePlayPause() {
if (!this.audio.src) return;
this.audio.paused ? this.audio.play() : this.audio.pause();
if (this.audio.paused) {
this.audio.play().catch(console.error);
} else {
this.audio.pause();
}
}
seekBackward(seconds = 10) {
const newTime = Math.max(0, this.audio.currentTime - seconds);
this.audio.currentTime = newTime;
this.updateMediaSessionPositionState();
}
seekForward(seconds = 10) {
const duration = this.audio.duration || 0;
const newTime = Math.min(duration, this.audio.currentTime + seconds);
this.audio.currentTime = newTime;
this.updateMediaSessionPositionState();
}
toggleShuffle() {
@ -188,6 +249,11 @@ export class Player {
addToQueue(track) {
this.queue.push(track);
if (!this.currentTrack || this.currentQueueIndex === -1) {
this.currentQueueIndex = this.queue.length - 1;
this.playTrackFromQueue();
}
}
getCurrentQueue() {
@ -208,14 +274,12 @@ export class Player {
const artwork = [];
const sizes = ['96', '128', '192', '256', '384', '512'];
const coverId = track.album?.cover;
if (coverId) {
sizes.forEach(size => {
const url = this.api.getCoverUrl(coverId, size);
artwork.push({
src: url,
src: this.api.getCoverUrl(coverId, size),
sizes: `${size}x${size}`,
type: 'image/jpeg'
});
@ -229,28 +293,33 @@ export class Player {
artwork: artwork.length > 0 ? artwork : undefined
});
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
this.updateMediaSessionPlaybackState();
this.updateMediaSessionPositionState();
}
updateMediaSessionPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
}
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = this.audio.paused ? 'paused' : 'playing';
}
updateMediaSessionPositionState() {
if ('mediaSession' in navigator && 'setPositionState' in navigator.mediaSession) {
if (this.audio.duration && !isNaN(this.audio.duration)) {
try {
navigator.mediaSession.setPositionState({
duration: this.audio.duration,
playbackRate: this.audio.playbackRate,
position: this.audio.currentTime
});
} catch (error) {
console.debug('Failed to update position state:', error);
}
}
if (!('mediaSession' in navigator)) return;
if (!('setPositionState' in navigator.mediaSession)) return;
const duration = this.audio.duration;
if (!duration || isNaN(duration) || !isFinite(duration)) {
return;
}
try {
navigator.mediaSession.setPositionState({
duration: duration,
playbackRate: this.audio.playbackRate || 1,
position: Math.min(this.audio.currentTime, duration)
});
} catch (error) {
console.debug('Failed to update Media Session position:', error);
}
}
}

View file

@ -1392,4 +1392,32 @@ input:checked + .slider:before {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.9rem;
}
}
.btn-download {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-download:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-download:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-download svg {
flex-shrink: 0;
}