1 // @generated by extracting the shared mock IPC logic from 2 3 /** 4 * Data payload for the mock Tauri IPC. All fields must be JSON-serializable 5 * (no Uint8Array, no functions, no undefined values that would be dropped by 6 * JSON.stringify). 7 */ 8 export interface MockData { 9 songs : MockSong []; 10
lyrics : MockLyrics ;
11 primarySongHash : string ;
12 sidebarWidth : number ;
13 libraryRegistry : {
14 active_library_id : string | null ;
15 libraries : Array <{
16 id : string ;
17 kind : string ;
18 display_name : string ;
19 root_path : string ;
20 }>;
21 };
22 activeLibrary : {
23 id : string ;
24 kind : string ;
25 display_name : string ;
26 root_path : string ;
27 };
28 libraryPath : string ;
29 windowShellState : {
30 chrome_variant : string ;
31 tier : string ;
32 toolbar_height_px : number ;
33 traffic_light_inset_leading : number ;
34 sidebar_header_height_px : number ;
35 sidebar_width_px : number ;
36 };
37 /** Initial settings snapshot (mutable via set_* commands). */
38 settings : Record < string , unknown >;
39 /** Initial playback snapshot (mutable via play/pause/seek/etc). */
40 playbackSnapshot : Record < string , unknown >;
41 bootstrapStatus : Record < string , unknown >;
42 /** Initial playlists (empty array for E2E; website preview adds some). */
43 playlists : Array <{
44 id : string ;
45 name : string ;
46 song_count : number ;
47 created_at : number ;
48 updated_at : number ;
49 }>;
50 playlistSongs : Record < string , string []>;
51 rotationState : {
52 singer_names : string [];
53 current_index : number ;
54 mode : string ;
55 active : boolean ;
56 };
57 /** When true, playback loops back to loopStartPositionMs on song end. */
58 loopPlayback ?: boolean ;
59 /** Position to seek back to when looping. Defaults to 0. */
60 loopStartPositionMs ?: number ;
61 }
62
63 // cover_art as number[] for JSON serialization.
64 export interface MockSong {
65 hash : string ;
66 file_path : string | null ;
67 audio_source_kind : string ;
68 cdg_path : string | null ;
69 media_g_container : string | null ;
70 instrumental : boolean ;
71 language : string | null ;
72 title : string | null ;
73 artist : string | null ;
74 album : string | null ;
75 duration_ms : number ;
76 cover_art : number [] | null ;
77 imported_at : number ;
78 original_ext : string | null ;
79 }
80
81 export interface MockLyrics {
82 raw_lrc : string ;
83 lines : Array <{
84 time_ms : number ;
85 text : string ;
86 words : unknown ;
87 bg_words : unknown ;
88 section : unknown ;
89 }>;
90 offset_ms : number ;
91 source : string ;
92 }
93
94 export interface TauriMockResult {
95 internals : {
96 metadata : {
97 currentWindow : { label : string };
98 currentWebview : { label : string };
99 };
100 invoke : ( cmd : string , args ?: unknown ) => Promise < unknown >;
101 transformCallback : (
102 callback : ( ... args : unknown []) => void ,
103 once ?: boolean ,
104 ) => number ;
105 unregisterCallback : ( id : number ) => void ;
106 };
107 eventPluginInternals : { unregisterListener : () => void };
108 helpers : {
109 emitEvent : ( eventName : string , payload : unknown ) => void ;
110 setCommandDelayMs : ( cmd : string , delayMs : number ) => void ;
111 setMockSongs : ( songs : MockSong []) => void ;
112 setMockLyrics : ( lyrics : MockLyrics ) => void ;
113 setLargeLibrary : ( count : number ) => void ;
114 getInvokeCalls : () => Array <{ cmd : string ; args : unknown }>;
115 getLastNativeMenu : () => unknown ;
116 clickNativeMenuItem : ( label : string ) => Promise < void >;
117 clickNativeSubmenuItem : (
118 parentLabel : string ,
119 label : string ,
120 ) => Promise < void >;
121 setPlaybackSnapshot : (
122 patch : Record < string , unknown >,
123 ) => Record < string , unknown >;
124 setSeparationCompleted : ( songHash : string ) => void ;
125 getPlaybackSnapshot : () => Record < string , unknown >;
126 };
127 }
128
129 // Everything below is a single self-contained function. All logic lives
130 // inside the function body so `toString()` produces a valid standalone script.
131
132 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- must be `any` for toString() serialization
133 export function createTauriMock ( data : any ) : TauriMockResult {
134 // Mutable so tests can override the library, lyrics, playback, etc.
135 let mockSongs = data.songs;
136 let mockLyrics = data.lyrics;
137 const invokeCalls : Array <{ cmd : string ; args : any }> = [];
138 const commandDelayMs = new Map < string , number >();
139 const playlists : any [] = (data.playlists || []). map (( p : any ) => ({ ... p }));
140 const playlistSongs = new Map < string , any []>();
141 const menuResources = new Map < number , any >();
142 const separationStatuses : Record < string , any > = {};
143 let nextPlaylistId = 1 ;
144 let nextEventId = 1 ;
145 let nextMenuRid = 1 ;
146 let lastNativeMenu : any = null ;
147 let transportGeneration = 0 ;
148 let rotationState = { ... data.rotationState };
149 let settingsSnapshot = { ... data.settings };
150 let currentPlaybackSnapshot = { ... data.playbackSnapshot };
151
152 let playbackEndTimer : ReturnType < typeof setTimeout> | null = null ;
153
154 // Initialize playlist songs from data
155 if (data.playlistSongs) {
156 for ( const [ playlistId , songHashes ] of Object. entries (data.playlistSongs)) {
157 playlistSongs. set (
158 playlistId,
159 (songHashes as string []). map (( songHash , index ) => ({
160 song_hash: songHash,
161 added_at: index + 1 ,
162 sort_order: index,
163 singer: null ,
164 })),
165 );
166 }
167 }
168
169 function clone ( value : any ) : any {
170 if (value == null ) return value;
171 return JSON . parse ( JSON . stringify (value));
172 }
173
174 function bumpTransportGeneration () : number {
175 transportGeneration += 1 ;
176 (currentPlaybackSnapshot as any ).transport_generation = transportGeneration;
177 return transportGeneration;
178 }
179
180 function schedulePlaybackEnd () : void {
181 if (playbackEndTimer) {
182 clearTimeout (playbackEndTimer);
183 playbackEndTimer = null ;
184 }
185 const snap = currentPlaybackSnapshot as any ;
186 if ( ! snap.is_playing || snap.state === "buffering" ) return ;
187 const remaining = (snap.duration_ms || 0 ) - (snap.position_ms || 0 );
188 if (remaining <= 0 ) return ;
189 playbackEndTimer = setTimeout (() => {
190 playbackEndTimer = null ;
191 const gen = (currentPlaybackSnapshot as any ).transport_generation;
192 if (gen !== transportGeneration) return ;
193 if (data.loopPlayback) {
194 const loopMs = data.loopStartPositionMs ?? 0 ;
195 currentPlaybackSnapshot = {
196 ... currentPlaybackSnapshot,
197 state: "playing" ,
198 is_playing: true ,
199 position_ms: loopMs,
200 };
201 emitMockEvent ( "playback-position" , {
202 ms: loopMs,
203 transport_generation: transportGeneration,
204 snapshot: clone (currentPlaybackSnapshot),
205 });
206 schedulePlaybackEnd ();
207 } else {
208 currentPlaybackSnapshot = {
209 ... currentPlaybackSnapshot,
210 state: "idle" ,
211 is_playing: false ,
212 position_ms: (currentPlaybackSnapshot as any ).duration_ms,
213 };
214 emitMockEvent ( "playback-position" , {
215 ms: (currentPlaybackSnapshot as any ).duration_ms,
216 transport_generation: transportGeneration,
217 snapshot: clone (currentPlaybackSnapshot),
218 });
219 }
220 }, remaining);
221 }
222
223 function clearPlaybackEnd () : void {
224 if (playbackEndTimer) {
225 clearTimeout (playbackEndTimer);
226 playbackEndTimer = null ;
227 }
228 }
229
230 function resolveCommandResult ( cmd : string , result : any ) : Promise < any > {
231 return Promise . resolve (result)
232 . then (( value ) => clone (value))
233 . then (( value ) => {
234 const delayMs = commandDelayMs. get (cmd) || 0 ;
235 if (delayMs <= 0 ) return value;
236 return new Promise (( resolve ) => {
237 setTimeout (() => resolve (value), delayMs);
238 });
239 });
240 }
241
242 function playlistSnapshot () : any [] {
243 return playlists. map (( playlist ) => ({
244 ... playlist,
245 song_count: playlistSongs. get (playlist.id)?. length || 0 ,
246 }));
247 }
248
249 function menuResourceSnapshot ( resource : any ) : any {
250 return {
251 label: resource.label,
252 children: resource.items
253 ? resource.items. map (( child : any ) => menuResourceSnapshot (child))
254 : undefined ,
255 };
256 }
257
258 function readMenuItemReference ( reference : any ) : any {
259 const rid = Array. isArray (reference) ? reference[ 0 ] : reference?.rid;
260 const resource = menuResources. get (rid);
261 if ( ! resource) {
262 throw new Error ( "Unknown native menu resource in mock: " + rid);
263 }
264 return resource;
265 }
266
267 const eventListeners = new Map < string , Map < number , number >>();
268 let callbackId = 0 ;
269 const callbacks = new Map < number , ( ... args : any []) => void >();
270
271 function emitMockEvent ( eventName : string , payload : any ) : void {
272 const handlers = eventListeners. get (eventName);
273 if ( ! handlers) return ;
274 for ( const [ id , callbackIdNum ] of handlers) {
275 const callback = callbacks. get (callbackIdNum);
276 if ( typeof callback === "function" ) {
277 callback ({ event: eventName, id, payload: clone (payload) });
278 }
279 }
280 }
281
282 function handleTauriEventCommand (
283 cmd : string ,
284 args : any ,
285 ) : Promise < any > | null {
286 if (cmd === "plugin:event|listen" ) {
287 const id = nextEventId ++ ;
288 if (args && args.event && typeof args.handler === "number" ) {
289 const handlers =
290 eventListeners. get (args.event) || new Map < number , number >();
291 handlers. set (id, args.handler);
292 eventListeners. set (args.event, handlers);
293 }
294 return Promise . resolve (id);
295 }
296 if (cmd === "plugin:event|unlisten" ) {
297 if (args && args.event) {
298 const handlers = eventListeners. get (args.event);
299 if (handlers) handlers. delete (args.eventId);
300 }
301 return Promise . resolve ( undefined );
302 }
303 if (cmd === "plugin:event|emit" || cmd === "plugin:event|emit_to" ) {
304 return Promise . resolve ( undefined );
305 }
306 return null ;
307 }
308
309 function handleTauriResourceCommand (
310 cmd : string ,
311 args : any ,
312 ) : Promise < any > | null {
313 if (cmd === "plugin:resources|close" ) {
314 menuResources. delete (args.rid);
315 return Promise . resolve ( undefined );
316 }
317 return null ;
318 }
319
320 function handleTauriWindowCommand ( cmd : string ) : Promise < any > | null {
321 if (cmd === "plugin:window|is_maximized" ) {
322 return Promise . resolve ( false );
323 }
324 if (
325 cmd === "plugin:window|close" ||
326 cmd === "plugin:window|minimize" ||
327 cmd === "plugin:window|start_dragging" ||
328 cmd === "plugin:window|start_resize_dragging" ||
329 cmd === "plugin:window|toggle_maximize"
330 ) {
331 return Promise . resolve ( undefined );
332 }
333 return null ;
334 }
335
336 const COMMANDS : Record < string , any > = {
337 get_library_registry: data.libraryRegistry,
338 get_settings : () => settingsSnapshot,
339 get_window_shell_state: data.windowShellState,
340 get_library_path: data.libraryPath,
341
342 get_active_library: data.activeLibrary,
343 get_library : () => mockSongs,
344 search_library : ( args : any ) => {
345 const q = ((args && args.query) || "" ). toLowerCase ();
346 if ( ! q) return mockSongs;
347 return mockSongs. filter (
348 ( s : any ) =>
349 (s.title ?? "" ). toLowerCase (). includes (q) ||
350 (s.artist ?? "" ). toLowerCase (). includes (q),
351 );
352 },
353 get_all_separation_statuses : () => clone (Object. values (separationStatuses)),
354 get_all_upload_statuses: {},
355 get_remote_cache_usage : () => ({
356 used_bytes: 512 * 1024 * 1024 ,
357 limit_bytes: 2 * 1024 * 1024 * 1024 ,
358 entry_count: 3 ,
359 pinned_count: 0 ,
360 }),
361 clear_remote_cache : () => 0 ,
362 get_remote_diagnostics : () => ({
363 has_active_remote: false ,
364 repository_id: null ,
365 writer_id: null ,
366 committed_generation: 0 ,
367 local_base_generation: 0 ,
368 local_state: "clean" ,
369 local_db_digest: null ,
370 active_operation_id: null ,
371 last_success_at_ms: null ,
372 last_error_code: null ,
373 recent_operations: [],
374 }),
375
376 get_playback_state : () => clone (currentPlaybackSnapshot),
377 play : ( args : any ) => {
378 const songId =
379 (args && (args.songId || args.song_id)) || data.primarySongHash;
380 const song = mockSongs. find (( s : any ) => s.hash === songId);
381 bumpTransportGeneration ();
382 currentPlaybackSnapshot = {
383 ... currentPlaybackSnapshot,
384 song_id: songId,
385 state: "playing" ,
386 is_playing: true ,
387 position_ms: 0 ,
388 duration_ms: song ? song.duration_ms : 300000 ,
389 buffered_ms: 0 ,
390 };
391 schedulePlaybackEnd ();
392 return clone (currentPlaybackSnapshot);
393 },
394 resume : () => {
395 bumpTransportGeneration ();
396 currentPlaybackSnapshot = {
397 ... currentPlaybackSnapshot,
398 state: "playing" ,
399 is_playing: true ,
400 };
401 schedulePlaybackEnd ();
402 return clone (currentPlaybackSnapshot);
403 },
404 pause : () => {
405 bumpTransportGeneration ();
406 clearPlaybackEnd ();
407 currentPlaybackSnapshot = {
408 ... currentPlaybackSnapshot,
409 state: "idle" ,
410 is_playing: false ,
411 };
412 return clone (currentPlaybackSnapshot);
413 },
414 seek : ( args : any ) => {
415 bumpTransportGeneration ();
416 clearPlaybackEnd ();
417 const targetMs = (args && args.ms) || 0 ;
418 const bufferingSnapshot = {
419 ... currentPlaybackSnapshot,
420 state: "buffering" ,
421 is_playing: true ,
422 position_ms: targetMs,
423 buffered_ms: targetMs,
424 };
425 currentPlaybackSnapshot = bufferingSnapshot;
426 const playingSnapshot = {
427 ... bufferingSnapshot,
428 state: "playing" ,
429 position_ms: targetMs + 50 ,
430 buffered_ms: (currentPlaybackSnapshot as any ).duration_ms || 354000 ,
431 };
432 queueMicrotask (() => {
433 emitMockEvent ( "playback-position" , {
434 ms: bufferingSnapshot.position_ms,
435 transport_generation: (bufferingSnapshot as any ).transport_generation,
436 snapshot: clone (bufferingSnapshot),
437 });
438 });
439 setTimeout (() => {
440 currentPlaybackSnapshot = playingSnapshot;
441 emitMockEvent ( "playback-position" , {
442 ms: playingSnapshot.position_ms,
443 transport_generation: (playingSnapshot as any ).transport_generation,
444 snapshot: clone (playingSnapshot),
445 });
446 schedulePlaybackEnd ();
447 }, 80 );
448 return clone (bufferingSnapshot);
449 },
450 set_volume : ( args : any ) => {
451 const level = args && args.level != null ? args.level : 0.8 ;
452 currentPlaybackSnapshot = { ... currentPlaybackSnapshot, volume: level };
453 return clone (currentPlaybackSnapshot);
454 },
455 set_stem_volume : ( args : any ) => {
456 const stem = args && args.stem;
457 const level = args && args.level != null ? args.level : 1 ;
458 if (stem && (currentPlaybackSnapshot as any ).stem_volumes) {
459 currentPlaybackSnapshot = {
460 ... currentPlaybackSnapshot,
461 stem_volumes: {
462 ... (currentPlaybackSnapshot as any ).stem_volumes,
463 [stem]: level,
464 },
465 };
466 }
467 return clone (currentPlaybackSnapshot);
468 },
469 load_stems : () => clone (currentPlaybackSnapshot),
470
471 fetch_lyrics : ( args : any ) => ({ ... mockLyrics, song_id: args?.songId }),
472 fetch_lyrics_online : ( args : any ) => ({
473 ... data.lyrics,
474 song_id: args?.songId,
475 }),
476 save_manual_lyrics : ( args : any ) => ({
477 raw_lrc: (args && args.text) || "" ,
478 lines: [],
479 offset_ms: 0 ,
480 source: "manual" ,
481 }),
482 set_lyrics_offset: undefined ,
483
484 list_playlists : () => playlistSnapshot (),
485 create_playlist : ( args : any ) => {
486 const pl = {
487 id: "pl-" + nextPlaylistId ++ ,
488 name: (args && args.name) || "New Playlist" ,
489 song_count: 0 ,
490 created_at: Date. now (),
491 updated_at: Date. now (),
492 };
493 return pl;
494 },
495 rename_playlist: undefined ,
496 delete_playlist: undefined ,
497 add_songs_to_playlist : ( args : any ) => {
498 const current = playlistSongs. get (args.playlistId) || [];
499 const existing = new Set (current. map (( entry : any ) => entry.song_hash));
500 const next = [ ... current];
501 for ( const songHash of args.songHashes || []) {
502 if ( ! existing. has (songHash)) {
503 next. push ({
504 song_hash: songHash,
505 added_at: Date. now (),
506 sort_order: next. length ,
507 singer: null ,
508 });
509 }
510 }
511 playlistSongs. set (args.playlistId, next);
512 return undefined ;
513 },
514 remove_songs_from_playlist : ( args : any ) => {
515 const remove = new Set (args.songHashes || []);
516 const next = (playlistSongs. get (args.playlistId) || [])
517 . filter (( entry : any ) => ! remove. has (entry.song_hash))
518 . map (( entry : any , index : number ) => ({ ... entry, sort_order: index }));
519 playlistSongs. set (args.playlistId, next);
520 return undefined ;
521 },
522 get_playlist_songs : ( args : any ) => playlistSongs. get (args.playlistId) || [],
523
524 get_rotation_state : () => rotationState,
525 set_rotation_state : ( args : any ) => {
526 rotationState = args.rotation;
527 return undefined ;
528 },
529 advance_rotation : () => {
530 if (rotationState.singer_names. length > 0 ) {
531 rotationState = {
532 ... rotationState,
533 current_index:
534 (rotationState.current_index + 1 ) %
535 rotationState.singer_names. length ,
536 };
537 }
538 return rotationState;
539 },
540
541 get_model_bootstrap_status: data.bootstrapStatus,
542
543 window_ready: undefined ,
544 set_language : ( args : any ) => {
545 settingsSnapshot = {
546 ... settingsSnapshot,
547 language: (args && args.language) || "en" ,
548 };
549 return settingsSnapshot;
550 },
551 set_stem_mode : ( args : any ) => {
552 settingsSnapshot = {
553 ... settingsSnapshot,
554 stem_mode: (args && args.mode) || "two_stem" ,
555 };
556 return settingsSnapshot;
557 },
558 set_lyrics_font_step : ( args : any ) => {
559 settingsSnapshot = {
560 ... settingsSnapshot,
561 lyrics_font_step: (args && args.step) || 0 ,
562 };
563 return settingsSnapshot;
564 },
565 set_execution_provider : ( args : any ) => {
566 settingsSnapshot = {
567 ... settingsSnapshot,
568 execution_provider: (args && args.provider) || "cpu" ,
569 };
570 return settingsSnapshot;
571 },
572 set_hide_batch_separate : ( args : any ) => {
573 settingsSnapshot = {
574 ... settingsSnapshot,
575 hide_batch_separate: (args && args.value) || false ,
576 };
577 return settingsSnapshot;
578 },
579 set_cover_art_backdrop : ( args : any ) => {
580 settingsSnapshot = {
581 ... settingsSnapshot,
582 cover_art_backdrop: (args && args.value) || false ,
583 };
584 return settingsSnapshot;
585 },
586 set_hide_upgrade_all : ( args : any ) => {
587 settingsSnapshot = {
588 ... settingsSnapshot,
589 hide_upgrade_all: (args && args.value) || false ,
590 };
591 return settingsSnapshot;
592 },
593 set_eq_enabled : ( args : any ) => {
594 settingsSnapshot = {
595 ... settingsSnapshot,
596 eq_enabled: !! (args && args.enabled),
597 };
598 return settingsSnapshot;
599 },
600 set_eq_gains : ( args : any ) => {
601 settingsSnapshot = {
602 ... settingsSnapshot,
603 eq_gains_db: (args && args.gainsDb) || [ 0 , 0 , 0 , 0 , 0 ],
604 };
605 return settingsSnapshot;
606 },
607 set_library_sort_mode : ( args : any ) => {
608 settingsSnapshot = {
609 ... settingsSnapshot,
610 library_sort_mode: (args && args.mode) || "recently_imported" ,
611 };
612 return settingsSnapshot;
613 },
614 set_theme_preference : ( args : any ) => {
615 settingsSnapshot = {
616 ... settingsSnapshot,
617 theme_preference: (args && args.preference) || "dark" ,
618 };
619 return settingsSnapshot;
620 },
621 set_update_policy : ( args : any ) => {
622 settingsSnapshot = {
623 ... settingsSnapshot,
624 update_policy: (args && args.policy) || "notify" ,
625 };
626 return settingsSnapshot;
627 },
628 check_runtime_updates : () => ({
629 generation: 1 ,
630 release_id: "mock-release" ,
631 target_triple: "aarch64-apple-darwin" ,
632 state: "up_to_date" ,
633 installed_version: "v1.27.1" ,
634 available_version: "v1.27.1" ,
635 available_bytes: 0 ,
636 restart_required: true ,
637 }),
638 set_crossfade_enabled : ( args : any ) => {
639 settingsSnapshot = {
640 ... settingsSnapshot,
641 crossfade_enabled: !! (args && args.enabled),
642 };
643 return settingsSnapshot;
644 },
645 set_crossfade_duration_ms : ( args : any ) => {
646 settingsSnapshot = {
647 ... settingsSnapshot,
648 crossfade_duration_ms: (args && args.durationMs) || 3000 ,
649 };
650 return settingsSnapshot;
651 },
652
653 get_audio_peaks: { writeIndex: 0 , peaks: [] },
654 set_preload_candidate: undefined ,
655 get_waveform : ( args : any ) => {
656 const buckets = (args && args.buckets) || 200 ;
657 return Array. from ({ length: buckets }, () => 0 );
658 },
659 restart_app: undefined ,
660 create_library: undefined ,
661 open_library: undefined ,
662 batch_separate: undefined ,
663 cancel_remote_auth: undefined ,
664 };
665
666 function invoke ( cmd : string , args ?: any ) : Promise < any > {
667 invokeCalls. push ({ cmd, args: clone (args) });
668
669 const eventResult = handleTauriEventCommand (cmd, args);
670 if (eventResult) return eventResult;
671
672 const resourceResult = handleTauriResourceCommand (cmd, args || {});
673 if (resourceResult) return resourceResult;
674
675 const windowResult = handleTauriWindowCommand (cmd);
676 if (windowResult) return windowResult;
677
678 if (cmd === "plugin:menu|new" ) {
679 const rid = nextMenuRid ++ ;
680 const options = (args && args.options) || {};
681 const items = (options.items || []). map (readMenuItemReference);
682 const resource = {
683 rid,
684 kind: args.kind,
685 label: options.text || options.item || "" ,
686 action: options.handler?.onmessage || args?.handler?.onmessage || null ,
687 items,
688 popupPosition: null ,
689 };
690 menuResources. set (rid, resource);
691 return Promise . resolve ([rid, String (rid)]);
692 }
693 if (cmd === "plugin:menu|popup" ) {
694 const resource = readMenuItemReference ({ rid: args.rid });
695 resource.popupPosition = args.at ? { x: args.at.x, y: args.at.y } : null ;
696 lastNativeMenu = resource;
697 return Promise . resolve ( undefined );
698 }
699
700 const handler = COMMANDS [cmd];
701 if (handler === undefined ) {
702 if ( Object . prototype .hasOwnProperty. call ( COMMANDS , cmd)) {
703 return Promise . resolve ( undefined );
704 }
705 return Promise . reject (
706 new Error ( "Unhandled Tauri invoke in mock: " + cmd),
707 );
708 }
709 if ( typeof handler === "function" ) {
710 try {
711 const result = handler (args);
712 if (cmd === "create_playlist" && result) {
713 playlists. push (result);
714 playlistSongs. set (result.id, []);
715 }
716 return resolveCommandResult (cmd, result);
717 } catch (e) {
718 return Promise . reject (e);
719 }
720 }
721 return resolveCommandResult (cmd, handler);
722 }
723
724 function menuSnapshot ( menu : any ) : any {
725 return {
726 items: menu.items. map (( item : any ) => menuResourceSnapshot (item)),
727 popupPosition: menu.popupPosition,
728 };
729 }
730
731 async function clickMenuItem ( menu : any , label : string ) : Promise < void > {
732 const item = menu.items. find (( candidate : any ) => candidate.label === label);
733 if ( ! item || typeof item.action !== "function" ) {
734 throw new Error ( "Native menu item not found in mock: " + label);
735 }
736 await item. action ();
737 }
738
739 async function clickSubmenuItem (
740 menu : any ,
741 parentLabel : string ,
742 label : string ,
743 ) : Promise < void > {
744 const parent = menu.items. find (
745 ( candidate : any ) => candidate.label === parentLabel,
746 );
747 const item = parent?.items?. find (
748 ( candidate : any ) => candidate.label === label,
749 );
750 if ( ! item || typeof item.action !== "function" ) {
751 throw new Error (
752 "Native submenu item not found in mock: " + parentLabel + " > " + label,
753 );
754 }
755 await item. action ();
756 }
757
758 function generateLargeLibrary ( count : number ) : any [] {
759 const songs = [];
760 const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ;
761 for ( let i = 0 ; i < count; i ++ ) {
762 const letter = letters[i % 26 ];
763 songs. push ({
764 hash: "large-" + i,
765 file_path: "/music/song-" + i + ".mp3" ,
766 audio_source_kind: "original" ,
767 cdg_path: null ,
768 media_g_container: null ,
769 instrumental: false ,
770 language: "en" ,
771 title: letter + " Song " + i,
772 artist: letter + " Artist " + i,
773 album: null ,
774 duration_ms: 180000 ,
775 cover_art: null ,
776 imported_at: count - i,
777 original_ext: ".mp3" ,
778 });
779 }
780 return songs;
781 }
782
783 // If the initial snapshot is already playing (website preview auto-play),
784 // schedule the end-of-song timer so playback loops correctly.
785 if ((currentPlaybackSnapshot as any ).is_playing) {
786 schedulePlaybackEnd ();
787 }
788
789 // ── Return ──
790 return {
791 internals: {
792 metadata: {
793 currentWindow: { label: "main" },
794 currentWebview: { label: "main" },
795 },
796 invoke,
797 transformCallback : function ( callback : ( ... args : any []) => void ) : number {
798 const id = ++ callbackId;
799 callbacks. set (id, callback);
800 return id;
801 },
802 unregisterCallback : function ( id : number ) : void {
803 callbacks. delete (id);
804 },
805 },
806 eventPluginInternals: {
807 unregisterListener : function () {},
808 },
809 helpers: {
810 emitEvent : ( eventName : string , payload : any ) =>
811 emitMockEvent (eventName, payload),
812 setCommandDelayMs : ( cmd : string , delayMs : number ) => {
813 commandDelayMs. set (cmd, Math. max ( 0 , delayMs));
814 },
815 setMockSongs : ( songs : any []) => {
816 mockSongs = songs;
817 },
818 setMockLyrics : ( lyrics : any ) => {
819 mockLyrics = lyrics;
820 },
821 setLargeLibrary : ( count : number ) => {
822 mockSongs = generateLargeLibrary (count);
823 },
824 getInvokeCalls : () => clone (invokeCalls),
825 getLastNativeMenu : () =>
826 lastNativeMenu ? menuSnapshot (lastNativeMenu) : null ,
827 clickNativeMenuItem : async ( label : string ) => {
828 if ( ! lastNativeMenu) throw new Error ( "No native menu has been opened" );
829 await clickMenuItem (lastNativeMenu, label);
830 },
831 clickNativeSubmenuItem : async ( parentLabel : string , label : string ) => {
832 if ( ! lastNativeMenu) throw new Error ( "No native menu has been opened" );
833 await clickSubmenuItem (lastNativeMenu, parentLabel, label);
834 },
835 setPlaybackSnapshot : ( patch : any ) => {
836 const next = { ... currentPlaybackSnapshot, ... patch };
837 if (
838 patch &&
839 patch.stem_volumes &&
840 (currentPlaybackSnapshot as any ).stem_volumes
841 ) {
842 next.stem_volumes = {
843 ... (currentPlaybackSnapshot as any ).stem_volumes,
844 ... patch.stem_volumes,
845 };
846 }
847 currentPlaybackSnapshot = next;
848 emitMockEvent ( "playback-position" , {
849 ms: next.position_ms,
850 transport_generation: next.transport_generation,
851 snapshot: clone (next),
852 });
853 if (
854 patch &&
855 (patch.is_playing !== undefined ||
856 patch.state !== undefined ||
857 patch.position_ms !== undefined ||
858 patch.duration_ms !== undefined )
859 ) {
860 if (next.is_playing && next.state !== "buffering" ) {
861 schedulePlaybackEnd ();
862 } else {
863 clearPlaybackEnd ();
864 }
865 }
866 return clone (next);
867 },
868 setSeparationCompleted : ( songHash : string ) => {
869 separationStatuses[songHash] = {
870 song_id: songHash,
871 state: "completed" ,
872 };
873 emitMockEvent ( "separation-complete" , {
874 song_id: songHash,
875 status: { song_id: songHash, state: "completed" },
876 });
877 },
878 getPlaybackSnapshot : () => clone (currentPlaybackSnapshot),
879 },
880 };
881 }
882