@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.
- package/CHANGELOG.md +2 -0
- package/dist/cli.js.map +3 -3
- package/dist/youtube-music-cli +0 -0
- package/package.json +1 -1
- package/dist/eslint.config.js +0 -55
- package/dist/package.json +0 -120
- package/dist/scripts/build-cli.js +0 -46
- package/dist/source/app.js +0 -17
- package/dist/source/cli.js +0 -504
- package/dist/source/components/common/ErrorBoundary.js +0 -22
- package/dist/source/components/common/Help.js +0 -18
- package/dist/source/components/common/ShortcutsBar.js +0 -89
- package/dist/source/components/config/ConfigLayout.js +0 -84
- package/dist/source/components/config/KeybindingsLayout.js +0 -107
- package/dist/source/components/export/ExportLayout.js +0 -111
- package/dist/source/components/import/ImportLayout.js +0 -119
- package/dist/source/components/import/ImportProgress.js +0 -73
- package/dist/source/components/layouts/ExploreLayout.js +0 -72
- package/dist/source/components/layouts/HistoryLayout.js +0 -37
- package/dist/source/components/layouts/LyricsLayout.js +0 -89
- package/dist/source/components/layouts/MainLayout.js +0 -190
- package/dist/source/components/layouts/MiniPlayerLayout.js +0 -20
- package/dist/source/components/layouts/PlayerLayout.js +0 -9
- package/dist/source/components/layouts/PluginsLayout.js +0 -77
- package/dist/source/components/layouts/SearchLayout.js +0 -193
- package/dist/source/components/layouts/TrendingLayout.js +0 -59
- package/dist/source/components/player/NowPlaying.js +0 -45
- package/dist/source/components/player/PlayerControls.js +0 -83
- package/dist/source/components/player/ProgressBar.js +0 -19
- package/dist/source/components/player/QueueList.js +0 -36
- package/dist/source/components/player/Suggestions.js +0 -50
- package/dist/source/components/playlist/PlaylistList.js +0 -138
- package/dist/source/components/plugins/PluginInstallDialog.js +0 -41
- package/dist/source/components/plugins/PluginsAvailable.js +0 -55
- package/dist/source/components/plugins/PluginsList.js +0 -18
- package/dist/source/components/search/SearchBar.js +0 -55
- package/dist/source/components/search/SearchHistory.js +0 -35
- package/dist/source/components/search/SearchResults.js +0 -280
- package/dist/source/components/settings/Settings.js +0 -211
- package/dist/source/components/theme/ThemeSwitcher.js +0 -11
- package/dist/source/config/themes.config.js +0 -123
- package/dist/source/contexts/theme.context.js +0 -29
- package/dist/source/hooks/useKeyboard.js +0 -188
- package/dist/source/hooks/useKeyboardBlocker.js +0 -45
- package/dist/source/hooks/useNavigation.js +0 -5
- package/dist/source/hooks/usePlayer.js +0 -43
- package/dist/source/hooks/usePlaylist.js +0 -65
- package/dist/source/hooks/useSearch.js +0 -76
- package/dist/source/hooks/useSleepTimer.js +0 -48
- package/dist/source/hooks/useTerminalSize.js +0 -24
- package/dist/source/hooks/useTheme.js +0 -5
- package/dist/source/hooks/useYouTubeMusic.js +0 -112
- package/dist/source/main.js +0 -127
- package/dist/source/services/cache/cache.service.js +0 -67
- package/dist/source/services/completions/completions.service.js +0 -313
- package/dist/source/services/config/config.service.js +0 -191
- package/dist/source/services/discord/discord-rpc.service.js +0 -95
- package/dist/source/services/download/download.service.js +0 -350
- package/dist/source/services/export/export.service.js +0 -131
- package/dist/source/services/history/history.service.js +0 -83
- package/dist/source/services/import/import.service.js +0 -272
- package/dist/source/services/import/spotify.service.js +0 -171
- package/dist/source/services/import/track-matcher.service.js +0 -271
- package/dist/source/services/import/youtube-import.service.js +0 -84
- package/dist/source/services/logger/logger.service.js +0 -52
- package/dist/source/services/lyrics/lyrics.service.js +0 -93
- package/dist/source/services/mpris/mpris.service.js +0 -78
- package/dist/source/services/notification/notification.service.js +0 -57
- package/dist/source/services/player/dependency-check.service.js +0 -140
- package/dist/source/services/player/player.service.js +0 -478
- package/dist/source/services/player-state/player-state.service.js +0 -123
- package/dist/source/services/plugin/plugin-audio-api.js +0 -36
- package/dist/source/services/plugin/plugin-context.js +0 -256
- package/dist/source/services/plugin/plugin-hooks.service.js +0 -135
- package/dist/source/services/plugin/plugin-installer.service.js +0 -248
- package/dist/source/services/plugin/plugin-loader.service.js +0 -161
- package/dist/source/services/plugin/plugin-permissions.service.js +0 -194
- package/dist/source/services/plugin/plugin-registry.service.js +0 -215
- package/dist/source/services/plugin/plugin-ui-api.js +0 -46
- package/dist/source/services/plugin/plugin-updater.service.js +0 -206
- package/dist/source/services/scrobbling/scrobbling.service.js +0 -115
- package/dist/source/services/sleep-timer/sleep-timer.service.js +0 -45
- package/dist/source/services/version-check/version-check.service.js +0 -121
- package/dist/source/services/web/static-file.service.js +0 -185
- package/dist/source/services/web/web-server-manager.js +0 -507
- package/dist/source/services/web/web-streaming.service.js +0 -292
- package/dist/source/services/web/websocket.server.js +0 -267
- package/dist/source/services/youtube-music/api.js +0 -649
- package/dist/source/services/youtube-music/search.service.js +0 -38
- package/dist/source/stores/history.store.js +0 -64
- package/dist/source/stores/navigation.store.js +0 -90
- package/dist/source/stores/player.store.js +0 -789
- package/dist/source/stores/plugins.store.js +0 -177
- package/dist/source/types/actions.js +0 -1
- package/dist/source/types/cli.types.js +0 -1
- package/dist/source/types/config.types.js +0 -1
- package/dist/source/types/history.types.js +0 -1
- package/dist/source/types/import.types.js +0 -2
- package/dist/source/types/keyboard.types.js +0 -1
- package/dist/source/types/navigation.types.js +0 -1
- package/dist/source/types/player.types.js +0 -1
- package/dist/source/types/playlist.types.js +0 -1
- package/dist/source/types/plugin.types.js +0 -1
- package/dist/source/types/theme.types.js +0 -1
- package/dist/source/types/web.types.js +0 -2
- package/dist/source/types/youtube-music.types.js +0 -1
- package/dist/source/types/youtubei.types.js +0 -3
- package/dist/source/utils/constants.js +0 -135
- package/dist/source/utils/format.js +0 -24
- package/dist/source/utils/icons.js +0 -28
- 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
|
-
}
|