From 7064ca500e6d711dd931416c83e42761002982b1 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 2 May 2026 16:44:29 +0200 Subject: [PATCH] feat(stats): shareable Top-Albums card export (#425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(stats): add shareable Top-Albums card export Adds a shareable PNG export of the user's most-played albums, available from the Statistics page under the "Most Played Albums" section header. Layout: * 3 formats — Story (1080×1920, 9:16), Square (1080×1080), Twitter Card (1200×675, 16:9). Story and Square stack a centered URL footer; Twitter packs logo + label + URL into a single header row. * 3 grid sizes — 3×3 (default), 4×4, 5×5. Modal fetches up to 25 frequent albums on open so larger grids work even when the entry surface only loaded a handful. * Cover tiles render the rank + play count in a thin black info strip at the bottom of the cover (rank left, "N Plays" right). * Header label is hardcoded English ("Top Albums") so a shared image remains legible to followers who don't share the user's UI language. Caller can override via `opts.meta`. Source path is local-only — `getAlbumList(type='frequent')` from Subsonic. Last.fm is intentionally not used. Implementation: * `src/utils/exportAlbumCard.ts` — pure Canvas-API renderer. Reads theme accent / bg from the document's CSS variables so the export matches the active theme. Cover art is loaded through the existing `getCachedBlob` IndexedDB cache, so repeated exports are cheap. Wordmark is rendered from the `PsysonicLogo` React component via `renderToStaticMarkup`, with both gradient stops baked to a single accent color for crisp single-tone branding. * `src/components/StatsExportModal.tsx` — UI: format + grid pickers, live downscaled preview, native save dialog via `@tauri-apps/plugin-dialog` + `plugin-fs`. * `src/components/AlbumRow.tsx` — adds optional `headerExtra` slot so the share button sits next to the existing scroll-nav arrows. * i18n keys added under `statistics.*` (`en`, `de`) — other locales fall back to English. * docs(changelog): add Top-Albums card export for PR #425 --- CHANGELOG.md | 11 + src/components/AlbumRow.tsx | 13 +- src/components/StatsExportModal.tsx | 308 +++++++++++++++++++++++ src/locales/de.ts | 18 ++ src/locales/en.ts | 18 ++ src/pages/Statistics.tsx | 20 ++ src/utils/exportAlbumCard.ts | 371 ++++++++++++++++++++++++++++ 7 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 src/components/StatsExportModal.tsx create mode 100644 src/utils/exportAlbumCard.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 67355b43..2bed3e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,17 @@ You can remove a track from the play queue by dragging its row **outside** the q The drag ghost shows a **trash** affordance only while the cursor is outside the queue bounds; inside the queue it behaves as a normal reorder drag. The mouse-event `psy-drop` path now carries cursor coordinates so removal can be detected when the drop target is not the queue panel itself. +### Statistics — Shareable Top-Albums Card + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#425](https://github.com/Psychotoxical/psysonic/pull/425)** + +Statistics page can now export your most-played albums as a shareable PNG, accessible via a share icon next to the **Most Played Albums** section header. Three aspect ratios for different platforms (Story 9:16, Square 1:1, Twitter Card 16:9), three grid sizes (3×3, 4×4, 5×5), with each cover carrying a thin info strip showing rank + play count. + +The card pulls the wordmark and accent color directly from the active theme, so a Catppuccin export looks Catppuccin and a Nord export looks Nord. Cover art reuses the existing IndexedDB cache, so no extra Subsonic round-trips on repeat exports. The header label is hardcoded English ("Top Albums") so a shared image stays legible to followers regardless of their language. + +Saving uses the native OS save dialog — no silent dump into Downloads, the user picks the path each time. Data source is local-only (Subsonic `getAlbumList(frequent)`); Last.fm is intentionally not used. There is no time-window selector because Navidrome's API exposes only cumulative play counts, not per-event play history. + + ## Fixed - **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible. diff --git a/src/components/AlbumRow.tsx b/src/components/AlbumRow.tsx index 041e021a..3d021897 100644 --- a/src/components/AlbumRow.tsx +++ b/src/components/AlbumRow.tsx @@ -13,9 +13,11 @@ interface Props { moreText?: string; onLoadMore?: () => Promise; showRating?: boolean; + /** Optional content rendered in the row header, left of the scroll-nav. */ + headerExtra?: React.ReactNode; } -export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) { +export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating, headerExtra }: Props) { const { t } = useTranslation(); const scrollRef = useRef(null); const navigate = useNavigate(); @@ -71,15 +73,16 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,

{title}

)}
- - +

+ {t('statistics.exportTitle')} +

+

+ {t('statistics.exportSubtitle')} +

+ + {/* Format */} +
+
+ {t('statistics.exportFormat')} +
+
+ {FORMATS.map(f => { + const active = format === f.key; + return ( + + ); + })} +
+
+ + {/* Grid */} +
+
+ {t('statistics.exportGrid')} +
+
+ {GRID_SIZES.map(n => { + const active = gridSize === n; + return ( + + ); + })} +
+
+ + {/* Preview */} +
+
+ {t('statistics.exportPreview')} +
+ + {!enoughAlbums ? ( +
+ {t('statistics.exportNotEnough', { count: required, n: gridSize })} +
+ ) : ( +
+ )} + +
+ +
+ + +
+
+
, + document.body, + ); +} + +const PreviewFrame = ({ format, children }: { format: ExportFormat; children: React.ReactNode }) => { + const aspect = useMemo(() => { + if (format === 'story') return '9 / 16'; + if (format === 'square') return '1 / 1'; + return '16 / 9'; + }, [format]); + // Cap preview height so the modal stays manageable on a typical desktop. + // Narrow ratios (Story) become height-bounded; wide ratios (Twitter) are + // width-bounded by the modal itself. + return ( +
+ {children} +
+ ); +}; diff --git a/src/locales/de.ts b/src/locales/de.ts index 8e6ddfcc..7e22230d 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1179,6 +1179,24 @@ export const deTranslation = { topRatedArtists: 'Bestbewertete Künstler', noRatedSongs: 'Noch keine bewerteten Songs. Songs in Album- oder Playlist-Ansicht bewerten.', noRatedArtists: 'Noch keine bewerteten Künstler.', + exportShare: 'Teilen', + exportTitle: 'Top-Alben teilen', + exportSubtitle: 'Ein teilbares Bild deiner meistgespielten Alben erstellen.', + exportFormat: 'Format', + exportFormatStory: 'Story', + exportFormatSquare: 'Quadrat', + exportFormatTwitter: 'Twitter-Card', + exportGrid: 'Raster', + exportGridLabel: '{{n}}×{{n}}', + exportPreview: 'Vorschau', + exportGenerate: 'Erstellen', + exportSave: 'Bild speichern…', + exportCancel: 'Abbrechen', + exportFooterLabel: 'Top-Alben', + exportNotEnough: 'Für ein {{n}}×{{n}}-Raster braucht es mindestens {{count}} Alben in der Library.', + exportSaving: 'Speichere…', + exportSaved: 'Gespeichert.', + exportSaveFailed: 'Bild konnte nicht gespeichert werden.', }, player: { regionLabel: 'Musikplayer', diff --git a/src/locales/en.ts b/src/locales/en.ts index 440fcaca..339408ab 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1185,6 +1185,24 @@ export const enTranslation = { topRatedArtists: 'Top Rated Artists', noRatedSongs: 'No rated songs yet. Rate songs in album or playlist view.', noRatedArtists: 'No rated artists yet.', + exportShare: 'Share', + exportTitle: 'Share Top Albums', + exportSubtitle: 'Generate a shareable image of your most-played albums.', + exportFormat: 'Format', + exportFormatStory: 'Story', + exportFormatSquare: 'Square', + exportFormatTwitter: 'Twitter Card', + exportGrid: 'Grid', + exportGridLabel: '{{n}}×{{n}}', + exportPreview: 'Preview', + exportGenerate: 'Generate', + exportSave: 'Save image…', + exportCancel: 'Cancel', + exportFooterLabel: 'Top Albums', + exportNotEnough: 'Need at least {{count}} albums in your library for a {{n}}×{{n}} grid.', + exportSaving: 'Saving…', + exportSaved: 'Saved.', + exportSaveFailed: 'Could not save the image.', }, player: { regionLabel: 'Music Player', diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index 2f89474c..ea06f3f4 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Share2 } from 'lucide-react'; import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, @@ -9,6 +10,7 @@ import { } from '../api/subsonic'; import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import AlbumRow from '../components/AlbumRow'; +import StatsExportModal from '../components/StatsExportModal'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useNavigate } from 'react-router-dom'; @@ -51,6 +53,8 @@ export default function Statistics() { const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null); const [formatSampleSize, setFormatSampleSize] = useState(0); + const [exportOpen, setExportOpen] = useState(false); + const [lfmPeriod, setLfmPeriod] = useState('1month'); const [lfmTopArtists, setLfmTopArtists] = useState([]); const [lfmTopAlbums, setLfmTopAlbums] = useState([]); @@ -270,6 +274,17 @@ export default function Statistics() { albums={frequent} onLoadMore={() => loadMore('frequent', frequent, setFrequent)} moreText={t('statistics.loadMore')} + headerExtra={frequent.length >= 9 ? ( + + ) : undefined} /> )} + setExportOpen(false)} + /> ); } diff --git a/src/utils/exportAlbumCard.ts b/src/utils/exportAlbumCard.ts new file mode 100644 index 00000000..cceb5837 --- /dev/null +++ b/src/utils/exportAlbumCard.ts @@ -0,0 +1,371 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { buildCoverArtUrl, coverArtCacheKey, SubsonicAlbum } from '../api/subsonic'; +import { getCachedBlob } from './imageCache'; +import PsysonicLogo from '../components/PsysonicLogo'; + +export type ExportFormat = 'story' | 'square' | 'twitter'; +export type ExportGridSize = 3 | 4 | 5; + +export interface ExportAlbumCardOptions { + albums: SubsonicAlbum[]; + format: ExportFormat; + gridSize: ExportGridSize; + /** Footer label like "Top Albums". */ + title: string; + /** Footer secondary label like the period or "Most Played". */ + periodLabel?: string; + /** Footer-right text shown next to the wordmark, usually a period or count. */ + meta?: string; + /** Optional explicit accent override; defaults to the document's `--accent`. */ + accent?: string; + /** Optional explicit background override; defaults to the document's `--bg-primary`. */ + background?: string; + /** Set to true while rendering a low-res preview. Skips slow font/quality settings. */ + preview?: boolean; +} + +const DIMENSIONS: Record = { + story: { w: 1080, h: 1920 }, + square: { w: 1080, h: 1080 }, + twitter: { w: 1200, h: 675 }, +}; + +const PREVIEW_MAX_WIDTH = 540; + +/** Reads a `--var` from `document.documentElement`, with optional fallback. */ +function readThemeVar(name: string, fallback: string): string { + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +/** Quick contrast check — returns true if the background is light. */ +function isLight(hex: string): boolean { + // Accepts `#RRGGBB`, `#RGB`, or `rgb(r, g, b)` — fall back to dark. + const m = hex.match(/^#([0-9a-f]{6})$/i) || hex.match(/^#([0-9a-f]{3})$/i); + if (!m) { + const rgb = hex.match(/rgb\(\s*(\d+)\D+(\d+)\D+(\d+)/i); + if (rgb) return (Number(rgb[1]) + Number(rgb[2]) + Number(rgb[3])) / 3 > 160; + return false; + } + const v = m[1]; + const expand = v.length === 3 ? v.split('').map(c => c + c).join('') : v; + const r = parseInt(expand.slice(0, 2), 16); + const g = parseInt(expand.slice(2, 4), 16); + const b = parseInt(expand.slice(4, 6), 16); + return (r + g + b) / 3 > 160; +} + +async function loadAlbumCover(album: SubsonicAlbum, size: number, signal?: AbortSignal): Promise { + if (!album.coverArt) return null; + try { + const blob = await getCachedBlob(buildCoverArtUrl(album.coverArt, size), coverArtCacheKey(album.coverArt, size), signal); + if (!blob) return null; + return await createImageBitmap(blob); + } catch { + return null; + } +} + +/** Decodes the Psysonic wordmark SVG into an Image, ready for drawImage. + * `targetHeight` is informational — actual scaling happens at drawImage time. */ +async function loadWordmark(color: string): Promise { + const svgMarkup = getCachedLogoSvg(color); + const blob = new Blob([svgMarkup], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + img.decoding = 'async'; + img.src = url; + await img.decode(); + return img; + } finally { + setTimeout(() => URL.revokeObjectURL(url), 0); + } +} + +/** Renders the PsysonicLogo component to an SVG string with a single solid + * color baked in (both gradient stops set to the same value), so the wordmark + * stays uniformly readable on the export background regardless of theme. + * The naive `var(--logo-color-start)` regex misses the nested fallback + * `var(--logo-color-start, var(--accent))` — we use exact-string replace. */ +let cachedLogoSvgKey = ''; +let cachedLogoSvg = ''; +function getCachedLogoSvg(color: string): string { + if (cachedLogoSvgKey === color && cachedLogoSvg) return cachedLogoSvg; + const raw = renderToStaticMarkup(React.createElement(PsysonicLogo, { gradientIdSuffix: 'export' })); + const swapped = raw + .replace('var(--logo-color-start, var(--accent))', color) + .replace('var(--logo-color-end, var(--ctp-blue))', color); + cachedLogoSvg = swapped; + cachedLogoSvgKey = color; + return swapped; +} + +/** + * Renders a Pano-Scrobbler-style "Top Albums" image to a canvas and returns it + * as a Blob (PNG). Caller is responsible for saving the blob to disk. + * + * The function reads accent + background colors from the active theme so the + * exported image matches the in-app look. Cover art is loaded through the + * existing IndexedDB cache (`getCachedBlob`), so repeated exports are cheap. + */ +export async function renderAlbumCardCanvas(opts: ExportAlbumCardOptions): Promise { + const { albums, format, gridSize, preview } = opts; + const dims = DIMENSIONS[format]; + const scale = preview ? Math.min(1, PREVIEW_MAX_WIDTH / dims.w) : 1; + const w = Math.round(dims.w * scale); + const h = Math.round(dims.h * scale); + + const accent = opts.accent ?? readThemeVar('--accent', '#CBA6F7'); + const bg = opts.background ?? readThemeVar('--bg-primary', '#1E1E2E'); + const fgPrimary = readThemeVar('--text-primary', isLight(bg) ? '#11111B' : '#CDD6F4'); + const fgMuted = readThemeVar('--text-muted', isLight(bg) ? '#444' : '#9399B2'); + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas 2d unavailable'); + + // ── Background ───────────────────────────────────────────────────────── + // Subtle vertical gradient: bg → bg with a slight accent tint at the bottom. + const bgGrad = ctx.createLinearGradient(0, 0, 0, h); + bgGrad.addColorStop(0, bg); + bgGrad.addColorStop(1, mixHex(bg, accent, 0.12)); + ctx.fillStyle = bgGrad; + ctx.fillRect(0, 0, w, h); + + // ── Layout ─────────────────────────────────────────────────────────────── + // Header / footer have format-specific minimum heights so the title and + // logo never collide visually with the grid even when the grid is + // height-bounded (Square, Twitter). Landscape (Twitter) needs the largest + // proportional band because the card is short. Portrait (Story) the + // smallest because there's already plenty of vertical room. + const pad = Math.round(w * 0.045); + const gap = Math.round(w * 0.012); + const isLandscape = format === 'twitter'; + const isPortrait = format === 'story'; + // Story stacks logo above the meta-label, so it needs the most header room. + const headerMinRatio = isPortrait ? 0.16 : isLandscape ? 0.18 : 0.13; + // Square + Story have a URL footer; Twitter doesn't (URL lives in header). + const footerMinRatio = isLandscape ? 0.05 : isPortrait ? 0.07 : 0.08; + const headerMin = Math.round(h * headerMinRatio); + const footerMin = Math.round(h * footerMinRatio); + let headerH = headerMin; + let footerH = footerMin; + const horizontalTile = Math.floor((w - pad * 2 - gap * (gridSize - 1)) / gridSize); + let availableH = h - headerH - footerH; + let verticalTile = Math.floor((availableH - gap * (gridSize - 1)) / gridSize); + let tileSize = Math.min(horizontalTile, verticalTile); + // If we're width-bounded the grid leaves vertical slack — push header + // (60%) and footer (40%) outward to absorb it. Header/footer content + // stays anchored away from the grid edge so it can't drift in. + const gridPxH0 = tileSize * gridSize + gap * (gridSize - 1); + const verticalSlack = availableH - gridPxH0; + if (verticalSlack > 0) { + headerH += Math.round(verticalSlack * 0.6); + footerH += verticalSlack - Math.round(verticalSlack * 0.6); + availableH = h - headerH - footerH; + verticalTile = Math.floor((availableH - gap * (gridSize - 1)) / gridSize); + tileSize = Math.min(horizontalTile, verticalTile); + } + const gridPxW = tileSize * gridSize + gap * (gridSize - 1); + const gridFinalH = tileSize * gridSize + gap * (gridSize - 1); + const gridX = pad + Math.round((w - pad * 2 - gridPxW) / 2); + const gridY = headerH + Math.round((h - headerH - footerH - gridFinalH) / 2); + const headerHasSlack = verticalSlack > 0; + + // ── Header: format-specific ────────────────────────────────────────────── + // Story (portrait): logo centered, meta-label centered below it. + // Twitter (landscape): logo left, meta center, url right (small). + // Square: logo left, meta right. + const logo = await loadWordmark(accent).catch(() => null); + const logoRatio = logo ? (logo.naturalWidth / logo.naturalHeight || 4.4) : 4.4; + const drawLogoAt = (lx: number, ly: number, lh: number) => { + if (logo) { + ctx.drawImage(logo, lx, ly, Math.round(lh * logoRatio), lh); + } else { + ctx.fillStyle = accent; + ctx.font = `700 ${Math.round(lh * 0.78)}px "Space Grotesk", "Inter", system-ui, sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText('psysonic', lx, ly); + } + }; + + // Header label is hardcoded English ("Top Albums") so a shared image is + // legible to anyone, regardless of the exporter's UI language. Caller can + // still override via `opts.meta` for special editions ("Top Albums 2026"). + const headerLabel = opts.meta ?? 'Top Albums'; + + if (isPortrait) { + // Story: stacked logo + label, both centered. + const logoH = Math.max(36, Math.round(headerMin * 0.36)); + const logoW = Math.round(logoH * logoRatio); + const metaSize = Math.max(18, Math.round(headerMin * 0.18)); + const stackGap = Math.round(headerMin * 0.08); + const totalH = logoH + stackGap + metaSize; + const stackTop = headerHasSlack + ? Math.round(pad * 0.85) + : Math.round((headerMin - totalH) / 2); + const logoX = Math.round((w - logoW) / 2); + drawLogoAt(logoX, stackTop, logoH); + ctx.font = `500 ${metaSize}px "Inter", system-ui, sans-serif`; + ctx.fillStyle = fgMuted; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(headerLabel, Math.round(w / 2), stackTop + logoH + stackGap); + } else if (isLandscape) { + // Twitter: logo left, label centered, url right (smaller). + const logoH = Math.max(30, Math.round(headerMin * 0.42)); + const headerCenterY = headerHasSlack + ? Math.round(pad * 0.85) + Math.round(logoH / 2) + : Math.round(headerMin / 2); + drawLogoAt(pad, headerCenterY - Math.round(logoH / 2), logoH); + const metaSize = Math.max(16, Math.round(headerMin * 0.22)); + ctx.font = `600 ${metaSize}px "Inter", system-ui, sans-serif`; + ctx.fillStyle = fgPrimary; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(headerLabel, Math.round(w / 2), headerCenterY); + const urlSize = Math.max(13, Math.round(headerMin * 0.16)); + ctx.font = `500 ${urlSize}px "Inter", system-ui, sans-serif`; + ctx.fillStyle = fgMuted; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText('www.psysonic.de', w - pad, headerCenterY); + } else { + // Square: logo left, label right. + const logoH = Math.max(28, Math.round(headerMin * 0.40)); + const headerCenterY = headerHasSlack + ? Math.round(pad * 0.85) + Math.round(logoH / 2) + : Math.round(headerMin / 2); + drawLogoAt(pad, headerCenterY - Math.round(logoH / 2), logoH); + const metaSize = Math.max(14, Math.round(headerMin * 0.22)); + ctx.font = `500 ${metaSize}px "Inter", system-ui, sans-serif`; + ctx.fillStyle = fgMuted; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(headerLabel, w - pad, headerCenterY); + } + + // ── Tiles ──────────────────────────────────────────────────────────────── + const desiredTilePx = preview ? 256 : 600; + const needed = gridSize * gridSize; + const tilesAlbums = albums.slice(0, needed); + const covers = await Promise.all(tilesAlbums.map(a => loadAlbumCover(a, desiredTilePx))); + + for (let i = 0; i < tilesAlbums.length; i++) { + const album = tilesAlbums[i]; + const col = i % gridSize; + const row = Math.floor(i / gridSize); + const x = gridX + col * (tileSize + gap); + const y = gridY + row * (tileSize + gap); + + // Cover or fallback panel. + const cover = covers[i]; + if (cover) { + ctx.drawImage(cover, x, y, tileSize, tileSize); + } else { + ctx.fillStyle = mixHex(bg, accent, 0.15); + ctx.fillRect(x, y, tileSize, tileSize); + } + + // Info strip — narrow bar at the bottom of the cover with the rank on + // the left and the play-count on the right. Full-width strip integrates + // visually into the cover (as opposed to a floating pill). + const stripH = Math.max(20, Math.round(tileSize * 0.13)); + const stripY = y + tileSize - stripH; + ctx.fillStyle = 'rgba(0, 0, 0, 0.68)'; + ctx.fillRect(x, stripY, tileSize, stripH); + + const stripFontSize = Math.round(stripH * 0.52); + const stripPadX = Math.round(stripH * 0.45); + const stripCenterY = stripY + stripH / 2 + 1; + + // Rank on the left. + ctx.font = `800 ${stripFontSize}px "Space Grotesk", "Inter", system-ui, sans-serif`; + ctx.fillStyle = '#fff'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(i + 1), x + stripPadX, stripCenterY); + + // Plays on the right (only when available). + const plays = album.playCount; + if (plays && plays > 0) { + ctx.font = `600 ${stripFontSize}px "Inter", system-ui, sans-serif`; + ctx.fillStyle = 'rgba(255, 255, 255, 0.88)'; + ctx.textAlign = 'right'; + ctx.fillText(`${plays} Plays`, x + tileSize - stripPadX, stripCenterY); + } + } + + // ── Footer: URL centered (Story + Square only; Twitter has it in header) ─ + if (!isLandscape) { + const urlSize = Math.max(13, Math.round(footerMin * 0.36)); + ctx.font = `500 ${urlSize}px "Inter", system-ui, sans-serif`; + ctx.fillStyle = fgMuted; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const footerCenterY = h - Math.round(footerMin / 2); + ctx.fillText('www.psysonic.de', Math.round(w / 2), footerCenterY); + } + + return canvas; +} + +export async function exportAlbumCardBlob(opts: ExportAlbumCardOptions): Promise { + const canvas = await renderAlbumCardCanvas(opts); + return await new Promise((resolve, reject) => { + canvas.toBlob(b => (b ? resolve(b) : reject(new Error('toBlob returned null'))), 'image/png'); + }); +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +/** Mixes two hex colors by `t` ∈ [0..1]. Falls back to `a` when parsing fails. */ +function mixHex(a: string, b: string, t: number): string { + const ca = hexToRgb(a); + const cb = hexToRgb(b); + if (!ca || !cb) return a; + const r = Math.round(ca.r + (cb.r - ca.r) * t); + const g = Math.round(ca.g + (cb.g - ca.g) * t); + const bl = Math.round(ca.b + (cb.b - ca.b) * t); + return `rgb(${r}, ${g}, ${bl})`; +} + +function hexToRgb(input: string): { r: number; g: number; b: number } | null { + const trimmed = input.trim(); + const hex = trimmed.match(/^#([0-9a-f]{3,8})$/i); + if (hex) { + let v = hex[1]; + if (v.length === 3) v = v.split('').map(c => c + c).join(''); + if (v.length === 6 || v.length === 8) { + return { + r: parseInt(v.slice(0, 2), 16), + g: parseInt(v.slice(2, 4), 16), + b: parseInt(v.slice(4, 6), 16), + }; + } + } + const rgb = trimmed.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/i); + if (rgb) return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) }; + return null; +}