Faisal Ahmed
Faisal Ahmed

Reputation: 84

HowlerJs Sound not working on production build without user interaction

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

Sound Hook

// 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;

Navbar code

// 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

Answers (0)

Related Questions