@involvex/youtube-music-cli 0.0.46 → 0.0.48

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 (118) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/cli.js.map +1004 -0
  3. package/dist/source/hooks/usePlayer.d.ts +1 -0
  4. package/dist/source/services/player-state/player-state.service.d.ts +1 -0
  5. package/dist/source/stores/player.store.d.ts +1 -0
  6. package/dist/source/types/actions.d.ts +4 -0
  7. package/dist/source/types/player.types.d.ts +3 -2
  8. package/dist/source/utils/constants.d.ts +1 -0
  9. package/dist/source/utils/icons.d.ts +1 -0
  10. package/dist/youtube-music-cli +0 -0
  11. package/package.json +1 -1
  12. package/dist/eslint.config.js +0 -55
  13. package/dist/package.json +0 -120
  14. package/dist/scripts/build-cli.js +0 -46
  15. package/dist/source/app.js +0 -17
  16. package/dist/source/cli.js +0 -504
  17. package/dist/source/components/common/ErrorBoundary.js +0 -22
  18. package/dist/source/components/common/Help.js +0 -18
  19. package/dist/source/components/common/ShortcutsBar.js +0 -80
  20. package/dist/source/components/config/ConfigLayout.js +0 -84
  21. package/dist/source/components/config/KeybindingsLayout.js +0 -107
  22. package/dist/source/components/export/ExportLayout.js +0 -111
  23. package/dist/source/components/import/ImportLayout.js +0 -119
  24. package/dist/source/components/import/ImportProgress.js +0 -73
  25. package/dist/source/components/layouts/ExploreLayout.js +0 -72
  26. package/dist/source/components/layouts/HistoryLayout.js +0 -37
  27. package/dist/source/components/layouts/LyricsLayout.js +0 -89
  28. package/dist/source/components/layouts/MainLayout.js +0 -190
  29. package/dist/source/components/layouts/MiniPlayerLayout.js +0 -20
  30. package/dist/source/components/layouts/PlayerLayout.js +0 -9
  31. package/dist/source/components/layouts/PluginsLayout.js +0 -77
  32. package/dist/source/components/layouts/SearchLayout.js +0 -193
  33. package/dist/source/components/layouts/TrendingLayout.js +0 -59
  34. package/dist/source/components/player/NowPlaying.js +0 -45
  35. package/dist/source/components/player/PlayerControls.js +0 -83
  36. package/dist/source/components/player/ProgressBar.js +0 -19
  37. package/dist/source/components/player/QueueList.js +0 -36
  38. package/dist/source/components/player/Suggestions.js +0 -50
  39. package/dist/source/components/playlist/PlaylistList.js +0 -138
  40. package/dist/source/components/plugins/PluginInstallDialog.js +0 -41
  41. package/dist/source/components/plugins/PluginsAvailable.js +0 -55
  42. package/dist/source/components/plugins/PluginsList.js +0 -18
  43. package/dist/source/components/search/SearchBar.js +0 -55
  44. package/dist/source/components/search/SearchHistory.js +0 -35
  45. package/dist/source/components/search/SearchResults.js +0 -280
  46. package/dist/source/components/settings/Settings.js +0 -211
  47. package/dist/source/components/theme/ThemeSwitcher.js +0 -11
  48. package/dist/source/config/themes.config.js +0 -123
  49. package/dist/source/contexts/theme.context.js +0 -29
  50. package/dist/source/hooks/useKeyboard.js +0 -188
  51. package/dist/source/hooks/useKeyboardBlocker.js +0 -45
  52. package/dist/source/hooks/useNavigation.js +0 -5
  53. package/dist/source/hooks/usePlayer.js +0 -43
  54. package/dist/source/hooks/usePlaylist.js +0 -65
  55. package/dist/source/hooks/useSearch.js +0 -76
  56. package/dist/source/hooks/useSleepTimer.js +0 -48
  57. package/dist/source/hooks/useTerminalSize.js +0 -24
  58. package/dist/source/hooks/useTheme.js +0 -5
  59. package/dist/source/hooks/useYouTubeMusic.js +0 -112
  60. package/dist/source/main.js +0 -127
  61. package/dist/source/services/cache/cache.service.js +0 -67
  62. package/dist/source/services/completions/completions.service.js +0 -313
  63. package/dist/source/services/config/config.service.js +0 -191
  64. package/dist/source/services/discord/discord-rpc.service.js +0 -95
  65. package/dist/source/services/download/download.service.js +0 -350
  66. package/dist/source/services/export/export.service.js +0 -131
  67. package/dist/source/services/history/history.service.js +0 -83
  68. package/dist/source/services/import/import.service.js +0 -272
  69. package/dist/source/services/import/spotify.service.js +0 -171
  70. package/dist/source/services/import/track-matcher.service.js +0 -271
  71. package/dist/source/services/import/youtube-import.service.js +0 -84
  72. package/dist/source/services/logger/logger.service.js +0 -52
  73. package/dist/source/services/lyrics/lyrics.service.js +0 -93
  74. package/dist/source/services/mpris/mpris.service.js +0 -78
  75. package/dist/source/services/notification/notification.service.js +0 -57
  76. package/dist/source/services/player/dependency-check.service.js +0 -140
  77. package/dist/source/services/player/player.service.js +0 -478
  78. package/dist/source/services/player-state/player-state.service.js +0 -122
  79. package/dist/source/services/plugin/plugin-audio-api.js +0 -36
  80. package/dist/source/services/plugin/plugin-context.js +0 -256
  81. package/dist/source/services/plugin/plugin-hooks.service.js +0 -135
  82. package/dist/source/services/plugin/plugin-installer.service.js +0 -248
  83. package/dist/source/services/plugin/plugin-loader.service.js +0 -161
  84. package/dist/source/services/plugin/plugin-permissions.service.js +0 -194
  85. package/dist/source/services/plugin/plugin-registry.service.js +0 -215
  86. package/dist/source/services/plugin/plugin-ui-api.js +0 -46
  87. package/dist/source/services/plugin/plugin-updater.service.js +0 -206
  88. package/dist/source/services/scrobbling/scrobbling.service.js +0 -115
  89. package/dist/source/services/sleep-timer/sleep-timer.service.js +0 -45
  90. package/dist/source/services/version-check/version-check.service.js +0 -121
  91. package/dist/source/services/web/static-file.service.js +0 -185
  92. package/dist/source/services/web/web-server-manager.js +0 -506
  93. package/dist/source/services/web/web-streaming.service.js +0 -290
  94. package/dist/source/services/web/websocket.server.js +0 -267
  95. package/dist/source/services/youtube-music/api.js +0 -649
  96. package/dist/source/services/youtube-music/search.service.js +0 -38
  97. package/dist/source/stores/history.store.js +0 -64
  98. package/dist/source/stores/navigation.store.js +0 -90
  99. package/dist/source/stores/player.store.js +0 -724
  100. package/dist/source/stores/plugins.store.js +0 -177
  101. package/dist/source/types/actions.js +0 -1
  102. package/dist/source/types/cli.types.js +0 -1
  103. package/dist/source/types/config.types.js +0 -1
  104. package/dist/source/types/history.types.js +0 -1
  105. package/dist/source/types/import.types.js +0 -2
  106. package/dist/source/types/keyboard.types.js +0 -1
  107. package/dist/source/types/navigation.types.js +0 -1
  108. package/dist/source/types/player.types.js +0 -1
  109. package/dist/source/types/playlist.types.js +0 -1
  110. package/dist/source/types/plugin.types.js +0 -1
  111. package/dist/source/types/theme.types.js +0 -1
  112. package/dist/source/types/web.types.js +0 -2
  113. package/dist/source/types/youtube-music.types.js +0 -1
  114. package/dist/source/types/youtubei.types.js +0 -3
  115. package/dist/source/utils/constants.js +0 -134
  116. package/dist/source/utils/format.js +0 -24
  117. package/dist/source/utils/icons.js +0 -26
  118. package/dist/source/utils/search-filters.js +0 -100
@@ -1,724 +0,0 @@
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
- import { getNotificationService } from "../services/notification/notification.service.js";
8
- import { getScrobblingService } from "../services/scrobbling/scrobbling.service.js";
9
- import { getDiscordRpcService } from "../services/discord/discord-rpc.service.js";
10
- import { getMprisService } from "../services/mpris/mpris.service.js";
11
- import { getWebServerManager } from "../services/web/web-server-manager.js";
12
- import { getWebStreamingService } from "../services/web/web-streaming.service.js";
13
- const initialState = {
14
- currentTrack: null,
15
- isPlaying: false,
16
- volume: 70,
17
- speed: 1.0,
18
- progress: 0,
19
- duration: 0,
20
- queue: [],
21
- queuePosition: 0,
22
- repeat: 'off',
23
- shuffle: false,
24
- isLoading: false,
25
- error: null,
26
- playRequestId: 0,
27
- };
28
- // Get player service instance
29
- const playerService = getPlayerService();
30
- export function playerReducer(state, action) {
31
- switch (action.category) {
32
- case 'PLAY':
33
- return {
34
- ...state,
35
- currentTrack: action.track,
36
- isPlaying: true,
37
- progress: 0,
38
- error: null,
39
- playRequestId: state.playRequestId + 1,
40
- };
41
- case 'PAUSE':
42
- return { ...state, isPlaying: false };
43
- case 'RESUME':
44
- return { ...state, isPlaying: true };
45
- case 'STOP':
46
- return {
47
- ...state,
48
- isPlaying: false,
49
- progress: 0,
50
- currentTrack: null,
51
- };
52
- case 'NEXT': {
53
- if (state.queue.length === 0)
54
- return state;
55
- // Shuffle mode: pick a random track excluding the current position
56
- if (state.shuffle && state.queue.length > 1) {
57
- let randomIndex;
58
- do {
59
- randomIndex = Math.floor(Math.random() * state.queue.length);
60
- } while (randomIndex === state.queuePosition);
61
- return {
62
- ...state,
63
- queuePosition: randomIndex,
64
- currentTrack: state.queue[randomIndex] ?? null,
65
- isPlaying: true,
66
- progress: 0,
67
- playRequestId: state.playRequestId + 1,
68
- };
69
- }
70
- // Sequential mode
71
- const nextPosition = state.queuePosition + 1;
72
- if (nextPosition >= state.queue.length) {
73
- if (state.repeat === 'all') {
74
- return {
75
- ...state,
76
- queuePosition: 0,
77
- currentTrack: state.queue[0] ?? null,
78
- isPlaying: true,
79
- progress: 0,
80
- playRequestId: state.playRequestId + 1,
81
- };
82
- }
83
- return state;
84
- }
85
- return {
86
- ...state,
87
- queuePosition: nextPosition,
88
- currentTrack: state.queue[nextPosition] ?? null,
89
- isPlaying: true,
90
- progress: 0,
91
- playRequestId: state.playRequestId + 1,
92
- };
93
- }
94
- case 'PREVIOUS':
95
- const prevPosition = state.queuePosition - 1;
96
- if (prevPosition < 0) {
97
- return state;
98
- }
99
- if (state.progress > 3) {
100
- return {
101
- ...state,
102
- progress: 0,
103
- playRequestId: state.playRequestId + 1,
104
- };
105
- }
106
- return {
107
- ...state,
108
- queuePosition: prevPosition,
109
- currentTrack: state.queue[prevPosition] ?? null,
110
- progress: 0,
111
- playRequestId: state.playRequestId + 1,
112
- };
113
- case 'SEEK':
114
- return {
115
- ...state,
116
- progress: Math.max(0, Math.min(action.position, state.duration)),
117
- };
118
- case 'SET_VOLUME': {
119
- const newVolume = Math.max(0, Math.min(100, action.volume));
120
- playerService.setVolume(newVolume);
121
- return { ...state, volume: newVolume };
122
- }
123
- case 'VOLUME_UP': {
124
- const newVolume = Math.min(100, state.volume + 10);
125
- logger.debug('PlayerReducer', 'VOLUME_UP', {
126
- oldVolume: state.volume,
127
- newVolume,
128
- });
129
- playerService.setVolume(newVolume);
130
- return { ...state, volume: newVolume };
131
- }
132
- case 'VOLUME_DOWN': {
133
- const newVolume = Math.max(0, state.volume - 10);
134
- logger.debug('PlayerReducer', 'VOLUME_DOWN', {
135
- oldVolume: state.volume,
136
- newVolume,
137
- });
138
- playerService.setVolume(newVolume);
139
- return { ...state, volume: newVolume };
140
- }
141
- case 'VOLUME_FINE_UP': {
142
- const newVolume = Math.min(100, state.volume + 1);
143
- playerService.setVolume(newVolume);
144
- return { ...state, volume: newVolume };
145
- }
146
- case 'VOLUME_FINE_DOWN': {
147
- const newVolume = Math.max(0, state.volume - 1);
148
- playerService.setVolume(newVolume);
149
- return { ...state, volume: newVolume };
150
- }
151
- case 'TOGGLE_SHUFFLE':
152
- return { ...state, shuffle: !state.shuffle };
153
- case 'TOGGLE_REPEAT':
154
- const repeatModes = ['off', 'all', 'one'];
155
- const currentIndex = repeatModes.indexOf(state.repeat);
156
- const nextRepeat = repeatModes[(currentIndex + 1) % 3] ?? 'off';
157
- return { ...state, repeat: nextRepeat };
158
- case 'SET_QUEUE':
159
- return {
160
- ...state,
161
- queue: action.queue,
162
- queuePosition: 0,
163
- };
164
- case 'ADD_TO_QUEUE':
165
- return { ...state, queue: [...state.queue, action.track] };
166
- case 'REMOVE_FROM_QUEUE':
167
- const newQueue = [...state.queue];
168
- newQueue.splice(action.index, 1);
169
- return { ...state, queue: newQueue };
170
- case 'CLEAR_QUEUE':
171
- return {
172
- ...state,
173
- queue: [],
174
- queuePosition: 0,
175
- isPlaying: false,
176
- };
177
- case 'SET_QUEUE_POSITION':
178
- if (action.position >= 0 && action.position < state.queue.length) {
179
- return {
180
- ...state,
181
- queuePosition: action.position,
182
- currentTrack: state.queue[action.position] ?? null,
183
- progress: 0,
184
- playRequestId: state.playRequestId + 1,
185
- };
186
- }
187
- return state;
188
- case 'UPDATE_PROGRESS':
189
- // Clamp progress to valid range
190
- const clampedProgress = Math.max(0, Math.min(action.progress, state.duration || action.progress));
191
- return { ...state, progress: clampedProgress };
192
- case 'SET_DURATION':
193
- return { ...state, duration: action.duration };
194
- case 'TICK':
195
- if (state.isPlaying && state.duration > 0) {
196
- const newProgress = state.progress + 1;
197
- // Don't exceed duration
198
- if (newProgress >= state.duration) {
199
- return { ...state, progress: state.duration, isPlaying: false };
200
- }
201
- return { ...state, progress: newProgress };
202
- }
203
- return state;
204
- case 'SET_LOADING':
205
- return { ...state, isLoading: action.loading };
206
- case 'SET_ERROR':
207
- return { ...state, error: action.error, isLoading: false };
208
- case 'SET_SPEED': {
209
- const clampedSpeed = Math.max(0.25, Math.min(4.0, action.speed));
210
- playerService.setSpeed(clampedSpeed);
211
- return { ...state, speed: clampedSpeed };
212
- }
213
- case 'RESTORE_STATE':
214
- logger.info('PlayerReducer', 'RESTORE_STATE', {
215
- hasTrack: !!action.currentTrack,
216
- queueLength: action.queue.length,
217
- });
218
- playerService.setVolume(action.volume);
219
- return {
220
- ...state,
221
- currentTrack: action.currentTrack,
222
- queue: action.queue,
223
- queuePosition: action.queuePosition,
224
- progress: action.progress,
225
- volume: action.volume,
226
- shuffle: action.shuffle,
227
- repeat: action.repeat,
228
- isPlaying: false, // Don't auto-play restored state
229
- };
230
- default:
231
- return state;
232
- }
233
- }
234
- import { getConfigService } from "../services/config/config.service.js";
235
- import { getMusicService } from "../services/youtube-music/api.js";
236
- import { useMemo } from 'react';
237
- const PlayerContext = createContext(null);
238
- function PlayerManager() {
239
- const { state, dispatch, next } = usePlayer();
240
- const progressIntervalRef = useRef(null);
241
- const musicService = getMusicService();
242
- const playerService = getPlayerService();
243
- // Initialize MPRIS (Linux only, no-ops on other platforms)
244
- useEffect(() => {
245
- void getMprisService().initialize({
246
- onPlay: () => dispatch({ category: 'RESUME' }),
247
- onPause: () => dispatch({ category: 'PAUSE' }),
248
- onNext: () => dispatch({ category: 'NEXT' }),
249
- onPrevious: () => dispatch({ category: 'PREVIOUS' }),
250
- });
251
- }, [dispatch]);
252
- // Register event handler for mpv IPC events
253
- const eofTimestampRef = useRef(0);
254
- const lastAutoNextRef = useRef(0);
255
- useEffect(() => {
256
- let lastProgressUpdate = 0;
257
- const PROGRESS_THROTTLE_MS = 1000; // Update progress max once per second
258
- playerService.onEvent(event => {
259
- if (event.duration !== undefined) {
260
- dispatch({ category: 'SET_DURATION', duration: event.duration });
261
- }
262
- if (event.timePos !== undefined) {
263
- // Throttle progress updates to reduce re-renders
264
- const now = Date.now();
265
- if (now - lastProgressUpdate >= PROGRESS_THROTTLE_MS) {
266
- dispatch({ category: 'UPDATE_PROGRESS', progress: event.timePos });
267
- lastProgressUpdate = now;
268
- }
269
- }
270
- if (event.eof) {
271
- // Track ended — record timestamp so we can suppress mpv's spurious
272
- // pause event that immediately follows EOF (idle state).
273
- const now = Date.now();
274
- eofTimestampRef.current = now;
275
- next();
276
- lastAutoNextRef.current = now;
277
- }
278
- if (event.paused !== undefined) {
279
- // mpv sends pause=true when a track ends and it enters idle mode.
280
- // Suppress this for ~2s after EOF to prevent it from overwriting
281
- // the isPlaying:true set by NEXT, which would block autoplay.
282
- if (event.paused && Date.now() - eofTimestampRef.current < 2000) {
283
- return;
284
- }
285
- if (event.paused) {
286
- dispatch({ category: 'PAUSE' });
287
- }
288
- else {
289
- dispatch({ category: 'RESUME' });
290
- }
291
- }
292
- });
293
- }, [playerService, dispatch, next]);
294
- // Initialize audio on mount
295
- useEffect(() => {
296
- const config = getConfigService();
297
- dispatch({ category: 'SET_VOLUME', volume: config.get('volume') });
298
- const currentInterval = progressIntervalRef.current;
299
- return () => {
300
- if (currentInterval) {
301
- clearInterval(currentInterval);
302
- }
303
- playerService.stop();
304
- };
305
- }, [dispatch, playerService]);
306
- // Handle track changes
307
- const lastPlayedRequestId = useRef(-1);
308
- useEffect(() => {
309
- const track = state.currentTrack;
310
- if (!track) {
311
- logger.debug('PlayerManager', 'No current track');
312
- return;
313
- }
314
- // Guard: Don't auto-play during initial state restoration
315
- if (!state.isPlaying) {
316
- logger.info('PlayerManager', 'Skipping auto-play (not playing)', {
317
- title: track.title,
318
- isPlaying: state.isPlaying,
319
- });
320
- return;
321
- }
322
- // Guard: Don't replay same track unless a new play request was explicitly dispatched
323
- const currentTrackId = playerService.getCurrentTrackId?.() || '';
324
- const isSameTrack = currentTrackId === track.videoId;
325
- const isNewPlayRequest = state.playRequestId !== lastPlayedRequestId.current;
326
- if (isSameTrack && !isNewPlayRequest) {
327
- logger.debug('PlayerManager', 'Track already playing, skipping', {
328
- videoId: track.videoId,
329
- });
330
- return;
331
- }
332
- lastPlayedRequestId.current = state.playRequestId;
333
- logger.info('PlayerManager', 'Loading track', {
334
- title: track.title,
335
- videoId: track.videoId,
336
- });
337
- const loadAndPlayTrack = async () => {
338
- // If a detached background session exists for this exact track, reattach
339
- // to the still-running mpv process instead of spawning a new one.
340
- const config = getConfigService();
341
- const bgState = config.getBackgroundPlaybackState();
342
- const trackUrl = `https://www.youtube.com/watch?v=${track.videoId}`;
343
- if (bgState.enabled &&
344
- bgState.ipcPath &&
345
- bgState.currentUrl === trackUrl) {
346
- try {
347
- await playerService.reattach(bgState.ipcPath, {
348
- trackId: track.videoId,
349
- currentUrl: trackUrl,
350
- });
351
- config.clearBackgroundPlaybackState();
352
- dispatch({ category: 'SET_LOADING', loading: false });
353
- logger.info('PlayerManager', 'Reattached to background mpv session');
354
- return;
355
- }
356
- catch (error) {
357
- logger.warn('PlayerManager', 'Failed to reattach background session, starting fresh', {
358
- error: error instanceof Error ? error.message : String(error),
359
- });
360
- config.clearBackgroundPlaybackState();
361
- // Fall through to normal play()
362
- }
363
- }
364
- dispatch({ category: 'SET_LOADING', loading: true });
365
- const MAX_RETRIES = 3;
366
- const RETRY_DELAY_MS = 1500;
367
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
368
- try {
369
- logger.debug('PlayerManager', 'Starting playback with mpv', {
370
- videoId: track.videoId,
371
- volume: state.volume,
372
- attempt,
373
- });
374
- // Pass YouTube URL directly to mpv (it handles stream extraction via yt-dlp)
375
- const youtubeUrl = `https://www.youtube.com/watch?v=${track.videoId}`;
376
- const config = getConfigService();
377
- const artists = track.artists?.map(a => a.name).join(', ') ?? 'Unknown';
378
- // Fire desktop notification if enabled (only on first attempt)
379
- if (attempt === 1 && config.get('notifications')) {
380
- const notificationService = getNotificationService();
381
- notificationService.setEnabled(true);
382
- void notificationService.notifyTrackChange(track.title, artists);
383
- }
384
- // Discord Rich Presence
385
- if (config.get('discordRichPresence')) {
386
- const discord = getDiscordRpcService();
387
- discord.setEnabled(true);
388
- void discord.connect().then(() => discord.updateActivity({
389
- title: track.title,
390
- artist: artists,
391
- startTimestamp: Date.now(),
392
- }));
393
- }
394
- // MPRIS (Linux)
395
- const mpris = getMprisService();
396
- mpris.updateTrack({
397
- title: track.title,
398
- artist: artists,
399
- duration: (track.duration ?? 0) * 1_000_000,
400
- }, true);
401
- await playerService.play(youtubeUrl, {
402
- volume: state.volume,
403
- audioNormalization: config.get('audioNormalization') ?? false,
404
- proxy: config.get('proxy'),
405
- gaplessPlayback: config.get('gaplessPlayback') ?? true,
406
- crossfadeDuration: config.get('crossfadeDuration') ?? 0,
407
- equalizerPreset: config.get('equalizerPreset') ?? 'flat',
408
- volumeFadeDuration: config.get('volumeFadeDuration') ?? 0,
409
- });
410
- logger.info('PlayerManager', 'Playback started successfully', {
411
- attempt,
412
- });
413
- dispatch({ category: 'SET_LOADING', loading: false });
414
- return; // Success
415
- }
416
- catch (error) {
417
- logger.error('PlayerManager', 'Failed to load track', {
418
- error: error instanceof Error ? error.message : String(error),
419
- track: { title: track.title, videoId: track.videoId },
420
- attempt,
421
- });
422
- if (attempt < MAX_RETRIES) {
423
- logger.info('PlayerManager', 'Retrying playback', {
424
- attempt,
425
- nextAttempt: attempt + 1,
426
- delayMs: RETRY_DELAY_MS,
427
- });
428
- await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
429
- }
430
- else {
431
- dispatch({
432
- category: 'SET_ERROR',
433
- error: error instanceof Error
434
- ? `${error.message} (after ${MAX_RETRIES} attempts)`
435
- : 'Failed to load track',
436
- });
437
- }
438
- }
439
- }
440
- };
441
- void loadAndPlayTrack();
442
- // Note: state.volume intentionally excluded - volume changes should not restart playback
443
- // eslint-disable-next-line react-hooks/exhaustive-deps
444
- }, [
445
- state.currentTrack,
446
- state.isPlaying,
447
- state.playRequestId,
448
- dispatch,
449
- musicService,
450
- ]);
451
- // Handle progress tracking
452
- useEffect(() => {
453
- if (state.isPlaying && state.currentTrack) {
454
- const interval = setInterval(() => {
455
- dispatch({ category: 'TICK' });
456
- }, 1000);
457
- return () => {
458
- clearInterval(interval);
459
- };
460
- }
461
- return undefined;
462
- }, [state.isPlaying, state.currentTrack, dispatch]);
463
- // Scrobble when >50% of track has been played
464
- const scrobbledRef = useRef(null);
465
- useEffect(() => {
466
- if (state.currentTrack &&
467
- state.duration > 0 &&
468
- state.progress / state.duration > 0.5 &&
469
- scrobbledRef.current !== state.currentTrack.videoId) {
470
- scrobbledRef.current = state.currentTrack.videoId;
471
- const config = getConfigService();
472
- const scrobblingConfig = config.get('scrobbling');
473
- if (scrobblingConfig) {
474
- const scrobbler = getScrobblingService();
475
- scrobbler.configure(scrobblingConfig);
476
- const artist = state.currentTrack.artists?.[0]?.name ?? 'Unknown';
477
- void scrobbler.scrobble({
478
- title: state.currentTrack.title,
479
- artist,
480
- duration: state.duration,
481
- });
482
- }
483
- }
484
- if (state.currentTrack &&
485
- scrobbledRef.current !== state.currentTrack.videoId &&
486
- state.progress < 1) {
487
- // New track started — reset so we can scrobble again
488
- scrobbledRef.current = null;
489
- }
490
- }, [state.progress, state.duration, state.currentTrack]);
491
- // Handle play/pause state
492
- useEffect(() => {
493
- if (state.isPlaying) {
494
- // Resume only if the same track is already loaded in the player service.
495
- // If the track changed, the "handle track changes" effect will call play().
496
- const currentTrackId = playerService.getCurrentTrackId?.() ?? '';
497
- if (currentTrackId && state.currentTrack?.videoId === currentTrackId) {
498
- playerService.resume();
499
- }
500
- }
501
- else {
502
- playerService.pause();
503
- }
504
- }, [state.isPlaying, state.currentTrack, playerService]);
505
- // Handle volume changes
506
- useEffect(() => {
507
- const config = getConfigService();
508
- config.set('volume', state.volume);
509
- }, [state.volume]);
510
- // Handle track completion
511
- const autoAdvanceRef = useRef(false);
512
- useEffect(() => {
513
- if (state.duration <= 0) {
514
- autoAdvanceRef.current = false;
515
- return;
516
- }
517
- if (state.progress < state.duration) {
518
- autoAdvanceRef.current = false;
519
- return;
520
- }
521
- if (state.repeat === 'one') {
522
- dispatch({ category: 'SEEK', position: 0 });
523
- return;
524
- }
525
- const hasNextTrack = state.queue.length > 0 &&
526
- (state.repeat === 'all' ||
527
- state.queuePosition < state.queue.length - 1 ||
528
- (state.shuffle && state.queue.length > 1));
529
- if (!hasNextTrack) {
530
- return;
531
- }
532
- const now = Date.now();
533
- if (now - lastAutoNextRef.current < 1500) {
534
- return;
535
- }
536
- if (!autoAdvanceRef.current) {
537
- autoAdvanceRef.current = true;
538
- lastAutoNextRef.current = now;
539
- dispatch({ category: 'NEXT' });
540
- }
541
- }, [
542
- state.duration,
543
- state.progress,
544
- state.repeat,
545
- state.queue.length,
546
- state.queuePosition,
547
- state.shuffle,
548
- dispatch,
549
- ]);
550
- return null;
551
- }
552
- export function PlayerProvider({ children }) {
553
- const [state, dispatch] = useReducer(playerReducer, initialState);
554
- const saveTimeoutRef = useRef(null);
555
- const isInitializedRef = useRef(false);
556
- // Load persisted state on mount
557
- useEffect(() => {
558
- void loadPlayerState().then(persistedState => {
559
- if (persistedState && !isInitializedRef.current) {
560
- logger.info('PlayerProvider', 'Restoring persisted state', {
561
- hasTrack: !!persistedState.currentTrack,
562
- queueLength: persistedState.queue.length,
563
- progress: persistedState.progress,
564
- });
565
- // Mark as initialized BEFORE dispatch to prevent re-triggers
566
- isInitializedRef.current = true;
567
- // Restore all state atomically with single dispatch
568
- dispatch({
569
- category: 'RESTORE_STATE',
570
- currentTrack: persistedState.currentTrack,
571
- queue: persistedState.queue,
572
- queuePosition: persistedState.queuePosition,
573
- progress: persistedState.progress,
574
- volume: persistedState.volume,
575
- shuffle: persistedState.shuffle,
576
- repeat: persistedState.repeat,
577
- });
578
- }
579
- });
580
- }, []);
581
- // Save state on changes (debounced for progress updates)
582
- useEffect(() => {
583
- // Don't save during initial load
584
- if (!isInitializedRef.current)
585
- return;
586
- // Debounce saves (every 5 seconds for progress, immediate for other changes)
587
- if (saveTimeoutRef.current) {
588
- clearTimeout(saveTimeoutRef.current);
589
- }
590
- saveTimeoutRef.current = setTimeout(() => {
591
- void savePlayerState({
592
- currentTrack: state.currentTrack,
593
- queue: state.queue,
594
- queuePosition: state.queuePosition,
595
- progress: state.progress,
596
- volume: state.volume,
597
- shuffle: state.shuffle,
598
- repeat: state.repeat,
599
- });
600
- },
601
- // Debounce progress updates (5s), immediate for track/queue changes
602
- state.progress > 0 ? 5000 : 0);
603
- return () => {
604
- if (saveTimeoutRef.current) {
605
- clearTimeout(saveTimeoutRef.current);
606
- }
607
- };
608
- }, [
609
- state.currentTrack,
610
- state.queue,
611
- state.queuePosition,
612
- state.progress,
613
- state.volume,
614
- state.shuffle,
615
- state.repeat,
616
- ]);
617
- // Save immediately on unmount/quit
618
- useEffect(() => {
619
- const stateRef = { current: state }; // Capture state in ref for exit handler
620
- const handleExit = () => {
621
- const currentState = stateRef.current;
622
- void savePlayerState({
623
- currentTrack: currentState.currentTrack,
624
- queue: currentState.queue,
625
- queuePosition: currentState.queuePosition,
626
- progress: currentState.progress,
627
- volume: currentState.volume,
628
- shuffle: currentState.shuffle,
629
- repeat: currentState.repeat,
630
- });
631
- };
632
- process.on('beforeExit', handleExit);
633
- process.on('SIGINT', handleExit);
634
- process.on('SIGTERM', handleExit);
635
- // Update ref when state changes
636
- stateRef.current = state;
637
- return () => {
638
- handleExit(); // Save on component unmount
639
- process.off('beforeExit', handleExit);
640
- process.off('SIGINT', handleExit);
641
- process.off('SIGTERM', handleExit);
642
- };
643
- // Only register handlers once, update via ref
644
- // eslint-disable-next-line react-hooks/exhaustive-deps
645
- }, []);
646
- // Web streaming: Broadcast state changes to connected clients
647
- useEffect(() => {
648
- // Initialize web streaming service and set up command handler
649
- const streamingService = getWebStreamingService();
650
- // Set up handler for incoming commands from web clients
651
- const unsubscribe = streamingService.onMessage(message => {
652
- if (message.type === 'command') {
653
- dispatch(message.action);
654
- }
655
- });
656
- return () => {
657
- unsubscribe();
658
- };
659
- }, [dispatch]);
660
- // Broadcast state changes to web clients
661
- useEffect(() => {
662
- const webServerManager = getWebServerManager();
663
- if (webServerManager.isServerRunning()) {
664
- const streamingService = getWebStreamingService();
665
- streamingService.onStateChange(state);
666
- }
667
- }, [state]);
668
- const actions = useMemo(() => ({
669
- play: (track) => {
670
- logger.info('PlayerProvider', 'play() action dispatched', {
671
- title: track.title,
672
- videoId: track.videoId,
673
- });
674
- dispatch({ category: 'PLAY', track });
675
- },
676
- pause: () => dispatch({ category: 'PAUSE' }),
677
- resume: () => dispatch({ category: 'RESUME' }),
678
- next: () => dispatch({ category: 'NEXT' }),
679
- previous: () => dispatch({ category: 'PREVIOUS' }),
680
- seek: (position) => dispatch({ category: 'SEEK', position }),
681
- setVolume: (volume) => dispatch({ category: 'SET_VOLUME', volume }),
682
- volumeUp: () => {
683
- logger.debug('PlayerActions', 'volumeUp called');
684
- dispatch({ category: 'VOLUME_UP' });
685
- },
686
- volumeDown: () => {
687
- logger.debug('PlayerActions', 'volumeDown called');
688
- dispatch({ category: 'VOLUME_DOWN' });
689
- },
690
- volumeFineUp: () => {
691
- dispatch({ category: 'VOLUME_FINE_UP' });
692
- },
693
- volumeFineDown: () => {
694
- dispatch({ category: 'VOLUME_FINE_DOWN' });
695
- },
696
- toggleShuffle: () => dispatch({ category: 'TOGGLE_SHUFFLE' }),
697
- toggleRepeat: () => dispatch({ category: 'TOGGLE_REPEAT' }),
698
- setQueue: (queue) => dispatch({ category: 'SET_QUEUE', queue }),
699
- addToQueue: (track) => dispatch({ category: 'ADD_TO_QUEUE', track }),
700
- removeFromQueue: (index) => dispatch({ category: 'REMOVE_FROM_QUEUE', index }),
701
- clearQueue: () => dispatch({ category: 'CLEAR_QUEUE' }),
702
- setQueuePosition: (position) => dispatch({ category: 'SET_QUEUE_POSITION', position }),
703
- setSpeed: (speed) => dispatch({ category: 'SET_SPEED', speed }),
704
- speedUp: () => {
705
- dispatch({ category: 'SET_SPEED', speed: (state.speed ?? 1.0) + 0.25 });
706
- },
707
- speedDown: () => {
708
- dispatch({ category: 'SET_SPEED', speed: (state.speed ?? 1.0) - 0.25 });
709
- },
710
- }), [dispatch, state.speed]);
711
- const contextValue = useMemo(() => ({
712
- state,
713
- dispatch, // Needed by PlayerManager
714
- ...actions,
715
- }), [state, dispatch, actions]);
716
- return (_jsxs(PlayerContext.Provider, { value: contextValue, children: [_jsx(PlayerManager, {}), children] }));
717
- }
718
- export function usePlayer() {
719
- const context = useContext(PlayerContext);
720
- if (!context) {
721
- throw new Error('usePlayer must be used within PlayerProvider');
722
- }
723
- return context;
724
- }