Skip to content

Commit

Permalink
Merge pull request #50130 from wildan-m/wildan/fix/47148-fix-sound-of…
Browse files Browse the repository at this point in the history
…fline

Resolves offline sound playback by caching assets locally
  • Loading branch information
yuwenmemon authored Oct 21, 2024
2 parents acebcb7 + aa7bcd4 commit 4d3f5da
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 67 deletions.
2 changes: 0 additions & 2 deletions config/webpack/webpack.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 10 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,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",
Expand Down Expand Up @@ -171,7 +172,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",
Expand Down Expand Up @@ -229,6 +229,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",
Expand Down
59 changes: 59 additions & 0 deletions src/libs/Sound/BaseSound.ts
Original file line number Diff line number Diff line change
@@ -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<F extends (...args: Parameters<F>) => ReturnType<F>>(func: F, minExecutionTime: number) {
const queue: Array<[() => ReturnType<F>, (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<F>) {
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};
19 changes: 19 additions & 0 deletions src/libs/Sound/index.native.ts
Original file line number Diff line number Diff line change
@@ -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<typeof SOUNDS>) => {
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);
127 changes: 75 additions & 52 deletions src/libs/Sound/index.ts
Original file line number Diff line number Diff line change
@@ -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<F extends (...args: Parameters<F>) => ReturnType<F>>(func: F, minExecutionTime: number) {
const queue: Array<[() => ReturnType<F>, (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<typeof SOUNDS>) => {
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<F>) {
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<typeof SOUNDS>) => {
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);
2 changes: 2 additions & 0 deletions src/libs/actions/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -546,6 +547,7 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) {
});
});
});
clearSoundAssetsCache();
}

export {
Expand Down
2 changes: 2 additions & 0 deletions src/libs/actions/Session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -776,6 +777,7 @@ function cleanupSession() {
clearCache().then(() => {
Log.info('Cleared all cache data', true, {}, true);
});
clearSoundAssetsCache();
Timing.clearData();
}

Expand Down

0 comments on commit 4d3f5da

Please sign in to comment.