diff --git a/apps/web/public/sounds/call_jingle_1.wav b/apps/web/public/sounds/call_jingle_1.wav new file mode 100644 index 0000000..93a23b5 Binary files /dev/null and b/apps/web/public/sounds/call_jingle_1.wav differ diff --git a/apps/web/public/sounds/call_jingle_2.wav b/apps/web/public/sounds/call_jingle_2.wav new file mode 100644 index 0000000..33d9efd Binary files /dev/null and b/apps/web/public/sounds/call_jingle_2.wav differ diff --git a/apps/web/public/sounds/call_join.wav b/apps/web/public/sounds/call_join.wav new file mode 100644 index 0000000..b135ab7 Binary files /dev/null and b/apps/web/public/sounds/call_join.wav differ diff --git a/apps/web/public/sounds/call_leave.wav b/apps/web/public/sounds/call_leave.wav new file mode 100644 index 0000000..eb7f53b Binary files /dev/null and b/apps/web/public/sounds/call_leave.wav differ diff --git a/apps/web/public/sounds/message.wav b/apps/web/public/sounds/message.wav new file mode 100644 index 0000000..59ecedf Binary files /dev/null and b/apps/web/public/sounds/message.wav differ diff --git a/apps/web/public/sounds/stream_end_other.wav b/apps/web/public/sounds/stream_end_other.wav new file mode 100644 index 0000000..4177d12 Binary files /dev/null and b/apps/web/public/sounds/stream_end_other.wav differ diff --git a/apps/web/public/sounds/stream_end_self.wav b/apps/web/public/sounds/stream_end_self.wav new file mode 100644 index 0000000..8a78493 Binary files /dev/null and b/apps/web/public/sounds/stream_end_self.wav differ diff --git a/apps/web/public/sounds/stream_start_other.wav b/apps/web/public/sounds/stream_start_other.wav new file mode 100644 index 0000000..bca4724 Binary files /dev/null and b/apps/web/public/sounds/stream_start_other.wav differ diff --git a/apps/web/public/sounds/stream_start_self.wav b/apps/web/public/sounds/stream_start_self.wav new file mode 100644 index 0000000..5e57021 Binary files /dev/null and b/apps/web/public/sounds/stream_start_self.wav differ diff --git a/apps/web/public/sounds/stream_watch_end.wav b/apps/web/public/sounds/stream_watch_end.wav new file mode 100644 index 0000000..e81557a Binary files /dev/null and b/apps/web/public/sounds/stream_watch_end.wav differ diff --git a/apps/web/public/sounds/stream_watch_start.wav b/apps/web/public/sounds/stream_watch_start.wav new file mode 100644 index 0000000..acf863d Binary files /dev/null and b/apps/web/public/sounds/stream_watch_start.wav differ diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 883150c..7ce9121 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -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(); +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 { 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); diff --git a/packages/chat/src/components/messageContextMenu.tsx b/packages/chat/src/components/messageContextMenu.tsx index dec94b5..227afbb 100644 --- a/packages/chat/src/components/messageContextMenu.tsx +++ b/packages/chat/src/components/messageContextMenu.tsx @@ -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); diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx index f2172af..0958fd5 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -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(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) diff --git a/packages/settings/src/manifest.ts b/packages/settings/src/manifest.ts index 3002928..5ca92c7 100644 --- a/packages/settings/src/manifest.ts +++ b/packages/settings/src/manifest.ts @@ -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 }, { diff --git a/packages/settings/src/pages/call.tsx b/packages/settings/src/pages/call.tsx new file mode 100644 index 0000000..0583a95 --- /dev/null +++ b/packages/settings/src/pages/call.tsx @@ -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 = { + jingle_1: "Jingle 1", + jingle_2: "Jingle 2", +}; + +export default function Page() { + const { load, save } = useStorage(); + const [jingle, setJingle] = useState( + settingsStorageDefaults["settings.call_jingle"], + ); + + useEffect(() => { + void load("settings.call_jingle").then(setJingle); + }, [load]); + + return ( +
+ + +
+ ); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 34a60ca..5bfb379 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" }, diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts index b550151..0c3116b 100644 --- a/packages/shared/src/settings.ts +++ b/packages/shared/src/settings.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 = { +type SettingStorageEntry = { [C in StringKeyOf]: { [P in StringKeyOf]: { - [S in StringKeyOf]: T[C][P][S] extends { - type: "boolean"; - default: boolean; - } - ? S + [S in StringKeyOf]: T[C][P][S] extends { default: infer V } + ? { key: `settings.${S}`; value: V } : never; }[StringKeyOf]; }[StringKeyOf]; }[StringKeyOf]; +type Widen = T extends boolean + ? boolean + : T extends string + ? string extends T + ? string + : T + : T extends number + ? number + : T; + export type SettingsStorageKey = - `settings.${BooleanSettingNames}`; + SettingStorageEntry["key"]; export type SettingsStorageDefaults< T extends SettingsSchema = typeof settings, > = { - [K in SettingsStorageKey]: boolean; + [K in SettingsStorageKey]: Widen< + Extract, { key: K }>["value"] + >; }; function buildSettingsStorageDefaults( @@ -85,12 +103,10 @@ function buildSettingsStorageDefaults( 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; - defaults[key] = setting.default; + defaults[key] = + setting.default as SettingsStorageDefaults[typeof key]; } } } diff --git a/packages/shared/src/sounds.ts b/packages/shared/src/sounds.ts new file mode 100644 index 0000000..b75713b --- /dev/null +++ b/packages/shared/src/sounds.ts @@ -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; +}