(feat): add sounds
This commit is contained in:
parent
6a5236bafe
commit
184b85190b
19 changed files with 207 additions and 16 deletions
BIN
apps/web/public/sounds/call_jingle_1.wav
Normal file
BIN
apps/web/public/sounds/call_jingle_1.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_jingle_2.wav
Normal file
BIN
apps/web/public/sounds/call_jingle_2.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_join.wav
Normal file
BIN
apps/web/public/sounds/call_join.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_leave.wav
Normal file
BIN
apps/web/public/sounds/call_leave.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/message.wav
Normal file
BIN
apps/web/public/sounds/message.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_end_other.wav
Normal file
BIN
apps/web/public/sounds/stream_end_other.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_end_self.wav
Normal file
BIN
apps/web/public/sounds/stream_end_self.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_start_other.wav
Normal file
BIN
apps/web/public/sounds/stream_start_other.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_start_self.wav
Normal file
BIN
apps/web/public/sounds/stream_start_self.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_watch_end.wav
Normal file
BIN
apps/web/public/sounds/stream_watch_end.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_watch_start.wav
Normal file
BIN
apps/web/public/sounds/stream_watch_start.wav
Normal file
Binary file not shown.
|
|
@ -4,6 +4,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
|
|||
import { useMTP } from "@tensamin/mtp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { mtp } from "@tensamin/shared/data";
|
||||
import { playSound, stopSound } from "@tensamin/shared/sounds";
|
||||
import { bytesToBase64 } from "mtp";
|
||||
import {
|
||||
deriveCallSecretId,
|
||||
|
|
@ -154,6 +155,8 @@ export function getRoom(): Room {
|
|||
}
|
||||
|
||||
const remoteAudioElements = new Map<string, HTMLMediaElement>();
|
||||
let outgoingJingle: HTMLAudioElement | null = null;
|
||||
let outgoingJingleGeneration = 0;
|
||||
|
||||
const CALL_SECRET_VERSION = 1;
|
||||
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
|
||||
|
|
@ -161,6 +164,34 @@ const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180;
|
|||
const SCREEN_SHARE_PREVIEW_QUALITY = 0.7;
|
||||
const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000;
|
||||
|
||||
async function startOutgoingJingle() {
|
||||
const generation = ++outgoingJingleGeneration;
|
||||
stopSound(outgoingJingle);
|
||||
outgoingJingle = null;
|
||||
|
||||
const jingle = await requireRuntime(useCall.getState().runtime).load(
|
||||
"settings.call_jingle",
|
||||
);
|
||||
|
||||
if (
|
||||
generation !== outgoingJingleGeneration ||
|
||||
useCall.getState().invitedUserId == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
outgoingJingle = playSound(
|
||||
jingle === "jingle_2" ? "call_jingle_2" : "call_jingle_1",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function stopOutgoingJingle() {
|
||||
outgoingJingleGeneration += 1;
|
||||
stopSound(outgoingJingle);
|
||||
outgoingJingle = null;
|
||||
}
|
||||
|
||||
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
|
@ -695,6 +726,16 @@ export async function sendCallInvite(userId: number) {
|
|||
// Start tracking a participant's shared screen in the call UI.
|
||||
export function startWatchingStream(participantId: number) {
|
||||
const trackReady = getScreenShareTrackForParticipant(participantId) != null;
|
||||
const alreadyWatching = useCall
|
||||
.getState()
|
||||
.watchedStreamParticipantIds.includes(participantId);
|
||||
const localParticipantId = getParticipantId(
|
||||
getRoom().localParticipant.identity,
|
||||
);
|
||||
|
||||
if (!alreadyWatching && participantId !== localParticipantId) {
|
||||
playSound("stream_watch_start");
|
||||
}
|
||||
|
||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare);
|
||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio);
|
||||
|
|
@ -745,6 +786,17 @@ export function focusParticipant(
|
|||
|
||||
// Stop tracking a participant's shared screen and clean up related UI state.
|
||||
export function stopWatchingStream(participantId: number) {
|
||||
const wasWatching = useCall
|
||||
.getState()
|
||||
.watchedStreamParticipantIds.includes(participantId);
|
||||
const localParticipantId = getParticipantId(
|
||||
getRoom().localParticipant.identity,
|
||||
);
|
||||
|
||||
if (wasWatching && participantId !== localParticipantId) {
|
||||
playSound("stream_watch_end");
|
||||
}
|
||||
|
||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false);
|
||||
setParticipantTrackSubscribed(
|
||||
participantId,
|
||||
|
|
@ -866,6 +918,7 @@ export async function connect(callId: string) {
|
|||
|
||||
// Tear down the active call session and return the store to a closed state.
|
||||
export async function disconnect() {
|
||||
stopOutgoingJingle();
|
||||
disposeSpeakingDetector();
|
||||
await clearScreenSharePreview();
|
||||
|
||||
|
|
@ -939,6 +992,10 @@ export async function joinCall(
|
|||
ownCallSecretInvitePending: isNewCall,
|
||||
});
|
||||
|
||||
if (sendInvite && !existingCallId) {
|
||||
void startOutgoingJingle();
|
||||
}
|
||||
|
||||
if (callSecret) {
|
||||
try {
|
||||
if (!existingCallId) {
|
||||
|
|
@ -1303,6 +1360,7 @@ export function useInitializeCall() {
|
|||
|
||||
const onConnected = async () => {
|
||||
useCall.setState({ state: "open" });
|
||||
playSound("call_join");
|
||||
syncParticipantState();
|
||||
|
||||
const detector = getSpeakingDetector();
|
||||
|
|
@ -1376,6 +1434,8 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onDisconnected = () => {
|
||||
stopOutgoingJingle();
|
||||
playSound("call_leave");
|
||||
useCall.setState({ state: "closed" });
|
||||
syncParticipantState();
|
||||
log(2, "call", "purple", "Disconnected from call", {
|
||||
|
|
@ -1385,11 +1445,14 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onParticipantConnected = () => {
|
||||
stopOutgoingJingle();
|
||||
playSound("call_join");
|
||||
syncAllRemoteTrackSubscriptions();
|
||||
syncParticipantState();
|
||||
};
|
||||
|
||||
const onParticipantDisconnected = (participant: Participant) => {
|
||||
playSound("call_leave");
|
||||
const participantId = getParticipantId(participant.identity);
|
||||
|
||||
if (participantId != null) {
|
||||
|
|
@ -1416,6 +1479,10 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onLocalTrackPublished = (publication: LocalTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
playSound("stream_start_self");
|
||||
}
|
||||
|
||||
if (
|
||||
publication.kind === Track.Kind.Audio &&
|
||||
publication.source === Track.Source.Microphone &&
|
||||
|
|
@ -1437,6 +1504,10 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
playSound("stream_end_self");
|
||||
}
|
||||
|
||||
if (
|
||||
publication.kind === Track.Kind.Audio &&
|
||||
publication.source === Track.Source.Microphone
|
||||
|
|
@ -1453,6 +1524,10 @@ export function useInitializeCall() {
|
|||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
playSound("stream_start_other");
|
||||
}
|
||||
|
||||
const participantId = getParticipantId(participant.identity);
|
||||
|
||||
if (participantId != null) {
|
||||
|
|
@ -1469,6 +1544,14 @@ export function useInitializeCall() {
|
|||
onParticipantStateChange();
|
||||
};
|
||||
|
||||
const onTrackUnpublished = (publication: RemoteTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
playSound("stream_end_other");
|
||||
}
|
||||
|
||||
onParticipantStateChange();
|
||||
};
|
||||
|
||||
const onTrackSubscribed = (
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
|
|
@ -1526,7 +1609,7 @@ export function useInitializeCall() {
|
|||
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
||||
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
||||
room.on(RoomEvent.TrackPublished, onTrackPublished);
|
||||
room.on(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
||||
room.on(RoomEvent.TrackUnpublished, onTrackUnpublished);
|
||||
room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||
|
|
@ -1547,7 +1630,7 @@ export function useInitializeCall() {
|
|||
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
||||
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
||||
room.off(RoomEvent.TrackPublished, onTrackPublished);
|
||||
room.off(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
||||
room.off(RoomEvent.TrackUnpublished, onTrackUnpublished);
|
||||
room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||
|
|
|
|||
|
|
@ -571,8 +571,10 @@ export default function MessageContextMenu({
|
|||
|
||||
function selectReaction(emoji: string) {
|
||||
void onReact(emoji);
|
||||
setActiveMiniMenu(null);
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerVisibility(false);
|
||||
setPickerOpen(false);
|
||||
setPickerClosing(false);
|
||||
}
|
||||
function setPickerVisibility(open: boolean) {
|
||||
setPickerOpen(open);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useSession } from "@tensamin/storage/session";
|
|||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { decryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import { playSound } from "@tensamin/shared/sounds";
|
||||
import { type RawMessage } from "@tensamin/chat/values";
|
||||
|
||||
export const context = createContext<contextType | undefined>(undefined);
|
||||
|
|
@ -44,6 +45,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
|||
const chatSecret = await getChatSecret(data.SenderId);
|
||||
|
||||
if (!data.Message || !chatSecret) return;
|
||||
playSound("message");
|
||||
const user = await get(data.SenderId);
|
||||
|
||||
void decryptChatText(chatSecret, data.Message.Content)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Cache from "./pages/cache";
|
||||
import Call from "./pages/call";
|
||||
import Chat from "./pages/chat";
|
||||
import Index from "./pages/index";
|
||||
import Licenses from "./pages/licenses";
|
||||
|
|
@ -21,6 +22,7 @@ export const settingsPages = [
|
|||
component: Security,
|
||||
},
|
||||
{ category: "general", path: "chat", label: "Chat", component: Chat },
|
||||
{ category: "general", path: "call", label: "Call", component: Call },
|
||||
{ category: "application", path: "cache", label: "Cache", component: Cache },
|
||||
{ category: "application", path: "theme", label: "Theme", component: Theme },
|
||||
{
|
||||
|
|
|
|||
51
packages/settings/src/pages/call.tsx
Normal file
51
packages/settings/src/pages/call.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import {
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@methanium/ui";
|
||||
import { settingsStorageDefaults } from "@tensamin/shared/settings";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type CallJingle = (typeof settingsStorageDefaults)["settings.call_jingle"];
|
||||
|
||||
const jingleLabels: Record<CallJingle, string> = {
|
||||
jingle_1: "Jingle 1",
|
||||
jingle_2: "Jingle 2",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { load, save } = useStorage();
|
||||
const [jingle, setJingle] = useState<CallJingle>(
|
||||
settingsStorageDefaults["settings.call_jingle"],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load("settings.call_jingle").then(setJingle);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex max-w-md flex-col gap-2">
|
||||
<Label htmlFor="call-jingle">Outgoing call jingle</Label>
|
||||
<Select
|
||||
value={jingle}
|
||||
onValueChange={(value) => {
|
||||
const nextJingle = value ?? "jingle_1";
|
||||
setJingle(nextJingle);
|
||||
void save("settings.call_jingle", nextJingle);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full" id="call-jingle">
|
||||
<SelectValue>{jingleLabels[jingle]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="jingle_1">Jingle 1</SelectItem>
|
||||
<SelectItem value="jingle_2">Jingle 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
"./log": "./src/log.tsx",
|
||||
"./indexedDb": "./src/indexedDb.ts",
|
||||
"./settings": "./src/settings.ts",
|
||||
"./sounds": "./src/sounds.ts",
|
||||
"./features/legal/schema": "./src/features/legal/schema.ts",
|
||||
"./features/conversation/schema": "./src/features/conversation/schema.ts"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,6 +45,15 @@ const settings = {
|
|||
},
|
||||
},
|
||||
},
|
||||
call: {
|
||||
call: {
|
||||
call_jingle: {
|
||||
display: "Jingle",
|
||||
type: "select",
|
||||
default: "jingle_1" as "jingle_1" | "jingle_2",
|
||||
},
|
||||
},
|
||||
},
|
||||
application: {
|
||||
cache: {},
|
||||
theme: {},
|
||||
|
|
@ -55,26 +64,35 @@ const settings = {
|
|||
// Assemble storage defaults
|
||||
export default settings;
|
||||
|
||||
type BooleanSettingNames<T extends SettingsSchema> = {
|
||||
type SettingStorageEntry<T extends SettingsSchema> = {
|
||||
[C in StringKeyOf<T>]: {
|
||||
[P in StringKeyOf<T[C]>]: {
|
||||
[S in StringKeyOf<T[C][P]>]: T[C][P][S] extends {
|
||||
type: "boolean";
|
||||
default: boolean;
|
||||
}
|
||||
? S
|
||||
[S in StringKeyOf<T[C][P]>]: T[C][P][S] extends { default: infer V }
|
||||
? { key: `settings.${S}`; value: V }
|
||||
: never;
|
||||
}[StringKeyOf<T[C][P]>];
|
||||
}[StringKeyOf<T[C]>];
|
||||
}[StringKeyOf<T>];
|
||||
|
||||
type Widen<T> = T extends boolean
|
||||
? boolean
|
||||
: T extends string
|
||||
? string extends T
|
||||
? string
|
||||
: T
|
||||
: T extends number
|
||||
? number
|
||||
: T;
|
||||
|
||||
export type SettingsStorageKey<T extends SettingsSchema = typeof settings> =
|
||||
`settings.${BooleanSettingNames<T>}`;
|
||||
SettingStorageEntry<T>["key"];
|
||||
|
||||
export type SettingsStorageDefaults<
|
||||
T extends SettingsSchema = typeof settings,
|
||||
> = {
|
||||
[K in SettingsStorageKey<T>]: boolean;
|
||||
[K in SettingsStorageKey<T>]: Widen<
|
||||
Extract<SettingStorageEntry<T>, { key: K }>["value"]
|
||||
>;
|
||||
};
|
||||
|
||||
function buildSettingsStorageDefaults<T extends SettingsSchema>(
|
||||
|
|
@ -85,12 +103,10 @@ function buildSettingsStorageDefaults<T extends SettingsSchema>(
|
|||
for (const category of Object.values(schema)) {
|
||||
for (const page of Object.values(category)) {
|
||||
for (const [settingName, setting] of Object.entries(page)) {
|
||||
if (
|
||||
setting.type === "boolean" &&
|
||||
typeof setting.default === "boolean"
|
||||
) {
|
||||
if ("default" in setting) {
|
||||
const key = `settings.${settingName}` as SettingsStorageKey<T>;
|
||||
defaults[key] = setting.default;
|
||||
defaults[key] =
|
||||
setting.default as SettingsStorageDefaults<T>[typeof key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
packages/shared/src/sounds.ts
Normal file
34
packages/shared/src/sounds.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export type SoundName =
|
||||
| "call_jingle_1"
|
||||
| "call_jingle_2"
|
||||
| "call_join"
|
||||
| "call_leave"
|
||||
| "message"
|
||||
| "stream_end_other"
|
||||
| "stream_end_self"
|
||||
| "stream_start_other"
|
||||
| "stream_start_self"
|
||||
| "stream_watch_end"
|
||||
| "stream_watch_start";
|
||||
|
||||
function soundUrl(sound: SoundName) {
|
||||
if (window.location.protocol === "file:") {
|
||||
return new URL(`./sounds/${sound}.wav`, document.baseURI).href;
|
||||
}
|
||||
|
||||
return `/sounds/${sound}.wav`;
|
||||
}
|
||||
|
||||
export function playSound(sound: SoundName, loop = false) {
|
||||
const audio = new Audio(soundUrl(sound));
|
||||
audio.loop = loop;
|
||||
void audio.play().catch(() => undefined);
|
||||
return audio;
|
||||
}
|
||||
|
||||
export function stopSound(audio: HTMLAudioElement | null) {
|
||||
if (!audio) return;
|
||||
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
Loading…
Reference in a new issue