mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
f9df918c72
* feat(themes): add semantic tokens for the theme-store contract (B0 P1) Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and --text-on-accent on the :root base as --ctp-* mappings. They resolve per-theme automatically and nothing consumes them yet (zero behaviour change) — groundwork for replacing direct --ctp-* use in components. * refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2) Replace every direct --ctp-* reference in component/layout/track CSS and TSX inline styles with the readable semantic token (--bg-app, --accent, --highlight, --text-on-accent, …). --ctp-* now survives only as the Catppuccin palette layer the base maps from, and as the deliberate categorical rainbow in Composers/Genres/artistsHelpers (left untouched). This is the readable contract surface for the community theme store. Divergences (theme set a semantic var != its --ctp- source) are corrections — the element now uses the theme's real semantic colour. * feat(themes): add player-bar title/artist color tokens New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token. * refactor(themes): token-only theme library (flatten, whitelist, one file per theme) Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer): - Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens. - Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents. - Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging. Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins. * chore(themes): remove seven themes retired after the token refactor These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry. * feat(themes): granular tokens — track lists Wire track rows to per-region tokens: row hover (--row-hover), the now-playing row + indicator (--row-playing-bg / --row-playing-text), track title/artist/ number/duration text, column-header text, row dividers, and the resize-handle active colour. Covers the desktop tracklist, the shared song-row (Tracks hub / search), and the mobile tracklist. Drop a baked border fallback. Visual no-op. * feat(themes): granular tokens — cards Wire album and artist cards to per-region tokens (--card-hover-border, --card-title, --card-subtitle, --card-placeholder-bg). Visual no-op. * fix(themes): drop undefined/baked colour aliases Replace the undefined --bg-surface (resolved to nothing — broken placeholder backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the baked-hex aliases --color-error / --color-warning with --danger / --warning so themes can actually recolour them. * feat(themes): Spectrum demo theme + trim unused cascade tokens Add a loud built-in demo theme that gives each region its own hue (sidebar green, player pink, lists cyan, cards gold, menus red, controls blue) so the per-region granularity is obvious when you switch to it. Drop two unused cascade tokens (--sidebar-text-active, --row-active-bg) and the unused on-media block (those media surfaces stay static by design). * style(themes): make Spectrum demo brutally loud Full-saturation neon per region (toxic green sidebar, magenta player, cyan lists, acid-yellow cards, blood-red menus, electric-blue controls, purple scrollbar) so the per-region separation is unmistakable. The earlier soft tints were too subtle to read. * feat(themes): name the granular demo theme Braindead * feat(themes): granular per-region tokens — cascade layer + sidebar Add an optional per-region token layer (semantic-cascade.css) so a theme can recolour individual regions — sidebar hover, player controls, list rows, menus, inputs, on-media surfaces — independently of the global tokens. Every token defaults to its base token (or a media-safe literal), so this is a visual no-op until a theme overrides one; it only adds control points. Wire the sidebar region as the first consumer and drop the baked grey fallbacks (--bg-tertiary, etc.) that no theme could reach. * feat(themes): granular tokens — controls, menus, scrollbar Wire inputs, buttons, sliders, the custom-select, context menus, submenus and modals to per-region tokens, and tokenise the scrollbar. Complete B0 by dropping the last direct --ctp-* references in the input/button/progress/ scrollbar utility CSS. Fix three undefined-token bugs that fell back to nothing (so no theme could reach them): --surface-2 (context-menu hover had no highlight), --bg-surface (submenu create-input had no background), and the baked grey fallbacks in the custom-select. Every other new token defaults to today's value — a visual no-op that only adds override points. * feat(themes): granular tokens — player bar Wire the desktop player bar's transport controls, time toggle, and overflow menu to per-region tokens (--player-control, --player-time-toggle-*, etc.). Fix the undefined --surface-hover/--surface-active grey fallbacks on the time toggle. Visual no-op; defaults match today's values. * chore(themes): remove empty theme stub files Six theme CSS files were reduced to comment-only stubs by the flatten sweep but their files and @import lines remained. Five are empty structural companions of now-flattened themes (morpheus, p-dvd, aero-glass, luna-teal) and two are orphans of cut themes (order-of-the-phoenix, pandora). Removed the files and their imports. * feat(themes): runtime injection foundation for the theme store Plumbing for installed community themes ahead of the in-app store UI, nothing user-visible yet: - installedThemesStore: persisted (localStorage) record of installed community themes incl. their CSS text, so an active community theme is available synchronously at startup (no flash, fully offline). - themeInjection: reconcile <head> <style data-installed-theme> elements with the store; lightweight defense-in-depth sanitize on top of CI. - themeRegistry: jsDelivr registry client with a 12h localStorage cache and stale-on-error fallback. - App: inject installed themes before applying data-theme, in both webviews. - themeStore: widen the Theme type to accept dynamic installed ids. * feat(themes): dedicated Themes settings tab Move theme selection and the day/night scheduler out of Appearance into a new dedicated Themes tab — the future home of the community Theme Store. Appearance keeps grid columns, visual options, UI scale, font and seekbar. - ThemesTab: theme picker + scheduler (relocated verbatim). - AppearanceTab: drop the two relocated sections + now-unused imports. - Register the tab in settingsTabs (Tab union, resolveTab, search index) and Settings (tab bar, render, label map). - i18n: settings.tabThemes in all 9 locales. * feat(themes): community Theme Store browse + install Add the Theme Store section to the Themes tab: - Fetch the jsDelivr registry (12h cache, stale-on-error fallback). - Search by name/author/description + filter by light/dark + refresh. - Per-row CDN thumbnail, name, author, description and actions: Install / Apply / Update / Uninstall. Installing fetches the CSS, persists it (localStorage) and the runtime injection applies it; uninstalling the active theme falls back to the matching core. - Rating slot left reserved (deferred). - i18n: themeStore* keys in all 9 locales. * feat(themes): slim bundle to fixed cores + flat Themes tab Remove the 86 store palettes (incl. braindead) from the app bundle — the CSS files, their index.css imports, the Theme union and the picker data — leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave, Stark HUD, Vision Dark/Navy). Everything else installs from the store. Themes tab is rebuilt flat (no collapsible accordions): - "Your Themes": one card grid of the fixed cores + installed community themes; click to apply, uninstall on community ones (active theme falls back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe pill on the colour-blind-safe Vision themes. - Scheduler day/night options include installed themes. - Theme Store: alphabetical order, thumbnail lightbox, and a submit hint above the search linking to the themes repository. - Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, … - ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts. - i18n for all new strings across 9 locales. * feat(themes): reset removed-from-bundle themes to a bundled fallback After slimming the bundle to the six fixed core themes, a profile upgraded from an older build may have an active or scheduler theme that is now store-only and not installed — it has no [data-theme] block and would render as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled nor installed to a bundled fallback: Mocha for the main + night slots, Latte for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the persisted selection in localStorage before React mounts (no flash; Zustand rehydrates after first paint). No auto-install and no network — the fallback is always a bundled theme, so it works offline. * feat(themes): floating back-to-top button on the Themes tab The Themes tab can get long (theme grid + scheduler + full store list), so add a floating back-to-top affordance that appears once the page is scrolled and smooth-scrolls to the top. It is portalled into the route host and positioned absolute against it — the main scroll viewport sets contain: paint, which would otherwise make position: fixed resolve against the scrolling box and drift with the content. Reusable component (scroll viewport id + threshold props); i18n common.backToTop added in all nine locales. * feat(themes): accessibility + state polish for the Theme Store - Reuse the shared CoverLightbox for the thumbnail preview instead of a second inline dialog — gains a visible close button and a focus-managed, portalled dialog, and drops duplicated markup. - Theme cards expose aria-pressed so assistive tech announces the selected theme, not just the visual check. - Transient store messages get live-region roles (loading/empty/install failure = status, fetch error = alert). - Thumbnails degrade gracefully when offline/missing (hide the broken-image glyph; the thumbnail button no longer stretches with the row, so its background can't show as letterbox bars). * feat(themes): larger store-row thumbnails (120x75 -> 200x125) The list previews were too small to make a theme out; bump the display size (same 1.6 aspect). Thumbnails are now served at 720x450, so the larger display stays crisp. * fix(themes): bust thumbnail cache on registry change jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail is updated the webview keeps showing its cached old image (the path is unchanged). Append the registry's generatedAt as a cache-busting query to the thumbnail URLs (list + lightbox); it changes on every themes push, so a registry refresh makes the webview re-fetch and reflect the current CDN image instead of a stale one. * docs(themes): changelog + credits for the Theme Store Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009) and the matching line in the Psychotoxical credits. * fix(themes): address PR review (uninstall hygiene, validation, polish) Uninstall/scheduler & validation: - uninstallTheme() repairs every selection slot (active + day + night), not just the manual one, and is shared by both uninstall buttons (dedup). - Validate theme CSS at install time and skip persisting CSS that won't inject (no more "installed/active but renders nothing" with no feedback). - Harden the runtime validator: exactly one rule, scoped exactly to the theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no at-rules, url() only data:, no expression()/javascript:, size-capped. Tokens & polish: - Fix three dangling undefined tokens (--surface-2 x2, --bg-surface). - Finish the warning/success token sweep (--warning / --positive, themeable). - Apply the active theme synchronously before React mounts (no first-frame flash) and inject installed themes up front. - One-time, dismissible notice when the slim-bundle migration reset a theme. - Update badge uses semver, not string inequality. - Offline/stale indicator in the store; cross-window theme sync; drop the now dead REMOVED_THEME_REMAP and Card.mode field. Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/ malformed), uninstallTheme (slot repair), migration notice. i18n in all nine locales. Full suite green (1755 tests). * test(bootstrap): cover startup theme apply + cross-window sync The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3% and failing the frontend hot-path coverage gate (>=70%). Add unit tests for both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
380 lines
17 KiB
TypeScript
380 lines
17 KiB
TypeScript
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
|
import React from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { coverArtRef } from '../../cover/ref';
|
|
import { loadCoverBlobForExport } from '../../cover/integrations/export';
|
|
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<ExportFormat, { w: number; h: number }> = {
|
|
story: { w: 1080, h: 1920 },
|
|
square: { w: 1080, h: 1080 },
|
|
twitter: { w: 1200, h: 675 },
|
|
};
|
|
|
|
// Preview canvas resolution. 540 left text and album covers visibly upscaled
|
|
// (and so blurry) when CSS stretched the canvas back up to the modal width
|
|
// — match the full export width for Square/Story (1080) so the preview is
|
|
// pixel-crisp at any modal size, and only Twitter scales down (1200 → 1080).
|
|
const PREVIEW_MAX_WIDTH = 1080;
|
|
|
|
/** 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, displayCssPx: number, signal?: AbortSignal): Promise<ImageBitmap | null> {
|
|
if (!album.coverArt) return null;
|
|
try {
|
|
const blob = await loadCoverBlobForExport(coverArtRef(album.coverArt), displayCssPx, 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<HTMLImageElement> {
|
|
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(--accent-2))', 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<HTMLCanvasElement> {
|
|
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 ────────────────────────────────────────────────────────────────
|
|
// Match the export tile resolution so preview covers downsample crisply
|
|
// into the (now full-width) canvas instead of upscaling 256 → ~300 and
|
|
// blurring every album thumbnail.
|
|
const desiredTilePx = 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<Blob> {
|
|
const canvas = await renderAlbumCardCanvas(opts);
|
|
return await new Promise<Blob>((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;
|
|
}
|