(feat): big call and chatting stuff #23
19 changed files with 207 additions and 16 deletions
commit
184b85190b
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 { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { mtp } from "@tensamin/shared/data";
|
import { mtp } from "@tensamin/shared/data";
|
||||||
|
import { playSound, stopSound } from "@tensamin/shared/sounds";
|
||||||
import { bytesToBase64 } from "mtp";
|
import { bytesToBase64 } from "mtp";
|
||||||
import {
|
import {
|
||||||
deriveCallSecretId,
|
deriveCallSecretId,
|
||||||
|
|
@ -154,6 +155,8 @@ export function getRoom(): Room {
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteAudioElements = new Map<string, HTMLMediaElement>();
|
const remoteAudioElements = new Map<string, HTMLMediaElement>();
|
||||||
|
let outgoingJingle: HTMLAudioElement | null = null;
|
||||||
|
let outgoingJingleGeneration = 0;
|
||||||
|
|
||||||
const CALL_SECRET_VERSION = 1;
|
const CALL_SECRET_VERSION = 1;
|
||||||
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
|
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_QUALITY = 0.7;
|
||||||
const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000;
|
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> {
|
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
return new Uint8Array(bytes);
|
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.
|
// Start tracking a participant's shared screen in the call UI.
|
||||||
export function startWatchingStream(participantId: number) {
|
export function startWatchingStream(participantId: number) {
|
||||||
const trackReady = getScreenShareTrackForParticipant(participantId) != null;
|
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.ScreenShare);
|
||||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio);
|
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.
|
// Stop tracking a participant's shared screen and clean up related UI state.
|
||||||
export function stopWatchingStream(participantId: number) {
|
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, Track.Source.ScreenShare, false);
|
||||||
setParticipantTrackSubscribed(
|
setParticipantTrackSubscribed(
|
||||||
participantId,
|
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.
|
// Tear down the active call session and return the store to a closed state.
|
||||||
export async function disconnect() {
|
export async function disconnect() {
|
||||||
|
stopOutgoingJingle();
|
||||||
disposeSpeakingDetector();
|
disposeSpeakingDetector();
|
||||||
await clearScreenSharePreview();
|
await clearScreenSharePreview();
|
||||||
|
|
||||||
|
|
@ -939,6 +992,10 @@ export async function joinCall(
|
||||||
ownCallSecretInvitePending: isNewCall,
|
ownCallSecretInvitePending: isNewCall,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (sendInvite && !existingCallId) {
|
||||||
|
void startOutgoingJingle();
|
||||||
|
}
|
||||||
|
|
||||||
if (callSecret) {
|
if (callSecret) {
|
||||||
try {
|
try {
|
||||||
if (!existingCallId) {
|
if (!existingCallId) {
|
||||||
|
|
@ -1303,6 +1360,7 @@ export function useInitializeCall() {
|
||||||
|
|
||||||
const onConnected = async () => {
|
const onConnected = async () => {
|
||||||
useCall.setState({ state: "open" });
|
useCall.setState({ state: "open" });
|
||||||
|
playSound("call_join");
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
|
|
||||||
const detector = getSpeakingDetector();
|
const detector = getSpeakingDetector();
|
||||||
|
|
@ -1376,6 +1434,8 @@ export function useInitializeCall() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDisconnected = () => {
|
const onDisconnected = () => {
|
||||||
|
stopOutgoingJingle();
|
||||||
|
playSound("call_leave");
|
||||||
useCall.setState({ state: "closed" });
|
useCall.setState({ state: "closed" });
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
log(2, "call", "purple", "Disconnected from call", {
|
log(2, "call", "purple", "Disconnected from call", {
|
||||||
|
|
@ -1385,11 +1445,14 @@ export function useInitializeCall() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParticipantConnected = () => {
|
const onParticipantConnected = () => {
|
||||||
|
stopOutgoingJingle();
|
||||||
|
playSound("call_join");
|
||||||
syncAllRemoteTrackSubscriptions();
|
syncAllRemoteTrackSubscriptions();
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParticipantDisconnected = (participant: Participant) => {
|
const onParticipantDisconnected = (participant: Participant) => {
|
||||||
|
playSound("call_leave");
|
||||||
const participantId = getParticipantId(participant.identity);
|
const participantId = getParticipantId(participant.identity);
|
||||||
|
|
||||||
if (participantId != null) {
|
if (participantId != null) {
|
||||||
|
|
@ -1416,6 +1479,10 @@ export function useInitializeCall() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLocalTrackPublished = (publication: LocalTrackPublication) => {
|
const onLocalTrackPublished = (publication: LocalTrackPublication) => {
|
||||||
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
playSound("stream_start_self");
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
publication.kind === Track.Kind.Audio &&
|
publication.kind === Track.Kind.Audio &&
|
||||||
publication.source === Track.Source.Microphone &&
|
publication.source === Track.Source.Microphone &&
|
||||||
|
|
@ -1437,6 +1504,10 @@ export function useInitializeCall() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
|
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
|
||||||
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
playSound("stream_end_self");
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
publication.kind === Track.Kind.Audio &&
|
publication.kind === Track.Kind.Audio &&
|
||||||
publication.source === Track.Source.Microphone
|
publication.source === Track.Source.Microphone
|
||||||
|
|
@ -1453,6 +1524,10 @@ export function useInitializeCall() {
|
||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
) => {
|
) => {
|
||||||
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
playSound("stream_start_other");
|
||||||
|
}
|
||||||
|
|
||||||
const participantId = getParticipantId(participant.identity);
|
const participantId = getParticipantId(participant.identity);
|
||||||
|
|
||||||
if (participantId != null) {
|
if (participantId != null) {
|
||||||
|
|
@ -1469,6 +1544,14 @@ export function useInitializeCall() {
|
||||||
onParticipantStateChange();
|
onParticipantStateChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onTrackUnpublished = (publication: RemoteTrackPublication) => {
|
||||||
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
playSound("stream_end_other");
|
||||||
|
}
|
||||||
|
|
||||||
|
onParticipantStateChange();
|
||||||
|
};
|
||||||
|
|
||||||
const onTrackSubscribed = (
|
const onTrackSubscribed = (
|
||||||
track: RemoteTrack,
|
track: RemoteTrack,
|
||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
|
|
@ -1526,7 +1609,7 @@ export function useInitializeCall() {
|
||||||
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
||||||
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
||||||
room.on(RoomEvent.TrackPublished, onTrackPublished);
|
room.on(RoomEvent.TrackPublished, onTrackPublished);
|
||||||
room.on(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
room.on(RoomEvent.TrackUnpublished, onTrackUnpublished);
|
||||||
room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
|
room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||||
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||||
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||||
|
|
@ -1547,7 +1630,7 @@ export function useInitializeCall() {
|
||||||
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
||||||
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
||||||
room.off(RoomEvent.TrackPublished, onTrackPublished);
|
room.off(RoomEvent.TrackPublished, onTrackPublished);
|
||||||
room.off(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
room.off(RoomEvent.TrackUnpublished, onTrackUnpublished);
|
||||||
room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
|
room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||||
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||||
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||||
|
|
|
||||||
|
|
@ -571,8 +571,10 @@ export default function MessageContextMenu({
|
||||||
|
|
||||||
function selectReaction(emoji: string) {
|
function selectReaction(emoji: string) {
|
||||||
void onReact(emoji);
|
void onReact(emoji);
|
||||||
|
setActiveMiniMenu(null);
|
||||||
setReactionDrawerOpen(false);
|
setReactionDrawerOpen(false);
|
||||||
setPickerVisibility(false);
|
setPickerOpen(false);
|
||||||
|
setPickerClosing(false);
|
||||||
}
|
}
|
||||||
function setPickerVisibility(open: boolean) {
|
function setPickerVisibility(open: boolean) {
|
||||||
setPickerOpen(open);
|
setPickerOpen(open);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useSession } from "@tensamin/storage/session";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { decryptChatText } from "@tensamin/crypto/chatSecret";
|
import { decryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import { playSound } from "@tensamin/shared/sounds";
|
||||||
import { type RawMessage } from "@tensamin/chat/values";
|
import { type RawMessage } from "@tensamin/chat/values";
|
||||||
|
|
||||||
export const context = createContext<contextType | undefined>(undefined);
|
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);
|
const chatSecret = await getChatSecret(data.SenderId);
|
||||||
|
|
||||||
if (!data.Message || !chatSecret) return;
|
if (!data.Message || !chatSecret) return;
|
||||||
|
playSound("message");
|
||||||
const user = await get(data.SenderId);
|
const user = await get(data.SenderId);
|
||||||
|
|
||||||
void decryptChatText(chatSecret, data.Message.Content)
|
void decryptChatText(chatSecret, data.Message.Content)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Cache from "./pages/cache";
|
import Cache from "./pages/cache";
|
||||||
|
import Call from "./pages/call";
|
||||||
import Chat from "./pages/chat";
|
import Chat from "./pages/chat";
|
||||||
import Index from "./pages/index";
|
import Index from "./pages/index";
|
||||||
import Licenses from "./pages/licenses";
|
import Licenses from "./pages/licenses";
|
||||||
|
|
@ -21,6 +22,7 @@ export const settingsPages = [
|
||||||
component: Security,
|
component: Security,
|
||||||
},
|
},
|
||||||
{ category: "general", path: "chat", label: "Chat", component: Chat },
|
{ 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: "cache", label: "Cache", component: Cache },
|
||||||
{ category: "application", path: "theme", label: "Theme", component: Theme },
|
{ 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",
|
"./log": "./src/log.tsx",
|
||||||
"./indexedDb": "./src/indexedDb.ts",
|
"./indexedDb": "./src/indexedDb.ts",
|
||||||
"./settings": "./src/settings.ts",
|
"./settings": "./src/settings.ts",
|
||||||
|
"./sounds": "./src/sounds.ts",
|
||||||
"./features/legal/schema": "./src/features/legal/schema.ts",
|
"./features/legal/schema": "./src/features/legal/schema.ts",
|
||||||
"./features/conversation/schema": "./src/features/conversation/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: {
|
application: {
|
||||||
cache: {},
|
cache: {},
|
||||||
theme: {},
|
theme: {},
|
||||||
|
|
@ -55,26 +64,35 @@ const settings = {
|
||||||
// Assemble storage defaults
|
// Assemble storage defaults
|
||||||
export default settings;
|
export default settings;
|
||||||
|
|
||||||
type BooleanSettingNames<T extends SettingsSchema> = {
|
type SettingStorageEntry<T extends SettingsSchema> = {
|
||||||
[C in StringKeyOf<T>]: {
|
[C in StringKeyOf<T>]: {
|
||||||
[P in StringKeyOf<T[C]>]: {
|
[P in StringKeyOf<T[C]>]: {
|
||||||
[S in StringKeyOf<T[C][P]>]: T[C][P][S] extends {
|
[S in StringKeyOf<T[C][P]>]: T[C][P][S] extends { default: infer V }
|
||||||
type: "boolean";
|
? { key: `settings.${S}`; value: V }
|
||||||
default: boolean;
|
|
||||||
}
|
|
||||||
? S
|
|
||||||
: never;
|
: never;
|
||||||
}[StringKeyOf<T[C][P]>];
|
}[StringKeyOf<T[C][P]>];
|
||||||
}[StringKeyOf<T[C]>];
|
}[StringKeyOf<T[C]>];
|
||||||
}[StringKeyOf<T>];
|
}[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> =
|
export type SettingsStorageKey<T extends SettingsSchema = typeof settings> =
|
||||||
`settings.${BooleanSettingNames<T>}`;
|
SettingStorageEntry<T>["key"];
|
||||||
|
|
||||||
export type SettingsStorageDefaults<
|
export type SettingsStorageDefaults<
|
||||||
T extends SettingsSchema = typeof settings,
|
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>(
|
function buildSettingsStorageDefaults<T extends SettingsSchema>(
|
||||||
|
|
@ -85,12 +103,10 @@ function buildSettingsStorageDefaults<T extends SettingsSchema>(
|
||||||
for (const category of Object.values(schema)) {
|
for (const category of Object.values(schema)) {
|
||||||
for (const page of Object.values(category)) {
|
for (const page of Object.values(category)) {
|
||||||
for (const [settingName, setting] of Object.entries(page)) {
|
for (const [settingName, setting] of Object.entries(page)) {
|
||||||
if (
|
if ("default" in setting) {
|
||||||
setting.type === "boolean" &&
|
|
||||||
typeof setting.default === "boolean"
|
|
||||||
) {
|
|
||||||
const key = `settings.${settingName}` as SettingsStorageKey<T>;
|
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