@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,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>;
|