Compare commits
9 Commits
fb936a64b0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 792c9bf9ec | |||
| f513061405 | |||
| 9f844bac2e | |||
| 6912653906 | |||
| bb4d07055f | |||
| af328deac1 | |||
| 381f273f10 | |||
| 2a628db521 | |||
| 2f635c9b24 |
@@ -39,3 +39,4 @@ ug-parser = { path = "crates/adapters/ug-parser" }
|
|||||||
strip = true
|
strip = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
|||||||
114
app/app/components/auto-scroll-controls.tsx
Normal file
114
app/app/components/auto-scroll-controls.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Pause, Play } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Slider } from "~/components/ui/slider";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSpeed(): number {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem("autoScrollSpeed");
|
||||||
|
if (v) return parseFloat(v);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoScrollControls({ scrollRef }: Props) {
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState(loadSpeed);
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
const lastTimeRef = useRef<number>(0);
|
||||||
|
const cancelledByUser = useRef(false);
|
||||||
|
|
||||||
|
const accumulatorRef = useRef(0);
|
||||||
|
|
||||||
|
const tick = useCallback(
|
||||||
|
(time: number) => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (lastTimeRef.current) {
|
||||||
|
const dt = (time - lastTimeRef.current) / 1000;
|
||||||
|
accumulatorRef.current += speed * dt;
|
||||||
|
const px = Math.floor(accumulatorRef.current);
|
||||||
|
if (px >= 1) {
|
||||||
|
accumulatorRef.current -= px;
|
||||||
|
el.scrollBy({ top: px });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 1) {
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastTimeRef.current = time;
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
},
|
||||||
|
[scrollRef, speed],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (playing) {
|
||||||
|
lastTimeRef.current = 0;
|
||||||
|
accumulatorRef.current = 0;
|
||||||
|
cancelledByUser.current = false;
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
|
}, [playing, tick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
function handleUserScroll() {
|
||||||
|
if (playing && !cancelledByUser.current) {
|
||||||
|
cancelledByUser.current = true;
|
||||||
|
setPlaying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("touchstart", handleUserScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("touchstart", handleUserScroll);
|
||||||
|
}, [scrollRef, playing]);
|
||||||
|
|
||||||
|
function handleSpeedChange(value: number[]) {
|
||||||
|
const v = value[0];
|
||||||
|
setSpeed(v);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("autoScrollSpeed", String(v));
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => setPlaying((p) => !p)}
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<Pause className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Slider
|
||||||
|
min={10}
|
||||||
|
max={80}
|
||||||
|
step={5}
|
||||||
|
value={[speed]}
|
||||||
|
onValueChange={handleSpeedChange}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ function ChordRow({
|
|||||||
onChordClick?: (chord: string) => void;
|
onChordClick?: (chord: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={`relative font-mono ${sizeClass} text-primary`} style={{ height: '1.5em' }}>
|
<div className={`relative font-mono ${sizeClass} text-primary overflow-hidden`} style={{ height: '1.5em' }}>
|
||||||
{chords.map(({ offset, chord }, i) => (
|
{chords.map(({ offset, chord }, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
@@ -85,15 +85,17 @@ function LineBlock({
|
|||||||
|
|
||||||
function SectionBlock({
|
function SectionBlock({
|
||||||
section,
|
section,
|
||||||
|
index,
|
||||||
sizeClass,
|
sizeClass,
|
||||||
onChordClick,
|
onChordClick,
|
||||||
}: {
|
}: {
|
||||||
section: Section;
|
section: Section;
|
||||||
|
index: number;
|
||||||
sizeClass: string;
|
sizeClass: string;
|
||||||
onChordClick?: (chord: string) => void;
|
onChordClick?: (chord: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6" id={`section-${index}`}>
|
||||||
{section.label && (
|
{section.label && (
|
||||||
<p className="text-xs text-muted-foreground mb-1">[{section.label}]</p>
|
<p className="text-xs text-muted-foreground mb-1">[{section.label}]</p>
|
||||||
)}
|
)}
|
||||||
@@ -111,7 +113,7 @@ export function ChordChart({ sections, fontSize, onChordClick }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
{sections.map((section, i) => (
|
{sections.map((section, i) => (
|
||||||
<SectionBlock key={i} section={section} sizeClass={sizeClass} onChordClick={onChordClick} />
|
<SectionBlock key={i} section={section} index={i} sizeClass={sizeClass} onChordClick={onChordClick} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
46
app/app/components/section-nav.tsx
Normal file
46
app/app/components/section-nav.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { ScrollArea, ScrollBar } from "~/components/ui/scroll-area";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sections: Array<{ label: string | null; index: number }>;
|
||||||
|
onJump: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abbreviate(label: string): string {
|
||||||
|
const lower = label.toLowerCase().replace(/[-_ ]/g, "");
|
||||||
|
if (lower.startsWith("verse")) return label.replace(/verse\s*/i, "V");
|
||||||
|
if (lower.startsWith("chorus") || lower.startsWith("refrain")) return "C";
|
||||||
|
if (lower.startsWith("bridge")) return "Br";
|
||||||
|
if (lower.startsWith("prechorus")) return "PC";
|
||||||
|
if (lower.startsWith("intro")) return "In";
|
||||||
|
if (lower.startsWith("outro")) return "Out";
|
||||||
|
if (lower.startsWith("instrumental") || lower.startsWith("interlude"))
|
||||||
|
return "Int";
|
||||||
|
if (lower.startsWith("solo")) return "Sol";
|
||||||
|
if (label.length > 4) return label.slice(0, 3);
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionNav({ sections, onJump }: Props) {
|
||||||
|
const labeled = sections.filter((s) => s.label);
|
||||||
|
if (labeled.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="min-w-0 flex-1">
|
||||||
|
<div className="flex gap-1 px-3 py-1.5">
|
||||||
|
{labeled.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s.index}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs shrink-0"
|
||||||
|
onClick={() => onJump(s.index)}
|
||||||
|
>
|
||||||
|
{abbreviate(s.label!)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
import { Star } from "lucide-react";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import type { SongSummary } from "~/lib/types";
|
import type { SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
song: SongSummary;
|
song: SongSummary;
|
||||||
|
isFavorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SongCard({ song }: Props) {
|
export function SongCard({ song, isFavorite }: Props) {
|
||||||
return (
|
return (
|
||||||
<Link to={`/songs/${song.id}`}>
|
<Link to={`/songs/${song.id}`}>
|
||||||
<Card className="h-full hover:bg-accent transition-colors cursor-pointer">
|
<Card className="h-full hover:bg-accent transition-colors cursor-pointer relative">
|
||||||
|
{isFavorite && (
|
||||||
|
<Star className="absolute top-2 right-2 w-3.5 h-3.5 fill-primary text-primary" />
|
||||||
|
)}
|
||||||
<CardContent className="p-3 flex flex-col gap-1">
|
<CardContent className="p-3 flex flex-col gap-1">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{song.preview_chords.map((chord) => (
|
{song.preview_chords.map((chord) => (
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
} from "~/components/ui/dropdown-menu";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { ChevronUp, ChevronDown, Minus, Plus, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
Maximize,
|
||||||
|
Minus,
|
||||||
|
Minimize,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { SongMeta } from "~/lib/types";
|
import type { SongMeta } from "~/lib/types";
|
||||||
|
|
||||||
@@ -13,41 +27,100 @@ interface Props {
|
|||||||
onOffsetChange: (offset: number) => void;
|
onOffsetChange: (offset: number) => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
fontSize?: 'sm' | 'base' | 'lg';
|
fontSize?: "sm" | "base" | "lg";
|
||||||
onFontSizeChange?: (size: 'sm' | 'base' | 'lg') => void;
|
onFontSizeChange?: (size: "sm" | "base" | "lg") => void;
|
||||||
capo?: number;
|
capo?: number;
|
||||||
applyCapo?: boolean;
|
applyCapo?: boolean;
|
||||||
onToggleCapo?: () => void;
|
onToggleCapo?: () => void;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
onToggleFavorite?: () => void;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
onToggleFullscreen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, fontSize, onFontSizeChange, capo, applyCapo, onToggleCapo }: Props) {
|
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 transposedKey(key: string, offset: number): string {
|
||||||
|
const root = key.length >= 2 && (key[1] === "#" || key[1] === "b") ? key.slice(0, 2) : key.slice(0, 1);
|
||||||
|
const suffix = key.slice(root.length);
|
||||||
|
const notes = offset > 0 ? NOTES_SHARP : NOTES_FLAT;
|
||||||
|
const idx = NOTES_SHARP.indexOf(root) !== -1 ? NOTES_SHARP.indexOf(root) : NOTES_FLAT.indexOf(root);
|
||||||
|
if (idx === -1) return key;
|
||||||
|
const newIdx = ((idx + offset) % 12 + 12) % 12;
|
||||||
|
return notes[newIdx] + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransposeBar({
|
||||||
|
meta,
|
||||||
|
offset,
|
||||||
|
onOffsetChange,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
fontSize,
|
||||||
|
onFontSizeChange,
|
||||||
|
capo,
|
||||||
|
applyCapo,
|
||||||
|
onToggleCapo,
|
||||||
|
isFavorite,
|
||||||
|
onToggleFavorite,
|
||||||
|
fullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
|
}: Props) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
|
const label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`;
|
||||||
|
|
||||||
const menuButton = (onEdit || onDelete) ? (
|
const keyDisplay =
|
||||||
<DropdownMenu>
|
meta.original_key && offset !== 0
|
||||||
<DropdownMenuTrigger asChild>
|
? `Key: ${meta.original_key} → ${transposedKey(meta.original_key, offset)}`
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
: meta.original_key
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
? `Key: ${meta.original_key}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const menuButton =
|
||||||
|
onEdit || onDelete ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{onEdit && (
|
||||||
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-4 py-1.5 border-b bg-background">
|
||||||
|
<span className="text-sm font-semibold truncate">{meta.title}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
>
|
||||||
|
<Minimize className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</div>
|
||||||
<DropdownMenuContent align="end">
|
);
|
||||||
{onEdit && (
|
}
|
||||||
<DropdownMenuItem onClick={onEdit}>
|
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
return (
|
return (
|
||||||
@@ -55,7 +128,12 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
|||||||
<span className="text-sm font-semibold truncate">{meta.title}</span>
|
<span className="text-sm font-semibold truncate">{meta.title}</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{menuButton}
|
{menuButton}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setExpanded(true)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,8 +149,40 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
|||||||
<span className="text-sm text-muted-foreground">{meta.artist}</span>
|
<span className="text-sm text-muted-foreground">{meta.artist}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{onToggleFavorite && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={onToggleFavorite}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
isFavorite
|
||||||
|
? "fill-primary text-primary"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onToggleFullscreen && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
>
|
||||||
|
<Maximize className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{menuButton}
|
{menuButton}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setExpanded(false)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
>
|
||||||
<ChevronUp className="w-4 h-4" />
|
<ChevronUp className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,16 +190,19 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||||
{meta.original_key && <span>Key: {meta.original_key}</span>}
|
{keyDisplay && <span>{keyDisplay}</span>}
|
||||||
{capo != null && onToggleCapo ? (
|
{capo != null && onToggleCapo ? (
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCapo}
|
onClick={onToggleCapo}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs transition-colors",
|
"text-xs transition-colors",
|
||||||
applyCapo ? "text-primary" : "text-muted-foreground hover:text-foreground"
|
applyCapo
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Capo {capo}{applyCapo ? " · sounding" : ""}
|
Capo {capo}
|
||||||
|
{applyCapo ? " · sounding" : ""}
|
||||||
</button>
|
</button>
|
||||||
) : meta.capo != null ? (
|
) : meta.capo != null ? (
|
||||||
<span>Capo: {meta.capo}</span>
|
<span>Capo: {meta.capo}</span>
|
||||||
@@ -99,7 +212,7 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onFontSizeChange && (
|
{onFontSizeChange && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{(['sm', 'base', 'lg'] as const).map((s) => (
|
{(["sm", "base", "lg"] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => onFontSizeChange(s)}
|
onClick={() => onFontSizeChange(s)}
|
||||||
@@ -107,21 +220,31 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
|
|||||||
"text-xs px-1.5 py-0.5 rounded transition-colors",
|
"text-xs px-1.5 py-0.5 rounded transition-colors",
|
||||||
fontSize === s
|
fontSize === s
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{s === 'sm' ? 'S' : s === 'base' ? 'M' : 'L'}
|
{s === "sm" ? "S" : s === "base" ? "M" : "L"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
<Button
|
||||||
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => onOffsetChange(Math.max(-11, offset - 1))}
|
||||||
|
>
|
||||||
<Minus className="w-4 h-4" />
|
<Minus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="w-8 text-center text-sm font-mono font-semibold">{label}</span>
|
<span className="w-8 text-center text-sm font-mono font-semibold">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8"
|
{label}
|
||||||
onClick={() => onOffsetChange(Math.min(11, offset + 1))}>
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => onOffsetChange(Math.min(11, offset + 1))}
|
||||||
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
57
app/app/hooks/use-favorites.ts
Normal file
57
app/app/hooks/use-favorites.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "favorites";
|
||||||
|
|
||||||
|
let listeners: Array<() => void> = [];
|
||||||
|
let cachedRaw: string | null = null;
|
||||||
|
let cachedParsed: string[] = [];
|
||||||
|
|
||||||
|
function getSnapshot(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw !== cachedRaw) {
|
||||||
|
cachedRaw = raw;
|
||||||
|
cachedParsed = raw ? JSON.parse(raw) : [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
cachedParsed = [];
|
||||||
|
}
|
||||||
|
return cachedParsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
listeners.push(cb);
|
||||||
|
return () => {
|
||||||
|
listeners = listeners.filter((l) => l !== cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: string[] = [];
|
||||||
|
function getServerSnapshot(): string[] {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
cachedRaw = null;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFavorites() {
|
||||||
|
const favorites = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
|
||||||
|
const isFavorite = useCallback(
|
||||||
|
(id: string) => favorites.includes(id),
|
||||||
|
[favorites],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = useCallback((id: string) => {
|
||||||
|
const current = getSnapshot();
|
||||||
|
const next = current.includes(id)
|
||||||
|
? current.filter((f) => f !== id)
|
||||||
|
: [...current, id];
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
notify();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { favorites, isFavorite, toggle };
|
||||||
|
}
|
||||||
22
app/app/hooks/use-fullscreen.tsx
Normal file
22
app/app/hooks/use-fullscreen.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface FullscreenState {
|
||||||
|
isFullscreen: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FullscreenContext = createContext<FullscreenState>({
|
||||||
|
isFullscreen: false,
|
||||||
|
toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function FullscreenProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const toggle = useCallback(() => setIsFullscreen((v) => !v), []);
|
||||||
|
const value = useMemo(() => ({ isFullscreen, toggle }), [isFullscreen, toggle]);
|
||||||
|
return <FullscreenContext value={value}>{children}</FullscreenContext>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFullscreen() {
|
||||||
|
return useContext(FullscreenContext);
|
||||||
|
}
|
||||||
28
app/app/hooks/use-wake-lock.ts
Normal file
28
app/app/hooks/use-wake-lock.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useWakeLock() {
|
||||||
|
const lockRef = useRef<WakeLockSentinel | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function acquire() {
|
||||||
|
try {
|
||||||
|
lockRef.current = await navigator.wakeLock.request("screen");
|
||||||
|
} catch {
|
||||||
|
// not supported or denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire();
|
||||||
|
|
||||||
|
function handleVisibility() {
|
||||||
|
if (document.visibilityState === "visible") acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibility);
|
||||||
|
lockRef.current?.release().catch(() => {});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
@@ -13,6 +14,10 @@ import "./app.css";
|
|||||||
import { AuthProvider } from "./lib/auth";
|
import { AuthProvider } from "./lib/auth";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip";
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
{
|
{
|
||||||
@@ -43,15 +48,17 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<TooltipProvider>
|
<AuthProvider>
|
||||||
{children}
|
<TooltipProvider>
|
||||||
<ScrollRestoration />
|
{children}
|
||||||
<Scripts />
|
<ScrollRestoration />
|
||||||
</TooltipProvider>
|
<Scripts />
|
||||||
</AuthProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
@@ -8,8 +9,8 @@ import { SongCard } from "~/components/song-card";
|
|||||||
import { AddSongSheet } from "~/components/add-song-sheet";
|
import { AddSongSheet } from "~/components/add-song-sheet";
|
||||||
import { listSongs } from "~/lib/api";
|
import { listSongs } from "~/lib/api";
|
||||||
import { useAuth } from "~/lib/auth";
|
import { useAuth } from "~/lib/auth";
|
||||||
|
import { useFavorites } from "~/hooks/use-favorites";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { SongSummary } from "~/lib/types";
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
@@ -20,12 +21,10 @@ export function meta() {
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { isFavorite } = useFavorites();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [songs, setSongs] = useState<SongSummary[]>([]);
|
|
||||||
const [localSongs, setLocalSongs] = useState<SongSummary[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
|
|
||||||
const q = searchParams.get("q") ?? "";
|
const q = searchParams.get("q") ?? "";
|
||||||
const sort = searchParams.get("sort") ?? "date";
|
const sort = searchParams.get("sort") ?? "date";
|
||||||
@@ -34,17 +33,10 @@ export default function Home() {
|
|||||||
const [inputValue, setInputValue] = useState(q);
|
const [inputValue, setInputValue] = useState(q);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: songs = [], isLoading, isError, refetch } = useQuery({
|
||||||
setLoading(true);
|
queryKey: ["songs", q, sort, order],
|
||||||
setError(false);
|
queryFn: () => listSongs(q, sort, order),
|
||||||
listSongs(q, sort, order)
|
});
|
||||||
.then((data) => {
|
|
||||||
setSongs(data);
|
|
||||||
setLocalSongs([]);
|
|
||||||
})
|
|
||||||
.catch(() => setError(true))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [q, sort, order]);
|
|
||||||
|
|
||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@@ -68,7 +60,11 @@ export default function Home() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allSongs = [...songs, ...localSongs];
|
const allSongs = songs.toSorted((a, b) => {
|
||||||
|
const af = isFavorite(a.id) ? 0 : 1;
|
||||||
|
const bf = isFavorite(b.id) ? 0 : 1;
|
||||||
|
return af - bf;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full max-w-lg mx-auto">
|
<div className="flex flex-col h-full max-w-lg mx-auto">
|
||||||
@@ -129,45 +125,38 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{isError && (
|
||||||
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
|
<div className="flex flex-col items-center gap-3 pt-8 pb-4 px-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Couldn't load your songs. Is the API running?
|
Couldn't load your songs. Is the API running?
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setError(false);
|
|
||||||
setLoading(true);
|
|
||||||
listSongs(q, sort, order)
|
|
||||||
.then(setSongs)
|
|
||||||
.catch(() => setError(true))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Retry
|
Retry
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
{loading && !error && (
|
{isLoading && !isError && (
|
||||||
<div className="flex justify-center pt-12">
|
<div className="flex justify-center pt-12">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && allSongs.length === 0 && (
|
{!isLoading && !isError && allSongs.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground text-center pt-8 pb-4">
|
<p className="text-sm text-muted-foreground text-center pt-8 pb-4">
|
||||||
{q
|
{q
|
||||||
? "No songs match your search."
|
? "No songs match your search."
|
||||||
: "No songs yet. Tap Add to get started."}
|
: "No songs yet. Tap Add to get started."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading && (
|
{!isLoading && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{allSongs.map((song) => (
|
{allSongs.map((song) => (
|
||||||
<SongCard key={song.id} song={song} />
|
<SongCard
|
||||||
|
key={song.id}
|
||||||
|
song={song}
|
||||||
|
isFavorite={isFavorite(song.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Card
|
<Card
|
||||||
@@ -186,7 +175,9 @@ export default function Home() {
|
|||||||
<AddSongSheet
|
<AddSongSheet
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
onSongAdded={(summary) => setLocalSongs((prev) => [...prev, summary])}
|
onSongAdded={() =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["songs"] })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { BottomNav } from "~/components/bottom-nav";
|
import { BottomNav } from "~/components/bottom-nav";
|
||||||
|
import { FullscreenProvider, useFullscreen } from "~/hooks/use-fullscreen";
|
||||||
|
|
||||||
|
function LayoutInner() {
|
||||||
|
const { isFullscreen } = useFullscreen();
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-dvh">
|
<div className="flex flex-col h-dvh">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
<BottomNav />
|
{!isFullscreen && <BottomNav />}
|
||||||
<Toaster position="top-center" richColors />
|
<Toaster position="top-center" richColors />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<FullscreenProvider>
|
||||||
|
<LayoutInner />
|
||||||
|
</FullscreenProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import { TransposeBar } from "~/components/transpose-bar";
|
import { TransposeBar } from "~/components/transpose-bar";
|
||||||
import { ChordChart } from "~/components/chord-chart";
|
import { ChordChart } from "~/components/chord-chart";
|
||||||
import { ChordGrid } from "~/components/chord-diagram/chord-grid";
|
import { ChordGrid } from "~/components/chord-diagram/chord-grid";
|
||||||
@@ -8,11 +10,15 @@ import { ChordDiagram } from "~/components/chord-diagram/chord-diagram";
|
|||||||
import type { Instrument } from "~/components/chord-diagram/chord-diagram";
|
import type { Instrument } from "~/components/chord-diagram/chord-diagram";
|
||||||
import { EditSongSheet } from "~/components/edit-song-sheet";
|
import { EditSongSheet } from "~/components/edit-song-sheet";
|
||||||
import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
import { DeleteSongDialog } from "~/components/delete-song-dialog";
|
||||||
import { transposeSong } from "~/lib/transpose";
|
import { SectionNav } from "~/components/section-nav";
|
||||||
|
import { AutoScrollControls } from "~/components/auto-scroll-controls";
|
||||||
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";
|
||||||
import type { Song, SongSummary } from "~/lib/types";
|
import { useFavorites } from "~/hooks/use-favorites";
|
||||||
|
import { useFullscreen } from "~/hooks/use-fullscreen";
|
||||||
|
import { useWakeLock } from "~/hooks/use-wake-lock";
|
||||||
|
import type { SongSummary } from "~/lib/types";
|
||||||
|
|
||||||
type FontSize = "sm" | "base" | "lg";
|
type FontSize = "sm" | "base" | "lg";
|
||||||
|
|
||||||
@@ -36,6 +42,19 @@ function initInstrument(): Instrument {
|
|||||||
return "piano";
|
return "piano";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initOffset(id: string): number {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(`transpose:${id}`);
|
||||||
|
if (v !== null) {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (!isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: "PocketChords" }];
|
return [{ title: "PocketChords" }];
|
||||||
}
|
}
|
||||||
@@ -43,26 +62,13 @@ export function meta() {
|
|||||||
export default function SongDetail() {
|
export default function SongDetail() {
|
||||||
const { id = "" } = useParams();
|
const { id = "" } = useParams();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
const { isFavorite, toggle: toggleFavorite } = useFavorites();
|
||||||
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen();
|
||||||
|
|
||||||
const [baseSong, setBaseSong] = useState<Song | null>(null);
|
useWakeLock();
|
||||||
const [displayedSong, setDisplayedSong] = useState<Song | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [offset, setOffset] = useState(() => initOffset(id));
|
||||||
const [applyCapo, setApplyCapo] = useState(false);
|
const [applyCapo, setApplyCapo] = useState(false);
|
||||||
|
|
||||||
const initOffset = (() => {
|
|
||||||
try {
|
|
||||||
const v = localStorage.getItem(`transpose:${id}`);
|
|
||||||
if (v !== null) {
|
|
||||||
const n = parseInt(v, 10);
|
|
||||||
if (!isNaN(n)) return n;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const [offset, setOffset] = useState(initOffset);
|
|
||||||
const [fontSize, setFontSize] = useState<FontSize>(initFontSize);
|
const [fontSize, setFontSize] = useState<FontSize>(initFontSize);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
@@ -70,25 +76,16 @@ export default function SongDetail() {
|
|||||||
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
|
const [instrument, setInstrument] = useState<Instrument>(initInstrument);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: baseSong, isLoading } = useQuery({
|
||||||
setLoading(true);
|
queryKey: ["song", id],
|
||||||
getSong(id)
|
queryFn: () => getSong(id),
|
||||||
.then((s) => {
|
});
|
||||||
setBaseSong(s);
|
|
||||||
setDisplayedSong(s);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: displayedSong } = useQuery({
|
||||||
if (applyCapo && baseSong?.meta.capo) {
|
queryKey: ["song", id, "view", offset, applyCapo],
|
||||||
getSong(id, true).then((s) => {
|
queryFn: () => getSong(id, { applyCapo, transpose: offset }),
|
||||||
if (s) setDisplayedSong(s);
|
enabled: !!baseSong,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setDisplayedSong(baseSong);
|
|
||||||
}
|
|
||||||
}, [applyCapo]); // eslint-disable-line
|
|
||||||
|
|
||||||
function handleOffsetChange(newOffset: number) {
|
function handleOffsetChange(newOffset: number) {
|
||||||
setOffset(newOffset);
|
setOffset(newOffset);
|
||||||
@@ -119,7 +116,12 @@ export default function SongDetail() {
|
|||||||
|
|
||||||
const handleScroll = useCallback(() => setActiveChord(null), []);
|
const handleScroll = useCallback(() => setActiveChord(null), []);
|
||||||
|
|
||||||
if (loading) {
|
function handleSectionJump(index: number) {
|
||||||
|
const el = document.getElementById(`section-${index}`);
|
||||||
|
el?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
@@ -127,7 +129,9 @@ export default function SongDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!baseSong || !displayedSong) {
|
const song = displayedSong ?? baseSong;
|
||||||
|
|
||||||
|
if (!baseSong || !song) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@@ -143,15 +147,15 @@ export default function SongDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayed = transposeSong(displayedSong, offset);
|
const uniqueChords = extractUniqueChords(song.sections);
|
||||||
const uniqueChords = extractUniqueChords(displayed.sections);
|
const sectionItems = song.sections.map((s, i) => ({
|
||||||
|
label: s.label,
|
||||||
|
index: i,
|
||||||
|
}));
|
||||||
const handleChordClick = (chord: string) => setActiveChord(chord);
|
const handleChordClick = (chord: string) => setActiveChord(chord);
|
||||||
|
|
||||||
function handleUpdated(summary: SongSummary) {
|
function handleUpdated(summary: SongSummary) {
|
||||||
setBaseSong((prev) => (prev ? { ...prev, meta: summary.meta } : prev));
|
// meta-only update; react-query will refetch on next focus
|
||||||
setDisplayedSong((prev) =>
|
|
||||||
prev ? { ...prev, meta: summary.meta } : prev,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,6 +171,10 @@ export default function SongDetail() {
|
|||||||
capo={baseSong.meta.capo ?? undefined}
|
capo={baseSong.meta.capo ?? undefined}
|
||||||
applyCapo={applyCapo}
|
applyCapo={applyCapo}
|
||||||
onToggleCapo={() => setApplyCapo((v) => !v)}
|
onToggleCapo={() => setApplyCapo((v) => !v)}
|
||||||
|
isFavorite={isFavorite(id)}
|
||||||
|
onToggleFavorite={() => toggleFavorite(id)}
|
||||||
|
fullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
<div className="flex-1 overflow-hidden flex flex-col lg:flex-row">
|
||||||
@@ -177,7 +185,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={song.sections}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
onChordClick={handleChordClick}
|
onChordClick={handleChordClick}
|
||||||
/>
|
/>
|
||||||
@@ -201,15 +209,22 @@ export default function SongDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:hidden border-t border-border bg-background flex items-center">
|
||||||
|
<SectionNav sections={sectionItems} onJump={handleSectionJump} />
|
||||||
|
<AutoScrollControls scrollRef={scrollRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{activeChord && (
|
{activeChord && (
|
||||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
|
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background shadow-lg p-3 flex items-center gap-3">
|
||||||
<ChordDiagram chord={activeChord} instrument={instrument} />
|
<ChordDiagram chord={activeChord} instrument={instrument} />
|
||||||
<button
|
<Button
|
||||||
className="ml-auto text-muted-foreground text-xs underline-offset-4 hover:underline"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto text-xs text-muted-foreground"
|
||||||
onClick={() => setActiveChord(null)}
|
onClick={() => setActiveChord(null)}
|
||||||
>
|
>
|
||||||
close
|
close
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
27
app/package-lock.json
generated
27
app/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@react-router/node": "7.14.0",
|
"@react-router/node": "7.14.0",
|
||||||
"@react-router/serve": "7.14.0",
|
"@react-router/serve": "7.14.0",
|
||||||
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -4045,6 +4046,32 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.101.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
|
||||||
|
"integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.101.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
|
||||||
|
"integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.101.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tonaljs/abc-notation": {
|
"node_modules/@tonaljs/abc-notation": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@react-router/node": "7.14.0",
|
"@react-router/node": "7.14.0",
|
||||||
"@react-router/serve": "7.14.0",
|
"@react-router/serve": "7.14.0",
|
||||||
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -15,9 +15,14 @@ pub enum TransposeError {
|
|||||||
impl ChordTransposer {
|
impl ChordTransposer {
|
||||||
pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord {
|
pub fn transpose_chord(&self, chord: &Chord, semitones: i8) -> Chord {
|
||||||
let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8;
|
let new_semitone = (chord.root.semitone() as i16 + semitones as i16).rem_euclid(12) as u8;
|
||||||
|
let bass = chord.bass.map(|b| {
|
||||||
|
let s = (b.semitone() as i16 + semitones as i16).rem_euclid(12) as u8;
|
||||||
|
Note::from_semitone(s)
|
||||||
|
});
|
||||||
Chord {
|
Chord {
|
||||||
root: Note::from_semitone(new_semitone),
|
root: Note::from_semitone(new_semitone),
|
||||||
descriptor: chord.descriptor.clone(),
|
descriptor: chord.descriptor.clone(),
|
||||||
|
bass,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ fn parse_simple() {
|
|||||||
let c = Chord::parse("Em").unwrap();
|
let c = Chord::parse("Em").unwrap();
|
||||||
assert_eq!(c.root, crate::value_objects::Note::E);
|
assert_eq!(c.root, crate::value_objects::Note::E);
|
||||||
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
||||||
|
assert!(c.bass.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -12,6 +13,7 @@ fn parse_no_descriptor() {
|
|||||||
let c = Chord::parse("G").unwrap();
|
let c = Chord::parse("G").unwrap();
|
||||||
assert_eq!(c.root, crate::value_objects::Note::G);
|
assert_eq!(c.root, crate::value_objects::Note::G);
|
||||||
assert!(c.descriptor.is_none());
|
assert!(c.descriptor.is_none());
|
||||||
|
assert!(c.bass.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -26,6 +28,7 @@ fn name_sharp() {
|
|||||||
let c = Chord {
|
let c = Chord {
|
||||||
root: crate::value_objects::Note::FSharpGFlat,
|
root: crate::value_objects::Note::FSharpGFlat,
|
||||||
descriptor: Some("m".into()),
|
descriptor: Some("m".into()),
|
||||||
|
bass: None,
|
||||||
};
|
};
|
||||||
assert_eq!(c.name(true), "F#m");
|
assert_eq!(c.name(true), "F#m");
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,7 @@ fn name_flat() {
|
|||||||
let c = Chord {
|
let c = Chord {
|
||||||
root: crate::value_objects::Note::ASharpBFlat,
|
root: crate::value_objects::Note::ASharpBFlat,
|
||||||
descriptor: None,
|
descriptor: None,
|
||||||
|
bass: None,
|
||||||
};
|
};
|
||||||
assert_eq!(c.name(false), "Bb");
|
assert_eq!(c.name(false), "Bb");
|
||||||
}
|
}
|
||||||
@@ -45,3 +49,39 @@ fn parse_flat_with_descriptor() {
|
|||||||
assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat);
|
assert_eq!(c.root, crate::value_objects::Note::ASharpBFlat);
|
||||||
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
assert_eq!(c.descriptor.as_deref(), Some("m"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_slash_chord() {
|
||||||
|
let c = Chord::parse("G/B").unwrap();
|
||||||
|
assert_eq!(c.root, crate::value_objects::Note::G);
|
||||||
|
assert!(c.descriptor.is_none());
|
||||||
|
assert_eq!(c.bass, Some(crate::value_objects::Note::B));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_slash_chord_with_descriptor() {
|
||||||
|
let c = Chord::parse("Am7/G").unwrap();
|
||||||
|
assert_eq!(c.root, crate::value_objects::Note::A);
|
||||||
|
assert_eq!(c.descriptor.as_deref(), Some("m7"));
|
||||||
|
assert_eq!(c.bass, Some(crate::value_objects::Note::G));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_slash_chord_with_sharp_bass() {
|
||||||
|
let c = Chord::parse("D/F#").unwrap();
|
||||||
|
assert_eq!(c.root, crate::value_objects::Note::D);
|
||||||
|
assert_eq!(c.bass, Some(crate::value_objects::Note::FSharpGFlat));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_slash_chord() {
|
||||||
|
let c = Chord::parse("G/B").unwrap();
|
||||||
|
assert_eq!(c.name(true), "G/B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_slash_chord_transpose_display() {
|
||||||
|
let c = Chord::parse("D/F#").unwrap();
|
||||||
|
assert_eq!(c.name(true), "D/F#");
|
||||||
|
assert_eq!(c.name(false), "D/Gb");
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ fn lyric_line_chord_positions() {
|
|||||||
chord: Chord {
|
chord: Chord {
|
||||||
root: Note::E,
|
root: Note::E,
|
||||||
descriptor: Some("m".into()),
|
descriptor: Some("m".into()),
|
||||||
|
bass: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ChordPosition {
|
ChordPosition {
|
||||||
@@ -18,6 +19,7 @@ fn lyric_line_chord_positions() {
|
|||||||
chord: Chord {
|
chord: Chord {
|
||||||
root: Note::C,
|
root: Note::C,
|
||||||
descriptor: None,
|
descriptor: None,
|
||||||
|
bass: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -66,3 +66,17 @@ fn transpose_to_key() {
|
|||||||
let result = t.transpose_to_key(&song, "A").unwrap();
|
let result = t.transpose_to_key(&song, "A").unwrap();
|
||||||
assert_eq!(result.sections.len(), 0);
|
assert_eq!(result.sections.len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transpose_slash_chord() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let result = t.transpose_chord(&chord("G/B"), 2);
|
||||||
|
assert_eq!(result.name(true), "A/C#");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transpose_slash_chord_down() {
|
||||||
|
let t = ChordTransposer;
|
||||||
|
let result = t.transpose_chord(&chord("D/F#"), -2);
|
||||||
|
assert_eq!(result.name(true), "C/E");
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,17 +6,33 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct Chord {
|
pub struct Chord {
|
||||||
pub root: Note,
|
pub root: Note,
|
||||||
pub descriptor: Option<String>,
|
pub descriptor: Option<String>,
|
||||||
|
pub bass: Option<Note>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Chord {
|
impl Chord {
|
||||||
pub fn parse(s: &str) -> Option<Self> {
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
let (root, consumed) = Note::parse_prefix(s)?;
|
let (main, bass_str) = match s.rsplit_once('/') {
|
||||||
let descriptor = if consumed < s.len() {
|
Some((m, b)) if !b.is_empty() => (m, Some(b)),
|
||||||
Some(s[consumed..].to_string())
|
_ => (s, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (root, consumed) = Note::parse_prefix(main)?;
|
||||||
|
let descriptor = if consumed < main.len() {
|
||||||
|
Some(main[consumed..].to_string())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
Some(Chord { root, descriptor })
|
|
||||||
|
let bass = match bass_str {
|
||||||
|
Some(b) => Some(Note::parse(b)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Chord {
|
||||||
|
root,
|
||||||
|
descriptor,
|
||||||
|
bass,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn name(&self, use_sharps: bool) -> String {
|
pub fn name(&self, use_sharps: bool) -> String {
|
||||||
@@ -25,9 +41,20 @@ impl Chord {
|
|||||||
} else {
|
} else {
|
||||||
self.root.to_flat_str()
|
self.root.to_flat_str()
|
||||||
};
|
};
|
||||||
match &self.descriptor {
|
let base = match &self.descriptor {
|
||||||
Some(d) => format!("{}{}", root_str, d),
|
Some(d) => format!("{}{}", root_str, d),
|
||||||
None => root_str.to_string(),
|
None => root_str.to_string(),
|
||||||
|
};
|
||||||
|
match &self.bass {
|
||||||
|
Some(b) => {
|
||||||
|
let bass_str = if use_sharps {
|
||||||
|
b.to_sharp_str()
|
||||||
|
} else {
|
||||||
|
b.to_flat_str()
|
||||||
|
};
|
||||||
|
format!("{}/{}", base, bass_str)
|
||||||
|
}
|
||||||
|
None => base,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ 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