@involvex/youtube-music-cli 0.0.34 → 0.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.0.35](https://github.com/involvex/youtube-music-cli/compare/v0.0.34...v0.0.35) (2026-02-22)
2
+
3
+ ### Features
4
+
5
+ - add history layout for recently played tracks ([a33c93c](https://github.com/involvex/youtube-music-cli/commit/a33c93c485e8e0e42a4d233cb60b06f4c08452cb))
6
+
1
7
  ## [0.0.34](https://github.com/involvex/youtube-music-cli/compare/v0.0.33...v0.0.34) (2026-02-22)
2
8
 
3
9
  ### Features
@@ -0,0 +1 @@
1
+ export default function HistoryLayout(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useTheme } from "../../hooks/useTheme.js";
4
+ import { useHistory } from "../../stores/history.store.js";
5
+ import { useTerminalSize } from "../../hooks/useTerminalSize.js";
6
+ import { truncate } from "../../utils/format.js";
7
+ import { useKeyBinding } from "../../hooks/useKeyboard.js";
8
+ import { KEYBINDINGS } from "../../utils/constants.js";
9
+ import { useNavigation } from "../../hooks/useNavigation.js";
10
+ const DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
11
+ dateStyle: 'medium',
12
+ timeStyle: 'short',
13
+ });
14
+ function formatTimestamp(iso) {
15
+ const date = new Date(iso);
16
+ if (Number.isNaN(date.getTime())) {
17
+ return iso;
18
+ }
19
+ return DATE_FORMATTER.format(date);
20
+ }
21
+ export default function HistoryLayout() {
22
+ const { theme } = useTheme();
23
+ const { history } = useHistory();
24
+ const { columns } = useTerminalSize();
25
+ const { dispatch } = useNavigation();
26
+ useKeyBinding(KEYBINDINGS.BACK, () => {
27
+ dispatch({ category: 'GO_BACK' });
28
+ });
29
+ const maxTitleLength = Math.max(30, columns - 20);
30
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, gap: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.primary, bold: true, children: "Recently Played" }) }), history.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No listening history yet." })) : (history.map(entry => {
31
+ const artists = entry.track.artists
32
+ ?.map(artist => artist.name)
33
+ .join(', ')
34
+ .trim();
35
+ return (_jsxs(Box, { flexDirection: "column", paddingY: 1, borderStyle: "round", borderColor: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.secondary, children: formatTimestamp(entry.playedAt) }), _jsxs(Box, { children: [_jsx(Text, { color: theme.colors.text, bold: true, children: truncate(entry.track.title, maxTitleLength) }), _jsx(Text, { color: theme.colors.dim, children: artists ? ` • ${artists}` : '' })] }), entry.track.album?.name && (_jsxs(Text, { color: theme.colors.dim, children: ["Album: ", entry.track.album.name] }))] }, `${entry.playedAt}-${entry.track.videoId}`));
36
+ })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Esc to go back \u2022 Shift+H to reopen history" }) })] }));
37
+ }
@@ -20,6 +20,7 @@ import SearchHistory from "../search/SearchHistory.js";
20
20
  import KeybindingsLayout from "../config/KeybindingsLayout.js";
21
21
  import TrendingLayout from "./TrendingLayout.js";
22
22
  import ExploreLayout from "./ExploreLayout.js";
23
+ import HistoryLayout from "./HistoryLayout.js";
23
24
  import ImportLayout from "../import/ImportLayout.js";
24
25
  import ExportLayout from "../export/ExportLayout.js";
25
26
  import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
@@ -51,6 +52,9 @@ function MainLayout() {
51
52
  const goToSettings = useCallback(() => {
52
53
  dispatch({ category: 'NAVIGATE', view: VIEW.SETTINGS });
53
54
  }, [dispatch]);
55
+ const goToHistory = useCallback(() => {
56
+ dispatch({ category: 'NAVIGATE', view: VIEW.HISTORY });
57
+ }, [dispatch]);
54
58
  const goToHelp = useCallback(() => {
55
59
  if (navState.currentView === VIEW.HELP) {
56
60
  dispatch({ category: 'GO_BACK' });
@@ -125,6 +129,7 @@ function MainLayout() {
125
129
  useKeyBinding(KEYBINDINGS.PLAYLISTS, goToPlaylists);
126
130
  useKeyBinding(KEYBINDINGS.PLUGINS, goToPlugins);
127
131
  useKeyBinding(KEYBINDINGS.SUGGESTIONS, goToSuggestions);
132
+ useKeyBinding(KEYBINDINGS.HISTORY, goToHistory);
128
133
  useKeyBinding(KEYBINDINGS.SETTINGS, goToSettings);
129
134
  useKeyBinding(KEYBINDINGS.HELP, goToHelp);
130
135
  useKeyBinding(['M'], togglePlayerMode);
@@ -154,6 +159,8 @@ function MainLayout() {
154
159
  return _jsx(PlaylistList, {}, "playlists");
155
160
  case 'suggestions':
156
161
  return _jsx(Suggestions, {}, "suggestions");
162
+ case 'history':
163
+ return _jsx(HistoryLayout, {}, "history");
157
164
  case 'settings':
158
165
  return _jsx(Settings, {}, "settings");
159
166
  case 'plugins':
@@ -5,6 +5,7 @@ import { PluginsProvider } from "./stores/plugins.store.js";
5
5
  import MainLayout from "./components/layouts/MainLayout.js";
6
6
  import { ThemeProvider } from "./contexts/theme.context.js";
7
7
  import { PlayerProvider } from "./stores/player.store.js";
8
+ import { HistoryProvider } from "./stores/history.store.js";
8
9
  import { ErrorBoundary } from "./components/common/ErrorBoundary.js";
9
10
  import { KeyboardManager } from "./hooks/useKeyboard.js";
10
11
  import { KeyboardBlockProvider } from "./hooks/useKeyboardBlocker.js";
@@ -122,5 +123,5 @@ function HeadlessLayout({ flags }) {
122
123
  return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "green", children: "Headless mode active." }) }));
123
124
  }
124
125
  export default function Main({ flags }) {
125
- return (_jsx(ErrorBoundary, { children: _jsx(ThemeProvider, { children: _jsx(PlayerProvider, { children: _jsx(NavigationProvider, { children: _jsx(PluginsProvider, { children: _jsx(KeyboardBlockProvider, { children: _jsxs(Box, { flexDirection: "column", children: [_jsx(KeyboardManager, {}), flags?.headless ? (_jsx(HeadlessLayout, { flags: flags })) : (_jsxs(_Fragment, { children: [_jsx(Initializer, { flags: flags }), _jsx(MainLayout, {})] }))] }) }) }) }) }) }) }));
126
+ return (_jsx(ErrorBoundary, { children: _jsx(ThemeProvider, { children: _jsx(PlayerProvider, { children: _jsx(HistoryProvider, { children: _jsx(NavigationProvider, { children: _jsx(PluginsProvider, { children: _jsx(KeyboardBlockProvider, { children: _jsxs(Box, { flexDirection: "column", children: [_jsx(KeyboardManager, {}), flags?.headless ? (_jsx(HeadlessLayout, { flags: flags })) : (_jsxs(_Fragment, { children: [_jsx(Initializer, { flags: flags }), _jsx(MainLayout, {})] }))] }) }) }) }) }) }) }) }));
126
127
  }
@@ -0,0 +1,4 @@
1
+ import type { HistoryEntry } from '../../types/history.types.ts';
2
+ export declare function saveHistory(entries: HistoryEntry[]): Promise<void>;
3
+ export declare function loadHistory(): Promise<HistoryEntry[]>;
4
+ export declare function clearHistory(): Promise<void>;
@@ -0,0 +1,83 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { CONFIG_DIR } from "../../utils/constants.js";
5
+ import { logger } from "../logger/logger.service.js";
6
+ const HISTORY_FILE = join(CONFIG_DIR, 'history.json');
7
+ const SCHEMA_VERSION = 1;
8
+ const defaultHistory = {
9
+ schemaVersion: SCHEMA_VERSION,
10
+ entries: [],
11
+ lastUpdated: new Date().toISOString(),
12
+ };
13
+ export async function saveHistory(entries) {
14
+ try {
15
+ if (!existsSync(CONFIG_DIR)) {
16
+ await mkdir(CONFIG_DIR, { recursive: true });
17
+ }
18
+ const stateToSave = {
19
+ ...defaultHistory,
20
+ entries,
21
+ lastUpdated: new Date().toISOString(),
22
+ };
23
+ const tempFile = `${HISTORY_FILE}.tmp`;
24
+ await writeFile(tempFile, JSON.stringify(stateToSave, null, 2), 'utf8');
25
+ if (process.platform === 'win32' && existsSync(HISTORY_FILE)) {
26
+ await import('node:fs/promises').then(fs => fs.unlink(HISTORY_FILE));
27
+ }
28
+ await import('node:fs/promises').then(fs => fs.rename(tempFile, HISTORY_FILE));
29
+ logger.debug('HistoryService', 'Saved listening history', {
30
+ count: entries.length,
31
+ });
32
+ }
33
+ catch (error) {
34
+ logger.error('HistoryService', 'Failed to save listening history', {
35
+ error: error instanceof Error ? error.message : String(error),
36
+ });
37
+ }
38
+ }
39
+ export async function loadHistory() {
40
+ try {
41
+ if (!existsSync(HISTORY_FILE)) {
42
+ logger.debug('HistoryService', 'No history file found');
43
+ return [];
44
+ }
45
+ const data = await readFile(HISTORY_FILE, 'utf8');
46
+ const persisted = JSON.parse(data);
47
+ if (persisted.schemaVersion !== SCHEMA_VERSION) {
48
+ logger.warn('HistoryService', 'Schema version mismatch', {
49
+ expected: SCHEMA_VERSION,
50
+ found: persisted.schemaVersion,
51
+ });
52
+ return [];
53
+ }
54
+ if (!Array.isArray(persisted.entries)) {
55
+ logger.warn('HistoryService', 'Invalid history format, resetting');
56
+ return [];
57
+ }
58
+ logger.info('HistoryService', 'Loaded listening history', {
59
+ count: persisted.entries.length,
60
+ lastUpdated: persisted.lastUpdated,
61
+ });
62
+ return persisted.entries;
63
+ }
64
+ catch (error) {
65
+ logger.error('HistoryService', 'Failed to load listening history', {
66
+ error: error instanceof Error ? error.message : String(error),
67
+ });
68
+ return [];
69
+ }
70
+ }
71
+ export async function clearHistory() {
72
+ try {
73
+ if (existsSync(HISTORY_FILE)) {
74
+ await import('node:fs/promises').then(fs => fs.unlink(HISTORY_FILE));
75
+ logger.info('HistoryService', 'Cleared listening history');
76
+ }
77
+ }
78
+ catch (error) {
79
+ logger.error('HistoryService', 'Failed to clear listening history', {
80
+ error: error instanceof Error ? error.message : String(error),
81
+ });
82
+ }
83
+ }
@@ -7,6 +7,10 @@ export type PlayOptions = {
7
7
  crossfadeDuration?: number;
8
8
  equalizerPreset?: EqualizerPreset;
9
9
  };
10
+ export type MpvArgsOptions = PlayOptions & {
11
+ volume: number;
12
+ };
13
+ export declare function buildMpvArgs(url: string, ipcPath: string, options: MpvArgsOptions): string[];
10
14
  export type PlayerEventCallback = (event: {
11
15
  timePos?: number;
12
16
  duration?: number;
@@ -2,6 +2,44 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { connect } from 'node:net';
4
4
  import { logger } from "../logger/logger.service.js";
5
+ export function buildMpvArgs(url, ipcPath, options) {
6
+ const gapless = options.gaplessPlayback ?? true;
7
+ const crossfadeDuration = Math.max(0, options.crossfadeDuration ?? 0);
8
+ const eqPreset = options.equalizerPreset ?? 'flat';
9
+ const audioFilters = [];
10
+ if (options.audioNormalization) {
11
+ audioFilters.push('dynaudnorm');
12
+ }
13
+ if (crossfadeDuration > 0) {
14
+ audioFilters.push(`acrossfade=d=${crossfadeDuration}`);
15
+ }
16
+ const presetFilters = EQUALIZER_PRESET_FILTERS[eqPreset] ?? [];
17
+ if (presetFilters.length > 0) {
18
+ audioFilters.push(...presetFilters);
19
+ }
20
+ const mpvArgs = [
21
+ '--no-video',
22
+ '--no-terminal',
23
+ `--volume=${options.volume}`,
24
+ '--no-audio-display',
25
+ '--really-quiet',
26
+ '--msg-level=all=error',
27
+ `--input-ipc-server=${ipcPath}`,
28
+ '--idle=yes',
29
+ '--cache=yes',
30
+ '--cache-secs=30',
31
+ '--network-timeout=10',
32
+ `--gapless-audio=${gapless ? 'yes' : 'no'}`,
33
+ ];
34
+ if (audioFilters.length > 0) {
35
+ mpvArgs.push(`--af=${audioFilters.join(',')}`);
36
+ }
37
+ if (options.proxy) {
38
+ mpvArgs.push(`--http-proxy=${options.proxy}`);
39
+ }
40
+ mpvArgs.push(url);
41
+ return mpvArgs;
42
+ }
5
43
  const EQUALIZER_PRESET_FILTERS = {
6
44
  flat: [],
7
45
  bass_boost: ['equalizer=f=60:width_type=o:width=2:g=5'],
@@ -225,42 +263,14 @@ class PlayerService {
225
263
  volume: this.currentVolume,
226
264
  ipcPath: this.ipcPath,
227
265
  });
228
- const gapless = options?.gaplessPlayback ?? true;
229
- const crossfadeDuration = Math.max(0, options?.crossfadeDuration ?? 0);
230
- const eqPreset = options?.equalizerPreset ?? 'flat';
231
- const audioFilters = [];
232
- if (options?.audioNormalization) {
233
- audioFilters.push('dynaudnorm');
234
- }
235
- if (crossfadeDuration > 0) {
236
- audioFilters.push(`acrossfade=d=${crossfadeDuration}`);
237
- }
238
- const presetFilters = EQUALIZER_PRESET_FILTERS[eqPreset] ?? [];
239
- if (presetFilters.length > 0) {
240
- audioFilters.push(...presetFilters);
241
- }
242
- // Spawn mpv with JSON IPC for better control
243
- const mpvArgs = [
244
- '--no-video', // Audio only
245
- '--no-terminal', // Don't read from stdin
246
- `--volume=${this.currentVolume}`,
247
- '--no-audio-display', // Don't show album art in terminal
248
- '--really-quiet', // Minimal output
249
- '--msg-level=all=error', // Only show errors
250
- `--input-ipc-server=${this.ipcPath}`, // Enable IPC
251
- '--idle=yes', // Keep mpv running after playback ends
252
- '--cache=yes', // Enable cache for network streams
253
- '--cache-secs=30', // Buffer 30 seconds ahead
254
- '--network-timeout=10', // 10s network timeout
255
- `--gapless-audio=${gapless ? 'yes' : 'no'}`,
256
- ];
257
- if (audioFilters.length > 0) {
258
- mpvArgs.push(`--af=${audioFilters.join(',')}`);
259
- }
260
- if (options?.proxy) {
261
- mpvArgs.push(`--http-proxy=${options.proxy}`);
262
- }
263
- mpvArgs.push(playUrl);
266
+ const mpvArgs = buildMpvArgs(playUrl, this.ipcPath, {
267
+ volume: this.currentVolume,
268
+ audioNormalization: options?.audioNormalization,
269
+ proxy: options?.proxy,
270
+ gaplessPlayback: options?.gaplessPlayback,
271
+ crossfadeDuration: options?.crossfadeDuration,
272
+ equalizerPreset: options?.equalizerPreset,
273
+ });
264
274
  // Capture process in local var so stale exit handlers from a killed
265
275
  // process don't overwrite state belonging to a newly-spawned process.
266
276
  const spawnedProcess = spawn(this.getMpvCommand(), mpvArgs, {
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { HistoryEntry } from '../types/history.types.ts';
3
+ type HistoryState = HistoryEntry[];
4
+ type HistoryContextValue = {
5
+ history: HistoryState;
6
+ };
7
+ export declare function HistoryProvider({ children }: {
8
+ children: ReactNode;
9
+ }): import("react/jsx-runtime").JSX.Element;
10
+ export declare function useHistory(): HistoryContextValue;
11
+ export {};
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useMemo, useReducer, useRef, } from 'react';
3
+ import { usePlayer } from "../hooks/usePlayer.js";
4
+ import { loadHistory, saveHistory } from "../services/history/history.service.js";
5
+ const MAX_HISTORY_ENTRIES = 500;
6
+ function historyReducer(state, action) {
7
+ switch (action.category) {
8
+ case 'SET_HISTORY':
9
+ return action.entries;
10
+ case 'ADD_ENTRY':
11
+ return [action.entry, ...state].slice(0, MAX_HISTORY_ENTRIES);
12
+ default:
13
+ return state;
14
+ }
15
+ }
16
+ const HistoryContext = createContext(null);
17
+ export function HistoryProvider({ children }) {
18
+ const [state, dispatch] = useReducer(historyReducer, []);
19
+ const { state: playerState } = usePlayer();
20
+ const lastLoggedId = useRef(null);
21
+ useEffect(() => {
22
+ let cancelled = false;
23
+ void loadHistory().then(entries => {
24
+ if (!cancelled) {
25
+ dispatch({ category: 'SET_HISTORY', entries });
26
+ }
27
+ });
28
+ return () => {
29
+ cancelled = true;
30
+ };
31
+ }, []);
32
+ useEffect(() => {
33
+ if (!playerState.currentTrack) {
34
+ lastLoggedId.current = null;
35
+ return;
36
+ }
37
+ if (!playerState.isPlaying) {
38
+ lastLoggedId.current = null;
39
+ return;
40
+ }
41
+ const videoId = playerState.currentTrack.videoId;
42
+ if (lastLoggedId.current === videoId) {
43
+ return;
44
+ }
45
+ lastLoggedId.current = videoId;
46
+ const entry = {
47
+ track: playerState.currentTrack,
48
+ playedAt: new Date().toISOString(),
49
+ };
50
+ dispatch({ category: 'ADD_ENTRY', entry });
51
+ }, [playerState.currentTrack, playerState.isPlaying]);
52
+ useEffect(() => {
53
+ void saveHistory(state);
54
+ }, [state]);
55
+ const value = useMemo(() => ({ history: state }), [state]);
56
+ return (_jsx(HistoryContext.Provider, { value: value, children: children }));
57
+ }
58
+ export function useHistory() {
59
+ const context = useContext(HistoryContext);
60
+ if (!context) {
61
+ throw new Error('useHistory must be used within HistoryProvider');
62
+ }
63
+ return context;
64
+ }
@@ -0,0 +1,10 @@
1
+ import type { Track } from './youtube-music.types.ts';
2
+ export interface HistoryEntry {
3
+ track: Track;
4
+ playedAt: string;
5
+ }
6
+ export interface PersistedHistory {
7
+ schemaVersion: number;
8
+ entries: HistoryEntry[];
9
+ lastUpdated: string;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -20,6 +20,7 @@ export declare const VIEW: {
20
20
  readonly EXPLORE: "explore";
21
21
  readonly IMPORT: "import";
22
22
  readonly EXPORT_PLAYLISTS: "export_playlists";
23
+ readonly HISTORY: "history";
23
24
  };
24
25
  export declare const SEARCH_TYPE: {
25
26
  readonly ALL: "all";
@@ -34,6 +35,7 @@ export declare const KEYBINDINGS: {
34
35
  readonly SEARCH: readonly ["/"];
35
36
  readonly PLAYLISTS: readonly ["shift+p"];
36
37
  readonly SUGGESTIONS: readonly ["g"];
38
+ readonly HISTORY: readonly ["shift+h"];
37
39
  readonly SETTINGS: readonly [","];
38
40
  readonly PLUGINS: readonly ["p"];
39
41
  readonly DETACH: readonly ["shift+q"];
@@ -25,6 +25,7 @@ export const VIEW = {
25
25
  EXPLORE: 'explore',
26
26
  IMPORT: 'import',
27
27
  EXPORT_PLAYLISTS: 'export_playlists',
28
+ HISTORY: 'history',
28
29
  };
29
30
  // Search types
30
31
  export const SEARCH_TYPE = {
@@ -42,6 +43,7 @@ export const KEYBINDINGS = {
42
43
  SEARCH: ['/'],
43
44
  PLAYLISTS: ['shift+p'],
44
45
  SUGGESTIONS: ['g'],
46
+ HISTORY: ['shift+h'],
45
47
  SETTINGS: [','],
46
48
  PLUGINS: ['p'],
47
49
  DETACH: ['shift+q'],
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.34",
3
+ "version": "0.0.35",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",
package/readme.md CHANGED
@@ -28,6 +28,10 @@ A powerful Terminal User Interface (TUI) music player for YouTube Music
28
28
  - 💾 **Downloads** - Save tracks/playlists/artists with `Shift+D`
29
29
  - 🏷️ **Metadata Tagging** - Auto-tag title/artist/album with optional cover art
30
30
 
31
+ ## Roadmap
32
+
33
+ Visit [`SUGGESTIONS.md`](SUGGESTIONS.md) for the full backlog and use `docs/roadmap.md` to understand the current implementation focus (crossfade + gapless playback) and the next steps planned for equalizer/enhancements. The roadmap doc also explains how to pick up work so reviewers and contributors remain aligned.
34
+
31
35
  ## Prerequisites
32
36
 
33
37
  **Required:**