Add installable PWA, score export (MIDI/MusicXML/PDF), and persist theme across reloads
This commit is contained in:
@@ -121,6 +121,16 @@ PDF to MusicXML uses [Audiveris](https://github.com/Audiveris/audiveris), an ope
|
||||
|
||||
Without the JAR, MIDI / MusicXML import still works, only PDF import is unavailable.
|
||||
|
||||
### Optional: PDF export via LilyPond
|
||||
|
||||
PDF export uses [LilyPond](https://lilypond.org/) for typesetting. It's not bundled by default (heavy install, ~200 MB).
|
||||
|
||||
- **macOS**: `brew install lilypond`
|
||||
- **Linux**: `sudo apt install lilypond`
|
||||
- **Docker**: add `lilypond` to `backend/Dockerfile`'s `apt-get install` line, then `docker compose build backend`
|
||||
|
||||
Restart the backend after installing. Without LilyPond, MIDI and MusicXML export still work; only PDF export is unavailable.
|
||||
|
||||
## Using the app on iPad / iPhone (same network)
|
||||
|
||||
1. In `frontend/vite.config.ts`, add `host: true` to the `server` block (or run `pnpm --filter frontend dev --host`)
|
||||
@@ -197,7 +207,7 @@ The MIDI-to-MusicXML conversion runs on the backend. Check that the backend is u
|
||||
<details>
|
||||
<summary><strong>No sound from the on-screen piano</strong></summary>
|
||||
|
||||
The first interaction (any click) wakes the audio context. If you still hear nothing, open DevTools > Console and look for errors related to `AudioContext` or sample fetches from `danigb.github.io` / `gleitz.github.io`. Some corporate networks block these CDNs.
|
||||
The first interaction (any click) wakes the audio context. If you still hear nothing, open DevTools > Console and look for errors related to `AudioContext` or sample fetches from `smpldsnds.github.io` / `gleitz.github.io`. Some corporate networks block these CDNs.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -64,9 +64,25 @@ async def export_pdf(req: ExportPdfRequest, background_tasks: BackgroundTasks):
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# Surface actionable runtime errors (e.g. missing LilyPond) to the client
|
||||
# so the user knows what to install instead of seeing a generic message.
|
||||
# NOTE: musicxml_to_pdf runs in a ProcessPoolExecutor; exceptions get
|
||||
# round-tripped through pickle, so we match by message substring rather
|
||||
# than exception type (which can be wrapped / mutated on the way back).
|
||||
logger.exception("export_pdf failed")
|
||||
raise HTTPException(status_code=500, detail="PDF export failed. Please try again.")
|
||||
msg = str(e)
|
||||
if "lilypond" in msg.lower():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"PDF export needs LilyPond on the backend (not bundled by default).\n\n"
|
||||
"Quick install: brew install lilypond (macOS) or apt install lilypond "
|
||||
"(Linux). For Docker, see the README section 'Optional: PDF export via "
|
||||
"LilyPond'. MIDI and MusicXML export work without it."
|
||||
),
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"PDF export failed: {msg or 'Unknown error'}")
|
||||
|
||||
|
||||
@router.post("/mp3")
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import bus from "../../engine/EventBus";
|
||||
import { PianoKeyboard } from "./PianoKeyboard";
|
||||
import { useAppStore, COLOR_PRESET_VALUES } from "../../store/useAppStore";
|
||||
@@ -11,6 +11,9 @@ export function PianoKeyboardView() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const kbRef = useRef<PianoKeyboard | null>(null);
|
||||
/** Flips true once PixiJS has finished drawing the keys; we fade the canvas in
|
||||
* and the skeleton out at that point. Avoids the brief "black bar" flash. */
|
||||
const [pianoReady, setPianoReady] = useState(false);
|
||||
const { settings } = useAppStore();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,7 +22,8 @@ export function PianoKeyboardView() {
|
||||
const container = containerRef.current;
|
||||
const width = container.clientWidth || window.innerWidth;
|
||||
|
||||
const themeColors = settings.colorTheme === "custom"
|
||||
const themeColors =
|
||||
settings.colorTheme === "custom"
|
||||
? settings.customColors
|
||||
: COLOR_PRESET_VALUES[settings.colorTheme];
|
||||
|
||||
@@ -41,7 +45,9 @@ export function PianoKeyboardView() {
|
||||
},
|
||||
});
|
||||
kbRef.current = kb;
|
||||
kb.init(canvasRef.current);
|
||||
// init() is async (awaits PIXI.Application.init() internally).
|
||||
// Flip the ready flag once it resolves so the skeleton can fade out.
|
||||
kb.init(canvasRef.current).then(() => setPianoReady(true));
|
||||
|
||||
const onNoteOn = (note: NoteEvent) => kb.noteOn(note, "playback");
|
||||
const onNoteOff = (note: NoteEvent) => kb.noteOff(note, "playback");
|
||||
@@ -86,7 +92,8 @@ export function PianoKeyboardView() {
|
||||
showFingering: settings.showFingering,
|
||||
showHandColors: settings.showHandColors,
|
||||
useFlats: settings.useFlats,
|
||||
customColors: settings.colorTheme === "custom"
|
||||
customColors:
|
||||
settings.colorTheme === "custom"
|
||||
? settings.customColors
|
||||
: COLOR_PRESET_VALUES[settings.colorTheme],
|
||||
});
|
||||
@@ -110,9 +117,43 @@ export function PianoKeyboardView() {
|
||||
// matches the look of a real piano's wooden frame regardless of app theme.
|
||||
background: "#0d0d10",
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
|
||||
{/* Skeleton: matches the falling-notes pane above so the piano slot looks
|
||||
like one continuous panel until PixiJS finishes drawing the keys.
|
||||
We replicate the falling-notes background EXACTLY (base color + the same
|
||||
purple radial gradient), but mirror the gradient anchor to `50% 0%` so the
|
||||
brightest tint is at the TOP of the skeleton - meeting the brightest tint
|
||||
at the BOTTOM of the falling-notes pane. Result: no color seam at the boundary.
|
||||
Note: only this overlay's color matches the falling-notes pane - the piano
|
||||
container's `#0d0d10` background (the dark "wooden frame" that shows through
|
||||
key gaps after init) is intentionally NOT changed. */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"radial-gradient(ellipse at 50% 0%, rgba(147,51,234,0.07) 0%, transparent 65%), " +
|
||||
(settings.theme === "dark" ? "#141428" : "#eef1fa"),
|
||||
opacity: pianoReady ? 0 : 1,
|
||||
transition: "opacity 0.2s ease-out",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
opacity: pianoReady ? 1 : 0,
|
||||
transition: "opacity 0.15s ease-in",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,12 +32,20 @@ export function TransportControls() {
|
||||
loadPdfFile,
|
||||
isLoadingDocument,
|
||||
loadingMessage,
|
||||
exportMidi,
|
||||
exportMusicXml,
|
||||
exportPdf,
|
||||
isExportingPdf,
|
||||
} = useAppStore();
|
||||
|
||||
const [speedOpen, setSpeedOpen] = useState(false);
|
||||
const [speedRect, setSpeedRect] = useState<{ bottom: number; right: number } | null>(null);
|
||||
const speedContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [exportRect, setExportRect] = useState<{ bottom: number; left: number } | null>(null);
|
||||
const exportContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// When opening, capture fixed position so popover escapes the overflow:auto container
|
||||
const toggleSpeed = () => {
|
||||
if (!speedOpen && speedContainerRef.current) {
|
||||
@@ -47,6 +55,24 @@ export function TransportControls() {
|
||||
setSpeedOpen((v) => !v);
|
||||
};
|
||||
|
||||
const toggleExport = () => {
|
||||
if (!exportOpen && exportContainerRef.current) {
|
||||
const r = exportContainerRef.current.getBoundingClientRect();
|
||||
setExportRect({ bottom: window.innerHeight - r.top + 8, left: r.left });
|
||||
}
|
||||
setExportOpen((v) => !v);
|
||||
};
|
||||
|
||||
// Close export popover on outside click
|
||||
useEffect(() => {
|
||||
if (!exportOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!exportContainerRef.current?.contains(e.target as Node)) setExportOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [exportOpen]);
|
||||
|
||||
// Close speed popover on outside click
|
||||
useEffect(() => {
|
||||
if (!speedOpen) return;
|
||||
@@ -227,6 +253,98 @@ export function TransportControls() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* ── Zone 1b: Export dropdown - visually paired with Import (same accent style) ── */}
|
||||
<div ref={exportContainerRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={toggleExport}
|
||||
title={
|
||||
doc ? "Save the current score in another format" : "Load a file to enable export"
|
||||
}
|
||||
aria-label="Export current score"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={exportOpen}
|
||||
disabled={!doc}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 32,
|
||||
width: 32,
|
||||
padding: 0,
|
||||
borderRadius: 7,
|
||||
cursor: doc ? "pointer" : "not-allowed",
|
||||
flexShrink: 0,
|
||||
background: doc ? "rgba(147,51,234,0.14)" : "var(--color-surface-2)",
|
||||
border: doc ? "1px solid rgba(147,51,234,0.35)" : "1px solid var(--color-border)",
|
||||
color: doc ? "var(--color-accent-text)" : "var(--color-text-muted)",
|
||||
opacity: doc ? 1 : 0.55,
|
||||
transition: "background 0.12s",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (doc) (e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.24)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (doc) (e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)";
|
||||
}}
|
||||
>
|
||||
{isExportingPdf ? <SpinnerIcon /> : <DownloadIcon />}
|
||||
</button>
|
||||
{exportOpen && exportRect && (
|
||||
<div
|
||||
role="menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: exportRect.bottom,
|
||||
left: exportRect.left,
|
||||
zIndex: 1100,
|
||||
minWidth: 180,
|
||||
padding: 6,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<ExportMenuItem
|
||||
label="MIDI"
|
||||
sublabel=".midi"
|
||||
enabled={!!doc?.midiBuffer}
|
||||
onClick={() => {
|
||||
exportMidi();
|
||||
setExportOpen(false);
|
||||
}}
|
||||
disabledReason="No MIDI data in this document"
|
||||
/>
|
||||
<ExportMenuItem
|
||||
label="MusicXML"
|
||||
sublabel=".musicxml"
|
||||
enabled={!!doc?.musicXml}
|
||||
onClick={() => {
|
||||
exportMusicXml();
|
||||
setExportOpen(false);
|
||||
}}
|
||||
disabledReason="No MusicXML data in this document"
|
||||
/>
|
||||
<ExportMenuItem
|
||||
label="PDF sheet music"
|
||||
sublabel={isExportingPdf ? "rendering..." : ".pdf"}
|
||||
enabled={!!doc?.musicXml && !isExportingPdf}
|
||||
onClick={() => {
|
||||
void exportPdf();
|
||||
setExportOpen(false);
|
||||
}}
|
||||
disabledReason={
|
||||
!doc?.musicXml ? "No MusicXML data in this document" : "Already rendering a PDF"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Zone 2: Playback controls ───────────────────────── */}
|
||||
@@ -624,6 +742,58 @@ function Divider() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function ExportMenuItem({
|
||||
label,
|
||||
sublabel,
|
||||
enabled,
|
||||
onClick,
|
||||
disabledReason,
|
||||
}: {
|
||||
label: string;
|
||||
sublabel: string;
|
||||
enabled: boolean;
|
||||
onClick: () => void;
|
||||
disabledReason: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={enabled ? onClick : undefined}
|
||||
disabled={!enabled}
|
||||
title={enabled ? `Save as ${sublabel}` : disabledReason}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "8px 10px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 5,
|
||||
cursor: enabled ? "pointer" : "not-allowed",
|
||||
opacity: enabled ? 1 : 0.45,
|
||||
color: "var(--color-text)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
fontFamily: "inherit",
|
||||
textAlign: "left",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (enabled) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-2)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{sublabel}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
const PlayIcon = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -661,6 +831,12 @@ const UploadIcon = () => (
|
||||
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z" />
|
||||
</svg>
|
||||
);
|
||||
const DownloadIcon = () => (
|
||||
// Vertical mirror of UploadIcon - same stem width, same bar at bottom, arrow flipped.
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 4h6v6h4l-7 7-7-7h4v-6zm-4 14h14v2H5v-2z" />
|
||||
</svg>
|
||||
);
|
||||
const SpinnerIcon = () => (
|
||||
<svg
|
||||
width="12"
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface AppState {
|
||||
isLoadingDocument: boolean;
|
||||
/** True while /fingering/generate is in flight */
|
||||
isGeneratingFingering: boolean;
|
||||
/** True while /export/pdf is in flight (PDF rendering is the only async export) */
|
||||
isExportingPdf: boolean;
|
||||
loadError: string | null;
|
||||
/** Non-null during slow imports (e.g. OMR) to show a descriptive message */
|
||||
loadingMessage: string | null;
|
||||
@@ -139,6 +141,9 @@ export interface AppState {
|
||||
loadMusicXmlFile: (file: File) => Promise<void>;
|
||||
loadPdfFile: (file: File) => Promise<void>;
|
||||
generateFingering: () => Promise<void>;
|
||||
exportMidi: () => void;
|
||||
exportMusicXml: () => void;
|
||||
exportPdf: () => Promise<void>;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
@@ -155,13 +160,46 @@ export interface AppState {
|
||||
syncEngineState: () => void;
|
||||
}
|
||||
|
||||
/** localStorage key for persisting the user's theme choice across reloads. */
|
||||
const THEME_STORAGE_KEY = "b2b-theme";
|
||||
|
||||
/**
|
||||
* Initial theme - the standard "system default + persisted override" pattern
|
||||
* used by GitHub, Linear, Notion, Vercel, etc.:
|
||||
*
|
||||
* 1. If the user has previously toggled, honor that choice (localStorage)
|
||||
* 2. Otherwise match the OS preference (`prefers-color-scheme: dark`)
|
||||
* 3. Fall back to light
|
||||
*
|
||||
* Runs at module load (before React mounts) so the first paint is correct -
|
||||
* no flash of wrong theme on reload.
|
||||
*/
|
||||
function getInitialTheme(): "dark" | "light" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
} catch {
|
||||
/* localStorage may throw in private/incognito mode */
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) return "dark";
|
||||
} catch {
|
||||
/* matchMedia not supported (very old browsers) */
|
||||
}
|
||||
|
||||
return "light";
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
showFingering: false,
|
||||
showFingeringOnNotes: false,
|
||||
showHandColors: true,
|
||||
useFlats: false,
|
||||
viewportSeconds: 4.0,
|
||||
theme: "light",
|
||||
theme: getInitialTheme(),
|
||||
activeHands: new Set(["left", "right"]),
|
||||
volume: 0.8,
|
||||
noteFilter: "all",
|
||||
@@ -225,6 +263,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
document: null,
|
||||
isLoadingDocument: false,
|
||||
isGeneratingFingering: false,
|
||||
isExportingPdf: false,
|
||||
loadError: null,
|
||||
loadingMessage: null,
|
||||
status: "stopped",
|
||||
@@ -443,6 +482,58 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Export ──────────────────────────────────────────────────────────────
|
||||
// MIDI + MusicXML are blob-and-download from data we already have in memory.
|
||||
// PDF requires a backend round-trip (music21 + LilyPond / Audiveris).
|
||||
// MP3 is intentionally NOT here yet - it needs an OfflineAudioContext WAV
|
||||
// pipeline which is a separate feature.
|
||||
|
||||
exportMidi: () => {
|
||||
const { document: doc } = get();
|
||||
if (!doc?.midiBuffer) return;
|
||||
triggerDownload(
|
||||
new Blob([doc.midiBuffer], { type: "audio/midi" }),
|
||||
`${safeFilename(doc.title)}.midi`
|
||||
);
|
||||
},
|
||||
|
||||
exportMusicXml: () => {
|
||||
const { document: doc } = get();
|
||||
if (!doc?.musicXml) return;
|
||||
triggerDownload(
|
||||
new Blob([doc.musicXml], { type: "application/vnd.recordare.musicxml+xml" }),
|
||||
`${safeFilename(doc.title)}.musicxml`
|
||||
);
|
||||
},
|
||||
|
||||
exportPdf: async () => {
|
||||
const { document: doc } = get();
|
||||
if (!doc?.musicXml) return;
|
||||
|
||||
set({ isExportingPdf: true, loadError: null });
|
||||
try {
|
||||
const res = await fetch("/api/export/pdf", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ musicxml: doc.musicXml }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(body.detail ?? `Server error ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
triggerDownload(blob, `${safeFilename(doc.title)}.pdf`);
|
||||
} catch (err) {
|
||||
// Use the backend's detail verbatim - it's already worded for the user.
|
||||
// Don't prefix with "PDF export failed:" because the toast's warning icon
|
||||
// already conveys the failure state and the backend message is descriptive.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
set({ loadError: msg });
|
||||
} finally {
|
||||
set({ isExportingPdf: false });
|
||||
}
|
||||
},
|
||||
|
||||
play: () => {
|
||||
const { settings, status } = get();
|
||||
// Speed trainer: seed the engine's current state before play
|
||||
@@ -510,6 +601,16 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
syncEngine.setWaitForHand(patch.waitForHand);
|
||||
if ("renderOffset" in patch && patch.renderOffset !== undefined)
|
||||
syncEngine.setRenderOffset(patch.renderOffset);
|
||||
|
||||
// Persist theme choice across reloads. localStorage write is fire-and-forget;
|
||||
// private/incognito mode can throw, in which case the choice just won't persist.
|
||||
if ("theme" in patch && (patch.theme === "dark" || patch.theme === "light")) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, patch.theme);
|
||||
} catch {
|
||||
/* localStorage unavailable - ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
resetSettings: () => {
|
||||
@@ -573,6 +674,27 @@ function parseMidiInWorker(buffer: ArrayBuffer, id: string, title: string): Prom
|
||||
});
|
||||
}
|
||||
|
||||
// ── Export helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Make a string safe for use as a filename: strip path separators, control chars,
|
||||
* and characters disallowed on Windows. Browsers further sanitize on download. */
|
||||
function safeFilename(name: string): string {
|
||||
return name.replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, "_").slice(0, 200) || "untitled";
|
||||
}
|
||||
|
||||
/** Create an anchor, click it to trigger a download, then revoke the object URL. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Defer revoke - some browsers race the download otherwise.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
// ── Fingering helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const STEP_TO_SEMITONE: Record<string, number> = {
|
||||
|
||||
+83
-1
@@ -1,10 +1,92 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
// Silently install updates - new SW takes over on next page open (no popup, no reload prompt).
|
||||
// Matches the consumer-app pattern (Gmail, Spotify Web, etc.).
|
||||
registerType: "autoUpdate",
|
||||
|
||||
// Off in dev so HMR isn't interfered with by cached responses.
|
||||
devOptions: { enabled: false },
|
||||
|
||||
// App icons + display name shown on install / home-screen / dock.
|
||||
manifest: {
|
||||
name: "Bach to Basics",
|
||||
short_name: "Bach to Basics",
|
||||
description:
|
||||
"Browser-based piano practice tool - falling notes, sheet music, MIDI, and practice tools",
|
||||
theme_color: "#9333ea", // matches the purple accent
|
||||
background_color: "#0a0a0a",
|
||||
display: "standalone",
|
||||
orientation: "landscape-primary",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
icons: [
|
||||
// Reuses existing public/ assets - logo.png is the high-res one,
|
||||
// apple-touch-icon and favicon are smaller fallbacks.
|
||||
{
|
||||
src: "/logo.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/logo.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/apple-touch-icon.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/favicon.png",
|
||||
sizes: "32x32",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
workbox: {
|
||||
// Precache built static assets so the shell loads instantly + works offline.
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||
|
||||
// Don't try to precache the soundfont folder (large; fetched lazily by AlphaTab).
|
||||
globIgnores: ["**/soundfont/**"],
|
||||
|
||||
// Critical: NEVER cache backend API calls - we want fresh data, not a stale
|
||||
// cached response (e.g. old fingerings, old transcription results).
|
||||
navigateFallbackDenylist: [/^\/api/],
|
||||
|
||||
// Runtime caching for cross-origin sample CDNs (smplr): serve fresh when online,
|
||||
// fall back to cache when offline so the on-screen piano still works.
|
||||
// Hosts: gleitz (Soundfont), smpldsnds (SplendidGrandPiano + ElectricPiano in 0.20+),
|
||||
// danigb (legacy, kept for older smplr versions).
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/(smpldsnds|gleitz|danigb)\.github\.io\//,
|
||||
handler: "StaleWhileRevalidate",
|
||||
options: {
|
||||
cacheName: "smplr-samples",
|
||||
expiration: { maxEntries: 500, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Bigger than the default 2 MiB so AlphaTab's bundle isn't rejected from precache.
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@bach-to-basics/shared": resolve(__dirname, "../shared/types/index.ts"),
|
||||
|
||||
Generated
+3060
-15
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user