(feat): add sounds
Some checks failed
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled

This commit is contained in:
Alois 2026-07-30 23:51:38 +02:00
commit 184b85190b
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
19 changed files with 207 additions and 16 deletions

View file

@ -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);

View file

@ -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);

View file

@ -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)

View file

@ -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 },
{

View 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>
);
}

View file

@ -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"
},

View file

@ -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];
}
}
}

View 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;
}