Skip to content
This repository was archived by the owner on Feb 28, 2026. It is now read-only.

Commit 195762f

Browse files
authored
Merge pull request #40 from NuclearPlayer/feat/plugin-api-expansion
Plugin api expansion
2 parents 3eb8395 + 249d758 commit 195762f

File tree

14 files changed

+641
-62
lines changed

14 files changed

+641
-62
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { useSoundStore } from '../stores/soundStore';
2+
import { playbackHost } from './playbackHost';
3+
4+
describe('playbackHost', () => {
5+
beforeEach(() => {
6+
useSoundStore.setState({
7+
src: null,
8+
status: 'stopped',
9+
seek: 0,
10+
duration: 0,
11+
crossfadeMs: 0,
12+
preload: 'auto',
13+
crossOrigin: '',
14+
});
15+
});
16+
17+
it('gets initial playback state', async () => {
18+
const state = await playbackHost.getState();
19+
20+
expect(state).toMatchInlineSnapshot(`
21+
{
22+
"duration": 0,
23+
"seek": 0,
24+
"status": "stopped",
25+
}
26+
`);
27+
});
28+
29+
it('play sets status to playing', async () => {
30+
await playbackHost.play();
31+
32+
const state = await playbackHost.getState();
33+
expect(state.status).toBe('playing');
34+
});
35+
36+
it('pause sets status to paused', async () => {
37+
await playbackHost.play();
38+
39+
await playbackHost.pause();
40+
41+
const state = await playbackHost.getState();
42+
expect(state.status).toBe('paused');
43+
});
44+
45+
it('stop resets status and seek', async () => {
46+
await playbackHost.play();
47+
await playbackHost.seekTo(30);
48+
49+
await playbackHost.stop();
50+
51+
const state = await playbackHost.getState();
52+
expect(state.status).toBe('stopped');
53+
expect(state.seek).toBe(0);
54+
});
55+
56+
it('toggle switches between playing and paused', async () => {
57+
await playbackHost.toggle();
58+
const afterFirst = await playbackHost.getState();
59+
expect(afterFirst.status).toBe('playing');
60+
61+
await playbackHost.toggle();
62+
const afterSecond = await playbackHost.getState();
63+
expect(afterSecond.status).toBe('paused');
64+
});
65+
66+
it('seekTo updates seek position', async () => {
67+
await playbackHost.seekTo(42.5);
68+
69+
const state = await playbackHost.getState();
70+
expect(state.seek).toBe(42.5);
71+
});
72+
73+
it('subscribe fires on state changes', async () => {
74+
const listener = vi.fn();
75+
playbackHost.subscribe(listener);
76+
77+
await playbackHost.play();
78+
79+
expect(listener).toHaveBeenCalled();
80+
const lastCallArg = listener.mock.calls[listener.mock.calls.length - 1][0];
81+
expect(lastCallArg).toMatchInlineSnapshot(`
82+
{
83+
"duration": 0,
84+
"seek": 0,
85+
"status": "playing",
86+
}
87+
`);
88+
});
89+
90+
it('unsubscribe stops notifications', async () => {
91+
const listener = vi.fn();
92+
const unsubscribe = playbackHost.subscribe(listener);
93+
94+
unsubscribe();
95+
await playbackHost.play();
96+
97+
expect(listener).not.toHaveBeenCalled();
98+
});
99+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type {
2+
PlaybackHost,
3+
PlaybackListener,
4+
PlaybackState,
5+
} from '@nuclearplayer/plugin-sdk';
6+
7+
import { useSoundStore } from '../stores/soundStore';
8+
9+
const toPlaybackState = (): PlaybackState => {
10+
const { status, seek, duration } = useSoundStore.getState();
11+
return { status, seek, duration };
12+
};
13+
14+
export const createPlaybackHost = (): PlaybackHost => ({
15+
getState: async () => toPlaybackState(),
16+
17+
play: async () => useSoundStore.getState().play(),
18+
19+
pause: async () => useSoundStore.getState().pause(),
20+
21+
stop: async () => useSoundStore.getState().stop(),
22+
23+
toggle: async () => useSoundStore.getState().toggle(),
24+
25+
seekTo: async (seconds) => useSoundStore.getState().seekTo(seconds),
26+
27+
subscribe: (listener: PlaybackListener) =>
28+
useSoundStore.subscribe((state) =>
29+
listener({
30+
status: state.status,
31+
seek: state.seek,
32+
duration: state.duration,
33+
}),
34+
),
35+
});
36+
37+
export const playbackHost = createPlaybackHost();
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { usePlaylistStore } from '../stores/playlistStore';
2+
import { resetInMemoryTauriStore } from '../test/utils/inMemoryTauriStore';
3+
import { createMockTrack } from '../test/utils/mockTrack';
4+
import { mockUuid } from '../test/utils/mockUuid';
5+
import { playlistsHost } from './playlistsHost';
6+
7+
describe('playlistsHost', () => {
8+
beforeEach(() => {
9+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
10+
mockUuid.reset();
11+
resetInMemoryTauriStore();
12+
usePlaylistStore.setState({
13+
index: [],
14+
playlists: new Map(),
15+
loaded: false,
16+
});
17+
});
18+
19+
it('gets the playlist index', async () => {
20+
await usePlaylistStore.getState().createPlaylist('Test Playlist');
21+
22+
const index = await playlistsHost.getIndex();
23+
24+
expect(index).toMatchInlineSnapshot(`
25+
[
26+
{
27+
"artwork": undefined,
28+
"createdAtIso": "2026-01-01T00:00:00.000Z",
29+
"id": "mock-uuid-0",
30+
"isReadOnly": false,
31+
"itemCount": 0,
32+
"lastModifiedIso": "2026-01-01T00:00:00.000Z",
33+
"name": "Test Playlist",
34+
"totalDurationMs": 0,
35+
},
36+
]
37+
`);
38+
});
39+
40+
it('gets a playlist by id', async () => {
41+
const playlistId = await usePlaylistStore
42+
.getState()
43+
.createPlaylist('Fetched Playlist');
44+
await usePlaylistStore
45+
.getState()
46+
.addTracks(playlistId, [createMockTrack('Song A')]);
47+
48+
const playlist = await playlistsHost.getPlaylist(playlistId);
49+
50+
expect(playlist).toMatchInlineSnapshot(`
51+
{
52+
"createdAtIso": "2026-01-01T00:00:00.000Z",
53+
"id": "mock-uuid-0",
54+
"isReadOnly": false,
55+
"items": [
56+
{
57+
"addedAtIso": "2026-01-01T00:00:00.000Z",
58+
"id": "mock-uuid-1",
59+
"track": {
60+
"artists": [
61+
{
62+
"name": "Test Artist",
63+
"roles": [
64+
"primary",
65+
],
66+
},
67+
],
68+
"source": {
69+
"id": "song a",
70+
"provider": "test",
71+
},
72+
"title": "Song A",
73+
},
74+
},
75+
],
76+
"lastModifiedIso": "2026-01-01T00:00:00.000Z",
77+
"name": "Fetched Playlist",
78+
}
79+
`);
80+
});
81+
82+
it('creates a playlist with correct structure', async () => {
83+
const playlistId = await playlistsHost.createPlaylist('My Playlist');
84+
85+
const playlist = usePlaylistStore.getState().playlists.get(playlistId);
86+
expect(playlist).toMatchInlineSnapshot(`
87+
{
88+
"createdAtIso": "2026-01-01T00:00:00.000Z",
89+
"id": "mock-uuid-0",
90+
"isReadOnly": false,
91+
"items": [],
92+
"lastModifiedIso": "2026-01-01T00:00:00.000Z",
93+
"name": "My Playlist",
94+
}
95+
`);
96+
expect(playlistId).toBe('mock-uuid-0');
97+
98+
const index = usePlaylistStore.getState().index;
99+
expect(index).toHaveLength(1);
100+
expect(index[0].id).toBe(playlistId);
101+
});
102+
103+
it('deletes a playlist', async () => {
104+
const playlistId = await usePlaylistStore
105+
.getState()
106+
.createPlaylist('To Delete');
107+
108+
await playlistsHost.deletePlaylist(playlistId);
109+
110+
expect(usePlaylistStore.getState().index).toHaveLength(0);
111+
expect(usePlaylistStore.getState().playlists.has(playlistId)).toBe(false);
112+
});
113+
114+
it('adds tracks to a playlist and returns the created items', async () => {
115+
const playlistId = await usePlaylistStore
116+
.getState()
117+
.createPlaylist('Track Playlist');
118+
119+
const addedItems = await playlistsHost.addTracks(playlistId, [
120+
createMockTrack('Song A'),
121+
createMockTrack('Song B'),
122+
]);
123+
124+
expect(addedItems).toMatchInlineSnapshot(`
125+
[
126+
{
127+
"addedAtIso": "2026-01-01T00:00:00.000Z",
128+
"id": "mock-uuid-1",
129+
"track": {
130+
"artists": [
131+
{
132+
"name": "Test Artist",
133+
"roles": [
134+
"primary",
135+
],
136+
},
137+
],
138+
"source": {
139+
"id": "song a",
140+
"provider": "test",
141+
},
142+
"title": "Song A",
143+
},
144+
},
145+
{
146+
"addedAtIso": "2026-01-01T00:00:00.000Z",
147+
"id": "mock-uuid-2",
148+
"track": {
149+
"artists": [
150+
{
151+
"name": "Test Artist",
152+
"roles": [
153+
"primary",
154+
],
155+
},
156+
],
157+
"source": {
158+
"id": "song b",
159+
"provider": "test",
160+
},
161+
"title": "Song B",
162+
},
163+
},
164+
]
165+
`);
166+
167+
const playlist = usePlaylistStore.getState().playlists.get(playlistId);
168+
expect(playlist?.items).toHaveLength(2);
169+
expect(playlist?.items[0].track.title).toBe('Song A');
170+
expect(playlist?.items[1].track.title).toBe('Song B');
171+
});
172+
173+
it('removes specific tracks by item id', async () => {
174+
const playlistId = await usePlaylistStore
175+
.getState()
176+
.createPlaylist('Remove Tracks Playlist');
177+
const addedItems = await usePlaylistStore
178+
.getState()
179+
.addTracks(playlistId, [
180+
createMockTrack('Song A'),
181+
createMockTrack('Song B'),
182+
createMockTrack('Song C'),
183+
]);
184+
185+
await playlistsHost.removeTracks(playlistId, [
186+
addedItems[0].id,
187+
addedItems[2].id,
188+
]);
189+
190+
const playlist = usePlaylistStore.getState().playlists.get(playlistId);
191+
expect(playlist?.items).toHaveLength(1);
192+
expect(playlist?.items[0].id).toBe(addedItems[1].id);
193+
expect(playlist?.items[0].track.title).toBe('Song B');
194+
});
195+
196+
it('reorders tracks by moving from one position to another', async () => {
197+
const playlistId = await usePlaylistStore
198+
.getState()
199+
.createPlaylist('Reorder Playlist');
200+
await usePlaylistStore
201+
.getState()
202+
.addTracks(playlistId, [
203+
createMockTrack('A'),
204+
createMockTrack('B'),
205+
createMockTrack('C'),
206+
]);
207+
208+
await playlistsHost.reorderTracks(playlistId, 0, 2);
209+
210+
const playlist = usePlaylistStore.getState().playlists.get(playlistId);
211+
const titles = playlist?.items.map((item) => item.track.title);
212+
expect(titles).toEqual(['B', 'C', 'A']);
213+
});
214+
215+
it('subscribes to index changes with full index entries', async () => {
216+
const listener = vi.fn();
217+
playlistsHost.subscribe(listener);
218+
219+
await usePlaylistStore.getState().createPlaylist('Subscribed Playlist');
220+
221+
expect(listener).toHaveBeenCalled();
222+
const lastCallArg = listener.mock.calls[listener.mock.calls.length - 1][0];
223+
expect(lastCallArg).toMatchInlineSnapshot(`
224+
[
225+
{
226+
"artwork": undefined,
227+
"createdAtIso": "2026-01-01T00:00:00.000Z",
228+
"id": "mock-uuid-0",
229+
"isReadOnly": false,
230+
"itemCount": 0,
231+
"lastModifiedIso": "2026-01-01T00:00:00.000Z",
232+
"name": "Subscribed Playlist",
233+
"totalDurationMs": 0,
234+
},
235+
]
236+
`);
237+
});
238+
239+
it('unsubscribes from index changes', async () => {
240+
const listener = vi.fn();
241+
const unsubscribe = playlistsHost.subscribe(listener);
242+
243+
unsubscribe();
244+
await usePlaylistStore.getState().createPlaylist('After Unsubscribe');
245+
246+
expect(listener).not.toHaveBeenCalled();
247+
});
248+
});

0 commit comments

Comments
 (0)