rvlib-shipkit 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -2
- package/package.json +2 -2
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +30 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +52 -11
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +11 -3
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -133
- package/src/core/app-icon.ts +10 -6
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +66 -5
- package/src/review/paste.ts +30 -0
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +22 -4
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +67 -58
- package/src/serve/hub-app/review/review-page.tsx +17 -15
- package/src/serve/hub-app/review/review-st.ts +191 -20
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +73 -13
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +15 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +182 -33
- package/src/trailer/ffmpeg.ts +21 -1
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -3,10 +3,17 @@
|
|
|
3
3
|
// sent. The list is read while the page shows, every 2 s, answered 304 when the store did not move
|
|
4
4
|
import { makeAutoObservable, reaction, runInAction } from 'mobx'
|
|
5
5
|
import {
|
|
6
|
+
asOptions,
|
|
7
|
+
isApproved,
|
|
8
|
+
isChoice,
|
|
6
9
|
isGallery,
|
|
10
|
+
isPair,
|
|
7
11
|
laneOf,
|
|
8
12
|
moveOf,
|
|
13
|
+
pasteBlock,
|
|
9
14
|
reworkOf,
|
|
15
|
+
STARS,
|
|
16
|
+
STATUS_LABEL,
|
|
10
17
|
stateOf,
|
|
11
18
|
unreadBy,
|
|
12
19
|
verdictOf,
|
|
@@ -25,12 +32,15 @@ import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive,
|
|
|
25
32
|
import { reach } from '../reach.ts'
|
|
26
33
|
import type { RepoSt } from '../repo-st.ts'
|
|
27
34
|
import type { Role } from '../../../review/role.ts'
|
|
35
|
+
import type { PasteHosts } from '../../../review/paste.ts'
|
|
28
36
|
import { ClipSt } from './clip-st.ts'
|
|
29
37
|
|
|
30
38
|
interface Draft {
|
|
31
39
|
rects: Rect[]
|
|
32
40
|
comment: string
|
|
33
41
|
reactions?: ReviewReaction[]
|
|
42
|
+
/** the option he picked, on a choice card */
|
|
43
|
+
pick?: number
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/** a live `sk review watch`: its process, the session or lane it keeps, when it started */
|
|
@@ -58,7 +68,7 @@ export const drawnSide = (mode: CompareMode, drawn: Side): Side => (mode === 'mo
|
|
|
58
68
|
|
|
59
69
|
/** below this width the page is the phone's: one column at a time, the verdicts in a bar under the thumb */
|
|
60
70
|
/** a repo's review unit as the page reads it: the words of its kinds and topics, its dispatcher and who holds the role */
|
|
61
|
-
export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[] }
|
|
71
|
+
export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[]; where?: PasteHosts | null }
|
|
62
72
|
|
|
63
73
|
export const PHONE_PX = 768
|
|
64
74
|
/** below this width of the review page itself (the project list and the sidebar already took theirs), `auto` stacks
|
|
@@ -71,6 +81,10 @@ export type PhonePane = 'list' | 'card' | 'answer'
|
|
|
71
81
|
export type PairSide = 'before' | 'after' | 'swipe'
|
|
72
82
|
|
|
73
83
|
const POLL_MS = 2000
|
|
84
|
+
/** a sound's clock steps a tenth of a second */
|
|
85
|
+
const SOUND_FPS = 10
|
|
86
|
+
/** ← → on a sound jump this many seconds */
|
|
87
|
+
const SOUND_JUMP = 2
|
|
74
88
|
|
|
75
89
|
const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
|
|
76
90
|
|
|
@@ -97,7 +111,7 @@ const draftOf = (s: string | null): Draft | null => {
|
|
|
97
111
|
try {
|
|
98
112
|
const d = s === null ? null : (JSON.parse(s) as Partial<Draft>)
|
|
99
113
|
if (!d || !Array.isArray(d.rects) || typeof d.comment !== 'string') return null
|
|
100
|
-
return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}) }
|
|
114
|
+
return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}), ...(typeof d.pick === 'number' ? { pick: d.pick } : {}) }
|
|
101
115
|
} catch {
|
|
102
116
|
return null
|
|
103
117
|
}
|
|
@@ -132,6 +146,26 @@ async function postJson<T>(url: string, body: unknown): Promise<T> {
|
|
|
132
146
|
return JSON.parse(text) as T
|
|
133
147
|
}
|
|
134
148
|
|
|
149
|
+
/** a text to the clipboard: the clipboard api, else (a page over plain http, the phone through a public host) the
|
|
150
|
+
* old copy command on a hidden field */
|
|
151
|
+
async function copyText(text: string): Promise<boolean> {
|
|
152
|
+
try {
|
|
153
|
+
await navigator.clipboard.writeText(text)
|
|
154
|
+
return true
|
|
155
|
+
} catch {
|
|
156
|
+
const el = document.createElement('textarea')
|
|
157
|
+
el.value = text
|
|
158
|
+
el.setAttribute('readonly', '')
|
|
159
|
+
el.style.position = 'fixed'
|
|
160
|
+
el.style.opacity = '0'
|
|
161
|
+
document.body.appendChild(el)
|
|
162
|
+
el.select()
|
|
163
|
+
const ok = document.execCommand('copy')
|
|
164
|
+
el.remove()
|
|
165
|
+
return ok
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
/** the pair's compare mode, the curtain's line and the onion's opacity, shared by every pair of the page */
|
|
136
170
|
export class CompareSt {
|
|
137
171
|
mode: CompareMode = 'side'
|
|
@@ -179,6 +213,8 @@ export class PhoneSt {
|
|
|
179
213
|
}
|
|
180
214
|
}
|
|
181
215
|
|
|
216
|
+
const isShownStatus = (s: string): s is ShownStatus => Object.hasOwn(STATUS_LABEL, s)
|
|
217
|
+
|
|
182
218
|
export class ReviewSt {
|
|
183
219
|
list: Listing | null = null
|
|
184
220
|
listError: string | null = null
|
|
@@ -206,6 +242,10 @@ export class ReviewSt {
|
|
|
206
242
|
viewZoom: Record<number, Zoom> = {}
|
|
207
243
|
/** the gallery tile shown large across the column, `<view>/<tile>` */
|
|
208
244
|
bigTile: string | null = null
|
|
245
|
+
/** a card of options shown full, every view stacked, by id: his switch, this visit */
|
|
246
|
+
full: Record<string, boolean> = {}
|
|
247
|
+
/** the option rows unfolded to their full view, by view index */
|
|
248
|
+
openRows: Record<number, boolean> = {}
|
|
209
249
|
replies: Record<string, string> = {}
|
|
210
250
|
private reading = new Set<string>()
|
|
211
251
|
/** « hide handled »: only the active threads in the list */
|
|
@@ -216,6 +256,9 @@ export class ReviewSt {
|
|
|
216
256
|
unfolded: Record<string, boolean> = {}
|
|
217
257
|
busy = false
|
|
218
258
|
error: string | null = null
|
|
259
|
+
/** what the last copy put on the clipboard, in words, shown a moment */
|
|
260
|
+
toast: string | null = null
|
|
261
|
+
private toastTimer: ReturnType<typeof setTimeout> | null = null
|
|
219
262
|
/** the card against its answer, his pick, kept with the prefs */
|
|
220
263
|
place: CardPlace = 'auto'
|
|
221
264
|
/** the page is narrower than STACK_PX */
|
|
@@ -239,6 +282,27 @@ export class ReviewSt {
|
|
|
239
282
|
setPlace(p: CardPlace): void {
|
|
240
283
|
this.place = p
|
|
241
284
|
}
|
|
285
|
+
/** the open card as thin option rows: a choice, or views that each play alone, unless he asked them full */
|
|
286
|
+
get rows(): boolean {
|
|
287
|
+
const x = this.open
|
|
288
|
+
return x !== null && asOptions(x.item) && this.full[x.item.id] !== true
|
|
289
|
+
}
|
|
290
|
+
setFull(on: boolean): void {
|
|
291
|
+
const id = this.open?.item.id
|
|
292
|
+
if (id) this.full = { ...this.full, [id]: on }
|
|
293
|
+
}
|
|
294
|
+
get choice(): boolean {
|
|
295
|
+
const x = this.open
|
|
296
|
+
return x !== null && isChoice(x.item)
|
|
297
|
+
}
|
|
298
|
+
/** the card's header (title, state, what, and in one column the verdicts) sits over its views: one column, the
|
|
299
|
+
* phone's card pane, a choice's options */
|
|
300
|
+
get headInViews(): boolean {
|
|
301
|
+
return this.stacked || this.phone.on || this.choice
|
|
302
|
+
}
|
|
303
|
+
toggleRow(i: number): void {
|
|
304
|
+
this.openRows = { ...this.openRows, [i]: !this.openRows[i] }
|
|
305
|
+
}
|
|
242
306
|
|
|
243
307
|
constructor(private readonly st: RepoSt) {
|
|
244
308
|
this.prefix = `shipkit.review.${st.key}.`
|
|
@@ -255,7 +319,7 @@ export class ReviewSt {
|
|
|
255
319
|
} catch {
|
|
256
320
|
// a broken pref is the default
|
|
257
321
|
}
|
|
258
|
-
makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix'>(this, {
|
|
322
|
+
makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix' | 'toastTimer'>(this, {
|
|
259
323
|
st: false,
|
|
260
324
|
clips: false,
|
|
261
325
|
clipsCard: false,
|
|
@@ -264,6 +328,7 @@ export class ReviewSt {
|
|
|
264
328
|
loadList: false,
|
|
265
329
|
loadWatchers: false,
|
|
266
330
|
prefix: false,
|
|
331
|
+
toastTimer: false,
|
|
267
332
|
compare: false,
|
|
268
333
|
phone: false,
|
|
269
334
|
})
|
|
@@ -317,6 +382,27 @@ export class ReviewSt {
|
|
|
317
382
|
}
|
|
318
383
|
}
|
|
319
384
|
|
|
385
|
+
/** the open card as one paste a fresh agent session in any repo acts on: what it is, its repo, the command that shows
|
|
386
|
+
* it whole, its link here and on the phone (C) */
|
|
387
|
+
async copyCard(): Promise<void> {
|
|
388
|
+
const x = this.open
|
|
389
|
+
if (!x) return
|
|
390
|
+
const id = x.item.id
|
|
391
|
+
const w = this.spec?.where
|
|
392
|
+
const here = typeof location === 'undefined' ? (w?.open ?? '') : `${location.origin}${location.pathname}${location.search}`
|
|
393
|
+
const text = pasteBlock(x.item, STATUS_LABEL[this.statusOf(x)], {
|
|
394
|
+
root: w?.root ?? '<the repo>',
|
|
395
|
+
open: `${here}#page:review:${id}`,
|
|
396
|
+
phone: w?.phone ? `${w.phone}#page:review:${id}` : null,
|
|
397
|
+
})
|
|
398
|
+
const ok = await copyText(text)
|
|
399
|
+
runInAction(() => {
|
|
400
|
+
this.toast = ok ? `copied for an agent: title, repo, sk review show, ${w?.phone ? 'links' : 'link'}` : 'the browser refused the copy'
|
|
401
|
+
if (this.toastTimer !== null) clearTimeout(this.toastTimer)
|
|
402
|
+
this.toastTimer = setTimeout(() => runInAction(() => (this.toast = null)), 2600)
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
320
406
|
setBoard(on: boolean): void {
|
|
321
407
|
this.board = on
|
|
322
408
|
}
|
|
@@ -470,7 +556,14 @@ export class ReviewSt {
|
|
|
470
556
|
if (!x) return { rects: [], comment: '' }
|
|
471
557
|
const v = verdictOf(x)
|
|
472
558
|
const sent = v?.reactions
|
|
473
|
-
return
|
|
559
|
+
return (
|
|
560
|
+
this.drafts[x.item.id] ?? {
|
|
561
|
+
rects: v?.rects ?? [],
|
|
562
|
+
comment: v?.comment ?? '',
|
|
563
|
+
...(sent && sent.length > 0 ? { reactions: sent } : {}),
|
|
564
|
+
...(v?.pick !== undefined ? { pick: v.pick } : {}),
|
|
565
|
+
}
|
|
566
|
+
)
|
|
474
567
|
}
|
|
475
568
|
|
|
476
569
|
get dirty(): boolean {
|
|
@@ -482,7 +575,7 @@ export class ReviewSt {
|
|
|
482
575
|
const x = this.open
|
|
483
576
|
if (!x) return
|
|
484
577
|
const d = this.draft
|
|
485
|
-
const { reactions, ...rest } = f({ rects: [...d.rects],
|
|
578
|
+
const { reactions, ...rest } = f({ ...d, rects: [...d.rects], reactions: [...(d.reactions ?? [])] })
|
|
486
579
|
const next: Draft = reactions && reactions.length > 0 ? { ...rest, reactions } : rest
|
|
487
580
|
this.drafts = { ...this.drafts, [x.item.id]: next }
|
|
488
581
|
store.put(`${this.prefix}draft.${x.item.id}`, JSON.stringify(next))
|
|
@@ -494,12 +587,43 @@ export class ReviewSt {
|
|
|
494
587
|
if (this.phone.on) this.phone.show('card')
|
|
495
588
|
}
|
|
496
589
|
|
|
590
|
+
/** what the address carries beside the card: its view and the list's filters, `view=1&subject=place`, each only
|
|
591
|
+
* off its default (`handled=1` shows the handled threads, `lanes=1` groups by lane) */
|
|
592
|
+
get address(): string {
|
|
593
|
+
const q = new URLSearchParams()
|
|
594
|
+
if (this.view > 0) q.set('view', String(this.view))
|
|
595
|
+
if (this.subject !== null) q.set('subject', this.subject)
|
|
596
|
+
if (this.whose !== null) q.set('whose', this.whose)
|
|
597
|
+
if (this.status !== null) q.set('status', this.status)
|
|
598
|
+
if (!this.hideHandled) q.set('handled', '1')
|
|
599
|
+
if (this.byLane) q.set('lanes', '1')
|
|
600
|
+
return q.toString()
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** an address's view and filters read back: a key it leaves out keeps what the page has; an unknown value is dropped */
|
|
604
|
+
applyAddress(raw: string): void {
|
|
605
|
+
const q = new URLSearchParams(raw)
|
|
606
|
+
const view = Number(q.get('view'))
|
|
607
|
+
if (Number.isInteger(view) && view > 0) this.view = view
|
|
608
|
+
const subject = q.get('subject')
|
|
609
|
+
if (subject) this.subject = subject
|
|
610
|
+
const whose = q.get('whose')
|
|
611
|
+
if (whose === 'owner' || whose === 'agents' || whose === 'none') this.whose = whose
|
|
612
|
+
const status = q.get('status')
|
|
613
|
+
if (status !== null && isShownStatus(status)) this.status = status
|
|
614
|
+
if (q.get('handled') === '1') this.hideHandled = false
|
|
615
|
+
if (q.get('lanes') === '1') this.byLane = true
|
|
616
|
+
}
|
|
617
|
+
|
|
497
618
|
/** the card the address names; its draft is read again from storage, the truth over every window */
|
|
498
619
|
select(id: string | null): void {
|
|
499
620
|
if (id !== this.selectedId) {
|
|
500
621
|
this.view = 0
|
|
501
622
|
this.viewZoom = {}
|
|
502
623
|
this.bigTile = null
|
|
624
|
+
this.openRows = {}
|
|
625
|
+
// a link to a card on the phone opens the card, never the list
|
|
626
|
+
if (id !== null && this.phone.on) this.phone.show('card')
|
|
503
627
|
}
|
|
504
628
|
this.selectedId = id
|
|
505
629
|
if (id !== null) {
|
|
@@ -545,6 +669,13 @@ export class ReviewSt {
|
|
|
545
669
|
this.rect = null
|
|
546
670
|
}
|
|
547
671
|
|
|
672
|
+
/** the playable view an option row or a clip shows: a clip, or a sound shown as a row */
|
|
673
|
+
private playableAt(i: number): MediaView | null {
|
|
674
|
+
const v = this.open?.item.views[i]
|
|
675
|
+
if (!v || isGallery(v)) return null
|
|
676
|
+
return v.clip || (v.audio && this.rows && !isPair(v.files)) ? v : null
|
|
677
|
+
}
|
|
678
|
+
|
|
548
679
|
get openView(): MediaView | null {
|
|
549
680
|
const v = this.open?.item.views[this.view]
|
|
550
681
|
return v && !isGallery(v) ? v : null
|
|
@@ -575,15 +706,28 @@ export class ReviewSt {
|
|
|
575
706
|
/** a reaction's toggle: sets that status, or clears it when it is already the one */
|
|
576
707
|
react(view: number, tile: number | undefined, status: ReviewStatus): void {
|
|
577
708
|
const cur = this.reactionOf(view, tile)
|
|
578
|
-
this.putReaction(view, tile, { status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
|
|
709
|
+
this.putReaction(view, tile, { ...cur, status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
|
|
579
710
|
}
|
|
580
711
|
|
|
581
712
|
setReactionComment(view: number, tile: number | undefined, comment: string): void {
|
|
582
|
-
this.putReaction(view, tile, {
|
|
713
|
+
this.putReaction(view, tile, { ...this.reactionOf(view, tile), comment })
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** an option's stars; the same number again clears them */
|
|
717
|
+
setStars(view: number, n: number): void {
|
|
718
|
+
const cur = this.reactionOf(view)
|
|
719
|
+
this.putReaction(view, undefined, { ...cur, comment: cur?.comment ?? '', stars: cur?.stars === n ? undefined : n })
|
|
583
720
|
}
|
|
584
721
|
|
|
585
722
|
removeReaction(view: number, tile: number | undefined): void {
|
|
586
|
-
this.putReaction(view, tile, {
|
|
723
|
+
this.putReaction(view, tile, { comment: '' })
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** « pick »: that option is his answer, sent at once as an approval; his stars and words go with it */
|
|
727
|
+
async choose(view: number): Promise<void> {
|
|
728
|
+
if (!this.choice) return
|
|
729
|
+
this.edit((d) => ({ ...d, pick: view }))
|
|
730
|
+
await this.submit('approved')
|
|
587
731
|
}
|
|
588
732
|
|
|
589
733
|
/** the reactions that go to the agent with the verdict, in the card's order */
|
|
@@ -591,19 +735,27 @@ export class ReviewSt {
|
|
|
591
735
|
return [...(this.draft.reactions ?? [])].sort((a, b) => a.view - b.view || (a.tile ?? -1) - (b.tile ?? -1))
|
|
592
736
|
}
|
|
593
737
|
|
|
594
|
-
private putReaction(view: number, tile: number | undefined, r: { status
|
|
595
|
-
const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment }
|
|
596
|
-
const empty = !r.status && r.comment === ''
|
|
738
|
+
private putReaction(view: number, tile: number | undefined, r: { status?: ReviewStatus | undefined; comment: string; stars?: number | undefined }): void {
|
|
739
|
+
const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment, ...(r.stars ? { stars: r.stars } : {}) }
|
|
740
|
+
const empty = !r.status && r.comment === '' && !r.stars
|
|
597
741
|
this.edit((d) => {
|
|
598
742
|
const rest = (d.reactions ?? []).filter((q) => !sameSpot(q, view, tile))
|
|
599
743
|
return { ...d, reactions: empty ? rest : [...rest, next] }
|
|
600
744
|
})
|
|
601
745
|
}
|
|
602
746
|
|
|
603
|
-
/** the open view's clip identity, empty when it shows images */
|
|
747
|
+
/** the open view's clip identity (a clip, or a sound on a row), empty when it shows images */
|
|
604
748
|
get clipKey(): string {
|
|
605
749
|
const x = this.open
|
|
606
|
-
return x && this.
|
|
750
|
+
return x && this.playableAt(this.view) ? `${x.item.id}#${this.view}` : ''
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** a row's ▶: that option plays with its sound, the one playing before it stops; again, it pauses */
|
|
754
|
+
listen(i: number): void {
|
|
755
|
+
this.setView(i)
|
|
756
|
+
const c = this.clipOf(i)
|
|
757
|
+
if (c.paused) c.listen()
|
|
758
|
+
else c.pause()
|
|
607
759
|
}
|
|
608
760
|
|
|
609
761
|
/** the clock of a clip view of the open card: every clip view plays and steps on its own */
|
|
@@ -616,9 +768,14 @@ export class ReviewSt {
|
|
|
616
768
|
let c = this.clips.get(view)
|
|
617
769
|
if (!c) {
|
|
618
770
|
c = new ClipSt()
|
|
619
|
-
const
|
|
620
|
-
const
|
|
621
|
-
|
|
771
|
+
const views = this.open?.item.views ?? []
|
|
772
|
+
const v = views[view]
|
|
773
|
+
const media = v && !isGallery(v) ? v : undefined
|
|
774
|
+
// one thing plays at a time: the first clip of a card loops on its own, the others and every option wait
|
|
775
|
+
const first = views.findIndex((w) => !isGallery(w) && w.clip !== undefined)
|
|
776
|
+
const still = this.rows || view !== first
|
|
777
|
+
if (media?.audio) c.load(`${id}#${view}`, media.audio.duration, SOUND_FPS, true)
|
|
778
|
+
else c.load(`${id}#${view}`, media?.clip?.duration ?? 0, media?.clip?.fps ?? 20, still)
|
|
622
779
|
this.clips.set(view, c)
|
|
623
780
|
}
|
|
624
781
|
return c
|
|
@@ -740,7 +897,8 @@ export class ReviewSt {
|
|
|
740
897
|
this.busy = true
|
|
741
898
|
this.error = null
|
|
742
899
|
try {
|
|
743
|
-
const
|
|
900
|
+
const pick = isChoice(x.item) && isApproved(status) && d.pick !== undefined ? { pick: d.pick } : {}
|
|
901
|
+
const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [], ...pick })
|
|
744
902
|
runInAction(() => {
|
|
745
903
|
// the verdict shows at once: the list of every card comes back after it
|
|
746
904
|
const r = this.list
|
|
@@ -760,10 +918,22 @@ export class ReviewSt {
|
|
|
760
918
|
}
|
|
761
919
|
|
|
762
920
|
/** the page's keys, as the atelier takes its own: true when it used the key. V approves, F ok with a fix, R rejects,
|
|
763
|
-
* A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
|
|
764
|
-
|
|
921
|
+
* A archives, N the next waiting card, C copies the card for an agent, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
|
|
922
|
+
* (a sound: two seconds); on option rows 1 to 9 play that row, ⇧1 to ⇧5 score the row playing, P picks it. A digit is
|
|
923
|
+
* read from its physical key (`code`), so ⇧ and an AZERTY keyboard read the same */
|
|
924
|
+
key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'> & { code?: string }): boolean {
|
|
765
925
|
const k = e.key.toLowerCase()
|
|
766
|
-
|
|
926
|
+
const digit = Number(/^Digit([1-9])$/.exec(e.code ?? '')?.[1] ?? (/^[1-9]$/.test(e.key) ? e.key : 0))
|
|
927
|
+
const sound = this.openView?.audio !== undefined
|
|
928
|
+
if (this.rows && digit > 0 && !e.shiftKey) {
|
|
929
|
+
if (digit <= (this.open?.item.views.length ?? 0)) this.listen(digit - 1)
|
|
930
|
+
} else if (this.choice && this.rows && digit > 0 && e.shiftKey) {
|
|
931
|
+
if (digit <= STARS) this.setStars(this.view, digit)
|
|
932
|
+
} else if (this.choice && k === 'p') void this.choose(this.view)
|
|
933
|
+
else if (this.clipKey && e.key === ' ') {
|
|
934
|
+
if (this.clip.paused && this.rows) this.clip.listen()
|
|
935
|
+
else this.clip.toggle()
|
|
936
|
+
} else if (this.clipKey && sound && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.jump(this.clip.time + (e.key === 'ArrowRight' ? SOUND_JUMP : -SOUND_JUMP))
|
|
767
937
|
else if (this.clipKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.step(e.key === 'ArrowRight' ? 1 : -1)
|
|
768
938
|
else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') this.move(e.key === 'ArrowDown' ? 1 : -1)
|
|
769
939
|
else if (k === 'v') void this.submit('approved')
|
|
@@ -771,6 +941,7 @@ export class ReviewSt {
|
|
|
771
941
|
else if (k === 'r') void this.submit('rejected')
|
|
772
942
|
else if (k === 'a') void this.submit('archived')
|
|
773
943
|
else if (k === 'n') this.nextPending()
|
|
944
|
+
else if (k === 'c') void this.copyCard()
|
|
774
945
|
else if (k === 'm') this.toggleMarks()
|
|
775
946
|
else if ((e.key === 'Delete' || e.key === 'Backspace') && this.rect !== null) this.removeRect(this.rect)
|
|
776
947
|
else return false
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// the review page's middle column: every view of the open card stacked, each under its heading with the owner's reaction and
|
|
2
2
|
// its zoom; a view is an image, a clip, a pair (side by side, the curtain, the onion skin, the after alone) or a
|
|
3
|
-
// gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time
|
|
3
|
+
// gallery of tiles. A drag on an image draws a rectangle, on a clip it pauses and keeps the frame's time. A choice, or
|
|
4
|
+
// views that each play alone, show as thin option rows; the card's header sits on top when the page reads in one column
|
|
4
5
|
import { observer } from 'mobx-react-lite'
|
|
5
6
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
6
|
-
import { isGallery, isPair, onFrame, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
|
|
7
|
+
import { isGallery, isPair, onFrame, STARS, thumbFileOf, verdictOf, viewName, type GalleryView, type MediaView, type Rect, type ReviewClip, type ReviewItem, type ReviewMark, type ReviewView, type Side } from '../../../review/model.ts'
|
|
7
8
|
import { blurOnEscape, Empty, FIT_ROOM, keepFocus, PAIR_NAMES, REACTIONS, secs } from './kit.tsx'
|
|
9
|
+
import { CardHead } from './review-answer.tsx'
|
|
8
10
|
import type { ClipSt } from './clip-st.ts'
|
|
9
11
|
import { CARD_PLACES, COMPARE_MODES, drawnSide, ZOOMS, type CompareMode, type CompareSt, type PairSide, type ReviewSt, type Zoom } from './review-st.ts'
|
|
10
12
|
|
|
@@ -16,12 +18,20 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
16
18
|
if (!x) return <Empty text={rv.list ? 'No card. An agent adds one with sk review add.' : 'Reading the cards…'} />
|
|
17
19
|
const r = x.item
|
|
18
20
|
const views = r.views
|
|
19
|
-
const drawable = views.some((v) => !isGallery(v) && !v.clip)
|
|
21
|
+
const drawable = views.some((v) => !isGallery(v) && !v.clip && !v.audio)
|
|
22
|
+
const rows = rv.rows
|
|
20
23
|
return (
|
|
21
24
|
<div className="rv-views">
|
|
25
|
+
{rv.headInViews ? <CardHead rv={rv} x={x} /> : null}
|
|
22
26
|
<div className="rv-viewsbar">
|
|
23
|
-
{views.length > 1 ? <JumpList rv={rv} /> : <span />}
|
|
27
|
+
{views.length > 1 && !rows ? <JumpList rv={rv} /> : <span />}
|
|
24
28
|
<span className="rv-viewsctl">
|
|
29
|
+
{asOptionsCard(rv) ? (
|
|
30
|
+
<span className="seg" title="the options as thin rows, one below the other, or every view full">
|
|
31
|
+
<button className={rows ? 'on' : ''} onMouseDown={keepFocus} onClick={() => rv.setFull(false)}>rows</button>
|
|
32
|
+
<button className={rows ? '' : 'on'} onMouseDown={keepFocus} onClick={() => rv.setFull(true)}>full</button>
|
|
33
|
+
</span>
|
|
34
|
+
) : null}
|
|
25
35
|
{rv.phone.on && drawable ? (
|
|
26
36
|
<button className={rv.phone.draw ? 'primary' : ''} onClick={() => rv.phone.toggleDraw()}>Circle</button>
|
|
27
37
|
) : null}
|
|
@@ -36,12 +46,144 @@ export const ReviewImage = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
36
46
|
</span>
|
|
37
47
|
</div>
|
|
38
48
|
{rv.answered.map((y) => <Raised key={y.item.id} rv={rv} x={y} />)}
|
|
39
|
-
{
|
|
49
|
+
{rows ? (
|
|
50
|
+
<div className="rv-opts">{views.map((v, i) => <OptionRow key={`${r.id}#${i}`} rv={rv} x={x} v={v} i={i} />)}</div>
|
|
51
|
+
) : (
|
|
52
|
+
views.map((v, i) => <ViewBlock key={`${r.id}#${i}`} rv={rv} id={r.id} v={v} i={i} many={views.length > 1} />)
|
|
53
|
+
)}
|
|
40
54
|
{views.length === 0 ? <TextOnly x={x} /> : null}
|
|
41
55
|
</div>
|
|
42
56
|
)
|
|
43
57
|
})
|
|
44
58
|
|
|
59
|
+
/** the open card may show as rows: a choice, or views that each play alone (the switch shows while it shows full) */
|
|
60
|
+
const asOptionsCard = (rv: ReviewSt): boolean => rv.rows || rv.full[rv.open?.item.id ?? ''] === true
|
|
61
|
+
|
|
62
|
+
/** one option of a choice, or one view of a group, as a thin row: its number, ▶ and the clock, its label, a strip that
|
|
63
|
+
* scrubs it, its stars and « pick » (a choice) or OK, fix, no (a group), ✎ his word, ⤢ the full view under it */
|
|
64
|
+
const OptionRow = observer(({ rv, x, v, i }: { rv: ReviewSt; x: ReviewItem; v: ReviewView; i: number }) => {
|
|
65
|
+
const [word, setWord] = useState(false)
|
|
66
|
+
const id = x.item.id
|
|
67
|
+
const media = isGallery(v) ? null : v
|
|
68
|
+
const playable = media !== null && !isPair(media.files) && (media.clip !== undefined || media.audio !== undefined) ? media : null
|
|
69
|
+
const c = playable ? rv.clipOf(i) : null
|
|
70
|
+
const reaction = rv.reactionOf(i)
|
|
71
|
+
const choice = rv.choice
|
|
72
|
+
const picked = rv.draft.pick === i
|
|
73
|
+
const sent = verdictOf(x)?.pick === i
|
|
74
|
+
const unfolded = rv.openRows[i] === true
|
|
75
|
+
const file = playable ? ('video' in playable.files ? playable.files.video : 'audio' in playable.files ? playable.files.audio : '') : ''
|
|
76
|
+
const duration = playable?.clip?.duration ?? playable?.audio?.duration ?? 0
|
|
77
|
+
const showWord = word || (reaction?.comment ?? '') !== ''
|
|
78
|
+
return (
|
|
79
|
+
<div id={`review-view-${i}`} className={`rv-opt${rv.view === i ? ' active' : ''}${picked ? ' picked' : ''}${toneClass(reaction?.status)}`} onMouseDownCapture={() => rv.view !== i && rv.setView(i)}>
|
|
80
|
+
<div className="rv-optrow">
|
|
81
|
+
<span className="rv-optn">{i + 1}</span>
|
|
82
|
+
{c ? (
|
|
83
|
+
<button className={`rv-optplay${c.paused ? '' : ' on'}`} title={`${c.paused ? 'play' : 'pause'} (${i + 1})`} onMouseDown={keepFocus} onClick={() => rv.listen(i)}>
|
|
84
|
+
{c.paused ? '▶' : '❚❚'}
|
|
85
|
+
</button>
|
|
86
|
+
) : null}
|
|
87
|
+
<b className="rv-optlabel" title={viewName(v, i)}>{viewName(v, i)}</b>
|
|
88
|
+
{c && playable?.clip && !unfolded ? (
|
|
89
|
+
<span className="rv-optthumb" style={{ aspectRatio: `${playable.size.w} / ${playable.size.h}` }} onClick={() => rv.toggleRow(i)} title="the full view">
|
|
90
|
+
<ClipVideo clip={c} src={rv.fileUrl(id, file)} poster={rv.fileUrl(id, playable.clip.poster)} loop={false} />
|
|
91
|
+
</span>
|
|
92
|
+
) : null}
|
|
93
|
+
{c && playable ? (
|
|
94
|
+
<Strip clip={c} duration={duration} wave={playable.audio ? rv.fileUrl(id, playable.audio.wave) : undefined} />
|
|
95
|
+
) : (
|
|
96
|
+
<span className="rv-optthumb img" onClick={() => rv.toggleRow(i)} title="the full view"><img src={rv.thumbUrl(x, thumbFileOf(v))} alt="" draggable={false} /></span>
|
|
97
|
+
)}
|
|
98
|
+
{c ? <span className="rv-mono rv-opttime">{clock(c.time)} / {clock(duration)}</span> : null}
|
|
99
|
+
<span className="rv-optacts">
|
|
100
|
+
{choice ? <Stars rv={rv} view={i} /> : <ReactionToggles rv={rv} view={i} small />}
|
|
101
|
+
<button className={`rv-optword${showWord ? ' on' : ''}`} title="a word on this one" onMouseDown={keepFocus} onClick={() => setWord(!word)}>✎</button>
|
|
102
|
+
{playable?.clip || !playable ? (
|
|
103
|
+
<button className={unfolded ? 'on' : ''} title={unfolded ? 'fold it back to its row' : 'the full view'} onMouseDown={keepFocus} onClick={() => rv.toggleRow(i)}>⤢</button>
|
|
104
|
+
) : null}
|
|
105
|
+
{playable?.audio ? <a className="rv-dl" href={rv.fileUrl(id, file)} download={`${id}-${file}`} title="download the sound">⤓</a> : null}
|
|
106
|
+
{choice ? (
|
|
107
|
+
<button className={`rv-pick${picked ? ' on' : ''}`} title={`pick this one: it is your answer (P)${sent ? ', sent' : ''}`} disabled={rv.busy} onClick={() => void rv.choose(i)}>
|
|
108
|
+
{picked ? '✓ picked' : 'pick'}
|
|
109
|
+
</button>
|
|
110
|
+
) : null}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
{media?.note ? <div className="rv-optnote">{media.note}</div> : null}
|
|
114
|
+
{showWord ? (
|
|
115
|
+
<textarea className="rv-word" rows={1} placeholder="a word on this one" value={reaction?.comment ?? ''} autoFocus={word && !reaction?.comment}
|
|
116
|
+
onKeyDown={blurOnEscape} onChange={(e) => rv.setReactionComment(i, undefined, e.currentTarget.value)} />
|
|
117
|
+
) : null}
|
|
118
|
+
{c && playable?.audio ? <SoundEl clip={c} src={rv.fileUrl(id, file)} /> : null}
|
|
119
|
+
{unfolded ? (
|
|
120
|
+
<div className="rv-optbody">
|
|
121
|
+
{isGallery(v) ? <Gallery rv={rv} id={id} v={v} view={i} /> : <MediaBody rv={rv} id={id} v={v} i={i} zoom={rv.zoomOf(i)} />}
|
|
122
|
+
</div>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/** `1:05`: a time on a row, short */
|
|
129
|
+
const clock = (t: number): string => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
|
|
130
|
+
|
|
131
|
+
/** an option's score: STARS stars, a click on the one given clears it */
|
|
132
|
+
const Stars = observer(({ rv, view }: { rv: ReviewSt; view: number }) => {
|
|
133
|
+
const n = rv.reactionOf(view)?.stars ?? 0
|
|
134
|
+
return (
|
|
135
|
+
<span className="rv-stars" title={`your score (⇧1 to ⇧${STARS} on the row playing)`}>
|
|
136
|
+
{Array.from({ length: STARS }, (_, k) => (
|
|
137
|
+
<button key={k} className={k < n ? 'on' : ''} aria-label={`${k + 1} of ${STARS}`} onMouseDown={keepFocus} onClick={() => rv.setStars(view, k + 1)}>{k < n ? '★' : '☆'}</button>
|
|
138
|
+
))}
|
|
139
|
+
</span>
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
/** a thin track a row scrubs: a sound's waveform under what has played; a click or a drag moves the time */
|
|
144
|
+
const Strip = observer(({ clip, duration, wave }: { clip: ClipSt; duration: number; wave: string | undefined }) => {
|
|
145
|
+
const el = useRef<HTMLDivElement>(null)
|
|
146
|
+
const down = (e: React.PointerEvent): void => {
|
|
147
|
+
const b = el.current?.getBoundingClientRect()
|
|
148
|
+
if (!b || duration <= 0) return
|
|
149
|
+
e.preventDefault()
|
|
150
|
+
const at = (px: number): void => clip.jump(Math.max(0, Math.min(1, (px - b.left) / b.width)) * duration)
|
|
151
|
+
at(e.clientX)
|
|
152
|
+
track((m) => at(m.clientX))
|
|
153
|
+
}
|
|
154
|
+
const played = duration > 0 ? Math.min(1, clip.time / duration) : 0
|
|
155
|
+
return (
|
|
156
|
+
<div ref={el} className={`rv-strip${wave ? ' wave' : ''}`} onPointerDown={down}>
|
|
157
|
+
{wave ? (
|
|
158
|
+
<>
|
|
159
|
+
<Wave src={wave} className="rv-stripwave" />
|
|
160
|
+
<Wave src={wave} className="rv-stripwave done" style={{ clipPath: `inset(0 ${100 - played * 100}% 0 0)` }} />
|
|
161
|
+
</>
|
|
162
|
+
) : (
|
|
163
|
+
<div className="rv-stripdone" style={{ width: `${played * 100}%` }} />
|
|
164
|
+
)}
|
|
165
|
+
<div className="rv-striphead" style={{ left: `${played * 100}%` }} />
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/** a waveform png as a mask the page fills with its own colour, so it reads on either theme (any colour it was drawn in) */
|
|
171
|
+
const Wave = ({ src, className, style }: { src: string; className: string; style?: React.CSSProperties }) => {
|
|
172
|
+
const mask = `url("${src}") center / 100% 100% no-repeat`
|
|
173
|
+
return <div className={className} style={{ ...style, mask, WebkitMask: mask }} />
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** a sound row's player, no controls of its own: the row's clock drives it; at its end it waits at the start */
|
|
177
|
+
const SoundEl = ({ clip, src }: { clip: ClipSt; src: string }) => {
|
|
178
|
+
const ref = useRef<HTMLAudioElement>(null)
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const el = ref.current
|
|
181
|
+
if (!el) return
|
|
182
|
+
return clip.attach(el)
|
|
183
|
+
}, [clip, src])
|
|
184
|
+
return <audio ref={ref} src={src} preload="metadata" onEnded={() => clip.seek(0)} />
|
|
185
|
+
}
|
|
186
|
+
|
|
45
187
|
/** a raised item the open card answers, nested above its views: the owner's words, his still */
|
|
46
188
|
const Raised = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
|
|
47
189
|
const opening = x.thread.find((m) => m.op === undefined && m.text !== undefined)
|
|
@@ -213,7 +355,9 @@ const SoundBody = ({ rv, id, v }: { rv: ReviewSt; id: string; v: MediaView }) =>
|
|
|
213
355
|
{rows.map((r) => (
|
|
214
356
|
<div key={r.file} className="rv-soundrow">
|
|
215
357
|
{r.label ? <div className="rv-dim">{r.label}</div> : null}
|
|
216
|
-
<
|
|
358
|
+
<div role="img" aria-label={`the waveform of ${r.file}`} className="rv-wavebox" style={{ aspectRatio: `${v.size.w} / ${v.size.h}` }}>
|
|
359
|
+
<Wave src={rv.fileUrl(id, r.wave)} className="rv-stripwave full" />
|
|
360
|
+
</div>
|
|
217
361
|
<audio controls preload="metadata" src={rv.fileUrl(id, r.file)} style={{ width: '100%' }} />
|
|
218
362
|
</div>
|
|
219
363
|
))}
|
|
@@ -463,15 +607,16 @@ const RectMark = ({ r, n, on, faint, at, pick }: { r: Rect; n: number; on: boole
|
|
|
463
607
|
|
|
464
608
|
// ── a clip ──
|
|
465
609
|
|
|
466
|
-
/** one video of the open view, driven by the view's one clock; muted until the sound button, looping,
|
|
467
|
-
|
|
610
|
+
/** one video of the open view, driven by the view's one clock; muted until the sound button, inline; looping, or at its
|
|
611
|
+
* end waiting at the start */
|
|
612
|
+
const ClipVideo = ({ clip, src, poster, loop = true }: { clip: ClipSt; src: string; poster?: string; loop?: boolean }) => {
|
|
468
613
|
const ref = useRef<HTMLVideoElement>(null)
|
|
469
614
|
useEffect(() => {
|
|
470
615
|
const el = ref.current
|
|
471
616
|
if (!el) return
|
|
472
617
|
return clip.attach(el)
|
|
473
618
|
}, [clip, src])
|
|
474
|
-
return <video ref={ref} className="rv-video" src={src} muted loop playsInline preload="auto" />
|
|
619
|
+
return <video ref={ref} className="rv-video" src={src} poster={poster} muted loop={loop} playsInline preload="auto" onEnded={loop ? undefined : () => clip.seek(0)} />
|
|
475
620
|
}
|
|
476
621
|
|
|
477
622
|
/** the sound button and the volume: a volume moved turns the sound on */
|