diff --git a/README.md b/README.md index eb9e121..0b67f58 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. diff --git a/package-lock.json b/package-lock.json index f696414..93d3a33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "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", @@ -202,7 +203,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -243,7 +243,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1306,7 +1305,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1443,7 +1441,6 @@ "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1500,7 +1497,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1829,7 +1825,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2254,7 +2249,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2309,7 +2303,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3234,7 +3227,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3284,7 +3276,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3407,7 +3398,6 @@ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3418,7 +3408,6 @@ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3904,7 +3893,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3967,7 +3955,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 9882a65..81d7946 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/constants.ts b/src/core/constants.ts index c072c46..35b0ddf 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -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'; diff --git a/src/core/manager.ts b/src/core/manager.ts index 8ea0678..461a2f9 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -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, @@ -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(); +let audioHandlers: { + timeupdate: () => void; + loadedmetadata: () => void; + playing: () => void; + pause: () => void; + ended: () => void; + waiting: () => void; + canplay: () => void; + error: () => void; +} | null = null; // 런타임 설정(`configure`로 덮어씀) let rememberProgress = true; @@ -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; }; @@ -135,6 +150,7 @@ const getAudio = () => ensureAudio(); // src 변경을 중앙화해 여러 컴포넌트가 엘리먼트를 서로 덮어쓰지 않게 함 const setSource = (src: string | null) => { const instance = ensureAudio(); + if (!instance) return; if (!src) { instance.pause(); @@ -171,6 +187,7 @@ const setPendingSeek = (time: number | null) => { // 현재 소스를 재생하거나, 먼저 새 소스로 교체한 뒤 재생 const play = async (src?: string) => { const instance = ensureAudio(); + if (!instance) return; if (src) { setSource(src); } @@ -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 }); @@ -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; @@ -208,6 +228,7 @@ 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 }); @@ -215,11 +236,32 @@ const setVolume = (volume: number) => { 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); @@ -269,6 +311,7 @@ export const audioManager = { getAudio, getSnapshot, subscribe, + dispose, getControls, subscribeEvents, }; diff --git a/src/core/storage.ts b/src/core/storage.ts index e872292..d191f46 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -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; diff --git a/test/core/manager.ssr.test.ts b/test/core/manager.ssr.test.ts new file mode 100644 index 0000000..f75844f --- /dev/null +++ b/test/core/manager.ssr.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('audioManager (SSR)', () => { + it('no-ops safely when Audio is unavailable', async () => { + vi.resetModules(); + vi.doMock('../../src/core/constants', async () => { + const actual = await vi.importActual( + '../../src/core/constants', + ); + return { ...actual, IS_BROWSER: false }; + }); + + const { audioManager } = await import('../../src/core/manager'); + const { DEFAULT_AUDIO_STATE } = await import('../../src/core/constants'); + + expect(() => audioManager.play()).not.toThrow(); + expect(() => audioManager.pause()).not.toThrow(); + expect(() => audioManager.stop()).not.toThrow(); + expect(() => audioManager.seek(10)).not.toThrow(); + expect(() => audioManager.setVolume(0.5)).not.toThrow(); + expect(() => audioManager.setPlaybackRate(1.2)).not.toThrow(); + expect(() => audioManager.setSource('https://example.com/a.mp3')).not.toThrow(); + + expect(audioManager.getAudio()).toBeNull(); + expect(audioManager.getSnapshot()).toEqual(DEFAULT_AUDIO_STATE); + }); +}); diff --git a/src/react/useGlobalAudio.test.tsx b/test/core/manager.test.ts similarity index 69% rename from src/react/useGlobalAudio.test.tsx rename to test/core/manager.test.ts index 1fada21..87dc4a6 100644 --- a/src/react/useGlobalAudio.test.tsx +++ b/test/core/manager.test.ts @@ -1,12 +1,14 @@ -import { renderHook, waitFor, cleanup } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_AUDIO_STATE } from '../core/constants'; -import { audioManager } from '../core/manager'; -import { useGlobalAudio } from './useGlobalAudio'; +import { DEFAULT_AUDIO_STATE } from '../../src/core/constants'; +import { audioManager } from '../../src'; -afterEach(() => { - cleanup(); -}); +const getAudioOrThrow = () => { + const audio = audioManager.getAudio(); + if (!audio) { + throw new Error('Audio is not available in this environment.'); + } + return audio; +}; beforeEach(() => { audioManager.setSource(null); @@ -14,6 +16,10 @@ beforeEach(() => { vi.clearAllMocks(); }); +afterEach(() => { + audioManager.dispose(); +}); + describe('audioManager', () => { it('sets source and resets to default state', () => { // 소스 지정 후 null로 초기화하면 기본 상태로 돌아가는지 검증 @@ -39,7 +45,7 @@ describe('audioManager', () => { it('applies pending seek after metadata is loaded', () => { // 메타데이터가 준비되기 전 seek 요청이 보류되었다가 반영되는지 검증 - const audio = audioManager.getAudio(); + const audio = getAudioOrThrow(); Object.defineProperty(audio, 'duration', { configurable: true, value: 120, @@ -54,14 +60,14 @@ describe('audioManager', () => { it('sets error state on audio error event', () => { // 에러 이벤트 발생 시 상태가 갱신되는지 검증 - const audio = audioManager.getAudio(); + const audio = getAudioOrThrow(); audio.dispatchEvent(new Event('error')); expect(audioManager.getSnapshot().error).toBe('audio_error'); }); it('stop resets currentTime and isPlaying', () => { // stop이 재생 상태를 종료하고 시간을 0으로 되돌리는지 검증 - const audio = audioManager.getAudio(); + const audio = getAudioOrThrow(); Object.defineProperty(audio, 'currentTime', { configurable: true, writable: true, @@ -83,7 +89,7 @@ describe('audioManager', () => { const unsubscribe = audioManager.subscribeEvents({ onPlay, onPause, onTimeUpdate }); - const audio = audioManager.getAudio(); + const audio = getAudioOrThrow(); audio.dispatchEvent(new Event('playing')); audio.dispatchEvent(new Event('pause')); audio.dispatchEvent(new Event('timeupdate')); @@ -94,29 +100,15 @@ describe('audioManager', () => { unsubscribe(); }); -}); -describe('useGlobalAudio', () => { - it('shares state across hook instances', async () => { - // 훅이 여러 번 사용되어도 동일한 전역 상태를 공유하는지 검증 - const first = renderHook(() => useGlobalAudio({ src: 'https://example.com/a.mp3' })); - const second = renderHook(() => useGlobalAudio()); - - await waitFor(() => { - expect(first.result.current.state.src).toBe('https://example.com/a.mp3'); - }); - - expect(second.result.current.state.src).toBe('https://example.com/a.mp3'); - }); - - it('autoPlay triggers audio play', async () => { - // autoPlay 옵션이 재생 호출로 이어지는지 검증 - const playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play'); - - renderHook(() => useGlobalAudio({ src: 'https://example.com/b.mp3', autoPlay: true })); - - await waitFor(() => { - expect(playSpy).toHaveBeenCalled(); - }); + it('dispose removes audio instance and resets state', () => { + // dispose가 오디오 인스턴스를 해제하고 상태를 초기화하는지 검증 + audioManager.setSource('https://example.com/c.mp3'); + const audio = getAudioOrThrow(); + expect(audio).not.toBeNull(); + audioManager.dispose(); + const nextAudio = getAudioOrThrow(); + expect(nextAudio).not.toBe(audio); + expect(audioManager.getSnapshot()).toEqual(DEFAULT_AUDIO_STATE); }); }); diff --git a/test/react/useGlobalAudio.test.tsx b/test/react/useGlobalAudio.test.tsx new file mode 100644 index 0000000..d704084 --- /dev/null +++ b/test/react/useGlobalAudio.test.tsx @@ -0,0 +1,40 @@ +import { waitFor } from '@testing-library/dom'; +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { audioManager } from '../../src'; +import { useGlobalAudio } from '../../src'; + +afterEach(() => { + cleanup(); +}); + +beforeEach(() => { + audioManager.setSource(null); + audioManager.configure({}); + vi.clearAllMocks(); +}); + +describe('useGlobalAudio', () => { + it('shares state across hook instances', async () => { + // 훅이 여러 번 사용되어도 동일한 전역 상태를 공유하는지 검증 + const first = renderHook(() => useGlobalAudio({ src: 'https://example.com/a.mp3' })); + const second = renderHook(() => useGlobalAudio()); + + await waitFor(() => { + expect(first.result.current.state.src).toBe('https://example.com/a.mp3'); + }); + + expect(second.result.current.state.src).toBe('https://example.com/a.mp3'); + }); + + it('autoPlay triggers audio play', async () => { + // autoPlay 옵션이 재생 호출로 이어지는지 검증 + const playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play'); + + renderHook(() => useGlobalAudio({ src: 'https://example.com/b.mp3', autoPlay: true })); + + await waitFor(() => { + expect(playSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 2186fdf..c2d0e7f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], + include: ['test/**/*.test.{ts,tsx}'], }, });