| |
| |
| |
| |
| |
|
|
| const fs = require('fs') |
| const path = require('path') |
| const { createCanvas, loadImage } = require('canvas') |
| const sharp = require('sharp') |
| const { drawLabel } = require('./canvas-utils') |
|
|
| |
| |
| |
| |
| const ICON_FILES = { |
| play: 'play_arrow.svg', |
| file: 'insert_drive_file.svg', |
| note: 'music_note.svg' |
| } |
| const ICONS_DIR = path.resolve(__dirname, '../../assets/icons') |
|
|
| let icons = null |
| let iconsLoading = null |
|
|
| |
| |
| async function loadIcons () { |
| if (icons) return icons |
| if (!iconsLoading) { |
| iconsLoading = (async () => { |
| const out = {} |
| for (const [key, file] of Object.entries(ICON_FILES)) { |
| const svg = await fs.promises.readFile(path.join(ICONS_DIR, file), 'utf8') |
| |
| const white = svg.replace('<svg ', '<svg fill="#ffffff" ') |
| out[key] = await loadImage( |
| await sharp(Buffer.from(white), { density: 256 / 24 * 72 }).resize(256, 256).png().toBuffer() |
| ) |
| } |
| icons = out |
| return icons |
| })().catch((err) => { |
| console.warn('Icon rasterization failed, using geometric fallbacks:', err.message) |
| iconsLoading = null |
| return null |
| }) |
| } |
| return iconsLoading |
| } |
|
|
| |
| function paintIcon (ctx, name, x, y, size, fallback) { |
| if (icons && icons[name]) { |
| ctx.imageSmoothingEnabled = true |
| ctx.imageSmoothingQuality = 'high' |
| ctx.drawImage(icons[name], x, y, size, size) |
| } else { |
| fallback() |
| } |
| } |
|
|
| const ROW = { |
| disc: 40, |
| gap: 11, |
| title: 16, |
| meta: 13, |
| metaAlpha: 0.55, |
| lineGap: 4, |
| bar: 3, |
| barGap: 2.5, |
| barMin: 4, |
| barMax: 26, |
| cover: 8 |
| } |
|
|
| |
| function formatDuration (seconds) { |
| const total = Math.max(0, Math.round(Number(seconds) || 0)) |
| const m = Math.floor(total / 60) |
| const s = total % 60 |
| return `${m}:${String(s).padStart(2, '0')}` |
| } |
|
|
| |
| function formatFileSize (bytes) { |
| const b = Math.max(0, Number(bytes) || 0) |
| if (b < 1024) return `${Math.round(b)} B` |
| const units = ['KB', 'MB', 'GB'] |
| let v = b |
| let u = -1 |
| do { |
| v /= 1024 |
| u++ |
| } while (v >= 1024 && u < units.length - 1) |
| return `${v.toFixed(1)} ${units[u]}` |
| } |
|
|
| |
| function drawPlayDisc (d, accent) { |
| const canvas = createCanvas(d, d) |
| const ctx = canvas.getContext('2d') |
| ctx.fillStyle = accent |
| ctx.beginPath() |
| ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2) |
| ctx.fill() |
| const size = d * 0.62 |
| paintIcon(ctx, 'play', (d - size) / 2, (d - size) / 2, size, () => { |
| |
| const r = d * 0.22 |
| const cx = d * 0.54 |
| const cy = d / 2 |
| ctx.fillStyle = '#fff' |
| ctx.beginPath() |
| ctx.moveTo(cx - r * 0.7, cy - r) |
| ctx.lineTo(cx - r * 0.7, cy + r) |
| ctx.lineTo(cx + r * 1.1, cy) |
| ctx.closePath() |
| ctx.fill() |
| }) |
| return canvas |
| } |
|
|
| |
| function drawFileDisc (d, accent) { |
| const canvas = createCanvas(d, d) |
| const ctx = canvas.getContext('2d') |
| ctx.fillStyle = accent |
| ctx.beginPath() |
| ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2) |
| ctx.fill() |
| const size = d * 0.55 |
| paintIcon(ctx, 'file', (d - size) / 2, (d - size) / 2, size, () => { |
| |
| const w = d * 0.34 |
| const h = d * 0.44 |
| const x = (d - w) / 2 |
| const y = (d - h) / 2 |
| const fold = w * 0.38 |
| ctx.fillStyle = '#fff' |
| ctx.beginPath() |
| ctx.moveTo(x, y) |
| ctx.lineTo(x + w - fold, y) |
| ctx.lineTo(x + w, y + fold) |
| ctx.lineTo(x + w, y + h) |
| ctx.lineTo(x, y + h) |
| ctx.closePath() |
| ctx.fill() |
| ctx.fillStyle = accent |
| ctx.beginPath() |
| ctx.moveTo(x + w - fold, y) |
| ctx.lineTo(x + w - fold, y + fold) |
| ctx.lineTo(x + w, y + fold) |
| ctx.closePath() |
| ctx.fill() |
| }) |
| return canvas |
| } |
|
|
| |
| function drawNoteDisc (d, accent) { |
| const canvas = createCanvas(d, d) |
| const ctx = canvas.getContext('2d') |
| ctx.fillStyle = accent |
| ctx.beginPath() |
| ctx.arc(d / 2, d / 2, d / 2, 0, Math.PI * 2) |
| ctx.fill() |
| const size = d * 0.55 |
| paintIcon(ctx, 'note', (d - size) / 2, (d - size) / 2, size, () => { |
| |
| ctx.fillStyle = '#fff' |
| const headR = d * 0.09 |
| const hx = d * 0.42 |
| const hy = d * 0.66 |
| const stemH = d * 0.34 |
| const stemW = Math.max(1, d * 0.045) |
| ctx.beginPath() |
| ctx.ellipse(hx, hy, headR * 1.25, headR, -0.4, 0, Math.PI * 2) |
| ctx.fill() |
| ctx.fillRect(hx + headR * 1.1 - stemW, hy - stemH, stemW, stemH) |
| ctx.beginPath() |
| ctx.moveTo(hx + headR * 1.1, hy - stemH) |
| ctx.quadraticCurveTo(d * 0.62, hy - stemH + d * 0.07, d * 0.58, hy - stemH + d * 0.2) |
| ctx.quadraticCurveTo(d * 0.56, hy - stemH + d * 0.12, hx + headR * 1.1, hy - stemH + d * 0.1) |
| ctx.closePath() |
| ctx.fill() |
| }) |
| return canvas |
| } |
|
|
| |
| function resampleWaveform (data, n) { |
| if (data.length <= n) return data.map((v) => v / 31) |
| const out = [] |
| for (let i = 0; i < n; i++) { |
| const from = Math.floor(i * data.length / n) |
| const to = Math.max(from + 1, Math.floor((i + 1) * data.length / n)) |
| let sum = 0 |
| for (let j = from; j < to; j++) sum += data[j] |
| out.push(sum / (to - from) / 31) |
| } |
| return out |
| } |
|
|
| |
| |
| |
| |
| function drawVoiceRow (waveform, duration, accent, textColor, scale, maxWidth) { |
| const s = (v) => v * scale |
| const d = s(ROW.disc) |
| const durLabel = drawLabel(formatDuration(duration), s(ROW.meta), textColor, { alpha: ROW.metaAlpha }) |
|
|
| const pitch = s(ROW.bar) + s(ROW.barGap) |
| const barsAvail = Math.max(pitch * 8, (maxWidth || s(220)) - d - s(ROW.gap) * 2 - durLabel.width) |
| const barCount = Math.min(Math.max(8, waveform.length), Math.floor(barsAvail / pitch)) |
| const heights = resampleWaveform(waveform, barCount) |
| const barsW = barCount * pitch - s(ROW.barGap) |
|
|
| const w = Math.ceil(d + s(ROW.gap) + barsW + s(ROW.gap) + durLabel.width) |
| const h = d |
| const canvas = createCanvas(w, h) |
| const ctx = canvas.getContext('2d') |
|
|
| ctx.drawImage(drawPlayDisc(d, accent), 0, 0) |
|
|
| ctx.fillStyle = accent |
| const cy = h / 2 |
| for (let i = 0; i < barCount; i++) { |
| const bh = Math.max(s(ROW.barMin), heights[i] * s(ROW.barMax)) |
| const x = d + s(ROW.gap) + i * pitch |
| roundedVBar(ctx, x, cy - bh / 2, s(ROW.bar), bh) |
| } |
|
|
| ctx.drawImage(durLabel, d + s(ROW.gap) + barsW + s(ROW.gap), Math.round((h - durLabel.height) / 2)) |
| return canvas |
| } |
|
|
| |
| |
| |
| function drawDocumentRow (doc, accent, textColor, scale, maxWidth) { |
| const s = (v) => v * scale |
| const d = s(ROW.disc) |
| const name = String(doc.file_name || 'File') |
| const ext = name.includes('.') ? name.split('.').pop().toUpperCase() : '' |
| const metaText = [doc.file_size != null ? formatFileSize(doc.file_size) : null, ext || null] |
| .filter(Boolean).join(' · ') |
|
|
| const title = drawLabel(name, s(ROW.title), textColor, { bold: true }) |
| const meta = metaText ? drawLabel(metaText, s(ROW.meta), textColor, { alpha: ROW.metaAlpha }) : null |
| return assembleRow(drawFileDisc(d, accent), title, meta, scale, maxWidth) |
| } |
|
|
| |
| |
| |
| |
| function drawAudioRow (audio, accent, textColor, scale, maxWidth, thumb) { |
| const s = (v) => v * scale |
| const d = s(ROW.disc) |
|
|
| let lead |
| if (thumb) { |
| lead = createCanvas(d, d) |
| const ctx = lead.getContext('2d') |
| const r = s(ROW.cover) |
| ctx.beginPath() |
| ctx.moveTo(r, 0) |
| ctx.arcTo(d, 0, d, d, r) |
| ctx.arcTo(d, d, 0, d, r) |
| ctx.arcTo(0, d, 0, 0, r) |
| ctx.arcTo(0, 0, d, 0, r) |
| ctx.closePath() |
| ctx.clip() |
| ctx.imageSmoothingEnabled = true |
| ctx.imageSmoothingQuality = 'high' |
| const side = Math.min(thumb.width, thumb.height) |
| ctx.drawImage(thumb, (thumb.width - side) / 2, (thumb.height - side) / 2, side, side, 0, 0, d, d) |
| } else { |
| lead = drawNoteDisc(d, accent) |
| } |
|
|
| const metaText = [audio.performer || null, audio.duration != null ? formatDuration(audio.duration) : null] |
| .filter(Boolean).join(' · ') |
| const title = drawLabel(String(audio.title || 'Audio'), s(ROW.title), textColor, { bold: true }) |
| const meta = metaText ? drawLabel(metaText, s(ROW.meta), textColor, { alpha: ROW.metaAlpha }) : null |
| return assembleRow(lead, title, meta, scale, maxWidth) |
| } |
|
|
| |
| function assembleRow (disc, title, meta, scale, maxWidth) { |
| const s = (v) => v * scale |
| const gap = s(ROW.gap) |
| const textW = Math.max(title.width, meta ? meta.width : 0) |
| let w = Math.ceil(disc.width + gap + textW) |
| if (maxWidth && w > maxWidth) w = Math.ceil(maxWidth) |
| const textsH = title.height + (meta ? s(ROW.lineGap) + meta.height : 0) |
| const h = Math.max(disc.height, textsH) |
|
|
| const canvas = createCanvas(w, h) |
| const ctx = canvas.getContext('2d') |
| ctx.drawImage(disc, 0, Math.round((h - disc.height) / 2)) |
|
|
| const maxTextW = w - disc.width - gap |
| let ty = Math.round((h - textsH) / 2) |
| ctx.drawImage(clampWidth(title, maxTextW), disc.width + gap, ty) |
| ty += title.height + s(ROW.lineGap) |
| if (meta) ctx.drawImage(clampWidth(meta, maxTextW), disc.width + gap, ty) |
| return canvas |
| } |
|
|
| |
| function clampWidth (canvas, maxW) { |
| if (canvas.width <= maxW) return canvas |
| const w = Math.max(1, Math.floor(maxW)) |
| const out = createCanvas(w, canvas.height) |
| const ctx = out.getContext('2d') |
| ctx.drawImage(canvas, 0, 0) |
| const fadeW = Math.min(w, Math.round(canvas.height * 0.9)) |
| const grad = ctx.createLinearGradient(w - fadeW, 0, w, 0) |
| grad.addColorStop(0, 'rgba(0, 0, 0, 0)') |
| grad.addColorStop(1, 'rgba(0, 0, 0, 1)') |
| ctx.globalCompositeOperation = 'destination-out' |
| ctx.fillStyle = grad |
| ctx.fillRect(w - fadeW, 0, fadeW, canvas.height) |
| return out |
| } |
|
|
| function roundedVBar (ctx, x, y, w, h) { |
| const r = w / 2 |
| ctx.beginPath() |
| ctx.moveTo(x, y + r) |
| ctx.arcTo(x, y, x + w, y, r) |
| ctx.arcTo(x + w, y, x + w, y + h, r) |
| ctx.lineTo(x + w, y + h - r) |
| ctx.arcTo(x + w, y + h, x, y + h, r) |
| ctx.arcTo(x, y + h, x, y + h - r, r) |
| ctx.closePath() |
| ctx.fill() |
| } |
|
|
| |
| |
| |
| |
| |
| function paintMediaBadges (ctx, x, y, w, h, badge, scale) { |
| const s = (v) => v * scale |
| if (!badge) return |
| ctx.save() |
| if (badge.play) { |
| const d = Math.min(s(44), w * 0.45, h * 0.45) |
| const cx = x + w / 2 |
| const cy = y + h / 2 |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.5)' |
| ctx.beginPath() |
| ctx.arc(cx, cy, d / 2, 0, Math.PI * 2) |
| ctx.fill() |
| const size = d * 0.62 |
| paintIcon(ctx, 'play', cx - size / 2, cy - size / 2, size, () => { |
| const r = d * 0.24 |
| ctx.fillStyle = '#fff' |
| ctx.beginPath() |
| ctx.moveTo(cx - r * 0.62, cy - r) |
| ctx.lineTo(cx - r * 0.62, cy + r) |
| ctx.lineTo(cx + r * 1.05, cy) |
| ctx.closePath() |
| ctx.fill() |
| }) |
| } |
| if (badge.label) { |
| const label = drawLabel(badge.label, s(13), '#fff') |
| const padX = s(7) |
| const padY = s(2) |
| const bw = label.width + padX * 2 |
| const bh = label.height + padY * 2 |
| const bx = x + s(6) |
| const by = y + h - bh - s(6) |
| const r = bh / 2 |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.45)' |
| ctx.beginPath() |
| ctx.moveTo(bx + r, by) |
| ctx.arcTo(bx + bw, by, bx + bw, by + bh, r) |
| ctx.arcTo(bx + bw, by + bh, bx, by + bh, r) |
| ctx.arcTo(bx, by + bh, bx, by, r) |
| ctx.arcTo(bx, by, bx + bw, by, r) |
| ctx.closePath() |
| ctx.fill() |
| ctx.drawImage(label, bx + padX, by + padY) |
| } |
| ctx.restore() |
| } |
|
|
| module.exports = { |
| loadIcons, |
| drawVoiceRow, |
| drawDocumentRow, |
| drawAudioRow, |
| paintMediaBadges, |
| formatDuration, |
| formatFileSize, |
| resampleWaveform, |
| ROW |
| } |
|
|