@crievo/omni 0.1.2 → 0.1.4
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/docs/onboarding.md +69 -161
- package/omnivideo.config.example.json +1 -1
- package/package.json +2 -1
- package/reference/learnings.md +12 -0
- package/reference/omnichat.md +5 -4
- package/reference/publish.md +9 -9
- package/reference/studio.md +1 -1
- package/studio/api/app.ts +2 -2
- package/studio/api/routes/sound.ts +22 -3
- package/studio/api/services/onboarding.ts +242 -21
- package/studio/api/services/settings.ts +4 -2
- package/studio/api/services/sound.ts +45 -0
- package/studio/shared/types/api.ts +26 -1
- package/studio/studio.ts +1 -0
- package/studio/web/components/AppShell.tsx +3 -1
- package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
- package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
- package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
- package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
- package/studio/web/components/Omni/OmniWidget.tsx +14 -5
- package/studio/web/components/Omni/PendingActions.tsx +25 -6
- package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
- package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
- package/studio/web/components/ResultMessage.tsx +12 -3
- package/studio/web/components/Sidebar.tsx +63 -24
- package/studio/web/components/StageStepper.tsx +105 -34
- package/studio/web/components/Toaster.tsx +25 -0
- package/studio/web/components/VoiceCarousel.tsx +19 -16
- package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
- package/studio/web/components/bencho/ActionNode.tsx +456 -0
- package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
- package/studio/web/components/bencho/BenchoScope.tsx +8 -0
- package/studio/web/components/bencho/Checklist.tsx +421 -0
- package/studio/web/components/bencho/CreateMenu.tsx +258 -0
- package/studio/web/components/bencho/GenerateImage.tsx +290 -0
- package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
- package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
- package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
- package/studio/web/components/bencho/NowPlaying.tsx +403 -0
- package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
- package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
- package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
- package/studio/web/components/bencho/SloshSlider.tsx +394 -0
- package/studio/web/components/bencho/StackedBars.tsx +342 -0
- package/studio/web/components/bencho/ToastStack.tsx +263 -0
- package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
- package/studio/web/components/bencho/VoiceNote.tsx +705 -0
- package/studio/web/components/icons.tsx +27 -0
- package/studio/web/components/ui/slider.tsx +34 -1
- package/studio/web/components/ui/switch.tsx +18 -5
- package/studio/web/hooks/useOnboarding.ts +24 -6
- package/studio/web/index.html +1 -1
- package/studio/web/lib/alerts.ts +10 -0
- package/studio/web/lib/omni-panel.ts +4 -1
- package/studio/web/lib/toast.ts +196 -0
- package/studio/web/lib/upload.ts +24 -0
- package/studio/web/pages/BenchoPage/index.tsx +74 -0
- package/studio/web/pages/BenchoPage/registry.ts +174 -0
- package/studio/web/pages/GettingStartedPage/components/ComputerCard.tsx +102 -0
- package/studio/web/pages/GettingStartedPage/index.tsx +84 -58
- package/studio/web/pages/NewVideoPage/index.tsx +6 -2
- package/studio/web/pages/ReferencesPage/index.tsx +4 -1
- package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +30 -3
- package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
- package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
- package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
- package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
- package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
- package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
- package/studio/web/pages/VideoPage/index.tsx +4 -0
- package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/bencho/action-node.css +227 -0
- package/studio/web/styles/components/bencho/checklist.css +182 -0
- package/studio/web/styles/components/bencho/dock.css +188 -0
- package/studio/web/styles/components/bencho/gallery.css +109 -0
- package/studio/web/styles/components/bencho/generate.css +151 -0
- package/studio/web/styles/components/bencho/image-accordion.css +37 -0
- package/studio/web/styles/components/bencho/liq-create.css +279 -0
- package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
- package/studio/web/styles/components/bencho/one-time-code.css +148 -0
- package/studio/web/styles/components/bencho/picker.css +204 -0
- package/studio/web/styles/components/bencho/pull.css +313 -0
- package/studio/web/styles/components/bencho/scope.css +42 -0
- package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
- package/studio/web/styles/components/bencho/slosh.css +137 -0
- package/studio/web/styles/components/bencho/sound.css +260 -0
- package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
- package/studio/web/styles/components/bencho/toast-stack.css +192 -0
- package/studio/web/styles/components/bencho/tokens.css +243 -0
- package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
- package/studio/web/styles/components/bencho/voice-note.css +337 -0
- package/studio/web/styles/components/clone-voice.css +12 -2
- package/studio/web/styles/components/omni.css +6 -1
- package/studio/web/styles/components/stepper.css +123 -7
- package/studio/web/styles/components/toaster.css +10 -0
- package/studio/web/styles/components/ui/slider.css +6 -16
- package/studio/web/styles/components/ui/switch.css +2 -41
- package/studio/web/styles/components/voice-carousel.css +46 -4
- package/studio/web/styles/index.css +23 -0
- package/studio/web/styles/pages/getting-started.css +46 -0
- package/studio/web/styles/pages/settings/shell.css +4 -0
- package/studio/web/styles/pages/video/layout.css +1 -4
- package/studio/web/styles/pages/video/sound-timeline.css +8 -0
- package/studio/web/styles/theme/identity/shell.css +10 -26
- package/tools/lib/agents.mjs +24 -13
- package/tools/lib/claude-settings.mjs +32 -3
- package/tools/lib/deps.mjs +24 -0
- package/tools/lib/platforms/instagram.mjs +1 -1
- package/tools/lib/up.mjs +5 -2
- package/tools/omni.mjs +7 -5
- package/tools/omnichat.mjs +5 -7
- package/tools/package.mjs +5 -3
- package/tools/publish.mjs +40 -137
- package/tools/reference.mjs +1 -1
- package/tools/setup.mjs +16 -15
- package/tools/sound.mjs +67 -3
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/* A dock whose icons swell toward the cursor, the nearest growing most. Scale falls off with distance from the pointer
|
|
2
|
+
rather than applying only to the hovered item, which is what stops it reading as a row of buttons that happen to
|
|
3
|
+
grow. Ported from bencho.dev (MIT). */
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type PointerEvent as ReactPointerEvent,
|
|
7
|
+
type RefObject,
|
|
8
|
+
useEffect,
|
|
9
|
+
useLayoutEffect,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { Bookmark, Folder, House, MagnifyingGlass, User } from "../icons";
|
|
14
|
+
|
|
15
|
+
type Item = { key: string; label: string; Icon: typeof House };
|
|
16
|
+
|
|
17
|
+
const DOCK: Item[] = [
|
|
18
|
+
{ key: "home", label: "Início", Icon: House },
|
|
19
|
+
{ key: "search", label: "Buscar", Icon: MagnifyingGlass },
|
|
20
|
+
{ key: "files", label: "Arquivos", Icon: Folder },
|
|
21
|
+
{ key: "saved", label: "Salvos", Icon: Bookmark },
|
|
22
|
+
{ key: "you", label: "Você", Icon: User },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function MagnifyDock({
|
|
26
|
+
/* what the glyph under the cursor grows to */
|
|
27
|
+
magnify = 1.32,
|
|
28
|
+
/* how many neighbours either side feel it: at 0 the dock is a row of buttons that happen to grow, and no amount of
|
|
29
|
+
magnification rescues it; higher and it reads as a sheet being pushed up from underneath */
|
|
30
|
+
spread = 2,
|
|
31
|
+
/* how far the nearest glyph rises out of the bar */
|
|
32
|
+
lift = 8,
|
|
33
|
+
/* the name that appears over whatever is under the cursor */
|
|
34
|
+
labels = true,
|
|
35
|
+
}: {
|
|
36
|
+
magnify?: number;
|
|
37
|
+
spread?: number;
|
|
38
|
+
lift?: number;
|
|
39
|
+
labels?: boolean;
|
|
40
|
+
}) {
|
|
41
|
+
const [hover, setHover] = useState<number | null>(null);
|
|
42
|
+
const [active, setActive] = useState(DOCK[0].key);
|
|
43
|
+
|
|
44
|
+
/* The bar reads the pointer, the items do not. A touch is captured to whatever it started on, so per-item enter
|
|
45
|
+
handlers keep sending every event to the first icon a finger landed on while it slides along the dock. One handler
|
|
46
|
+
on the bar instead, the index coming from the nearest item centre to the pointer's x: measured rather than derived
|
|
47
|
+
from the index, because the items are not all the same width once one is magnified, and an arithmetic guess would
|
|
48
|
+
lag the sweep. */
|
|
49
|
+
const bar = useRef<HTMLElement | null>(null);
|
|
50
|
+
const down = useRef(false);
|
|
51
|
+
/* set when a finger has already chosen on lift, so the click the browser sends afterwards (aimed at the item the
|
|
52
|
+
touch started on, not the one it ended over) does not undo the choice. A mouse never sets it. */
|
|
53
|
+
const chose = useRef(false);
|
|
54
|
+
|
|
55
|
+
const at = (clientX: number) => {
|
|
56
|
+
const items = bar.current?.querySelectorAll(".bn-gdock-item");
|
|
57
|
+
if (!items?.length) return null;
|
|
58
|
+
let best = 0;
|
|
59
|
+
let gap = Number.POSITIVE_INFINITY;
|
|
60
|
+
items.forEach((el, i) => {
|
|
61
|
+
const b = el.getBoundingClientRect();
|
|
62
|
+
const d = Math.abs(clientX - (b.left + b.width / 2));
|
|
63
|
+
if (d < gap) {
|
|
64
|
+
gap = d;
|
|
65
|
+
best = i;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return best;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const to = (i: number | null) => setHover(i);
|
|
72
|
+
|
|
73
|
+
const track = (e: ReactPointerEvent) => {
|
|
74
|
+
/* a mouse magnifies on hover; a finger has to be down, or the bar would answer a scroll passing over it */
|
|
75
|
+
if (e.pointerType !== "mouse" && !down.current) return;
|
|
76
|
+
to(at(e.clientX));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<nav
|
|
81
|
+
className="bn-gdock"
|
|
82
|
+
aria-label="Dock"
|
|
83
|
+
ref={bar}
|
|
84
|
+
/* Capture phase, and no pointer capture: two different things. Each item stops pointerdown propagating, so
|
|
85
|
+
bubbling handlers here never ran (measured: the magnification sat on item 0 through a full sweep); capture runs
|
|
86
|
+
top-down, before the child can stop it. What this does not do is setPointerCapture: the touch is already
|
|
87
|
+
captured to the item it began on, and capturing on the bar as well would retarget the click and take it from
|
|
88
|
+
the item, breaking the mouse to fix the finger. */
|
|
89
|
+
onPointerDownCapture={e => {
|
|
90
|
+
down.current = true;
|
|
91
|
+
/* disarmed here, not by the click: a finger that lands on one item and lifts over another is a drag, and a
|
|
92
|
+
browser is entitled to send no click at all, so a flag cleared by the click could stay armed and eat the
|
|
93
|
+
next real mouse click. Cleared at the start of the next gesture instead. */
|
|
94
|
+
chose.current = false;
|
|
95
|
+
to(at(e.clientX));
|
|
96
|
+
}}
|
|
97
|
+
onPointerMoveCapture={track}
|
|
98
|
+
/* with the bar holding the touch the button underneath never gets its click, so the lift is where a finger
|
|
99
|
+
picks: whichever item it came to rest over. A mouse still clicks the button. */
|
|
100
|
+
onPointerUpCapture={e => {
|
|
101
|
+
if (down.current && e.pointerType !== "mouse") {
|
|
102
|
+
const i = at(e.clientX);
|
|
103
|
+
if (i !== null) {
|
|
104
|
+
chose.current = true;
|
|
105
|
+
if (DOCK[i].key !== active) {
|
|
106
|
+
setActive(DOCK[i].key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
down.current = false;
|
|
111
|
+
if (e.pointerType !== "mouse") to(null);
|
|
112
|
+
}}
|
|
113
|
+
onPointerCancelCapture={() => {
|
|
114
|
+
down.current = false;
|
|
115
|
+
to(null);
|
|
116
|
+
}}
|
|
117
|
+
onPointerLeave={() => {
|
|
118
|
+
if (!down.current) to(null);
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
{DOCK.map(({ key, label, Icon }, i) => {
|
|
122
|
+
const d = hover === null ? 99 : Math.abs(i - hover);
|
|
123
|
+
/* a raised cosine over the reach: 1 under the cursor, easing to 0 at the edge of the reach and flat zero past
|
|
124
|
+
it. A curve, so the reach is adjustable without picking every intermediate value by hand. */
|
|
125
|
+
const f = d > spread ? 0 : (1 + Math.cos((Math.PI * d) / (spread + 1))) / 2;
|
|
126
|
+
const scale = 1 + (magnify - 1) * f;
|
|
127
|
+
return (
|
|
128
|
+
<button
|
|
129
|
+
key={key}
|
|
130
|
+
type="button"
|
|
131
|
+
className="bn-gdock-item"
|
|
132
|
+
aria-label={label}
|
|
133
|
+
aria-current={active === key ? "page" : undefined}
|
|
134
|
+
data-near={d <= 1 ? d : undefined}
|
|
135
|
+
data-active={active === key}
|
|
136
|
+
onClick={() => {
|
|
137
|
+
/* the lift already chose: see `chose` */
|
|
138
|
+
if (chose.current) return;
|
|
139
|
+
setActive(key);
|
|
140
|
+
}}
|
|
141
|
+
onPointerDown={e => e.stopPropagation()}
|
|
142
|
+
>
|
|
143
|
+
{/* the glyph magnifies, not the button: scaling the whole item dragged the dot and the label along with it */}
|
|
144
|
+
<span className="bn-gdock-glyph" style={{ transform: `translateY(${-lift * f}px) scale(${scale})` }}>
|
|
145
|
+
<Icon size={20} />
|
|
146
|
+
</span>
|
|
147
|
+
<span className="bn-gdock-dot" data-on={active === key} />
|
|
148
|
+
</button>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
{labels && <DockTip bar={bar} at={hover} />}
|
|
152
|
+
</nav>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* One tip that travels: not a label per icon blinking on and off, but one pill that slides along to whichever icon is
|
|
157
|
+
under the pointer and changes width to fit the new word as it goes, both on one short curve. It keeps the last word
|
|
158
|
+
while it fades, so leaving the dock does not empty it mid-fade. Its place is read off the item it sits over, centre to
|
|
159
|
+
centre, since a magnified dock does not lay out evenly. */
|
|
160
|
+
function DockTip({ bar, at }: { bar: RefObject<HTMLElement | null>; at: number | null }) {
|
|
161
|
+
const [last, setLast] = useState(0);
|
|
162
|
+
const [place, setPlace] = useState<{ x: number; w: number } | null>(null);
|
|
163
|
+
const word = useRef<HTMLSpanElement>(null);
|
|
164
|
+
/* the first placing must not slide in from the bar's left edge: until it has been placed once it moves without a
|
|
165
|
+
transition */
|
|
166
|
+
const [ready, setReady] = useState(false);
|
|
167
|
+
if (at !== null && at !== last) setLast(at);
|
|
168
|
+
/* a child's layout effect runs before the parent's ref is attached, so on mount `bar.current` is still null and the
|
|
169
|
+
first placing would wait for the next hover change (item 0 would show no tip the first time); this re-runs it once
|
|
170
|
+
the bar is there */
|
|
171
|
+
const [mounted, setMounted] = useState(false);
|
|
172
|
+
useEffect(() => setMounted(true), []);
|
|
173
|
+
|
|
174
|
+
useLayoutEffect(() => {
|
|
175
|
+
const nav = bar.current;
|
|
176
|
+
const item = nav?.querySelectorAll<HTMLElement>(".bn-gdock-item")[last];
|
|
177
|
+
const text = word.current;
|
|
178
|
+
if (!nav || !item || !text) return;
|
|
179
|
+
/* offsetLeft is layout, so the glyph's own scale does not skew it */
|
|
180
|
+
const w = Math.ceil(text.scrollWidth) + 24;
|
|
181
|
+
setPlace({ x: item.offsetLeft + item.offsetWidth / 2 - w / 2, w });
|
|
182
|
+
}, [last, bar, mounted]);
|
|
183
|
+
useLayoutEffect(() => {
|
|
184
|
+
if (!place || ready) return;
|
|
185
|
+
const id = requestAnimationFrame(() => setReady(true));
|
|
186
|
+
return () => cancelAnimationFrame(id);
|
|
187
|
+
}, [place, ready]);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<span
|
|
191
|
+
className="bn-gdock-tip"
|
|
192
|
+
aria-hidden="true"
|
|
193
|
+
data-on={at !== null && !!place}
|
|
194
|
+
data-ready={ready || undefined}
|
|
195
|
+
style={place ? { width: place.w, transform: `translateX(${place.x}px)` } : undefined}
|
|
196
|
+
>
|
|
197
|
+
<span ref={word}>{DOCK[last].label}</span>
|
|
198
|
+
</span>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/* A now-playing bar that opens into a player and folds back as one object.
|
|
2
|
+
Ported from bencho.dev (MIT).
|
|
3
|
+
|
|
4
|
+
One number is the state: a single tween runs 0 -> 1 and every position, size, radius and type size is read off it.
|
|
5
|
+
Nothing has a transition of its own, so nothing can arrive early. The cover is the hinge: it travels from a 40px
|
|
6
|
+
square at the left of the bar to a 64px square beside the words and never crossfades. The width never changes;
|
|
7
|
+
the card only grows downward and the transport slides from the end of the bar to the middle.
|
|
8
|
+
Demo data: the track, the cover art and the clock are made up, nothing plays. */
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useState } from "react";
|
|
11
|
+
import { Heart, SkipBack, SkipForward } from "../icons";
|
|
12
|
+
|
|
13
|
+
/* how long it takes, as a multiplier on the tuned duration: 0 is 1.6x slower, 100 is 2.5x faster, 50 is exactly 1 */
|
|
14
|
+
const rate = (speed: number) => 1.6 - (speed / 100) * 1.2;
|
|
15
|
+
|
|
16
|
+
/* a preference, not a live input: read once */
|
|
17
|
+
const stillness = () =>
|
|
18
|
+
typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
19
|
+
|
|
20
|
+
/* the sleeve: generated art (sunset over two hills), cropped square by `cover` */
|
|
21
|
+
const COVER_SVG =
|
|
22
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">' +
|
|
23
|
+
'<defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1">' +
|
|
24
|
+
'<stop offset="0" stop-color="#3b2a6b"/><stop offset=".45" stop-color="#d9577a"/><stop offset="1" stop-color="#f6b56a"/>' +
|
|
25
|
+
"</linearGradient></defs>" +
|
|
26
|
+
'<rect width="100" height="100" fill="url(#s)"/>' +
|
|
27
|
+
'<circle cx="64" cy="52" r="15" fill="#ffe3a8"/>' +
|
|
28
|
+
'<path d="M0 70 C20 56 38 60 56 68 S88 66 100 58 V100 H0Z" fill="#6b2f5c"/>' +
|
|
29
|
+
'<path d="M0 82 C18 72 40 78 60 82 S86 80 100 74 V100 H0Z" fill="#2a1840"/>' +
|
|
30
|
+
"</svg>";
|
|
31
|
+
const COVER = `url("data:image/svg+xml,${encodeURIComponent(COVER_SVG)}")`;
|
|
32
|
+
|
|
33
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
34
|
+
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
35
|
+
|
|
36
|
+
/* Eased, not sprung. A spring past its target takes every derived value along, and the corner would dip under its end
|
|
37
|
+
value and come back: a wobble, not a bounce. Quart-out lands without ever passing the number. It reads its start
|
|
38
|
+
from wherever it currently is, so pressing again mid-flight turns the object around. */
|
|
39
|
+
const BASE = 460;
|
|
40
|
+
|
|
41
|
+
const QUART = (t: number) => 1 - (1 - t) ** 4;
|
|
42
|
+
/* the swell's clock */
|
|
43
|
+
const FLAT = (t: number) => t;
|
|
44
|
+
/* in and out, for the one thing that is a round trip between two shapes */
|
|
45
|
+
const SWING = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
|
|
46
|
+
|
|
47
|
+
function useTween(to: number, ms: number, instant = false, ease = QUART) {
|
|
48
|
+
const [at, setAt] = useState(to);
|
|
49
|
+
const cur = useRef(to);
|
|
50
|
+
const raf = useRef(0);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (instant) {
|
|
54
|
+
cur.current = to;
|
|
55
|
+
setAt(to);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const from = cur.current;
|
|
59
|
+
if (from === to) return;
|
|
60
|
+
const t0 = performance.now();
|
|
61
|
+
const step = (now: number) => {
|
|
62
|
+
const t = Math.min(1, (now - t0) / ms);
|
|
63
|
+
cur.current = from + (to - from) * ease(t);
|
|
64
|
+
setAt(cur.current);
|
|
65
|
+
raf.current = t < 1 ? requestAnimationFrame(step) : 0;
|
|
66
|
+
};
|
|
67
|
+
raf.current = requestAnimationFrame(step);
|
|
68
|
+
return () => {
|
|
69
|
+
cancelAnimationFrame(raf.current);
|
|
70
|
+
raf.current = 0;
|
|
71
|
+
};
|
|
72
|
+
}, [to, ms, instant, ease]);
|
|
73
|
+
|
|
74
|
+
return at;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* one width for both states, and the frame's width too */
|
|
78
|
+
const W = 260;
|
|
79
|
+
/* 78 and not 70: the track needs a band of its own at the foot */
|
|
80
|
+
const SHUT = 78;
|
|
81
|
+
|
|
82
|
+
/* One margin, both states: the object grows, its frame does not move. The sleeve's inset, the rail's inset, where
|
|
83
|
+
the transport row ends and the room under it all answer to it. */
|
|
84
|
+
const PAD = 10;
|
|
85
|
+
const ART = { s: [40, 64] } as const;
|
|
86
|
+
|
|
87
|
+
/* Two concentric corners: the box's radius is the sleeve's plus the margin between them, so the curves stay parallel
|
|
88
|
+
and never pinch. The knob sets the sleeve and the box is derived from it. It stops at 32, where the 64px sleeve
|
|
89
|
+
becomes a circle; the bar's sleeve scales by the ratio of the two squares so it reaches its own circle at the same
|
|
90
|
+
setting. */
|
|
91
|
+
const CORNER = 16;
|
|
92
|
+
const CORNER_MAX = 32;
|
|
93
|
+
|
|
94
|
+
/* The card's stack, measured from the sleeve down. The card's height falls out of the sum. */
|
|
95
|
+
const RAIL_GAP = 22;
|
|
96
|
+
const CLOCK_GAP = 8; /* also .bn-snd-bar's own gap */
|
|
97
|
+
const OPS_GAP = 16;
|
|
98
|
+
const RAIL_H = 3;
|
|
99
|
+
const CLOCK_H = 10;
|
|
100
|
+
const LEAD = 46; /* the play button, opened */
|
|
101
|
+
|
|
102
|
+
const RAIL_Y = PAD + ART.s[1] + RAIL_GAP;
|
|
103
|
+
const OPS_Y = RAIL_Y + RAIL_H + CLOCK_GAP + CLOCK_H + OPS_GAP + LEAD / 2;
|
|
104
|
+
/* the transport's own bottom, and one more margin under it */
|
|
105
|
+
const OPEN = Math.round(OPS_Y + LEAD / 2 + PAD);
|
|
106
|
+
|
|
107
|
+
const TOTAL = 214;
|
|
108
|
+
|
|
109
|
+
/* The play mark is drawn, not swapped. Both marks are the same two quadrilaterals; the difference is where eight
|
|
110
|
+
points are. The pause is a pair of bars; the play is that pair with the inner edges pulled to the middle and
|
|
111
|
+
collapsed to a point. Wound the same way in both (top-left, top-right, bottom-right, bottom-left) or the halves
|
|
112
|
+
would turn inside out on the way across. */
|
|
113
|
+
const PAUSE_L = [6, 4, 10, 4, 10, 20, 6, 20] as const;
|
|
114
|
+
const PLAY_L = [6.5, 4, 13.25, 8, 13.25, 16, 6.5, 20] as const;
|
|
115
|
+
const PAUSE_R = [14, 4, 18, 4, 18, 20, 14, 20] as const;
|
|
116
|
+
const PLAY_R = [13.25, 8, 20, 12, 20, 12, 13.25, 16] as const;
|
|
117
|
+
|
|
118
|
+
const quad = (a: readonly number[], b: readonly number[], t: number) => {
|
|
119
|
+
let d = "";
|
|
120
|
+
for (let i = 0; i < 8; i += 2)
|
|
121
|
+
d += `${i ? "L" : "M"}${mix(a[i], b[i], t).toFixed(2)} ${mix(a[i + 1], b[i + 1], t).toFixed(2)}`;
|
|
122
|
+
return `${d}Z`;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function Mark({ playing, size, still }: { playing: boolean; size: number; still: boolean }) {
|
|
126
|
+
/* 0 is the pause, 1 is the play; eased both ends since it leaves a shape as well as arriving at one */
|
|
127
|
+
const t = useTween(playing ? 0 : 1, 300, still, SWING);
|
|
128
|
+
|
|
129
|
+
/* The goo: zero at both ends and one in the middle, so it is a bulge on the way rather than a difference between
|
|
130
|
+
the states. The mark squashes across as it stretches up, the halves lean into each other until they touch and the
|
|
131
|
+
whole thing tips and comes back level. The tilt swaps sign with the direction. */
|
|
132
|
+
const goo = Math.sin(clamp(t, 0, 1) * Math.PI);
|
|
133
|
+
const pull = 1.6 * goo;
|
|
134
|
+
const tip = (playing ? -9 : 9) * goo;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<svg
|
|
138
|
+
width={size}
|
|
139
|
+
height={size}
|
|
140
|
+
viewBox="0 0 24 24"
|
|
141
|
+
aria-hidden="true"
|
|
142
|
+
fill="currentColor"
|
|
143
|
+
stroke="currentColor"
|
|
144
|
+
strokeWidth={2}
|
|
145
|
+
strokeLinejoin="round"
|
|
146
|
+
strokeLinecap="round"
|
|
147
|
+
style={{
|
|
148
|
+
transformOrigin: "50% 50%",
|
|
149
|
+
transform: `rotate(${tip}deg) scale(${1 - 0.13 * goo}, ${1 + 0.11 * goo})`,
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<path d={quad(PAUSE_L, PLAY_L, t)} transform={`translate(${pull} 0)`} />
|
|
153
|
+
<path d={quad(PAUSE_R, PLAY_R, t)} transform={`translate(${-pull} 0)`} />
|
|
154
|
+
</svg>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* the heart's box, and the room the words give up for it */
|
|
159
|
+
const LIKE = 30;
|
|
160
|
+
|
|
161
|
+
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
162
|
+
|
|
163
|
+
export function NowPlaying({
|
|
164
|
+
/* how quickly the shape changes, 0..100: 50 is the tuned 460ms */
|
|
165
|
+
morph = 50,
|
|
166
|
+
/* the sleeve's corner on the card, 0..32; the box's corner follows it */
|
|
167
|
+
corner = CORNER,
|
|
168
|
+
}: {
|
|
169
|
+
morph?: number;
|
|
170
|
+
corner?: number;
|
|
171
|
+
} = {}) {
|
|
172
|
+
const skin = useRef<HTMLDivElement | null>(null);
|
|
173
|
+
const [open, setOpen] = useState(false);
|
|
174
|
+
const [going, setGoing] = useState(false);
|
|
175
|
+
const [liked, setLiked] = useState(false);
|
|
176
|
+
const [at, setAt] = useState(52);
|
|
177
|
+
const still = stillness();
|
|
178
|
+
|
|
179
|
+
const dur = BASE * rate(clamp(morph, 0, 100));
|
|
180
|
+
|
|
181
|
+
/* The sleeve's corner at each end, and the box's from it. The bar's sleeve takes the same fraction of its own side. */
|
|
182
|
+
const cr = clamp(corner, 0, CORNER_MAX);
|
|
183
|
+
const artR = [(cr * ART.s[0]) / ART.s[1], cr] as const;
|
|
184
|
+
|
|
185
|
+
/* The offset fades in, it is not a constant: at radius zero there is no curve to stay parallel to, so it scales with
|
|
186
|
+
the slider and reaches its full PAD by the default. Below that both corners converge until they are square
|
|
187
|
+
together. */
|
|
188
|
+
const off = PAD * Math.min(1, cr / CORNER);
|
|
189
|
+
const boxR = [artR[0] + off, artR[1] + off] as const;
|
|
190
|
+
|
|
191
|
+
/* 0 shut, 1 open. Tweened once, read everywhere. */
|
|
192
|
+
const p = useTween(open ? 1 : 0, dur, still);
|
|
193
|
+
|
|
194
|
+
/* The swell is a second number so `p` stays monotonic (the sleeve, the box and the margin between them are always
|
|
195
|
+
in the ratio that makes the corners concentric). What bounces is the whole object, uniformly, so nothing can get
|
|
196
|
+
out of step. It rides its own linear clock, with the peak pushed late (^1.5 puts it at 0.63) so the object gathers
|
|
197
|
+
on the way out and releases as it lands. Zero at both ends. On the close only: opening already has its event, the
|
|
198
|
+
box growing, and a scale on top of it would be a second size change in the same direction. */
|
|
199
|
+
const u = useTween(open ? 1 : 0, dur, still, FLAT);
|
|
200
|
+
const swell = Math.sin(Math.PI * clamp(u, 0, 1) ** 1.5);
|
|
201
|
+
const zoom = open ? 1 : 1 - 0.035 * swell;
|
|
202
|
+
|
|
203
|
+
/* Nothing moves until somebody presses play: at rest there is no timer at all. */
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!going) return;
|
|
206
|
+
const id = window.setInterval(() => setAt(s => (s >= TOTAL ? 0 : s + 1)), 1000);
|
|
207
|
+
return () => window.clearInterval(id);
|
|
208
|
+
}, [going]);
|
|
209
|
+
|
|
210
|
+
/* Outside closes it: only while open, and only for a press that started outside the box. pointerdown and not click,
|
|
211
|
+
since a press that begins outside and drifts in is still reaching past this component. */
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!open) return;
|
|
214
|
+
const away = (e: PointerEvent) => {
|
|
215
|
+
/* only a real hand dismisses this: a scripted pointer anywhere on the page would shut it a frame after it opened */
|
|
216
|
+
if (!e.isTrusted) return;
|
|
217
|
+
const el = skin.current;
|
|
218
|
+
if (el && !el.contains(e.target as Node)) setOpen(false);
|
|
219
|
+
};
|
|
220
|
+
/* the next tick, or the press that opened it closes it */
|
|
221
|
+
const id = window.setTimeout(() => document.addEventListener("pointerdown", away), 0);
|
|
222
|
+
return () => {
|
|
223
|
+
window.clearTimeout(id);
|
|
224
|
+
document.removeEventListener("pointerdown", away);
|
|
225
|
+
};
|
|
226
|
+
}, [open]);
|
|
227
|
+
|
|
228
|
+
/* at the margin in both states: the sleeve, the words and the transport are one row against the top margin and
|
|
229
|
+
everything below belongs to the track */
|
|
230
|
+
const art = { x: PAD, y: PAD, s: mix(ART.s[0], ART.s[1], p) };
|
|
231
|
+
|
|
232
|
+
/* everything below is derived, never stored */
|
|
233
|
+
const h = mix(SHUT, OPEN, p);
|
|
234
|
+
|
|
235
|
+
/* The transport exists in both states: shut it is smaller and almost touching, pinned to the right so the play
|
|
236
|
+
button lands where a thumb goes; open it spreads and the play grows. Placed by the row's centre, because the
|
|
237
|
+
row's own width changes. */
|
|
238
|
+
const side = mix(24, 34, p);
|
|
239
|
+
const lead = mix(30, LEAD, p);
|
|
240
|
+
const gap = mix(5, 14, p);
|
|
241
|
+
const opsX = mix(W - PAD - 44, W / 2, p);
|
|
242
|
+
/* level with the sleeve on the bar, and under the times on the card */
|
|
243
|
+
const opsY = mix(PAD + ART.s[0] / 2, OPS_Y, p);
|
|
244
|
+
|
|
245
|
+
/* the deep half of the morph, for what only exists in the card: held back to the last 40% so it arrives into a
|
|
246
|
+
shape that has stopped growing */
|
|
247
|
+
const late = clamp((p - 0.6) / 0.4, 0, 1);
|
|
248
|
+
|
|
249
|
+
const flip = () => {
|
|
250
|
+
setOpen(v => !v);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div className="bn-snd" style={{ width: W, height: OPEN }}>
|
|
255
|
+
<div
|
|
256
|
+
className="bn-snd-box"
|
|
257
|
+
ref={skin}
|
|
258
|
+
data-open={open || undefined}
|
|
259
|
+
style={{
|
|
260
|
+
width: W,
|
|
261
|
+
height: h,
|
|
262
|
+
borderRadius: mix(boxR[0], boxR[1], p),
|
|
263
|
+
scale: zoom,
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
{/* the hinge */}
|
|
267
|
+
<span
|
|
268
|
+
className="bn-snd-art"
|
|
269
|
+
aria-hidden="true"
|
|
270
|
+
style={{
|
|
271
|
+
backgroundImage: COVER,
|
|
272
|
+
left: art.x,
|
|
273
|
+
top: art.y,
|
|
274
|
+
width: art.s,
|
|
275
|
+
height: art.s,
|
|
276
|
+
borderRadius: mix(artR[0], artR[1], p),
|
|
277
|
+
}}
|
|
278
|
+
/>
|
|
279
|
+
|
|
280
|
+
<span
|
|
281
|
+
className="bn-snd-say"
|
|
282
|
+
style={{
|
|
283
|
+
/* beside the sleeve at both ends, level with it and centred by its height */
|
|
284
|
+
left: mix(PAD + ART.s[0] + 10, PAD + ART.s[1] + 10, p),
|
|
285
|
+
top: PAD,
|
|
286
|
+
height: art.s,
|
|
287
|
+
/* what is left between the sleeve and the row, less the heart on the card */
|
|
288
|
+
width: mix(94, W - PAD - (PAD + ART.s[1] + 10) - (LIKE + 10), p),
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
<span className="bn-snd-title" style={{ fontSize: mix(13, 15.5, p) }}>
|
|
292
|
+
Fim de Tarde
|
|
293
|
+
</span>
|
|
294
|
+
<span className="bn-snd-by" style={{ fontSize: mix(11, 12, p) }}>
|
|
295
|
+
Lado B
|
|
296
|
+
</span>
|
|
297
|
+
</span>
|
|
298
|
+
|
|
299
|
+
{/* The track is on the bar too, along the foot and inset by the same margin as everything else. Only the
|
|
300
|
+
times belong to the card: they fade with the deep half of the move and the box clips them until then. */}
|
|
301
|
+
<span
|
|
302
|
+
className="bn-snd-bar"
|
|
303
|
+
style={{
|
|
304
|
+
top: mix(SHUT - PAD - RAIL_H, RAIL_Y, p),
|
|
305
|
+
left: PAD,
|
|
306
|
+
width: W - PAD * 2,
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
309
|
+
<span className="bn-snd-rail">
|
|
310
|
+
<span className="bn-snd-run" style={{ width: `${(at / TOTAL) * 100}%` }} />
|
|
311
|
+
</span>
|
|
312
|
+
<span className="bn-snd-clock" style={{ opacity: late }}>
|
|
313
|
+
<span>{clock(at)}</span>
|
|
314
|
+
<span>−{clock(TOTAL - at)}</span>
|
|
315
|
+
</span>
|
|
316
|
+
</span>
|
|
317
|
+
|
|
318
|
+
{/* The one thing you press to change shape. Invisible; the whole bar when shut, the sleeve and the words
|
|
319
|
+
only when opened, so reaching for the title does not collapse it. Declared before the transport: both
|
|
320
|
+
are positioned, the later paints on top, and the buttons have to win a press that lands on both. */}
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
className="bn-snd-tap"
|
|
324
|
+
onClick={flip}
|
|
325
|
+
aria-expanded={open}
|
|
326
|
+
aria-label={open ? "Recolher o player" : "Abrir o player"}
|
|
327
|
+
style={{
|
|
328
|
+
left: mix(0, PAD, p),
|
|
329
|
+
top: mix(0, PAD, p),
|
|
330
|
+
width: mix(W, W - PAD * 2, p),
|
|
331
|
+
height: mix(SHUT, ART.s[1], p),
|
|
332
|
+
borderRadius: mix(boxR[0], artR[1], p),
|
|
333
|
+
}}
|
|
334
|
+
/>
|
|
335
|
+
|
|
336
|
+
{/* The heart belongs to the card alone: it rides `late` and takes its hit area with it. After the tap, so a
|
|
337
|
+
press in the corner reaches the heart rather than closing the player underneath it. */}
|
|
338
|
+
<button
|
|
339
|
+
type="button"
|
|
340
|
+
className="bn-snd-like"
|
|
341
|
+
data-on={liked || undefined}
|
|
342
|
+
onClick={() => {
|
|
343
|
+
setLiked(v => !v);
|
|
344
|
+
}}
|
|
345
|
+
aria-label={liked ? "Remover das curtidas" : "Curtir"}
|
|
346
|
+
aria-pressed={liked}
|
|
347
|
+
tabIndex={open ? 0 : -1}
|
|
348
|
+
style={{
|
|
349
|
+
left: W - PAD - LIKE,
|
|
350
|
+
top: PAD,
|
|
351
|
+
width: LIKE,
|
|
352
|
+
height: LIKE,
|
|
353
|
+
opacity: late,
|
|
354
|
+
pointerEvents: late > 0.9 ? "auto" : "none",
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<Heart size={16} weight={liked ? "fill" : "bold"} />
|
|
358
|
+
</button>
|
|
359
|
+
|
|
360
|
+
<span className="bn-snd-ops" style={{ left: opsX, top: opsY, gap }}>
|
|
361
|
+
{/* drawn, not filled, unlike the play: the difference in weight says which one is the button */}
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
className="bn-snd-op"
|
|
365
|
+
style={{ width: side, height: side }}
|
|
366
|
+
onClick={() => {
|
|
367
|
+
setAt(0);
|
|
368
|
+
}}
|
|
369
|
+
aria-label="Voltar ao início"
|
|
370
|
+
>
|
|
371
|
+
<SkipBack size={mix(14, 16, p)} weight="bold" />
|
|
372
|
+
</button>
|
|
373
|
+
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
className="bn-snd-op"
|
|
377
|
+
data-lead
|
|
378
|
+
style={{ width: lead, height: lead }}
|
|
379
|
+
onClick={() => {
|
|
380
|
+
setGoing(v => !v);
|
|
381
|
+
}}
|
|
382
|
+
aria-label={going ? "Pausar" : "Tocar"}
|
|
383
|
+
aria-pressed={going}
|
|
384
|
+
>
|
|
385
|
+
<Mark playing={going} size={mix(14, 18, p)} still={still} />
|
|
386
|
+
</button>
|
|
387
|
+
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
className="bn-snd-op"
|
|
391
|
+
style={{ width: side, height: side }}
|
|
392
|
+
onClick={() => {
|
|
393
|
+
setAt(0);
|
|
394
|
+
}}
|
|
395
|
+
aria-label="Próxima"
|
|
396
|
+
>
|
|
397
|
+
<SkipForward size={mix(14, 16, p)} weight="bold" />
|
|
398
|
+
</button>
|
|
399
|
+
</span>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|