UI improvements
This commit is contained in:
96
src/App.tsx
96
src/App.tsx
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { Square } from 'lucide-react'
|
import { Square, Archive, Dices } from 'lucide-react'
|
||||||
import { PlaybackManager } from './services/PlaybackManager'
|
import { PlaybackManager } from './services/PlaybackManager'
|
||||||
import { DownloadService } from './services/DownloadService'
|
import { DownloadService } from './services/DownloadService'
|
||||||
import { generateFormulaGrid, generateRandomFormula } from './utils/bytebeatFormulas'
|
import { generateFormulaGrid, generateRandomFormula } from './utils/bytebeatFormulas'
|
||||||
@ -9,6 +9,7 @@ import { EffectsBar } from './components/EffectsBar'
|
|||||||
import { EngineControls } from './components/EngineControls'
|
import { EngineControls } from './components/EngineControls'
|
||||||
import { getSampleRateFromIndex } from './config/effects'
|
import { getSampleRateFromIndex } from './config/effects'
|
||||||
import { engineSettings, effectSettings } from './stores/settings'
|
import { engineSettings, effectSettings } from './stores/settings'
|
||||||
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const engineValues = useStore(engineSettings)
|
const engineValues = useStore(engineSettings)
|
||||||
@ -22,6 +23,7 @@ function App() {
|
|||||||
const [regenerating, setRegenerating] = useState<string | null>(null)
|
const [regenerating, setRegenerating] = useState<string | null>(null)
|
||||||
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
const [playbackPosition, setPlaybackPosition] = useState<number>(0)
|
||||||
const [downloading, setDownloading] = useState(false)
|
const [downloading, setDownloading] = useState(false)
|
||||||
|
const [focusedTile, setFocusedTile] = useState<{ row: number; col: number }>({ row: 0, col: 0 })
|
||||||
const playbackManagerRef = useRef<PlaybackManager | null>(null)
|
const playbackManagerRef = useRef<PlaybackManager | null>(null)
|
||||||
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
const downloadServiceRef = useRef<DownloadService>(new DownloadService())
|
||||||
const animationFrameRef = useRef<number | null>(null)
|
const animationFrameRef = useRef<number | null>(null)
|
||||||
@ -82,6 +84,7 @@ function App() {
|
|||||||
|
|
||||||
const handleTileClick = (formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
const handleTileClick = (formula: string, row: number, col: number, isDoubleClick: boolean = false) => {
|
||||||
const id = `${row}-${col}`
|
const id = `${row}-${col}`
|
||||||
|
setFocusedTile({ row, col })
|
||||||
|
|
||||||
if (playing === id) {
|
if (playing === id) {
|
||||||
playbackManagerRef.current?.stop()
|
playbackManagerRef.current?.stop()
|
||||||
@ -179,12 +182,89 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const moveFocus = (direction: 'up' | 'down' | 'left' | 'right', step: number = 1) => {
|
||||||
|
setFocusedTile(prev => {
|
||||||
|
let { row, col } = prev
|
||||||
|
const maxRow = formulas.length - 1
|
||||||
|
const maxCol = (formulas[row]?.length || 1) - 1
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case 'up':
|
||||||
|
row = Math.max(0, row - step)
|
||||||
|
break
|
||||||
|
case 'down':
|
||||||
|
row = Math.min(maxRow, row + step)
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
col = Math.max(0, col - step)
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
col = Math.min(maxCol, col + step)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return { row, col }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardSpace = () => {
|
||||||
|
if (playing) {
|
||||||
|
handleStop()
|
||||||
|
} else {
|
||||||
|
const formula = formulas[focusedTile.row]?.[focusedTile.col]
|
||||||
|
if (formula) {
|
||||||
|
handleTileClick(formula, focusedTile.row, focusedTile.col, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardEnter = () => {
|
||||||
|
const formula = formulas[focusedTile.row]?.[focusedTile.col]
|
||||||
|
if (formula) {
|
||||||
|
handleTileClick(formula, focusedTile.row, focusedTile.col, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardDoubleEnter = () => {
|
||||||
|
const formula = formulas[focusedTile.row]?.[focusedTile.col]
|
||||||
|
if (formula) {
|
||||||
|
handleTileClick(formula, focusedTile.row, focusedTile.col, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardR = () => {
|
||||||
|
handleRegenerate(focusedTile.row, focusedTile.col)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyboardShiftR = () => {
|
||||||
|
handleRandom()
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
onSpace: handleKeyboardSpace,
|
||||||
|
onArrowUp: (shift) => moveFocus('up', shift ? 10 : 1),
|
||||||
|
onArrowDown: (shift) => moveFocus('down', shift ? 10 : 1),
|
||||||
|
onArrowLeft: (shift) => moveFocus('left', shift ? 10 : 1),
|
||||||
|
onArrowRight: (shift) => moveFocus('right', shift ? 10 : 1),
|
||||||
|
onEnter: handleKeyboardEnter,
|
||||||
|
onDoubleEnter: handleKeyboardDoubleEnter,
|
||||||
|
onR: handleKeyboardR,
|
||||||
|
onShiftR: handleKeyboardShiftR
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = document.querySelector(`[data-tile-id="${focusedTile.row}-${focusedTile.col}"]`)
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}
|
||||||
|
}, [focusedTile])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
<div className="w-screen h-screen flex flex-col bg-black overflow-hidden">
|
||||||
<header className="bg-black border-b-2 border-white px-6 py-3">
|
<header className="bg-black border-b-2 border-white px-6 py-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between gap-6">
|
||||||
<h1 className="font-mono text-sm tracking-[0.3em] text-white">BRUITISTE</h1>
|
<h1 className="font-mono text-sm tracking-[0.3em] text-white flex-shrink-0">BRUITISTE</h1>
|
||||||
<div className="flex gap-4">
|
<EngineControls values={engineValues} onChange={handleEngineChange} />
|
||||||
|
<div className="flex gap-4 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
disabled={!playing}
|
disabled={!playing}
|
||||||
@ -195,20 +275,21 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRandom}
|
onClick={handleRandom}
|
||||||
className="px-6 py-2 bg-white text-black font-mono text-[11px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all"
|
className="px-6 py-2 bg-white text-black font-mono text-[11px] tracking-[0.2em] hover:bg-black hover:text-white border-2 border-white transition-all flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
<Dices size={14} strokeWidth={2} />
|
||||||
RANDOM
|
RANDOM
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadAll}
|
onClick={handleDownloadAll}
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-black text-white border-2 border-white font-mono text-[11px] tracking-[0.2em] hover:bg-white hover:text-black transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
<Archive size={14} strokeWidth={2} />
|
||||||
{downloading ? 'DOWNLOADING...' : 'DOWNLOAD ALL'}
|
{downloading ? 'DOWNLOADING...' : 'DOWNLOAD ALL'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EngineControls values={engineValues} onChange={handleEngineChange} />
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px] overflow-auto">
|
<div className="flex-1 grid grid-cols-2 auto-rows-min gap-[1px] bg-white p-[1px] overflow-auto">
|
||||||
@ -224,6 +305,7 @@ function App() {
|
|||||||
isPlaying={playing === id}
|
isPlaying={playing === id}
|
||||||
isQueued={queued === id}
|
isQueued={queued === id}
|
||||||
isRegenerating={regenerating === id}
|
isRegenerating={regenerating === id}
|
||||||
|
isFocused={focusedTile.row === i && focusedTile.col === j}
|
||||||
playbackPosition={playing === id ? playbackPosition : 0}
|
playbackPosition={playing === id ? playbackPosition : 0}
|
||||||
onPlay={handleTileClick}
|
onPlay={handleTileClick}
|
||||||
onDoubleClick={handleTileDoubleClick}
|
onDoubleClick={handleTileDoubleClick}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect } from 'react'
|
||||||
import { Download, RefreshCw } from 'lucide-react'
|
import { Download, Dices } from 'lucide-react'
|
||||||
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
|
import { generateWaveformData, drawWaveform } from '../utils/waveformGenerator'
|
||||||
|
|
||||||
interface BytebeatTileProps {
|
interface BytebeatTileProps {
|
||||||
@ -9,6 +9,7 @@ interface BytebeatTileProps {
|
|||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
isQueued: boolean
|
isQueued: boolean
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
|
isFocused: boolean
|
||||||
playbackPosition: number
|
playbackPosition: number
|
||||||
onPlay: (formula: string, row: number, col: number) => void
|
onPlay: (formula: string, row: number, col: number) => void
|
||||||
onDoubleClick: (formula: string, row: number, col: number) => void
|
onDoubleClick: (formula: string, row: number, col: number) => void
|
||||||
@ -16,7 +17,7 @@ interface BytebeatTileProps {
|
|||||||
onRegenerate: (row: number, col: number) => void
|
onRegenerate: (row: number, col: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegenerating, playbackPosition, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) {
|
export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegenerating, isFocused, playbackPosition, onPlay, onDoubleClick, onDownload, onRegenerate }: BytebeatTileProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -44,11 +45,12 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-tile-id={`${row}-${col}`}
|
||||||
onClick={() => onPlay(formula, row, col)}
|
onClick={() => onPlay(formula, row, col)}
|
||||||
onDoubleClick={() => onDoubleClick(formula, row, col)}
|
onDoubleClick={() => onDoubleClick(formula, row, col)}
|
||||||
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-3 cursor-pointer overflow-hidden ${
|
className={`relative hover:scale-[0.98] transition-all duration-150 font-mono p-3 flex items-center justify-between gap-3 cursor-pointer overflow-hidden ${
|
||||||
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : isRegenerating ? 'bg-black text-white border-2 border-white' : 'bg-black text-white'
|
isPlaying ? 'bg-white text-black' : isQueued ? 'bg-black text-white animate-pulse' : isRegenerating ? 'bg-black text-white border-2 border-white' : 'bg-black text-white'
|
||||||
}`}
|
} ${isFocused ? 'outline outline-2 outline-white outline-offset-[-4px]' : ''}`}
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@ -72,7 +74,7 @@ export function BytebeatTile({ formula, row, col, isPlaying, isQueued, isRegener
|
|||||||
: 'bg-white text-black border-white'
|
: 'bg-white text-black border-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} strokeWidth={2} className={isRegenerating ? 'animate-spin' : ''} />
|
<Dices size={14} strokeWidth={2} className={isRegenerating ? 'animate-spin' : ''} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
|||||||
86
src/hooks/useKeyboardShortcuts.ts
Normal file
86
src/hooks/useKeyboardShortcuts.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export interface KeyboardShortcutHandlers {
|
||||||
|
onSpace?: () => void
|
||||||
|
onArrowUp?: (shift: boolean) => void
|
||||||
|
onArrowDown?: (shift: boolean) => void
|
||||||
|
onArrowLeft?: (shift: boolean) => void
|
||||||
|
onArrowRight?: (shift: boolean) => void
|
||||||
|
onEnter?: () => void
|
||||||
|
onDoubleEnter?: () => void
|
||||||
|
onR?: () => void
|
||||||
|
onShiftR?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOUBLE_ENTER_THRESHOLD = 300
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(handlers: KeyboardShortcutHandlers) {
|
||||||
|
const handlersRef = useRef(handlers)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handlersRef.current = handlers
|
||||||
|
}, [handlers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let lastEnterTime = 0
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = handlersRef.current
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onSpace?.()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onArrowUp?.(e.shiftKey)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onArrowDown?.(e.shiftKey)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onArrowLeft?.(e.shiftKey)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
h.onArrowRight?.(e.shiftKey)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastEnterTime < DOUBLE_ENTER_THRESHOLD) {
|
||||||
|
h.onDoubleEnter?.()
|
||||||
|
} else {
|
||||||
|
h.onEnter?.()
|
||||||
|
}
|
||||||
|
lastEnterTime = now
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'r':
|
||||||
|
case 'R':
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.shiftKey) {
|
||||||
|
h.onShiftR?.()
|
||||||
|
} else {
|
||||||
|
h.onR?.()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user