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