Reputation: 84
How can I fix the issue where the sound does not consistently play for new orders in my restaurant ticket system, particularly in the production build, using React 18 and Howler.js?
I've implemented sound notifications for new orders, and it works well during development. However, on the production server, the sound plays inconsistently (often not at all). I see the following error:
howler.js:2521 The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page.
Despite attempts to resolve the issue, such as resuming the AudioContext and testing various approaches, the sound still behaves unpredictably. How can I ensure reliable sound playback for new orders in production?
I have implemented many solution for some sound have distortion , some initial time doesn't work etc etc
// src/hooks/useSoundEffect.js
import { useEffect, useRef } from "react";
import { useKdsContext } from "../contexts/KdsProvider";
import { getSecondsDifference } from "../utils";
const useSoundEffect = (items, thresholdSeconds = 2, cooldownMs = 300) => {
const { soundOption, getActiveHowl } = useKdsContext();
const triggeredOrdersRef = useRef(new Set());
const lastSoundTimeRef = useRef(0);
useEffect(() => {
if (!items || items.length === 0) return;
const newItems = items.filter((item) => {
const diff = getSecondsDifference(item.OrderDateTime);
return diff < thresholdSeconds;
});
const mapByOrderNumber = newItems.reduce((acc, item) => {
if (!acc[item.OrderNumber]) {
acc[item.OrderNumber] = [];
}
acc[item.OrderNumber].push(item);
return acc;
}, {});
Object.keys(mapByOrderNumber).forEach((orderNumber) => {
const alreadyTriggered = triggeredOrdersRef.current.has(orderNumber);
if (!alreadyTriggered) {
const now = Date.now();
if (now - lastSoundTimeRef.current < cooldownMs) {
return;
}
lastSoundTimeRef.current = now;
triggeredOrdersRef.current.add(orderNumber);
if (soundOption !== "No_Sound") {
const howl = getActiveHowl();
if (howl) {
if (howl.state() === "loaded") {
howl.stop();
howl.play();
} else {
howl.once("load", () => {
howl.stop();
howl.play();
});
}
}
}
}
});
}, [items, thresholdSeconds, cooldownMs, soundOption, getActiveHowl]);
};
export default useSoundEffect;
// src/components/KitchenDisplay/Content/ContentTop.js
import React from "react";
import {
HStack,
Icon,
useColorMode,
Menu,
MenuButton,
MenuList,
MenuItem,
Button,
Text,
} from "@chakra-ui/react";
import { IoMdMenu } from "react-icons/io";
import {
MdOutlineLogout,
MdBrightness4,
MdBrightness7,
MdVolumeOff,
MdVolumeUp,
MdMusicNote,
} from "react-icons/md";
import { useNavigate } from "react-router-dom";
import { Howler } from "howler";
import KitchenScreensMenu from "./KitchenScreensMenu";
import Pagination from "./Pagination";
import CustomIcon from "../../../components/CustomIcon";
import DepartmentsMenu from "./DepartmentsMenu";
import useColors from "../../../hooks/useColors";
import { useKdsContext } from "../../../contexts/KdsProvider";
const ContentTop = () => {
const {
toggleSidebar,
soundOption,
setSoundOption,
newOrderHowlRef,
pleaseHowlRef,
alertHowlRef,
} = useKdsContext();
const navigate = useNavigate();
const { toggleColorMode, colorMode } = useColorMode();
const { alpha100 } = useColors();
const handleLogout = () => {
localStorage.removeItem("token");
navigate("/login");
};
// Sound options configuration
const soundOptions = [
{
value: "No_Sound",
label: "No Sound",
icon: MdVolumeOff,
description: "Mute",
},
{
value: "New_Order",
label: "New Order",
icon: MdMusicNote,
description: "Play",
},
{
value: "Please",
label: "Please",
icon: MdMusicNote,
description: "Play",
},
{
value: "Alert",
label: "Alert",
icon: MdMusicNote,
description: "Play",
},
];
/**
* Called when a user selects a sound option from the dropdown.
* 1) If "No_Sound", just set it and return.
* 2) Otherwise, that click is a user-gesture, so we can resume Howler’s AudioContext.
* 3) Immediately stop + play the chosen sound as a preview.
*/
const handleSoundSelect = (optionValue) => {
setSoundOption(optionValue);
// If user picked "No_Sound", do nothing more
if (optionValue === "No_Sound") {
return;
}
// Because the user physically clicked the menu item,
// we can resume the audio context if it's suspended.
const audioCtx = Howler.ctx;
if (audioCtx && audioCtx.state === "suspended") {
audioCtx.resume().then(() => {
// Once resumed, play the chosen sound
playSelectedSound(optionValue);
});
} else {
// If audio context wasn't suspended, just play right away
playSelectedSound(optionValue);
}
};
// Helper function: stop + play the chosen sound
const playSelectedSound = (optionValue) => {
const soundMap = {
New_Order: newOrderHowlRef.current,
Please: pleaseHowlRef.current,
Alert: alertHowlRef.current,
};
const soundInstance = soundMap[optionValue];
if (soundInstance) {
if (soundInstance.state() === "loaded") {
soundInstance.stop();
soundInstance.play();
} else {
soundInstance.once("load", () => {
soundInstance.stop();
soundInstance.play();
});
}
}
};
// Display the icon for the current sound option
const getSoundIcon = () => {
const currentOption = soundOptions.find((opt) => opt.value === soundOption);
return <Icon as={currentOption?.icon || MdVolumeUp} boxSize="18px" />;
};
// Display the label for the current sound option
const getSoundLabel = () => {
const currentOption = soundOptions.find((opt) => opt.value === soundOption);
return currentOption?.label || "New Order Sound";
};
return (
<HStack justifyContent="space-between" alignItems="center" w="full">
{/* Left side: Menu toggle + Pagination */}
<HStack gap="5">
<CustomIcon
fontSize="28px"
onClick={toggleSidebar}
cursor="pointer"
_hover={{ color: "gray.600" }}
>
<IoMdMenu />
</CustomIcon>
<Pagination />
</HStack>
{/* Right side: Sound settings, Kitchen screens, Departments, Dark mode, Logout */}
<HStack gap="5" alignItems="center">
{/* Sound Settings Dropdown */}
<Menu closeOnSelect>
<MenuButton
as={Button}
leftIcon={getSoundIcon()}
variant="ghost"
size="md"
px={4}
py={2}
transition="all 0.2s"
borderRadius="md"
borderWidth="2px"
borderColor={alpha100}
_hover={{
bg: colorMode === "light" ? "gray.100" : "whiteAlpha.200",
}}
_expanded={{
bg: colorMode === "light" ? "gray.100" : "whiteAlpha.200",
}}
_focus={{ boxShadow: "outline" }}
>
<Text as="span" fontSize="sm" fontWeight="medium">
{getSoundLabel()}
</Text>
</MenuButton>
<MenuList>
{soundOptions.map((option) => (
<MenuItem
key={option.value}
icon={<Icon as={option.icon} boxSize="18px" />}
onClick={() => handleSoundSelect(option.value)}
position="relative"
py={3}
px={4}
_hover={{
bg: colorMode === "light" ? "gray.50" : "whiteAlpha.200",
}}
bg={
soundOption === option.value
? colorMode === "light"
? "gray.50"
: "whiteAlpha.200"
: "transparent"
}
>
<HStack spacing={2}>
<Text
fontWeight={
soundOption === option.value ? "medium" : "normal"
}
>
{option.label}
</Text>
<Text
fontSize="xs"
color={colorMode === "light" ? "gray.500" : "gray.400"}
>
{option.description}
</Text>
</HStack>
</MenuItem>
))}
</MenuList>
</Menu>
{/* Kitchen Screens Menu */}
<KitchenScreensMenu />
{/* Departments Menu */}
<DepartmentsMenu />
{/* Dark Mode Toggle */}
<CustomIcon
onClick={toggleColorMode}
cursor="pointer"
_hover={{ color: "gray.600" }}
>
{colorMode === "light" ? (
<Icon as={MdBrightness4} boxSize="25px" title="Dark Mode" />
) : (
<Icon as={MdBrightness7} boxSize="25px" title="Light Mode" />
)}
</CustomIcon>
{/* Logout */}
<CustomIcon
onClick={handleLogout}
cursor="pointer"
_hover={{ color: "gray.600" }}
>
<Icon as={MdOutlineLogout} boxSize="25px" title="Log Out" />
</CustomIcon>
</HStack>
</HStack>
);
};
export default ContentTop;
TicketCard.jsx
// src/components/KitchenDisplay/Content/Tickets/TicketCard.js
import React, { useMemo } from "react";
import { Stack, useColorModeValue } from "@chakra-ui/react";
import useColors from "../../../hooks/useColors";
import TicketCardHeader from "./TicketCardHeader";
import Orders from "./Orders";
import { getSecondsDifference } from "../../../utils";
// Updated import to reflect the new, revised version of the hook:
import useSoundEffect from "../../../hooks/useSoundEffect";
const TicketCard = ({ items }) => {
const { componentBg } = useColors();
const boxShadowColor = useColorModeValue("#828282", "#3d4b5c");
useSoundEffect(items, 2, 5000);
const isAnyItemUnderTwenty = useMemo(
() => items.some((item) => getSecondsDifference(item.OrderDateTime) < 20),
[items]
);
const { Department, TableName, TicketNumber, NoOfGuests, OrderDateTime, OrderId } = items[0];
const orderWiseItems = items.reduce((acc, curr) => {
const found = acc.find(
(grouped) => grouped[0].OrderNumber === curr.OrderNumber
);
if (!found) {
acc.push(items.filter((i) => i.OrderNumber === curr.OrderNumber));
}
return acc;
}, []);
function sortOrders() {
const served = [];
const notServed = [];
orderWiseItems.forEach((o) => {
if (o.every((i) => i.TicketStatus === "Served")) {
served.push(o);
} else {
notServed.push(o);
}
});
// Sort descending by OrderNumber
const sortFn = (a, b) => b[0].OrderNumber - a[0].OrderNumber;
return [...notServed.sort(sortFn), ...served.sort(sortFn)];
}
return (
<Stack
bg={componentBg}
borderRadius="md"
boxShadow={`0 6px 25px -3px ${boxShadowColor}, 0 1px 10px -1px ${boxShadowColor}`}
maxH="450px"
overflow="auto"
position="relative"
borderColor="primary.400"
// Apply "newTicket" class if any item is within 20 seconds
className={isAnyItemUnderTwenty ? "newTicket" : ""}
>
<TicketCardHeader data={items[0]} />
<Stack pb="5" px="5" minH="150px">
{sortOrders().map((orders, i) => (
<Orders key={i} items={orders} />
))}
</Stack>
</Stack>
);
};
export default TicketCard;
Upvotes: -1
Views: 24