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