refactor: backend-only transpose, remove client-side transpose logic
This commit is contained in:
@@ -94,9 +94,16 @@ export async function listSongs(q = "", sort = "date", order = "desc"): Promise<
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSong(id: string, applyCapo = false): Promise<Song | null> {
|
export async function getSong(
|
||||||
const url = applyCapo
|
id: string,
|
||||||
? `${API_BASE}/songs/${id}?apply_capo=true`
|
opts: { applyCapo?: boolean; transpose?: number } = {},
|
||||||
|
): Promise<Song | null> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.applyCapo) params.set("apply_capo", "true");
|
||||||
|
if (opts.transpose && opts.transpose !== 0)
|
||||||
|
params.set("transpose", String(opts.transpose));
|
||||||
|
const url = params.size
|
||||||
|
? `${API_BASE}/songs/${id}?${params}`
|
||||||
: `${API_BASE}/songs/${id}`;
|
: `${API_BASE}/songs/${id}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { Song } from "./types";
|
|
||||||
|
|
||||||
const NOTES_SHARP = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
||||||
const NOTES_FLAT = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
|
|
||||||
|
|
||||||
function transposeChord(chord: string, semitones: number): string {
|
|
||||||
const match = chord.match(/^([A-G][#b]?)(.*)/);
|
|
||||||
if (!match) return chord;
|
|
||||||
const [, root, descriptor] = match;
|
|
||||||
const idx = NOTES_SHARP.indexOf(root) !== -1
|
|
||||||
? NOTES_SHARP.indexOf(root)
|
|
||||||
: NOTES_FLAT.indexOf(root);
|
|
||||||
if (idx === -1) return chord;
|
|
||||||
const newIdx = ((idx + semitones) % 12 + 12) % 12;
|
|
||||||
const notes = semitones >= 0 ? NOTES_SHARP : NOTES_FLAT;
|
|
||||||
return notes[newIdx] + descriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transposeSong(song: Song, semitones: number): Song {
|
|
||||||
if (semitones === 0) return song;
|
|
||||||
return {
|
|
||||||
...song,
|
|
||||||
sections: song.sections.map((section) => ({
|
|
||||||
...section,
|
|
||||||
lines: section.lines.map((line) => ({
|
|
||||||
...line,
|
|
||||||
chords: line.chords.map((cp) => ({
|
|
||||||
...cp,
|
|
||||||
chord: transposeChord(cp.chord, semitones),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import { EditSongSheet } from "~/components/edit-song-sheet";
|
|||||||
import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
||||||
import { SectionNav } from "~/components/section-nav";
|
import { SectionNav } from "~/components/section-nav";
|
||||||
import { AutoScrollControls } from "~/components/auto-scroll-controls";
|
import { AutoScrollControls } from "~/components/auto-scroll-controls";
|
||||||
import { transposeSong } from "~/lib/transpose";
|
|
||||||
import { extractUniqueChords } from "~/lib/song-utils";
|
import { extractUniqueChords } from "~/lib/song-utils";
|
||||||
import { getSong } from "~/lib/api";
|
import { getSong } from "~/lib/api";
|
||||||
import { useAuth } from "~/lib/auth";
|
import { useAuth } from "~/lib/auth";
|
||||||
@@ -82,23 +81,19 @@ export default function SongDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getSong(id)
|
getSong(id, { transpose: initOffset })
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
setBaseSong(s);
|
setBaseSong(s);
|
||||||
setDisplayedSong(s);
|
setDisplayedSong(s);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [id]); // eslint-disable-line
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (applyCapo && baseSong?.meta.capo) {
|
getSong(id, { applyCapo, transpose: offset }).then((s) => {
|
||||||
getSong(id, true).then((s) => {
|
if (s) setDisplayedSong(s);
|
||||||
if (s) setDisplayedSong(s);
|
});
|
||||||
});
|
}, [id, applyCapo, offset]);
|
||||||
} else {
|
|
||||||
setDisplayedSong(baseSong);
|
|
||||||
}
|
|
||||||
}, [applyCapo]); // eslint-disable-line
|
|
||||||
|
|
||||||
function handleOffsetChange(newOffset: number) {
|
function handleOffsetChange(newOffset: number) {
|
||||||
setOffset(newOffset);
|
setOffset(newOffset);
|
||||||
@@ -158,9 +153,8 @@ export default function SongDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayed = transposeSong(displayedSong, offset);
|
const uniqueChords = extractUniqueChords(displayedSong.sections);
|
||||||
const uniqueChords = extractUniqueChords(displayed.sections);
|
const sectionItems = displayedSong.sections.map((s, i) => ({
|
||||||
const sectionItems = displayed.sections.map((s, i) => ({
|
|
||||||
label: s.label,
|
label: s.label,
|
||||||
index: i,
|
index: i,
|
||||||
}));
|
}));
|
||||||
@@ -200,7 +194,7 @@ export default function SongDetail() {
|
|||||||
>
|
>
|
||||||
<div className="max-w-lg mx-auto lg:max-w-none">
|
<div className="max-w-lg mx-auto lg:max-w-none">
|
||||||
<ChordChart
|
<ChordChart
|
||||||
sections={displayed.sections}
|
sections={displayedSong.sections}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
onChordClick={handleChordClick}
|
onChordClick={handleChordClick}
|
||||||
/>
|
/>
|
||||||
@@ -224,7 +218,6 @@ export default function SongDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom toolbar: section nav + auto-scroll */}
|
|
||||||
<div className="lg:hidden border-t border-border bg-background flex items-center">
|
<div className="lg:hidden border-t border-border bg-background flex items-center">
|
||||||
<SectionNav sections={sectionItems} onJump={handleSectionJump} />
|
<SectionNav sections={sectionItems} onJump={handleSectionJump} />
|
||||||
<AutoScrollControls scrollRef={scrollRef} />
|
<AutoScrollControls scrollRef={scrollRef} />
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub struct UpdateSongRequest {
|
|||||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||||
pub struct GetSongQuery {
|
pub struct GetSongQuery {
|
||||||
pub apply_capo: Option<bool>,
|
pub apply_capo: Option<bool>,
|
||||||
|
pub transpose: Option<i8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ pub async fn get_song(
|
|||||||
song
|
song
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let song = match params.transpose {
|
||||||
|
Some(semitones) if semitones != 0 => {
|
||||||
|
ChordTransposer.transpose_song(&song, semitones)
|
||||||
|
}
|
||||||
|
_ => song,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Json(song))
|
Ok(Json(song))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user