mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(stats): shareable Top-Albums card export (#425)
* 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
This commit is contained in:
committed by
GitHub
parent
297c9f1125
commit
7064ca500e
@@ -13,9 +13,11 @@ interface Props {
|
||||
moreText?: string;
|
||||
onLoadMore?: () => Promise<void>;
|
||||
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<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
@@ -71,15 +73,16 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
)}
|
||||
<div className="album-row-nav">
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
{headerExtra}
|
||||
<button
|
||||
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!showLeft}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
<button
|
||||
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!showRight}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
|
||||
import { showToast } from '../utils/toast';
|
||||
import {
|
||||
exportAlbumCardBlob,
|
||||
renderAlbumCardCanvas,
|
||||
ExportFormat,
|
||||
ExportGridSize,
|
||||
} from '../utils/exportAlbumCard';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Pre-loaded albums (e.g. from the statistics page). The modal will fetch
|
||||
* more on open if this list is shorter than 25 (max grid 5×5). */
|
||||
albums: SubsonicAlbum[];
|
||||
/** Footer-right meta string, e.g. "Most Played" or a date. */
|
||||
meta?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MAX_NEEDED = 25; // 5 × 5 grid
|
||||
|
||||
const FORMATS: { key: ExportFormat; ratioBox: { w: number; h: number } }[] = [
|
||||
{ key: 'story', ratioBox: { w: 36, h: 64 } },
|
||||
{ key: 'square', ratioBox: { w: 50, h: 50 } },
|
||||
{ key: 'twitter', ratioBox: { w: 64, h: 36 } },
|
||||
];
|
||||
|
||||
const GRID_SIZES: ExportGridSize[] = [3, 4, 5];
|
||||
|
||||
export default function StatsExportModal({ open, albums, meta, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [format, setFormat] = useState<ExportFormat>('square');
|
||||
const [gridSize, setGridSize] = useState<ExportGridSize>(3);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [topUpAlbums, setTopUpAlbums] = useState<SubsonicAlbum[] | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewSeqRef = useRef(0);
|
||||
|
||||
const effectiveAlbums = topUpAlbums ?? albums;
|
||||
const required = gridSize * gridSize;
|
||||
const enoughAlbums = effectiveAlbums.length >= required;
|
||||
|
||||
// On open: if the caller-provided list is shorter than the largest grid,
|
||||
// fetch up to 25 in the background so the user can pick 4×4 / 5×5 even
|
||||
// when the entry surface only loaded a few albums.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (albums.length >= MAX_NEEDED) {
|
||||
setTopUpAlbums(albums);
|
||||
return;
|
||||
}
|
||||
setTopUpAlbums(null);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const more = await getAlbumList('frequent', MAX_NEEDED, 0);
|
||||
if (cancelled) return;
|
||||
setTopUpAlbums(more.length > albums.length ? more : albums);
|
||||
} catch {
|
||||
if (!cancelled) setTopUpAlbums(albums);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, albums]);
|
||||
|
||||
const title = t('statistics.exportFooterLabel');
|
||||
|
||||
// Live preview: re-renders on format / gridSize / albums changes.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!enoughAlbums) return;
|
||||
const host = previewRef.current;
|
||||
if (!host) return;
|
||||
const seq = ++previewSeqRef.current;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const canvas = await renderAlbumCardCanvas({
|
||||
albums: effectiveAlbums,
|
||||
format,
|
||||
gridSize,
|
||||
title,
|
||||
meta,
|
||||
preview: true,
|
||||
});
|
||||
if (cancelled || seq !== previewSeqRef.current) return;
|
||||
// Replace any previous preview canvas.
|
||||
host.replaceChildren(canvas);
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = 'auto';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.borderRadius = '12px';
|
||||
canvas.style.boxShadow = '0 8px 24px rgba(0,0,0,0.35)';
|
||||
} catch (e) {
|
||||
if (!cancelled && seq === previewSeqRef.current) {
|
||||
host.textContent = String(e);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, format, gridSize, effectiveAlbums, enoughAlbums, title, meta]);
|
||||
|
||||
// Esc-to-close.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const onSave = async () => {
|
||||
if (saving || !enoughAlbums) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const blob = await exportAlbumCardBlob({ albums: effectiveAlbums, format, gridSize, title, meta });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
|
||||
const suggested = `psysonic-top-albums-${gridSize}x${gridSize}-${format}-${stamp}.png`;
|
||||
const path = await save({
|
||||
title: t('statistics.exportSave'),
|
||||
defaultPath: suggested,
|
||||
filters: [{ name: 'PNG', extensions: ['png'] }],
|
||||
});
|
||||
if (!path) {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||
await writeFile(path, buf);
|
||||
showToast(t('statistics.exportSaved'), 2400, 'info');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('[stats-export] save failed', err);
|
||||
showToast(t('statistics.exportSaveFailed'), 3200, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ maxWidth: '720px', width: 'min(720px, 92vw)' }}
|
||||
>
|
||||
<button className="modal-close" onClick={onClose} aria-label={t('statistics.exportCancel')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<h3 style={{ marginBottom: '0.25rem', fontFamily: 'var(--font-display)' }}>
|
||||
{t('statistics.exportTitle')}
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', fontSize: '0.875rem' }}>
|
||||
{t('statistics.exportSubtitle')}
|
||||
</p>
|
||||
|
||||
{/* Format */}
|
||||
<div style={{ marginBottom: '0.875rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('statistics.exportFormat')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{FORMATS.map(f => {
|
||||
const active = format === f.key;
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFormat(f.key)}
|
||||
className="btn btn-surface"
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
|
||||
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: f.ratioBox.w * 0.4,
|
||||
height: f.ratioBox.h * 0.4,
|
||||
background: active ? 'var(--accent)' : 'var(--text-muted)',
|
||||
opacity: active ? 0.9 : 0.5,
|
||||
borderRadius: 2,
|
||||
}} />
|
||||
{t(`statistics.exportFormat${f.key[0].toUpperCase()}${f.key.slice(1)}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('statistics.exportGrid')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{GRID_SIZES.map(n => {
|
||||
const active = gridSize === n;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setGridSize(n)}
|
||||
className="btn btn-surface"
|
||||
style={{
|
||||
padding: '0.5rem 0.875rem',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--glass-border)'}`,
|
||||
background: active ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : undefined,
|
||||
}}
|
||||
>
|
||||
{t('statistics.exportGridLabel', { n })}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('statistics.exportPreview')}
|
||||
</div>
|
||||
<PreviewFrame format={format}>
|
||||
{!enoughAlbums ? (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '0.875rem',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{t('statistics.exportNotEnough', { count: required, n: gridSize })}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={previewRef} style={{ width: '100%' }} />
|
||||
)}
|
||||
</PreviewFrame>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={onClose} disabled={saving}>
|
||||
{t('statistics.exportCancel')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onSave}
|
||||
disabled={!enoughAlbums || saving}
|
||||
>
|
||||
{saving ? t('statistics.exportSaving') : t('statistics.exportSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
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 (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxHeight: '52vh',
|
||||
aspectRatio: aspect,
|
||||
margin: '0 auto',
|
||||
maxWidth: format === 'story' ? '320px' : undefined,
|
||||
background: 'var(--glass-bg)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--glass-border)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<LastfmPeriod>('1month');
|
||||
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
|
||||
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
|
||||
@@ -270,6 +274,17 @@ export default function Statistics() {
|
||||
albums={frequent}
|
||||
onLoadMore={() => loadMore('frequent', frequent, setFrequent)}
|
||||
moreText={t('statistics.loadMore')}
|
||||
headerExtra={frequent.length >= 9 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nav-btn"
|
||||
onClick={() => setExportOpen(true)}
|
||||
data-tooltip={t('statistics.exportTitle')}
|
||||
aria-label={t('statistics.exportTitle')}
|
||||
>
|
||||
<Share2 size={18} />
|
||||
</button>
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<AlbumRow
|
||||
@@ -384,6 +399,11 @@ export default function Statistics() {
|
||||
|
||||
</div>
|
||||
)}
|
||||
<StatsExportModal
|
||||
open={exportOpen}
|
||||
albums={frequent}
|
||||
onClose={() => setExportOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ExportFormat, { w: number; h: number }> = {
|
||||
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<ImageBitmap | null> {
|
||||
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<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(--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<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 ────────────────────────────────────────────────────────────────
|
||||
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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user