@protobase/ui 0.62.0 → 0.62.2
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/dist/app/app.js +6 -1
- package/dist/app/current-address.d.ts +1 -0
- package/dist/app/current-address.js +2 -1
- package/dist/calls/call-check.js +78 -0
- package/dist/calls/call-controls.js +50 -0
- package/dist/calls/call-layout.js +31 -0
- package/dist/calls/call-mark.d.ts +13 -0
- package/dist/calls/call-mark.js +57 -0
- package/dist/calls/call-message.js +25 -0
- package/dist/calls/call-people.js +46 -0
- package/dist/calls/call-rings.js +78 -0
- package/dist/calls/call-stage.js +111 -0
- package/dist/calls/call-tile.js +60 -0
- package/dist/calls/call-view.d.ts +20 -0
- package/dist/calls/call-view.js +88 -0
- package/dist/calls/devices.js +55 -0
- package/dist/calls/messages.js +76 -0
- package/dist/calls/record-call.d.ts +12 -0
- package/dist/calls/record-call.js +18 -0
- package/dist/calls/ring-tone.js +35 -0
- package/dist/calls/ringing.js +16 -0
- package/dist/calls/track-media.js +36 -0
- package/dist/calls/use-audio-levels.js +43 -0
- package/dist/calls/use-call-session.js +157 -0
- package/dist/calls/use-record-call-address.d.ts +17 -0
- package/dist/calls/use-record-call-address.js +30 -0
- package/dist/calls/use-record-calls.d.ts +172 -0
- package/dist/calls/use-record-calls.js +43 -0
- package/dist/calls/use-rings.js +52 -0
- package/dist/calls/use-width.js +18 -0
- package/dist/data/api-provider.d.ts +8 -0
- package/dist/data/meta-data.d.ts +2 -0
- package/dist/data/query-keys.js +8 -0
- package/dist/data/use-meta.js +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/package.js +1 -1
- package/dist/primitives/route-link.d.ts +13 -1
- package/dist/shell/assets/app-i5V50MJx.js +9 -0
- package/dist/shell/assets/calls-DwVdIhNM.js +2 -0
- package/dist/shell/assets/index-BgcF_WZ0.css +2 -0
- package/dist/shell/assets/{index-Qko7VXeJ.js → index-jTcckwc-.js} +2 -2
- package/dist/shell/assets/{records-map-DMtWYUIm.js → records-map-idUZ9NK2.js} +2 -2
- package/dist/shell/assets/shared-protobase-ui-55TVcKm3.js +2 -0
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +2 -2
- package/dist/shell/protobase.sw.js +2 -2
- package/dist/shell-source-maps/assets/app-i5V50MJx.js.map +1 -0
- package/dist/shell-source-maps/assets/calls-DwVdIhNM.js.map +1 -0
- package/dist/shell-source-maps/assets/{index-Qko7VXeJ.js.map → index-jTcckwc-.js.map} +1 -1
- package/dist/shell-source-maps/assets/{records-map-DMtWYUIm.js.map → records-map-idUZ9NK2.js.map} +1 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-55TVcKm3.js.map +1 -0
- package/dist/toasts/toast-provider.js +3 -1
- package/dist/toasts/toast.js +6 -4
- package/package.json +4 -4
- package/dist/shell/assets/app-Dk6Wz4uU.js +0 -9
- package/dist/shell/assets/index-Iii7afo2.css +0 -2
- package/dist/shell/assets/shared-protobase-ui-DJI957cu.js +0 -2
- package/dist/shell-source-maps/assets/app-Dk6Wz4uU.js.map +0 -1
- package/dist/shell-source-maps/assets/shared-protobase-ui-DJI957cu.js.map +0 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useOptionalAuth } from "../auth/auth-provider.js";
|
|
2
|
+
import { text } from "./messages.js";
|
|
3
|
+
import { CallCheck } from "./call-check.js";
|
|
4
|
+
import { CallMessage } from "./call-message.js";
|
|
5
|
+
import { callPeople } from "./call-people.js";
|
|
6
|
+
import { CallStage } from "./call-stage.js";
|
|
7
|
+
import { useActiveSpeaker } from "./use-audio-levels.js";
|
|
8
|
+
import { useCallSession } from "./use-call-session.js";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import { jsx } from "react/jsx-runtime";
|
|
11
|
+
//#region src/calls/call-view.tsx
|
|
12
|
+
const deviceNotices = (devices, refusedSending) => [
|
|
13
|
+
...devices?.blocked.microphone ? [text("noMicrophone")] : [],
|
|
14
|
+
...devices?.blocked.camera ? [text("noCamera")] : [],
|
|
15
|
+
...devices?.none ? [text("noDevices")] : [],
|
|
16
|
+
...refusedSending ? [text("refused")] : []
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* A call, ready-made, that fills its parent, such as a column beside a conversation's messages: a check of the
|
|
20
|
+
* microphone and camera first, then, after Join, everyone in the call with the one speaking ringed, a screen share
|
|
21
|
+
* large, microphone, camera, screen share and Leave, the quality of each connection, Reconnecting while the link is
|
|
22
|
+
* restored, and plain words for a full call, a blocked device or someone the browser cannot connect to. Made for small
|
|
23
|
+
* peer-to-peer calls, at a desktop's width or a phone's.
|
|
24
|
+
*/
|
|
25
|
+
const CallView = ({ url, leave, nameOf = (member) => member, className }) => {
|
|
26
|
+
const session = useCallSession(url);
|
|
27
|
+
const auth = useOptionalAuth();
|
|
28
|
+
const user = auth?.state.kind === "signed-in" ? auth.state.user : void 0;
|
|
29
|
+
const self = session.self ?? user?.id ?? "";
|
|
30
|
+
const [now] = useState(() => Date.now());
|
|
31
|
+
const { devices, phase } = session;
|
|
32
|
+
const own = {
|
|
33
|
+
...devices?.microphone && { microphone: devices.microphone },
|
|
34
|
+
...devices?.camera && { camera: devices.camera },
|
|
35
|
+
...session.screen && { screen: session.screen.video },
|
|
36
|
+
microphoneOn: session.microphoneOn,
|
|
37
|
+
cameraOn: session.cameraOn
|
|
38
|
+
};
|
|
39
|
+
const voices = new Map([...devices?.microphone && session.microphoneOn ? [[self, devices.microphone]] : [], ...session.tracks.filter((track) => track.source === "microphone" && track.track && !track.muted).map((track) => [track.member, track.track])]);
|
|
40
|
+
const speaker = useActiveSpeaker(phase.kind === "in" ? voices : /* @__PURE__ */ new Map());
|
|
41
|
+
const notices = deviceNotices(devices, session.refusedSending);
|
|
42
|
+
if (phase.kind === "left") return /* @__PURE__ */ jsx(CallMessage, {
|
|
43
|
+
reason: phase.reason,
|
|
44
|
+
onJoinAgain: () => void session.join()
|
|
45
|
+
});
|
|
46
|
+
if (phase.kind === "check") return /* @__PURE__ */ jsx(CallCheck, {
|
|
47
|
+
name: user?.name ?? text("you"),
|
|
48
|
+
member: self,
|
|
49
|
+
...devices?.camera && { camera: devices.camera },
|
|
50
|
+
microphoneOn: session.microphoneOn,
|
|
51
|
+
cameraOn: session.cameraOn,
|
|
52
|
+
microphone: Boolean(devices?.microphone),
|
|
53
|
+
cameraAvailable: Boolean(devices?.camera),
|
|
54
|
+
notices,
|
|
55
|
+
onMicrophone: session.toggleMicrophone,
|
|
56
|
+
onCamera: session.toggleCamera,
|
|
57
|
+
onJoin: () => void session.join(),
|
|
58
|
+
cancel: leave
|
|
59
|
+
});
|
|
60
|
+
const people = callPeople({
|
|
61
|
+
members: session.members,
|
|
62
|
+
self,
|
|
63
|
+
tracks: session.tracks,
|
|
64
|
+
own,
|
|
65
|
+
nameOf,
|
|
66
|
+
...speaker && { speaker },
|
|
67
|
+
connections: session.connections,
|
|
68
|
+
seen: session.seen,
|
|
69
|
+
now: Math.max(now, Date.now())
|
|
70
|
+
});
|
|
71
|
+
return /* @__PURE__ */ jsx(CallStage, {
|
|
72
|
+
...className && { className },
|
|
73
|
+
people,
|
|
74
|
+
state: phase.kind === "joining" ? "joining" : phase.resuming ? "resuming" : "joined",
|
|
75
|
+
notices,
|
|
76
|
+
controls: {
|
|
77
|
+
microphoneOn: session.microphoneOn && Boolean(devices?.microphone),
|
|
78
|
+
cameraOn: session.cameraOn && Boolean(devices?.camera),
|
|
79
|
+
sharing: Boolean(session.screen),
|
|
80
|
+
onMicrophone: session.toggleMicrophone,
|
|
81
|
+
onCamera: session.toggleCamera,
|
|
82
|
+
...session.toggleScreen && { onShare: () => void session.toggleScreen() },
|
|
83
|
+
leave
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
//#endregion
|
|
88
|
+
export { CallView };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/calls/devices.ts
|
|
2
|
+
const blockedNames = /* @__PURE__ */ new Set(["NotAllowedError", "SecurityError"]);
|
|
3
|
+
const missingNames = /* @__PURE__ */ new Set([
|
|
4
|
+
"NotFoundError",
|
|
5
|
+
"NotReadableError",
|
|
6
|
+
"OverconstrainedError"
|
|
7
|
+
]);
|
|
8
|
+
const one = async (media, kind) => {
|
|
9
|
+
try {
|
|
10
|
+
return {
|
|
11
|
+
track: (await media.getUserMedia({ [kind]: true })).getTracks()[0],
|
|
12
|
+
blocked: false
|
|
13
|
+
};
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (!(error instanceof DOMException)) throw error;
|
|
16
|
+
if (blockedNames.has(error.name)) return { blocked: true };
|
|
17
|
+
if (missingNames.has(error.name)) return { blocked: false };
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Asks for the microphone and the camera at once, so the browser asks the person once; when that is refused, each on
|
|
23
|
+
* its own, to tell which one is blocked or missing.
|
|
24
|
+
*/
|
|
25
|
+
const openDevices = async (media = navigator.mediaDevices) => {
|
|
26
|
+
try {
|
|
27
|
+
const stream = await media.getUserMedia({
|
|
28
|
+
audio: true,
|
|
29
|
+
video: true
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
microphone: stream.getAudioTracks()[0],
|
|
33
|
+
camera: stream.getVideoTracks()[0],
|
|
34
|
+
blocked: {
|
|
35
|
+
microphone: false,
|
|
36
|
+
camera: false
|
|
37
|
+
},
|
|
38
|
+
none: false
|
|
39
|
+
};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (!(error instanceof DOMException) || !(blockedNames.has(error.name) || missingNames.has(error.name))) throw error;
|
|
42
|
+
}
|
|
43
|
+
const [audio, video] = [await one(media, "audio"), await one(media, "video")];
|
|
44
|
+
return {
|
|
45
|
+
...audio.track && { microphone: audio.track },
|
|
46
|
+
...video.track && { camera: video.track },
|
|
47
|
+
blocked: {
|
|
48
|
+
microphone: audio.blocked,
|
|
49
|
+
camera: video.blocked
|
|
50
|
+
},
|
|
51
|
+
none: !audio.track && !video.track && !audio.blocked && !video.blocked
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { openDevices };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { defineMessages } from "../locale/messages.js";
|
|
2
|
+
//#region src/calls/messages.ts
|
|
3
|
+
const text = defineMessages({
|
|
4
|
+
en: {
|
|
5
|
+
accept: "Accept",
|
|
6
|
+
decline: "Decline",
|
|
7
|
+
startCall: "Start call",
|
|
8
|
+
join: "Join",
|
|
9
|
+
inProgress: "Call in progress",
|
|
10
|
+
people: (count) => count === 1 ? "1 person" : `${count} people`,
|
|
11
|
+
joinCall: "Join call",
|
|
12
|
+
leave: "Leave",
|
|
13
|
+
microphone: "Microphone",
|
|
14
|
+
camera: "Camera",
|
|
15
|
+
shareScreen: "Share screen",
|
|
16
|
+
stopSharing: "Stop sharing",
|
|
17
|
+
muted: "Muted",
|
|
18
|
+
you: "You",
|
|
19
|
+
alone: "You are the only one here",
|
|
20
|
+
connecting: "Joining…",
|
|
21
|
+
reconnecting: "Reconnecting…",
|
|
22
|
+
full: "This call is full",
|
|
23
|
+
removed: "You were removed from this call",
|
|
24
|
+
ended: "The call has ended",
|
|
25
|
+
refused: "You cannot join this call",
|
|
26
|
+
unavailable: "The call cannot be reached now. Try again in a moment.",
|
|
27
|
+
callAgain: "Join again",
|
|
28
|
+
noMicrophone: "Your microphone is blocked: allow it in the browser to be heard.",
|
|
29
|
+
noCamera: "Your camera is blocked: allow it in the browser to be seen.",
|
|
30
|
+
noDevices: "No camera or microphone was found.",
|
|
31
|
+
cannotConnect: (name) => `Cannot connect to ${name}`,
|
|
32
|
+
poorConnection: "Poor connection",
|
|
33
|
+
goodConnection: "Good connection",
|
|
34
|
+
sharing: (name) => `${name} is sharing their screen`,
|
|
35
|
+
youAreSharing: "You are sharing your screen",
|
|
36
|
+
speaking: "Speaking",
|
|
37
|
+
preview: "Your camera before you join"
|
|
38
|
+
},
|
|
39
|
+
nl: {
|
|
40
|
+
accept: "Opnemen",
|
|
41
|
+
decline: "Weigeren",
|
|
42
|
+
startCall: "Gesprek starten",
|
|
43
|
+
join: "Deelnemen",
|
|
44
|
+
inProgress: "Gesprek bezig",
|
|
45
|
+
people: (count) => count === 1 ? "1 persoon" : `${count} personen`,
|
|
46
|
+
joinCall: "Deelnemen aan gesprek",
|
|
47
|
+
leave: "Verlaten",
|
|
48
|
+
microphone: "Microfoon",
|
|
49
|
+
camera: "Camera",
|
|
50
|
+
shareScreen: "Scherm delen",
|
|
51
|
+
stopSharing: "Stoppen met delen",
|
|
52
|
+
muted: "Gedempt",
|
|
53
|
+
you: "Jij",
|
|
54
|
+
alone: "Je bent de enige hier",
|
|
55
|
+
connecting: "Deelnemen…",
|
|
56
|
+
reconnecting: "Opnieuw verbinden…",
|
|
57
|
+
full: "Dit gesprek is vol",
|
|
58
|
+
removed: "Je bent uit dit gesprek gehaald",
|
|
59
|
+
ended: "Het gesprek is afgelopen",
|
|
60
|
+
refused: "Je kunt niet aan dit gesprek deelnemen",
|
|
61
|
+
unavailable: "Het gesprek is nu niet bereikbaar. Probeer het zo opnieuw.",
|
|
62
|
+
callAgain: "Opnieuw deelnemen",
|
|
63
|
+
noMicrophone: "Je microfoon is geblokkeerd: sta hem toe in de browser om gehoord te worden.",
|
|
64
|
+
noCamera: "Je camera is geblokkeerd: sta hem toe in de browser om gezien te worden.",
|
|
65
|
+
noDevices: "Er is geen camera of microfoon gevonden.",
|
|
66
|
+
cannotConnect: (name) => `Kan geen verbinding maken met ${name}`,
|
|
67
|
+
poorConnection: "Slechte verbinding",
|
|
68
|
+
goodConnection: "Goede verbinding",
|
|
69
|
+
sharing: (name) => `${name} deelt het scherm`,
|
|
70
|
+
youAreSharing: "Je deelt je scherm",
|
|
71
|
+
speaking: "Spreekt",
|
|
72
|
+
preview: "Je camera voordat je deelneemt"
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
//#endregion
|
|
76
|
+
export { text };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { CallViewProps } from "./call-view.js";
|
|
2
|
+
//#region src/calls/record-call.d.ts
|
|
3
|
+
export type RecordCallProps = Omit<CallViewProps, 'leave'> & {
|
|
4
|
+
resource: string;
|
|
5
|
+
recordKey: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The record's call view while the app's address joins it (`call=join`, as Accept and Join open it), and nothing
|
|
9
|
+
* otherwise: put it beside the record's own content, such as on a full-area record page. Leave goes back to the record.
|
|
10
|
+
*/
|
|
11
|
+
export declare const RecordCall: ({ resource, recordKey, ...rest }: RecordCallProps) => import("react").JSX.Element | null;
|
|
12
|
+
//#endregion
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CallView } from "./call-view.js";
|
|
2
|
+
import { useRecordCallAddress } from "./use-record-call-address.js";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/calls/record-call.tsx
|
|
5
|
+
/**
|
|
6
|
+
* The record's call view while the app's address joins it (`call=join`, as Accept and Join open it), and nothing
|
|
7
|
+
* otherwise: put it beside the record's own content, such as on a full-area record page. Leave goes back to the record.
|
|
8
|
+
*/
|
|
9
|
+
const RecordCall = ({ resource, recordKey, ...rest }) => {
|
|
10
|
+
const { joining, leave } = useRecordCallAddress(resource, recordKey);
|
|
11
|
+
if (!joining) return null;
|
|
12
|
+
return /* @__PURE__ */ jsx(CallView, {
|
|
13
|
+
leave,
|
|
14
|
+
...rest
|
|
15
|
+
}, `${resource}/${recordKey}`);
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { RecordCall };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
//#region src/calls/ring-tone.ts
|
|
3
|
+
const toneHz = [440, 480];
|
|
4
|
+
const ringMs = 400;
|
|
5
|
+
const cycleMs = 3e3;
|
|
6
|
+
/**
|
|
7
|
+
* Plays a ring while `on`, through Web Audio. A browser that has not let the page play sound yet, before the person
|
|
8
|
+
* clicked or typed in it, keeps the context suspended and plays nothing: the ring still shows.
|
|
9
|
+
*/
|
|
10
|
+
const useRingTone = (on) => {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!on || typeof AudioContext === "undefined") return;
|
|
13
|
+
const context = new AudioContext();
|
|
14
|
+
const ring = () => {
|
|
15
|
+
const gain = context.createGain();
|
|
16
|
+
gain.gain.value = .08;
|
|
17
|
+
gain.connect(context.destination);
|
|
18
|
+
for (const [index, start] of [0, ringMs * 1.5 / 1e3].entries()) for (const hz of toneHz) {
|
|
19
|
+
const oscillator = context.createOscillator();
|
|
20
|
+
oscillator.frequency.value = hz;
|
|
21
|
+
oscillator.connect(gain);
|
|
22
|
+
oscillator.start(context.currentTime + start);
|
|
23
|
+
oscillator.stop(context.currentTime + start + ringMs / 1e3 / (index + 1));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
ring();
|
|
27
|
+
const timer = setInterval(ring, cycleMs);
|
|
28
|
+
return () => {
|
|
29
|
+
clearInterval(timer);
|
|
30
|
+
context.close();
|
|
31
|
+
};
|
|
32
|
+
}, [on]);
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { useRingTone };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/calls/ringing.ts
|
|
2
|
+
/** The rings as read at `readAt`, each with the time it stops ringing here. */
|
|
3
|
+
const ringsUntil = (rings, readAt) => rings.map((ring) => ({
|
|
4
|
+
...ring,
|
|
5
|
+
until: readAt + ring.remaining_ms
|
|
6
|
+
}));
|
|
7
|
+
/** The rings still ringing at `now`, and the next time one stops, to look again then. */
|
|
8
|
+
const ringingAt = (rings, now) => {
|
|
9
|
+
const ringing = rings.filter((ring) => ring.until > now);
|
|
10
|
+
return {
|
|
11
|
+
ringing,
|
|
12
|
+
next: ringing.length === 0 ? void 0 : Math.min(...ringing.map((ring) => ring.until))
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { ringingAt, ringsUntil };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/calls/track-media.tsx
|
|
4
|
+
const keepPlaying = (element, track) => {
|
|
5
|
+
element.srcObject = new MediaStream([track]);
|
|
6
|
+
const play = () => {
|
|
7
|
+
if (track.readyState !== "live") return;
|
|
8
|
+
element.play().catch((error) => {
|
|
9
|
+
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "NotAllowedError")) return;
|
|
10
|
+
throw error;
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
element.addEventListener("pause", play);
|
|
14
|
+
return () => element.removeEventListener("pause", play);
|
|
15
|
+
};
|
|
16
|
+
/** A video element showing the track, muted, inline, playing as soon as it can and for as long as the track is live. */
|
|
17
|
+
const TrackVideo = ({ track, ...rest }) => {
|
|
18
|
+
const ref = useCallback((element) => element ? keepPlaying(element, track) : void 0, [track]);
|
|
19
|
+
return /* @__PURE__ */ jsx("video", {
|
|
20
|
+
ref,
|
|
21
|
+
autoPlay: true,
|
|
22
|
+
playsInline: true,
|
|
23
|
+
muted: true,
|
|
24
|
+
...rest
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
/** Plays the track's sound, unseen, for as long as the track is live. */
|
|
28
|
+
const TrackAudio = ({ track }) => {
|
|
29
|
+
const ref = useCallback((element) => element ? keepPlaying(element, track) : void 0, [track]);
|
|
30
|
+
return /* @__PURE__ */ jsx("audio", {
|
|
31
|
+
ref,
|
|
32
|
+
autoPlay: true
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { TrackAudio, TrackVideo };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { activeSpeaker } from "./call-layout.js";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
//#region src/calls/use-audio-levels.ts
|
|
4
|
+
const readEveryMs = 200;
|
|
5
|
+
const holdMs = 1e3;
|
|
6
|
+
const levelOf = (analyser, samples) => {
|
|
7
|
+
analyser.getFloatTimeDomainData(samples);
|
|
8
|
+
let sum = 0;
|
|
9
|
+
for (const sample of samples) sum += sample * sample;
|
|
10
|
+
return Math.min(1, Math.sqrt(sum / samples.length) * 4);
|
|
11
|
+
};
|
|
12
|
+
/** Who of these members is speaking, by the sound of their microphone tracks, read in the browser through Web Audio. */
|
|
13
|
+
const useActiveSpeaker = (tracks) => {
|
|
14
|
+
const [speaker, setSpeaker] = useState();
|
|
15
|
+
const ids = [...tracks].map(([member, track]) => `${member}:${track.id}`).join(",");
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (tracks.size === 0 || typeof AudioContext === "undefined") return;
|
|
18
|
+
const context = new AudioContext();
|
|
19
|
+
const analysers = new Map([...tracks].map(([member, track]) => {
|
|
20
|
+
const analyser = context.createAnalyser();
|
|
21
|
+
analyser.fftSize = 512;
|
|
22
|
+
context.createMediaStreamSource(new MediaStream([track])).connect(analyser);
|
|
23
|
+
return [member, analyser];
|
|
24
|
+
}));
|
|
25
|
+
const samples = /* @__PURE__ */ new Float32Array(512);
|
|
26
|
+
let last;
|
|
27
|
+
const timer = setInterval(() => {
|
|
28
|
+
const loudest = activeSpeaker(new Map([...analysers].map(([member, analyser]) => [member, levelOf(analyser, samples)])));
|
|
29
|
+
if (loudest) last = {
|
|
30
|
+
member: loudest,
|
|
31
|
+
at: Date.now()
|
|
32
|
+
};
|
|
33
|
+
setSpeaker(last && Date.now() - last.at < holdMs ? last.member : void 0);
|
|
34
|
+
}, readEveryMs);
|
|
35
|
+
return () => {
|
|
36
|
+
clearInterval(timer);
|
|
37
|
+
context.close();
|
|
38
|
+
};
|
|
39
|
+
}, [ids]);
|
|
40
|
+
return speaker;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { useActiveSpeaker };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { openDevices } from "./devices.js";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
//#region src/calls/use-call-session.ts
|
|
4
|
+
const connectionsEveryMs = 2e3;
|
|
5
|
+
const stop = (tracks) => {
|
|
6
|
+
for (const track of tracks) track?.stop();
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The member's session in the call the app's function at `url` admits them to: their devices from the start, for the
|
|
10
|
+
* check before joining; then the call, which `join` joins after the click, with what they send and receive, and who
|
|
11
|
+
* is in it. Leaving the page leaves the call and stops the devices.
|
|
12
|
+
*/
|
|
13
|
+
const useCallSession = (url) => {
|
|
14
|
+
const [phase, setPhase] = useState({ kind: "check" });
|
|
15
|
+
const [devices, setDevices] = useState();
|
|
16
|
+
const [microphoneOn, setMicrophoneOn] = useState(true);
|
|
17
|
+
const [cameraOn, setCameraOn] = useState(true);
|
|
18
|
+
const [tracks, setTracks] = useState([]);
|
|
19
|
+
const [members, setMembers] = useState([]);
|
|
20
|
+
const [connections, setConnections] = useState([]);
|
|
21
|
+
const [screen, setScreen] = useState();
|
|
22
|
+
const [refusedSending, setRefusedSending] = useState(false);
|
|
23
|
+
const call = useRef(void 0);
|
|
24
|
+
const gone = useRef(false);
|
|
25
|
+
const own = useRef([]);
|
|
26
|
+
const seen = useRef(/* @__PURE__ */ new Map());
|
|
27
|
+
const opening = useRef(Promise.resolve(void 0));
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
gone.current = false;
|
|
31
|
+
opening.current = openDevices().then((opened) => {
|
|
32
|
+
if (cancelled) {
|
|
33
|
+
stop([opened.microphone, opened.camera]);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
own.current.push(opened.microphone, opened.camera);
|
|
37
|
+
setDevices(opened);
|
|
38
|
+
return opened;
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
gone.current = true;
|
|
43
|
+
call.current?.leave();
|
|
44
|
+
call.current = void 0;
|
|
45
|
+
stop(own.current);
|
|
46
|
+
own.current = [];
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
for (const { member } of members) if (!seen.current.has(member)) seen.current.set(member, Date.now());
|
|
51
|
+
}, [members]);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (phase.kind !== "in") return;
|
|
54
|
+
const timer = setInterval(() => void call.current?.connections().then(setConnections), connectionsEveryMs);
|
|
55
|
+
return () => clearInterval(timer);
|
|
56
|
+
}, [phase.kind]);
|
|
57
|
+
const join = useCallback(async () => {
|
|
58
|
+
setPhase({ kind: "joining" });
|
|
59
|
+
const { joinCall, PublishRefused } = await import("@protobase/client/calls");
|
|
60
|
+
const joined = await joinCall({
|
|
61
|
+
url,
|
|
62
|
+
onTracks: setTracks,
|
|
63
|
+
onMembers: setMembers,
|
|
64
|
+
onState: (state) => {
|
|
65
|
+
if (state.state === "joined" || state.state === "resuming") setPhase({
|
|
66
|
+
kind: "in",
|
|
67
|
+
resuming: state.state === "resuming"
|
|
68
|
+
});
|
|
69
|
+
if (state.state === "left") setPhase({
|
|
70
|
+
kind: "left",
|
|
71
|
+
reason: state.reason
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}).then((made) => made, () => void 0);
|
|
75
|
+
if (!joined) return;
|
|
76
|
+
if (gone.current) return joined.leave();
|
|
77
|
+
call.current = joined;
|
|
78
|
+
const opened = await opening.current;
|
|
79
|
+
const sending = {
|
|
80
|
+
...opened?.microphone && { microphone: opened.microphone },
|
|
81
|
+
...opened?.camera && { camera: opened.camera }
|
|
82
|
+
};
|
|
83
|
+
await joined.publish(sending).then(() => {
|
|
84
|
+
if (sending.microphone) joined.mute("microphone", !microphoneOn);
|
|
85
|
+
if (sending.camera) joined.mute("camera", !cameraOn);
|
|
86
|
+
}, (error) => {
|
|
87
|
+
if (!(error instanceof PublishRefused)) throw error;
|
|
88
|
+
setRefusedSending(true);
|
|
89
|
+
});
|
|
90
|
+
}, [
|
|
91
|
+
url,
|
|
92
|
+
microphoneOn,
|
|
93
|
+
cameraOn
|
|
94
|
+
]);
|
|
95
|
+
const toggleMicrophone = useCallback(() => {
|
|
96
|
+
const next = !microphoneOn;
|
|
97
|
+
setMicrophoneOn(next);
|
|
98
|
+
if (devices?.microphone) devices.microphone.enabled = next;
|
|
99
|
+
call.current?.mute("microphone", !next);
|
|
100
|
+
}, [microphoneOn, devices]);
|
|
101
|
+
const toggleCamera = useCallback(() => {
|
|
102
|
+
const next = !cameraOn;
|
|
103
|
+
setCameraOn(next);
|
|
104
|
+
if (devices?.camera) devices.camera.enabled = next;
|
|
105
|
+
call.current?.mute("camera", !next);
|
|
106
|
+
}, [cameraOn, devices]);
|
|
107
|
+
const stopSharing = useCallback(async (shared) => {
|
|
108
|
+
setScreen(void 0);
|
|
109
|
+
stop([shared.video, shared.audio]);
|
|
110
|
+
await call.current?.unpublish("screen");
|
|
111
|
+
if (shared.audio) await call.current?.unpublish("screen-audio");
|
|
112
|
+
}, []);
|
|
113
|
+
const toggleScreen = useCallback(async () => {
|
|
114
|
+
if (screen) return stopSharing(screen);
|
|
115
|
+
let display;
|
|
116
|
+
try {
|
|
117
|
+
display = await navigator.mediaDevices.getDisplayMedia({
|
|
118
|
+
video: true,
|
|
119
|
+
audio: true
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof DOMException && error.name === "NotAllowedError") return;
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
const shared = {
|
|
126
|
+
video: display.getVideoTracks()[0],
|
|
127
|
+
...display.getAudioTracks()[0] && { audio: display.getAudioTracks()[0] }
|
|
128
|
+
};
|
|
129
|
+
own.current.push(shared.video, shared.audio);
|
|
130
|
+
shared.video.addEventListener("ended", () => void stopSharing(shared));
|
|
131
|
+
await call.current?.publish({
|
|
132
|
+
screen: shared.video,
|
|
133
|
+
...shared.audio && { "screen-audio": shared.audio }
|
|
134
|
+
});
|
|
135
|
+
setScreen(shared);
|
|
136
|
+
}, [screen, stopSharing]);
|
|
137
|
+
const canShare = typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getDisplayMedia === "function";
|
|
138
|
+
return {
|
|
139
|
+
phase,
|
|
140
|
+
devices,
|
|
141
|
+
microphoneOn,
|
|
142
|
+
cameraOn,
|
|
143
|
+
tracks,
|
|
144
|
+
members,
|
|
145
|
+
connections,
|
|
146
|
+
screen,
|
|
147
|
+
refusedSending,
|
|
148
|
+
seen: seen.current,
|
|
149
|
+
self: call.current?.participant,
|
|
150
|
+
join,
|
|
151
|
+
toggleMicrophone,
|
|
152
|
+
toggleCamera,
|
|
153
|
+
...canShare && { toggleScreen }
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
//#endregion
|
|
157
|
+
export { useCallSession };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/calls/use-record-call-address.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A record's call as the app's address holds it: whether the page joins it (`call=join`), the link that joins it, as a
|
|
4
|
+
* ring's Accept and a call mark's Join open it, and the link back to the record without the call, as Leave opens it.
|
|
5
|
+
*/
|
|
6
|
+
export declare const useRecordCallAddress: (resource: string, recordKey: string) => {
|
|
7
|
+
joining: boolean;
|
|
8
|
+
join: {
|
|
9
|
+
href: string;
|
|
10
|
+
onFollow: () => void;
|
|
11
|
+
};
|
|
12
|
+
leave: {
|
|
13
|
+
href: string;
|
|
14
|
+
onFollow: () => void;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useAppAddress } from "../app/use-app-address.js";
|
|
2
|
+
//#region src/calls/use-record-call-address.ts
|
|
3
|
+
/**
|
|
4
|
+
* A record's call as the app's address holds it: whether the page joins it (`call=join`), the link that joins it, as a
|
|
5
|
+
* ring's Accept and a call mark's Join open it, and the link back to the record without the call, as Leave opens it.
|
|
6
|
+
*/
|
|
7
|
+
const useRecordCallAddress = (resource, recordKey) => {
|
|
8
|
+
const { address, href, open } = useAppAddress();
|
|
9
|
+
const joining = address?.kind === "record" && address.resource === resource && address.key === recordKey && address.call === "join";
|
|
10
|
+
const target = (to) => ({
|
|
11
|
+
href: to,
|
|
12
|
+
onFollow: () => open(to)
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
joining,
|
|
16
|
+
join: target(href({
|
|
17
|
+
kind: "record",
|
|
18
|
+
resource,
|
|
19
|
+
key: recordKey,
|
|
20
|
+
call: "join"
|
|
21
|
+
})),
|
|
22
|
+
leave: target(href({
|
|
23
|
+
kind: "record",
|
|
24
|
+
resource,
|
|
25
|
+
key: recordKey
|
|
26
|
+
}))
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { useRecordCallAddress };
|