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 0000000..57f4930 Binary files /dev/null and b/resources/drag/audio-clip.png differ diff --git a/resources/drag/audio-clip@2x.png b/resources/drag/audio-clip@2x.png new file mode 100644 index 0000000..e01da46 Binary files /dev/null and b/resources/drag/audio-clip@2x.png differ diff --git a/scripts/build/generate-audio-clip-drag-icons.mjs b/scripts/build/generate-audio-clip-drag-icons.mjs new file mode 100644 index 0000000..404f184 --- /dev/null +++ b/scripts/build/generate-audio-clip-drag-icons.mjs @@ -0,0 +1,236 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { deflateSync } from 'node:zlib' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(scriptDir, '../..') +const outputDir = join(repoRoot, 'resources', 'drag') +const logicalSize = 32 +const supersample = 4 +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) + +const colors = { + outline: [232, 241, 247, 255], + page: [9, 16, 23, 255], + fold: [26, 43, 55, 255], + waveform: [48, 197, 255, 255], +} + +const documentOutline = [ + [8.25, 2], + [18.75, 2], + [26, 9.25], + [26, 27], + [25.7, 28.25], + [24.7, 29.5], + [8.25, 29.5], + [7, 29.2], + [6, 28.2], + [6, 4.75], + [6.3, 3.5], + [7.05, 2.55], +] + +const documentPage = [ + [8.4, 3.7], + [18.05, 3.7], + [24.3, 9.95], + [24.3, 27.1], + [23.75, 27.8], + [8.25, 27.8], + [7.7, 27.15], + [7.7, 4.55], +] + +const documentFold = [ + [19.1, 4.75], + [23.25, 8.9], + [19.1, 8.9], +] + +const waveformBars = [ + { x: 10.5, y: 16.5, height: 5 }, + { x: 13.25, y: 13.5, height: 11 }, + { x: 16, y: 15, height: 8 }, + { x: 18.75, y: 12.5, height: 13 }, + { x: 21.5, y: 16, height: 6 }, +] + +function makeCrc32Table() { + const table = new Uint32Array(256) + for (let i = 0; i < table.length; i += 1) { + let value = i + for (let bit = 0; bit < 8; bit += 1) { + value = (value & 1) !== 0 + ? 0xedb88320 ^ (value >>> 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({