@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.
Files changed (136) hide show
  1. package/README.md +352 -0
  2. package/dist/eslint.config.d.ts +2 -0
  3. package/dist/eslint.config.js +55 -0
  4. package/dist/source/app.d.ts +4 -0
  5. package/dist/source/app.js +17 -0
  6. package/dist/source/cli.d.ts +2 -0
  7. package/dist/source/cli.js +241 -0
  8. package/dist/source/components/common/ErrorBoundary.d.ts +15 -0
  9. package/dist/source/components/common/ErrorBoundary.js +22 -0
  10. package/dist/source/components/common/Help.d.ts +1 -0
  11. package/dist/source/components/common/Help.js +10 -0
  12. package/dist/source/components/common/ShortcutsBar.d.ts +1 -0
  13. package/dist/source/components/common/ShortcutsBar.js +33 -0
  14. package/dist/source/components/config/ConfigLayout.d.ts +1 -0
  15. package/dist/source/components/config/ConfigLayout.js +84 -0
  16. package/dist/source/components/layouts/MainLayout.d.ts +4 -0
  17. package/dist/source/components/layouts/MainLayout.js +83 -0
  18. package/dist/source/components/layouts/PlayerLayout.d.ts +1 -0
  19. package/dist/source/components/layouts/PlayerLayout.js +10 -0
  20. package/dist/source/components/layouts/PluginsLayout.d.ts +1 -0
  21. package/dist/source/components/layouts/PluginsLayout.js +77 -0
  22. package/dist/source/components/layouts/SearchLayout.d.ts +4 -0
  23. package/dist/source/components/layouts/SearchLayout.js +81 -0
  24. package/dist/source/components/player/NowPlaying.d.ts +1 -0
  25. package/dist/source/components/player/NowPlaying.js +21 -0
  26. package/dist/source/components/player/PlayerControls.d.ts +1 -0
  27. package/dist/source/components/player/PlayerControls.js +41 -0
  28. package/dist/source/components/player/ProgressBar.d.ts +1 -0
  29. package/dist/source/components/player/ProgressBar.js +18 -0
  30. package/dist/source/components/player/QueueList.d.ts +4 -0
  31. package/dist/source/components/player/QueueList.js +30 -0
  32. package/dist/source/components/player/Suggestions.d.ts +1 -0
  33. package/dist/source/components/player/Suggestions.js +47 -0
  34. package/dist/source/components/playlist/PlaylistList.d.ts +1 -0
  35. package/dist/source/components/playlist/PlaylistList.js +11 -0
  36. package/dist/source/components/plugins/PluginInstallDialog.d.ts +5 -0
  37. package/dist/source/components/plugins/PluginInstallDialog.js +41 -0
  38. package/dist/source/components/plugins/PluginsAvailable.d.ts +5 -0
  39. package/dist/source/components/plugins/PluginsAvailable.js +55 -0
  40. package/dist/source/components/plugins/PluginsList.d.ts +8 -0
  41. package/dist/source/components/plugins/PluginsList.js +18 -0
  42. package/dist/source/components/search/SearchBar.d.ts +8 -0
  43. package/dist/source/components/search/SearchBar.js +50 -0
  44. package/dist/source/components/search/SearchResults.d.ts +10 -0
  45. package/dist/source/components/search/SearchResults.js +111 -0
  46. package/dist/source/components/settings/Settings.d.ts +1 -0
  47. package/dist/source/components/settings/Settings.js +42 -0
  48. package/dist/source/components/theme/ThemeSwitcher.d.ts +1 -0
  49. package/dist/source/components/theme/ThemeSwitcher.js +11 -0
  50. package/dist/source/config/themes.config.d.ts +3 -0
  51. package/dist/source/config/themes.config.js +63 -0
  52. package/dist/source/contexts/theme.context.d.ts +13 -0
  53. package/dist/source/contexts/theme.context.js +29 -0
  54. package/dist/source/hooks/useKeyboard.d.ts +10 -0
  55. package/dist/source/hooks/useKeyboard.js +104 -0
  56. package/dist/source/hooks/useNavigation.d.ts +1 -0
  57. package/dist/source/hooks/useNavigation.js +5 -0
  58. package/dist/source/hooks/usePlayer.d.ts +23 -0
  59. package/dist/source/hooks/usePlayer.js +35 -0
  60. package/dist/source/hooks/usePlaylist.d.ts +8 -0
  61. package/dist/source/hooks/usePlaylist.js +50 -0
  62. package/dist/source/hooks/useSearch.d.ts +8 -0
  63. package/dist/source/hooks/useSearch.js +76 -0
  64. package/dist/source/hooks/useTerminalSize.d.ts +4 -0
  65. package/dist/source/hooks/useTerminalSize.js +24 -0
  66. package/dist/source/hooks/useTheme.d.ts +6 -0
  67. package/dist/source/hooks/useTheme.js +5 -0
  68. package/dist/source/hooks/useYouTubeMusic.d.ts +11 -0
  69. package/dist/source/hooks/useYouTubeMusic.js +112 -0
  70. package/dist/source/main.d.ts +4 -0
  71. package/dist/source/main.js +69 -0
  72. package/dist/source/services/config/config.service.d.ts +26 -0
  73. package/dist/source/services/config/config.service.js +125 -0
  74. package/dist/source/services/logger/logger.service.d.ts +10 -0
  75. package/dist/source/services/logger/logger.service.js +52 -0
  76. package/dist/source/services/player/player.service.d.ts +58 -0
  77. package/dist/source/services/player/player.service.js +349 -0
  78. package/dist/source/services/player-state/player-state.service.d.ts +24 -0
  79. package/dist/source/services/player-state/player-state.service.js +122 -0
  80. package/dist/source/services/plugin/plugin-audio-api.d.ts +17 -0
  81. package/dist/source/services/plugin/plugin-audio-api.js +36 -0
  82. package/dist/source/services/plugin/plugin-context.d.ts +5 -0
  83. package/dist/source/services/plugin/plugin-context.js +256 -0
  84. package/dist/source/services/plugin/plugin-hooks.service.d.ts +62 -0
  85. package/dist/source/services/plugin/plugin-hooks.service.js +135 -0
  86. package/dist/source/services/plugin/plugin-installer.service.d.ts +27 -0
  87. package/dist/source/services/plugin/plugin-installer.service.js +247 -0
  88. package/dist/source/services/plugin/plugin-loader.service.d.ts +33 -0
  89. package/dist/source/services/plugin/plugin-loader.service.js +161 -0
  90. package/dist/source/services/plugin/plugin-permissions.service.d.ts +72 -0
  91. package/dist/source/services/plugin/plugin-permissions.service.js +194 -0
  92. package/dist/source/services/plugin/plugin-registry.service.d.ts +76 -0
  93. package/dist/source/services/plugin/plugin-registry.service.js +215 -0
  94. package/dist/source/services/plugin/plugin-ui-api.d.ts +25 -0
  95. package/dist/source/services/plugin/plugin-ui-api.js +46 -0
  96. package/dist/source/services/plugin/plugin-updater.service.d.ts +23 -0
  97. package/dist/source/services/plugin/plugin-updater.service.js +206 -0
  98. package/dist/source/services/youtube-music/api.d.ts +13 -0
  99. package/dist/source/services/youtube-music/api.js +371 -0
  100. package/dist/source/services/youtube-music/search.service.d.ts +11 -0
  101. package/dist/source/services/youtube-music/search.service.js +38 -0
  102. package/dist/source/stores/navigation.store.d.ts +10 -0
  103. package/dist/source/stores/navigation.store.js +67 -0
  104. package/dist/source/stores/player.store.d.ts +28 -0
  105. package/dist/source/stores/player.store.js +458 -0
  106. package/dist/source/stores/plugins.store.d.ts +46 -0
  107. package/dist/source/stores/plugins.store.js +177 -0
  108. package/dist/source/types/actions.d.ts +119 -0
  109. package/dist/source/types/actions.js +1 -0
  110. package/dist/source/types/cli.types.d.ts +14 -0
  111. package/dist/source/types/cli.types.js +1 -0
  112. package/dist/source/types/config.types.d.ts +19 -0
  113. package/dist/source/types/config.types.js +1 -0
  114. package/dist/source/types/keyboard.types.d.ts +5 -0
  115. package/dist/source/types/keyboard.types.js +1 -0
  116. package/dist/source/types/navigation.types.d.ts +14 -0
  117. package/dist/source/types/navigation.types.js +1 -0
  118. package/dist/source/types/player.types.d.ts +16 -0
  119. package/dist/source/types/player.types.js +1 -0
  120. package/dist/source/types/playlist.types.d.ts +12 -0
  121. package/dist/source/types/playlist.types.js +1 -0
  122. package/dist/source/types/plugin.types.d.ts +239 -0
  123. package/dist/source/types/plugin.types.js +1 -0
  124. package/dist/source/types/theme.types.d.ts +18 -0
  125. package/dist/source/types/theme.types.js +1 -0
  126. package/dist/source/types/youtube-music.types.d.ts +35 -0
  127. package/dist/source/types/youtube-music.types.js +1 -0
  128. package/dist/source/types/youtubei.types.d.ts +60 -0
  129. package/dist/source/types/youtubei.types.js +3 -0
  130. package/dist/source/utils/constants.d.ts +65 -0
  131. package/dist/source/utils/constants.js +82 -0
  132. package/dist/source/utils/format.d.ts +3 -0
  133. package/dist/source/utils/format.js +24 -0
  134. package/dist/test.d.ts +1 -0
  135. package/dist/test.js +13 -0
  136. package/package.json +100 -0
@@ -0,0 +1,125 @@
1
+ // Configuration management service
2
+ import { CONFIG_DIR, CONFIG_FILE } from "../../utils/constants.js";
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { BUILTIN_THEMES, DEFAULT_THEME } from "../../config/themes.config.js";
5
+ class ConfigService {
6
+ configPath;
7
+ configDir;
8
+ config;
9
+ constructor() {
10
+ this.configDir = CONFIG_DIR;
11
+ this.configPath = CONFIG_FILE;
12
+ this.config = this.load() || this.getDefaultConfig();
13
+ }
14
+ getDefaultConfig() {
15
+ return {
16
+ theme: 'dark',
17
+ volume: 70,
18
+ keybindings: {},
19
+ playlists: [],
20
+ history: [],
21
+ favorites: [],
22
+ repeat: 'off',
23
+ shuffle: false,
24
+ customTheme: undefined,
25
+ streamQuality: 'high',
26
+ };
27
+ }
28
+ load() {
29
+ try {
30
+ if (!existsSync(this.configPath)) {
31
+ return null;
32
+ }
33
+ const data = readFileSync(this.configPath, 'utf-8');
34
+ const config = JSON.parse(data);
35
+ // Merge with defaults to handle new fields
36
+ return { ...this.getDefaultConfig(), ...config };
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ save() {
43
+ try {
44
+ // Ensure config directory exists
45
+ if (!existsSync(this.configDir)) {
46
+ mkdirSync(this.configDir, { recursive: true });
47
+ }
48
+ writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
49
+ }
50
+ catch (error) {
51
+ console.error('Failed to save config:', error);
52
+ }
53
+ }
54
+ get(key) {
55
+ return this.config[key];
56
+ }
57
+ set(key, value) {
58
+ this.config[key] = value;
59
+ this.save();
60
+ }
61
+ updateTheme(themeName) {
62
+ this.config.theme = themeName;
63
+ this.save();
64
+ }
65
+ getTheme() {
66
+ if (this.config.theme === 'custom' && this.config.customTheme) {
67
+ return this.config.customTheme;
68
+ }
69
+ const builtinTheme = BUILTIN_THEMES[this.config.theme];
70
+ if (builtinTheme) {
71
+ return builtinTheme;
72
+ }
73
+ return DEFAULT_THEME;
74
+ }
75
+ setCustomTheme(theme) {
76
+ this.config.customTheme = theme;
77
+ this.config.theme = 'custom';
78
+ this.save();
79
+ }
80
+ getKeybinding(action) {
81
+ return this.config.keybindings[action]?.keys;
82
+ }
83
+ setKeybinding(action, keys) {
84
+ this.config.keybindings[action] = {
85
+ keys,
86
+ description: `Custom binding for ${action}`,
87
+ };
88
+ this.save();
89
+ }
90
+ addToHistory(trackId) {
91
+ // Add to front of history, limit to 1000
92
+ this.config.history = [
93
+ trackId,
94
+ ...this.config.history.filter(id => id !== trackId),
95
+ ].slice(0, 1000);
96
+ this.save();
97
+ }
98
+ getHistory() {
99
+ return this.config.history;
100
+ }
101
+ addFavorite(trackId) {
102
+ if (!this.config.favorites.includes(trackId)) {
103
+ this.config.favorites.push(trackId);
104
+ this.save();
105
+ }
106
+ }
107
+ removeFavorite(trackId) {
108
+ this.config.favorites = this.config.favorites.filter(id => id !== trackId);
109
+ this.save();
110
+ }
111
+ isFavorite(trackId) {
112
+ return this.config.favorites.includes(trackId);
113
+ }
114
+ getFavorites() {
115
+ return this.config.favorites;
116
+ }
117
+ }
118
+ // Singleton instance
119
+ let configServiceInstance = null;
120
+ export function getConfigService() {
121
+ if (!configServiceInstance) {
122
+ configServiceInstance = new ConfigService();
123
+ }
124
+ return configServiceInstance;
125
+ }
@@ -0,0 +1,10 @@
1
+ declare class Logger {
2
+ private writeToFile;
3
+ debug(category: string, message: string, data?: unknown): void;
4
+ info(category: string, message: string, data?: unknown): void;
5
+ warn(category: string, message: string, data?: unknown): void;
6
+ error(category: string, message: string, data?: unknown): void;
7
+ getLogPath(): string;
8
+ }
9
+ export declare const logger: Logger;
10
+ export {};
@@ -0,0 +1,52 @@
1
+ // Debug logging service
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ const DEBUG_DIR = path.join(os.homedir(), '.youtube-music-cli');
6
+ const DEBUG_FILE = path.join(DEBUG_DIR, 'debug.log');
7
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
8
+ // Ensure debug directory exists
9
+ if (!fs.existsSync(DEBUG_DIR)) {
10
+ fs.mkdirSync(DEBUG_DIR, { recursive: true });
11
+ }
12
+ // Rotate log if too large
13
+ if (fs.existsSync(DEBUG_FILE)) {
14
+ const stats = fs.statSync(DEBUG_FILE);
15
+ if (stats.size > MAX_LOG_SIZE) {
16
+ const backupFile = path.join(DEBUG_DIR, 'debug.log.old');
17
+ if (fs.existsSync(backupFile)) {
18
+ fs.unlinkSync(backupFile);
19
+ }
20
+ fs.renameSync(DEBUG_FILE, backupFile);
21
+ }
22
+ }
23
+ class Logger {
24
+ writeToFile(level, category, message, data) {
25
+ const timestamp = new Date().toISOString();
26
+ const dataStr = data ? `\n${JSON.stringify(data, null, 2)}` : '';
27
+ const logLine = `[${timestamp}] [${level}] [${category}] ${message}${dataStr}\n`;
28
+ fs.appendFileSync(DEBUG_FILE, logLine);
29
+ }
30
+ debug(category, message, data) {
31
+ this.writeToFile('DEBUG', category, message, data);
32
+ }
33
+ info(category, message, data) {
34
+ this.writeToFile('INFO', category, message, data);
35
+ // Disabled: console.log causes Ink to re-render constantly
36
+ // console.log(`[${category}] ${message}`);
37
+ }
38
+ warn(category, message, data) {
39
+ this.writeToFile('WARN', category, message, data);
40
+ // Disabled: console.warn causes Ink to re-render
41
+ // console.warn(`[${category}] ${message}`);
42
+ }
43
+ error(category, message, data) {
44
+ this.writeToFile('ERROR', category, message, data);
45
+ // Keep console.error for critical errors, but this should be rare
46
+ console.error(`[${category}] ${message}`);
47
+ }
48
+ getLogPath() {
49
+ return DEBUG_FILE;
50
+ }
51
+ }
52
+ export const logger = new Logger();
@@ -0,0 +1,58 @@
1
+ export type PlayOptions = {
2
+ volume?: number;
3
+ };
4
+ export type PlayerEventCallback = (event: {
5
+ timePos?: number;
6
+ duration?: number;
7
+ paused?: boolean;
8
+ eof?: boolean;
9
+ }) => void;
10
+ declare class PlayerService {
11
+ private static instance;
12
+ private mpvProcess;
13
+ private ipcSocket;
14
+ private ipcPath;
15
+ private currentUrl;
16
+ private currentVolume;
17
+ private isPlaying;
18
+ private eventCallback;
19
+ private ipcConnectRetries;
20
+ private readonly maxIpcRetries;
21
+ private currentTrackId;
22
+ private constructor();
23
+ static getInstance(): PlayerService;
24
+ getCurrentTrackId(): string | null;
25
+ /**
26
+ * Register callback for player events (time position, duration updates)
27
+ */
28
+ onEvent(callback: PlayerEventCallback): void;
29
+ /**
30
+ * Generate IPC socket path based on platform
31
+ */
32
+ private getIpcPath;
33
+ /**
34
+ * Connect to mpv IPC socket
35
+ */
36
+ private connectIpc;
37
+ /**
38
+ * Send command to mpv via IPC
39
+ */
40
+ private sendIpcCommand;
41
+ /**
42
+ * Handle IPC message from mpv
43
+ */
44
+ private handleIpcMessage;
45
+ /**
46
+ * Handle property change events from mpv
47
+ */
48
+ private handlePropertyChange;
49
+ play(url: string, options?: PlayOptions): Promise<void>;
50
+ pause(): void;
51
+ resume(): void;
52
+ stop(): void;
53
+ setVolume(volume: number): void;
54
+ getVolume(): number;
55
+ isCurrentlyPlaying(): boolean;
56
+ }
57
+ export declare const getPlayerService: () => PlayerService;
58
+ export {};
@@ -0,0 +1,349 @@
1
+ // Audio playback service using mpv media player with IPC control
2
+ import { spawn } from 'node:child_process';
3
+ import { connect } from 'node:net';
4
+ import { logger } from "../logger/logger.service.js";
5
+ class PlayerService {
6
+ static instance;
7
+ mpvProcess = null;
8
+ ipcSocket = null;
9
+ ipcPath = null;
10
+ currentUrl = null;
11
+ currentVolume = 70;
12
+ isPlaying = false;
13
+ eventCallback = null;
14
+ ipcConnectRetries = 0;
15
+ maxIpcRetries = 10;
16
+ currentTrackId = null; // Track currently playing
17
+ constructor() { }
18
+ static getInstance() {
19
+ if (!PlayerService.instance) {
20
+ PlayerService.instance = new PlayerService();
21
+ }
22
+ return PlayerService.instance;
23
+ }
24
+ getCurrentTrackId() {
25
+ return this.currentTrackId;
26
+ }
27
+ /**
28
+ * Register callback for player events (time position, duration updates)
29
+ */
30
+ onEvent(callback) {
31
+ this.eventCallback = callback;
32
+ }
33
+ /**
34
+ * Generate IPC socket path based on platform
35
+ */
36
+ getIpcPath() {
37
+ if (process.platform === 'win32') {
38
+ // Windows named pipe
39
+ return `\\\\.\\pipe\\mpvsocket-${process.pid}`;
40
+ }
41
+ else {
42
+ // Unix domain socket
43
+ return `/tmp/mpvsocket-${process.pid}`;
44
+ }
45
+ }
46
+ /**
47
+ * Connect to mpv IPC socket
48
+ */
49
+ async connectIpc() {
50
+ if (!this.ipcPath) {
51
+ throw new Error('IPC path not set');
52
+ }
53
+ return new Promise((resolve, reject) => {
54
+ const attemptConnect = () => {
55
+ logger.debug('PlayerService', 'Attempting IPC connection', {
56
+ path: this.ipcPath,
57
+ attempt: this.ipcConnectRetries + 1,
58
+ });
59
+ this.ipcSocket = connect(this.ipcPath);
60
+ this.ipcSocket.on('connect', () => {
61
+ logger.info('PlayerService', 'IPC socket connected');
62
+ this.ipcConnectRetries = 0;
63
+ // Request property observations
64
+ this.sendIpcCommand(['observe_property', 1, 'time-pos']);
65
+ this.sendIpcCommand(['observe_property', 2, 'duration']);
66
+ this.sendIpcCommand(['observe_property', 3, 'pause']);
67
+ this.sendIpcCommand(['observe_property', 4, 'eof-reached']);
68
+ resolve();
69
+ });
70
+ this.ipcSocket.on('data', (data) => {
71
+ this.handleIpcMessage(data.toString());
72
+ });
73
+ this.ipcSocket.on('error', (err) => {
74
+ logger.debug('PlayerService', 'IPC socket error', {
75
+ error: err.message,
76
+ attempt: this.ipcConnectRetries + 1,
77
+ });
78
+ if (this.ipcConnectRetries < this.maxIpcRetries) {
79
+ this.ipcConnectRetries++;
80
+ setTimeout(attemptConnect, 100); // Retry after 100ms
81
+ }
82
+ else {
83
+ reject(new Error(`Failed to connect to IPC socket after ${this.maxIpcRetries} attempts`));
84
+ }
85
+ });
86
+ this.ipcSocket.on('close', () => {
87
+ logger.debug('PlayerService', 'IPC socket closed');
88
+ this.ipcSocket = null;
89
+ });
90
+ };
91
+ attemptConnect();
92
+ });
93
+ }
94
+ /**
95
+ * Send command to mpv via IPC
96
+ */
97
+ sendIpcCommand(command) {
98
+ if (!this.ipcSocket || this.ipcSocket.destroyed) {
99
+ logger.warn('PlayerService', 'Cannot send IPC command: socket not connected');
100
+ return;
101
+ }
102
+ const message = JSON.stringify({ command }) + '\n';
103
+ this.ipcSocket.write(message);
104
+ logger.debug('PlayerService', 'Sent IPC command', {
105
+ command: command[0],
106
+ });
107
+ }
108
+ /**
109
+ * Handle IPC message from mpv
110
+ */
111
+ handleIpcMessage(data) {
112
+ const lines = data.trim().split('\n');
113
+ for (const line of lines) {
114
+ try {
115
+ const message = JSON.parse(line);
116
+ if (message.event === 'property-change') {
117
+ this.handlePropertyChange(message);
118
+ }
119
+ else if (message.error !== 'success' && message.error) {
120
+ logger.warn('PlayerService', 'IPC error response', {
121
+ error: message.error,
122
+ });
123
+ }
124
+ }
125
+ catch (err) {
126
+ logger.debug('PlayerService', 'Failed to parse IPC message', {
127
+ data: line,
128
+ error: err instanceof Error ? err.message : String(err),
129
+ });
130
+ }
131
+ }
132
+ }
133
+ /**
134
+ * Handle property change events from mpv
135
+ */
136
+ handlePropertyChange(message) {
137
+ if (!this.eventCallback)
138
+ return;
139
+ const event = {};
140
+ switch (message.name) {
141
+ case 'time-pos':
142
+ event.timePos = message.data;
143
+ logger.debug('PlayerService', 'Time position updated', {
144
+ timePos: event.timePos,
145
+ });
146
+ break;
147
+ case 'duration':
148
+ event.duration = message.data;
149
+ logger.debug('PlayerService', 'Duration updated', {
150
+ duration: event.duration,
151
+ });
152
+ break;
153
+ case 'pause':
154
+ event.paused = message.data;
155
+ logger.debug('PlayerService', 'Pause state changed', {
156
+ paused: event.paused,
157
+ });
158
+ break;
159
+ case 'eof-reached':
160
+ event.eof = message.data;
161
+ if (event.eof) {
162
+ logger.info('PlayerService', 'End of file reached');
163
+ }
164
+ break;
165
+ }
166
+ this.eventCallback(event);
167
+ }
168
+ async play(url, options) {
169
+ logger.info('PlayerService', 'play() called with mpv', {
170
+ urlLength: url.length,
171
+ urlPreview: url.substring(0, 100),
172
+ volume: options?.volume || this.currentVolume,
173
+ });
174
+ // Extract videoId from URL
175
+ const videoIdMatch = url.match(/[?&]v=([^&]+)/);
176
+ const videoId = videoIdMatch ? videoIdMatch[1] : null;
177
+ // Guard: Don't spawn if same track already playing
178
+ if (this.currentTrackId === videoId && this.mpvProcess && this.isPlaying) {
179
+ logger.info('PlayerService', 'Same track already playing, skipping spawn', {
180
+ videoId,
181
+ });
182
+ return;
183
+ }
184
+ this.currentTrackId = videoId || null;
185
+ // Stop any existing playback
186
+ this.stop();
187
+ this.currentUrl = url;
188
+ if (options?.volume !== undefined) {
189
+ this.currentVolume = options.volume;
190
+ }
191
+ // Build YouTube URL from videoId if needed
192
+ let playUrl = url;
193
+ if (!url.startsWith('http')) {
194
+ playUrl = `https://www.youtube.com/watch?v=${url}`;
195
+ }
196
+ // Generate IPC socket path
197
+ this.ipcPath = this.getIpcPath();
198
+ return new Promise((resolve, reject) => {
199
+ try {
200
+ logger.debug('PlayerService', 'Spawning mpv process with IPC', {
201
+ url: playUrl,
202
+ volume: this.currentVolume,
203
+ ipcPath: this.ipcPath,
204
+ });
205
+ // Spawn mpv with JSON IPC for better control
206
+ this.mpvProcess = spawn('mpv', [
207
+ '--no-video', // Audio only
208
+ '--no-terminal', // Don't read from stdin
209
+ `--volume=${this.currentVolume}`,
210
+ '--no-audio-display', // Don't show album art in terminal
211
+ '--really-quiet', // Minimal output
212
+ '--msg-level=all=error', // Only show errors
213
+ `--input-ipc-server=${this.ipcPath}`, // Enable IPC
214
+ '--idle=yes', // Keep mpv running after playback ends
215
+ playUrl,
216
+ ]);
217
+ if (!this.mpvProcess.stdout || !this.mpvProcess.stderr) {
218
+ throw new Error('Failed to create mpv process streams');
219
+ }
220
+ this.isPlaying = true;
221
+ // Connect to IPC socket after a short delay (let mpv start)
222
+ setTimeout(() => {
223
+ void this.connectIpc().catch(error => {
224
+ logger.warn('PlayerService', 'Failed to connect IPC', {
225
+ error: error.message,
226
+ });
227
+ // Continue without IPC - basic playback will still work
228
+ });
229
+ }, 200);
230
+ // Handle stdout (should be minimal with --really-quiet)
231
+ this.mpvProcess.stdout.on('data', (data) => {
232
+ logger.debug('PlayerService', 'mpv stdout', {
233
+ output: data.toString().trim(),
234
+ });
235
+ });
236
+ // Handle stderr (errors)
237
+ this.mpvProcess.stderr.on('data', (data) => {
238
+ const error = data.toString().trim();
239
+ if (error) {
240
+ logger.error('PlayerService', 'mpv stderr', { error });
241
+ }
242
+ });
243
+ // Handle process exit
244
+ this.mpvProcess.on('exit', (code, signal) => {
245
+ logger.info('PlayerService', 'mpv process exited', {
246
+ code,
247
+ signal,
248
+ wasPlaying: this.isPlaying,
249
+ });
250
+ this.isPlaying = false;
251
+ this.mpvProcess = null;
252
+ if (code === 0) {
253
+ // Normal exit (track finished)
254
+ resolve();
255
+ }
256
+ else if (code !== null && code > 0) {
257
+ // Error exit
258
+ reject(new Error(`mpv exited with code ${code}`));
259
+ }
260
+ // If killed by signal, don't reject (user stopped it)
261
+ });
262
+ // Handle errors
263
+ this.mpvProcess.on('error', (error) => {
264
+ logger.error('PlayerService', 'mpv process error', {
265
+ error: error.message,
266
+ stack: error.stack,
267
+ });
268
+ this.isPlaying = false;
269
+ this.mpvProcess = null;
270
+ reject(error);
271
+ });
272
+ logger.info('PlayerService', 'mpv process started successfully');
273
+ }
274
+ catch (error) {
275
+ logger.error('PlayerService', 'Exception in play()', {
276
+ error: error instanceof Error ? error.message : String(error),
277
+ stack: error instanceof Error ? error.stack : undefined,
278
+ });
279
+ this.isPlaying = false;
280
+ reject(error);
281
+ }
282
+ });
283
+ }
284
+ pause() {
285
+ logger.debug('PlayerService', 'pause() called');
286
+ this.isPlaying = false;
287
+ if (this.ipcSocket && !this.ipcSocket.destroyed) {
288
+ this.sendIpcCommand(['set_property', 'pause', true]);
289
+ }
290
+ }
291
+ resume() {
292
+ logger.debug('PlayerService', 'resume() called');
293
+ this.isPlaying = true;
294
+ if (this.ipcSocket && !this.ipcSocket.destroyed) {
295
+ this.sendIpcCommand(['set_property', 'pause', false]);
296
+ // Reapply volume after resume to ensure audio isn't muted
297
+ if (this.currentVolume !== undefined) {
298
+ setTimeout(() => {
299
+ this.sendIpcCommand(['set_property', 'volume', this.currentVolume]);
300
+ }, 100);
301
+ }
302
+ }
303
+ else if (!this.isPlaying && this.currentUrl) {
304
+ void this.play(this.currentUrl, { volume: this.currentVolume });
305
+ }
306
+ }
307
+ stop() {
308
+ logger.debug('PlayerService', 'stop() called');
309
+ // Close IPC socket
310
+ if (this.ipcSocket && !this.ipcSocket.destroyed) {
311
+ this.ipcSocket.destroy();
312
+ this.ipcSocket = null;
313
+ }
314
+ if (this.mpvProcess) {
315
+ try {
316
+ this.mpvProcess.kill('SIGTERM');
317
+ this.mpvProcess = null;
318
+ this.isPlaying = false;
319
+ this.currentTrackId = null; // Clear track ID on stop
320
+ logger.info('PlayerService', 'mpv process killed');
321
+ }
322
+ catch (error) {
323
+ logger.error('PlayerService', 'Error killing mpv process', {
324
+ error: error instanceof Error ? error.message : String(error),
325
+ });
326
+ }
327
+ }
328
+ this.ipcPath = null;
329
+ this.ipcConnectRetries = 0;
330
+ }
331
+ setVolume(volume) {
332
+ logger.debug('PlayerService', 'setVolume() called', {
333
+ oldVolume: this.currentVolume,
334
+ newVolume: volume,
335
+ });
336
+ this.currentVolume = Math.max(0, Math.min(100, volume));
337
+ // Update mpv volume via IPC if connected
338
+ if (this.ipcSocket && !this.ipcSocket.destroyed) {
339
+ this.sendIpcCommand(['set_property', 'volume', this.currentVolume]);
340
+ }
341
+ }
342
+ getVolume() {
343
+ return this.currentVolume;
344
+ }
345
+ isCurrentlyPlaying() {
346
+ return this.isPlaying;
347
+ }
348
+ }
349
+ export const getPlayerService = () => PlayerService.getInstance();
@@ -0,0 +1,24 @@
1
+ import type { Track } from '../../types/youtube-music.types.ts';
2
+ export interface PersistedPlayerState {
3
+ schemaVersion: number;
4
+ currentTrack: Track | null;
5
+ queue: Track[];
6
+ queuePosition: number;
7
+ progress: number;
8
+ volume: number;
9
+ shuffle: boolean;
10
+ repeat: 'off' | 'all' | 'one';
11
+ lastUpdated: string;
12
+ }
13
+ /**
14
+ * Saves player state to disk
15
+ */
16
+ export declare function savePlayerState(state: Partial<PersistedPlayerState>): Promise<void>;
17
+ /**
18
+ * Loads player state from disk
19
+ */
20
+ export declare function loadPlayerState(): Promise<PersistedPlayerState | null>;
21
+ /**
22
+ * Clears saved player state
23
+ */
24
+ export declare function clearPlayerState(): Promise<void>;