@involvex/youtube-music-cli 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +352 -0
  2. package/dist/eslint.config.d.ts +2 -0
  3. package/dist/eslint.config.js +55 -0
  4. package/dist/source/app.d.ts +4 -0
  5. package/dist/source/app.js +17 -0
  6. package/dist/source/cli.d.ts +2 -0
  7. package/dist/source/cli.js +241 -0
  8. package/dist/source/components/common/ErrorBoundary.d.ts +15 -0
  9. package/dist/source/components/common/ErrorBoundary.js +22 -0
  10. package/dist/source/components/common/Help.d.ts +1 -0
  11. package/dist/source/components/common/Help.js +10 -0
  12. package/dist/source/components/common/ShortcutsBar.d.ts +1 -0
  13. package/dist/source/components/common/ShortcutsBar.js +33 -0
  14. package/dist/source/components/config/ConfigLayout.d.ts +1 -0
  15. package/dist/source/components/config/ConfigLayout.js +84 -0
  16. package/dist/source/components/layouts/MainLayout.d.ts +4 -0
  17. package/dist/source/components/layouts/MainLayout.js +83 -0
  18. package/dist/source/components/layouts/PlayerLayout.d.ts +1 -0
  19. package/dist/source/components/layouts/PlayerLayout.js +10 -0
  20. package/dist/source/components/layouts/PluginsLayout.d.ts +1 -0
  21. package/dist/source/components/layouts/PluginsLayout.js +77 -0
  22. package/dist/source/components/layouts/SearchLayout.d.ts +4 -0
  23. package/dist/source/components/layouts/SearchLayout.js +81 -0
  24. package/dist/source/components/player/NowPlaying.d.ts +1 -0
  25. package/dist/source/components/player/NowPlaying.js +21 -0
  26. package/dist/source/components/player/PlayerControls.d.ts +1 -0
  27. package/dist/source/components/player/PlayerControls.js +41 -0
  28. package/dist/source/components/player/ProgressBar.d.ts +1 -0
  29. package/dist/source/components/player/ProgressBar.js +18 -0
  30. package/dist/source/components/player/QueueList.d.ts +4 -0
  31. package/dist/source/components/player/QueueList.js +30 -0
  32. package/dist/source/components/player/Suggestions.d.ts +1 -0
  33. package/dist/source/components/player/Suggestions.js +47 -0
  34. package/dist/source/components/playlist/PlaylistList.d.ts +1 -0
  35. package/dist/source/components/playlist/PlaylistList.js +11 -0
  36. package/dist/source/components/plugins/PluginInstallDialog.d.ts +5 -0
  37. package/dist/source/components/plugins/PluginInstallDialog.js +41 -0
  38. package/dist/source/components/plugins/PluginsAvailable.d.ts +5 -0
  39. package/dist/source/components/plugins/PluginsAvailable.js +55 -0
  40. package/dist/source/components/plugins/PluginsList.d.ts +8 -0
  41. package/dist/source/components/plugins/PluginsList.js +18 -0
  42. package/dist/source/components/search/SearchBar.d.ts +8 -0
  43. package/dist/source/components/search/SearchBar.js +50 -0
  44. package/dist/source/components/search/SearchResults.d.ts +10 -0
  45. package/dist/source/components/search/SearchResults.js +111 -0
  46. package/dist/source/components/settings/Settings.d.ts +1 -0
  47. package/dist/source/components/settings/Settings.js +42 -0
  48. package/dist/source/components/theme/ThemeSwitcher.d.ts +1 -0
  49. package/dist/source/components/theme/ThemeSwitcher.js +11 -0
  50. package/dist/source/config/themes.config.d.ts +3 -0
  51. package/dist/source/config/themes.config.js +63 -0
  52. package/dist/source/contexts/theme.context.d.ts +13 -0
  53. package/dist/source/contexts/theme.context.js +29 -0
  54. package/dist/source/hooks/useKeyboard.d.ts +10 -0
  55. package/dist/source/hooks/useKeyboard.js +104 -0
  56. package/dist/source/hooks/useNavigation.d.ts +1 -0
  57. package/dist/source/hooks/useNavigation.js +5 -0
  58. package/dist/source/hooks/usePlayer.d.ts +23 -0
  59. package/dist/source/hooks/usePlayer.js +35 -0
  60. package/dist/source/hooks/usePlaylist.d.ts +8 -0
  61. package/dist/source/hooks/usePlaylist.js +50 -0
  62. package/dist/source/hooks/useSearch.d.ts +8 -0
  63. package/dist/source/hooks/useSearch.js +76 -0
  64. package/dist/source/hooks/useTerminalSize.d.ts +4 -0
  65. package/dist/source/hooks/useTerminalSize.js +24 -0
  66. package/dist/source/hooks/useTheme.d.ts +6 -0
  67. package/dist/source/hooks/useTheme.js +5 -0
  68. package/dist/source/hooks/useYouTubeMusic.d.ts +11 -0
  69. package/dist/source/hooks/useYouTubeMusic.js +112 -0
  70. package/dist/source/main.d.ts +4 -0
  71. package/dist/source/main.js +69 -0
  72. package/dist/source/services/config/config.service.d.ts +26 -0
  73. package/dist/source/services/config/config.service.js +125 -0
  74. package/dist/source/services/logger/logger.service.d.ts +10 -0
  75. package/dist/source/services/logger/logger.service.js +52 -0
  76. package/dist/source/services/player/player.service.d.ts +58 -0
  77. package/dist/source/services/player/player.service.js +349 -0
  78. package/dist/source/services/player-state/player-state.service.d.ts +24 -0
  79. package/dist/source/services/player-state/player-state.service.js +122 -0
  80. package/dist/source/services/plugin/plugin-audio-api.d.ts +17 -0
  81. package/dist/source/services/plugin/plugin-audio-api.js +36 -0
  82. package/dist/source/services/plugin/plugin-context.d.ts +5 -0
  83. package/dist/source/services/plugin/plugin-context.js +256 -0
  84. package/dist/source/services/plugin/plugin-hooks.service.d.ts +62 -0
  85. package/dist/source/services/plugin/plugin-hooks.service.js +135 -0
  86. package/dist/source/services/plugin/plugin-installer.service.d.ts +27 -0
  87. package/dist/source/services/plugin/plugin-installer.service.js +247 -0
  88. package/dist/source/services/plugin/plugin-loader.service.d.ts +33 -0
  89. package/dist/source/services/plugin/plugin-loader.service.js +161 -0
  90. package/dist/source/services/plugin/plugin-permissions.service.d.ts +72 -0
  91. package/dist/source/services/plugin/plugin-permissions.service.js +194 -0
  92. package/dist/source/services/plugin/plugin-registry.service.d.ts +76 -0
  93. package/dist/source/services/plugin/plugin-registry.service.js +215 -0
  94. package/dist/source/services/plugin/plugin-ui-api.d.ts +25 -0
  95. package/dist/source/services/plugin/plugin-ui-api.js +46 -0
  96. package/dist/source/services/plugin/plugin-updater.service.d.ts +23 -0
  97. package/dist/source/services/plugin/plugin-updater.service.js +206 -0
  98. package/dist/source/services/youtube-music/api.d.ts +13 -0
  99. package/dist/source/services/youtube-music/api.js +371 -0
  100. package/dist/source/services/youtube-music/search.service.d.ts +11 -0
  101. package/dist/source/services/youtube-music/search.service.js +38 -0
  102. package/dist/source/stores/navigation.store.d.ts +10 -0
  103. package/dist/source/stores/navigation.store.js +67 -0
  104. package/dist/source/stores/player.store.d.ts +28 -0
  105. package/dist/source/stores/player.store.js +458 -0
  106. package/dist/source/stores/plugins.store.d.ts +46 -0
  107. package/dist/source/stores/plugins.store.js +177 -0
  108. package/dist/source/types/actions.d.ts +119 -0
  109. package/dist/source/types/actions.js +1 -0
  110. package/dist/source/types/cli.types.d.ts +14 -0
  111. package/dist/source/types/cli.types.js +1 -0
  112. package/dist/source/types/config.types.d.ts +19 -0
  113. package/dist/source/types/config.types.js +1 -0
  114. package/dist/source/types/keyboard.types.d.ts +5 -0
  115. package/dist/source/types/keyboard.types.js +1 -0
  116. package/dist/source/types/navigation.types.d.ts +14 -0
  117. package/dist/source/types/navigation.types.js +1 -0
  118. package/dist/source/types/player.types.d.ts +16 -0
  119. package/dist/source/types/player.types.js +1 -0
  120. package/dist/source/types/playlist.types.d.ts +12 -0
  121. package/dist/source/types/playlist.types.js +1 -0
  122. package/dist/source/types/plugin.types.d.ts +239 -0
  123. package/dist/source/types/plugin.types.js +1 -0
  124. package/dist/source/types/theme.types.d.ts +18 -0
  125. package/dist/source/types/theme.types.js +1 -0
  126. package/dist/source/types/youtube-music.types.d.ts +35 -0
  127. package/dist/source/types/youtube-music.types.js +1 -0
  128. package/dist/source/types/youtubei.types.d.ts +60 -0
  129. package/dist/source/types/youtubei.types.js +3 -0
  130. package/dist/source/utils/constants.d.ts +65 -0
  131. package/dist/source/utils/constants.js +82 -0
  132. package/dist/source/utils/format.d.ts +3 -0
  133. package/dist/source/utils/format.js +24 -0
  134. package/dist/test.d.ts +1 -0
  135. package/dist/test.js +13 -0
  136. package/package.json +100 -0
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useReducer, useMemo, } from 'react';
3
+ const initialState = {
4
+ currentView: 'player',
5
+ previousView: null,
6
+ searchQuery: '',
7
+ searchCategory: 'all',
8
+ searchType: 'all',
9
+ selectedResult: 0,
10
+ selectedPlaylist: 0,
11
+ hasSearched: false,
12
+ searchLimit: 10,
13
+ history: [],
14
+ };
15
+ function navigationReducer(state, action) {
16
+ switch (action.category) {
17
+ case 'NAVIGATE':
18
+ return {
19
+ ...state,
20
+ currentView: action.view,
21
+ previousView: state.currentView,
22
+ history: [...state.history, state.currentView],
23
+ };
24
+ case 'GO_BACK':
25
+ if (state.history.length === 0) {
26
+ return state;
27
+ }
28
+ const previousViews = [...state.history];
29
+ const backView = previousViews.pop();
30
+ return {
31
+ ...state,
32
+ currentView: backView,
33
+ previousView: state.currentView,
34
+ history: previousViews,
35
+ };
36
+ case 'SET_SEARCH_QUERY':
37
+ return { ...state, searchQuery: action.query };
38
+ case 'SET_SEARCH_CATEGORY':
39
+ return { ...state, searchCategory: action.category };
40
+ case 'SET_SELECTED_RESULT':
41
+ return { ...state, selectedResult: action.index };
42
+ case 'SET_SELECTED_PLAYLIST':
43
+ return { ...state, selectedPlaylist: action.index };
44
+ case 'SET_HAS_SEARCHED':
45
+ return { ...state, hasSearched: action.hasSearched };
46
+ case 'SET_SEARCH_LIMIT':
47
+ return {
48
+ ...state,
49
+ searchLimit: Math.max(1, Math.min(50, action.limit)),
50
+ };
51
+ default:
52
+ return state;
53
+ }
54
+ }
55
+ const NavigationContext = createContext(null);
56
+ export function NavigationProvider({ children }) {
57
+ const [state, dispatch] = useReducer(navigationReducer, initialState);
58
+ const contextValue = useMemo(() => ({ state, dispatch }), [state, dispatch]);
59
+ return (_jsx(NavigationContext.Provider, { value: contextValue, children: children }));
60
+ }
61
+ export function useNavigation() {
62
+ const context = useContext(NavigationContext);
63
+ if (!context) {
64
+ throw new Error('useNavigation must be used within NavigationProvider');
65
+ }
66
+ return context;
67
+ }
@@ -0,0 +1,28 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { PlayerState, PlayerAction } from '../types/player.types.ts';
3
+ import type { Track } from '../types/youtube-music.types.ts';
4
+ type PlayerContextValue = {
5
+ state: PlayerState;
6
+ dispatch: (action: PlayerAction) => void;
7
+ play: (track: Track) => void;
8
+ pause: () => void;
9
+ resume: () => void;
10
+ next: () => void;
11
+ previous: () => void;
12
+ seek: (position: number) => void;
13
+ setVolume: (volume: number) => void;
14
+ volumeUp: () => void;
15
+ volumeDown: () => void;
16
+ toggleShuffle: () => void;
17
+ toggleRepeat: () => void;
18
+ setQueue: (queue: Track[]) => void;
19
+ addToQueue: (track: Track) => void;
20
+ removeFromQueue: (index: number) => void;
21
+ clearQueue: () => void;
22
+ setQueuePosition: (position: number) => void;
23
+ };
24
+ export declare function PlayerProvider({ children }: {
25
+ children: ReactNode;
26
+ }): import("react/jsx-runtime").JSX.Element;
27
+ export declare function usePlayer(): PlayerContextValue;
28
+ export {};
@@ -0,0 +1,458 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Player store - manages player state
3
+ import { createContext, useContext, useReducer, useEffect, useRef, } from 'react';
4
+ import { getPlayerService } from "../services/player/player.service.js";
5
+ import { loadPlayerState, savePlayerState, } from "../services/player-state/player-state.service.js";
6
+ import { logger } from "../services/logger/logger.service.js";
7
+ const initialState = {
8
+ currentTrack: null,
9
+ isPlaying: false,
10
+ volume: 70,
11
+ progress: 0,
12
+ duration: 0,
13
+ queue: [],
14
+ queuePosition: 0,
15
+ repeat: 'off',
16
+ shuffle: false,
17
+ isLoading: false,
18
+ error: null,
19
+ };
20
+ // Get player service instance
21
+ const playerService = getPlayerService();
22
+ function playerReducer(state, action) {
23
+ switch (action.category) {
24
+ case 'PLAY':
25
+ return {
26
+ ...state,
27
+ currentTrack: action.track,
28
+ isPlaying: true,
29
+ progress: 0,
30
+ error: null,
31
+ };
32
+ case 'PAUSE':
33
+ return { ...state, isPlaying: false };
34
+ case 'RESUME':
35
+ return { ...state, isPlaying: true };
36
+ case 'STOP':
37
+ return {
38
+ ...state,
39
+ isPlaying: false,
40
+ progress: 0,
41
+ currentTrack: null,
42
+ };
43
+ case 'NEXT':
44
+ const nextPosition = state.queuePosition + 1;
45
+ if (nextPosition >= state.queue.length) {
46
+ if (state.repeat === 'all') {
47
+ return {
48
+ ...state,
49
+ queuePosition: 0,
50
+ currentTrack: state.queue[0] ?? null,
51
+ progress: 0,
52
+ };
53
+ }
54
+ return state;
55
+ }
56
+ return {
57
+ ...state,
58
+ queuePosition: nextPosition,
59
+ currentTrack: state.queue[nextPosition] ?? null,
60
+ progress: 0,
61
+ };
62
+ case 'PREVIOUS':
63
+ const prevPosition = state.queuePosition - 1;
64
+ if (prevPosition < 0) {
65
+ return state;
66
+ }
67
+ if (state.progress > 3) {
68
+ return {
69
+ ...state,
70
+ progress: 0,
71
+ };
72
+ }
73
+ return {
74
+ ...state,
75
+ queuePosition: prevPosition,
76
+ currentTrack: state.queue[prevPosition] ?? null,
77
+ progress: 0,
78
+ };
79
+ case 'SEEK':
80
+ return {
81
+ ...state,
82
+ progress: Math.max(0, Math.min(action.position, state.duration)),
83
+ };
84
+ case 'SET_VOLUME': {
85
+ const newVolume = Math.max(0, Math.min(100, action.volume));
86
+ playerService.setVolume(newVolume);
87
+ return { ...state, volume: newVolume };
88
+ }
89
+ case 'VOLUME_UP': {
90
+ const newVolume = Math.min(100, state.volume + 10);
91
+ logger.debug('PlayerReducer', 'VOLUME_UP', {
92
+ oldVolume: state.volume,
93
+ newVolume,
94
+ });
95
+ playerService.setVolume(newVolume);
96
+ return { ...state, volume: newVolume };
97
+ }
98
+ case 'VOLUME_DOWN': {
99
+ const newVolume = Math.max(0, state.volume - 10);
100
+ logger.debug('PlayerReducer', 'VOLUME_DOWN', {
101
+ oldVolume: state.volume,
102
+ newVolume,
103
+ });
104
+ playerService.setVolume(newVolume);
105
+ return { ...state, volume: newVolume };
106
+ }
107
+ case 'TOGGLE_SHUFFLE':
108
+ return { ...state, shuffle: !state.shuffle };
109
+ case 'TOGGLE_REPEAT':
110
+ const repeatModes = ['off', 'all', 'one'];
111
+ const currentIndex = repeatModes.indexOf(state.repeat);
112
+ const nextRepeat = repeatModes[(currentIndex + 1) % 3] ?? 'off';
113
+ return { ...state, repeat: nextRepeat };
114
+ case 'SET_QUEUE':
115
+ return {
116
+ ...state,
117
+ queue: action.queue,
118
+ queuePosition: 0,
119
+ };
120
+ case 'ADD_TO_QUEUE':
121
+ return { ...state, queue: [...state.queue, action.track] };
122
+ case 'REMOVE_FROM_QUEUE':
123
+ const newQueue = [...state.queue];
124
+ newQueue.splice(action.index, 1);
125
+ return { ...state, queue: newQueue };
126
+ case 'CLEAR_QUEUE':
127
+ return {
128
+ ...state,
129
+ queue: [],
130
+ queuePosition: 0,
131
+ isPlaying: false,
132
+ };
133
+ case 'SET_QUEUE_POSITION':
134
+ if (action.position >= 0 && action.position < state.queue.length) {
135
+ return {
136
+ ...state,
137
+ queuePosition: action.position,
138
+ currentTrack: state.queue[action.position] ?? null,
139
+ progress: 0,
140
+ };
141
+ }
142
+ return state;
143
+ case 'UPDATE_PROGRESS':
144
+ return { ...state, progress: action.progress };
145
+ case 'SET_DURATION':
146
+ return { ...state, duration: action.duration };
147
+ case 'TICK':
148
+ if (state.isPlaying) {
149
+ return { ...state, progress: state.progress + 1 };
150
+ }
151
+ return state;
152
+ case 'SET_LOADING':
153
+ return { ...state, isLoading: action.loading };
154
+ case 'SET_ERROR':
155
+ return { ...state, error: action.error, isLoading: false };
156
+ case 'RESTORE_STATE':
157
+ logger.info('PlayerReducer', 'RESTORE_STATE', {
158
+ hasTrack: !!action.currentTrack,
159
+ queueLength: action.queue.length,
160
+ });
161
+ return {
162
+ ...state,
163
+ currentTrack: action.currentTrack,
164
+ queue: action.queue,
165
+ queuePosition: action.queuePosition,
166
+ progress: action.progress,
167
+ volume: action.volume,
168
+ shuffle: action.shuffle,
169
+ repeat: action.repeat,
170
+ isPlaying: false, // Don't auto-play restored state
171
+ };
172
+ default:
173
+ return state;
174
+ }
175
+ }
176
+ import { getConfigService } from "../services/config/config.service.js";
177
+ import { getMusicService } from "../services/youtube-music/api.js";
178
+ import { useMemo } from 'react';
179
+ const PlayerContext = createContext(null);
180
+ function PlayerManager() {
181
+ const { state, dispatch, next } = usePlayer();
182
+ const progressIntervalRef = useRef(null);
183
+ const musicService = getMusicService();
184
+ const playerService = getPlayerService();
185
+ // Register event handler for mpv IPC events
186
+ useEffect(() => {
187
+ let lastProgressUpdate = 0;
188
+ const PROGRESS_THROTTLE_MS = 1000; // Update progress max once per second
189
+ playerService.onEvent(event => {
190
+ if (event.duration !== undefined) {
191
+ dispatch({ category: 'SET_DURATION', duration: event.duration });
192
+ }
193
+ if (event.timePos !== undefined) {
194
+ // Throttle progress updates to reduce re-renders
195
+ const now = Date.now();
196
+ if (now - lastProgressUpdate >= PROGRESS_THROTTLE_MS) {
197
+ dispatch({ category: 'UPDATE_PROGRESS', progress: event.timePos });
198
+ lastProgressUpdate = now;
199
+ }
200
+ }
201
+ if (event.paused !== undefined) {
202
+ if (event.paused) {
203
+ dispatch({ category: 'PAUSE' });
204
+ }
205
+ else {
206
+ dispatch({ category: 'RESUME' });
207
+ }
208
+ }
209
+ if (event.eof) {
210
+ // Track ended, play next
211
+ next();
212
+ }
213
+ });
214
+ }, [playerService, dispatch, next]);
215
+ // Initialize audio on mount
216
+ useEffect(() => {
217
+ const config = getConfigService();
218
+ dispatch({ category: 'SET_VOLUME', volume: config.get('volume') });
219
+ const currentInterval = progressIntervalRef.current;
220
+ return () => {
221
+ if (currentInterval) {
222
+ clearInterval(currentInterval);
223
+ }
224
+ playerService.stop();
225
+ };
226
+ }, [dispatch, playerService]);
227
+ // Handle track changes
228
+ useEffect(() => {
229
+ const track = state.currentTrack;
230
+ if (!track) {
231
+ logger.debug('PlayerManager', 'No current track');
232
+ return;
233
+ }
234
+ // Guard: Don't auto-play during initial state restoration
235
+ if (!state.isPlaying) {
236
+ logger.info('PlayerManager', 'Skipping auto-play (not playing)', {
237
+ title: track.title,
238
+ isPlaying: state.isPlaying,
239
+ });
240
+ return;
241
+ }
242
+ // Guard: Only play if track actually changed
243
+ const currentTrackId = playerService.getCurrentTrackId?.() || '';
244
+ if (currentTrackId === track.videoId) {
245
+ logger.debug('PlayerManager', 'Track already playing, skipping', {
246
+ videoId: track.videoId,
247
+ });
248
+ return;
249
+ }
250
+ logger.info('PlayerManager', 'Loading track', {
251
+ title: track.title,
252
+ videoId: track.videoId,
253
+ });
254
+ const loadAndPlayTrack = async () => {
255
+ dispatch({ category: 'SET_LOADING', loading: true });
256
+ try {
257
+ logger.debug('PlayerManager', 'Starting playback with mpv', {
258
+ videoId: track.videoId,
259
+ volume: state.volume,
260
+ });
261
+ // Pass YouTube URL directly to mpv (it handles stream extraction via yt-dlp)
262
+ const youtubeUrl = `https://www.youtube.com/watch?v=${track.videoId}`;
263
+ await playerService.play(youtubeUrl, {
264
+ volume: state.volume,
265
+ });
266
+ logger.info('PlayerManager', 'Playback started successfully');
267
+ dispatch({ category: 'SET_LOADING', loading: false });
268
+ }
269
+ catch (error) {
270
+ logger.error('PlayerManager', 'Failed to load track', {
271
+ error: error instanceof Error ? error.message : String(error),
272
+ stack: error instanceof Error ? error.stack : undefined,
273
+ track: { title: track.title, videoId: track.videoId },
274
+ });
275
+ dispatch({
276
+ category: 'SET_ERROR',
277
+ error: error instanceof Error ? error.message : 'Failed to load track',
278
+ });
279
+ }
280
+ };
281
+ void loadAndPlayTrack();
282
+ // Note: state.volume intentionally excluded - volume changes should not restart playback
283
+ // eslint-disable-next-line react-hooks/exhaustive-deps
284
+ }, [state.currentTrack, dispatch, musicService]);
285
+ // Handle progress tracking
286
+ useEffect(() => {
287
+ if (state.isPlaying && state.currentTrack) {
288
+ const interval = setInterval(() => {
289
+ dispatch({ category: 'TICK' });
290
+ }, 1000);
291
+ return () => {
292
+ clearInterval(interval);
293
+ };
294
+ }
295
+ return undefined;
296
+ }, [state.isPlaying, state.currentTrack, dispatch]);
297
+ // Handle play/pause state
298
+ useEffect(() => {
299
+ if (!state.isPlaying) {
300
+ playerService.pause();
301
+ }
302
+ }, [state.isPlaying, playerService]);
303
+ // Handle volume changes
304
+ useEffect(() => {
305
+ const config = getConfigService();
306
+ config.set('volume', state.volume);
307
+ }, [state.volume]);
308
+ // Handle track completion
309
+ useEffect(() => {
310
+ if (state.duration > 0 && state.progress >= state.duration) {
311
+ if (state.repeat === 'one') {
312
+ dispatch({ category: 'SEEK', position: 0 });
313
+ }
314
+ else {
315
+ next();
316
+ }
317
+ }
318
+ }, [state.progress, state.duration, state.repeat, next, dispatch]);
319
+ return null;
320
+ }
321
+ export function PlayerProvider({ children }) {
322
+ const [state, dispatch] = useReducer(playerReducer, initialState);
323
+ const saveTimeoutRef = useRef(null);
324
+ const isInitializedRef = useRef(false);
325
+ // Load persisted state on mount
326
+ useEffect(() => {
327
+ void loadPlayerState().then(persistedState => {
328
+ if (persistedState && !isInitializedRef.current) {
329
+ logger.info('PlayerProvider', 'Restoring persisted state', {
330
+ hasTrack: !!persistedState.currentTrack,
331
+ queueLength: persistedState.queue.length,
332
+ progress: persistedState.progress,
333
+ });
334
+ // Mark as initialized BEFORE dispatch to prevent re-triggers
335
+ isInitializedRef.current = true;
336
+ // Restore all state atomically with single dispatch
337
+ dispatch({
338
+ category: 'RESTORE_STATE',
339
+ currentTrack: persistedState.currentTrack,
340
+ queue: persistedState.queue,
341
+ queuePosition: persistedState.queuePosition,
342
+ progress: persistedState.progress,
343
+ volume: persistedState.volume,
344
+ shuffle: persistedState.shuffle,
345
+ repeat: persistedState.repeat,
346
+ });
347
+ }
348
+ });
349
+ }, []);
350
+ // Save state on changes (debounced for progress updates)
351
+ useEffect(() => {
352
+ // Don't save during initial load
353
+ if (!isInitializedRef.current)
354
+ return;
355
+ // Debounce saves (every 5 seconds for progress, immediate for other changes)
356
+ if (saveTimeoutRef.current) {
357
+ clearTimeout(saveTimeoutRef.current);
358
+ }
359
+ saveTimeoutRef.current = setTimeout(() => {
360
+ void savePlayerState({
361
+ currentTrack: state.currentTrack,
362
+ queue: state.queue,
363
+ queuePosition: state.queuePosition,
364
+ progress: state.progress,
365
+ volume: state.volume,
366
+ shuffle: state.shuffle,
367
+ repeat: state.repeat,
368
+ });
369
+ },
370
+ // Debounce progress updates (5s), immediate for track/queue changes
371
+ state.progress > 0 ? 5000 : 0);
372
+ return () => {
373
+ if (saveTimeoutRef.current) {
374
+ clearTimeout(saveTimeoutRef.current);
375
+ }
376
+ };
377
+ }, [
378
+ state.currentTrack,
379
+ state.queue,
380
+ state.queuePosition,
381
+ state.progress,
382
+ state.volume,
383
+ state.shuffle,
384
+ state.repeat,
385
+ ]);
386
+ // Save immediately on unmount/quit
387
+ useEffect(() => {
388
+ const stateRef = { current: state }; // Capture state in ref for exit handler
389
+ const handleExit = () => {
390
+ const currentState = stateRef.current;
391
+ void savePlayerState({
392
+ currentTrack: currentState.currentTrack,
393
+ queue: currentState.queue,
394
+ queuePosition: currentState.queuePosition,
395
+ progress: currentState.progress,
396
+ volume: currentState.volume,
397
+ shuffle: currentState.shuffle,
398
+ repeat: currentState.repeat,
399
+ });
400
+ };
401
+ process.on('beforeExit', handleExit);
402
+ process.on('SIGINT', handleExit);
403
+ process.on('SIGTERM', handleExit);
404
+ // Update ref when state changes
405
+ stateRef.current = state;
406
+ return () => {
407
+ handleExit(); // Save on component unmount
408
+ process.off('beforeExit', handleExit);
409
+ process.off('SIGINT', handleExit);
410
+ process.off('SIGTERM', handleExit);
411
+ };
412
+ // Only register handlers once, update via ref
413
+ // eslint-disable-next-line react-hooks/exhaustive-deps
414
+ }, []);
415
+ const actions = useMemo(() => ({
416
+ play: (track) => {
417
+ logger.info('PlayerProvider', 'play() action dispatched', {
418
+ title: track.title,
419
+ videoId: track.videoId,
420
+ });
421
+ dispatch({ category: 'PLAY', track });
422
+ },
423
+ pause: () => dispatch({ category: 'PAUSE' }),
424
+ resume: () => dispatch({ category: 'RESUME' }),
425
+ next: () => dispatch({ category: 'NEXT' }),
426
+ previous: () => dispatch({ category: 'PREVIOUS' }),
427
+ seek: (position) => dispatch({ category: 'SEEK', position }),
428
+ setVolume: (volume) => dispatch({ category: 'SET_VOLUME', volume }),
429
+ volumeUp: () => {
430
+ logger.debug('PlayerActions', 'volumeUp called');
431
+ dispatch({ category: 'VOLUME_UP' });
432
+ },
433
+ volumeDown: () => {
434
+ logger.debug('PlayerActions', 'volumeDown called');
435
+ dispatch({ category: 'VOLUME_DOWN' });
436
+ },
437
+ toggleShuffle: () => dispatch({ category: 'TOGGLE_SHUFFLE' }),
438
+ toggleRepeat: () => dispatch({ category: 'TOGGLE_REPEAT' }),
439
+ setQueue: (queue) => dispatch({ category: 'SET_QUEUE', queue }),
440
+ addToQueue: (track) => dispatch({ category: 'ADD_TO_QUEUE', track }),
441
+ removeFromQueue: (index) => dispatch({ category: 'REMOVE_FROM_QUEUE', index }),
442
+ clearQueue: () => dispatch({ category: 'CLEAR_QUEUE' }),
443
+ setQueuePosition: (position) => dispatch({ category: 'SET_QUEUE_POSITION', position }),
444
+ }), [dispatch]);
445
+ const contextValue = useMemo(() => ({
446
+ state,
447
+ dispatch, // Needed by PlayerManager
448
+ ...actions,
449
+ }), [state, dispatch, actions]);
450
+ return (_jsxs(PlayerContext.Provider, { value: contextValue, children: [_jsx(PlayerManager, {}), children] }));
451
+ }
452
+ export function usePlayer() {
453
+ const context = useContext(PlayerContext);
454
+ if (!context) {
455
+ throw new Error('usePlayer must be used within PlayerProvider');
456
+ }
457
+ return context;
458
+ }
@@ -0,0 +1,46 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { PluginInstance, AvailablePlugin, PluginInstallResult } from '../types/plugin.types.ts';
3
+ interface PluginsState {
4
+ installedPlugins: PluginInstance[];
5
+ availablePlugins: AvailablePlugin[];
6
+ selectedIndex: number;
7
+ isLoading: boolean;
8
+ error: string | null;
9
+ lastAction: string | null;
10
+ }
11
+ type PluginsAction = {
12
+ type: 'SET_INSTALLED';
13
+ plugins: PluginInstance[];
14
+ } | {
15
+ type: 'SET_AVAILABLE';
16
+ plugins: AvailablePlugin[];
17
+ } | {
18
+ type: 'SET_SELECTED';
19
+ index: number;
20
+ } | {
21
+ type: 'SET_LOADING';
22
+ loading: boolean;
23
+ } | {
24
+ type: 'SET_ERROR';
25
+ error: string | null;
26
+ } | {
27
+ type: 'SET_LAST_ACTION';
28
+ action: string | null;
29
+ } | {
30
+ type: 'REFRESH';
31
+ };
32
+ interface PluginsContextValue {
33
+ state: PluginsState;
34
+ dispatch: React.Dispatch<PluginsAction>;
35
+ refreshPlugins: () => void;
36
+ installPlugin: (nameOrUrl: string) => Promise<PluginInstallResult>;
37
+ uninstallPlugin: (pluginId: string) => Promise<PluginInstallResult>;
38
+ enablePlugin: (pluginId: string) => Promise<void>;
39
+ disablePlugin: (pluginId: string) => Promise<void>;
40
+ updatePlugin: (pluginId: string) => Promise<void>;
41
+ }
42
+ export declare function PluginsProvider({ children }: {
43
+ children: ReactNode;
44
+ }): import("react/jsx-runtime").JSX.Element;
45
+ export declare function usePlugins(): PluginsContextValue;
46
+ export {};