@involvex/youtube-music-cli 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/source/components/common/ShortcutsBar.js +3 -1
  2. package/dist/source/components/config/KeybindingsLayout.d.ts +1 -0
  3. package/dist/source/components/config/KeybindingsLayout.js +107 -0
  4. package/dist/source/components/layouts/ExploreLayout.d.ts +1 -0
  5. package/dist/source/components/layouts/ExploreLayout.js +72 -0
  6. package/dist/source/components/layouts/LyricsLayout.d.ts +1 -0
  7. package/dist/source/components/layouts/LyricsLayout.js +89 -0
  8. package/dist/source/components/layouts/MainLayout.js +39 -1
  9. package/dist/source/components/layouts/MiniPlayerLayout.d.ts +1 -0
  10. package/dist/source/components/layouts/MiniPlayerLayout.js +19 -0
  11. package/dist/source/components/layouts/PlayerLayout.js +1 -2
  12. package/dist/source/components/layouts/SearchLayout.js +10 -3
  13. package/dist/source/components/layouts/TrendingLayout.d.ts +1 -0
  14. package/dist/source/components/layouts/TrendingLayout.js +59 -0
  15. package/dist/source/components/player/NowPlaying.js +28 -5
  16. package/dist/source/components/player/PlayerControls.js +4 -2
  17. package/dist/source/components/player/ProgressBar.js +6 -5
  18. package/dist/source/components/player/QueueList.d.ts +1 -1
  19. package/dist/source/components/player/QueueList.js +11 -5
  20. package/dist/source/components/search/SearchBar.js +4 -1
  21. package/dist/source/components/search/SearchHistory.d.ts +5 -0
  22. package/dist/source/components/search/SearchHistory.js +35 -0
  23. package/dist/source/components/settings/Settings.js +74 -11
  24. package/dist/source/config/themes.config.js +60 -0
  25. package/dist/source/hooks/usePlayer.d.ts +5 -0
  26. package/dist/source/hooks/usePlaylist.d.ts +2 -1
  27. package/dist/source/hooks/usePlaylist.js +8 -2
  28. package/dist/source/hooks/useSleepTimer.d.ts +9 -0
  29. package/dist/source/hooks/useSleepTimer.js +48 -0
  30. package/dist/source/services/cache/cache.service.d.ts +14 -0
  31. package/dist/source/services/cache/cache.service.js +67 -0
  32. package/dist/source/services/config/config.service.d.ts +2 -0
  33. package/dist/source/services/config/config.service.js +17 -0
  34. package/dist/source/services/discord/discord-rpc.service.d.ts +17 -0
  35. package/dist/source/services/discord/discord-rpc.service.js +95 -0
  36. package/dist/source/services/lyrics/lyrics.service.d.ts +22 -0
  37. package/dist/source/services/lyrics/lyrics.service.js +93 -0
  38. package/dist/source/services/mpris/mpris.service.d.ts +20 -0
  39. package/dist/source/services/mpris/mpris.service.js +78 -0
  40. package/dist/source/services/notification/notification.service.d.ts +14 -0
  41. package/dist/source/services/notification/notification.service.js +57 -0
  42. package/dist/source/services/player/player.service.d.ts +3 -0
  43. package/dist/source/services/player/player.service.js +20 -3
  44. package/dist/source/services/plugin/plugin-installer.service.js +2 -1
  45. package/dist/source/services/scrobbling/scrobbling.service.d.ts +23 -0
  46. package/dist/source/services/scrobbling/scrobbling.service.js +115 -0
  47. package/dist/source/services/sleep-timer/sleep-timer.service.d.ts +16 -0
  48. package/dist/source/services/sleep-timer/sleep-timer.service.js +45 -0
  49. package/dist/source/services/youtube-music/api.d.ts +6 -0
  50. package/dist/source/services/youtube-music/api.js +102 -2
  51. package/dist/source/stores/navigation.store.js +6 -0
  52. package/dist/source/stores/player.store.d.ts +5 -0
  53. package/dist/source/stores/player.store.js +151 -27
  54. package/dist/source/types/actions.d.ts +13 -0
  55. package/dist/source/types/config.types.d.ts +15 -1
  56. package/dist/source/types/navigation.types.d.ts +3 -2
  57. package/dist/source/types/player.types.d.ts +3 -2
  58. package/dist/source/utils/constants.d.ts +9 -0
  59. package/dist/source/utils/constants.js +9 -0
  60. package/dist/youtube-music-cli.exe +0 -0
  61. package/package.json +5 -2
@@ -10,7 +10,7 @@ import { KEYBINDINGS } from "../../utils/constants.js";
10
10
  export default function ShortcutsBar() {
11
11
  const { theme } = useTheme();
12
12
  const { dispatch: navDispatch } = useNavigation();
13
- const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, } = usePlayer();
13
+ const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, volumeFineUp, volumeFineDown, } = usePlayer();
14
14
  // Register key bindings globally
15
15
  const handlePlayPause = () => {
16
16
  if (playerState.isPlaying) {
@@ -28,6 +28,8 @@ export default function ShortcutsBar() {
28
28
  useKeyBinding(KEYBINDINGS.PREVIOUS, previous);
29
29
  useKeyBinding(KEYBINDINGS.VOLUME_UP, volumeUp);
30
30
  useKeyBinding(KEYBINDINGS.VOLUME_DOWN, volumeDown);
31
+ useKeyBinding(KEYBINDINGS.VOLUME_FINE_UP, volumeFineUp);
32
+ useKeyBinding(KEYBINDINGS.VOLUME_FINE_DOWN, volumeFineDown);
31
33
  useKeyBinding(KEYBINDINGS.SETTINGS, goConfig);
32
34
  return (_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.dim, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { color: theme.colors.dim, children: ["Shortcuts: ", _jsx(Text, { color: theme.colors.text, children: "Space" }), " Play/Pause |", ' ', _jsx(Text, { color: theme.colors.text, children: "n" }), " Next |", ' ', _jsx(Text, { color: theme.colors.text, children: "p" }), " Previous |", ' ', _jsx(Text, { color: theme.colors.text, children: "/" }), " Search |", ' ', _jsx(Text, { color: theme.colors.text, children: "," }), " Settings |", ' ', _jsx(Text, { color: theme.colors.text, children: "?" }), " Help |", ' ', _jsx(Text, { color: theme.colors.text, children: "q" }), " Quit"] }), _jsxs(Text, { color: theme.colors.text, children: [_jsx(Text, { color: theme.colors.dim, children: "[=/" }), "-", _jsx(Text, { color: theme.colors.dim, children: "]" }), " Vol:", ' ', _jsxs(Text, { color: theme.colors.primary, children: [playerState.volume, "%"] })] })] }));
33
35
  }
@@ -0,0 +1 @@
1
+ export default function KeybindingsLayout(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Custom keybindings editor — shows all actions and their bound keys
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { useState } from 'react';
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 { KEYBINDINGS } from "../../utils/constants.js";
9
+ function buildEntries() {
10
+ const config = getConfigService();
11
+ return Object.entries(KEYBINDINGS).map(([action, defaultKeys]) => {
12
+ const custom = config.getKeybinding(action);
13
+ return {
14
+ action,
15
+ label: action
16
+ .toLowerCase()
17
+ .replace(/_/g, ' ')
18
+ .replace(/\b\w/g, c => c.toUpperCase()),
19
+ keys: custom ?? [...defaultKeys],
20
+ };
21
+ });
22
+ }
23
+ export default function KeybindingsLayout() {
24
+ const { theme } = useTheme();
25
+ const { dispatch } = useNavigation();
26
+ const [entries, setEntries] = useState(buildEntries);
27
+ const [selectedIndex, setSelectedIndex] = useState(0);
28
+ const [isCapturing, setIsCapturing] = useState(false);
29
+ const [statusMessage, setStatusMessage] = useState('');
30
+ useInput((input, key) => {
31
+ if (isCapturing) {
32
+ // Build key string from the pressed key
33
+ const parts = [];
34
+ if (key.ctrl)
35
+ parts.push('ctrl');
36
+ if (key.meta)
37
+ parts.push('meta');
38
+ if (key.shift)
39
+ parts.push('shift');
40
+ let keyName = input;
41
+ if (key.upArrow)
42
+ keyName = 'up';
43
+ else if (key.downArrow)
44
+ keyName = 'down';
45
+ else if (key.leftArrow)
46
+ keyName = 'left';
47
+ else if (key.rightArrow)
48
+ keyName = 'right';
49
+ else if (key.return)
50
+ keyName = 'enter';
51
+ else if (key.tab)
52
+ keyName = 'tab';
53
+ else if (key.backspace || key.delete)
54
+ keyName = 'backspace';
55
+ else if (key.escape) {
56
+ setIsCapturing(false);
57
+ setStatusMessage('Cancelled');
58
+ return;
59
+ }
60
+ if (!keyName || keyName.length === 0)
61
+ return;
62
+ parts.push(keyName);
63
+ const newKey = parts.join('+');
64
+ // Persist new binding
65
+ const entry = entries[selectedIndex];
66
+ if (!entry)
67
+ return;
68
+ getConfigService().setKeybinding(entry.action, [newKey]);
69
+ setEntries(buildEntries());
70
+ setIsCapturing(false);
71
+ setStatusMessage(`Bound ${entry.action} to "${newKey}"`);
72
+ return;
73
+ }
74
+ if (key.escape) {
75
+ dispatch({ category: 'GO_BACK' });
76
+ return;
77
+ }
78
+ if (key.upArrow || input === 'k') {
79
+ setSelectedIndex(i => Math.max(0, i - 1));
80
+ }
81
+ else if (key.downArrow || input === 'j') {
82
+ setSelectedIndex(i => Math.min(entries.length - 1, i + 1));
83
+ }
84
+ else if (key.return) {
85
+ setIsCapturing(true);
86
+ setStatusMessage('Press any key to bind...');
87
+ }
88
+ else if (input === 'r') {
89
+ // Reset selected binding to default
90
+ const entry = entries[selectedIndex];
91
+ if (!entry)
92
+ return;
93
+ const defaultKeys = KEYBINDINGS[entry.action];
94
+ if (defaultKeys) {
95
+ getConfigService().setKeybinding(entry.action, [
96
+ ...defaultKeys,
97
+ ]);
98
+ setEntries(buildEntries());
99
+ setStatusMessage(`Reset ${entry.action} to default`);
100
+ }
101
+ }
102
+ });
103
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.primary, bold: true, children: "Custom Keybindings" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.dim, children: "\u2191/\u2193 Navigate | Enter Edit | r Reset | Esc Back" }) }), statusMessage ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.secondary, children: statusMessage }) })) : null, entries.map((entry, index) => {
104
+ const isSelected = index === selectedIndex;
105
+ return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: isSelected ? '▶ ' : ' ' }), _jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: entry.label.padEnd(25) }), _jsx(Text, { color: theme.colors.secondary, children: entry.keys.join(', ') })] }, entry.action));
106
+ }), isCapturing ? (_jsxs(Box, { marginTop: 1, borderStyle: "single", borderColor: theme.colors.secondary, padding: 1, children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: ["Press any key combination...", ' '] }), _jsx(Text, { color: theme.colors.dim, children: "(Esc to cancel)" })] })) : null] }));
107
+ }
@@ -0,0 +1 @@
1
+ export default function ExploreLayout(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,72 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Explore / Genre browsing view — shows curated sections from YouTube Music
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { useState, useEffect } from 'react';
5
+ import { useTheme } from "../../hooks/useTheme.js";
6
+ import { useNavigation } from "../../hooks/useNavigation.js";
7
+ import { usePlayer } from "../../hooks/usePlayer.js";
8
+ import { getMusicService } from "../../services/youtube-music/api.js";
9
+ export default function ExploreLayout() {
10
+ const { theme } = useTheme();
11
+ const { dispatch } = useNavigation();
12
+ const { play } = usePlayer();
13
+ const [sections, setSections] = useState([]);
14
+ const [sectionIndex, setSectionIndex] = useState(0);
15
+ const [trackIndex, setTrackIndex] = useState(0);
16
+ const [isLoading, setIsLoading] = useState(true);
17
+ const [error, setError] = useState(null);
18
+ useEffect(() => {
19
+ let cancelled = false;
20
+ getMusicService()
21
+ .getExploreSections()
22
+ .then(results => {
23
+ if (!cancelled) {
24
+ setSections(results);
25
+ setIsLoading(false);
26
+ }
27
+ })
28
+ .catch((err) => {
29
+ if (!cancelled) {
30
+ setError(err instanceof Error ? err.message : 'Failed to load explore');
31
+ setIsLoading(false);
32
+ }
33
+ });
34
+ return () => {
35
+ cancelled = true;
36
+ };
37
+ }, []);
38
+ const currentSection = sections[sectionIndex];
39
+ const tracks = currentSection?.tracks ?? [];
40
+ useInput((input, key) => {
41
+ if (key.escape) {
42
+ dispatch({ category: 'GO_BACK' });
43
+ return;
44
+ }
45
+ if (key.leftArrow || input === 'h') {
46
+ setSectionIndex(i => Math.max(0, i - 1));
47
+ setTrackIndex(0);
48
+ }
49
+ else if (key.rightArrow || input === 'l') {
50
+ setSectionIndex(i => Math.min(sections.length - 1, i + 1));
51
+ setTrackIndex(0);
52
+ }
53
+ else if (key.upArrow || input === 'k') {
54
+ setTrackIndex(i => Math.max(0, i - 1));
55
+ }
56
+ else if (key.downArrow || input === 'j') {
57
+ setTrackIndex(i => Math.min(tracks.length - 1, i + 1));
58
+ }
59
+ else if (key.return) {
60
+ const track = tracks[trackIndex];
61
+ if (track)
62
+ play(track);
63
+ }
64
+ });
65
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.primary, bold: true, children: "\uD83C\uDFB5 Explore" }) }), isLoading ? (_jsx(Text, { color: theme.colors.dim, children: "Loading explore sections..." })) : error ? (_jsx(Text, { color: theme.colors.error, children: error })) : sections.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No sections found" })) : (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, gap: 2, children: sections.map((section, index) => (_jsx(Text, { color: index === sectionIndex
66
+ ? theme.colors.primary
67
+ : theme.colors.dim, bold: index === sectionIndex, underline: index === sectionIndex, children: section.title }, section.title))) }), tracks.map((track, index) => {
68
+ const isSelected = index === trackIndex;
69
+ const artist = track.artists?.[0]?.name ?? 'Unknown';
70
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.dim, children: isSelected ? '▶ ' : `${String(index + 1).padStart(2)}. ` }), _jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: track.title }), _jsxs(Text, { color: theme.colors.dim, children: [" \u2014 ", artist] })] }, track.videoId));
71
+ })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "\u2190/\u2192 Sections | \u2191/\u2193 Tracks | Enter Play | Esc Back" }) })] }));
72
+ }
@@ -0,0 +1 @@
1
+ export default function LyricsLayout(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Lyrics view layout - displays synced or plain lyrics
3
+ import { useState, useEffect } from 'react';
4
+ import { Box, Text } from 'ink';
5
+ import { useTheme } from "../../hooks/useTheme.js";
6
+ import { usePlayer } from "../../hooks/usePlayer.js";
7
+ import { getLyricsService, } from "../../services/lyrics/lyrics.service.js";
8
+ import { useTerminalSize } from "../../hooks/useTerminalSize.js";
9
+ const CONTEXT_LINES = 3; // Lines shown before/after current line
10
+ export default function LyricsLayout() {
11
+ const { theme } = useTheme();
12
+ const { state } = usePlayer();
13
+ const { rows } = useTerminalSize();
14
+ const [lyrics, setLyrics] = useState(null);
15
+ const [loading, setLoading] = useState(false);
16
+ const [error, setError] = useState(null);
17
+ const lyricsService = getLyricsService();
18
+ // Fetch lyrics when track changes
19
+ useEffect(() => {
20
+ const track = state.currentTrack;
21
+ let cancelled = false;
22
+ if (!track) {
23
+ queueMicrotask(() => {
24
+ if (!cancelled) {
25
+ setLyrics(null);
26
+ setLoading(false);
27
+ setError(null);
28
+ }
29
+ });
30
+ return;
31
+ }
32
+ const artist = track.artists?.[0]?.name ?? '';
33
+ queueMicrotask(() => {
34
+ if (!cancelled) {
35
+ setLoading(true);
36
+ setError(null);
37
+ }
38
+ });
39
+ void lyricsService
40
+ .getLyrics(track.title, artist, state.duration || undefined)
41
+ .then(result => {
42
+ if (cancelled) {
43
+ return;
44
+ }
45
+ setLyrics(result);
46
+ setLoading(false);
47
+ if (!result) {
48
+ setError('No lyrics found');
49
+ }
50
+ })
51
+ .catch(() => {
52
+ if (cancelled) {
53
+ return;
54
+ }
55
+ setLoading(false);
56
+ setError('Failed to load lyrics');
57
+ });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [lyricsService, state.currentTrack, state.duration]);
62
+ const track = state.currentTrack;
63
+ const title = track?.title ?? 'No track playing';
64
+ const artist = track?.artists?.map(a => a.name).join(', ') ?? '';
65
+ // Determine current line
66
+ const currentLineIndex = lyrics?.synced
67
+ ? lyricsService.getCurrentLineIndex(lyrics.synced, state.progress)
68
+ : -1;
69
+ // Calculate visible lines window
70
+ const visibleLines = (() => {
71
+ if (!lyrics?.synced)
72
+ return null;
73
+ const start = Math.max(0, currentLineIndex - CONTEXT_LINES);
74
+ const maxLines = Math.max(5, rows - 8);
75
+ const end = Math.min(lyrics.synced.length, start + maxLines);
76
+ return lyrics.synced.slice(start, end).map((line, i) => ({
77
+ line,
78
+ globalIndex: start + i,
79
+ }));
80
+ })();
81
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: title }), artist && _jsxs(Text, { color: theme.colors.secondary, children: [" \u2014 ", artist] })] }), loading && _jsx(Text, { color: theme.colors.accent, children: "Loading lyrics..." }), error && !loading && _jsx(Text, { color: theme.colors.dim, children: error }), !loading && visibleLines && (_jsx(Box, { flexDirection: "column", paddingX: 1, children: visibleLines.map(({ line, globalIndex }) => (_jsxs(Text, { bold: globalIndex === currentLineIndex, color: globalIndex === currentLineIndex
82
+ ? theme.colors.primary
83
+ : globalIndex < currentLineIndex
84
+ ? theme.colors.dim
85
+ : theme.colors.text, children: [globalIndex === currentLineIndex ? '▶ ' : ' ', line.text || '♪'] }, globalIndex))) })), !loading && !lyrics?.synced && lyrics?.plain && (_jsx(Box, { flexDirection: "column", paddingX: 1, children: lyrics.plain
86
+ .split('\n')
87
+ .slice(0, Math.max(5, rows - 8))
88
+ .map((line, i) => (_jsx(Text, { color: theme.colors.text, children: line || ' ' }, i))) })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.colors.dim, children: ["Press ", _jsx(Text, { color: theme.colors.text, children: "l" }), " or", ' ', _jsx(Text, { color: theme.colors.text, children: "Esc" }), " to go back"] }) })] }));
89
+ }
@@ -9,11 +9,17 @@ import { useTheme } from "../../hooks/useTheme.js";
9
9
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
10
10
  import SearchLayout from "./SearchLayout.js";
11
11
  import PlayerLayout from "./PlayerLayout.js";
12
+ import MiniPlayerLayout from "./MiniPlayerLayout.js";
12
13
  import PluginsLayout from "./PluginsLayout.js";
13
14
  import Suggestions from "../player/Suggestions.js";
14
15
  import Settings from "../settings/Settings.js";
15
16
  import ConfigLayout from "../config/ConfigLayout.js";
16
17
  import ShortcutsBar from "../common/ShortcutsBar.js";
18
+ import LyricsLayout from "./LyricsLayout.js";
19
+ import SearchHistory from "../search/SearchHistory.js";
20
+ import KeybindingsLayout from "../config/KeybindingsLayout.js";
21
+ import TrendingLayout from "./TrendingLayout.js";
22
+ import ExploreLayout from "./ExploreLayout.js";
17
23
  import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
18
24
  import { Box } from 'ink';
19
25
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
@@ -47,6 +53,18 @@ function MainLayout() {
47
53
  // From other views, go back
48
54
  dispatch({ category: 'GO_BACK' });
49
55
  }, [navState.currentView, dispatch]);
56
+ const goToLyrics = useCallback(() => {
57
+ dispatch({ category: 'NAVIGATE', view: VIEW.LYRICS });
58
+ }, [dispatch]);
59
+ const goToTrending = useCallback(() => {
60
+ dispatch({ category: 'NAVIGATE', view: VIEW.TRENDING });
61
+ }, [dispatch]);
62
+ const goToExplore = useCallback(() => {
63
+ dispatch({ category: 'NAVIGATE', view: VIEW.EXPLORE });
64
+ }, [dispatch]);
65
+ const togglePlayerMode = useCallback(() => {
66
+ dispatch({ category: 'TOGGLE_PLAYER_MODE' });
67
+ }, [dispatch]);
50
68
  // Global keyboard bindings
51
69
  useKeyBinding(KEYBINDINGS.QUIT, handleQuit);
52
70
  useKeyBinding(KEYBINDINGS.SEARCH, goToSearch);
@@ -54,14 +72,26 @@ function MainLayout() {
54
72
  useKeyBinding(KEYBINDINGS.SUGGESTIONS, goToSuggestions);
55
73
  useKeyBinding(KEYBINDINGS.SETTINGS, goToSettings);
56
74
  useKeyBinding(KEYBINDINGS.HELP, goToHelp);
75
+ useKeyBinding(['m'], togglePlayerMode);
76
+ useKeyBinding(['l'], goToLyrics);
77
+ useKeyBinding(['T'], goToTrending);
78
+ useKeyBinding(['e'], goToExplore);
57
79
  // Memoize the view component to prevent unnecessary remounts
58
80
  // Only recreate when currentView actually changes
59
81
  const currentView = useMemo(() => {
82
+ // In mini mode, only show the mini player bar
83
+ if (navState.playerMode === 'mini') {
84
+ return _jsx(MiniPlayerLayout, {}, "mini-player");
85
+ }
60
86
  switch (navState.currentView) {
61
87
  case 'player':
62
88
  return _jsx(PlayerLayout, {}, "player");
63
89
  case 'search':
64
90
  return _jsx(SearchLayout, {}, "search");
91
+ case 'search_history':
92
+ return (_jsx(SearchHistory, { onSelect: query => {
93
+ dispatch({ category: 'SET_SEARCH_QUERY', query });
94
+ } }, "search_history"));
65
95
  case 'playlists':
66
96
  return _jsx(PlaylistList, {}, "playlists");
67
97
  case 'suggestions':
@@ -72,12 +102,20 @@ function MainLayout() {
72
102
  return _jsx(PluginsLayout, {}, "plugins");
73
103
  case 'config':
74
104
  return _jsx(ConfigLayout, {}, "config");
105
+ case 'lyrics':
106
+ return _jsx(LyricsLayout, {}, "lyrics");
107
+ case 'keybindings':
108
+ return _jsx(KeybindingsLayout, {}, "keybindings");
109
+ case 'trending':
110
+ return _jsx(TrendingLayout, {}, "trending");
111
+ case 'explore':
112
+ return _jsx(ExploreLayout, {}, "explore");
75
113
  case 'help':
76
114
  return _jsx(Help, {}, "help");
77
115
  default:
78
116
  return _jsx(PlayerLayout, {}, "player-default");
79
117
  }
80
- }, [navState.currentView]);
118
+ }, [navState.currentView, navState.playerMode, dispatch]);
81
119
  return (_jsxs(Box, { flexDirection: "column", paddingX: getPadding(), borderStyle: "single", borderColor: theme.colors.primary, children: [currentView, _jsx(ShortcutsBar, {})] }));
82
120
  }
83
121
  export default React.memo(MainLayout);
@@ -0,0 +1 @@
1
+ export default function MiniPlayerLayout(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Mini player layout - compact single-line player
3
+ import { Box, Text } from 'ink';
4
+ import { usePlayer } from "../../hooks/usePlayer.js";
5
+ import { useTheme } from "../../hooks/useTheme.js";
6
+ import { formatTime } from "../../utils/format.js";
7
+ export default function MiniPlayerLayout() {
8
+ const { theme } = useTheme();
9
+ const { state } = usePlayer();
10
+ const track = state.currentTrack;
11
+ const artist = track?.artists?.map(a => a.name).join(', ') ?? 'Unknown';
12
+ const title = track?.title ?? 'No track playing';
13
+ const progress = formatTime(state.progress);
14
+ const duration = formatTime(state.duration);
15
+ const playIcon = state.isPlaying ? '▶' : '⏸';
16
+ const vol = `${state.volume}%`;
17
+ const speed = (state.speed ?? 1.0) !== 1.0 ? ` ${(state.speed ?? 1.0).toFixed(2)}x` : '';
18
+ return (_jsxs(Box, { flexDirection: "row", paddingX: 1, gap: 1, children: [_jsx(Text, { color: state.isPlaying ? theme.colors.success : theme.colors.dim, children: playIcon }), _jsx(Text, { bold: true, color: theme.colors.primary, children: title }), _jsx(Text, { color: theme.colors.dim, children: "\u2014" }), _jsx(Text, { color: theme.colors.secondary, children: artist }), _jsx(Text, { color: theme.colors.dim, children: "|" }), _jsxs(Text, { color: theme.colors.text, children: [progress, "/", duration] }), _jsx(Text, { color: theme.colors.dim, children: "|" }), _jsxs(Text, { color: theme.colors.text, children: ["vol:", vol] }), speed && _jsx(Text, { color: theme.colors.accent, children: speed }), state.isLoading && _jsx(Text, { color: theme.colors.accent, children: "Loading..." })] }));
19
+ }
@@ -1,10 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { usePlayer } from "../../hooks/usePlayer.js";
3
3
  import NowPlaying from "../player/NowPlaying.js";
4
- import ProgressBar from "../player/ProgressBar.js";
5
4
  import QueueList from "../player/QueueList.js";
6
5
  import { Box } from 'ink';
7
6
  export default function PlayerLayout() {
8
7
  const { state: playerState } = usePlayer();
9
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(NowPlaying, {}), _jsx(ProgressBar, {}), playerState.queue.length > 0 && _jsx(QueueList, {})] }));
8
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(NowPlaying, {}), playerState.queue.length > 0 && _jsx(QueueList, {})] }));
10
9
  }
@@ -8,7 +8,7 @@ import React from 'react';
8
8
  import { useTheme } from "../../hooks/useTheme.js";
9
9
  import SearchBar from "../search/SearchBar.js";
10
10
  import { useKeyBinding } from "../../hooks/useKeyboard.js";
11
- import { KEYBINDINGS } from "../../utils/constants.js";
11
+ import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
12
12
  import { Box, Text } from 'ink';
13
13
  function SearchLayout() {
14
14
  const { theme } = useTheme();
@@ -45,6 +45,13 @@ function SearchLayout() {
45
45
  }, [navState.searchLimit, dispatch]);
46
46
  useKeyBinding(KEYBINDINGS.INCREASE_RESULTS, increaseLimit);
47
47
  useKeyBinding(KEYBINDINGS.DECREASE_RESULTS, decreaseLimit);
48
+ // Open search history
49
+ const goToHistory = useCallback(() => {
50
+ if (isTyping) {
51
+ dispatch({ category: 'NAVIGATE', view: VIEW.SEARCH_HISTORY });
52
+ }
53
+ }, [isTyping, dispatch]);
54
+ useKeyBinding(['h'], goToHistory);
48
55
  // Initial search if query is in state (usually from CLI flags)
49
56
  useEffect(() => {
50
57
  if (navState.searchQuery && !navState.hasSearched) {
@@ -75,7 +82,7 @@ function SearchLayout() {
75
82
  return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { borderStyle: "single", borderColor: theme.colors.secondary, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: "Search" }), _jsxs(Text, { color: theme.colors.dim, children: [' ', "| Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] })] }), _jsx(SearchBar, { isActive: isTyping && !isSearching, onInput: input => {
76
83
  void performSearch(input);
77
84
  } }), (isLoading || isSearching) && (_jsx(Text, { color: theme.colors.accent, children: "Searching..." })), error && _jsx(Text, { color: theme.colors.error, children: error }), !isLoading && navState.hasSearched && (_jsx(SearchResults, { results: results, selectedIndex: navState.selectedResult, isActive: !isTyping })), !isLoading && navState.hasSearched && results.length === 0 && !error && (_jsx(Text, { color: theme.colors.dim, children: "No results found" })), _jsx(Text, { color: theme.colors.dim, children: isTyping
78
- ? 'Type to search, Enter to start'
79
- : 'Arrows to navigate, Enter to play, Esc to type again' })] }));
85
+ ? 'Type to search, Enter to start, H for history'
86
+ : `Arrows to navigate, Enter to play, ]/[ more/fewer results (${navState.searchLimit}), Esc to type` })] }));
80
87
  }
81
88
  export default React.memo(SearchLayout);
@@ -0,0 +1 @@
1
+ export default function TrendingLayout(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Trending tracks view — shows YouTube trending music
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { useState, useEffect } from 'react';
5
+ import { useTheme } from "../../hooks/useTheme.js";
6
+ import { useNavigation } from "../../hooks/useNavigation.js";
7
+ import { usePlayer } from "../../hooks/usePlayer.js";
8
+ import { getMusicService } from "../../services/youtube-music/api.js";
9
+ export default function TrendingLayout() {
10
+ const { theme } = useTheme();
11
+ const { dispatch } = useNavigation();
12
+ const { play } = usePlayer();
13
+ const [tracks, setTracks] = useState([]);
14
+ const [selectedIndex, setSelectedIndex] = useState(0);
15
+ const [isLoading, setIsLoading] = useState(true);
16
+ const [error, setError] = useState(null);
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+ getMusicService()
20
+ .getTrending()
21
+ .then(results => {
22
+ if (!cancelled) {
23
+ setTracks(results);
24
+ setIsLoading(false);
25
+ }
26
+ })
27
+ .catch((err) => {
28
+ if (!cancelled) {
29
+ setError(err instanceof Error ? err.message : 'Failed to load trending');
30
+ setIsLoading(false);
31
+ }
32
+ });
33
+ return () => {
34
+ cancelled = true;
35
+ };
36
+ }, []);
37
+ useInput((input, key) => {
38
+ if (key.escape) {
39
+ dispatch({ category: 'GO_BACK' });
40
+ return;
41
+ }
42
+ if (key.upArrow || input === 'k') {
43
+ setSelectedIndex(i => Math.max(0, i - 1));
44
+ }
45
+ else if (key.downArrow || input === 'j') {
46
+ setSelectedIndex(i => Math.min(tracks.length - 1, i + 1));
47
+ }
48
+ else if (key.return) {
49
+ const track = tracks[selectedIndex];
50
+ if (track)
51
+ play(track);
52
+ }
53
+ });
54
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.primary, bold: true, children: "\uD83D\uDD25 Trending Music" }) }), isLoading ? (_jsx(Text, { color: theme.colors.dim, children: "Loading trending tracks..." })) : error ? (_jsx(Text, { color: theme.colors.error, children: error })) : tracks.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No trending tracks found" })) : (tracks.map((track, index) => {
55
+ const isSelected = index === selectedIndex;
56
+ const artist = track.artists?.[0]?.name ?? 'Unknown';
57
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.dim, children: isSelected ? '▶ ' : `${String(index + 1).padStart(2)}. ` }), _jsx(Text, { color: isSelected ? theme.colors.primary : theme.colors.text, bold: isSelected, children: track.title }), _jsxs(Text, { color: theme.colors.dim, children: [" \u2014 ", artist] })] }, track.videoId));
58
+ })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "\u2191/\u2193 Navigate | Enter Play | Esc Back" }) })] }));
59
+ }
@@ -5,17 +5,40 @@ import { usePlayer } from "../../hooks/usePlayer.js";
5
5
  import { useTheme } from "../../hooks/useTheme.js";
6
6
  import { formatTime } from "../../utils/format.js";
7
7
  import { useTerminalSize } from "../../hooks/useTerminalSize.js";
8
+ import { getSleepTimerService } from "../../services/sleep-timer/sleep-timer.service.js";
9
+ import { useState, useEffect } from 'react';
8
10
  export default function NowPlaying() {
9
11
  const { theme } = useTheme();
10
12
  const { state: playerState } = usePlayer();
11
13
  const { columns } = useTerminalSize();
14
+ const sleepTimer = getSleepTimerService();
15
+ const [sleepRemaining, setSleepRemaining] = useState(null);
16
+ // Poll sleep timer remaining every second
17
+ useEffect(() => {
18
+ if (!sleepTimer.isActive()) {
19
+ return;
20
+ }
21
+ const interval = setInterval(() => {
22
+ const remaining = sleepTimer.getRemainingSeconds();
23
+ setSleepRemaining(remaining);
24
+ if (remaining === null || remaining === 0) {
25
+ clearInterval(interval);
26
+ }
27
+ }, 1000);
28
+ return () => {
29
+ clearInterval(interval);
30
+ };
31
+ }, [sleepTimer]);
12
32
  if (!playerState.currentTrack) {
13
- return (_jsx(Box, { borderStyle: "round", borderColor: theme.colors.dim, padding: 1, marginY: 1, children: _jsx(Text, { color: theme.colors.dim, children: "No track playing" }) }));
33
+ return (_jsx(Box, { borderStyle: "round", borderColor: theme.colors.dim, paddingX: 1, children: _jsx(Text, { color: theme.colors.dim, children: "No track playing" }) }));
14
34
  }
15
35
  const track = playerState.currentTrack;
16
36
  const artists = track.artists?.map(a => a.name).join(', ') || 'Unknown Artist';
17
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.primary, padding: 1, marginY: 1, children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: track.title }), _jsx(Text, { color: theme.colors.secondary, children: artists }), track.album && _jsx(Text, { color: theme.colors.dim, children: track.album.name }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.text, children: formatTime(playerState.progress) }), _jsx(Text, { children: " " }), _jsxs(Text, { color: theme.colors.dim, children: ["[", Math.round((playerState.progress / (playerState.duration || 1)) * 100), "%]"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.text, children: formatTime(playerState.duration) })] }), playerState.duration > 0 && (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.primary, children: '■'.repeat(Math.floor((playerState.progress / playerState.duration) * (columns - 10))) }), _jsx(Text, { color: theme.colors.dim, children: '-'.repeat(Math.max(0, columns -
18
- 10 -
19
- Math.floor((playerState.progress / playerState.duration) *
20
- (columns - 10)))) })] })), playerState.isLoading && (_jsx(Text, { color: theme.colors.accent, children: "Loading..." })), playerState.error && (_jsx(Text, { color: theme.colors.error, children: playerState.error }))] }));
37
+ // Clamp progress to valid range
38
+ const progress = Math.max(0, Math.min(playerState.progress, playerState.duration || 0));
39
+ const duration = playerState.duration || 0;
40
+ const percentage = duration > 0 ? Math.min(100, Math.floor((progress / duration) * 100)) : 0;
41
+ const barWidth = Math.max(10, columns - 8);
42
+ const filledWidth = duration > 0 ? Math.floor((progress / duration) * barWidth) : 0;
43
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.primary, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: track.title }), _jsx(Text, { color: theme.colors.dim, children: " \u2022 " }), _jsx(Text, { color: theme.colors.secondary, children: artists })] }), track.album && _jsx(Text, { color: theme.colors.dim, children: track.album.name }), _jsxs(Box, { children: [_jsx(Text, { color: theme.colors.primary, children: '█'.repeat(Math.min(filledWidth, barWidth)) }), _jsx(Text, { color: theme.colors.dim, children: '░'.repeat(Math.max(0, barWidth - filledWidth)) })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.colors.text, children: formatTime(progress) }), _jsxs(Text, { color: theme.colors.dim, children: [" / ", formatTime(duration), " "] }), _jsxs(Text, { color: theme.colors.dim, children: ["[", percentage, "%]"] }), playerState.isLoading && (_jsx(Text, { color: theme.colors.accent, children: " Loading..." })), !playerState.isPlaying && progress > 0 && (_jsx(Text, { color: theme.colors.dim, children: " \u23F8" })), sleepRemaining !== null && (_jsxs(Text, { color: theme.colors.warning, children: [' ', "\u23FE ", formatTime(sleepRemaining)] }))] }), playerState.error && (_jsx(Text, { color: theme.colors.error, children: playerState.error }))] }));
21
44
  }
@@ -17,7 +17,7 @@ export default function PlayerControls() {
17
17
  };
18
18
  }, [instanceId]);
19
19
  const { theme } = useTheme();
20
- const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, } = usePlayer();
20
+ const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, speedUp, speedDown, } = usePlayer();
21
21
  // DEBUG: Log when callbacks change (detect instability)
22
22
  useEffect(() => {
23
23
  // Temporarily output to stderr to debug without triggering Ink re-render
@@ -37,5 +37,7 @@ export default function PlayerControls() {
37
37
  useKeyBinding(KEYBINDINGS.PREVIOUS, previous);
38
38
  useKeyBinding(KEYBINDINGS.VOLUME_UP, volumeUp);
39
39
  useKeyBinding(KEYBINDINGS.VOLUME_DOWN, volumeDown);
40
- return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 2, borderStyle: "classic", borderColor: theme.colors.dim, children: [_jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "b" }), "] Prev"] }), _jsx(Text, { color: theme.colors.primary, children: playerState.isPlaying ? (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Pause"] })) : (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Play"] })) }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "n" }), "] Next"] }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "+/-" }), "] Vol: ", playerState.volume, "%"] })] }));
40
+ useKeyBinding(KEYBINDINGS.SPEED_UP, speedUp);
41
+ useKeyBinding(KEYBINDINGS.SPEED_DOWN, speedDown);
42
+ return (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 2, borderStyle: "classic", borderColor: theme.colors.dim, children: [_jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "b" }), "] Prev"] }), _jsx(Text, { color: theme.colors.primary, children: playerState.isPlaying ? (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Pause"] })) : (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Play"] })) }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "n" }), "] Next"] }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "+/-" }), "] Vol: ", playerState.volume, "%"] }), (playerState.speed ?? 1.0) !== 1.0 && (_jsxs(Text, { color: theme.colors.accent, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "<>" }), "]", ' ', (playerState.speed ?? 1.0).toFixed(2), "x"] }))] }));
41
43
  }
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Progress bar component
3
3
  import { Box, Text } from 'ink';
4
4
  import { useTheme } from "../../hooks/useTheme.js";
@@ -10,9 +10,10 @@ export default function ProgressBar() {
10
10
  if (!playerState.currentTrack || !playerState.duration) {
11
11
  return null;
12
12
  }
13
- const progress = playerState.progress;
13
+ // Clamp values to valid range
14
+ const progress = Math.max(0, Math.min(playerState.progress, playerState.duration));
14
15
  const duration = playerState.duration;
15
- const percentage = duration > 0 ? Math.floor((progress / duration) * 100) : 0;
16
- const barWidth = Math.max(0, Math.min(20, Math.floor(percentage / 5))); // 20 chars max, bounds checked
17
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: theme.colors.text, children: [formatTime(progress), " / ", formatTime(duration)] }), _jsxs(Text, { color: theme.colors.dim, children: [" ", percentage, "%"] })] }), _jsxs(Box, { children: [_jsx(Text, { color: theme.colors.primary, children: ''.repeat(barWidth) }), _jsx(Text, { color: theme.colors.dim, children: '-'.repeat(20 - barWidth) })] })] }));
16
+ const percentage = duration > 0 ? Math.min(100, Math.floor((progress / duration) * 100)) : 0;
17
+ const barWidth = Math.min(20, Math.floor(percentage / 5));
18
+ return (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.text, children: formatTime(progress) }), _jsx(Text, { color: theme.colors.dim, children: "/" }), _jsx(Text, { color: theme.colors.text, children: formatTime(duration) }), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.primary, children: ''.repeat(barWidth) }), _jsx(Text, { color: theme.colors.dim, children: ''.repeat(20 - barWidth) }), _jsxs(Text, { color: theme.colors.dim, children: [" ", percentage, "%"] })] }));
18
19
  }
@@ -1,4 +1,4 @@
1
1
  import React from 'react';
2
- declare function QueueList(): import("react/jsx-runtime").JSX.Element;
2
+ declare function QueueList(): import("react/jsx-runtime").JSX.Element | null;
3
3
  declare const _default: React.MemoExoticComponent<typeof QueueList>;
4
4
  export default _default;