안녕하세요.
유니티 WebGL로 만든 앱을 앱인토스에 연동하고 있습니다.
백그라운드 진입을 웹뷰 이벤트로 감지해서 오디오를 끄는 처리를 했는데요.
그런데 iOS에서는 지연이 없지만 안드로이드에서는 백그라운드 진입 후 약 3초 후에야 오디오가 꺼지는 현상이 발생합니다.
발생한 이벤트는 잘 감지되고 있는 것을 확인했습니다.
현재 가로모드 앱의 경우, 광고를 보면 광고화면이 화면 전체를 덮지 못해서 오디오가 저절로 꺼지지 않는 현상이 있습니다.
그래서 별도로 웹뷰 focus, blur 등 이벤트를 감지해서 수동으로 오디오를 끄는 처리를 하고 있는데요.
이때는 오디오가 지연없이 잘 꺼지고 있습니다.
단지, 안드로이드 홈버튼을 눌러서 앱을 백그라운드로 진입시켰을 때만 오디오 OFF가 3초 지연되고 있습니다.
유니티로 만든 WebGL 앱이 안드로이드 백그라운드로 진입 시 오디오가 3초 후 꺼지는 문제를 해결할 방법이 있을까요?
여러 가지 방법을 다 동원해봤지만 아직 해결하지 못하여 이렇게 도움을 요청 드립니다.
아래는 제가 구현한 코드의 일부입니다.
function __suspendCtx(ctx) {
try { if (ctx && ctx.state !== 'closed' &&
ctx.state !== 'suspended' &&
typeof ctx.suspend === 'function') return ctx.suspend(); } catch (_) {}
}
function __resumeCtx(ctx) {
try { if (ctx && ctx.state !== 'closed' &&
ctx.state !== 'running' &&
typeof ctx.resume === 'function') return ctx.resume(); } catch (_) {}
}
function __setGlobalAudioMuted(muted) {
muted = !!muted;
// <audio>/<video> 태그
try {
var media = document.querySelectorAll ? document.querySelectorAll('audio, video') : [];
for (var i = 0; i < media.length; i++) {
try { media[i].muted = muted; } catch (_) {}
if (!muted && typeof media[i].play === 'function') {
try {
var p = media[i].play();
if (p && typeof p.catch === 'function') p.catch(function () { });
} catch (_) {}
}
}
} catch (_) {}
// WebAudio 컨텍스트 (Unity 포함)
__collectUnityCtx();
try {
if (__ctxSet.forEach) {
var ops = [];
__ctxSet.forEach(function (ctx) { ops.push(muted ? __suspendCtx(ctx) : __resumeCtx(ctx)); });
if (typeof Promise !== 'undefined') {
(Promise.allSettled ? Promise.allSettled(ops) : Promise.all(ops)).catch(function () { });
}
} else {
for (var j = 0; j < __ctxSet.length; j++)
muted ? __suspendCtx(__ctxSet[j]) : __resumeCtx(__ctxSet[j]);
}
} catch (_) {}
}
function send(state, evt) {
var hidden = state === 'hidden';
try { __setGlobalAudioMuted(hidden); } catch (_) {} // 오디오 ON 또는 OFF 처리
var payload = JSON.stringify({
state: state,
eventType: evt,
hidden: hidden,
ts: Date.now()
});
try { SendMessage(goName, method, payload); } catch (e) {}
}
function onVisibility() { send(document.hidden ? 'hidden' : 'visible', 'visibilitychange'); }
function onPageHide() { send('hidden', 'pagehide'); }
function onPageShow() { send('visible', 'pageshow'); }
function onBlur() { send(document.hidden ? 'hidden' : 'visible', 'blur'); }
function onFocus() { send(document.hidden ? 'hidden' : 'visible', 'focus'); }
function onFreeze() { send('hidden', 'freeze'); }
document.addEventListener('visibilitychange', onVisibility, opts);
window.addEventListener('pagehide', onPageHide, opts);
window.addEventListener('pageshow', onPageShow, opts);
window.addEventListener('blur', onBlur, opts);
window.addEventListener('focus', onFocus, opts);
window.addEventListener('freeze', onFreeze, opts);
mergeInto(LibraryManager.library, {
AIT_StartObserveVisibility: function (goPtr, methodPtr) {
var goName = UTF8ToString(goPtr);
var method = UTF8ToString(methodPtr);
// 이전 구독 해제
if (typeof window.__tossVisUnsub === 'function') {
try { window.__tossVisUnsub(); } catch (e) {}
window.__tossVisUnsub = null;
}
// ===== 오디오 제어 유틸 =====
var AC = window.AudioContext || window.webkitAudioContext;
var __ctxSet = typeof Set !== 'undefined' ? new Set() : [];
function __trackCtx(ctx) {
if (!ctx) return;
try { __ctxSet.add ? __ctxSet.add(ctx) : __ctxSet.push(ctx); } catch (_) {}
}
// 새로 생성되는 AudioContext 래핑(1회)
try {
if (AC && !window.__AIT_AC_WRAPPED__) {
var OrigAC = AC;
var WrappedAC = function () {
var c = new (Function.prototype.bind.apply(OrigAC, [null].concat([].slice.call(arguments))));
__trackCtx(c);
return c;
};
WrappedAC.prototype = OrigAC.prototype;
Object.defineProperty(window, AC === window.AudioContext ? 'AudioContext' : 'webkitAudioContext', {
configurable: true, writable: true, value: WrappedAC
});
window.__AIT_AC_WRAPPED__ = true;
}
} catch (_) {}
function __collectUnityCtx() {
try {
var M = (window.unityInstance && window.unityInstance.Module) || window.Module || {};
var uc =
M.webAudioContext ||
M.audioContext ||
M.ctx ||
M._audioContext ||
// Unity(WebGL/Emscripten)에서 가장 흔한 위치들
(M.SDL && M.SDL.audioContext) ||
(M.SDL2 && M.SDL2.audioContext);
if (uc) __trackCtx(uc);
} catch (_) {}
}
function __suspendCtx(ctx) {
try { if (ctx && ctx.state !== 'closed' && ctx.state !== 'suspended' && typeof ctx.suspend === 'function') return ctx.suspend(); } catch (_) {}
}
function __resumeCtx(ctx) {
try { if (ctx && ctx.state !== 'closed' && ctx.state !== 'running' && typeof ctx.resume === 'function') return ctx.resume(); } catch (_) {}
}
/**
* 전역 음소거/해제: <audio>/<video> + WebAudio
*/
function __setGlobalAudioMuted(muted) {
muted = !!muted;
// <audio>/<video> 태그
try {
var media = document.querySelectorAll ? document.querySelectorAll('audio, video') : [];
for (var i = 0; i < media.length; i++) {
try { media[i].muted = muted; } catch (_) {}
if (!muted && typeof media[i].play === 'function') {
try {
var p = media[i].play();
if (p && typeof p.catch === 'function') p.catch(function () { });
} catch (_) {}
}
}
} catch (_) {}
// WebAudio 컨텍스트 (Unity 포함)
__collectUnityCtx();
try {
if (__ctxSet.forEach) {
var ops = [];
__ctxSet.forEach(function (ctx) { ops.push(muted ? __suspendCtx(ctx) : __resumeCtx(ctx)); });
if (typeof Promise !== 'undefined') {
(Promise.allSettled ? Promise.allSettled(ops) : Promise.all(ops)).catch(function () { });
}
} else {
for (var j = 0; j < __ctxSet.length; j++) muted ? __suspendCtx(__ctxSet[j]) : __resumeCtx(__ctxSet[j]);
}
} catch (_) {}
}
function send(state, evt) {
var hidden = state === 'hidden';
try { __setGlobalAudioMuted(hidden); } catch (_) {}
var payload = JSON.stringify({
state: state, // "visible" | "hidden"
eventType: evt, // "visibilitychange" | "pagehide" | "pageshow" | "blur" | "focus" | "init"
hidden: hidden,
ts: Date.now()
});
try { SendMessage(goName, method, payload); } catch (e) {}
}
// 캡처 단계로 가장 먼저 잡는다(숨기기 직전에도 최대한 빨리 유니티 호출)
var opts = { capture: true, passive: true };
function onVisibility() { send(document.hidden ? 'hidden' : 'visible', 'visibilitychange'); }
function onPageHide() { send('hidden', 'pagehide'); }
function onPageShow() { send('visible', 'pageshow'); }
function onBlur() { send(document.hidden ? 'hidden' : 'visible', 'blur'); }
function onFocus() { send(document.hidden ? 'hidden' : 'visible', 'focus'); }
function onFreeze() { send('hidden', 'freeze'); }
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
try { __setGlobalAudioMuted(true); } catch(_){}
send('hidden', 'visibilitychange');
} else {
try { __setGlobalAudioMuted(false); } catch(_){}
send('visible', 'visibilitychange');
}
}, { capture: true, passive: true });
window.addEventListener('pagehide', function () {
try { __setGlobalAudioMuted(true); } catch(_){}
send('hidden', 'pagehide');
}, { capture: true, passive: true });
window.addEventListener('pageshow', function () {
try { __setGlobalAudioMuted(false); } catch(_){}
send('visible', 'pageshow');
}, { capture: true, passive: true });
window.addEventListener('blur', function () {
try { __setGlobalAudioMuted(true); } catch(_){}
send('hidden', 'blur');
}, { capture: true, passive: true });
window.addEventListener('focus', function () {
try { __setGlobalAudioMuted(false); } catch(_){}
send('visible', 'focus');
}, { capture: true, passive: true });
window.addEventListener('freeze', function () {
try { __setGlobalAudioMuted(true); } catch(_){}
send('hidden', 'freeze');
}, { capture: true, passive: true });
//
// document.addEventListener('visibilitychange', onVisibility, opts);
// window.addEventListener('pagehide', onPageHide, opts);
// window.addEventListener('pageshow', onPageShow, opts);
// window.addEventListener('blur', onBlur, opts);
// window.addEventListener('focus', onFocus, opts);
// window.addEventListener('freeze', onFreeze, opts);
// 초기 1회 상태 통지(필요 시)
send(document.hidden ? 'hidden' : 'visible', 'init');
window.__tossVisUnsub = function () {
document.removeEventListener('visibilitychange', onVisibility, opts);
window.removeEventListener('pagehide', onPageHide, opts);
window.removeEventListener('pageshow', onPageShow, opts);
window.removeEventListener('blur', onBlur, opts);
window.removeEventListener('focus', onFocus, opts);
window.removeEventListener('freeze', onFreeze, opts);
};
window.__AIT_forceMute = function (on) {
try { __setGlobalAudioMuted(!!on); } catch (_) {}
};
// 6) rAF Heartbeat 휴리스틱: 렌더링이 뚝 끊기면 즉시 음소거
(function(){
var lastRAF = performance.now();
function rafTick(){ lastRAF = performance.now(); requestAnimationFrame(rafTick); }
requestAnimationFrame(rafTick);
var hb = setInterval(function(){
// 화면이 보이는 상태인데 rAF가 300ms 이상 끊기면 '사실상 백그라운드'로 판단
if ((document.visibilityState || 'visible') === 'visible') {
var gap = performance.now() - lastRAF;
if (gap > 300) { // 0.3초 이상 프레임 정지 → 바로 음소거
try { window.__AIT_forceMute(true); } catch (_){}
send('hidden', 'heartbeat');
}
}
}, 200);
// 필요 시 해제: window.__tossVisUnsub 안에 clearInterval(hb) 추가
})();
저는 초기에 오디오 Pause를 AudioListener.volume = 0; 이렇게 처리했었는데, 안드로이드에서 3초 정도 지연이 발생했었습니다.
아래의 코드대로 처리하면 안드로이드에서 홈버튼 등을 눌러서 백그라운드로 진입했을 때 오디오가 지연없이 중지됩니다.
// jslib 파일
mergeInto(LibraryManager.library, {
AIT_StartObserveVisibility: function (goPtr, methodPtr) {
var goName = UTF8ToString(goPtr);
var method = UTF8ToString(methodPtr);
// 이전 구독 해제
if (typeof window.__tossVisUnsub === 'function') {
try { window.__tossVisUnsub(); } catch (e) {}
window.__tossVisUnsub = null;
}
function send(state, evt) {
var payload = JSON.stringify({
state: state, // "visible" | "hidden"
eventType: evt, // "visibilitychange" | "pagehide" | "pageshow" | "blur" | "focus" | "init"
hidden: state === 'hidden',
ts: Date.now()
});
try { SendMessage(goName, method, payload); } catch (e) {}
}
// 캡처 단계로 가장 먼저 잡는다(숨기기 직전에도 최대한 빨리 유니티 호출)
var opts = { capture: true, passive: true };
function onVisibility() { send(document.hidden ? 'hidden' : 'visible', 'visibilitychange'); }
function onPageHide() { send('hidden', 'pagehide'); }
function onPageShow() { send('visible', 'pageshow'); }
function onBlur() { send(document.hidden ? 'hidden' : 'visible', 'blur'); }
function onFocus() { send(document.hidden ? 'hidden' : 'visible', 'focus'); }
function onFreeze() { send('hidden', 'freeze'); }
// 이벤트 구독
document.addEventListener('visibilitychange', onVisibility, opts);
window.addEventListener('pagehide', onPageHide, opts);
window.addEventListener('pageshow', onPageShow, opts);
window.addEventListener('blur', onBlur, opts);
window.addEventListener('focus', onFocus, opts);
window.addEventListener('freeze', onFreeze, opts);
// 초기 1회 상태 통지(필요 시)
send(document.hidden ? 'hidden' : 'visible', 'init');
// 이벤트 구독 해제 함수 정의
window.__tossVisUnsub = function () {
document.removeEventListener('visibilitychange', onVisibility, opts);
window.removeEventListener('pagehide', onPageHide, opts);
window.removeEventListener('pageshow', onPageShow, opts);
window.removeEventListener('blur', onBlur, opts);
window.removeEventListener('focus', onFocus, opts);
window.removeEventListener('freeze', onFreeze, opts);
};
}
});
// 유니티 코드
public void OnFocus(bool isFocus)
{
if (isFocus) // Foreground
{
// 사운드 Resume
AudioListener.pause = false;
// 게임 Resume
Time.timeScale = _originTimeScale;
}
else // Background
{
// 사운드 Pause
AudioListener.pause = true;
// 게임 Pause
_originTimeScale = Time.timeScale;
Time.timeScale = 0;
}