@marktiderman/genesis-chat 0.1.1 → 0.2.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/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* A voice note in a bubble: play and pause, its waveform (drawn before the
|
|
4
|
+
* audio loads), drag across it to scrub, 1x / 1.5x / 2x, and the time. One
|
|
5
|
+
* voice note plays at a time: starting one pauses the one playing. Each one
|
|
6
|
+
* also answers `toggleVoiceNote` / `cycleVoiceNoteRate` by its id, which the
|
|
7
|
+
* bubble's VoiceOver actions call (an accessible bubble hides its children).
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
10
|
+
import { PanResponder, Pressable, View, type LayoutChangeEvent } from "react-native";
|
|
11
|
+
import { NativeIcon, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
12
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
13
|
+
import { formatDuration, nextPlaybackRate } from "../core/media";
|
|
14
|
+
import { fill } from "./copy";
|
|
15
|
+
import { useChatNative } from "./context";
|
|
16
|
+
import type { ExpoAudioLike } from "./modules";
|
|
17
|
+
|
|
18
|
+
// ---- One at a time --------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
let active: { id: string; pause: () => void } | null = null;
|
|
21
|
+
const playbackListeners = new Set<() => void>();
|
|
22
|
+
const notifyPlayback = () => playbackListeners.forEach((l) => l());
|
|
23
|
+
|
|
24
|
+
/** Called when a voice note starts: pauses whichever other one was playing. */
|
|
25
|
+
export function claimPlayback(id: string, pause: () => void): void {
|
|
26
|
+
if (active && active.id !== id) {
|
|
27
|
+
try {
|
|
28
|
+
active.pause();
|
|
29
|
+
} catch {
|
|
30
|
+
// A player already gone has nothing to pause.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
active = { id, pause };
|
|
34
|
+
notifyPlayback();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Called when a voice note stops or unmounts. */
|
|
38
|
+
export function releasePlayback(id: string): void {
|
|
39
|
+
if (active?.id === id) {
|
|
40
|
+
active = null;
|
|
41
|
+
notifyPlayback();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const subscribePlayback = (l: () => void) => {
|
|
46
|
+
playbackListeners.add(l);
|
|
47
|
+
return () => void playbackListeners.delete(l);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Which of these voice notes is playing, or null (re-renders only when that answer changes). */
|
|
51
|
+
export function usePlayingVoiceNote(ids: readonly string[]): string | null {
|
|
52
|
+
const key = ids.join("|");
|
|
53
|
+
return useSyncExternalStore(
|
|
54
|
+
subscribePlayback,
|
|
55
|
+
() => (active && key.split("|").includes(active.id) ? active.id : null),
|
|
56
|
+
() => null,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- Controls by id, for the bubble's accessibility actions -----------------
|
|
61
|
+
|
|
62
|
+
interface VoiceNoteControls {
|
|
63
|
+
toggle: () => void;
|
|
64
|
+
cycleRate: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Every mounted player per id (one attachment can show in the timeline and in its thread): the newest answers.
|
|
68
|
+
const controls = new Map<string, VoiceNoteControls[]>();
|
|
69
|
+
const controlsFor = (id: string) => controls.get(id)?.at(-1);
|
|
70
|
+
|
|
71
|
+
/** Plays or pauses the voice note with this id; false when none with it is on screen with playback. */
|
|
72
|
+
export function toggleVoiceNote(id: string): boolean {
|
|
73
|
+
const c = controlsFor(id);
|
|
74
|
+
c?.toggle();
|
|
75
|
+
return Boolean(c);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Moves the voice note with this id to its next speed; false when none is on screen with playback. */
|
|
79
|
+
export function cycleVoiceNoteRate(id: string): boolean {
|
|
80
|
+
const c = controlsFor(id);
|
|
81
|
+
c?.cycleRate();
|
|
82
|
+
return Boolean(c);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The playing voice note's id, for tests. */
|
|
86
|
+
export function activePlaybackId(): string | null {
|
|
87
|
+
return active?.id ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Where a finger at `x` across a waveform `width` wide puts the playhead, in seconds. */
|
|
91
|
+
export function scrubSeconds(x: number, width: number, durationSec: number): number {
|
|
92
|
+
if (width <= 0 || durationSec <= 0) return 0;
|
|
93
|
+
return Math.max(0, Math.min(1, x / width)) * durationSec;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const BAR_WIDTH = 3;
|
|
97
|
+
const BAR_GAP = 2;
|
|
98
|
+
|
|
99
|
+
/** How many bars fit across `width` points, one bar plus a gap each; never fewer than one. */
|
|
100
|
+
export function barsThatFit(width: number): number {
|
|
101
|
+
if (!(width > 0)) return 1;
|
|
102
|
+
return Math.max(1, Math.floor((width + BAR_GAP) / (BAR_WIDTH + BAR_GAP)));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Bars 0–1 from stored peaks (0–100), resampled to `count`; flat when there are none. */
|
|
106
|
+
export function waveformBars(peaks: readonly number[] | null | undefined, count = 32): number[] {
|
|
107
|
+
if (!peaks || peaks.length === 0) return new Array(count).fill(0.08);
|
|
108
|
+
return Array.from({ length: count }, (_, i) => {
|
|
109
|
+
const v = peaks[Math.min(peaks.length - 1, Math.floor((i * peaks.length) / count))] ?? 0;
|
|
110
|
+
return Math.max(0.08, Math.min(1, v / 100));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface VoiceNotePlayerProps {
|
|
115
|
+
/** Stable id (the attachment's), for one-at-a-time. */
|
|
116
|
+
id: string;
|
|
117
|
+
/** A signed URL or a local file URI; null while it is fetched. */
|
|
118
|
+
uri: string | null;
|
|
119
|
+
durationMs: number | null;
|
|
120
|
+
waveform: number[] | null;
|
|
121
|
+
/** On the tint bubble: draw in its foreground. */
|
|
122
|
+
mine?: boolean;
|
|
123
|
+
testID?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function VoiceNotePlayer(props: VoiceNotePlayerProps) {
|
|
127
|
+
const { modules } = useChatNative();
|
|
128
|
+
if (!modules.audio) return <StaticVoiceNote {...props} />;
|
|
129
|
+
return <AudioVoiceNote {...props} audio={modules.audio} />;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Without expo-audio: the waveform and duration, no playback. */
|
|
133
|
+
function StaticVoiceNote({ durationMs, waveform, mine, testID }: VoiceNotePlayerProps) {
|
|
134
|
+
const { copy } = useChatNative();
|
|
135
|
+
const colors = useThemeRoles();
|
|
136
|
+
const duration = formatDuration(durationMs);
|
|
137
|
+
return (
|
|
138
|
+
<View testID={testID} accessible accessibilityLabel={fill(copy.voiceMessage, { duration })} style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 44 }}>
|
|
139
|
+
<Bars bars={waveformBars(waveform, 24)} progress={0} mine={mine} colors={colors} />
|
|
140
|
+
<NativeText className={mine ? "text-primary-foreground" : undefined} style={{ fontSize: 13, fontVariant: ["tabular-nums"] }}>
|
|
141
|
+
{duration}
|
|
142
|
+
</NativeText>
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function AudioVoiceNote({ id, uri, durationMs, waveform, mine, testID, audio }: VoiceNotePlayerProps & { audio: ExpoAudioLike }) {
|
|
148
|
+
const { copy } = useChatNative();
|
|
149
|
+
const colors = useThemeRoles();
|
|
150
|
+
const source = useMemo(() => (uri ? { uri } : null), [uri]);
|
|
151
|
+
const player = audio.useAudioPlayer(source, { updateInterval: 100 });
|
|
152
|
+
const status = audio.useAudioPlayerStatus(player);
|
|
153
|
+
const [scrubbing, setScrubbing] = useState<number | null>(null);
|
|
154
|
+
const width = useRef(0);
|
|
155
|
+
// As many bars as the waveform's slot holds, so they never run under the time and speed.
|
|
156
|
+
const [barCount, setBarCount] = useState(0);
|
|
157
|
+
|
|
158
|
+
// Unmounted (scrolled away, screen closed): it no longer holds the one playback slot.
|
|
159
|
+
useEffect(() => () => releasePlayback(id), [id]);
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (status.didJustFinish) releasePlayback(id);
|
|
162
|
+
}, [status.didJustFinish, id]);
|
|
163
|
+
|
|
164
|
+
const durationSec = status.duration > 0 ? status.duration : (durationMs ?? 0) / 1000;
|
|
165
|
+
const position = scrubbing ?? status.currentTime;
|
|
166
|
+
const progress = durationSec > 0 ? Math.min(1, position / durationSec) : 0;
|
|
167
|
+
const rate = status.playbackRate || 1;
|
|
168
|
+
|
|
169
|
+
const pause = () => {
|
|
170
|
+
player.pause();
|
|
171
|
+
releasePlayback(id);
|
|
172
|
+
};
|
|
173
|
+
const toggle = () => {
|
|
174
|
+
if (status.playing) return pause();
|
|
175
|
+
claimPlayback(id, () => player.pause());
|
|
176
|
+
if (status.didJustFinish || (durationSec > 0 && status.currentTime >= durationSec - 0.05)) void player.seekTo(0);
|
|
177
|
+
player.play();
|
|
178
|
+
};
|
|
179
|
+
const seek = (seconds: number) => void player.seekTo(Math.max(0, Math.min(durationSec, seconds)));
|
|
180
|
+
const cycleRate = () => player.setPlaybackRate(nextPlaybackRate(rate));
|
|
181
|
+
|
|
182
|
+
// The bubble's VoiceOver actions reach this player by its id.
|
|
183
|
+
const handles = useRef({ toggle, cycleRate });
|
|
184
|
+
handles.current = { toggle, cycleRate };
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const entry: VoiceNoteControls = { toggle: () => handles.current.toggle(), cycleRate: () => handles.current.cycleRate() };
|
|
187
|
+
controls.set(id, [...(controls.get(id) ?? []), entry]);
|
|
188
|
+
return () => {
|
|
189
|
+
const rest = (controls.get(id) ?? []).filter((e) => e !== entry);
|
|
190
|
+
if (rest.length) controls.set(id, rest);
|
|
191
|
+
else controls.delete(id);
|
|
192
|
+
};
|
|
193
|
+
}, [id]);
|
|
194
|
+
|
|
195
|
+
const latest = useRef({ durationSec, seek });
|
|
196
|
+
latest.current = { durationSec, seek };
|
|
197
|
+
const responder = useMemo(
|
|
198
|
+
() =>
|
|
199
|
+
PanResponder.create({
|
|
200
|
+
// Only a sideways drag scrubs, so a scroll that starts on a voice note still scrolls.
|
|
201
|
+
onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) > 6 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5,
|
|
202
|
+
onPanResponderTerminationRequest: () => false,
|
|
203
|
+
onPanResponderGrant: (e) => setScrubbing(scrubSeconds(e.nativeEvent.locationX, width.current, latest.current.durationSec)),
|
|
204
|
+
onPanResponderMove: (e) => setScrubbing(scrubSeconds(e.nativeEvent.locationX, width.current, latest.current.durationSec)),
|
|
205
|
+
onPanResponderRelease: (e) => {
|
|
206
|
+
latest.current.seek(scrubSeconds(e.nativeEvent.locationX, width.current, latest.current.durationSec));
|
|
207
|
+
setScrubbing(null);
|
|
208
|
+
},
|
|
209
|
+
onPanResponderTerminate: () => setScrubbing(null),
|
|
210
|
+
}),
|
|
211
|
+
[],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const fg = mine ? "text-primary-foreground" : undefined;
|
|
215
|
+
const shown = formatDuration((status.playing || position > 0 ? position : durationSec) * 1000);
|
|
216
|
+
return (
|
|
217
|
+
<View testID={testID} style={{ flexDirection: "row", alignItems: "center", gap: 6, minWidth: 240 }}>
|
|
218
|
+
<Pressable
|
|
219
|
+
testID={testID ? `${testID}-play` : undefined}
|
|
220
|
+
accessibilityRole="button"
|
|
221
|
+
accessibilityLabel={status.playing ? copy.pause : copy.play}
|
|
222
|
+
onPress={toggle}
|
|
223
|
+
disabled={!uri}
|
|
224
|
+
hitSlop={4}
|
|
225
|
+
style={{ width: 44, height: 44, alignItems: "center", justifyContent: "center", opacity: uri ? 1 : 0.5 }}
|
|
226
|
+
>
|
|
227
|
+
<NativeIcon name={status.playing ? "pause" : "play"} size={22} color={mine ? colors.surface : colors.tint} />
|
|
228
|
+
</Pressable>
|
|
229
|
+
<View
|
|
230
|
+
testID={testID ? `${testID}-wave` : undefined}
|
|
231
|
+
accessible
|
|
232
|
+
accessibilityRole="adjustable"
|
|
233
|
+
accessibilityLabel={fill(copy.voiceMessage, { duration: formatDuration(durationSec * 1000) })}
|
|
234
|
+
accessibilityValue={{ text: `${formatDuration(position * 1000)} / ${formatDuration(durationSec * 1000)}` }}
|
|
235
|
+
accessibilityActions={[{ name: "increment" }, { name: "decrement" }]}
|
|
236
|
+
onAccessibilityAction={(e) => seek(status.currentTime + (e.nativeEvent.actionName === "increment" ? 5 : -5))}
|
|
237
|
+
onLayout={(e: LayoutChangeEvent) => {
|
|
238
|
+
width.current = e.nativeEvent.layout.width;
|
|
239
|
+
setBarCount(barsThatFit(e.nativeEvent.layout.width));
|
|
240
|
+
}}
|
|
241
|
+
style={{ flex: 1, minWidth: 0, height: 44, justifyContent: "center", overflow: "hidden" }}
|
|
242
|
+
{...responder.panHandlers}
|
|
243
|
+
>
|
|
244
|
+
{barCount > 0 ? <Bars bars={waveformBars(waveform, barCount)} progress={progress} mine={mine} colors={colors} /> : null}
|
|
245
|
+
</View>
|
|
246
|
+
<NativeText className={fg} style={{ fontSize: 13, minWidth: 36, fontVariant: ["tabular-nums"] }}>
|
|
247
|
+
{shown}
|
|
248
|
+
</NativeText>
|
|
249
|
+
<Pressable
|
|
250
|
+
testID={testID ? `${testID}-rate` : undefined}
|
|
251
|
+
accessibilityRole="button"
|
|
252
|
+
accessibilityLabel={fill(copy.speed, { rate: `${rate}x` })}
|
|
253
|
+
onPress={cycleRate}
|
|
254
|
+
hitSlop={8}
|
|
255
|
+
style={{ minWidth: 44, height: 44, alignItems: "center", justifyContent: "center" }}
|
|
256
|
+
>
|
|
257
|
+
<NativeText className={fg} style={{ fontSize: 13, fontWeight: "600" }}>{`${rate}×`}</NativeText>
|
|
258
|
+
</Pressable>
|
|
259
|
+
</View>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function Bars({ bars, progress, mine, colors }: { bars: number[]; progress: number; mine?: boolean; colors: ReturnType<typeof useThemeRoles> }) {
|
|
264
|
+
// On the tint bubble the bars are the surface color, the unplayed part faded.
|
|
265
|
+
const played = mine ? colors.surface : colors.tint;
|
|
266
|
+
const rest = mine ? colors.surface : colors.tertiaryLabel;
|
|
267
|
+
return (
|
|
268
|
+
<View pointerEvents="none" style={{ flexDirection: "row", alignItems: "center", gap: BAR_GAP, height: 28, overflow: "hidden" }}>
|
|
269
|
+
{bars.map((h, i) => (
|
|
270
|
+
<View
|
|
271
|
+
key={i}
|
|
272
|
+
style={{
|
|
273
|
+
width: BAR_WIDTH,
|
|
274
|
+
borderRadius: BAR_WIDTH / 2,
|
|
275
|
+
height: Math.max(3, Math.round(h * 28)),
|
|
276
|
+
backgroundColor: (i + 0.5) / bars.length <= progress ? played : rest,
|
|
277
|
+
opacity: mine && (i + 0.5) / bars.length > progress ? 0.45 : 1,
|
|
278
|
+
}}
|
|
279
|
+
/>
|
|
280
|
+
))}
|
|
281
|
+
</View>
|
|
282
|
+
);
|
|
283
|
+
}
|