From 8d6916f20302f5305c62f77c0ad0c90a98a2a427 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:23:14 -0400 Subject: [PATCH] fix icon for clip drag --- package.json | 5 + resources/drag/audio-clip.png | Bin 0 -> 312 bytes resources/drag/audio-clip@2x.png | Bin 0 -> 504 bytes .../build/generate-audio-clip-drag-icons.mjs | 236 ++++++++++++++++++ src/main/index.ts | 27 +- test/renderer-helpers.test.ts | 30 +++ 6 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 resources/drag/audio-clip.png create mode 100644 resources/drag/audio-clip@2x.png create mode 100644 scripts/build/generate-audio-clip-drag-icons.mjs diff --git a/package.json b/package.json index 746a942..be920d7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit", "build:icons": "node scripts/build/generate-prism-icons.mjs", + "build:clip-drag-icons": "node scripts/build/generate-audio-clip-drag-icons.mjs", "test": "node scripts/run-tests.mjs", "test:audio-router": "node scripts/run-audio-router-tests.mjs", "test:audio-clips": "node scripts/run-audio-clip-tests.mjs", @@ -107,6 +108,10 @@ "from": "resources/icon.png", "to": "icon.png" }, + { + "from": "resources/drag/", + "to": "drag/" + }, { "from": "resources/tray/", "to": "tray/" diff --git a/resources/drag/audio-clip.png b/resources/drag/audio-clip.png new file mode 100644 index 0000000000000000000000000000000000000000..57f4930979b07357d929cd4af06ca4e91d087fba GIT binary patch literal 312 zcmV-80muG{P)zT;>AZ`iq?AinkZC3PJEG7Mxnr+_$7*x1VP z|Lv!*spklg??FBz)&U^w7!XZ8N08$HDQ$D=If7gVP|XpPIDjgSAjbjN5+*En42T0j z7-R;>tl{qfsrcIe0!GfXcL2x|kY(uN21oz@m(A{`y#qj&fGisn4p8aY4#qTh0JpRn z*l-vPbAZ~6qhO5e08kWx*s%0K6$c3027(O*(S#g;taj7^!^Z&wo(Km#8xE?1Xuyl& z0k4ecS{;L0Gmakq6k%wK=swvU6Dr{bavP=rAk7rwg3*pHJ^%n6hk)LPa6b|N0000< KMNUMnLSTX~=6mx1 literal 0 HcmV?d00001 diff --git a/resources/drag/audio-clip@2x.png b/resources/drag/audio-clip@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..e01da4679b4dba20a4d047a7dd65efb2b24aa9fd GIT binary patch literal 504 zcmVFC&qY6%_<4YcVyM#ek z1A&?=TwcFn>EDukwvJrGMLEIcQ+niod6MnO>(D#W(NH<0^R^;H* z2*GnG6M+MOGQYm59$((%A#edqw${{Arz;PE6JTnkBM*TaKpcV%08t3G0K_2J1Q3D1 z1<;sTju5g!Katjs6Sx5K9x*xt@Ed|t02Jy0000000BQp;*S}Qlfr=iT1MoMx(ct6I z>skQ(bygc&J3j#Eb>#s}X0?_8CWCScfL6ll{V+lCqpcXdPw#3&I&-;J3KD*egTslonDOpN$QLPAY{5*Bu}0000>> 1) + : value >>> 1 + } + table[i] = value >>> 0 + } + return table +} + +const crc32Table = makeCrc32Table() + +function crc32(buffer) { + let crc = 0xffffffff + for (const byte of buffer) { + crc = crc32Table[(crc ^ byte) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function pngChunk(type, data) { + const typeBuffer = Buffer.from(type, 'ascii') + const lengthBuffer = Buffer.alloc(4) + lengthBuffer.writeUInt32BE(data.length, 0) + const crcBuffer = Buffer.alloc(4) + crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0) + return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer]) +} + +function writePng(filePath, width, height, pixels) { + const stride = width * 4 + const raw = Buffer.alloc((stride + 1) * height) + + for (let y = 0; y < height; y += 1) { + const rowOffset = y * (stride + 1) + raw[rowOffset] = 0 + pixels.copy(raw, rowOffset + 1, y * stride, (y + 1) * stride) + } + + const header = Buffer.alloc(13) + header.writeUInt32BE(width, 0) + header.writeUInt32BE(height, 4) + header.writeUInt8(8, 8) + header.writeUInt8(6, 9) + + writeFileSync(filePath, Buffer.concat([ + pngSignature, + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(raw, { level: 9 })), + pngChunk('IEND', Buffer.alloc(0)), + ])) +} + +function pointInPolygon(x, y, points) { + let inside = false + for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { + const [xi, yi] = points[i] + const [xj, yj] = points[j] + if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi) / (yj - yi)) + xi) { + inside = !inside + } + } + return inside +} + +function setPixel(pixels, width, x, y, color) { + const offset = ((y * width) + x) * 4 + pixels[offset] = color[0] + pixels[offset + 1] = color[1] + pixels[offset + 2] = color[2] + pixels[offset + 3] = color[3] +} + +function fillPolygon(pixels, width, height, points, color, scale) { + const scaledPoints = points.map(([x, y]) => [x * scale, y * scale]) + const xs = scaledPoints.map(([x]) => x) + const ys = scaledPoints.map(([, y]) => y) + const minX = Math.max(0, Math.floor(Math.min(...xs))) + const maxX = Math.min(width - 1, Math.ceil(Math.max(...xs))) + const minY = Math.max(0, Math.floor(Math.min(...ys))) + const maxY = Math.min(height - 1, Math.ceil(Math.max(...ys))) + + for (let y = minY; y <= maxY; y += 1) { + for (let x = minX; x <= maxX; x += 1) { + if (pointInPolygon(x + 0.5, y + 0.5, scaledPoints)) { + setPixel(pixels, width, x, y, color) + } + } + } +} + +function fillRoundedRect(pixels, width, height, rect, color, scale) { + const left = (rect.x - (rect.width / 2)) * scale + const top = rect.y * scale + const rectWidth = rect.width * scale + const rectHeight = rect.height * scale + const radius = Math.min(rectWidth / 2, rectHeight / 2) + const right = left + rectWidth + const bottom = top + rectHeight + + for (let y = Math.max(0, Math.floor(top)); y < Math.min(height, Math.ceil(bottom)); y += 1) { + for (let x = Math.max(0, Math.floor(left)); x < Math.min(width, Math.ceil(right)); x += 1) { + const sampleX = x + 0.5 + const sampleY = y + 0.5 + const nearestX = Math.max(left + radius, Math.min(right - radius, sampleX)) + const nearestY = Math.max(top + radius, Math.min(bottom - radius, sampleY)) + const dx = sampleX - nearestX + const dy = sampleY - nearestY + if ((dx * dx) + (dy * dy) <= radius * radius) { + setPixel(pixels, width, x, y, color) + } + } + } +} + +function downsample(source, sourceSize, targetSize) { + const ratio = sourceSize / targetSize + const target = Buffer.alloc(targetSize * targetSize * 4) + + for (let y = 0; y < targetSize; y += 1) { + for (let x = 0; x < targetSize; x += 1) { + let alpha = 0 + let red = 0 + let green = 0 + let blue = 0 + + for (let sampleY = 0; sampleY < ratio; sampleY += 1) { + for (let sampleX = 0; sampleX < ratio; sampleX += 1) { + const sourceOffset = ((((y * ratio) + sampleY) * sourceSize) + (x * ratio) + sampleX) * 4 + const sampleAlpha = source[sourceOffset + 3] + alpha += sampleAlpha + red += source[sourceOffset] * sampleAlpha + green += source[sourceOffset + 1] * sampleAlpha + blue += source[sourceOffset + 2] * sampleAlpha + } + } + + const targetOffset = ((y * targetSize) + x) * 4 + const sampleCount = ratio * ratio + target[targetOffset + 3] = Math.round(alpha / sampleCount) + if (alpha > 0) { + target[targetOffset] = Math.round(red / alpha) + target[targetOffset + 1] = Math.round(green / alpha) + target[targetOffset + 2] = Math.round(blue / alpha) + } + } + } + + return target +} + +function renderIcon(size) { + const sourceSize = size * supersample + const scale = sourceSize / logicalSize + const source = Buffer.alloc(sourceSize * sourceSize * 4) + + fillPolygon(source, sourceSize, sourceSize, documentOutline, colors.outline, scale) + fillPolygon(source, sourceSize, sourceSize, documentPage, colors.page, scale) + fillPolygon(source, sourceSize, sourceSize, documentFold, colors.fold, scale) + for (const bar of waveformBars) { + fillRoundedRect(source, sourceSize, sourceSize, { + ...bar, + width: 1.7, + }, colors.waveform, scale) + } + + return downsample(source, sourceSize, size) +} + +mkdirSync(outputDir, { recursive: true }) +for (const [filename, size] of [['audio-clip.png', 32], ['audio-clip@2x.png', 64]]) { + writePng(join(outputDir, filename), size, size, renderIcon(size)) +} diff --git a/src/main/index.ts b/src/main/index.ts index 5f2e865..8308df4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -153,6 +153,8 @@ const NOW_PLAYING_CONFIG_DEFAULTS = { } const STATIC_APP_ICON_FILENAME = 'icon.png' +const AUDIO_CLIP_DRAG_ICON_FILENAME = 'audio-clip.png' +const AUDIO_CLIP_DRAG_ICON_SIZE = 32 const DESKTOP_INTEGRATION_PREFS_FILENAME = 'desktop-integration.json' const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180 const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64 @@ -377,11 +379,32 @@ function getStaticWindowIconOptions(): Pick existsSync(candidate)) +} + function getAudioClipDragIcon() { - const iconPath = getStaticAppIconPath() + const iconPath = getAudioClipDragIconPath() if (iconPath) { const icon = nativeImage.createFromPath(iconPath) - if (!icon.isEmpty()) return icon + if (!icon.isEmpty()) { + const size = icon.getSize() + if (size.width > AUDIO_CLIP_DRAG_ICON_SIZE || size.height > AUDIO_CLIP_DRAG_ICON_SIZE) { + return icon.resize({ + width: AUDIO_CLIP_DRAG_ICON_SIZE, + height: AUDIO_CLIP_DRAG_ICON_SIZE, + quality: 'best', + }) + } + return icon + } } const fallback = nativeImage.createFromDataURL( diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index bad6deb..b6eadc8 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -4492,6 +4492,36 @@ test('rolling capture exposes persisted duration controls and a native toolbar d assert.match(trayBridgeSource, /rollingCaptureSeconds,/) }) +test('rolling capture uses a dedicated bounded audio-file drag preview', async () => { + const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8') + const packageSource = await readFile(join(process.cwd(), 'package.json'), 'utf8') + const icon1x = await readFile(join(process.cwd(), 'resources', 'drag', 'audio-clip.png')) + const icon2x = await readFile(join(process.cwd(), 'resources', 'drag', 'audio-clip@2x.png')) + const packageJson = JSON.parse(packageSource) as { + build: { + extraResources: Array<{ from: string; to: string }> + } + } + + const pngDimensions = (buffer: Buffer): [number, number] => { + assert.deepEqual([...buffer.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]) + return [buffer.readUInt32BE(16), buffer.readUInt32BE(20)] + } + + assert.deepEqual(pngDimensions(icon1x), [32, 32]) + assert.deepEqual(pngDimensions(icon2x), [64, 64]) + assert.match(mainSource, /const AUDIO_CLIP_DRAG_ICON_FILENAME = 'audio-clip\.png'/) + assert.match(mainSource, /const AUDIO_CLIP_DRAG_ICON_SIZE = 32/) + assert.match(mainSource, /function getAudioClipDragIconPath\(\)/) + assert.match(mainSource, /const iconPath = getAudioClipDragIconPath\(\)/) + assert.match(mainSource, /icon\.resize\(\{[\s\S]*width: AUDIO_CLIP_DRAG_ICON_SIZE,[\s\S]*height: AUDIO_CLIP_DRAG_ICON_SIZE/) + assert.doesNotMatch(mainSource, /function getAudioClipDragIcon\(\) \{\s*const iconPath = getStaticAppIconPath\(\)/) + assert.match(mainSource, /event\.sender\.startDrag\(\{\s*file: filePath,\s*icon: getAudioClipDragIcon\(\),\s*\}\)/) + assert.ok(packageJson.build.extraResources.some((entry) => { + return entry.from === 'resources/drag/' && entry.to === 'drag/' + })) +}) + test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => { assert.deepEqual( resolveWindowCapabilities({