diff --git a/src/Earwurm.ts b/src/Earwurm.ts index d3fd0c2..b3aea29 100644 --- a/src/Earwurm.ts +++ b/src/Earwurm.ts @@ -1,7 +1,7 @@ import {EmittenCommon} from 'emitten'; import {getErrorMessage, unlockAudioContext} from './helpers'; -import {clamp, msToSec, secToMs} from './utilities'; +import {arrayShallowEquals, clamp, msToSec, secToMs} from './utilities'; import {tokens} from './tokens'; import type { @@ -255,8 +255,16 @@ export class Earwurm extends EmittenCommon { } #setLibrary(library: Stack[]) { + const oldKeys = this._keys; + const newKeys = library.map(({id}) => id); + const identicalKeys = arrayShallowEquals(oldKeys, newKeys); + this.#library = library; - this._keys = this.#library.map(({id}) => id); + this._keys = newKeys; + + if (!identicalKeys) { + this.emit('keys', newKeys, oldKeys); + } } #setState(value: ManagerState) { diff --git a/src/tests/Earwurm.test.ts b/src/tests/Earwurm.test.ts index a22d963..22b80cc 100644 --- a/src/tests/Earwurm.test.ts +++ b/src/tests/Earwurm.test.ts @@ -20,6 +20,7 @@ describe('Earwurm component', () => { {id: 'One', path: 'to/no/file.mp3'}, {id: 'Two', path: ''}, ]; + const mockInitialKeys: LibraryKeys = mockEntries.map(({id}) => id); afterEach(() => { mockManager.teardown(); @@ -250,6 +251,36 @@ describe('Earwurm component', () => { expect(mockManager.keys).toHaveLength(4); }); + it('emits `keys` event with new and old `keys`', async () => { + const spyKeysChange: ManagerEventMap['keys'] = vi.fn((_value) => {}); + + mockManager.on('keys', spyKeysChange); + expect(spyKeysChange).not.toBeCalled(); + + mockManager.add(...mockEntries); + expect(spyKeysChange).toBeCalledWith(mockInitialKeys, []); + expect(spyKeysChange).toBeCalledTimes(1); + + mockManager.add(mockEntries[0]); + expect(spyKeysChange).not.toBeCalledTimes(2); + + const mockUniqueEntry: LibraryEntry = { + id: 'Unique', + path: 'does/not/overwrite/anything.wav', + }; + const mockChangedEntries: LibraryEntry[] = [ + mockUniqueEntry, + mockEntries[1], + ]; + + mockManager.add(...mockChangedEntries); + expect(spyKeysChange).toBeCalledTimes(2); + expect(spyKeysChange).toBeCalledWith( + [...mockInitialKeys, mockUniqueEntry.id], + mockInitialKeys, + ); + }); + // TODO: Figure out how best to read `fadeMs` and `request` from Stack. it.skip('passes `fadeMs` and `request` to Stack', async () => { const mockConfig: ManagerConfig = { @@ -291,6 +322,22 @@ describe('Earwurm component', () => { expect(capturedKeys).toStrictEqual([]); }); + it('emits `keys` event with new and old `keys`', async () => { + const spyKeysChange: ManagerEventMap['keys'] = vi.fn((_value) => {}); + + mockManager.add(...mockEntries); + mockManager.on('keys', spyKeysChange); + + mockManager.remove('Foo', 'Bar'); + expect(spyKeysChange).not.toBeCalled(); + + mockManager.remove(mockEntries[1].id); + expect(spyKeysChange).toBeCalledWith( + [mockEntries[0].id, mockEntries[2].id], + mockInitialKeys, + ); + }); + it('tears down Stacks before removing from library', async () => { const mockChangedEntries: LibraryEntry[] = [ { @@ -394,6 +441,18 @@ describe('Earwurm component', () => { expect(mockManager.keys).toStrictEqual([]); }); + it('emits `keys` event with empty array', async () => { + const spyKeysChange: ManagerEventMap['keys'] = vi.fn((_value) => {}); + + mockManager.add(...mockEntries); + + mockManager.on('keys', spyKeysChange); + expect(spyKeysChange).not.toBeCalled(); + + mockManager.teardown(); + expect(spyKeysChange).toBeCalledWith([], mockInitialKeys); + }); + it('does not resume the AudioContext', async () => { mockManager.unlock(); document.dispatchEvent(clickEvent); diff --git a/src/types.ts b/src/types.ts index 8485dc5..7e2309c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,10 +19,14 @@ export type ManagerState = AudioContextState | 'suspending' | 'interrupted'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type ManagerEventMap = { - statechange: (state: ManagerState) => void; - error: (error: CombinedErrorMessage) => void; + // TODO: Rename this to `state`. + statechange: (current: ManagerState) => void; + error: (message: CombinedErrorMessage) => void; volume: (level: number) => void; mute: (muted: boolean) => void; + // This is not the same as a "library change". This event + // will not fire if an identical `entry > id` updates it's `path`. + keys: (newKeys: LibraryKeys, oldKeys: LibraryKeys) => void; }; export interface ManagerConfig {