commit
b43a41930c
10 changed files with 1177 additions and 380 deletions
23
js/api.js
23
js/api.js
|
|
@ -1,6 +1,7 @@
|
||||||
//js/api.js
|
//js/api.js
|
||||||
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
|
import { RATE_LIMIT_ERROR_MESSAGE, deriveTrackQuality, delay } from './utils.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
|
import { addMetadataToAudio } from './metadata.js';
|
||||||
|
|
||||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||||
|
|
||||||
|
|
@ -585,7 +586,7 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadTrack(id, quality = 'LOSSLESS', filename, options = {}) {
|
async downloadTrack(id, quality = 'LOSSLESS', filename, options = {}) {
|
||||||
const { onProgress } = options;
|
const { onProgress, track } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lookup = await this.getTrack(id, quality);
|
const lookup = await this.getTrack(id, quality);
|
||||||
|
|
@ -613,6 +614,7 @@ export class LosslessAPI {
|
||||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
||||||
|
|
||||||
let receivedBytes = 0;
|
let receivedBytes = 0;
|
||||||
|
let blob;
|
||||||
|
|
||||||
if (response.body && onProgress) {
|
if (response.body && onProgress) {
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
|
@ -634,10 +636,9 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' });
|
blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'audio/flac' });
|
||||||
this.triggerDownload(blob, filename);
|
|
||||||
} else {
|
} else {
|
||||||
const blob = await response.blob();
|
blob = await response.blob();
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
onProgress({
|
onProgress({
|
||||||
stage: 'downloading',
|
stage: 'downloading',
|
||||||
|
|
@ -645,8 +646,20 @@ export class LosslessAPI {
|
||||||
totalBytes: blob.size
|
totalBytes: blob.size
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.triggerDownload(blob, filename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add metadata if track information is provided
|
||||||
|
if (track) {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
stage: 'processing',
|
||||||
|
message: 'Adding metadata...'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
blob = await addMetadataToAudio(blob, track, this, quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.triggerDownload(blob, filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { LastFMScrobbler } from './lastfm.js';
|
||||||
import { LyricsManager, createLyricsPanel, showKaraokeView, showSyncedLyricsPanel, clearLyricsPanelSync } from './lyrics.js';
|
import { LyricsManager, createLyricsPanel, showKaraokeView, showSyncedLyricsPanel, clearLyricsPanelSync } from './lyrics.js';
|
||||||
import { createRouter, updateTabTitle } from './router.js';
|
import { createRouter, updateTabTitle } from './router.js';
|
||||||
import { initializeSettings } from './settings.js';
|
import { initializeSettings } from './settings.js';
|
||||||
import { initializePlayerEvents, initializeTrackInteractions } from './events.js';
|
import { initializePlayerEvents, initializeTrackInteractions, handleTrackAction } from './events.js';
|
||||||
import { initializeUIInteractions } from './ui-interactions.js';
|
import { initializeUIInteractions } from './ui-interactions.js';
|
||||||
import { downloadAlbumAsZip, downloadDiscography, downloadPlaylistAsZip } from './downloads.js';
|
import { downloadAlbumAsZip, downloadDiscography, downloadPlaylistAsZip } from './downloads.js';
|
||||||
import { debounce, SVG_PLAY } from './utils.js';
|
import { debounce, SVG_PLAY } from './utils.js';
|
||||||
|
|
@ -275,7 +275,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
document.getElementById('download-current-btn')?.addEventListener('click', () => {
|
document.getElementById('download-current-btn')?.addEventListener('click', () => {
|
||||||
if (player.currentTrack) {
|
if (player.currentTrack) {
|
||||||
downloadTrackWithMetadata(player.currentTrack, player.quality, api, lyricsManager);
|
handleTrackAction('download', player.currentTrack, player, api, lyricsManager);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
499
js/downloads.js
499
js/downloads.js
|
|
@ -1,10 +1,22 @@
|
||||||
//js/downloads.js
|
//js/downloads.js
|
||||||
import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate, SVG_CLOSE } from './utils.js';
|
import { buildTrackFilename, sanitizeForFilename, RATE_LIMIT_ERROR_MESSAGE, getTrackArtists, getTrackTitle, formatTemplate, SVG_CLOSE, getCoverBlob } from './utils.js';
|
||||||
import { lyricsSettings } from './storage.js';
|
import { lyricsSettings } from './storage.js';
|
||||||
|
import { addMetadataToAudio } from './metadata.js';
|
||||||
|
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
let downloadNotificationContainer = null;
|
let downloadNotificationContainer = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a cover blob to a JSZip instance
|
||||||
|
*/
|
||||||
|
function addCoverBlobToZip(zip, folderPath, blob) {
|
||||||
|
if (!blob) return;
|
||||||
|
const path = folderPath ? `${folderPath}/cover.jpg` : 'cover.jpg';
|
||||||
|
if (!zip.file(path)) {
|
||||||
|
zip.file(path, blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadJSZip() {
|
async function loadJSZip() {
|
||||||
try {
|
try {
|
||||||
const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm');
|
const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm');
|
||||||
|
|
@ -62,7 +74,7 @@ export function addDownloadTask(trackId, track, filename, api) {
|
||||||
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
|
<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 class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem;">Starting...</div>
|
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Starting...</div>
|
||||||
</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;">
|
<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_CLOSE}
|
${SVG_CLOSE}
|
||||||
|
|
@ -148,7 +160,7 @@ function removeDownloadTask(trackId) {
|
||||||
taskEl.remove();
|
taskEl.remove();
|
||||||
downloadTasks.delete(trackId);
|
downloadTasks.delete(trackId);
|
||||||
|
|
||||||
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
|
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
|
||||||
downloadNotificationContainer.remove();
|
downloadNotificationContainer.remove();
|
||||||
downloadNotificationContainer = null;
|
downloadNotificationContainer = null;
|
||||||
}
|
}
|
||||||
|
|
@ -172,215 +184,173 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch track: ${response.status}`);
|
throw new Error(`Failed to fetch track: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
let blob = await response.blob();
|
||||||
|
|
||||||
|
// Add metadata to the blob
|
||||||
|
blob = await addMetadataToAudio(blob, track, api, quality);
|
||||||
|
|
||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTrackMetadata(track, api) {
|
async function generateAndDownloadZip(zip, filename, notification, progressTotal, fileHandle = null) {
|
||||||
const artists = [];
|
updateBulkDownloadProgress(notification, progressTotal, progressTotal, 'Creating ZIP...');
|
||||||
if (Array.isArray(track.artists) && track.artists.length) {
|
|
||||||
for (const a of track.artists) artists.push(a.name || a);
|
|
||||||
} else if (track.artist && track.artist.name) {
|
|
||||||
artists.push(track.artist.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: track.id,
|
|
||||||
title: track.title || null,
|
|
||||||
artists,
|
|
||||||
album: track.album?.title || null,
|
|
||||||
albumArtist: track.album?.artist?.name || track.artist?.name || null,
|
|
||||||
trackNumber: track.trackNumber ?? null,
|
|
||||||
discNumber: track.discNumber ?? null,
|
|
||||||
durationMs: track.duration ?? null,
|
|
||||||
releaseDate: track.album?.releaseDate || null,
|
|
||||||
bitrate: track.audioQuality || null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addCoverToZipIfMissing(zip, folderPath, coverId, api) {
|
|
||||||
if (!coverId) return;
|
|
||||||
|
|
||||||
const coverPath = folderPath ? `${folderPath}/cover.jpg` : 'cover.jpg';
|
|
||||||
if (zip.file(coverPath)) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = api.getCoverUrl(coverId, '1000');
|
// Use the pre-acquired file handle for streaming (Chrome/Edge/Opera)
|
||||||
const resp = await fetch(url);
|
if (fileHandle) {
|
||||||
if (!resp.ok) return;
|
const writable = await fileHandle.createWritable();
|
||||||
const blob = await resp.blob();
|
|
||||||
zip.file(coverPath, blob);
|
await new Promise((resolve, reject) => {
|
||||||
} catch (e) {
|
zip.generateInternalStream({
|
||||||
|
type: 'uint8array',
|
||||||
console.warn('Could not fetch cover for zip:', e);
|
compression: 'STORE',
|
||||||
|
streamFiles: true
|
||||||
|
})
|
||||||
|
.on('data', (chunk, metadata) => {
|
||||||
|
writable.write(chunk);
|
||||||
|
})
|
||||||
|
.on('error', (err) => {
|
||||||
|
writable.close();
|
||||||
|
reject(err);
|
||||||
|
})
|
||||||
|
.on('end', () => {
|
||||||
|
writable.close();
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.resume();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for Firefox/Safari or if user cancelled/API not available
|
||||||
|
const zipBlob = await zip.generateAsync({
|
||||||
|
type: 'blob',
|
||||||
|
compression: 'STORE',
|
||||||
|
streamFiles: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(zipBlob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${filename}.zip`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
completeBulkDownload(notification, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ZIP generation failed:', error);
|
||||||
|
completeBulkDownload(notification, false, 'ZIP creation failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeZipDownload(defaultName, useFilePicker = false) {
|
||||||
|
const JSZip = await loadJSZip();
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
let fileHandle = null;
|
||||||
|
if (useFilePicker && window.showSaveFilePicker) {
|
||||||
|
try {
|
||||||
|
fileHandle = await window.showSaveFilePicker({
|
||||||
|
suggestedName: `${defaultName}.zip`,
|
||||||
|
types: [{
|
||||||
|
description: 'ZIP Archive',
|
||||||
|
accept: { 'application/zip': ['.zip'] }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return null; // User cancelled
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { zip, fileHandle };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification, startProgressIndex = 0, totalTracks = tracks.length) {
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
const track = tracks[i];
|
||||||
|
const currentGlobalIndex = startProgressIndex + i;
|
||||||
|
const filename = buildTrackFilename(track, quality);
|
||||||
|
const trackTitle = getTrackTitle(track);
|
||||||
|
|
||||||
|
updateBulkDownloadProgress(notification, currentGlobalIndex, totalTracks, trackTitle);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await downloadTrackBlob(track, quality, api);
|
||||||
|
zip.file(`${folderName}/${filename}`, blob);
|
||||||
|
|
||||||
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||||
|
try {
|
||||||
|
const lyricsData = await lyricsManager.fetchLyrics(track.id);
|
||||||
|
if (lyricsData) {
|
||||||
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
||||||
|
if (lrcContent) {
|
||||||
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||||
|
zip.file(`${folderName}/${lrcFilename}`, lrcContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Could not add lyrics for:', trackTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to download track ${trackTitle}:`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
|
export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
|
||||||
const JSZip = await loadJSZip();
|
const releaseDateStr = album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||||
const zip = new JSZip();
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||||
|
|
||||||
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
|
|
||||||
const releaseDate = album.releaseDate ? new Date(album.releaseDate) : null;
|
|
||||||
const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
|
const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
|
||||||
|
|
||||||
const folderName = formatTemplate(template, {
|
const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
||||||
albumTitle: album.title,
|
albumTitle: album.title,
|
||||||
albumArtist: album.artist?.name,
|
albumArtist: album.artist?.name,
|
||||||
year: year
|
year: year
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only prompt for save location if we have >= 20 tracks (to capture user gesture early)
|
||||||
|
// Otherwise, we'll auto-download the blob at the end
|
||||||
|
const initResult = await initializeZipDownload(folderName, tracks.length >= 20);
|
||||||
|
if (!initResult) return; // User cancelled
|
||||||
|
const { zip, fileHandle } = initResult;
|
||||||
|
|
||||||
|
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
|
||||||
const notification = createBulkDownloadNotification('album', album.title, tracks.length);
|
const notification = createBulkDownloadNotification('album', album.title, tracks.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
addCoverBlobToZip(zip, folderName, coverBlob);
|
||||||
const albumCoverId = album.cover || album.album?.cover || album.coverId || null;
|
await downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification);
|
||||||
|
await generateAndDownloadZip(zip, folderName, notification, tracks.length, fileHandle);
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
|
||||||
const track = tracks[i];
|
|
||||||
const filename = buildTrackFilename(track, quality);
|
|
||||||
const trackTitle = getTrackTitle(track);
|
|
||||||
|
|
||||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const blob = await downloadTrackBlob(track, quality, api);
|
|
||||||
zip.file(`${folderName}/${filename}`, blob);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const meta = buildTrackMetadata(track, api);
|
|
||||||
const metaFilename = filename.replace(/\.[^.]+$/, '.json');
|
|
||||||
zip.file(`${folderName}/${metaFilename}`, JSON.stringify(meta, null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Could not attach metadata for', trackTitle, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addCoverToZipIfMissing(zip, folderName, albumCoverId || track.album?.cover, api);
|
|
||||||
} catch (e) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
||||||
try {
|
|
||||||
const lyricsData = await lyricsManager.fetchLyrics(track.id);
|
|
||||||
if (lyricsData) {
|
|
||||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
|
||||||
if (lrcContent) {
|
|
||||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
||||||
zip.file(`${folderName}/${lrcFilename}`, lrcContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Could not add lyrics for:', trackTitle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to download track ${trackTitle}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
completeBulkDownload(notification, false, error.message);
|
completeBulkDownload(notification, false, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
||||||
const JSZip = await loadJSZip();
|
const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
|
|
||||||
const folderName = formatTemplate(template, {
|
|
||||||
albumTitle: playlist.title,
|
albumTitle: playlist.title,
|
||||||
albumArtist: 'Playlist',
|
albumArtist: 'Playlist',
|
||||||
year: new Date().getFullYear()
|
year: new Date().getFullYear()
|
||||||
});
|
});
|
||||||
|
|
||||||
const notification = createBulkDownloadNotification('playlist', playlist.title, tracks.length);
|
const initResult = await initializeZipDownload(folderName, tracks.length >= 20);
|
||||||
|
if (!initResult) return; // User cancelled
|
||||||
|
const { zip, fileHandle } = initResult;
|
||||||
|
|
||||||
|
const notification = createBulkDownloadNotification('playlist', playlist.title, tracks.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
// Find a representative cover for the playlist (first track with cover)
|
||||||
const track = tracks[i];
|
const representativeTrack = tracks.find(t => t.album?.cover);
|
||||||
const filename = buildTrackFilename(track, quality);
|
const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
|
||||||
const trackTitle = getTrackTitle(track);
|
addCoverBlobToZip(zip, folderName, coverBlob);
|
||||||
|
|
||||||
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
await downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification);
|
||||||
|
await generateAndDownloadZip(zip, folderName, notification, tracks.length, fileHandle);
|
||||||
try {
|
|
||||||
const blob = await downloadTrackBlob(track, quality, api);
|
|
||||||
zip.file(`${folderName}/${filename}`, blob);
|
|
||||||
|
|
||||||
// add metadata JSON
|
|
||||||
try {
|
|
||||||
const meta = buildTrackMetadata(track, api);
|
|
||||||
const metaFilename = filename.replace(/\.[^.]+$/, '.json');
|
|
||||||
zip.file(`${folderName}/${metaFilename}`, JSON.stringify(meta, null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Could not attach metadata for', trackTitle, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// add cover per track/playlist (attempt once per track)
|
|
||||||
try {
|
|
||||||
await addCoverToZipIfMissing(zip, folderName, track.album?.cover, api);
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
||||||
try {
|
|
||||||
const lyricsData = await lyricsManager.fetchLyrics(track.id);
|
|
||||||
if (lyricsData) {
|
|
||||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
|
||||||
if (lrcContent) {
|
|
||||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
||||||
zip.file(`${folderName}/${lrcFilename}`, lrcContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Could not add lyrics for:', trackTitle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to download track ${trackTitle}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
completeBulkDownload(notification, false, error.message);
|
completeBulkDownload(notification, false, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -388,51 +358,44 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadDiscography(artist, api, quality, lyricsManager = null) {
|
export async function downloadDiscography(artist, api, quality, lyricsManager = null) {
|
||||||
const JSZip = await loadJSZip();
|
const rootFolder = `${sanitizeForFilename(artist.name)} discography`;
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
const template = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
|
// Always use file picker for discography as it's likely large
|
||||||
const rootFolder = `${sanitizeForFilename(artist.name)} discography - monochrome.tf`;
|
const initResult = await initializeZipDownload(rootFolder, true);
|
||||||
|
if (!initResult) return; // User cancelled
|
||||||
|
const { zip, fileHandle } = initResult;
|
||||||
|
|
||||||
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
|
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
|
||||||
const totalAlbums = allReleases.length;
|
const notification = createBulkDownloadNotification('discography', artist.name, allReleases.length);
|
||||||
const notification = createBulkDownloadNotification('discography', artist.name, totalAlbums);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let albumIndex = 0; albumIndex < allReleases.length; albumIndex++) {
|
for (let albumIndex = 0; albumIndex < allReleases.length; albumIndex++) {
|
||||||
const album = allReleases[albumIndex];
|
const album = allReleases[albumIndex];
|
||||||
|
|
||||||
updateBulkDownloadProgress(notification, albumIndex, totalAlbums, album.title);
|
updateBulkDownloadProgress(notification, albumIndex, allReleases.length, album.title);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
|
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
|
||||||
const releaseDate = fullAlbum.releaseDate ? new Date(fullAlbum.releaseDate) : null;
|
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover);
|
||||||
|
|
||||||
|
const releaseDateStr = fullAlbum.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||||
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||||
const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
|
const year = (releaseDate && !isNaN(releaseDate.getTime())) ? releaseDate.getFullYear() : '';
|
||||||
|
|
||||||
const albumFolder = formatTemplate(template, {
|
const albumFolder = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
||||||
albumTitle: fullAlbum.title,
|
albumTitle: fullAlbum.title,
|
||||||
albumArtist: fullAlbum.artist?.name,
|
albumArtist: fullAlbum.artist?.name,
|
||||||
year: year
|
year: year
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
||||||
|
addCoverBlobToZip(zip, fullFolderPath, coverBlob);
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
const filename = buildTrackFilename(track, quality);
|
const filename = buildTrackFilename(track, quality);
|
||||||
|
try {
|
||||||
try {
|
|
||||||
const blob = await downloadTrackBlob(track, quality, api);
|
const blob = await downloadTrackBlob(track, quality, api);
|
||||||
zip.file(`${rootFolder}/${albumFolder}/${filename}`, blob);
|
zip.file(`${fullFolderPath}/${filename}`, blob);
|
||||||
|
|
||||||
try {
|
|
||||||
const meta = buildTrackMetadata(track, api);
|
|
||||||
const metaFilename = filename.replace(/\.[^.]+$/, '.json');
|
|
||||||
zip.file(`${rootFolder}/${albumFolder}/${metaFilename}`, JSON.stringify(meta, null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Could not attach metadata for', track.title, e);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addCoverToZipIfMissing(zip, `${rootFolder}/${albumFolder}`, track.album?.cover || album.cover, api);
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -441,40 +404,24 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
|
||||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
||||||
if (lrcContent) {
|
if (lrcContent) {
|
||||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||||
zip.file(`${rootFolder}/${albumFolder}/${lrcFilename}`, lrcContent);
|
zip.file(`${fullFolderPath}/${lrcFilename}`, lrcContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Could not add lyrics for:', track.title);
|
// Silent fail for lyrics in bulk
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to download track ${track.title} in album ${album.title}:`, err);
|
console.error(`Failed to download track ${track.title}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to download album ${album.title}:`, error);
|
console.error(`Failed to download album ${album.title}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBulkDownloadProgress(notification, totalAlbums, totalAlbums, 'Creating ZIP...');
|
await generateAndDownloadZip(zip, rootFolder, notification, allReleases.length, fileHandle);
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
completeBulkDownload(notification, false, error.message);
|
completeBulkDownload(notification, false, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -487,7 +434,7 @@ function createBulkDownloadNotification(type, name, totalItems) {
|
||||||
const notifEl = document.createElement('div');
|
const notifEl = document.createElement('div');
|
||||||
notifEl.className = 'download-task bulk-download';
|
notifEl.className = 'download-task bulk-download';
|
||||||
|
|
||||||
const typeLabel = type === 'album' ? 'Album' : type === 'playlist' ? 'Playlist' : 'Discography';
|
const typeLabel = type === 'album' ? 'Album' : type === 'playlist' ? 'Playlist' : 'Discography';
|
||||||
|
|
||||||
notifEl.innerHTML = `
|
notifEl.innerHTML = `
|
||||||
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
||||||
|
|
@ -499,7 +446,7 @@ function createBulkDownloadNotification(type, name, totalItems) {
|
||||||
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
|
<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 class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem;">Starting...</div>
|
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Starting...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -562,102 +509,26 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
||||||
api
|
api
|
||||||
);
|
);
|
||||||
|
|
||||||
// Manually fetch the stream so we can include metadata and cover in a ZIP
|
await api.downloadTrack(track.id, quality, filename, {
|
||||||
const lookup = await api.getTrack(track.id, quality);
|
signal: controller.signal,
|
||||||
let streamUrl;
|
track: track,
|
||||||
|
onProgress: (progress) => {
|
||||||
if (lookup.originalTrackUrl) {
|
updateDownloadProgress(track.id, progress);
|
||||||
streamUrl = lookup.originalTrackUrl;
|
|
||||||
} else {
|
|
||||||
streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
|
|
||||||
if (!streamUrl) {
|
|
||||||
throw new Error('Could not resolve stream URL');
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
const resp = await fetch(streamUrl, { signal: controller.signal, cache: 'no-store' });
|
completeDownloadTask(track.id, true);
|
||||||
if (!resp.ok) throw new Error(`Fetch failed: ${resp.status}`);
|
|
||||||
|
|
||||||
const contentLength = resp.headers.get('Content-Length');
|
|
||||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
|
||||||
let receivedBytes = 0;
|
|
||||||
|
|
||||||
const reader = resp.body ? resp.body.getReader() : null;
|
|
||||||
const chunks = [];
|
|
||||||
|
|
||||||
if (reader) {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
if (value) {
|
|
||||||
chunks.push(value);
|
|
||||||
receivedBytes += value.byteLength;
|
|
||||||
updateDownloadProgress(track.id, {
|
|
||||||
stage: 'downloading',
|
|
||||||
receivedBytes,
|
|
||||||
totalBytes: totalBytes || undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const blob = await resp.blob();
|
|
||||||
chunks.push(new Uint8Array(await blob.arrayBuffer()));
|
|
||||||
receivedBytes = chunks.reduce((s, c) => s + c.length, 0);
|
|
||||||
updateDownloadProgress(track.id, { stage: 'downloading', receivedBytes, totalBytes: receivedBytes });
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioBlob = new Blob(chunks, { type: resp.headers.get('Content-Type') || 'audio/flac' });
|
|
||||||
|
|
||||||
// Create ZIP with audio + metadata + cover + lyrics
|
|
||||||
const JSZip = await loadJSZip();
|
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
zip.file(filename, audioBlob);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const meta = buildTrackMetadata(track, api);
|
|
||||||
const metaFilename = filename.replace(/\.[^.]+$/, '.json');
|
|
||||||
const jsonContent = JSON.stringify(meta, null, 2);
|
|
||||||
zip.file(metaFilename, jsonContent);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Could not create metadata for current track', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addCoverToZipIfMissing(zip, '', track.album?.cover, api);
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||||
try {
|
try {
|
||||||
const lyricsData = await lyricsManager.fetchLyrics(track.id);
|
const lyricsData = await lyricsManager.fetchLyrics(track.id);
|
||||||
if (lyricsData) {
|
if (lyricsData) {
|
||||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
lyricsManager.downloadLRC(lyricsData, track);
|
||||||
if (lrcContent) {
|
|
||||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
||||||
zip.file(lrcFilename, lrcContent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Could not download lyrics for track');
|
console.log('Could not download lyrics for track');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDownloadProgress(track.id, { stage: 'downloading', receivedBytes: receivedBytes, totalBytes });
|
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } }, (metadata) => {
|
|
||||||
// metadata.percent available but we already show streaming progress
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(zipBlob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename.replace(/\.[^.]+$/, '') + '.zip';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
completeDownloadTask(track.id, true);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name !== 'AbortError') {
|
if (error.name !== 'AbortError') {
|
||||||
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
|
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
|
||||||
|
|
@ -666,4 +537,4 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
|
||||||
completeDownloadTask(track.id, false, errorMsg);
|
completeDownloadTask(track.id, false, errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
78
js/events.js
78
js/events.js
|
|
@ -1,7 +1,7 @@
|
||||||
//js/events.js
|
//js/events.js
|
||||||
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore, RATE_LIMIT_ERROR_MESSAGE, buildTrackFilename } from './utils.js';
|
import { SVG_PLAY, SVG_PAUSE, SVG_VOLUME, SVG_MUTE, REPEAT_MODE, trackDataStore } from './utils.js';
|
||||||
import { lastFMStorage } from './storage.js';
|
import { lastFMStorage } from './storage.js';
|
||||||
import { addDownloadTask, updateDownloadProgress, completeDownloadTask, showNotification, downloadTrackWithMetadata } from './downloads.js';
|
import { showNotification, downloadTrackWithMetadata } from './downloads.js';
|
||||||
import { lyricsSettings } from './storage.js';
|
import { lyricsSettings } from './storage.js';
|
||||||
import { updateTabTitle } from './router.js';
|
import { updateTabTitle } from './router.js';
|
||||||
|
|
||||||
|
|
@ -290,6 +290,22 @@ function initializeSmoothSliders(audioPlayer, player) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleTrackAction(action, track, player, api, lyricsManager) {
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
if (action === 'add-to-queue') {
|
||||||
|
player.addToQueue(track);
|
||||||
|
renderQueue(player);
|
||||||
|
showNotification(`Added to queue: ${track.title}`);
|
||||||
|
} else if (action === 'play-next') {
|
||||||
|
player.addNextToQueue(track);
|
||||||
|
renderQueue(player);
|
||||||
|
showNotification(`Playing next: ${track.title}`);
|
||||||
|
} else if (action === 'download') {
|
||||||
|
await downloadTrackWithMetadata(track, player.quality, api, lyricsManager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager) {
|
export function initializeTrackInteractions(player, api, mainContent, contextMenu, lyricsManager) {
|
||||||
let contextTrack = null;
|
let contextTrack = null;
|
||||||
|
|
||||||
|
|
@ -300,19 +316,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
const trackItem = actionBtn.closest('.track-item');
|
const trackItem = actionBtn.closest('.track-item');
|
||||||
if (trackItem) {
|
if (trackItem) {
|
||||||
const track = trackDataStore.get(trackItem);
|
const track = trackDataStore.get(trackItem);
|
||||||
const action = actionBtn.dataset.action;
|
handleTrackAction(actionBtn.dataset.action, track, player, api, lyricsManager);
|
||||||
|
|
||||||
if (action === 'add-to-queue' && track) {
|
|
||||||
player.addToQueue(track);
|
|
||||||
renderQueue(player);
|
|
||||||
showNotification(`Added to queue: ${track.title}`);
|
|
||||||
} else if (action === 'play-next' && track) {
|
|
||||||
player.addNextToQueue(track);
|
|
||||||
renderQueue(player);
|
|
||||||
showNotification(`Playing next: ${track.title}`);
|
|
||||||
} else if (action === 'download' && track) {
|
|
||||||
handleDownload(track, player, api);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -367,19 +371,9 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
contextMenu.addEventListener('click', async e => {
|
contextMenu.addEventListener('click', async e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const action = e.target.dataset.action;
|
const action = e.target.dataset.action;
|
||||||
|
if (action && contextTrack) {
|
||||||
if (action === 'play-next' && contextTrack) {
|
await handleTrackAction(action, contextTrack, player, api, lyricsManager);
|
||||||
player.addNextToQueue(contextTrack);
|
|
||||||
renderQueue(player);
|
|
||||||
showNotification(`Playing next: ${contextTrack.title}`);
|
|
||||||
} else if (action === 'add-to-queue' && contextTrack) {
|
|
||||||
player.addToQueue(contextTrack);
|
|
||||||
renderQueue(player);
|
|
||||||
showNotification(`Added to queue: ${contextTrack.title}`);
|
|
||||||
} else if (action === 'download' && contextTrack) {
|
|
||||||
await downloadTrackWithMetadata(contextTrack, player.quality, api, lyricsManager);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
contextMenu.style.display = 'none';
|
contextMenu.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -451,33 +445,3 @@ function positionMenu(menu, x, y, anchorRect = null) {
|
||||||
menu.style.left = `${left}px`;
|
menu.style.left = `${left}px`;
|
||||||
menu.style.visibility = 'visible';
|
menu.style.visibility = 'visible';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload(track, player, api) {
|
|
||||||
const quality = player.quality;
|
|
||||||
const filename = buildTrackFilename(track, quality);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { taskEl, abortController } = addDownloadTask(
|
|
||||||
track.id,
|
|
||||||
track,
|
|
||||||
filename,
|
|
||||||
api
|
|
||||||
);
|
|
||||||
|
|
||||||
await api.downloadTrack(track.id, quality, filename, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
onProgress: (progress) => {
|
|
||||||
updateDownloadProgress(track.id, progress);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
completeDownloadTask(track.id, true);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== 'AbortError') {
|
|
||||||
const errorMsg = error.message === RATE_LIMIT_ERROR_MESSAGE
|
|
||||||
? error.message
|
|
||||||
: 'Download failed. Please try again.';
|
|
||||||
completeDownloadTask(track.id, false, errorMsg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
897
js/metadata.js
Normal file
897
js/metadata.js
Normal file
|
|
@ -0,0 +1,897 @@
|
||||||
|
import { getExtensionForQuality, getCoverBlob } from './utils.js';
|
||||||
|
|
||||||
|
const VENDOR_STRING = 'Monochrome';
|
||||||
|
const DEFAULT_TITLE = 'Unknown Title';
|
||||||
|
const DEFAULT_ARTIST = 'Unknown Artist';
|
||||||
|
const DEFAULT_ALBUM = 'Unknown Album';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds metadata tags to audio files (FLAC or M4A)
|
||||||
|
* @param {Blob} audioBlob - The audio file blob
|
||||||
|
* @param {Object} track - Track metadata
|
||||||
|
* @param {Object} api - API instance for fetching album art
|
||||||
|
* @param {string} quality - Audio quality
|
||||||
|
* @returns {Promise<Blob>} - Audio blob with embedded metadata
|
||||||
|
*/
|
||||||
|
export async function addMetadataToAudio(audioBlob, track, api, quality) {
|
||||||
|
const extension = getExtensionForQuality(quality);
|
||||||
|
|
||||||
|
if (extension === 'flac') {
|
||||||
|
return await addFlacMetadata(audioBlob, track, api);
|
||||||
|
} else if (extension === 'm4a') {
|
||||||
|
return await addM4aMetadata(audioBlob, track, api);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If unsupported format, return original blob
|
||||||
|
return audioBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds Vorbis comment metadata to FLAC files
|
||||||
|
*/
|
||||||
|
async function addFlacMetadata(flacBlob, track, api) {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await flacBlob.arrayBuffer();
|
||||||
|
const dataView = new DataView(arrayBuffer);
|
||||||
|
|
||||||
|
// Verify FLAC signature
|
||||||
|
if (!isFlacFile(dataView)) {
|
||||||
|
console.warn('Not a valid FLAC file, returning original');
|
||||||
|
return flacBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse FLAC structure
|
||||||
|
const blocks = parseFlacBlocks(dataView);
|
||||||
|
|
||||||
|
// Create or update Vorbis comment block
|
||||||
|
const vorbisCommentBlock = createVorbisCommentBlock(track);
|
||||||
|
|
||||||
|
// Fetch album artwork if available
|
||||||
|
let pictureBlock = null;
|
||||||
|
if (track.album?.cover) {
|
||||||
|
try {
|
||||||
|
pictureBlock = await createFlacPictureBlock(track.album.cover, api);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to embed album art:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild FLAC file with new metadata
|
||||||
|
const newFlacData = rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock);
|
||||||
|
|
||||||
|
return new Blob([newFlacData], { type: 'audio/flac' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add FLAC metadata:', error);
|
||||||
|
return flacBlob;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFlacFile(dataView) {
|
||||||
|
// Check for "fLaC" signature at the beginning
|
||||||
|
return dataView.byteLength >= 4 &&
|
||||||
|
dataView.getUint8(0) === 0x66 && // 'f'
|
||||||
|
dataView.getUint8(1) === 0x4C && // 'L'
|
||||||
|
dataView.getUint8(2) === 0x61 && // 'a'
|
||||||
|
dataView.getUint8(3) === 0x43; // 'C'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFlacBlocks(dataView) {
|
||||||
|
const blocks = [];
|
||||||
|
let offset = 4; // Skip "fLaC" signature
|
||||||
|
|
||||||
|
while (offset + 4 <= dataView.byteLength) {
|
||||||
|
const header = dataView.getUint8(offset);
|
||||||
|
const isLast = (header & 0x80) !== 0;
|
||||||
|
const blockType = header & 0x7F;
|
||||||
|
|
||||||
|
const blockSize = (dataView.getUint8(offset + 1) << 16) |
|
||||||
|
(dataView.getUint8(offset + 2) << 8) |
|
||||||
|
dataView.getUint8(offset + 3);
|
||||||
|
|
||||||
|
// Validate block size
|
||||||
|
if (offset + 4 + blockSize > dataView.byteLength) {
|
||||||
|
console.warn('Invalid block size detected, stopping parse');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: blockType,
|
||||||
|
isLast: isLast,
|
||||||
|
size: blockSize,
|
||||||
|
offset: offset + 4,
|
||||||
|
headerOffset: offset
|
||||||
|
});
|
||||||
|
|
||||||
|
offset += 4 + blockSize;
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
// Save the audio data offset
|
||||||
|
blocks.audioDataOffset = offset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVorbisCommentBlock(track) {
|
||||||
|
// Vorbis comment structure
|
||||||
|
const comments = [];
|
||||||
|
|
||||||
|
// Add standard tags
|
||||||
|
if (track.title) {
|
||||||
|
comments.push(['TITLE', track.title]);
|
||||||
|
}
|
||||||
|
if (track.artist?.name) {
|
||||||
|
comments.push(['ARTIST', track.artist.name]);
|
||||||
|
}
|
||||||
|
if (track.album?.title) {
|
||||||
|
comments.push(['ALBUM', track.album.title]);
|
||||||
|
}
|
||||||
|
if (track.album?.artist?.name) {
|
||||||
|
comments.push(['ALBUMARTIST', track.album.artist.name]);
|
||||||
|
}
|
||||||
|
if (track.trackNumber) {
|
||||||
|
comments.push(['TRACKNUMBER', String(track.trackNumber)]);
|
||||||
|
}
|
||||||
|
if (track.album?.numberOfTracks) {
|
||||||
|
comments.push(['TRACKTOTAL', String(track.album.numberOfTracks)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
|
||||||
|
if (releaseDateStr) {
|
||||||
|
try {
|
||||||
|
const year = new Date(releaseDateStr).getFullYear();
|
||||||
|
if (!isNaN(year)) {
|
||||||
|
comments.push(['DATE', String(year)]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Invalid date, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.copyright) {
|
||||||
|
comments.push(['COPYRIGHT', track.copyright]);
|
||||||
|
}
|
||||||
|
if (track.isrc) {
|
||||||
|
comments.push(['ISRC', track.isrc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
const vendor = VENDOR_STRING;
|
||||||
|
const vendorBytes = new TextEncoder().encode(vendor);
|
||||||
|
|
||||||
|
let totalSize = 4 + vendorBytes.length + 4; // vendor length + vendor + comment count
|
||||||
|
|
||||||
|
const encodedComments = comments.map(([key, value]) => {
|
||||||
|
const text = `${key}=${value}`;
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
totalSize += 4 + bytes.length;
|
||||||
|
return bytes;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create buffer
|
||||||
|
const buffer = new ArrayBuffer(totalSize);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const uint8Array = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// Vendor length (little-endian)
|
||||||
|
view.setUint32(offset, vendorBytes.length, true);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Vendor string
|
||||||
|
uint8Array.set(vendorBytes, offset);
|
||||||
|
offset += vendorBytes.length;
|
||||||
|
|
||||||
|
// Comment count (little-endian)
|
||||||
|
view.setUint32(offset, comments.length, true);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
for (const commentBytes of encodedComments) {
|
||||||
|
view.setUint32(offset, commentBytes.length, true);
|
||||||
|
offset += 4;
|
||||||
|
uint8Array.set(commentBytes, offset);
|
||||||
|
offset += commentBytes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFlacPictureBlock(coverId, api) {
|
||||||
|
try {
|
||||||
|
// Fetch album art
|
||||||
|
const imageBlob = await getCoverBlob(api, coverId);
|
||||||
|
if (!imageBlob) {
|
||||||
|
throw new Error('Failed to fetch album art');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
|
||||||
|
|
||||||
|
// Detect MIME type from blob or use default
|
||||||
|
const mimeType = imageBlob.type || 'image/jpeg';
|
||||||
|
const mimeBytes = new TextEncoder().encode(mimeType);
|
||||||
|
const description = '';
|
||||||
|
const descBytes = new TextEncoder().encode(description);
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
const totalSize = 4 + // picture type
|
||||||
|
4 + mimeBytes.length + // mime length + mime
|
||||||
|
4 + descBytes.length + // desc length + desc
|
||||||
|
4 + // width
|
||||||
|
4 + // height
|
||||||
|
4 + // color depth
|
||||||
|
4 + // indexed colors
|
||||||
|
4 + imageBytes.length; // image length + image
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(totalSize);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const uint8Array = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// Picture type (3 = front cover)
|
||||||
|
view.setUint32(offset, 3, false);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// MIME type length
|
||||||
|
view.setUint32(offset, mimeBytes.length, false);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// MIME type
|
||||||
|
uint8Array.set(mimeBytes, offset);
|
||||||
|
offset += mimeBytes.length;
|
||||||
|
|
||||||
|
// Description length
|
||||||
|
view.setUint32(offset, descBytes.length, false);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Description (empty)
|
||||||
|
if (descBytes.length > 0) {
|
||||||
|
uint8Array.set(descBytes, offset);
|
||||||
|
offset += descBytes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width (0 = unknown)
|
||||||
|
view.setUint32(offset, 0, false);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Height (0 = unknown)
|
||||||
|
view.setUint32(offset, 0, false);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Color depth (0 = unknown)
|
||||||
|
view.setUint32(offset, 0, false);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Indexed colors (0 = not indexed)
|
||||||
|
view.setUint32(offset, 0, false);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Image data length
|
||||||
|
view.setUint32(offset, imageBytes.length, false);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Image data
|
||||||
|
uint8Array.set(imageBytes, offset);
|
||||||
|
|
||||||
|
return uint8Array;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create FLAC picture block:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildFlacWithMetadata(dataView, blocks, vorbisCommentBlock, pictureBlock) {
|
||||||
|
const originalArray = new Uint8Array(dataView.buffer);
|
||||||
|
|
||||||
|
// Remove old Vorbis comment and picture blocks
|
||||||
|
const filteredBlocks = blocks.filter(b => b.type !== 4 && b.type !== 6); // 4 = Vorbis, 6 = Picture
|
||||||
|
|
||||||
|
// Calculate new file size
|
||||||
|
let newSize = 4; // "fLaC" signature
|
||||||
|
|
||||||
|
// Add STREAMINFO and other essential blocks
|
||||||
|
for (const block of filteredBlocks) {
|
||||||
|
newSize += 4 + block.size; // header + data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new Vorbis comment block
|
||||||
|
newSize += 4 + vorbisCommentBlock.length;
|
||||||
|
|
||||||
|
// Add picture block if available
|
||||||
|
if (pictureBlock) {
|
||||||
|
newSize += 4 + pictureBlock.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add audio data
|
||||||
|
const audioDataOffset = blocks.audioDataOffset;
|
||||||
|
if (audioDataOffset === undefined) {
|
||||||
|
throw new Error('Invalid FLAC file structure: unable to locate audio data stream');
|
||||||
|
}
|
||||||
|
const audioDataSize = dataView.byteLength - audioDataOffset;
|
||||||
|
newSize += audioDataSize;
|
||||||
|
|
||||||
|
// Build new file
|
||||||
|
const newFile = new Uint8Array(newSize);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// Write "fLaC" signature
|
||||||
|
newFile[offset++] = 0x66; // 'f'
|
||||||
|
newFile[offset++] = 0x4C; // 'L'
|
||||||
|
newFile[offset++] = 0x61; // 'a'
|
||||||
|
newFile[offset++] = 0x43; // 'C'
|
||||||
|
|
||||||
|
// Write existing blocks (except Vorbis and Picture)
|
||||||
|
for (let i = 0; i < filteredBlocks.length; i++) {
|
||||||
|
const block = filteredBlocks[i];
|
||||||
|
const isLast = false; // We'll add more blocks
|
||||||
|
|
||||||
|
// Write block header
|
||||||
|
const header = (isLast ? 0x80 : 0x00) | block.type;
|
||||||
|
newFile[offset++] = header;
|
||||||
|
newFile[offset++] = (block.size >> 16) & 0xFF;
|
||||||
|
newFile[offset++] = (block.size >> 8) & 0xFF;
|
||||||
|
newFile[offset++] = block.size & 0xFF;
|
||||||
|
|
||||||
|
// Write block data
|
||||||
|
newFile.set(originalArray.subarray(block.offset, block.offset + block.size), offset);
|
||||||
|
offset += block.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write new Vorbis comment block
|
||||||
|
const vorbisHeaderOffset = offset;
|
||||||
|
const vorbisHeader = 0x04; // Vorbis comment type
|
||||||
|
newFile[offset++] = vorbisHeader;
|
||||||
|
newFile[offset++] = (vorbisCommentBlock.length >> 16) & 0xFF;
|
||||||
|
newFile[offset++] = (vorbisCommentBlock.length >> 8) & 0xFF;
|
||||||
|
newFile[offset++] = vorbisCommentBlock.length & 0xFF;
|
||||||
|
newFile.set(vorbisCommentBlock, offset);
|
||||||
|
offset += vorbisCommentBlock.length;
|
||||||
|
|
||||||
|
let lastBlockHeaderOffset = vorbisHeaderOffset;
|
||||||
|
|
||||||
|
// Write picture block if available
|
||||||
|
if (pictureBlock) {
|
||||||
|
const pictureHeaderOffset = offset;
|
||||||
|
const pictureHeader = 0x06; // Picture type
|
||||||
|
newFile[offset++] = pictureHeader;
|
||||||
|
newFile[offset++] = (pictureBlock.length >> 16) & 0xFF;
|
||||||
|
newFile[offset++] = (pictureBlock.length >> 8) & 0xFF;
|
||||||
|
newFile[offset++] = pictureBlock.length & 0xFF;
|
||||||
|
newFile.set(pictureBlock, offset);
|
||||||
|
offset += pictureBlock.length;
|
||||||
|
lastBlockHeaderOffset = pictureHeaderOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the last metadata block with the 0x80 flag
|
||||||
|
newFile[lastBlockHeaderOffset] |= 0x80;
|
||||||
|
|
||||||
|
// Write audio data
|
||||||
|
if (audioDataSize > 0) {
|
||||||
|
newFile.set(originalArray.subarray(audioDataOffset, audioDataOffset + audioDataSize), offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds metadata to M4A files using MP4 atoms
|
||||||
|
*/
|
||||||
|
async function addM4aMetadata(m4aBlob, track, api) {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await m4aBlob.arrayBuffer();
|
||||||
|
const dataView = new DataView(arrayBuffer);
|
||||||
|
|
||||||
|
// Parse MP4 atoms
|
||||||
|
const atoms = parseMp4Atoms(dataView);
|
||||||
|
|
||||||
|
// Create metadata atoms
|
||||||
|
const metadataAtoms = createMp4MetadataAtoms(track);
|
||||||
|
|
||||||
|
// Fetch album artwork if available
|
||||||
|
if (track.album?.cover) {
|
||||||
|
try {
|
||||||
|
const imageBlob = await getCoverBlob(api, track.album.cover);
|
||||||
|
if (imageBlob) {
|
||||||
|
const imageBytes = new Uint8Array(await imageBlob.arrayBuffer());
|
||||||
|
metadataAtoms.cover = {
|
||||||
|
type: 'covr',
|
||||||
|
data: imageBytes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to embed album art in M4A:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild MP4 file with metadata
|
||||||
|
const newMp4Data = rebuildMp4WithMetadata(dataView, atoms, metadataAtoms);
|
||||||
|
|
||||||
|
return new Blob([newMp4Data], { type: 'audio/mp4' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add M4A metadata:', error);
|
||||||
|
return m4aBlob;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMp4Atoms(dataView) {
|
||||||
|
const atoms = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset + 8 <= dataView.byteLength) {
|
||||||
|
// MP4 atoms use big-endian byte order
|
||||||
|
let size = dataView.getUint32(offset, false);
|
||||||
|
|
||||||
|
// Handle special size values
|
||||||
|
if (size === 0) {
|
||||||
|
// Size 0 means the atom extends to the end of the file
|
||||||
|
size = dataView.byteLength - offset;
|
||||||
|
} else if (size === 1) {
|
||||||
|
// Size 1 means 64-bit extended size follows (after the type field)
|
||||||
|
if (offset + 16 > dataView.byteLength) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Read 64-bit size from offset+8 (big-endian)
|
||||||
|
const sizeHigh = dataView.getUint32(offset + 8, false);
|
||||||
|
const sizeLow = dataView.getUint32(offset + 12, false);
|
||||||
|
if (sizeHigh !== 0) {
|
||||||
|
console.warn('64-bit MP4 atoms larger than 4GB are not supported - file may be processed incompletely');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
size = sizeLow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size < 8 || offset + size > dataView.byteLength) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = String.fromCharCode(
|
||||||
|
dataView.getUint8(offset + 4),
|
||||||
|
dataView.getUint8(offset + 5),
|
||||||
|
dataView.getUint8(offset + 6),
|
||||||
|
dataView.getUint8(offset + 7)
|
||||||
|
);
|
||||||
|
|
||||||
|
atoms.push({
|
||||||
|
type: type,
|
||||||
|
offset: offset,
|
||||||
|
size: size
|
||||||
|
});
|
||||||
|
|
||||||
|
offset += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
return atoms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMp4MetadataAtoms(track) {
|
||||||
|
// MP4 metadata atoms are more complex than FLAC
|
||||||
|
// We'll create basic iTunes-style metadata
|
||||||
|
|
||||||
|
const tags = {
|
||||||
|
'©nam': track.title || DEFAULT_TITLE,
|
||||||
|
'©ART': track.artist?.name || DEFAULT_ARTIST,
|
||||||
|
'©alb': track.album?.title || DEFAULT_ALBUM,
|
||||||
|
'aART': track.album?.artist?.name || DEFAULT_ARTIST,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (track.trackNumber) {
|
||||||
|
tags['trkn'] = track.trackNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseDateStr = track.album?.releaseDate || (track.streamStartDate ? track.streamStartDate.split('T')[0] : '');
|
||||||
|
if (releaseDateStr) {
|
||||||
|
try {
|
||||||
|
const year = new Date(releaseDateStr).getFullYear();
|
||||||
|
if (!isNaN(year)) {
|
||||||
|
tags['©day'] = String(year);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Invalid date, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tags };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMp4WithMetadata(dataView, atoms, metadataAtoms) {
|
||||||
|
const originalArray = new Uint8Array(dataView.buffer);
|
||||||
|
|
||||||
|
// Find moov atom
|
||||||
|
const moovAtom = atoms.find(a => a.type === 'moov');
|
||||||
|
if (!moovAtom) {
|
||||||
|
console.warn('No moov atom found in M4A file');
|
||||||
|
return originalArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the new metadata block (udta -> meta -> ilst)
|
||||||
|
const newMetadataBytes = createMetadataBlock(metadataAtoms);
|
||||||
|
|
||||||
|
// We need to insert this into the moov atom.
|
||||||
|
// If udta exists, we merge/replace. For simplicity, we'll append/create.
|
||||||
|
// Ideally, we should parse moov children to find udta.
|
||||||
|
|
||||||
|
// 1. Calculate new sizes
|
||||||
|
// New file size = Original size + Metadata block size
|
||||||
|
// Note: If we are replacing existing metadata, this calculation would be different,
|
||||||
|
// but here we are assuming we are adding fresh or appending.
|
||||||
|
// A robust implementation would parse moov children, remove existing udta, and add new one.
|
||||||
|
|
||||||
|
// Let's try to do it right: parse moov children
|
||||||
|
const moovChildren = parseMp4Atoms(new DataView(originalArray.buffer, moovAtom.offset + 8, moovAtom.size - 8));
|
||||||
|
|
||||||
|
// Filter out existing udta to replace it
|
||||||
|
const filteredMoovChildren = moovChildren.filter(a => a.type !== 'udta');
|
||||||
|
|
||||||
|
// Calculate new moov size
|
||||||
|
// Header (8) + Sum of other children sizes + New Metadata Block Size
|
||||||
|
let newMoovSize = 8;
|
||||||
|
for (const child of filteredMoovChildren) {
|
||||||
|
newMoovSize += child.size;
|
||||||
|
}
|
||||||
|
newMoovSize += newMetadataBytes.length;
|
||||||
|
|
||||||
|
const sizeDiff = newMoovSize - moovAtom.size;
|
||||||
|
const newFileSize = originalArray.length + sizeDiff;
|
||||||
|
|
||||||
|
const newFile = new Uint8Array(newFileSize);
|
||||||
|
let offset = 0;
|
||||||
|
let originalOffset = 0;
|
||||||
|
|
||||||
|
// Copy atoms before moov
|
||||||
|
const atomsBeforeMoov = atoms.filter(a => a.offset < moovAtom.offset);
|
||||||
|
for (const atom of atomsBeforeMoov) {
|
||||||
|
newFile.set(originalArray.subarray(atom.offset, atom.offset + atom.size), offset);
|
||||||
|
offset += atom.size;
|
||||||
|
originalOffset += atom.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write new moov atom
|
||||||
|
// Size
|
||||||
|
newFile[offset++] = (newMoovSize >> 24) & 0xFF;
|
||||||
|
newFile[offset++] = (newMoovSize >> 16) & 0xFF;
|
||||||
|
newFile[offset++] = (newMoovSize >> 8) & 0xFF;
|
||||||
|
newFile[offset++] = newMoovSize & 0xFF;
|
||||||
|
|
||||||
|
// Type 'moov'
|
||||||
|
newFile[offset++] = 0x6D;
|
||||||
|
newFile[offset++] = 0x6F;
|
||||||
|
newFile[offset++] = 0x6F;
|
||||||
|
newFile[offset++] = 0x76;
|
||||||
|
|
||||||
|
// Write preserved children of moov
|
||||||
|
for (const child of filteredMoovChildren) {
|
||||||
|
const childStart = moovAtom.offset + 8 + child.offset; // child.offset is relative to moov body start in our parseMp4Atoms helper usage?
|
||||||
|
// Wait, parseMp4Atoms returns absolute offsets usually?
|
||||||
|
// Let's verify parseMp4Atoms usage.
|
||||||
|
// When we passed a slice DataView, the offsets returned by parseMp4Atoms
|
||||||
|
// are relative to the start of that DataView (which is moov body start).
|
||||||
|
|
||||||
|
const absoluteChildStart = moovAtom.offset + 8 + child.offset;
|
||||||
|
newFile.set(originalArray.subarray(absoluteChildStart, absoluteChildStart + child.size), offset);
|
||||||
|
offset += child.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write new metadata block (udta)
|
||||||
|
newFile.set(newMetadataBytes, offset);
|
||||||
|
offset += newMetadataBytes.length;
|
||||||
|
|
||||||
|
// Update originalOffset to skip old moov
|
||||||
|
originalOffset = moovAtom.offset + moovAtom.size;
|
||||||
|
|
||||||
|
// Copy atoms after moov
|
||||||
|
// Adjust offsets in stco/co64 atoms if necessary?
|
||||||
|
// Changing the size of moov (or atoms before mdat) shifts the mdat offsets.
|
||||||
|
// If moov comes before mdat, we MUST update the Chunk Offset Atom (stco or co64).
|
||||||
|
// This is complex.
|
||||||
|
|
||||||
|
// Safe strategy: If moov is AFTER mdat, we don't need to update offsets.
|
||||||
|
// If moov is BEFORE mdat, we need to shift offsets.
|
||||||
|
// Most streaming optimized files have moov before mdat.
|
||||||
|
|
||||||
|
const mdatAtom = atoms.find(a => a.type === 'mdat');
|
||||||
|
const moovBeforeMdat = mdatAtom && moovAtom.offset < mdatAtom.offset;
|
||||||
|
|
||||||
|
if (moovBeforeMdat) {
|
||||||
|
// We need to update stco/co64 atoms inside the copied moov children content in newFile.
|
||||||
|
// This is getting very complicated for a simple "add metadata" feature without a proper library.
|
||||||
|
// However, we can try to find 'stco' or 'co64' in the new buffer we just wrote and offset values.
|
||||||
|
|
||||||
|
// Let's assume we need to shift by sizeDiff.
|
||||||
|
updateChunkOffsets(newFile, offset - newMoovSize, newMoovSize, sizeDiff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy remaining data (mdat etc.)
|
||||||
|
if (originalOffset < originalArray.length) {
|
||||||
|
newFile.set(originalArray.subarray(originalOffset), offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMetadataBlock(metadataAtoms) {
|
||||||
|
const { tags, cover } = metadataAtoms;
|
||||||
|
|
||||||
|
const ilstChildren = [];
|
||||||
|
|
||||||
|
// Text tags
|
||||||
|
for (const [key, value] of Object.entries(tags)) {
|
||||||
|
if (key === 'trkn') {
|
||||||
|
ilstChildren.push(createIntAtom(key, value));
|
||||||
|
} else {
|
||||||
|
ilstChildren.push(createStringAtom(key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover art
|
||||||
|
if (cover) {
|
||||||
|
ilstChildren.push(createCoverAtom(cover.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct ilst atom
|
||||||
|
const ilstSize = 8 + ilstChildren.reduce((acc, buf) => acc + buf.length, 0);
|
||||||
|
const ilst = new Uint8Array(ilstSize);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
writeAtomHeader(ilst, offset, ilstSize, 'ilst');
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
for (const child of ilstChildren) {
|
||||||
|
ilst.set(child, offset);
|
||||||
|
offset += child.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct meta atom (FullAtom, version+flags = 4 bytes)
|
||||||
|
const metaSize = 12 + ilstSize;
|
||||||
|
const meta = new Uint8Array(metaSize);
|
||||||
|
offset = 0;
|
||||||
|
|
||||||
|
writeAtomHeader(meta, offset, metaSize, 'meta');
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
meta[offset++] = 0; // Version
|
||||||
|
meta[offset++] = 0; // Flags
|
||||||
|
meta[offset++] = 0;
|
||||||
|
meta[offset++] = 0;
|
||||||
|
|
||||||
|
meta.set(ilst, offset);
|
||||||
|
|
||||||
|
// Construct hdlr atom (required for meta)
|
||||||
|
// "mdir" subtype, "appl" manufacturer, 0 flags/masks, empty name
|
||||||
|
// hdlr size: 4 (size) + 4 (type) + 4 (ver/flags) + 4 (pre_defined) + 4 (handler_type) + 12 (reserved) + name (string)
|
||||||
|
// Minimal valid hdlr for iTunes metadata:
|
||||||
|
const hdlrContent = new Uint8Array([
|
||||||
|
0, 0, 0, 0, // Version/Flags
|
||||||
|
0, 0, 0, 0, // Pre-defined
|
||||||
|
0x6D, 0x64, 0x69, 0x72, // 'mdir'
|
||||||
|
0x61, 0x70, 0x70, 0x6C, // 'appl'
|
||||||
|
0, 0, 0, 0, // Reserved
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 0 // Name (empty null-term) check spec? usually simple 0 is enough
|
||||||
|
]);
|
||||||
|
const hdlrSize = 8 + hdlrContent.length;
|
||||||
|
const hdlr = new Uint8Array(hdlrSize);
|
||||||
|
writeAtomHeader(hdlr, 0, hdlrSize, 'hdlr');
|
||||||
|
hdlr.set(hdlrContent, 8);
|
||||||
|
|
||||||
|
|
||||||
|
// Construct udta atom
|
||||||
|
// udta contains meta. meta usually should contain hdlr before ilst?
|
||||||
|
// Actually, QuickTime spec says meta contains hdlr then ilst.
|
||||||
|
|
||||||
|
const finalMetaSize = 12 + hdlrSize + ilstSize;
|
||||||
|
const finalMeta = new Uint8Array(finalMetaSize);
|
||||||
|
offset = 0;
|
||||||
|
writeAtomHeader(finalMeta, offset, finalMetaSize, 'meta');
|
||||||
|
offset += 8;
|
||||||
|
finalMeta[offset++] = 0; // Version
|
||||||
|
finalMeta[offset++] = 0; // Flags
|
||||||
|
finalMeta[offset++] = 0;
|
||||||
|
finalMeta[offset++] = 0;
|
||||||
|
|
||||||
|
finalMeta.set(hdlr, offset);
|
||||||
|
offset += hdlrSize;
|
||||||
|
finalMeta.set(ilst, offset);
|
||||||
|
|
||||||
|
const udtaSize = 8 + finalMetaSize;
|
||||||
|
const udta = new Uint8Array(udtaSize);
|
||||||
|
writeAtomHeader(udta, 0, udtaSize, 'udta');
|
||||||
|
udta.set(finalMeta, 8);
|
||||||
|
|
||||||
|
return udta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStringAtom(type, value) {
|
||||||
|
const textBytes = new TextEncoder().encode(value);
|
||||||
|
const dataSize = 16 + textBytes.length; // 8 (data atom header) + 8 (flags/null) + text
|
||||||
|
const atomSize = 8 + dataSize;
|
||||||
|
|
||||||
|
const buf = new Uint8Array(atomSize);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// Wrapper atom (e.g., ©nam)
|
||||||
|
writeAtomHeader(buf, offset, atomSize, type);
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
// Data atom
|
||||||
|
writeAtomHeader(buf, offset, dataSize, 'data');
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
// Data Type (1 = UTF-8 text) + Locale (0)
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 1; // Type 1
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
|
||||||
|
buf.set(textBytes, offset);
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIntAtom(type, value) {
|
||||||
|
// trkn is special: data is 8 bytes.
|
||||||
|
// reserved(2) + track(2) + total(2) + reserved(2)
|
||||||
|
const dataSize = 16 + 8;
|
||||||
|
const atomSize = 8 + dataSize;
|
||||||
|
|
||||||
|
const buf = new Uint8Array(atomSize);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
writeAtomHeader(buf, offset, atomSize, type);
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
writeAtomHeader(buf, offset, dataSize, 'data');
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
// Data Type (0 = implicit/int) + Locale
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0; // Type 0
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
|
||||||
|
// Track data
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
// Track num
|
||||||
|
const trk = parseInt(value) || 0;
|
||||||
|
buf[offset++] = (trk >> 8) & 0xFF;
|
||||||
|
buf[offset++] = trk & 0xFF;
|
||||||
|
// Total (0 for now)
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCoverAtom(imageBytes) {
|
||||||
|
const dataSize = 16 + imageBytes.length;
|
||||||
|
const atomSize = 8 + dataSize;
|
||||||
|
|
||||||
|
const buf = new Uint8Array(atomSize);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
writeAtomHeader(buf, offset, atomSize, 'covr');
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
writeAtomHeader(buf, offset, dataSize, 'data');
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
// Data Type (13 = JPEG, 14 = PNG)
|
||||||
|
// We try to detect or default to JPEG (13)
|
||||||
|
let type = 13;
|
||||||
|
if (imageBytes[0] === 0x89 && imageBytes[1] === 0x50) { // PNG signature
|
||||||
|
type = 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = type;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
buf[offset++] = 0;
|
||||||
|
|
||||||
|
buf.set(imageBytes, offset);
|
||||||
|
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAtomHeader(buf, offset, size, type) {
|
||||||
|
buf[offset++] = (size >> 24) & 0xFF;
|
||||||
|
buf[offset++] = (size >> 16) & 0xFF;
|
||||||
|
buf[offset++] = (size >> 8) & 0xFF;
|
||||||
|
buf[offset++] = size & 0xFF;
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
buf[offset++] = type.charCodeAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChunkOffsets(buffer, moovOffset, moovSize, shift) {
|
||||||
|
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||||
|
|
||||||
|
// Scan moov for stco/co64
|
||||||
|
// This is a naive recursive search restricted to the known moov range
|
||||||
|
|
||||||
|
// We parse atoms starting from moov content
|
||||||
|
let offset = moovOffset + 8; // Skip moov header
|
||||||
|
const end = moovOffset + moovSize;
|
||||||
|
|
||||||
|
findAndShiftOffsets(view, offset, end, shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAndShiftOffsets(view, start, end, shift) {
|
||||||
|
let offset = start;
|
||||||
|
|
||||||
|
while (offset + 8 <= end) {
|
||||||
|
const size = view.getUint32(offset, false);
|
||||||
|
const type = String.fromCharCode(
|
||||||
|
view.getUint8(offset + 4),
|
||||||
|
view.getUint8(offset + 5),
|
||||||
|
view.getUint8(offset + 6),
|
||||||
|
view.getUint8(offset + 7)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (size < 8) break;
|
||||||
|
|
||||||
|
if (type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') {
|
||||||
|
// Container atoms, recurse
|
||||||
|
findAndShiftOffsets(view, offset + 8, offset + size, shift);
|
||||||
|
} else if (type === 'stco') {
|
||||||
|
// Chunk Offset Atom (32-bit)
|
||||||
|
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 4)
|
||||||
|
const count = view.getUint32(offset + 12, false);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const entryOffset = offset + 16 + (i * 4);
|
||||||
|
const oldVal = view.getUint32(entryOffset, false);
|
||||||
|
view.setUint32(entryOffset, oldVal + shift, false);
|
||||||
|
}
|
||||||
|
} else if (type === 'co64') {
|
||||||
|
// Chunk Offset Atom (64-bit)
|
||||||
|
// Header (8) + Version(1) + Flags(3) + Count(4) + Entries(Count * 8)
|
||||||
|
const count = view.getUint32(offset + 12, false);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const entryOffset = offset + 16 + (i * 8);
|
||||||
|
// Read 64-bit int
|
||||||
|
const oldHigh = view.getUint32(entryOffset, false);
|
||||||
|
const oldLow = view.getUint32(entryOffset + 4, false);
|
||||||
|
|
||||||
|
// Add shift (assuming shift is small enough not to overflow low 32 in a way that affects high simply?)
|
||||||
|
// Shift is Javascript number (double), up to 9007199254740991.
|
||||||
|
// 32-bit uint max is 4294967295.
|
||||||
|
|
||||||
|
// Proper 64-bit addition
|
||||||
|
// Construct BigInt
|
||||||
|
// Note: BigInt might not be available in all older environments, but modern browsers support it.
|
||||||
|
// Fallback: simpler logic
|
||||||
|
|
||||||
|
let newLow = oldLow + shift;
|
||||||
|
let carry = 0;
|
||||||
|
if (newLow > 0xFFFFFFFF) {
|
||||||
|
carry = Math.floor(newLow / 0x100000000);
|
||||||
|
newLow = newLow >>> 0;
|
||||||
|
}
|
||||||
|
const newHigh = oldHigh + carry;
|
||||||
|
|
||||||
|
view.setUint32(entryOffset, newHigh, false);
|
||||||
|
view.setUint32(entryOffset + 4, newLow, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -215,7 +215,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
// ZIP folder template
|
// ZIP folder template
|
||||||
const zipFolderTemplate = document.getElementById('zip-folder-template');
|
const zipFolderTemplate = document.getElementById('zip-folder-template');
|
||||||
if (zipFolderTemplate) {
|
if (zipFolderTemplate) {
|
||||||
zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist} - monochrome.tf';
|
zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}';
|
||||||
zipFolderTemplate.addEventListener('change', (e) => {
|
zipFolderTemplate.addEventListener('change', (e) => {
|
||||||
localStorage.setItem('zip-folder-template', e.target.value);
|
localStorage.setItem('zip-folder-template', e.target.value);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1
js/ui.js
1
js/ui.js
|
|
@ -836,5 +836,4 @@ export class UIRenderer {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
js/utils.js
49
js/utils.js
|
|
@ -202,3 +202,52 @@ export const formatDuration = (seconds) => {
|
||||||
}
|
}
|
||||||
return `${minutes} min`;
|
return `${minutes} min`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coverCache = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and caches cover art as a Blob
|
||||||
|
*/
|
||||||
|
export async function getCoverBlob(api, coverId) {
|
||||||
|
if (!coverId) return null;
|
||||||
|
if (coverCache.has(coverId)) return coverCache.get(coverId);
|
||||||
|
|
||||||
|
const fetchWithProxy = async (url) => {
|
||||||
|
try {
|
||||||
|
const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(url)}`;
|
||||||
|
const response = await fetch(proxyUrl);
|
||||||
|
if (response.ok) return await response.blob();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Proxy fetch failed:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = api.getCoverUrl(coverId, '1280');
|
||||||
|
// Try direct fetch first
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
coverCache.set(coverId, blob);
|
||||||
|
return blob;
|
||||||
|
} else {
|
||||||
|
// If direct fetch fails (e.g. 404 from SW due to CORS), try proxy
|
||||||
|
const blob = await fetchWithProxy(url);
|
||||||
|
if (blob) {
|
||||||
|
coverCache.set(coverId, blob);
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Network error (CORS rejection not handled by SW), try proxy
|
||||||
|
const url = api.getCoverUrl(coverId, '1280');
|
||||||
|
const blob = await fetchWithProxy(url);
|
||||||
|
if (blob) {
|
||||||
|
coverCache.set(coverId, blob);
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1918,6 +1918,7 @@ input:checked + .slider::before {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
animation: slideIn 0.3s ease;
|
animation: slideIn 0.3s ease;
|
||||||
|
min-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-cancel:hover {
|
.download-cancel:hover {
|
||||||
|
|
|
||||||
3
sw.js
3
sw.js
|
|
@ -45,6 +45,9 @@ self.addEventListener('fetch', event => {
|
||||||
cache.put(event.request, networkResponse.clone());
|
cache.put(event.request, networkResponse.clone());
|
||||||
}
|
}
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
|
}).catch(() => {
|
||||||
|
// If fetch fails (e.g. CORS), return null/error so client handles it
|
||||||
|
return new Response(null, { status: 404, statusText: 'Image fetch failed' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue