@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,47 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Suggestions component
|
|
3
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { useYouTubeMusic } from "../../hooks/useYouTubeMusic.js";
|
|
6
|
+
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
7
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
8
|
+
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
9
|
+
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
10
|
+
import { truncate } from "../../utils/format.js";
|
|
11
|
+
export default function Suggestions() {
|
|
12
|
+
const { theme } = useTheme();
|
|
13
|
+
const { state: playerState, play } = usePlayer();
|
|
14
|
+
const { getSuggestions, isLoading } = useYouTubeMusic();
|
|
15
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
16
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (playerState.currentTrack?.videoId) {
|
|
19
|
+
getSuggestions(playerState.currentTrack.videoId).then(setSuggestions);
|
|
20
|
+
}
|
|
21
|
+
}, [playerState.currentTrack?.videoId, getSuggestions]);
|
|
22
|
+
const navigateUp = useCallback(() => {
|
|
23
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
24
|
+
}, []);
|
|
25
|
+
const navigateDown = useCallback(() => {
|
|
26
|
+
setSelectedIndex(prev => Math.min(suggestions.length - 1, prev + 1));
|
|
27
|
+
}, [suggestions.length]);
|
|
28
|
+
const playSelected = useCallback(() => {
|
|
29
|
+
const track = suggestions[selectedIndex];
|
|
30
|
+
if (track) {
|
|
31
|
+
play(track);
|
|
32
|
+
}
|
|
33
|
+
}, [selectedIndex, suggestions, play]);
|
|
34
|
+
useKeyBinding(KEYBINDINGS.UP, navigateUp);
|
|
35
|
+
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
|
|
36
|
+
useKeyBinding(KEYBINDINGS.SELECT, playSelected);
|
|
37
|
+
if (isLoading) {
|
|
38
|
+
return _jsx(Text, { color: theme.colors.accent, children: "Loading suggestions..." });
|
|
39
|
+
}
|
|
40
|
+
if (suggestions.length === 0) {
|
|
41
|
+
return _jsx(Text, { color: theme.colors.dim, children: "No suggestions available" });
|
|
42
|
+
}
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { bold: true, color: theme.colors.primary, children: ["Suggestions based on: ", playerState.currentTrack?.title] }), suggestions.map((track, index) => {
|
|
44
|
+
const isSelected = index === selectedIndex;
|
|
45
|
+
return (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: isSelected ? theme.colors.primary : undefined, color: isSelected ? theme.colors.background : theme.colors.text, bold: isSelected, children: [index + 1, ". ", truncate(track.title, 40), " -", ' ', track.artists?.map(a => a.name).join(', ')] }) }, track.videoId));
|
|
46
|
+
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to play, Esc to go back" }) })] }));
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function PlaylistList(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Playlist list component
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
5
|
+
import { getConfigService } from "../../services/config/config.service.js";
|
|
6
|
+
export default function PlaylistList() {
|
|
7
|
+
const { theme } = useTheme();
|
|
8
|
+
const config = getConfigService();
|
|
9
|
+
const playlists = config.get('playlists');
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Playlists" }) }), playlists.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No playlists yet" })) : (playlists.map((playlist, index) => (_jsxs(Box, { paddingX: 1, children: [_jsxs(Text, { color: theme.colors.primary, children: [index + 1, "."] }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: playlist.name }), _jsxs(Text, { color: theme.colors.dim, children: [_jsx(Text, { children: " " }), "(", playlist.tracks?.length || 0, " tracks)"] })] }, playlist.playlistId || index)))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "c" }), " to create playlist", _jsx(Text, { children: " | " }), _jsx(Text, { color: theme.colors.text, children: "Esc" }), " to go back"] }) })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Plugin install dialog - prompts for plugin name or URL
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import TextInput from 'ink-text-input';
|
|
6
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
7
|
+
import { usePlugins } from "../../stores/plugins.store.js";
|
|
8
|
+
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
9
|
+
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
10
|
+
export default function PluginInstallDialog({ onClose, }) {
|
|
11
|
+
const { theme } = useTheme();
|
|
12
|
+
const { installPlugin, state } = usePlugins();
|
|
13
|
+
const [input, setInput] = useState('');
|
|
14
|
+
const [installing, setInstalling] = useState(false);
|
|
15
|
+
const [result, setResult] = useState(null);
|
|
16
|
+
const handleSubmit = useCallback(async () => {
|
|
17
|
+
if (!input.trim() || installing)
|
|
18
|
+
return;
|
|
19
|
+
setInstalling(true);
|
|
20
|
+
setResult(null);
|
|
21
|
+
const installResult = await installPlugin(input.trim());
|
|
22
|
+
setInstalling(false);
|
|
23
|
+
setResult({
|
|
24
|
+
success: installResult.success,
|
|
25
|
+
message: installResult.success
|
|
26
|
+
? `Successfully installed ${installResult.pluginId}`
|
|
27
|
+
: installResult.error || 'Installation failed',
|
|
28
|
+
});
|
|
29
|
+
if (installResult.success) {
|
|
30
|
+
// Close after a brief delay on success
|
|
31
|
+
setTimeout(onClose, 1500);
|
|
32
|
+
}
|
|
33
|
+
}, [input, installing, installPlugin, onClose]);
|
|
34
|
+
const handleClose = useCallback(() => {
|
|
35
|
+
if (!installing) {
|
|
36
|
+
onClose();
|
|
37
|
+
}
|
|
38
|
+
}, [installing, onClose]);
|
|
39
|
+
useKeyBinding(KEYBINDINGS.BACK, handleClose);
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Install Plugin" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Enter a plugin name (from default repo) or GitHub URL:" }) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: theme.colors.text, children: '> ' }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: "e.g., adblock or https://github.com/user/plugin" })] }), installing && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.colors.warning, children: "Installing..." }) })), result && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: result.success ? theme.colors.success : theme.colors.error, children: [result.success ? '✓' : '✗', " ", result.message] }) })), state.error && !result && (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.colors.error, children: ["Error: ", state.error] }) })), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "Enter" }), " to install,", ' ', _jsx(Text, { color: theme.colors.text, children: "Esc" }), " to cancel"] }) })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Available plugins component - displays plugins from the default repo
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
import { usePlugins } from "../../stores/plugins.store.js";
|
|
7
|
+
// Mock available plugins (in production, these would be fetched from the repo)
|
|
8
|
+
const AVAILABLE_PLUGINS = [
|
|
9
|
+
{
|
|
10
|
+
id: 'adblock',
|
|
11
|
+
name: 'Adblock',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
description: 'Blocks ads by filtering known ad video IDs',
|
|
14
|
+
author: 'involvex',
|
|
15
|
+
repository: 'https://github.com/involvex/youtube-music-cli-plugins',
|
|
16
|
+
installUrl: 'adblock',
|
|
17
|
+
tags: ['audio', 'filter'],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'now-playing',
|
|
21
|
+
name: 'Now Playing',
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
description: 'Shows system notifications when track changes',
|
|
24
|
+
author: 'involvex',
|
|
25
|
+
repository: 'https://github.com/involvex/youtube-music-cli-plugins',
|
|
26
|
+
installUrl: 'now-playing',
|
|
27
|
+
tags: ['notifications'],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'lyrics',
|
|
31
|
+
name: 'Lyrics',
|
|
32
|
+
version: '1.0.0',
|
|
33
|
+
description: 'Displays lyrics for the current track',
|
|
34
|
+
author: 'involvex',
|
|
35
|
+
repository: 'https://github.com/involvex/youtube-music-cli-plugins',
|
|
36
|
+
installUrl: 'lyrics',
|
|
37
|
+
tags: ['ui', 'lyrics'],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
export default function PluginsAvailable({ selectedIndex, }) {
|
|
41
|
+
const { theme } = useTheme();
|
|
42
|
+
const { state } = usePlugins();
|
|
43
|
+
// Use useMemo instead of useEffect to avoid setState in effect
|
|
44
|
+
const plugins = useMemo(() => {
|
|
45
|
+
const installedIds = new Set(state.installedPlugins.map(p => p.manifest.id));
|
|
46
|
+
return AVAILABLE_PLUGINS.filter(p => !installedIds.has(p.id));
|
|
47
|
+
}, [state.installedPlugins]);
|
|
48
|
+
if (plugins.length === 0) {
|
|
49
|
+
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.colors.dim, children: "All available plugins are already installed." }) }));
|
|
50
|
+
}
|
|
51
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.secondary, children: "Available Plugins" }) }), plugins.map((plugin, index) => {
|
|
52
|
+
const isSelected = index === selectedIndex;
|
|
53
|
+
return (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: isSelected ? theme.colors.primary : undefined, color: isSelected ? theme.colors.background : theme.colors.text, bold: isSelected, children: [plugin.name, _jsxs(Text, { color: isSelected ? undefined : theme.colors.dim, children: [' ', "v", plugin.version, " - ", plugin.description] })] }) }, plugin.id));
|
|
54
|
+
})] }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PluginInstance } from '../../types/plugin.types.ts';
|
|
2
|
+
interface PluginsListProps {
|
|
3
|
+
plugins: PluginInstance[];
|
|
4
|
+
selectedIndex: number;
|
|
5
|
+
onToggle?: (pluginId: string) => void;
|
|
6
|
+
}
|
|
7
|
+
export default function PluginsList({ plugins, selectedIndex, }: PluginsListProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Plugins list component - displays installed plugins
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
5
|
+
export default function PluginsList({ plugins, selectedIndex, }) {
|
|
6
|
+
const { theme } = useTheme();
|
|
7
|
+
if (plugins.length === 0) {
|
|
8
|
+
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.colors.dim, children: "No plugins installed. Press 'i' to install a plugin." }) }));
|
|
9
|
+
}
|
|
10
|
+
return (_jsx(Box, { flexDirection: "column", children: plugins.map((plugin, index) => {
|
|
11
|
+
const isSelected = index === selectedIndex;
|
|
12
|
+
const statusIcon = plugin.enabled ? '●' : '○';
|
|
13
|
+
const statusColor = plugin.enabled
|
|
14
|
+
? theme.colors.success
|
|
15
|
+
: theme.colors.dim;
|
|
16
|
+
return (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: isSelected ? theme.colors.primary : undefined, color: isSelected ? theme.colors.background : theme.colors.text, bold: isSelected, children: [_jsx(Text, { color: isSelected ? undefined : statusColor, children: statusIcon }), ' ', plugin.manifest.name, _jsxs(Text, { color: isSelected ? undefined : theme.colors.dim, children: [' ', "v", plugin.manifest.version] })] }) }, plugin.manifest.id));
|
|
17
|
+
}) }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
type Props = {
|
|
3
|
+
onInput: (input: string) => void;
|
|
4
|
+
isActive?: boolean;
|
|
5
|
+
};
|
|
6
|
+
declare function SearchBar({ onInput, isActive }: Props): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
declare const _default: React.MemoExoticComponent<typeof SearchBar>;
|
|
8
|
+
export default _default;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Search bar component
|
|
3
|
+
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
4
|
+
import { useState, useCallback } from 'react';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { SEARCH_TYPE } from "../../utils/constants.js";
|
|
7
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
8
|
+
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
9
|
+
import { Box, Text } from 'ink';
|
|
10
|
+
import TextInput from 'ink-text-input';
|
|
11
|
+
function SearchBar({ onInput, isActive = true }) {
|
|
12
|
+
const { theme } = useTheme();
|
|
13
|
+
const { state: navState, dispatch } = useNavigation();
|
|
14
|
+
const [input, setInput] = useState('');
|
|
15
|
+
const searchTypes = Object.values(SEARCH_TYPE);
|
|
16
|
+
// Handle type switching
|
|
17
|
+
const cycleType = useCallback(() => {
|
|
18
|
+
if (!isActive)
|
|
19
|
+
return;
|
|
20
|
+
const currentIndex = searchTypes.indexOf(navState.searchType);
|
|
21
|
+
const nextIndex = (currentIndex + 1) % searchTypes.length;
|
|
22
|
+
const nextType = searchTypes[nextIndex];
|
|
23
|
+
if (nextType) {
|
|
24
|
+
dispatch({
|
|
25
|
+
category: 'SET_SEARCH_CATEGORY',
|
|
26
|
+
searchType: nextType,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}, [navState.searchType, searchTypes, dispatch, isActive]);
|
|
30
|
+
// Handle submit via ink-text-input's onSubmit
|
|
31
|
+
const handleSubmit = useCallback((value) => {
|
|
32
|
+
if (value && isActive) {
|
|
33
|
+
dispatch({ category: 'SET_SEARCH_QUERY', query: value });
|
|
34
|
+
onInput(value);
|
|
35
|
+
}
|
|
36
|
+
}, [dispatch, onInput, isActive]);
|
|
37
|
+
// Handle clearing search
|
|
38
|
+
const clearSearch = useCallback(() => {
|
|
39
|
+
if (isActive) {
|
|
40
|
+
setInput('');
|
|
41
|
+
onInput('');
|
|
42
|
+
}
|
|
43
|
+
}, [isActive, onInput]);
|
|
44
|
+
useKeyBinding(['tab'], cycleType);
|
|
45
|
+
useKeyBinding(['escape'], clearSearch);
|
|
46
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.colors.secondary, padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: theme.colors.dim, children: "Type: " }), searchTypes.map((type, index) => (_jsxs(Text, { color: navState.searchType === type
|
|
47
|
+
? theme.colors.primary
|
|
48
|
+
: theme.colors.dim, bold: navState.searchType === type, children: [type, index < searchTypes.length - 1 && ' '] }, type))), _jsx(Text, { color: theme.colors.dim, children: " (Tab to switch)" })] }), isActive && (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.primary, children: "Search: " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: "Type to search...", focus: isActive })] })), !isActive && (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.primary, children: "Search: " }), _jsx(Text, { color: theme.colors.dim, children: input || 'Type to search...' })] })), _jsx(Text, { color: theme.colors.dim, children: "Type to search, Enter to search, Tab to change type, Esc to clear" })] }));
|
|
49
|
+
}
|
|
50
|
+
export default React.memo(SearchBar);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { SearchResult } from '../../types/youtube-music.types.ts';
|
|
3
|
+
type Props = {
|
|
4
|
+
results: SearchResult[];
|
|
5
|
+
selectedIndex: number;
|
|
6
|
+
isActive?: boolean;
|
|
7
|
+
};
|
|
8
|
+
declare function SearchResults({ results, selectedIndex, isActive }: Props): import("react/jsx-runtime").JSX.Element | null;
|
|
9
|
+
declare const _default: React.MemoExoticComponent<typeof SearchResults>;
|
|
10
|
+
export default _default;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Search results component
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
|
+
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
8
|
+
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
9
|
+
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
10
|
+
import { truncate } from "../../utils/format.js";
|
|
11
|
+
import { useCallback, useRef, useEffect } from 'react';
|
|
12
|
+
import { logger } from "../../services/logger/logger.service.js";
|
|
13
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
14
|
+
// Generate unique component instance ID
|
|
15
|
+
let instanceCounter = 0;
|
|
16
|
+
function SearchResults({ results, selectedIndex, isActive = true }) {
|
|
17
|
+
const { theme } = useTheme();
|
|
18
|
+
const { dispatch } = useNavigation();
|
|
19
|
+
const { play } = usePlayer();
|
|
20
|
+
const { columns } = useTerminalSize();
|
|
21
|
+
// Track component instance and last action time for debouncing
|
|
22
|
+
const instanceIdRef = useRef(++instanceCounter);
|
|
23
|
+
const lastSelectTime = useRef(0);
|
|
24
|
+
const SELECT_DEBOUNCE_MS = 300; // Prevent duplicate triggers within 300ms
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const instanceId = instanceIdRef.current;
|
|
27
|
+
logger.debug('SearchResults', 'Component mounted', { instanceId });
|
|
28
|
+
return () => {
|
|
29
|
+
logger.debug('SearchResults', 'Component unmounted', { instanceId });
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
// Navigate results with arrow keys
|
|
33
|
+
const navigateUp = useCallback(() => {
|
|
34
|
+
if (!isActive)
|
|
35
|
+
return;
|
|
36
|
+
if (selectedIndex > 0) {
|
|
37
|
+
dispatch({ category: 'SET_SELECTED_RESULT', index: selectedIndex - 1 });
|
|
38
|
+
}
|
|
39
|
+
}, [selectedIndex, dispatch, isActive]);
|
|
40
|
+
const navigateDown = useCallback(() => {
|
|
41
|
+
if (!isActive)
|
|
42
|
+
return;
|
|
43
|
+
if (selectedIndex < results.length - 1) {
|
|
44
|
+
dispatch({ category: 'SET_SELECTED_RESULT', index: selectedIndex + 1 });
|
|
45
|
+
}
|
|
46
|
+
}, [selectedIndex, results.length, dispatch, isActive]);
|
|
47
|
+
// Play selected result
|
|
48
|
+
const playSelected = useCallback(() => {
|
|
49
|
+
logger.debug('SearchResults', 'playSelected called', {
|
|
50
|
+
isActive,
|
|
51
|
+
selectedIndex,
|
|
52
|
+
resultsLength: results.length,
|
|
53
|
+
});
|
|
54
|
+
if (!isActive)
|
|
55
|
+
return;
|
|
56
|
+
const selected = results[selectedIndex];
|
|
57
|
+
logger.info('SearchResults', 'Playing selected track', {
|
|
58
|
+
type: selected?.type,
|
|
59
|
+
title: selected?.type === 'song' ? selected.data.title : 'N/A',
|
|
60
|
+
});
|
|
61
|
+
if (selected && selected.type === 'song') {
|
|
62
|
+
// Clear queue when playing from search results to ensure indices match
|
|
63
|
+
play(selected.data, { clearQueue: true });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
logger.warn('SearchResults', 'Selected item is not a song', {
|
|
67
|
+
type: selected?.type,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}, [selectedIndex, results, play, isActive]);
|
|
71
|
+
// Play selected result handler (memoized to prevent duplicate registrations)
|
|
72
|
+
const handleSelect = useCallback(() => {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const timeSinceLastSelect = now - lastSelectTime.current;
|
|
75
|
+
const instanceId = instanceIdRef.current;
|
|
76
|
+
if (!isActive) {
|
|
77
|
+
logger.debug('SearchResults', 'SELECT ignored, not active', { instanceId });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Debounce to prevent double-triggers
|
|
81
|
+
if (timeSinceLastSelect < SELECT_DEBOUNCE_MS) {
|
|
82
|
+
logger.warn('SearchResults', 'SELECT debounced (duplicate trigger)', {
|
|
83
|
+
instanceId,
|
|
84
|
+
timeSinceLastSelect,
|
|
85
|
+
debounceMs: SELECT_DEBOUNCE_MS,
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
lastSelectTime.current = now;
|
|
90
|
+
logger.debug('SearchResults', 'SELECT key pressed', { isActive, instanceId });
|
|
91
|
+
playSelected();
|
|
92
|
+
}, [isActive, playSelected]);
|
|
93
|
+
useKeyBinding(KEYBINDINGS.UP, navigateUp);
|
|
94
|
+
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
|
|
95
|
+
useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
|
|
96
|
+
// Note: Removed redundant useEffect that was syncing selectedIndex to dispatch
|
|
97
|
+
// This was causing unnecessary re-renders. The selectedIndex is already managed
|
|
98
|
+
// by the parent component (SearchLayout) and passed down as a prop.
|
|
99
|
+
if (results.length === 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// Calculate responsive truncation
|
|
103
|
+
const maxTitleWidth = Math.max(20, Math.floor(columns * 0.4));
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: theme.colors.dim, bold: true, children: ["Results (", results.length, ")"] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: theme.colors.dim, bold: true, children: ['#'.padEnd(6), " ", 'Type'.padEnd(10), " ", 'Title'.padEnd(maxTitleWidth)] }) }), results.map((result, index) => {
|
|
105
|
+
const isSelected = index === selectedIndex;
|
|
106
|
+
const data = result.data;
|
|
107
|
+
const title = 'title' in data ? data.title : 'name' in data ? data.name : 'Unknown';
|
|
108
|
+
return (_jsxs(Box, { paddingX: 1, borderStyle: isSelected ? 'double' : undefined, borderColor: isSelected ? theme.colors.primary : undefined, children: [_jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.dim, bold: isSelected, children: (isSelected ? '> ' : ' ') + (index + 1).toString().padEnd(4) }), _jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.dim, bold: isSelected, children: result.type.toUpperCase().padEnd(10) }), _jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: truncate(title, maxTitleWidth) })] }, index));
|
|
109
|
+
})] }));
|
|
110
|
+
}
|
|
111
|
+
export default React.memo(SearchResults);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function Settings(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Settings component
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import { Box, Text } from 'ink';
|
|
5
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
6
|
+
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
7
|
+
import { getConfigService } from "../../services/config/config.service.js";
|
|
8
|
+
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
9
|
+
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
10
|
+
const QUALITIES = ['low', 'medium', 'high'];
|
|
11
|
+
const SETTINGS_ITEMS = ['Stream Quality', 'Manage Plugins'];
|
|
12
|
+
export default function Settings() {
|
|
13
|
+
const { theme } = useTheme();
|
|
14
|
+
const { dispatch } = useNavigation();
|
|
15
|
+
const config = getConfigService();
|
|
16
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
17
|
+
const [quality, setQuality] = useState(config.get('streamQuality') || 'high');
|
|
18
|
+
const navigateUp = useCallback(() => {
|
|
19
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
20
|
+
}, [setSelectedIndex]);
|
|
21
|
+
const navigateDown = useCallback(() => {
|
|
22
|
+
setSelectedIndex(prev => Math.min(SETTINGS_ITEMS.length - 1, prev + 1));
|
|
23
|
+
}, [setSelectedIndex]);
|
|
24
|
+
const toggleQuality = useCallback(() => {
|
|
25
|
+
const currentIndex = QUALITIES.indexOf(quality);
|
|
26
|
+
const nextQuality = QUALITIES[(currentIndex + 1) % QUALITIES.length];
|
|
27
|
+
setQuality(nextQuality);
|
|
28
|
+
config.set('streamQuality', nextQuality);
|
|
29
|
+
}, [quality, config]);
|
|
30
|
+
const handleSelect = useCallback(() => {
|
|
31
|
+
if (selectedIndex === 0) {
|
|
32
|
+
toggleQuality();
|
|
33
|
+
}
|
|
34
|
+
else if (selectedIndex === 1) {
|
|
35
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
|
|
36
|
+
}
|
|
37
|
+
}, [selectedIndex, toggleQuality, dispatch]);
|
|
38
|
+
useKeyBinding(KEYBINDINGS.UP, navigateUp);
|
|
39
|
+
useKeyBinding(KEYBINDINGS.DOWN, navigateDown);
|
|
40
|
+
useKeyBinding(KEYBINDINGS.SELECT, handleSelect);
|
|
41
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Settings" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 0 ? theme.colors.primary : undefined, color: selectedIndex === 0 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 0, children: ["Stream Quality: ", quality.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, children: "Manage Plugins" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to select, Esc/q to go back" }) })] }));
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function ThemeSwitcher(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Theme switcher component
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
5
|
+
import { BUILTIN_THEMES } from "../../config/themes.config.js";
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
export default function ThemeSwitcher() {
|
|
8
|
+
const { theme } = useTheme();
|
|
9
|
+
const [expanded, _setExpanded] = useState(false);
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, children: [_jsxs(Text, { bold: true, color: theme.colors.primary, children: ["Theme: ", theme.name] }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.dim, children: "(Enter to change, Esc to close)" })] }), expanded ? (_jsx(_Fragment, { children: Object.keys(BUILTIN_THEMES).map(themeName => (_jsxs(Box, { paddingX: 2, children: [_jsx(Text, { color: theme.colors.text, children: "\u2192 " }), _jsx(Text, { color: theme.colors.dim, children: themeName }), _jsx(Text, { children: " " }), _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "Enter" }), " to select"] })] }, themeName))) })) : (_jsxs(Box, { paddingX: 2, children: [_jsx(Text, { color: theme.colors.dim, children: "Press " }), _jsx(Text, { color: theme.colors.text, children: "Enter" }), _jsx(Text, { color: theme.colors.dim, children: " to browse themes" })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.colors.dim, children: ["Current: ", _jsx(Text, { color: theme.colors.primary, children: theme.name })] }) })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const BUILTIN_THEMES = {
|
|
2
|
+
dark: {
|
|
3
|
+
name: 'Dark',
|
|
4
|
+
colors: {
|
|
5
|
+
primary: 'cyan',
|
|
6
|
+
secondary: 'blue',
|
|
7
|
+
background: 'black',
|
|
8
|
+
text: 'white',
|
|
9
|
+
accent: 'yellow',
|
|
10
|
+
dim: 'gray',
|
|
11
|
+
error: 'red',
|
|
12
|
+
success: 'green',
|
|
13
|
+
warning: 'yellow',
|
|
14
|
+
},
|
|
15
|
+
inverse: false,
|
|
16
|
+
},
|
|
17
|
+
light: {
|
|
18
|
+
name: 'Light',
|
|
19
|
+
colors: {
|
|
20
|
+
primary: 'blue',
|
|
21
|
+
secondary: 'cyan',
|
|
22
|
+
background: 'white',
|
|
23
|
+
text: 'black',
|
|
24
|
+
accent: 'magenta',
|
|
25
|
+
dim: 'gray',
|
|
26
|
+
error: 'red',
|
|
27
|
+
success: 'green',
|
|
28
|
+
warning: 'yellow',
|
|
29
|
+
},
|
|
30
|
+
inverse: false,
|
|
31
|
+
},
|
|
32
|
+
midnight: {
|
|
33
|
+
name: 'Midnight',
|
|
34
|
+
colors: {
|
|
35
|
+
primary: 'magenta',
|
|
36
|
+
secondary: 'purple',
|
|
37
|
+
background: 'black',
|
|
38
|
+
text: 'white',
|
|
39
|
+
accent: 'cyan',
|
|
40
|
+
dim: 'gray',
|
|
41
|
+
error: 'red',
|
|
42
|
+
success: 'greenBright',
|
|
43
|
+
warning: 'yellowBright',
|
|
44
|
+
},
|
|
45
|
+
inverse: false,
|
|
46
|
+
},
|
|
47
|
+
matrix: {
|
|
48
|
+
name: 'Matrix',
|
|
49
|
+
colors: {
|
|
50
|
+
primary: 'green',
|
|
51
|
+
secondary: 'greenBright',
|
|
52
|
+
background: 'black',
|
|
53
|
+
text: 'white',
|
|
54
|
+
accent: 'greenBright',
|
|
55
|
+
dim: 'green',
|
|
56
|
+
error: 'red',
|
|
57
|
+
success: 'greenBright',
|
|
58
|
+
warning: 'yellow',
|
|
59
|
+
},
|
|
60
|
+
inverse: false,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
export const DEFAULT_THEME = BUILTIN_THEMES['dark'];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { Theme } from '../types/theme.types.ts';
|
|
3
|
+
type ThemeContextValue = {
|
|
4
|
+
theme: Theme;
|
|
5
|
+
themeName: string;
|
|
6
|
+
setTheme: (name: string) => void;
|
|
7
|
+
setCustomTheme: (theme: Theme) => void;
|
|
8
|
+
};
|
|
9
|
+
export declare function ThemeProvider({ children }: {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export declare function useTheme(): ThemeContextValue;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Theme context and provider
|
|
3
|
+
import { createContext, useContext, useState, useCallback, } from 'react';
|
|
4
|
+
import { getConfigService } from "../services/config/config.service.js";
|
|
5
|
+
const ThemeContext = createContext(null);
|
|
6
|
+
export function ThemeProvider({ children }) {
|
|
7
|
+
const [theme, setThemeState] = useState(getConfigService().getTheme());
|
|
8
|
+
const [themeName, setThemeNameState] = useState(getConfigService().get('theme'));
|
|
9
|
+
const setTheme = useCallback((name) => {
|
|
10
|
+
const configService = getConfigService();
|
|
11
|
+
configService.updateTheme(name);
|
|
12
|
+
setThemeNameState(name);
|
|
13
|
+
setThemeState(configService.getTheme());
|
|
14
|
+
}, []);
|
|
15
|
+
const setCustomTheme = useCallback((themeValue) => {
|
|
16
|
+
const configService = getConfigService();
|
|
17
|
+
configService.setCustomTheme(themeValue);
|
|
18
|
+
setThemeNameState('custom');
|
|
19
|
+
setThemeState(themeValue);
|
|
20
|
+
}, []);
|
|
21
|
+
return (_jsx(ThemeContext.Provider, { value: { theme, themeName, setTheme, setCustomTheme }, children: children }));
|
|
22
|
+
}
|
|
23
|
+
export function useTheme() {
|
|
24
|
+
const context = useContext(ThemeContext);
|
|
25
|
+
if (!context) {
|
|
26
|
+
throw new Error('useTheme must be used within ThemeProvider');
|
|
27
|
+
}
|
|
28
|
+
return context;
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to bind keyboard shortcuts.
|
|
3
|
+
* This uses a centralized manager to avoid multiple useInput calls and memory leaks.
|
|
4
|
+
*/
|
|
5
|
+
export declare function useKeyBinding(keys: readonly string[], handler: () => void): void;
|
|
6
|
+
/**
|
|
7
|
+
* Global Keyboard Manager Component
|
|
8
|
+
* This should be rendered once at the root of the app.
|
|
9
|
+
*/
|
|
10
|
+
export declare function KeyboardManager(): null;
|