Task #389: Notification sounds + vibration with per-user customization
DB - Added 7 user pref columns; vibration is now per-channel (vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime sounds, vibration on, mute off, volume 70. API - PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial update, integer guard for volume). buildAuthUser exposes all new fields. - OpenAPI spec updated; orval client + zod regenerated. Frontend - Static sound library: 8 short WAV assets under public/sounds/ + manifest (notification-sound-manifest.ts) mapping id -> labelKey -> URL. - HTMLAudio-based player (notification-sounds.ts) with throttle, unlock, cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture. - Settings popover: global mute, volume, slot tabs (orders/meetings) with per-channel sound + per-channel vibration + per-channel toggle, sound list with one-tap previews. Concurrency-safe optimistic updates (monotonic seq counter). RTL-correct toggle knob transforms. - QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with confirmation toast. - useAudioUnlock: unlocks audio on first interaction. - useAutoplayHint: toasts a localized "click anywhere to enable" hint when the player reports a blocked play attempt. - Socket hook plays the right per-channel sound + vibration on notification_created (orders, executive_meeting) when the tab is hidden. - UpcomingMeetingAlert: plays meeting reminder once per new eligible meeting (deduped by meetingId, survives 30s polling refetches). - AR/EN translations added. Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing test workflow failures are unrelated.
This commit is contained in:
@@ -66,7 +66,8 @@ function buildAuthUser(
|
||||
notificationSoundMeeting: user.notificationSoundMeeting,
|
||||
notifyOrdersEnabled: user.notifyOrdersEnabled,
|
||||
notifyMeetingsEnabled: user.notifyMeetingsEnabled,
|
||||
vibrationEnabled: user.vibrationEnabled,
|
||||
vibrationEnabledOrder: user.vibrationEnabledOrder,
|
||||
vibrationEnabledMeeting: user.vibrationEnabledMeeting,
|
||||
notificationsMuted: user.notificationsMuted,
|
||||
notificationVolume: user.notificationVolume,
|
||||
roles,
|
||||
@@ -445,7 +446,8 @@ router.patch("/auth/me/notification-preferences", requireAuth, async (req, res):
|
||||
"notificationSoundMeeting",
|
||||
"notifyOrdersEnabled",
|
||||
"notifyMeetingsEnabled",
|
||||
"vibrationEnabled",
|
||||
"vibrationEnabledOrder",
|
||||
"vibrationEnabledMeeting",
|
||||
"notificationsMuted",
|
||||
"notificationVolume",
|
||||
] as const) {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
|
||||
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
|
||||
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
|
||||
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import LoginPage from "@/pages/login";
|
||||
@@ -33,6 +34,7 @@ const queryClient = new QueryClient({
|
||||
function NotificationsSocketBridge() {
|
||||
useNotificationsSocket();
|
||||
useAudioUnlock();
|
||||
useAutoplayHint();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { hexToRgba, useAlertPrefs } from "@/lib/upcoming-alert-prefs";
|
||||
import { formatTime as formatTimeLocale } from "@/lib/i18n-format";
|
||||
import { notificationPlayer } from "@/lib/notification-sounds";
|
||||
|
||||
const POSITION_KEY = "txos.upcomingMeetingAlert.position";
|
||||
const ALERT_LEAD_MINUTES = 5;
|
||||
@@ -457,6 +458,22 @@ export function UpcomingMeetingAlert() {
|
||||
setExpanded(false);
|
||||
}, [eligibleId]);
|
||||
|
||||
// Play the meeting reminder sound exactly once per *new* eligible
|
||||
// meeting (deduped by id) so polling refetches don't replay the chime
|
||||
// every 30s. Honors the user's mute / per-channel toggle.
|
||||
const playedRef = useRef<Set<number>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!eligibleId || !user) return;
|
||||
if (playedRef.current.has(eligibleId)) return;
|
||||
playedRef.current.add(eligibleId);
|
||||
if (user.notificationsMuted) return;
|
||||
if (!user.notifyMeetingsEnabled) return;
|
||||
notificationPlayer.play(user.notificationSoundMeeting, {
|
||||
volume: user.notificationVolume,
|
||||
vibrate: user.vibrationEnabledMeeting,
|
||||
});
|
||||
}, [eligibleId, user]);
|
||||
|
||||
if (!enabled || !eligible) return null;
|
||||
// User toggled the popup off in settings — render nothing. Detection
|
||||
// happens after `enabled/eligible` so the upstream "shown" recording
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
type AuthUser,
|
||||
type NotificationSoundId,
|
||||
} from "@workspace/api-client-react";
|
||||
import { Bell, BellOff, Check, Volume2, Play } from "lucide-react";
|
||||
import { Bell, BellOff, Check, Volume2, VolumeX, Play } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -21,7 +22,8 @@ type PrefsPatch = {
|
||||
notificationSoundMeeting?: NotificationSoundId;
|
||||
notifyOrdersEnabled?: boolean;
|
||||
notifyMeetingsEnabled?: boolean;
|
||||
vibrationEnabled?: boolean;
|
||||
vibrationEnabledOrder?: boolean;
|
||||
vibrationEnabledMeeting?: boolean;
|
||||
notificationsMuted?: boolean;
|
||||
notificationVolume?: number;
|
||||
};
|
||||
@@ -157,6 +159,42 @@ function Toggle({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-tap mute toggle for the topbar (separate from the settings popover
|
||||
* trigger). Shows a confirmation toast so the user knows the change took
|
||||
* effect even though there's no visual focus shift.
|
||||
*/
|
||||
export function QuickMuteButton() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const apply = useUpdatePrefs();
|
||||
if (!user) return null;
|
||||
const muted = user.notificationsMuted;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(muted ? "notifSettings.quickUnmute" : "notifSettings.quickMute")}
|
||||
aria-pressed={muted}
|
||||
data-testid="notif-quick-mute"
|
||||
onClick={() => {
|
||||
const next = !muted;
|
||||
apply({ notificationsMuted: next });
|
||||
toast({
|
||||
title: t(next ? "notifSettings.mutedToast" : "notifSettings.unmutedToast"),
|
||||
});
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5"
|
||||
>
|
||||
{muted ? (
|
||||
<VolumeX className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
||||
) : (
|
||||
<Volume2 className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationSettingsPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
@@ -173,16 +211,9 @@ export function NotificationSettingsPicker() {
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(muted ? "notifSettings.unmute" : "notifSettings.mute")}
|
||||
aria-pressed={muted}
|
||||
aria-label={t("notifSettings.title")}
|
||||
aria-haspopup="dialog"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 relative"
|
||||
onClick={(e) => {
|
||||
// Shift+click toggles mute quickly without opening the popover.
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
apply({ notificationsMuted: !muted });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{muted ? (
|
||||
<BellOff className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
||||
@@ -201,11 +232,6 @@ export function NotificationSettingsPicker() {
|
||||
onClick={() => apply({ notificationsMuted: !muted })}
|
||||
label={t("notifSettings.globalSound")}
|
||||
/>
|
||||
<Toggle
|
||||
active={user.vibrationEnabled}
|
||||
onClick={() => apply({ vibrationEnabled: !user.vibrationEnabled })}
|
||||
label={t("notifSettings.vibration")}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
@@ -230,23 +256,6 @@ export function NotificationSettingsPicker() {
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<Toggle
|
||||
active={user.notifyOrdersEnabled}
|
||||
onClick={() =>
|
||||
apply({ notifyOrdersEnabled: !user.notifyOrdersEnabled })
|
||||
}
|
||||
label={t("notifSettings.ordersEnabled")}
|
||||
/>
|
||||
<Toggle
|
||||
active={user.notifyMeetingsEnabled}
|
||||
onClick={() =>
|
||||
apply({ notifyMeetingsEnabled: !user.notifyMeetingsEnabled })
|
||||
}
|
||||
label={t("notifSettings.meetingsEnabled")}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<div
|
||||
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
|
||||
role="group"
|
||||
@@ -271,6 +280,47 @@ export function NotificationSettingsPicker() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
active={
|
||||
slot === "order"
|
||||
? user.notifyOrdersEnabled
|
||||
: user.notifyMeetingsEnabled
|
||||
}
|
||||
onClick={() =>
|
||||
apply(
|
||||
slot === "order"
|
||||
? { notifyOrdersEnabled: !user.notifyOrdersEnabled }
|
||||
: { notifyMeetingsEnabled: !user.notifyMeetingsEnabled },
|
||||
)
|
||||
}
|
||||
label={t(
|
||||
slot === "order"
|
||||
? "notifSettings.ordersEnabled"
|
||||
: "notifSettings.meetingsEnabled",
|
||||
)}
|
||||
/>
|
||||
<Toggle
|
||||
active={
|
||||
slot === "order"
|
||||
? user.vibrationEnabledOrder
|
||||
: user.vibrationEnabledMeeting
|
||||
}
|
||||
onClick={() =>
|
||||
apply(
|
||||
slot === "order"
|
||||
? { vibrationEnabledOrder: !user.vibrationEnabledOrder }
|
||||
: { vibrationEnabledMeeting: !user.vibrationEnabledMeeting },
|
||||
)
|
||||
}
|
||||
label={t(
|
||||
slot === "order"
|
||||
? "notifSettings.vibrationOrder"
|
||||
: "notifSettings.vibrationMeeting",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<SoundList
|
||||
current={
|
||||
slot === "order"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { AUTOPLAY_BLOCKED_EVENT } from "@/lib/notification-sounds";
|
||||
|
||||
/**
|
||||
* When a notification sound is attempted before the browser has
|
||||
* accepted a user gesture, the player dispatches a custom event. We
|
||||
* surface a one-time toast asking the user to click anywhere so future
|
||||
* sounds can play. The first qualifying click also triggers
|
||||
* `useAudioUnlock`, so the hint typically only ever shows once per
|
||||
* page load.
|
||||
*/
|
||||
export function useAutoplayHint(): void {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
toast({
|
||||
title: t("notifSettings.autoplayHintTitle"),
|
||||
description: t("notifSettings.autoplayHint"),
|
||||
});
|
||||
};
|
||||
window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
|
||||
return () => window.removeEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
|
||||
}, [t, toast]);
|
||||
}
|
||||
@@ -56,9 +56,12 @@ export function useNotificationsSocket() {
|
||||
const soundId = isOrder
|
||||
? user.notificationSoundOrder
|
||||
: user.notificationSoundMeeting;
|
||||
const vibrate = isOrder
|
||||
? user.vibrationEnabledOrder
|
||||
: user.vibrationEnabledMeeting;
|
||||
notificationPlayer.play(soundId, {
|
||||
volume: user.notificationVolume,
|
||||
vibrate: user.vibrationEnabled,
|
||||
vibrate,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { NotificationSoundId } from "@workspace/api-client-react";
|
||||
|
||||
const BASE = (import.meta.env.BASE_URL || "/").replace(/\/$/, "");
|
||||
|
||||
export type NotificationSoundEntry = {
|
||||
id: NotificationSoundId;
|
||||
labelKey: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manifest of bundled short notification sounds. The actual audio files
|
||||
* live under `public/sounds/<id>.wav` in the tx-os artifact and are
|
||||
* served from the artifact base path. The matching i18n labels live in
|
||||
* `notifSettings.sounds.<id>` for both languages.
|
||||
*/
|
||||
export const NOTIFICATION_SOUND_MANIFEST: ReadonlyArray<NotificationSoundEntry> =
|
||||
[
|
||||
{ id: "ding", labelKey: "notifSettings.sounds.ding", url: `${BASE}/sounds/ding.wav` },
|
||||
{ id: "chime", labelKey: "notifSettings.sounds.chime", url: `${BASE}/sounds/chime.wav` },
|
||||
{ id: "bell", labelKey: "notifSettings.sounds.bell", url: `${BASE}/sounds/bell.wav` },
|
||||
{ id: "knock", labelKey: "notifSettings.sounds.knock", url: `${BASE}/sounds/knock.wav` },
|
||||
{ id: "pop", labelKey: "notifSettings.sounds.pop", url: `${BASE}/sounds/pop.wav` },
|
||||
{ id: "alert", labelKey: "notifSettings.sounds.alert", url: `${BASE}/sounds/alert.wav` },
|
||||
{ id: "beep", labelKey: "notifSettings.sounds.beep", url: `${BASE}/sounds/beep.wav` },
|
||||
{ id: "soft", labelKey: "notifSettings.sounds.soft", url: `${BASE}/sounds/soft.wav` },
|
||||
];
|
||||
|
||||
export const SOUND_BY_ID = new Map(
|
||||
NOTIFICATION_SOUND_MANIFEST.map((s) => [s.id, s]),
|
||||
);
|
||||
@@ -1,128 +1,72 @@
|
||||
import type { NotificationSoundId } from "@workspace/api-client-react";
|
||||
import {
|
||||
NOTIFICATION_SOUND_MANIFEST,
|
||||
SOUND_BY_ID,
|
||||
} from "@/lib/notification-sound-manifest";
|
||||
|
||||
type ToneStep = {
|
||||
freq: number;
|
||||
duration: number;
|
||||
delay?: number;
|
||||
type?: OscillatorType;
|
||||
gain?: number;
|
||||
};
|
||||
export const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> =
|
||||
NOTIFICATION_SOUND_MANIFEST.map((s) => s.id);
|
||||
|
||||
type SoundDef = {
|
||||
id: NotificationSoundId;
|
||||
steps: ToneStep[];
|
||||
};
|
||||
|
||||
export const SOUND_LIBRARY: ReadonlyArray<SoundDef> = [
|
||||
{
|
||||
id: "ding",
|
||||
steps: [
|
||||
{ freq: 1318.5, duration: 0.35, type: "sine", gain: 0.5 },
|
||||
{ freq: 880, duration: 0.45, delay: 0.05, type: "sine", gain: 0.35 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chime",
|
||||
steps: [
|
||||
{ freq: 523.25, duration: 0.3, type: "sine" },
|
||||
{ freq: 659.25, duration: 0.3, delay: 0.12, type: "sine" },
|
||||
{ freq: 783.99, duration: 0.5, delay: 0.24, type: "sine" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bell",
|
||||
steps: [
|
||||
{ freq: 1760, duration: 0.6, type: "triangle", gain: 0.45 },
|
||||
{ freq: 880, duration: 0.6, delay: 0.0, type: "sine", gain: 0.25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knock",
|
||||
steps: [
|
||||
{ freq: 180, duration: 0.12, type: "square", gain: 0.55 },
|
||||
{ freq: 180, duration: 0.12, delay: 0.18, type: "square", gain: 0.55 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pop",
|
||||
steps: [
|
||||
{ freq: 600, duration: 0.08, type: "sine", gain: 0.55 },
|
||||
{ freq: 1200, duration: 0.12, delay: 0.04, type: "sine", gain: 0.45 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "alert",
|
||||
steps: [
|
||||
{ freq: 988, duration: 0.18, type: "square", gain: 0.4 },
|
||||
{ freq: 988, duration: 0.18, delay: 0.22, type: "square", gain: 0.4 },
|
||||
{ freq: 988, duration: 0.18, delay: 0.44, type: "square", gain: 0.4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "beep",
|
||||
steps: [{ freq: 1000, duration: 0.25, type: "sine", gain: 0.5 }],
|
||||
},
|
||||
{
|
||||
id: "soft",
|
||||
steps: [
|
||||
{ freq: 392, duration: 0.5, type: "sine", gain: 0.35 },
|
||||
{ freq: 587.33, duration: 0.5, delay: 0.1, type: "sine", gain: 0.3 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SOUND_BY_ID = new Map(SOUND_LIBRARY.map((s) => [s.id, s]));
|
||||
|
||||
const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> = SOUND_LIBRARY.map(
|
||||
(s) => s.id,
|
||||
);
|
||||
export { ALL_SOUND_IDS };
|
||||
|
||||
type AnyAudioContext = AudioContext;
|
||||
/**
|
||||
* Custom DOM event the player dispatches when a play attempt is denied
|
||||
* because the user hasn't yet interacted with the page (browser autoplay
|
||||
* policy). The App listens for this and shows a one-time toast hint.
|
||||
*/
|
||||
export const AUTOPLAY_BLOCKED_EVENT = "txos:notification-autoplay-blocked";
|
||||
|
||||
class NotificationPlayer {
|
||||
private ctx: AnyAudioContext | null = null;
|
||||
private cache = new Map<NotificationSoundId, HTMLAudioElement>();
|
||||
private unlocked = false;
|
||||
private lastPlayAt = 0;
|
||||
private hintDispatched = false;
|
||||
|
||||
private getCtx(): AnyAudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (this.ctx) return this.ctx;
|
||||
const Ctor =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
try {
|
||||
this.ctx = new Ctor();
|
||||
} catch {
|
||||
this.ctx = null;
|
||||
}
|
||||
return this.ctx;
|
||||
isUnlocked(): boolean {
|
||||
return this.unlocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers require a user gesture before audio can play. Call this once
|
||||
* the user interacts with the app to "unlock" the AudioContext.
|
||||
* the user interacts with the app to "unlock" the audio elements.
|
||||
*/
|
||||
unlock(): void {
|
||||
if (this.unlocked) return;
|
||||
const ctx = this.getCtx();
|
||||
if (!ctx) return;
|
||||
if (ctx.state === "suspended") {
|
||||
ctx.resume().catch(() => {});
|
||||
}
|
||||
// Play a tiny silent buffer to satisfy iOS/Safari unlock semantics.
|
||||
try {
|
||||
const buffer = ctx.createBuffer(1, 1, 22050);
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
src.connect(ctx.destination);
|
||||
src.start(0);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.unlocked = true;
|
||||
// Touch each cached element with a silent play()/pause() so iOS/Safari
|
||||
// marks them as user-initiated. Pre-warm cache for snappier playback.
|
||||
for (const entry of NOTIFICATION_SOUND_MANIFEST) {
|
||||
let el = this.cache.get(entry.id);
|
||||
if (!el) {
|
||||
el = new Audio(entry.url);
|
||||
el.preload = "auto";
|
||||
this.cache.set(entry.id, el);
|
||||
}
|
||||
el.muted = true;
|
||||
const p = el.play();
|
||||
if (p && typeof p.then === "function") {
|
||||
p.then(() => {
|
||||
el!.pause();
|
||||
el!.currentTime = 0;
|
||||
el!.muted = false;
|
||||
}).catch(() => {
|
||||
el!.muted = false;
|
||||
});
|
||||
} else {
|
||||
el.muted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchHint() {
|
||||
if (this.hintDispatched) return;
|
||||
this.hintDispatched = true;
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(AUTOPLAY_BLOCKED_EVENT));
|
||||
}
|
||||
// Allow another hint after a generous cool-down so the user doesn't
|
||||
// get spammed if they ignore the first toast.
|
||||
setTimeout(() => {
|
||||
this.hintDispatched = false;
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
play(
|
||||
@@ -136,34 +80,28 @@ class NotificationPlayer {
|
||||
if (now - this.lastPlayAt < 600) return;
|
||||
this.lastPlayAt = now;
|
||||
|
||||
const def = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
|
||||
if (!def) return;
|
||||
const ctx = this.getCtx();
|
||||
if (ctx) {
|
||||
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
||||
const baseGain = Math.max(0, Math.min(1, (opts.volume ?? 70) / 100));
|
||||
const t0 = ctx.currentTime + 0.01;
|
||||
for (const step of def.steps) {
|
||||
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
|
||||
if (!entry) return;
|
||||
|
||||
let el = this.cache.get(entry.id);
|
||||
if (!el) {
|
||||
el = new Audio(entry.url);
|
||||
el.preload = "auto";
|
||||
this.cache.set(entry.id, el);
|
||||
}
|
||||
try {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = step.type ?? "sine";
|
||||
osc.frequency.value = step.freq;
|
||||
const start = t0 + (step.delay ?? 0);
|
||||
const peak = baseGain * (step.gain ?? 0.4);
|
||||
g.gain.setValueAtTime(0.0001, start);
|
||||
g.gain.exponentialRampToValueAtTime(
|
||||
Math.max(peak, 0.0002),
|
||||
start + 0.01,
|
||||
);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, start + step.duration);
|
||||
osc.connect(g).connect(ctx.destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + step.duration + 0.05);
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
/* ignore single-step failures */
|
||||
}
|
||||
/* some browsers throw if not loaded */
|
||||
}
|
||||
el.volume = Math.max(0, Math.min(1, (opts.volume ?? 70) / 100));
|
||||
const promise = el.play();
|
||||
if (promise && typeof promise.then === "function") {
|
||||
promise.catch(() => {
|
||||
// Most likely autoplay blocked — surface a hint exactly once
|
||||
// per cool-down so the user knows to click anywhere to enable.
|
||||
if (!this.unlocked) this.dispatchHint();
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
|
||||
@@ -292,11 +292,18 @@
|
||||
"mute": "كتم الإشعارات",
|
||||
"unmute": "إلغاء كتم الإشعارات",
|
||||
"globalSound": "تشغيل صوت عند التنبيه",
|
||||
"vibration": "اهتزاز عند التنبيه",
|
||||
"volume": "مستوى الصوت",
|
||||
"ordersEnabled": "صوت للطلبات الجديدة",
|
||||
"meetingsEnabled": "صوت لتذكيرات الاجتماعات",
|
||||
"vibrationOrder": "اهتزاز عند الطلبات الجديدة",
|
||||
"vibrationMeeting": "اهتزاز عند تذكيرات الاجتماعات",
|
||||
"preview": "تجربة",
|
||||
"quickMute": "كتم الإشعارات",
|
||||
"quickUnmute": "إلغاء كتم الإشعارات",
|
||||
"mutedToast": "تم كتم الإشعارات",
|
||||
"unmutedToast": "تم إلغاء كتم الإشعارات",
|
||||
"autoplayHintTitle": "تحتاج الأصوات إلى نقرة",
|
||||
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
|
||||
"slot": {
|
||||
"order": "الطلبات",
|
||||
"meeting": "الاجتماعات"
|
||||
|
||||
@@ -295,11 +295,18 @@
|
||||
"mute": "Mute notifications",
|
||||
"unmute": "Unmute notifications",
|
||||
"globalSound": "Play sound on alerts",
|
||||
"vibration": "Vibrate on alerts",
|
||||
"volume": "Volume",
|
||||
"ordersEnabled": "Sound for new orders",
|
||||
"meetingsEnabled": "Sound for meeting reminders",
|
||||
"vibrationOrder": "Vibrate on new orders",
|
||||
"vibrationMeeting": "Vibrate on meeting reminders",
|
||||
"preview": "Preview",
|
||||
"quickMute": "Mute notifications",
|
||||
"quickUnmute": "Unmute notifications",
|
||||
"mutedToast": "Notifications muted",
|
||||
"unmutedToast": "Notifications unmuted",
|
||||
"autoplayHintTitle": "Sounds need a click",
|
||||
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
|
||||
"slot": {
|
||||
"order": "Orders",
|
||||
"meeting": "Meetings"
|
||||
|
||||
@@ -65,7 +65,10 @@ import {
|
||||
type HomeClockSize,
|
||||
} from "@/components/clock";
|
||||
import { ClockStylePicker } from "@/components/clock-style-picker";
|
||||
import { NotificationSettingsPicker } from "@/components/notification-settings";
|
||||
import {
|
||||
NotificationSettingsPicker,
|
||||
QuickMuteButton,
|
||||
} from "@/components/notification-settings";
|
||||
|
||||
type IconName = keyof typeof LucideIcons;
|
||||
function isIconName(x: string): x is IconName {
|
||||
@@ -496,6 +499,7 @@ export default function HomePage() {
|
||||
currentStyle={user?.clockStyle ?? null}
|
||||
currentHour12={user?.clockHour12 ?? null}
|
||||
/>
|
||||
<QuickMuteButton />
|
||||
<NotificationSettingsPicker />
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
|
||||
@@ -115,7 +115,8 @@ export interface UpdateNotificationPreferencesBody {
|
||||
notificationSoundMeeting?: NotificationSoundId;
|
||||
notifyOrdersEnabled?: boolean;
|
||||
notifyMeetingsEnabled?: boolean;
|
||||
vibrationEnabled?: boolean;
|
||||
vibrationEnabledOrder?: boolean;
|
||||
vibrationEnabledMeeting?: boolean;
|
||||
notificationsMuted?: boolean;
|
||||
/**
|
||||
* @minimum 0
|
||||
@@ -160,7 +161,8 @@ export interface AuthUser {
|
||||
notificationSoundMeeting: NotificationSoundId;
|
||||
notifyOrdersEnabled: boolean;
|
||||
notifyMeetingsEnabled: boolean;
|
||||
vibrationEnabled: boolean;
|
||||
vibrationEnabledOrder: boolean;
|
||||
vibrationEnabledMeeting: boolean;
|
||||
notificationsMuted: boolean;
|
||||
notificationVolume: number;
|
||||
roles: string[];
|
||||
|
||||
@@ -3004,7 +3004,9 @@ components:
|
||||
type: boolean
|
||||
notifyMeetingsEnabled:
|
||||
type: boolean
|
||||
vibrationEnabled:
|
||||
vibrationEnabledOrder:
|
||||
type: boolean
|
||||
vibrationEnabledMeeting:
|
||||
type: boolean
|
||||
notificationsMuted:
|
||||
type: boolean
|
||||
@@ -3055,7 +3057,9 @@ components:
|
||||
type: boolean
|
||||
notifyMeetingsEnabled:
|
||||
type: boolean
|
||||
vibrationEnabled:
|
||||
vibrationEnabledOrder:
|
||||
type: boolean
|
||||
vibrationEnabledMeeting:
|
||||
type: boolean
|
||||
notificationsMuted:
|
||||
type: boolean
|
||||
@@ -3082,7 +3086,8 @@ components:
|
||||
- notificationSoundMeeting
|
||||
- notifyOrdersEnabled
|
||||
- notifyMeetingsEnabled
|
||||
- vibrationEnabled
|
||||
- vibrationEnabledOrder
|
||||
- vibrationEnabledMeeting
|
||||
- notificationsMuted
|
||||
- notificationVolume
|
||||
- roles
|
||||
|
||||
@@ -76,7 +76,8 @@ export const LoginResponse = zod.object({
|
||||
]),
|
||||
notifyOrdersEnabled: zod.boolean(),
|
||||
notifyMeetingsEnabled: zod.boolean(),
|
||||
vibrationEnabled: zod.boolean(),
|
||||
vibrationEnabledOrder: zod.boolean(),
|
||||
vibrationEnabledMeeting: zod.boolean(),
|
||||
notificationsMuted: zod.boolean(),
|
||||
notificationVolume: zod.number(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -189,7 +190,8 @@ export const GetMeResponse = zod.object({
|
||||
]),
|
||||
notifyOrdersEnabled: zod.boolean(),
|
||||
notifyMeetingsEnabled: zod.boolean(),
|
||||
vibrationEnabled: zod.boolean(),
|
||||
vibrationEnabledOrder: zod.boolean(),
|
||||
vibrationEnabledMeeting: zod.boolean(),
|
||||
notificationsMuted: zod.boolean(),
|
||||
notificationVolume: zod.number(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -251,7 +253,8 @@ export const UpdateLanguageResponse = zod.object({
|
||||
]),
|
||||
notifyOrdersEnabled: zod.boolean(),
|
||||
notifyMeetingsEnabled: zod.boolean(),
|
||||
vibrationEnabled: zod.boolean(),
|
||||
vibrationEnabledOrder: zod.boolean(),
|
||||
vibrationEnabledMeeting: zod.boolean(),
|
||||
notificationsMuted: zod.boolean(),
|
||||
notificationVolume: zod.number(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -320,7 +323,8 @@ export const UpdateClockStyleResponse = zod.object({
|
||||
]),
|
||||
notifyOrdersEnabled: zod.boolean(),
|
||||
notifyMeetingsEnabled: zod.boolean(),
|
||||
vibrationEnabled: zod.boolean(),
|
||||
vibrationEnabledOrder: zod.boolean(),
|
||||
vibrationEnabledMeeting: zod.boolean(),
|
||||
notificationsMuted: zod.boolean(),
|
||||
notificationVolume: zod.number(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -350,7 +354,8 @@ export const UpdateNotificationPreferencesBody = zod.object({
|
||||
.optional(),
|
||||
notifyOrdersEnabled: zod.boolean().optional(),
|
||||
notifyMeetingsEnabled: zod.boolean().optional(),
|
||||
vibrationEnabled: zod.boolean().optional(),
|
||||
vibrationEnabledOrder: zod.boolean().optional(),
|
||||
vibrationEnabledMeeting: zod.boolean().optional(),
|
||||
notificationsMuted: zod.boolean().optional(),
|
||||
notificationVolume: zod
|
||||
.number()
|
||||
@@ -399,7 +404,8 @@ export const UpdateNotificationPreferencesResponse = zod.object({
|
||||
]),
|
||||
notifyOrdersEnabled: zod.boolean(),
|
||||
notifyMeetingsEnabled: zod.boolean(),
|
||||
vibrationEnabled: zod.boolean(),
|
||||
vibrationEnabledOrder: zod.boolean(),
|
||||
vibrationEnabledMeeting: zod.boolean(),
|
||||
notificationsMuted: zod.boolean(),
|
||||
notificationVolume: zod.number(),
|
||||
roles: zod.array(zod.string()),
|
||||
@@ -466,7 +472,8 @@ export const UpdateClockHour12Response = zod.object({
|
||||
]),
|
||||
notifyOrdersEnabled: zod.boolean(),
|
||||
notifyMeetingsEnabled: zod.boolean(),
|
||||
vibrationEnabled: zod.boolean(),
|
||||
vibrationEnabledOrder: zod.boolean(),
|
||||
vibrationEnabledMeeting: zod.boolean(),
|
||||
notificationsMuted: zod.boolean(),
|
||||
notificationVolume: zod.number(),
|
||||
roles: zod.array(zod.string()),
|
||||
|
||||
@@ -18,7 +18,8 @@ export const usersTable = pgTable("users", {
|
||||
notificationSoundMeeting: varchar("notification_sound_meeting", { length: 30 }).notNull().default("chime"),
|
||||
notifyOrdersEnabled: boolean("notify_orders_enabled").notNull().default(true),
|
||||
notifyMeetingsEnabled: boolean("notify_meetings_enabled").notNull().default(true),
|
||||
vibrationEnabled: boolean("vibration_enabled").notNull().default(true),
|
||||
vibrationEnabledOrder: boolean("vibration_enabled_order").notNull().default(true),
|
||||
vibrationEnabledMeeting: boolean("vibration_enabled_meeting").notNull().default(true),
|
||||
notificationsMuted: boolean("notifications_muted").notNull().default(false),
|
||||
notificationVolume: integer("notification_volume").notNull().default(70),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
Reference in New Issue
Block a user