diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 91fc4b1bf528..2d8e27fd453e 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -227,8 +227,6 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): 'react-native-config': 'react-web-config', // eslint-disable-next-line @typescript-eslint/naming-convention 'react-native$': 'react-native-web', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'react-native-sound': 'react-native-web-sound', // Module alias for web & desktop // https://webpack.js.org/configuration/resolve/#resolvealias // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/package-lock.json b/package-lock.json index 22385023374c..d51716d85806 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "expo-image-manipulator": "12.0.5", "fast-equals": "^4.0.3", "focus-trap-react": "^10.2.3", + "howler": "^2.2.4", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "lodash-es": "4.17.21", @@ -115,7 +116,6 @@ "react-native-view-shot": "3.8.0", "react-native-vision-camera": "4.0.0-beta.13", "react-native-web": "^0.19.12", - "react-native-web-sound": "^0.1.3", "react-native-webview": "13.8.6", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", @@ -173,6 +173,7 @@ "@types/base-64": "^1.0.2", "@types/canvas-size": "^1.2.2", "@types/concurrently": "^7.0.0", + "@types/howler": "^2.2.12", "@types/jest": "^29.5.2", "@types/jest-when": "^3.5.2", "@types/js-yaml": "^4.0.5", @@ -15763,6 +15764,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/howler": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.12.tgz", + "integrity": "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==", + "dev": true + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "dev": true, @@ -25965,7 +25972,8 @@ }, "node_modules/howler": { "version": "2.2.4", - "license": "MIT" + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==" }, "node_modules/hpack.js": { "version": "2.1.6", @@ -35716,16 +35724,6 @@ "react-dom": "^18.0.0" } }, - "node_modules/react-native-web-sound": { - "version": "0.1.3", - "license": "MIT", - "dependencies": { - "howler": "^2.2.1" - }, - "peerDependencies": { - "react-native-web": "*" - } - }, "node_modules/react-native-web/node_modules/memoize-one": { "version": "6.0.0", "license": "MIT" diff --git a/package.json b/package.json index 1387bda002d6..912f1a7f6079 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "expo-image-manipulator": "12.0.5", "fast-equals": "^4.0.3", "focus-trap-react": "^10.2.3", + "howler": "^2.2.4", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", "lodash-es": "4.17.21", @@ -170,7 +171,6 @@ "react-native-view-shot": "3.8.0", "react-native-vision-camera": "4.0.0-beta.13", "react-native-web": "^0.19.12", - "react-native-web-sound": "^0.1.3", "react-native-webview": "13.8.6", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", @@ -228,6 +228,7 @@ "@types/base-64": "^1.0.2", "@types/canvas-size": "^1.2.2", "@types/concurrently": "^7.0.0", + "@types/howler": "^2.2.12", "@types/jest": "^29.5.2", "@types/jest-when": "^3.5.2", "@types/js-yaml": "^4.0.5", diff --git a/src/libs/Sound/BaseSound.ts b/src/libs/Sound/BaseSound.ts new file mode 100644 index 000000000000..e7fc5fadd259 --- /dev/null +++ b/src/libs/Sound/BaseSound.ts @@ -0,0 +1,59 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +let isMuted = false; + +Onyx.connect({ + key: ONYXKEYS.USER, + callback: (val) => (isMuted = !!val?.isMutedAllSounds), +}); + +const SOUNDS = { + DONE: 'done', + SUCCESS: 'success', + ATTENTION: 'attention', + RECEIVE: 'receive', +} as const; + +const getIsMuted = () => isMuted; + +/** + * Creates a version of the given function that, when called, queues the execution and ensures that + * calls are spaced out by at least the specified `minExecutionTime`, even if called more frequently. This allows + * for throttling frequent calls to a function, ensuring each is executed with a minimum `minExecutionTime` between calls. + * Each call returns a promise that resolves when the function call is executed, allowing for asynchronous handling. + */ +function withMinimalExecutionTime) => ReturnType>(func: F, minExecutionTime: number) { + const queue: Array<[() => ReturnType, (value?: unknown) => void]> = []; + let timerId: NodeJS.Timeout | null = null; + + function processQueue() { + if (queue.length > 0) { + const next = queue.shift(); + + if (!next) { + return; + } + + const [nextFunc, resolve] = next; + nextFunc(); + resolve(); + timerId = setTimeout(processQueue, minExecutionTime); + } else { + timerId = null; + } + } + + return function (...args: Parameters) { + return new Promise((resolve) => { + queue.push([() => func(...args), resolve]); + + if (!timerId) { + // If the timer isn't running, start processing the queue + processQueue(); + } + }); + }; +} + +export {SOUNDS, withMinimalExecutionTime, getIsMuted}; diff --git a/src/libs/Sound/index.native.ts b/src/libs/Sound/index.native.ts new file mode 100644 index 000000000000..f9e0db31b9b0 --- /dev/null +++ b/src/libs/Sound/index.native.ts @@ -0,0 +1,19 @@ +import Sound from 'react-native-sound'; +import type {ValueOf} from 'type-fest'; +import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound'; +import config from './config'; + +const playSound = (soundFile: ValueOf) => { + const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => { + if (error || getIsMuted()) { + return; + } + + sound.play(); + }); +}; + +function clearSoundAssetsCache() {} + +export {SOUNDS, clearSoundAssetsCache}; +export default withMinimalExecutionTime(playSound, 300); diff --git a/src/libs/Sound/index.ts b/src/libs/Sound/index.ts index 4639887e831c..39ff567df68e 100644 --- a/src/libs/Sound/index.ts +++ b/src/libs/Sound/index.ts @@ -1,71 +1,94 @@ -import Onyx from 'react-native-onyx'; -import Sound from 'react-native-sound'; +import {Howl} from 'howler'; import type {ValueOf} from 'type-fest'; -import ONYXKEYS from '@src/ONYXKEYS'; +import Log from '@libs/Log'; +import {getIsMuted, SOUNDS, withMinimalExecutionTime} from './BaseSound'; import config from './config'; -let isMuted = false; +function cacheSoundAssets() { + // Exit early if the Cache API is not available in the current browser. + if (!('caches' in window)) { + return; + } -Onyx.connect({ - key: ONYXKEYS.USER, - callback: (val) => (isMuted = !!val?.isMutedAllSounds), -}); + caches.open('sound-assets').then((cache) => { + const soundFiles = Object.values(SOUNDS).map((sound) => `${config.prefix}${sound}.mp3`); -const SOUNDS = { - DONE: 'done', - SUCCESS: 'success', - ATTENTION: 'attention', - RECEIVE: 'receive', -} as const; + // Cache each sound file if it's not already cached. + const cachePromises = soundFiles.map((soundFile) => { + return cache.match(soundFile).then((response) => { + if (response) { + return; + } + return cache.add(soundFile); + }); + }); -/** - * Creates a version of the given function that, when called, queues the execution and ensures that - * calls are spaced out by at least the specified `minExecutionTime`, even if called more frequently. This allows - * for throttling frequent calls to a function, ensuring each is executed with a minimum `minExecutionTime` between calls. - * Each call returns a promise that resolves when the function call is executed, allowing for asynchronous handling. - */ -function withMinimalExecutionTime) => ReturnType>(func: F, minExecutionTime: number) { - const queue: Array<[() => ReturnType, (value?: unknown) => void]> = []; - let timerId: NodeJS.Timeout | null = null; + return Promise.all(cachePromises); + }); +} - function processQueue() { - if (queue.length > 0) { - const next = queue.shift(); +const initializeAndPlaySound = (src: string) => { + const sound = new Howl({ + src: [src], + format: ['mp3'], + onloaderror: (_id: number, error: unknown) => { + Log.alert('[sound] Load error:', {message: (error as Error).message}); + }, + onplayerror: (_id: number, error: unknown) => { + Log.alert('[sound] Play error:', {message: (error as Error).message}); + }, + }); + sound.play(); +}; - if (!next) { +const playSound = (soundFile: ValueOf) => { + if (getIsMuted()) { + return; + } + + const soundSrc = `${config.prefix}${soundFile}.mp3`; + + if (!('caches' in window)) { + // Fallback to fetching from network if not in cache + initializeAndPlaySound(soundSrc); + return; + } + + caches.open('sound-assets').then((cache) => { + cache.match(soundSrc).then((response) => { + if (response) { + response.blob().then((soundBlob) => { + const soundUrl = URL.createObjectURL(soundBlob); + initializeAndPlaySound(soundUrl); + }); return; } + initializeAndPlaySound(soundSrc); + }); + }); +}; - const [nextFunc, resolve] = next; - nextFunc(); - resolve(); - timerId = setTimeout(processQueue, minExecutionTime); - } else { - timerId = null; - } +function clearSoundAssetsCache() { + // Exit early if the Cache API is not available in the current browser. + if (!('caches' in window)) { + return; } - return function (...args: Parameters) { - return new Promise((resolve) => { - queue.push([() => func(...args), resolve]); - - if (!timerId) { - // If the timer isn't running, start processing the queue - processQueue(); + caches + .delete('sound-assets') + .then((success) => { + if (success) { + return; } + Log.alert('[sound] Failed to clear sound assets cache.'); + }) + .catch((error) => { + Log.alert('[sound] Error clearing sound assets cache:', {message: (error as Error).message}); }); - }; } -const playSound = (soundFile: ValueOf) => { - const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => { - if (error || isMuted) { - return; - } - - sound.play(); - }); -}; +// Cache sound assets on load +cacheSoundAssets(); -export {SOUNDS}; +export {SOUNDS, clearSoundAssetsCache}; export default withMinimalExecutionTime(playSound, 300); diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index d69bcf0e5761..44bdc55c90fc 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -17,6 +17,7 @@ import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as SessionUtils from '@libs/SessionUtils'; +import {clearSoundAssetsCache} from '@libs/Sound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -559,6 +560,7 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { }); }); }); + clearSoundAssetsCache(); } export { diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 4d6ba6cfa774..37488442525d 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -36,6 +36,7 @@ import NetworkConnection from '@libs/NetworkConnection'; import * as Pusher from '@libs/Pusher/pusher'; import * as ReportUtils from '@libs/ReportUtils'; import * as SessionUtils from '@libs/SessionUtils'; +import {clearSoundAssetsCache} from '@libs/Sound'; import Timers from '@libs/Timers'; import {hideContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import {KEYS_TO_PRESERVE, openApp} from '@userActions/App'; @@ -761,6 +762,7 @@ function cleanupSession() { clearCache().then(() => { Log.info('Cleared all cache data', true, {}, true); }); + clearSoundAssetsCache(); Timing.clearData(); }