PLEASE work
This commit is contained in:
parent
c588ac630a
commit
07345867b9
6 changed files with 1160 additions and 256 deletions
61
index.html
61
index.html
|
|
@ -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
383
js/api.js
|
|
@ -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
607
js/app.js
|
|
@ -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
210
js/metadata.js
Normal 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, '\\"');
|
||||
}
|
||||
}
|
||||
127
js/player.js
127
js/player.js
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
styles.css
28
styles.css
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue