diff --git a/app/app/components/auto-scroll-controls.tsx b/app/app/components/auto-scroll-controls.tsx new file mode 100644 index 0000000..19dde43 --- /dev/null +++ b/app/app/components/auto-scroll-controls.tsx @@ -0,0 +1,106 @@ +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; +} + +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(0); + const lastTimeRef = useRef(0); + const cancelledByUser = useRef(false); + + const tick = useCallback( + (time: number) => { + if (!scrollRef.current) return; + if (lastTimeRef.current) { + const dt = (time - lastTimeRef.current) / 1000; + scrollRef.current.scrollTop += speed * dt; + + const el = scrollRef.current; + 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; + 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 ( +
+ + +
+ ); +} diff --git a/app/app/components/chord-chart.tsx b/app/app/components/chord-chart.tsx index 47624a9..e646050 100644 --- a/app/app/components/chord-chart.tsx +++ b/app/app/components/chord-chart.tsx @@ -85,15 +85,17 @@ function LineBlock({ function SectionBlock({ section, + index, sizeClass, onChordClick, }: { section: Section; + index: number; sizeClass: string; onChordClick?: (chord: string) => void; }) { return ( -
+
{section.label && (

[{section.label}]

)} @@ -111,7 +113,7 @@ export function ChordChart({ sections, fontSize, onChordClick }: Props) { return (
{sections.map((section, i) => ( - + ))}
); diff --git a/app/app/components/section-nav.tsx b/app/app/components/section-nav.tsx new file mode 100644 index 0000000..288f822 --- /dev/null +++ b/app/app/components/section-nav.tsx @@ -0,0 +1,44 @@ +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(); + if (lower.startsWith("verse")) return label.replace(/verse\s*/i, "V"); + if (lower.startsWith("chorus")) return "C"; + if (lower.startsWith("bridge")) return "Br"; + if (lower.startsWith("pre-chorus") || lower.startsWith("prechorus")) + return "PC"; + if (lower.startsWith("intro")) return "In"; + if (lower.startsWith("outro")) return "Out"; + if (label.length > 6) return label.slice(0, 5); + return label; +} + +export function SectionNav({ sections, onJump }: Props) { + const labeled = sections.filter((s) => s.label); + if (labeled.length === 0) return null; + + return ( + +
+ {labeled.map((s) => ( + + ))} +
+ +
+ ); +} diff --git a/app/app/components/song-card.tsx b/app/app/components/song-card.tsx index 95bbbcb..7c6f62f 100644 --- a/app/app/components/song-card.tsx +++ b/app/app/components/song-card.tsx @@ -1,16 +1,21 @@ import { Link } from "react-router"; +import { Star } from "lucide-react"; import { Badge } from "~/components/ui/badge"; import { Card, CardContent } from "~/components/ui/card"; import type { SongSummary } from "~/lib/types"; interface Props { song: SongSummary; + isFavorite?: boolean; } -export function SongCard({ song }: Props) { +export function SongCard({ song, isFavorite }: Props) { return ( - + + {isFavorite && ( + + )}
{song.preview_chords.map((chord) => ( diff --git a/app/app/components/transpose-bar.tsx b/app/app/components/transpose-bar.tsx index 5d3024e..2a39240 100644 --- a/app/app/components/transpose-bar.tsx +++ b/app/app/components/transpose-bar.tsx @@ -1,9 +1,23 @@ import { useState } from "react"; import { Button } from "~/components/ui/button"; import { - DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } 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 type { SongMeta } from "~/lib/types"; @@ -13,41 +27,100 @@ interface Props { onOffsetChange: (offset: number) => void; onEdit?: () => void; onDelete?: () => void; - fontSize?: 'sm' | 'base' | 'lg'; - onFontSizeChange?: (size: 'sm' | 'base' | 'lg') => void; + fontSize?: "sm" | "base" | "lg"; + onFontSizeChange?: (size: "sm" | "base" | "lg") => void; capo?: number; applyCapo?: boolean; 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 label = offset === 0 ? "±0" : offset > 0 ? `+${offset}` : `${offset}`; - const menuButton = (onEdit || onDelete) ? ( - - - + + + {onEdit && ( + + + Edit + + )} + {onDelete && ( + + + Delete + + )} + + + ) : null; + + if (fullscreen) { + return ( +
+ {meta.title} + - - - {onEdit && ( - - - Edit - - )} - {onDelete && ( - - - Delete - - )} - - - ) : null; +
+ ); + } if (!expanded) { return ( @@ -55,7 +128,12 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f {meta.title}
{menuButton} -
@@ -71,8 +149,40 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f {meta.artist}
+ {onToggleFavorite && ( + + )} + {onToggleFullscreen && ( + + )} {menuButton} -
@@ -80,16 +190,19 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
- {meta.original_key && Key: {meta.original_key}} + {keyDisplay && {keyDisplay}} {capo != null && onToggleCapo ? ( ) : meta.capo != null ? ( Capo: {meta.capo} @@ -99,7 +212,7 @@ export function TransposeBar({ meta, offset, onOffsetChange, onEdit, onDelete, f
{onFontSizeChange && (
- {(['sm', 'base', 'lg'] as const).map((s) => ( + {(["sm", "base", "lg"] as const).map((s) => ( ))}
)} - - {label} -
diff --git a/app/app/hooks/use-favorites.ts b/app/app/hooks/use-favorites.ts new file mode 100644 index 0000000..48e44dd --- /dev/null +++ b/app/app/hooks/use-favorites.ts @@ -0,0 +1,45 @@ +import { useCallback, useSyncExternalStore } from "react"; + +const STORAGE_KEY = "favorites"; + +let listeners: Array<() => void> = []; + +function getSnapshot(): string[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function subscribe(cb: () => void) { + listeners.push(cb); + return () => { + listeners = listeners.filter((l) => l !== cb); + }; +} + +function notify() { + listeners.forEach((l) => l()); +} + +export function useFavorites() { + const favorites = useSyncExternalStore(subscribe, getSnapshot, () => []); + + 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 }; +} diff --git a/app/app/hooks/use-fullscreen.tsx b/app/app/hooks/use-fullscreen.tsx new file mode 100644 index 0000000..524eaee --- /dev/null +++ b/app/app/hooks/use-fullscreen.tsx @@ -0,0 +1,22 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; + +interface FullscreenState { + isFullscreen: boolean; + toggle: () => void; +} + +const FullscreenContext = createContext({ + 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 {children}; +} + +export function useFullscreen() { + return useContext(FullscreenContext); +} diff --git a/app/app/hooks/use-wake-lock.ts b/app/app/hooks/use-wake-lock.ts new file mode 100644 index 0000000..ffdcaba --- /dev/null +++ b/app/app/hooks/use-wake-lock.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; + +export function useWakeLock() { + const lockRef = useRef(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(() => {}); + }; + }, []); +} diff --git a/app/app/routes/home.tsx b/app/app/routes/home.tsx index bc2a7af..c51a99b 100644 --- a/app/app/routes/home.tsx +++ b/app/app/routes/home.tsx @@ -8,6 +8,7 @@ import { SongCard } from "~/components/song-card"; import { AddSongSheet } from "~/components/add-song-sheet"; import { listSongs } from "~/lib/api"; import { useAuth } from "~/lib/auth"; +import { useFavorites } from "~/hooks/use-favorites"; import { cn } from "~/lib/utils"; import type { SongSummary } from "~/lib/types"; @@ -20,6 +21,7 @@ export function meta() { export default function Home() { const { isAuthenticated } = useAuth(); + const { isFavorite } = useFavorites(); const [searchParams, setSearchParams] = useSearchParams(); const [sheetOpen, setSheetOpen] = useState(false); const [songs, setSongs] = useState([]); @@ -68,7 +70,12 @@ export default function Home() { [], ); - const allSongs = [...songs, ...localSongs]; + const merged = [...songs, ...localSongs]; + const allSongs = merged.toSorted((a, b) => { + const af = isFavorite(a.id) ? 0 : 1; + const bf = isFavorite(b.id) ? 0 : 1; + return af - bf; + }); return (
@@ -167,7 +174,7 @@ export default function Home() { {!loading && (
{allSongs.map((song) => ( - + ))} {isAuthenticated && (
- + {!isFullscreen && }
); } + +export default function Layout() { + return ( + + + + ); +} diff --git a/app/app/routes/songs.$id.tsx b/app/app/routes/songs.$id.tsx index 64ce751..da88d66 100644 --- a/app/app/routes/songs.$id.tsx +++ b/app/app/routes/songs.$id.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { Link, useParams } from "react-router"; import { Loader2 } from "lucide-react"; +import { Button } from "~/components/ui/button"; import { TransposeBar } from "~/components/transpose-bar"; import { ChordChart } from "~/components/chord-chart"; import { ChordGrid } from "~/components/chord-diagram/chord-grid"; @@ -8,10 +9,15 @@ import { ChordDiagram } from "~/components/chord-diagram/chord-diagram"; import type { Instrument } from "~/components/chord-diagram/chord-diagram"; import { EditSongSheet } from "~/components/edit-song-sheet"; import { DeleteSongDialog } from "~/components/delete-song-dialog"; +import { SectionNav } from "~/components/section-nav"; +import { AutoScrollControls } from "~/components/auto-scroll-controls"; import { transposeSong } from "~/lib/transpose"; import { extractUniqueChords } from "~/lib/song-utils"; import { getSong } from "~/lib/api"; import { useAuth } from "~/lib/auth"; +import { useFavorites } from "~/hooks/use-favorites"; +import { useFullscreen } from "~/hooks/use-fullscreen"; +import { useWakeLock } from "~/hooks/use-wake-lock"; import type { Song, SongSummary } from "~/lib/types"; type FontSize = "sm" | "base" | "lg"; @@ -43,6 +49,10 @@ export function meta() { export default function SongDetail() { const { id = "" } = useParams(); const { isAuthenticated } = useAuth(); + const { isFavorite, toggle: toggleFavorite } = useFavorites(); + const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(); + + useWakeLock(); const [baseSong, setBaseSong] = useState(null); const [displayedSong, setDisplayedSong] = useState(null); @@ -119,6 +129,11 @@ export default function SongDetail() { const handleScroll = useCallback(() => setActiveChord(null), []); + function handleSectionJump(index: number) { + const el = document.getElementById(`section-${index}`); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + if (loading) { return (
@@ -145,6 +160,10 @@ export default function SongDetail() { const displayed = transposeSong(displayedSong, offset); const uniqueChords = extractUniqueChords(displayed.sections); + const sectionItems = displayed.sections.map((s, i) => ({ + label: s.label, + index: i, + })); const handleChordClick = (chord: string) => setActiveChord(chord); function handleUpdated(summary: SongSummary) { @@ -167,6 +186,10 @@ export default function SongDetail() { capo={baseSong.meta.capo ?? undefined} applyCapo={applyCapo} onToggleCapo={() => setApplyCapo((v) => !v)} + isFavorite={isFavorite(id)} + onToggleFavorite={() => toggleFavorite(id)} + fullscreen={isFullscreen} + onToggleFullscreen={toggleFullscreen} />
@@ -201,15 +224,23 @@ export default function SongDetail() {
+ {/* Bottom toolbar: section nav + auto-scroll */} +
+ + +
+ {activeChord && (
- +
)}