@marktiderman/genesis-chat 0.1.0 → 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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* The recorder machine on expo-audio: asks for the microphone, records with
|
|
4
|
+
* metering on, reads the level every 100 ms for the live waveform (and to tell
|
|
5
|
+
* the others you are recording), and hands back a voice note as a LocalFile
|
|
6
|
+
* whose meta carries its duration and waveform, ready for `send`.
|
|
7
|
+
*
|
|
8
|
+
* Interruptions end the attempt cleanly, with a notice: the app going to the
|
|
9
|
+
* background, or the recorder stopping on its own (a phone call, Siri, another
|
|
10
|
+
* app taking the microphone) or its status no longer reading. A stop that fails
|
|
11
|
+
* or leaves no file says so. Leaving the screen while a stop is under way lets
|
|
12
|
+
* that stop finish and send what was recorded.
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { AppState } from "react-native";
|
|
16
|
+
import { waveformFromMetering } from "../core/media";
|
|
17
|
+
import type { LocalFile } from "../core/outbox";
|
|
18
|
+
import { haptic, type ExpoAudioLike, type HapticsLike } from "./modules";
|
|
19
|
+
import { idleRecorder, recorderStep, type RecorderConfig, type RecorderEffect, type RecorderEvent, type RecorderState } from "./recorder-machine";
|
|
20
|
+
|
|
21
|
+
export interface UseVoiceRecorderOptions extends Omit<RecorderConfig, "limitMs"> {
|
|
22
|
+
limitMs: number;
|
|
23
|
+
onRecorded: (file: LocalFile) => void;
|
|
24
|
+
/** `useConversation().signalRecording` (throttled there). */
|
|
25
|
+
signalRecording?: () => void;
|
|
26
|
+
haptics?: HapticsLike;
|
|
27
|
+
/** How often the level is read. Default 100 ms. */
|
|
28
|
+
intervalMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Reads in a row that show the recorder stopped (or can't be read) before it counts as interrupted. */
|
|
32
|
+
export const RECORDER_STALL_READS = 3;
|
|
33
|
+
/** Reads after `record()` that may pass before the recorder says it is recording; then the attempt failed. */
|
|
34
|
+
export const RECORDER_START_READS = 10;
|
|
35
|
+
|
|
36
|
+
/** The voice note a finished recording becomes. */
|
|
37
|
+
export function voiceNoteFile(uri: string, durationMs: number, levelsDb: readonly number[]): LocalFile {
|
|
38
|
+
return {
|
|
39
|
+
data: uri,
|
|
40
|
+
name: "voice.m4a",
|
|
41
|
+
contentType: "audio/mp4",
|
|
42
|
+
meta: { kind: "voice", durationMs: Math.round(durationMs), waveform: waveformFromMetering([...levelsDb]) },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useVoiceRecorder(audio: ExpoAudioLike, options: UseVoiceRecorderOptions) {
|
|
47
|
+
const recorder = audio.useAudioRecorder(useMemo(() => ({ ...audio.RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true }), [audio]));
|
|
48
|
+
const [state, setState] = useState<RecorderState>(idleRecorder);
|
|
49
|
+
const stateRef = useRef(state);
|
|
50
|
+
const latest = useRef(options);
|
|
51
|
+
latest.current = options;
|
|
52
|
+
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
53
|
+
const recording = useRef(false);
|
|
54
|
+
// A stop (finish) under way: unmounting leaves it to finish rather than stopping twice.
|
|
55
|
+
const stopping = useRef(false);
|
|
56
|
+
// Each press is an attempt; an async step that finishes after its attempt ended is dropped.
|
|
57
|
+
const attempt = useRef(0);
|
|
58
|
+
const mounted = useRef(true);
|
|
59
|
+
|
|
60
|
+
const stopTimer = () => {
|
|
61
|
+
if (timer.current) clearInterval(timer.current);
|
|
62
|
+
timer.current = null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Never throws: letting the microphone go is best effort.
|
|
66
|
+
const releaseAudio = () => {
|
|
67
|
+
try {
|
|
68
|
+
void Promise.resolve(audio.setAudioModeAsync({ allowsRecording: false })).catch(() => undefined);
|
|
69
|
+
} catch {
|
|
70
|
+
// The audio module is already gone.
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const dispatchRef = useRef<(event: RecorderEvent) => void>(() => undefined);
|
|
75
|
+
|
|
76
|
+
const run = useCallback(
|
|
77
|
+
(effect: RecorderEffect, after: RecorderState) => {
|
|
78
|
+
const dispatch = dispatchRef.current;
|
|
79
|
+
switch (effect.type) {
|
|
80
|
+
case "prepare": {
|
|
81
|
+
const mine = ++attempt.current;
|
|
82
|
+
const current = () => mounted.current && attempt.current === mine && stateRef.current.phase === "starting";
|
|
83
|
+
void (async () => {
|
|
84
|
+
try {
|
|
85
|
+
const permission = await audio.requestRecordingPermissionsAsync();
|
|
86
|
+
if (!current()) return;
|
|
87
|
+
if (!permission.granted) return dispatch({ type: "failed", reason: "denied" });
|
|
88
|
+
await audio.setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
|
|
89
|
+
if (!current()) return releaseAudio();
|
|
90
|
+
await recorder.prepareToRecordAsync();
|
|
91
|
+
if (!current()) return releaseAudio();
|
|
92
|
+
dispatch({ type: "ready" });
|
|
93
|
+
} catch {
|
|
94
|
+
if (current()) dispatch({ type: "failed", reason: "error" });
|
|
95
|
+
else releaseAudio();
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
case "record": {
|
|
101
|
+
try {
|
|
102
|
+
recorder.record();
|
|
103
|
+
} catch {
|
|
104
|
+
dispatch({ type: "failed", reason: "error" });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
recording.current = true;
|
|
108
|
+
stopTimer();
|
|
109
|
+
// Reads in a row where the recorder had stopped by itself or couldn't be read: a few
|
|
110
|
+
// mean something else has the microphone, and the attempt ends instead of freezing.
|
|
111
|
+
let stalled = 0;
|
|
112
|
+
let sawRecording = false;
|
|
113
|
+
let reads = 0;
|
|
114
|
+
timer.current = setInterval(() => {
|
|
115
|
+
reads++;
|
|
116
|
+
let s: ReturnType<typeof recorder.getStatus> | null = null;
|
|
117
|
+
try {
|
|
118
|
+
s = recorder.getStatus();
|
|
119
|
+
} catch {
|
|
120
|
+
s = null;
|
|
121
|
+
}
|
|
122
|
+
if (s && s.isRecording) sawRecording = true;
|
|
123
|
+
// Before the first read that says recording, a stopped recorder is still starting.
|
|
124
|
+
const bad = !s || (sawRecording && !s.isRecording);
|
|
125
|
+
stalled = bad ? stalled + 1 : 0;
|
|
126
|
+
if (stalled >= RECORDER_STALL_READS) return dispatch({ type: "interrupted" });
|
|
127
|
+
// record() that never started (the microphone was taken before it began): not a frozen 0:00.
|
|
128
|
+
if (!sawRecording && reads >= RECORDER_START_READS) return dispatch({ type: "failed", reason: "error" });
|
|
129
|
+
if (s && !bad) dispatch({ type: "tick", durationMs: s.durationMillis, meteringDb: s.metering });
|
|
130
|
+
}, latest.current.intervalMs ?? 100);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
case "finish":
|
|
134
|
+
stopTimer();
|
|
135
|
+
stopping.current = true;
|
|
136
|
+
void (async () => {
|
|
137
|
+
let ok = false;
|
|
138
|
+
const read = <T,>(get: () => T): T | null => {
|
|
139
|
+
try {
|
|
140
|
+
return get();
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
const status = recorder.getStatus();
|
|
147
|
+
// Known before the stop (from prepare): the native recorder may be released while it stops.
|
|
148
|
+
const before = read(() => recorder.uri) ?? status.url;
|
|
149
|
+
await recorder.stop();
|
|
150
|
+
const uri = read(() => recorder.uri) ?? before;
|
|
151
|
+
const durationMs = Math.max(after.durationMs, status.durationMillis || 0);
|
|
152
|
+
if (uri) {
|
|
153
|
+
latest.current.onRecorded(voiceNoteFile(uri, durationMs, after.levels));
|
|
154
|
+
ok = true;
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Nothing usable was recorded: the notice says so.
|
|
158
|
+
} finally {
|
|
159
|
+
recording.current = false;
|
|
160
|
+
stopping.current = false;
|
|
161
|
+
releaseAudio();
|
|
162
|
+
dispatch({ type: "finished", ok });
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
return;
|
|
166
|
+
case "discard":
|
|
167
|
+
attempt.current++;
|
|
168
|
+
stopTimer();
|
|
169
|
+
if (recording.current && !stopping.current) {
|
|
170
|
+
recording.current = false;
|
|
171
|
+
void recorder.stop().catch(() => undefined);
|
|
172
|
+
}
|
|
173
|
+
releaseAudio();
|
|
174
|
+
return;
|
|
175
|
+
case "haptic":
|
|
176
|
+
haptic(latest.current.haptics, effect.event);
|
|
177
|
+
return;
|
|
178
|
+
case "signal":
|
|
179
|
+
latest.current.signalRecording?.();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
|
+
[audio, recorder],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const dispatch = useCallback(
|
|
188
|
+
(event: RecorderEvent) => {
|
|
189
|
+
if (!mounted.current) return;
|
|
190
|
+
const { limitMs, minMs, cancelDistance, lockDistance, maxLevels } = latest.current;
|
|
191
|
+
const step = recorderStep(stateRef.current, event, { limitMs, minMs, cancelDistance, lockDistance, maxLevels });
|
|
192
|
+
if (step.state !== stateRef.current) {
|
|
193
|
+
stateRef.current = step.state;
|
|
194
|
+
setState(step.state);
|
|
195
|
+
}
|
|
196
|
+
for (const effect of step.effects) run(effect, step.state);
|
|
197
|
+
},
|
|
198
|
+
[run],
|
|
199
|
+
);
|
|
200
|
+
dispatchRef.current = dispatch;
|
|
201
|
+
|
|
202
|
+
// Leaving the screen mid-recording throws the recording away; a stop under way finishes and sends.
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
mounted.current = true;
|
|
205
|
+
return () => {
|
|
206
|
+
mounted.current = false;
|
|
207
|
+
attempt.current++;
|
|
208
|
+
stateRef.current = idleRecorder;
|
|
209
|
+
stopTimer();
|
|
210
|
+
if (stopping.current) return;
|
|
211
|
+
if (recording.current) {
|
|
212
|
+
recording.current = false;
|
|
213
|
+
try {
|
|
214
|
+
void recorder.stop().catch(() => undefined);
|
|
215
|
+
} catch {
|
|
216
|
+
// The recorder is already gone.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
releaseAudio();
|
|
220
|
+
};
|
|
221
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
222
|
+
}, [recorder]);
|
|
223
|
+
|
|
224
|
+
// The app going to the background ends a recording (iOS takes the microphone from a backgrounded
|
|
225
|
+
// app). Only once recording: Android reports the microphone permission dialog as the background.
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const sub = AppState?.addEventListener?.("change", (next) => {
|
|
228
|
+
if (next === "background" && stateRef.current.phase === "recording") dispatchRef.current({ type: "interrupted" });
|
|
229
|
+
});
|
|
230
|
+
return () => sub?.remove?.();
|
|
231
|
+
}, []);
|
|
232
|
+
|
|
233
|
+
return { state, dispatch };
|
|
234
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* A video in a bubble, on expo-video (`modules.video`): its poster (Mux's thumbnail, the
|
|
4
|
+
* attachment's poster, or a plain frame) with the duration and a play button; tap to play,
|
|
5
|
+
* then pause, mute, 1x / 1.5x / 2x and full screen. A Mux video streams as HLS from its
|
|
6
|
+
* playback id; a stored one plays from its signed link (or, while it uploads, from the
|
|
7
|
+
* phone). Until Mux has processed it, it says so. One plays at a time, voice notes included:
|
|
8
|
+
* it takes the same playback slot (`claimPlayback`), so starting it pauses a voice note and
|
|
9
|
+
* the other way round. The bubble's VoiceOver actions reach it by id (`toggleVideo`).
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
12
|
+
import { ActivityIndicator, Pressable, View, useWindowDimensions } from "react-native";
|
|
13
|
+
import { NativeIcon, NativeImage, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
14
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
15
|
+
import { formatDuration, nextPlaybackRate } from "../core/media";
|
|
16
|
+
import { fill } from "./copy";
|
|
17
|
+
import { useChatNative } from "./context";
|
|
18
|
+
import { MEDIA_FRAME, MEDIA_SCRIM, MEDIA_SCRIM_LIGHT, ON_MEDIA } from "./media-colors";
|
|
19
|
+
import type { ExpoVideoLike, VideoPlayerLike, VideoViewHandleLike } from "./modules";
|
|
20
|
+
import { claimPlayback, releasePlayback } from "./voice-player";
|
|
21
|
+
|
|
22
|
+
export const muxStreamUrl = (playbackId: string) => `https://stream.mux.com/${encodeURIComponent(playbackId)}.m3u8`;
|
|
23
|
+
export const muxPosterUrl = (playbackId: string, width = 480) => `https://image.mux.com/${encodeURIComponent(playbackId)}/thumbnail.jpg?width=${width}`;
|
|
24
|
+
|
|
25
|
+
export interface ChatVideoPlayerProps {
|
|
26
|
+
/** Stable id (the attachment's), for one-at-a-time and the bubble's actions. */
|
|
27
|
+
id: string;
|
|
28
|
+
/** A stored video's signed link, or the file on the phone while it uploads. */
|
|
29
|
+
uri: string | null;
|
|
30
|
+
/** Mux's playback id: streamed as HLS. */
|
|
31
|
+
playbackId?: string | null;
|
|
32
|
+
/** The attachment's poster (a signed link). */
|
|
33
|
+
posterUri?: string | null;
|
|
34
|
+
provider?: string | null;
|
|
35
|
+
status?: "pending" | "ready" | "failed" | null;
|
|
36
|
+
durationMs: number | null;
|
|
37
|
+
width?: number | null;
|
|
38
|
+
height?: number | null;
|
|
39
|
+
/** Still uploading from this phone. */
|
|
40
|
+
uploading?: boolean;
|
|
41
|
+
testID?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ChatVideoState = "failed" | "processing" | "loading" | "playable";
|
|
45
|
+
|
|
46
|
+
/** What the player can show: a hosted video waits for its provider, a stored one for its link. */
|
|
47
|
+
export function videoState(p: Pick<ChatVideoPlayerProps, "uri" | "playbackId" | "provider" | "status">): ChatVideoState {
|
|
48
|
+
if (p.status === "failed") return "failed";
|
|
49
|
+
if (p.playbackId) return "playable";
|
|
50
|
+
if (p.provider && p.provider !== "storage" && p.provider !== "external") return p.uri ? "playable" : "processing";
|
|
51
|
+
return p.uri ? "playable" : "loading";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What expo-video loads: HLS for a playback id, else the file. */
|
|
55
|
+
export function videoSource(p: Pick<ChatVideoPlayerProps, "uri" | "playbackId">): { uri: string; contentType: "hls" | "auto" } | null {
|
|
56
|
+
if (p.playbackId) return { uri: muxStreamUrl(p.playbackId), contentType: "hls" };
|
|
57
|
+
return p.uri ? { uri: p.uri, contentType: "auto" } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- Controls by id, for the bubble's accessibility actions -----------------
|
|
61
|
+
|
|
62
|
+
interface VideoControls {
|
|
63
|
+
toggle: () => void;
|
|
64
|
+
fullScreen: () => void;
|
|
65
|
+
mute: () => void;
|
|
66
|
+
rate: () => void;
|
|
67
|
+
}
|
|
68
|
+
const controls = new Map<string, VideoControls[]>();
|
|
69
|
+
const playingIds = new Set<string>();
|
|
70
|
+
const playingListeners = new Set<() => void>();
|
|
71
|
+
const setPlayingId = (id: string, on: boolean) => {
|
|
72
|
+
if (playingIds.has(id) === on) return;
|
|
73
|
+
if (on) playingIds.add(id);
|
|
74
|
+
else playingIds.delete(id);
|
|
75
|
+
playingListeners.forEach((l) => l());
|
|
76
|
+
};
|
|
77
|
+
const subscribePlaying = (l: () => void) => {
|
|
78
|
+
playingListeners.add(l);
|
|
79
|
+
return () => void playingListeners.delete(l);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Which of these videos is playing, or null (re-renders only when that answer changes). */
|
|
83
|
+
export function usePlayingVideo(ids: readonly string[]): string | null {
|
|
84
|
+
const key = ids.join("|");
|
|
85
|
+
return useSyncExternalStore(
|
|
86
|
+
subscribePlaying,
|
|
87
|
+
() => key.split("|").find((id) => id && playingIds.has(id)) ?? null,
|
|
88
|
+
() => null,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Mutes or unmutes the video with this id while it plays. */
|
|
93
|
+
export function toggleVideoMute(id: string): boolean {
|
|
94
|
+
const c = controls.get(id)?.at(-1);
|
|
95
|
+
c?.mute();
|
|
96
|
+
return Boolean(c);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Moves the playing video with this id to its next speed. */
|
|
100
|
+
export function cycleVideoRate(id: string): boolean {
|
|
101
|
+
const c = controls.get(id)?.at(-1);
|
|
102
|
+
c?.rate();
|
|
103
|
+
return Boolean(c);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Plays or pauses the video with this id; false when none with it is on screen. */
|
|
107
|
+
export function toggleVideo(id: string): boolean {
|
|
108
|
+
const c = controls.get(id)?.at(-1);
|
|
109
|
+
c?.toggle();
|
|
110
|
+
return Boolean(c);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Opens the video with this id full screen (starting it); false when none is on screen. */
|
|
114
|
+
export function fullScreenVideo(id: string): boolean {
|
|
115
|
+
const c = controls.get(id)?.at(-1);
|
|
116
|
+
c?.fullScreen();
|
|
117
|
+
return Boolean(c);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether the video with this id is playing. */
|
|
121
|
+
export function isVideoPlaying(id: string): boolean {
|
|
122
|
+
return playingIds.has(id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function useRegister(id: string, handles: React.MutableRefObject<VideoControls>) {
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const entry: VideoControls = {
|
|
128
|
+
toggle: () => handles.current.toggle(),
|
|
129
|
+
fullScreen: () => handles.current.fullScreen(),
|
|
130
|
+
mute: () => handles.current.mute(),
|
|
131
|
+
rate: () => handles.current.rate(),
|
|
132
|
+
};
|
|
133
|
+
controls.set(id, [...(controls.get(id) ?? []), entry]);
|
|
134
|
+
return () => {
|
|
135
|
+
const rest = (controls.get(id) ?? []).filter((e) => e !== entry);
|
|
136
|
+
if (rest.length) controls.set(id, rest);
|
|
137
|
+
else controls.delete(id);
|
|
138
|
+
};
|
|
139
|
+
}, [id, handles]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The bubble's video. Renders nothing without `modules.video` (the bubble shows a row instead). */
|
|
143
|
+
export function ChatVideoPlayer(props: ChatVideoPlayerProps) {
|
|
144
|
+
const { modules, copy } = useChatNative();
|
|
145
|
+
const colors = useThemeRoles();
|
|
146
|
+
const { width: screen } = useWindowDimensions();
|
|
147
|
+
const [active, setActive] = useState(false);
|
|
148
|
+
const [fullOnStart, setFullOnStart] = useState(false);
|
|
149
|
+
const testID = props.testID ?? `chat-video-${props.id}`;
|
|
150
|
+
const state = videoState(props);
|
|
151
|
+
const source = useMemo(() => videoSource(props), [props.uri, props.playbackId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
152
|
+
|
|
153
|
+
const [broken, setBroken] = useState(false);
|
|
154
|
+
const handles = useRef<VideoControls>({ toggle: () => undefined, fullScreen: () => undefined, mute: () => undefined, rate: () => undefined });
|
|
155
|
+
handles.current = {
|
|
156
|
+
toggle: () => state === "playable" && setActive(true),
|
|
157
|
+
fullScreen: () => {
|
|
158
|
+
if (state !== "playable") return;
|
|
159
|
+
setFullOnStart(true);
|
|
160
|
+
setActive(true);
|
|
161
|
+
},
|
|
162
|
+
mute: () => undefined,
|
|
163
|
+
rate: () => undefined,
|
|
164
|
+
};
|
|
165
|
+
useRegister(props.id, handles);
|
|
166
|
+
|
|
167
|
+
if (!modules.video) return null;
|
|
168
|
+
const w = Math.min(240, Math.round(screen * 0.62));
|
|
169
|
+
const ratio = props.width && props.height ? props.width / props.height : 3 / 4;
|
|
170
|
+
const h = Math.round(Math.max(140, Math.min(w * 1.5, w / ratio)));
|
|
171
|
+
const duration = props.durationMs ? formatDuration(props.durationMs) : null;
|
|
172
|
+
const box = { width: w, height: h, borderRadius: 14, overflow: "hidden" as const, backgroundColor: MEDIA_FRAME };
|
|
173
|
+
|
|
174
|
+
if (state === "failed" || state === "processing" || broken) {
|
|
175
|
+
const text = broken ? copy.videoCantPlay : state === "failed" ? copy.videoFailed : copy.videoProcessing;
|
|
176
|
+
return (
|
|
177
|
+
<View
|
|
178
|
+
testID={`${testID}-${broken ? "broken" : state}`}
|
|
179
|
+
accessible
|
|
180
|
+
accessibilityLabel={text}
|
|
181
|
+
accessibilityLiveRegion="polite"
|
|
182
|
+
style={[box, { alignItems: "center", justifyContent: "center", gap: 8, padding: 12, backgroundColor: colors.fill }]}
|
|
183
|
+
>
|
|
184
|
+
{state === "processing" && !broken ? <ActivityIndicator color={colors.secondaryLabel as string} /> : <NativeIcon name="video" size={24} color={colors.secondaryLabel} />}
|
|
185
|
+
<NativeText preset="caption" style={{ color: colors.secondaryLabel, textAlign: "center" }}>
|
|
186
|
+
{text}
|
|
187
|
+
</NativeText>
|
|
188
|
+
</View>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (active && source) {
|
|
193
|
+
return (
|
|
194
|
+
<ActiveVideo
|
|
195
|
+
{...props}
|
|
196
|
+
video={modules.video}
|
|
197
|
+
source={source}
|
|
198
|
+
style={box}
|
|
199
|
+
fullOnStart={fullOnStart}
|
|
200
|
+
onFullStarted={() => setFullOnStart(false)}
|
|
201
|
+
onBroken={() => setBroken(true)}
|
|
202
|
+
handles={handles}
|
|
203
|
+
testID={testID}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const poster = props.posterUri ?? (props.playbackId ? muxPosterUrl(props.playbackId) : null);
|
|
209
|
+
return (
|
|
210
|
+
<Pressable
|
|
211
|
+
testID={`${testID}-poster`}
|
|
212
|
+
accessibilityRole="button"
|
|
213
|
+
accessibilityLabel={duration ? fill(copy.videoMessage, { duration }) : copy.video}
|
|
214
|
+
accessibilityHint={copy.playVideo}
|
|
215
|
+
accessibilityState={{ disabled: state !== "playable", busy: props.uploading }}
|
|
216
|
+
disabled={state !== "playable"}
|
|
217
|
+
onPress={() => setActive(true)}
|
|
218
|
+
style={box}
|
|
219
|
+
>
|
|
220
|
+
{poster ? <NativeImage src={poster} width={w} height={h} contentFit="cover" recyclingKey={props.id} /> : null}
|
|
221
|
+
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" }}>
|
|
222
|
+
{state === "loading" || props.uploading ? (
|
|
223
|
+
<ActivityIndicator color={ON_MEDIA} />
|
|
224
|
+
) : (
|
|
225
|
+
<View style={{ width: 52, height: 52, borderRadius: 26, backgroundColor: MEDIA_SCRIM, alignItems: "center", justifyContent: "center" }}>
|
|
226
|
+
<NativeIcon name="play" size={24} color={ON_MEDIA} />
|
|
227
|
+
</View>
|
|
228
|
+
)}
|
|
229
|
+
</View>
|
|
230
|
+
{duration ? (
|
|
231
|
+
<View style={{ position: "absolute", left: 8, bottom: 8, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 8, backgroundColor: MEDIA_SCRIM }}>
|
|
232
|
+
<NativeText style={{ color: ON_MEDIA, fontSize: 12, fontVariant: ["tabular-nums"] }}>{duration}</NativeText>
|
|
233
|
+
</View>
|
|
234
|
+
) : null}
|
|
235
|
+
</Pressable>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ActiveVideo({
|
|
240
|
+
id,
|
|
241
|
+
video,
|
|
242
|
+
source,
|
|
243
|
+
style,
|
|
244
|
+
fullOnStart,
|
|
245
|
+
onFullStarted,
|
|
246
|
+
onBroken,
|
|
247
|
+
handles,
|
|
248
|
+
testID,
|
|
249
|
+
}: ChatVideoPlayerProps & {
|
|
250
|
+
onBroken: () => void;
|
|
251
|
+
video: ExpoVideoLike;
|
|
252
|
+
source: { uri: string; contentType: "hls" | "auto" };
|
|
253
|
+
style: Record<string, unknown>;
|
|
254
|
+
fullOnStart: boolean;
|
|
255
|
+
onFullStarted: () => void;
|
|
256
|
+
handles: React.MutableRefObject<VideoControls>;
|
|
257
|
+
testID: string;
|
|
258
|
+
}) {
|
|
259
|
+
const { copy } = useChatNative();
|
|
260
|
+
const player: VideoPlayerLike = video.useVideoPlayer(source, (p) => {
|
|
261
|
+
p.play();
|
|
262
|
+
});
|
|
263
|
+
const viewRef = useRef<VideoViewHandleLike | null>(null);
|
|
264
|
+
const latestBroken = useRef(onBroken);
|
|
265
|
+
latestBroken.current = onBroken;
|
|
266
|
+
const [playing, setPlaying] = useState(true);
|
|
267
|
+
const [muted, setMuted] = useState(false);
|
|
268
|
+
const [rate, setRate] = useState(1);
|
|
269
|
+
const ended = useRef(false);
|
|
270
|
+
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
claimPlayback(id, () => player.pause());
|
|
273
|
+
setPlayingId(id, true);
|
|
274
|
+
const subs = [
|
|
275
|
+
player.addListener("playingChange", ((payload: { isPlaying: boolean }) => {
|
|
276
|
+
setPlaying(payload.isPlaying);
|
|
277
|
+
setPlayingId(id, payload.isPlaying);
|
|
278
|
+
if (payload.isPlaying) {
|
|
279
|
+
ended.current = false;
|
|
280
|
+
claimPlayback(id, () => player.pause());
|
|
281
|
+
} else {
|
|
282
|
+
releasePlayback(id);
|
|
283
|
+
}
|
|
284
|
+
}) as (payload: never) => void),
|
|
285
|
+
player.addListener("playToEnd", (() => {
|
|
286
|
+
ended.current = true;
|
|
287
|
+
}) as (payload: never) => void),
|
|
288
|
+
// A stream that won't load (gone, refused, offline) says so instead of staying black.
|
|
289
|
+
player.addListener("statusChange", ((payload: { status: string }) => {
|
|
290
|
+
if (payload.status === "error") latestBroken.current();
|
|
291
|
+
}) as (payload: never) => void),
|
|
292
|
+
];
|
|
293
|
+
return () => {
|
|
294
|
+
subs.forEach((s) => s.remove());
|
|
295
|
+
setPlayingId(id, false);
|
|
296
|
+
releasePlayback(id);
|
|
297
|
+
};
|
|
298
|
+
}, [id, player]);
|
|
299
|
+
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (!fullOnStart) return;
|
|
302
|
+
onFullStarted();
|
|
303
|
+
void viewRef.current?.enterFullscreen().catch(() => undefined);
|
|
304
|
+
}, [fullOnStart, onFullStarted]);
|
|
305
|
+
|
|
306
|
+
const toggle = () => {
|
|
307
|
+
if (playing) return player.pause();
|
|
308
|
+
if (ended.current) player.currentTime = 0;
|
|
309
|
+
player.play();
|
|
310
|
+
};
|
|
311
|
+
const fullScreen = () => void viewRef.current?.enterFullscreen().catch(() => undefined);
|
|
312
|
+
const toggleMute = () => {
|
|
313
|
+
player.muted = !muted;
|
|
314
|
+
setMuted(!muted);
|
|
315
|
+
};
|
|
316
|
+
const cycleRate = () => {
|
|
317
|
+
const next = nextPlaybackRate(rate);
|
|
318
|
+
player.playbackRate = next;
|
|
319
|
+
setRate(next);
|
|
320
|
+
};
|
|
321
|
+
handles.current = { toggle, fullScreen, mute: toggleMute, rate: cycleRate };
|
|
322
|
+
const { VideoView } = video;
|
|
323
|
+
return (
|
|
324
|
+
<View testID={`${testID}-active`} style={style}>
|
|
325
|
+
<VideoView ref={viewRef} player={player} nativeControls={false} contentFit="cover" allowsPictureInPicture={false} style={{ width: "100%", height: "100%" }} testID={`${testID}-view`} />
|
|
326
|
+
<Pressable
|
|
327
|
+
testID={`${testID}-surface`}
|
|
328
|
+
accessibilityRole="button"
|
|
329
|
+
accessibilityLabel={playing ? copy.pauseVideo : copy.playVideo}
|
|
330
|
+
onPress={toggle}
|
|
331
|
+
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 44, alignItems: "center", justifyContent: "center" }}
|
|
332
|
+
>
|
|
333
|
+
{!playing ? (
|
|
334
|
+
<View style={{ width: 52, height: 52, borderRadius: 26, backgroundColor: MEDIA_SCRIM, alignItems: "center", justifyContent: "center" }}>
|
|
335
|
+
<NativeIcon name="play" size={24} color={ON_MEDIA} />
|
|
336
|
+
</View>
|
|
337
|
+
) : null}
|
|
338
|
+
</Pressable>
|
|
339
|
+
<View style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 44, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 2, backgroundColor: MEDIA_SCRIM_LIGHT }}>
|
|
340
|
+
<Control testID={`${testID}-play`} label={playing ? copy.pauseVideo : copy.playVideo} onPress={toggle} icon={playing ? "pause" : "play"} />
|
|
341
|
+
<Control testID={`${testID}-mute`} label={muted ? copy.unmute : copy.mute} onPress={toggleMute} icon={muted ? "volume-off" : "volume"} />
|
|
342
|
+
<Pressable
|
|
343
|
+
testID={`${testID}-rate`}
|
|
344
|
+
accessibilityRole="button"
|
|
345
|
+
accessibilityLabel={fill(copy.speed, { rate: `${rate}x` })}
|
|
346
|
+
onPress={cycleRate}
|
|
347
|
+
style={{ minWidth: 44, height: 44, alignItems: "center", justifyContent: "center" }}
|
|
348
|
+
>
|
|
349
|
+
<NativeText style={{ color: ON_MEDIA, fontSize: 13, fontWeight: "600", fontVariant: ["tabular-nums"] }}>{`${rate}x`}</NativeText>
|
|
350
|
+
</Pressable>
|
|
351
|
+
<Control testID={`${testID}-fullscreen`} label={copy.fullScreen} onPress={fullScreen} icon="expand" />
|
|
352
|
+
</View>
|
|
353
|
+
</View>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function Control({ icon, label, onPress, selected, testID }: { icon: "play" | "pause" | "volume" | "volume-off" | "expand"; label: string; onPress: () => void; selected?: boolean; testID: string }) {
|
|
358
|
+
return (
|
|
359
|
+
<Pressable
|
|
360
|
+
testID={testID}
|
|
361
|
+
accessibilityRole="button"
|
|
362
|
+
accessibilityLabel={label}
|
|
363
|
+
accessibilityState={selected === undefined ? undefined : { selected }}
|
|
364
|
+
onPress={onPress}
|
|
365
|
+
style={{ width: 44, height: 44, alignItems: "center", justifyContent: "center" }}
|
|
366
|
+
>
|
|
367
|
+
<NativeIcon name={icon} size={20} color={ON_MEDIA} />
|
|
368
|
+
</Pressable>
|
|
369
|
+
);
|
|
370
|
+
}
|