From aacb427d1bcbb6ee10de14851141b902aa2f0dc8 Mon Sep 17 00:00:00 2001 From: Armin Date: Wed, 8 Jul 2026 17:52:35 +0200 Subject: [PATCH] Parse embedded cover art from ID3v2 APIC frames and FLAC picture blocks --- index.html | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index e94c2d1..71b2af7 100644 --- a/index.html +++ b/index.html @@ -21,8 +21,11 @@ footer { display: flex !important; align-items: center; gap: 16px; padding: 10px footer .brand { margin-left: auto; } .content { flex: 1; overflow: hidden; display: flex; flex-direction: column; } .pinned { flex-shrink: 0; background: linear-gradient(to bottom, #1e2132, #161821); padding: 16px 24px; display: flex; flex-direction: row; gap: 20px; align-items: center; } -.player { display: flex; flex-direction: row; width: 100%; align-items: center; gap: 24px; } +.player { display: flex; flex-direction: row; width: 100%; align-items: center; } canvas { width: 100%; height: 100px; border-radius: 6px; display: block; } +.cover-art { width: 120px; height: 120px; flex-shrink: 0; border-radius: 8px; overflow: hidden; background: #272c42; display: flex; align-items: center; justify-content: center; margin-right: 24px; } +.cover-art img { width: 100%; height: 100%; object-fit: cover; display: block; } +.cover-art .no-cover { font-size: 32px; color: #6b7089; user-select: none; } .player-right { display: flex; flex-direction: column; width: 320px; flex-shrink: 0; align-items: center; } .audio-info { font-size: 11px; color: #6b7089; font-variant-numeric: tabular-nums; min-height: 1.4em; } .player .track-info { margin-bottom: 4px; } @@ -831,6 +834,23 @@ async function readId3v2(file) { if (frameSize < 1 || frameSize > 10000000) { pos += 10; continue; } const fData = tagData.slice(pos + 10, pos + 10 + frameSize); if (fData.length < 2) { pos += frameSize + 10; continue; } + if (frameId === 'APIC') { + let off = 1; + while (off < fData.length && fData[off] !== 0) off++; + const mime = new TextDecoder('iso-8859-1').decode(fData.slice(1, off)); + off++; + off++; + if (fData[0] === 0) { + while (off < fData.length && fData[off] !== 0) off++; + off++; + } else { + while (off + 1 < fData.length && (fData[off] !== 0 || fData[off + 1] !== 0)) off += 2; + off += 2; + } + tags.picture = URL.createObjectURL(new Blob([fData.slice(off)], { type: mime || 'image/jpeg' })); + pos += frameSize + 10; + continue; + } const enc = fData[0]; const text = decodeText(fData, 1, fData.length - 1, enc); if (!text) { pos += frameSize + 10; continue; } @@ -875,25 +895,43 @@ async function readVorbisComments(file) { if (data[0] !== 0x66 || data[1] !== 0x4C || data[2] !== 0x61 || data[3] !== 0x43) return null; let pos = 4; let commentData = null; + let pictureData = null; + let pictureMime = ''; while (pos + 4 <= data.length) { const isLast = data[pos] & 0x80; const blockType = data[pos] & 0x7f; const blockLen = (data[pos + 1] << 16) | (data[pos + 2] << 8) | data[pos + 3]; pos += 4; if (pos + blockLen > data.length) break; - if (blockType === 4) { commentData = data.slice(pos, pos + blockLen); break; } + if (blockType === 4) { commentData = data.slice(pos, pos + blockLen); } + if (blockType === 6) { + let p = pos + 4; + const mimeLen = (data[p] << 24) | (data[p+1] << 16) | (data[p+2] << 8) | data[p+3]; + p += 4; + pictureMime = new TextDecoder('iso-8859-1').decode(data.slice(p, p + mimeLen)); + p += mimeLen; + const descLen = (data[p] << 24) | (data[p+1] << 16) | (data[p+2] << 8) | data[p+3]; + p += 4 + descLen + 16; + const picLen = (data[p] << 24) | (data[p+1] << 16) | (data[p+2] << 8) | data[p+3]; + p += 4; + pictureData = data.slice(p, p + picLen); + } pos += blockLen; if (isLast) break; } - if (!commentData) return null; + if (!commentData && !pictureData) return null; let off = 0; - if (off + 4 > commentData.length) return null; + const tags = {}; + if (pictureData) { + tags.picture = URL.createObjectURL(new Blob([pictureData], { type: pictureMime || 'image/jpeg' })); + } + if (!commentData) return tags; + if (off + 4 > commentData.length) return tags; const vendorLen = commentData[off] | (commentData[off + 1] << 8) | (commentData[off + 2] << 16) | (commentData[off + 3] << 24); off += 4 + vendorLen; if (off + 4 > commentData.length) return null; const numTags = commentData[off] | (commentData[off + 1] << 8) | (commentData[off + 2] << 16) | (commentData[off + 3] << 24); off += 4; - const tags = {}; for (let i = 0; i < numTags; i++) { if (off + 4 > commentData.length) break; const tagLen = commentData[off] | (commentData[off + 1] << 8) | (commentData[off + 2] << 16) | (commentData[off + 3] << 24); @@ -1054,6 +1092,7 @@ async function processFiles(files, paths) { composer: tags.composer || '', urlTag: tags.urlTag || '', encodedBy: tags.encodedBy || '', + picture: tags.picture || '', filename: file.name, url, file,