Phase 2: pattern renderers + rhythm/fingerstyle/lead drills
Add PickingLane renderer and PickingView. Variant-level meter overrides so R3 can drop from 7/8 to 4/4 live at the next bar boundary. Wire R2, R3, R5, F1-F3, L1-L3 (incl. L2 eighth-note triplets via stepsPerBeat=3). Refactor drill model into a strum/picking union with shared meter + accent-map helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import type { PickingDrill } from '../../drills'
|
||||
import { effectiveMeter, patternFromAccents } from '../../drills'
|
||||
import { MetronomeController } from '../../lib/audio/metronomeController.svelte'
|
||||
import * as storage from '../../lib/storage'
|
||||
import { router } from '../../router.svelte'
|
||||
import DrillLayout from '../common/DrillLayout.svelte'
|
||||
import Transport from '../metronome/Transport.svelte'
|
||||
import PickingLane from '../patterns/PickingLane.svelte'
|
||||
|
||||
interface Props {
|
||||
drill: PickingDrill
|
||||
params: URLSearchParams
|
||||
}
|
||||
|
||||
let { drill, params }: Props = $props()
|
||||
|
||||
function initialBpm(): number {
|
||||
const q = Number(params.get('bpm'))
|
||||
if (Number.isFinite(q) && q > 0) return q
|
||||
return storage.getDrillBpm(drill.code, drill.defaultBpm)
|
||||
}
|
||||
function initialVariantId(): string {
|
||||
const q = params.get('variant')
|
||||
if (q && drill.variants.some((v) => v.id === q)) return q
|
||||
const stored = storage.getDrillVariant(drill.code, drill.variants[0].id)
|
||||
return drill.variants.some((v) => v.id === stored) ? stored : drill.variants[0].id
|
||||
}
|
||||
|
||||
const controller = new MetronomeController(initialBpm())
|
||||
let variantId = $state(initialVariantId())
|
||||
let variant = $derived(
|
||||
drill.variants.find((v) => v.id === variantId) ?? drill.variants[0],
|
||||
)
|
||||
let meter = $derived(effectiveMeter(drill, variant))
|
||||
|
||||
$effect(() => {
|
||||
controller.setPattern(patternFromAccents(meter, variant.accentSteps))
|
||||
})
|
||||
|
||||
function persistBpm(bpm: number) {
|
||||
storage.setDrillBpm(drill.code, bpm)
|
||||
}
|
||||
function selectVariant(id: string) {
|
||||
variantId = id
|
||||
storage.setDrillVariant(drill.code, id)
|
||||
router.setQuery({ variant: id })
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
controller.toggle()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
controller.nudgeBpm(2)
|
||||
persistBpm(controller.bpm)
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
controller.nudgeBpm(-2)
|
||||
persistBpm(controller.bpm)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => window.addEventListener('keydown', onKey))
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
controller.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<DrillLayout
|
||||
code={drill.code}
|
||||
name={drill.name}
|
||||
category={drill.category}
|
||||
description={drill.description}
|
||||
meterLabel={meter.meterLabel}
|
||||
>
|
||||
<Transport {controller} stepsPerBeat={meter.stepsPerBeat} onBpmCommit={persistBpm} />
|
||||
|
||||
<PickingLane
|
||||
steps={variant.steps}
|
||||
stepsPerBeat={meter.stepsPerBeat}
|
||||
currentStep={controller.currentStep}
|
||||
/>
|
||||
|
||||
{#if drill.chordLabels && drill.chordLabels.length}
|
||||
<div class="chords">
|
||||
<span class="eyebrow">Chords{drill.chordLabels.length > 1 ? ' (per bar)' : ''}</span>
|
||||
<div class="chip-row">
|
||||
{#each drill.chordLabels as c}
|
||||
<span class="chord num">{c}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if drill.variants.length > 1}
|
||||
<div class="variants">
|
||||
<span class="eyebrow">Variant</span>
|
||||
<div class="chips">
|
||||
{#each drill.variants as v}
|
||||
<button class="chip" class:sel={v.id === variantId} onclick={() => selectVariant(v.id)}>
|
||||
{v.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DrillLayout>
|
||||
|
||||
<style>
|
||||
.chords,
|
||||
.variants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.chip-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chord {
|
||||
padding: 0.4rem 0.7rem;
|
||||
background: var(--walnut);
|
||||
border: 1px solid var(--fretwire-dim);
|
||||
border-radius: var(--radius);
|
||||
color: var(--inlay);
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.chip {
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: var(--walnut);
|
||||
border: 1px solid var(--fretwire-dim);
|
||||
border-radius: 999px;
|
||||
color: var(--inlay-dim);
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--fretwire);
|
||||
color: var(--inlay);
|
||||
}
|
||||
.chip.sel {
|
||||
color: var(--tubeglow);
|
||||
border-color: var(--tubeglow-soft);
|
||||
background: color-mix(in srgb, var(--tubeglow) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user