-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPlaylistView.tish
More file actions
198 lines (184 loc) · 7.18 KB
/
Copy pathPlaylistView.tish
File metadata and controls
198 lines (184 loc) · 7.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
// PlaylistView — the Set timeline: a recording of WHAT PLAYED. While the transport runs, every step's note
// triggers (stepTriggers' output) are taped (the FULL set is still captured + replayable note-for-note — the
// ghost). The DISPLAY is ONE continuous summarized waveform PER DECK (in the deck's colour), drawn IMPERATIVELY
// to a <canvas> per lane by drawSetWaveLane (called from scopeFrame off an incremental rt.tplEnergy map) — NOT
// through the vdom. This component renders only the static chrome (head/ruler/labels/playhead/status) so a
// 90-bar take never re-buckets the tape or rebuilds a giant SVG path on render; the waveform pixels live on
// canvas and update at ~30fps independent of React.
import { h, Fragment } from '@tishlang/lattish'
import { IkSelect } from './InstrumentKit.tish'
let BAR_PX = 26
let STEPS_PER_BAR = 16
let LABEL_W = 0
let MIN_BARS = 8
let DECK_WAVE_H = 21
fn deckColorHex(deck) {
if (deck === "B") {
return "#e879f9"
}
if (deck === "C") {
return "#f59e0b"
}
if (deck === "D") {
return "#4ade80"
}
return "#22d3ee"
}
export fn PlaylistView(project, rt, props) {
let p = props ? props : {}
let tape = rt && Array.isArray(rt.tplTape) ? rt.tplTape : []
let recordings = Array.isArray(p.recordings) ? p.recordings : []
// Deck remap (a channel changed deck/owner → channelsRev bumped): the incremental tplEnergy assigned past
// triggers to the OLD decks, so re-fold the whole tape once with the fresh roster. Fires exactly once per
// channelsRev change (and only when there's tape to re-fold), via a baseline stored on rt.
if (rt) {
if (rt.tplViewChannelsRev !== p.channelsRev) {
rt.tplViewChannelsRev = p.channelsRev
if (tape.length > 0 && p.rebuildTplEnergy) {
p.rebuildTplEnergy()
}
}
}
let energy = rt && rt.tplEnergy ? rt.tplEnergy : { A: {}, B: {}, C: {}, D: {}, maxStep: 0, maxE: 1, totalNotes: 0 }
let active = rt && rt.tplActiveDecks ? rt.tplActiveDecks : { A: false, B: false, C: false, D: false }
let maxStep = energy.maxStep !== null ? energy.maxStep : 0
let totalNotes = energy.totalNotes !== null ? energy.totalNotes : 0
// Direct-DOM playhead mover (re-asserted every rAF by the transport.position subscriber in App).
if (rt) {
rt.updateTimelinePlayhead = (step, playing) => {
if (globalThis.document === null || globalThis.document.querySelector === null) {
return
}
let doc = globalThis.document
let el = doc.querySelector(".pl-playhead")
if (!el) {
return
}
if (!playing || step < 0) {
el.style.opacity = "0"
return
}
let phX = LABEL_W + (step / STEPS_PER_BAR) * BAR_PX
el.style.opacity = "1"
el.style.left = String(phX) + "px"
// Auto-scroll so the playhead trails ~45% across the viewport as the take advances. The deck labels are
// pinned (position: sticky) so they stay put while the waveform track scrolls under them.
let scroller = doc.querySelector(".pl-scroll")
if (scroller) {
let target = phX - scroller.clientWidth * 0.45
if (target < 0) {
target = 0
}
scroller.scrollLeft = target
}
}
}
let gridBars = Math.ceil((maxStep + 1) / STEPS_PER_BAR) + 2
if (gridBars < MIN_BARS) {
gridBars = MIN_BARS
}
// The recorder lane is capped at ~8 bars wide — a compact footer waveform (the take still records past this;
// the lane just stops widening, so a long take never grows the footer / forces horizontal scroll).
if (gridBars > 8) {
gridBars = 8
}
let laneWidth = gridBars * BAR_PX
// ---- bar ruler ----
let ruler = []
let b = 0
while (b < gridBars) {
ruler.push(
<div class={(b % 4 === 0) ? "pl-tick pl-tick-major" : "pl-tick"} style={{ left: String(b * BAR_PX) + "px" }}>
{b % 4 === 0 ? <span class="pl-tick-num">{String(b + 1)}</span> : null}
</div>
)
b = b + 1
}
// ---- one canvas lane PER DECK that has produced sound (drawn imperatively by drawSetWaveLane in scopeFrame) ----
let rows = []
let hasData = active.A === true || active.B === true || active.C === true || active.D === true
if (hasData) {
rows.push(
<div class="pl-lane pl-wave-lane">
<div class="pl-lane-track pl-wave-track" style={{ width: String(laneWidth) + "px", height: String(DECK_WAVE_H) + "px" }}>
<canvas class="pl-wave-cv pl-wave-cv-overlay"></canvas>
</div>
</div>
)
} else {
rows.push(
<div class="pl-empty-hint">{"Hit Play — what plays is captured here, Save it as a Set"}</div>
)
}
// The canvases above may have been freshly (re)created by memo — re-assert a repaint on the next scopeFrame so
// they're filled from rt.tplEnergy (same re-assert-after-render discipline the playhead relies on).
if (rt) {
rt.tplWaveDirty = true
}
// ---- recordings dropdown ----
let recOpts = [{ value: "", label: "— replay —", deletable: false }]
let ri = 0
while (ri < recordings.length) {
recOpts.push({ value: recordings[ri].name, label: recordings[ri].name })
ri = ri + 1
}
let steps = tape.length
let bars = Math.ceil((maxStep + 1) / STEPS_PER_BAR)
let status = steps === 0
? "nothing recorded yet"
: String(totalNotes) + " note" + (totalNotes === 1 ? "" : "s") + " · " + String(steps) + " step" + (steps === 1 ? "" : "s") + " · " + String(bars) + " bar" + (bars === 1 ? "" : "s")
return (
<div class="pl-view pl-tpl">
<div class="pl-head">
<button
type="button"
class={p.recording ? "pl-rec pl-rec-on" : "pl-rec"}
title={p.recording ? "Recording ARMED — Play captures what the sequencer fires into the timeline. Click to disarm." : "Recording OFF — click to arm, then Play to capture a take."}
onclick={() => { if (p.onToggleRec) { p.onToggleRec() } }}
>
<span class="pl-rec-dot"></span>
</button>
<button
type="button"
class="pl-btn pl-btn-ico"
title="New take — clear the timeline; the next Play records a fresh take"
onclick={() => { if (p.onNewTake) { p.onNewTake() } }}
>
{"+"}
</button>
<button
type="button"
class="pl-btn pl-btn-ico"
title="Save this take as a replayable Set"
disabled={steps === 0}
onclick={() => { if (p.onSaveRecording) { p.onSaveRecording() } }}
>
{"⤓"}
</button>
{IkSelect({
class: "pl-replay-select",
value: "",
title: "Load a recorded Set and replay exactly what played (the ghost)",
options: recOpts,
onChange: (v) => {
if (v && v.length > 0 && p.onLoadReplay) {
p.onLoadReplay(v)
}
},
onDelete: (v) => {
if (v && v.length > 0 && p.onDeleteReplay) {
p.onDeleteReplay(v)
}
}
})}
</div>
<div class="pl-scroll">
<div class="pl-grid">
<div class="pl-ruler" style={{ width: String(laneWidth) + "px" }}>{ruler}</div>
<div class="pl-rows">{rows}</div>
<div class="pl-playhead"></div>
</div>
</div>
</div>
)
}