Files
Psychotoxical-psysonic/src/components/StatsExportModal.tsx
T
Frank Stellmacher fcae65db80 fix(stats-export): full-resolution preview, fit Square in modal (#740)
* fix(stats-export): full-resolution preview, fit Square in modal

Reported on the Psysonic Discord — three connected issues with the
**Share Top Albums** dialog on the Statistics page:

1. **Square preview clipped.** `PreviewFrame` capped only `maxHeight: 52vh`
   while letting `width: 100%` + `aspectRatio: 1/1` push the 1:1 canvas
   far past the cap; `overflow: hidden` then chopped the bottom rows
   with no way to scroll them in (Twitter is width-bound, Story is
   width-capped at 320px, so Square was the only ratio that overflowed).
   Cap **both** dimensions per format — Square gets `maxWidth: 52vh`,
   Story gets `min(320px, calc(52vh * 9/16))`, Twitter stays
   width-bound by the modal — so the preview always fits.

2. **Outer scrolling didn't reveal the bottom.** Same root cause: the
   modal-content scroll only exposed the action buttons; the canvas
   itself sat inside `overflow: hidden` with nothing more to scroll to.
   Fixed implicitly by (1).

3. **Preview is blurry.** `PREVIEW_MAX_WIDTH = 540` rendered a 540×540
   canvas that CSS then stretched back to ~676px in a 720px-wide modal,
   and `desiredTilePx = 256` decoded covers at 256 px only to upscale
   them into ~300 px tiles. Render the preview canvas at the full
   export width (1080) and decode covers at the export tile size (600)
   so text is sharp and covers downsample crisply.

* docs(changelog): stats-export preview fix (PR #740)
2026-05-17 00:55:08 +02:00

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getAlbumList } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
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 { showToast } from '../utils/ui/toast';
import {
exportAlbumCardBlob,
renderAlbumCardCanvas,
ExportFormat,
ExportGridSize,
} from '../utils/export/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 }) => {
// Aspect-aware bounds: cap BOTH dimensions so the preview always fits inside
// the modal at any ratio. The earlier version capped only `maxHeight`, so
// Square (1:1) tried to span the full modal width — the 1:1 canvas then
// overflowed `maxHeight: 52vh` and the bottom rows were clipped by
// `overflow: hidden` with no way to scroll them into view.
const { aspect, maxWidth } = useMemo(() => {
if (format === 'story') return { aspect: '9 / 16', maxWidth: 'min(320px, calc(52vh * 9 / 16))' };
if (format === 'square') return { aspect: '1 / 1', maxWidth: '52vh' };
return { aspect: '16 / 9', maxWidth: undefined as string | undefined };
}, [format]);
return (
<div style={{
position: 'relative',
width: '100%',
aspectRatio: aspect,
margin: '0 auto',
maxWidth,
maxHeight: '52vh',
background: 'var(--glass-bg)',
borderRadius: 12,
border: '1px solid var(--glass-border)',
overflow: 'hidden',
}}>
{children}
</div>
);
};