Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ import { audioManager } from 'react-global-audio';
audioManager.configure({ rememberProgress: true, storage: 'localStorage' });
await audioManager.play('/audio/track.mp3');
audioManager.pause();
// 테스트/정리 시 전역 오디오 인스턴스를 해제
audioManager.dispose();
```

Dispose the global audio instance (useful for tests or cleanup):

```ts
audioManager.dispose();
```

Subscribe to state:
Expand Down Expand Up @@ -110,3 +118,4 @@ Available from the hook (`controls`) and `audioManager.getControls()`:
- Progress is saved in whole seconds.
- A single shared `HTMLAudioElement` instance is used.
- Set `storage: false` to disable persistence.
- In SSR, audio actions are no-ops and the snapshot stays at the default state.
15 changes: 1 addition & 14 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/jsdom": "^27.0.0",
Expand Down
3 changes: 3 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ export const DEFAULT_AUDIO_STATE: AudioState = {

// 진행률 저장 쓰기 간격(ms). 너무 자주 저장하지 않도록 기본 throttling 값 지정
export const DEFAULT_THROTTLE_MS = 2000;

// SSR 환경 감지를 위한 플래그
export const IS_BROWSER = typeof window !== 'undefined' && typeof window.document !== 'undefined';
139 changes: 91 additions & 48 deletions src/core/manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DEFAULT_AUDIO_STATE, DEFAULT_THROTTLE_MS } from './constants';
import { DEFAULT_AUDIO_STATE, DEFAULT_THROTTLE_MS, IS_BROWSER } from './constants';
import { buildProgressKey, getStorage, loadProgressValue, saveProgressValue } from './storage';
import type {
AudioControls,
Expand All @@ -15,6 +15,16 @@ let audio: HTMLAudioElement | null = null;
let state: AudioState = { ...DEFAULT_AUDIO_STATE };
const listeners = new Set<(next: AudioState) => void>();
const eventHandlers = new Set<AudioEventHandlers>();
let audioHandlers: {
timeupdate: () => void;
loadedmetadata: () => void;
playing: () => void;
pause: () => void;
ended: () => void;
waiting: () => void;
canplay: () => void;
error: () => void;
} | null = null;

// 런타임 설정(`configure`로 덮어씀)
let rememberProgress = true;
Expand Down Expand Up @@ -73,58 +83,63 @@ const loadProgress = (src: string) => {
};

// 단일 HTMLAudioElement를 지연 생성하고, DOM 이벤트를 스토어 업데이트로 연결
const ensureAudio = () => {
const ensureAudio = (): HTMLAudioElement | null => {
if (!IS_BROWSER) return null;
if (audio) return audio;

audio = new Audio();

audio.addEventListener('timeupdate', () => {
setState({ currentTime: audio?.currentTime ?? 0 });
saveProgress();
emitEvent('onTimeUpdate', audio?.currentTime ?? 0);
});

audio.addEventListener('loadedmetadata', () => {
if (!audio) return;
setState({ duration: audio.duration || 0, isReady: true });
emitEvent('onLoadedMetadata', audio.duration || 0);
// duration/메타데이터가 준비되면 보류된 seek를 적용한다.
if (pendingSeekTime !== null) {
audio.currentTime = Math.max(0, pendingSeekTime);
setState({ currentTime: audio.currentTime });
pendingSeekTime = null;
}
});

audio.addEventListener('playing', () => {
setState({ isPlaying: true, isReady: true });
emitEvent('onPlay');
});

audio.addEventListener('pause', () => {
setState({ isPlaying: false });
saveProgress(true);
emitEvent('onPause');
});

audio.addEventListener('ended', () => {
setState({ isPlaying: false });
saveProgress(true);
emitEvent('onEnded');
});

audio.addEventListener('waiting', () => {
emitEvent('onWaiting');
});

audio.addEventListener('canplay', () => {
emitEvent('onCanPlay');
});
audioHandlers = {
timeupdate: () => {
setState({ currentTime: audio?.currentTime ?? 0 });
saveProgress();
emitEvent('onTimeUpdate', audio?.currentTime ?? 0);
},
loadedmetadata: () => {
if (!audio) return;
setState({ duration: audio.duration || 0, isReady: true });
emitEvent('onLoadedMetadata', audio.duration || 0);
// duration/메타데이터가 준비되면 보류된 seek를 적용한다.
if (pendingSeekTime !== null) {
audio.currentTime = Math.max(0, pendingSeekTime);
setState({ currentTime: audio.currentTime });
pendingSeekTime = null;
}
},
playing: () => {
setState({ isPlaying: true, isReady: true });
emitEvent('onPlay');
},
pause: () => {
setState({ isPlaying: false });
saveProgress(true);
emitEvent('onPause');
},
ended: () => {
setState({ isPlaying: false });
saveProgress(true);
emitEvent('onEnded');
},
waiting: () => {
emitEvent('onWaiting');
},
canplay: () => {
emitEvent('onCanPlay');
},
error: () => {
setState({ error: 'audio_error' });
emitEvent('onError', 'audio_error');
},
};

audio.addEventListener('error', () => {
setState({ error: 'audio_error' });
emitEvent('onError', 'audio_error');
});
audio.addEventListener('timeupdate', audioHandlers.timeupdate);
audio.addEventListener('loadedmetadata', audioHandlers.loadedmetadata);
audio.addEventListener('playing', audioHandlers.playing);
audio.addEventListener('pause', audioHandlers.pause);
audio.addEventListener('ended', audioHandlers.ended);
audio.addEventListener('waiting', audioHandlers.waiting);
audio.addEventListener('canplay', audioHandlers.canplay);
audio.addEventListener('error', audioHandlers.error);

return audio;
};
Expand All @@ -135,6 +150,7 @@ const getAudio = () => ensureAudio();
// src 변경을 중앙화해 여러 컴포넌트가 엘리먼트를 서로 덮어쓰지 않게 함
const setSource = (src: string | null) => {
const instance = ensureAudio();
if (!instance) return;

if (!src) {
instance.pause();
Expand Down Expand Up @@ -171,6 +187,7 @@ const setPendingSeek = (time: number | null) => {
// 현재 소스를 재생하거나, 먼저 새 소스로 교체한 뒤 재생
const play = async (src?: string) => {
const instance = ensureAudio();
if (!instance) return;
if (src) {
setSource(src);
}
Expand All @@ -183,11 +200,13 @@ const play = async (src?: string) => {

const pause = () => {
const instance = ensureAudio();
if (!instance) return;
instance.pause();
};

const stop = () => {
const instance = ensureAudio();
if (!instance) return;
instance.pause();
instance.currentTime = 0;
setState({ currentTime: 0, isPlaying: false });
Expand All @@ -197,6 +216,7 @@ const stop = () => {
// 메타데이터가 준비되기 전에도 안전하게 seek하기 위해 지연 적용 지원
const seek = (time: number) => {
const instance = ensureAudio();
if (!instance) return;
const nextTime = Math.max(0, Math.min(state.duration || time, time));
if (!state.duration) {
pendingSeekTime = nextTime;
Expand All @@ -208,18 +228,40 @@ const seek = (time: number) => {

const setVolume = (volume: number) => {
const instance = ensureAudio();
if (!instance) return;
const next = Math.max(0, Math.min(1, volume));
instance.volume = next;
setState({ volume: next });
};

const setPlaybackRate = (rate: number) => {
const instance = ensureAudio();
if (!instance) return;
const next = Math.max(0.5, Math.min(2, rate));
instance.playbackRate = next;
setState({ rate: next });
};

// 오디오 엘리먼트 및 이벤트 리스너를 정리
const dispose = () => {
if (!audio || !audioHandlers) return;
audio.removeEventListener('timeupdate', audioHandlers.timeupdate);
audio.removeEventListener('loadedmetadata', audioHandlers.loadedmetadata);
audio.removeEventListener('playing', audioHandlers.playing);
audio.removeEventListener('pause', audioHandlers.pause);
audio.removeEventListener('ended', audioHandlers.ended);
audio.removeEventListener('waiting', audioHandlers.waiting);
audio.removeEventListener('canplay', audioHandlers.canplay);
audio.removeEventListener('error', audioHandlers.error);
audio.pause();
audio.removeAttribute('src');
audio.load();
audioHandlers = null;
audio = null;
pendingSeekTime = null;
setState({ ...DEFAULT_AUDIO_STATE });
};

// `useSyncExternalStore`에서 사용하는 외부 스토어 구독 API
const subscribe = (listener: (next: AudioState) => void) => {
listeners.add(listener);
Expand Down Expand Up @@ -269,6 +311,7 @@ export const audioManager = {
getAudio,
getSnapshot,
subscribe,
dispose,
getControls,
subscribeEvents,
};
4 changes: 3 additions & 1 deletion src/core/storage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { StorageMode } from './types';
import { IS_BROWSER } from './constants';
import type { StorageMode } from './types';

// 선택한 저장소 백엔드를 반환하고, 비활성화면 null을 반환
export const getStorage = (mode: StorageMode) => {
if (!IS_BROWSER) return null;
if (mode === 'localStorage') return localStorage;
if (mode === 'sessionStorage') return sessionStorage;
return null;
Expand Down
Loading