@involvex/youtube-music-cli 0.0.2 → 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.
- package/dist/source/components/common/ShortcutsBar.js +3 -1
- package/dist/source/components/config/KeybindingsLayout.d.ts +1 -0
- package/dist/source/components/config/KeybindingsLayout.js +107 -0
- package/dist/source/components/layouts/ExploreLayout.d.ts +1 -0
- package/dist/source/components/layouts/ExploreLayout.js +72 -0
- package/dist/source/components/layouts/LyricsLayout.d.ts +1 -0
- package/dist/source/components/layouts/LyricsLayout.js +89 -0
- package/dist/source/components/layouts/MainLayout.js +39 -1
- package/dist/source/components/layouts/MiniPlayerLayout.d.ts +1 -0
- package/dist/source/components/layouts/MiniPlayerLayout.js +19 -0
- package/dist/source/components/layouts/SearchLayout.js +10 -3
- package/dist/source/components/layouts/TrendingLayout.d.ts +1 -0
- package/dist/source/components/layouts/TrendingLayout.js +59 -0
- package/dist/source/components/player/NowPlaying.js +21 -1
- package/dist/source/components/player/PlayerControls.js +4 -2
- package/dist/source/components/search/SearchBar.js +4 -1
- package/dist/source/components/search/SearchHistory.d.ts +5 -0
- package/dist/source/components/search/SearchHistory.js +35 -0
- package/dist/source/components/settings/Settings.js +74 -11
- package/dist/source/config/themes.config.js +60 -0
- package/dist/source/hooks/usePlayer.d.ts +5 -0
- package/dist/source/hooks/usePlaylist.d.ts +2 -1
- package/dist/source/hooks/usePlaylist.js +8 -2
- package/dist/source/hooks/useSleepTimer.d.ts +9 -0
- package/dist/source/hooks/useSleepTimer.js +48 -0
- package/dist/source/services/cache/cache.service.d.ts +14 -0
- package/dist/source/services/cache/cache.service.js +67 -0
- package/dist/source/services/config/config.service.d.ts +2 -0
- package/dist/source/services/config/config.service.js +17 -0
- package/dist/source/services/discord/discord-rpc.service.d.ts +17 -0
- package/dist/source/services/discord/discord-rpc.service.js +95 -0
- package/dist/source/services/lyrics/lyrics.service.d.ts +22 -0
- package/dist/source/services/lyrics/lyrics.service.js +93 -0
- package/dist/source/services/mpris/mpris.service.d.ts +20 -0
- package/dist/source/services/mpris/mpris.service.js +78 -0
- package/dist/source/services/notification/notification.service.d.ts +14 -0
- package/dist/source/services/notification/notification.service.js +57 -0
- package/dist/source/services/player/player.service.d.ts +3 -0
- package/dist/source/services/player/player.service.js +20 -3
- package/dist/source/services/scrobbling/scrobbling.service.d.ts +23 -0
- package/dist/source/services/scrobbling/scrobbling.service.js +115 -0
- package/dist/source/services/sleep-timer/sleep-timer.service.d.ts +16 -0
- package/dist/source/services/sleep-timer/sleep-timer.service.js +45 -0
- package/dist/source/services/youtube-music/api.d.ts +6 -0
- package/dist/source/services/youtube-music/api.js +102 -2
- package/dist/source/stores/navigation.store.js +6 -0
- package/dist/source/stores/player.store.d.ts +5 -0
- package/dist/source/stores/player.store.js +141 -24
- package/dist/source/types/actions.d.ts +13 -0
- package/dist/source/types/config.types.d.ts +15 -1
- package/dist/source/types/navigation.types.d.ts +3 -2
- package/dist/source/types/player.types.d.ts +3 -2
- package/dist/source/utils/constants.d.ts +9 -0
- package/dist/source/utils/constants.js +9 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +5 -2
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
interface TrackInfo {
|
|
2
|
+
title: string;
|
|
3
|
+
artist: string;
|
|
4
|
+
duration: number;
|
|
5
|
+
}
|
|
6
|
+
type PlaybackCallbacks = {
|
|
7
|
+
onPlay?: () => void;
|
|
8
|
+
onPause?: () => void;
|
|
9
|
+
onNext?: () => void;
|
|
10
|
+
onPrevious?: () => void;
|
|
11
|
+
};
|
|
12
|
+
export declare class MprisService {
|
|
13
|
+
private player;
|
|
14
|
+
get isSupported(): boolean;
|
|
15
|
+
initialize(callbacks?: PlaybackCallbacks): Promise<void>;
|
|
16
|
+
updateTrack(track: TrackInfo, isPlaying: boolean): void;
|
|
17
|
+
setPlaying(playing: boolean): void;
|
|
18
|
+
}
|
|
19
|
+
export declare const getMprisService: () => MprisService;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// MPRIS service — Linux only, enables playerctl / media key support
|
|
2
|
+
// No-ops on non-Linux platforms
|
|
3
|
+
import { logger } from "../logger/logger.service.js";
|
|
4
|
+
export class MprisService {
|
|
5
|
+
player = null;
|
|
6
|
+
get isSupported() {
|
|
7
|
+
return process.platform === 'linux';
|
|
8
|
+
}
|
|
9
|
+
async initialize(callbacks = {}) {
|
|
10
|
+
if (!this.isSupported) {
|
|
11
|
+
logger.debug('MprisService', 'MPRIS not supported on this platform', {
|
|
12
|
+
platform: process.platform,
|
|
13
|
+
});
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
const mpris = (await import('mpris-service'));
|
|
20
|
+
this.player = mpris.createPlayer({
|
|
21
|
+
name: 'youtube-music-cli',
|
|
22
|
+
identity: 'YouTube Music CLI',
|
|
23
|
+
supportedInterfaces: ['player'],
|
|
24
|
+
});
|
|
25
|
+
// Wire up MPRIS events to player callbacks
|
|
26
|
+
if (callbacks.onPlay)
|
|
27
|
+
this.player.on('play', callbacks.onPlay);
|
|
28
|
+
if (callbacks.onPause)
|
|
29
|
+
this.player.on('pause', callbacks.onPause);
|
|
30
|
+
if (callbacks.onNext)
|
|
31
|
+
this.player.on('next', callbacks.onNext);
|
|
32
|
+
if (callbacks.onPrevious)
|
|
33
|
+
this.player.on('previous', callbacks.onPrevious);
|
|
34
|
+
this.player.canPlay = true;
|
|
35
|
+
this.player.canPause = true;
|
|
36
|
+
this.player.canGoNext = true;
|
|
37
|
+
this.player.canGoPrevious = true;
|
|
38
|
+
this.player.canSeek = false;
|
|
39
|
+
logger.info('MprisService', 'MPRIS player initialized');
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger.warn('MprisService', 'Could not initialize MPRIS', {
|
|
43
|
+
error: error instanceof Error ? error.message : String(error),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
updateTrack(track, isPlaying) {
|
|
48
|
+
if (!this.player)
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
this.player.metadata = {
|
|
52
|
+
'mpris:length': track.duration,
|
|
53
|
+
'xesam:title': track.title,
|
|
54
|
+
'xesam:artist': [track.artist],
|
|
55
|
+
};
|
|
56
|
+
this.player.playbackStatus = isPlaying ? 'Playing' : 'Paused';
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Ignore MPRIS update errors
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
setPlaying(playing) {
|
|
63
|
+
if (!this.player)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
this.player.playbackStatus = playing ? 'Playing' : 'Paused';
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Ignore
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let instance = null;
|
|
74
|
+
export const getMprisService = () => {
|
|
75
|
+
if (!instance)
|
|
76
|
+
instance = new MprisService();
|
|
77
|
+
return instance;
|
|
78
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare class NotificationService {
|
|
2
|
+
private static instance;
|
|
3
|
+
private enabled;
|
|
4
|
+
private notifier;
|
|
5
|
+
private constructor();
|
|
6
|
+
static getInstance(): NotificationService;
|
|
7
|
+
setEnabled(enabled: boolean): void;
|
|
8
|
+
isEnabled(): boolean;
|
|
9
|
+
private getNotifier;
|
|
10
|
+
notify(title: string, message: string): Promise<void>;
|
|
11
|
+
notifyTrackChange(title: string, artist: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare const getNotificationService: () => NotificationService;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Desktop notification service
|
|
2
|
+
import { logger } from "../logger/logger.service.js";
|
|
3
|
+
class NotificationService {
|
|
4
|
+
static instance;
|
|
5
|
+
enabled = false;
|
|
6
|
+
notifier = null;
|
|
7
|
+
constructor() { }
|
|
8
|
+
static getInstance() {
|
|
9
|
+
if (!NotificationService.instance) {
|
|
10
|
+
NotificationService.instance = new NotificationService();
|
|
11
|
+
}
|
|
12
|
+
return NotificationService.instance;
|
|
13
|
+
}
|
|
14
|
+
setEnabled(enabled) {
|
|
15
|
+
this.enabled = enabled;
|
|
16
|
+
}
|
|
17
|
+
isEnabled() {
|
|
18
|
+
return this.enabled;
|
|
19
|
+
}
|
|
20
|
+
async getNotifier() {
|
|
21
|
+
if (!this.notifier) {
|
|
22
|
+
// Lazy-load to avoid startup cost when disabled
|
|
23
|
+
const mod = await import('node-notifier');
|
|
24
|
+
this.notifier = mod.default;
|
|
25
|
+
}
|
|
26
|
+
return this.notifier;
|
|
27
|
+
}
|
|
28
|
+
async notify(title, message) {
|
|
29
|
+
if (!this.enabled)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
const notifier = await this.getNotifier();
|
|
33
|
+
notifier.notify({
|
|
34
|
+
title,
|
|
35
|
+
message,
|
|
36
|
+
sound: false,
|
|
37
|
+
wait: false,
|
|
38
|
+
}, (error) => {
|
|
39
|
+
if (error) {
|
|
40
|
+
logger.warn('NotificationService', 'Notification failed', {
|
|
41
|
+
error: error.message,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// Gracefully handle if notifications aren't supported
|
|
48
|
+
logger.warn('NotificationService', 'Failed to send notification', {
|
|
49
|
+
error: error instanceof Error ? error.message : String(error),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async notifyTrackChange(title, artist) {
|
|
54
|
+
await this.notify('Now Playing', `${title} — ${artist}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export const getNotificationService = () => NotificationService.getInstance();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type PlayOptions = {
|
|
2
2
|
volume?: number;
|
|
3
|
+
audioNormalization?: boolean;
|
|
4
|
+
proxy?: string;
|
|
3
5
|
};
|
|
4
6
|
export type PlayerEventCallback = (event: {
|
|
5
7
|
timePos?: number;
|
|
@@ -52,6 +54,7 @@ declare class PlayerService {
|
|
|
52
54
|
stop(): void;
|
|
53
55
|
setVolume(volume: number): void;
|
|
54
56
|
getVolume(): number;
|
|
57
|
+
setSpeed(speed: number): void;
|
|
55
58
|
isCurrentlyPlaying(): boolean;
|
|
56
59
|
}
|
|
57
60
|
export declare const getPlayerService: () => PlayerService;
|
|
@@ -203,7 +203,7 @@ class PlayerService {
|
|
|
203
203
|
ipcPath: this.ipcPath,
|
|
204
204
|
});
|
|
205
205
|
// Spawn mpv with JSON IPC for better control
|
|
206
|
-
|
|
206
|
+
const mpvArgs = [
|
|
207
207
|
'--no-video', // Audio only
|
|
208
208
|
'--no-terminal', // Don't read from stdin
|
|
209
209
|
`--volume=${this.currentVolume}`,
|
|
@@ -212,8 +212,18 @@ class PlayerService {
|
|
|
212
212
|
'--msg-level=all=error', // Only show errors
|
|
213
213
|
`--input-ipc-server=${this.ipcPath}`, // Enable IPC
|
|
214
214
|
'--idle=yes', // Keep mpv running after playback ends
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
'--cache=yes', // Enable cache for network streams
|
|
216
|
+
'--cache-secs=30', // Buffer 30 seconds ahead
|
|
217
|
+
'--network-timeout=10', // 10s network timeout
|
|
218
|
+
];
|
|
219
|
+
if (options?.audioNormalization) {
|
|
220
|
+
mpvArgs.push('--af=dynaudnorm');
|
|
221
|
+
}
|
|
222
|
+
if (options?.proxy) {
|
|
223
|
+
mpvArgs.push(`--http-proxy=${options.proxy}`);
|
|
224
|
+
}
|
|
225
|
+
mpvArgs.push(playUrl);
|
|
226
|
+
this.mpvProcess = spawn('mpv', mpvArgs);
|
|
217
227
|
if (!this.mpvProcess.stdout || !this.mpvProcess.stderr) {
|
|
218
228
|
throw new Error('Failed to create mpv process streams');
|
|
219
229
|
}
|
|
@@ -342,6 +352,13 @@ class PlayerService {
|
|
|
342
352
|
getVolume() {
|
|
343
353
|
return this.currentVolume;
|
|
344
354
|
}
|
|
355
|
+
setSpeed(speed) {
|
|
356
|
+
const clamped = Math.max(0.25, Math.min(4.0, speed));
|
|
357
|
+
logger.debug('PlayerService', 'setSpeed() called', { speed: clamped });
|
|
358
|
+
if (this.ipcSocket && !this.ipcSocket.destroyed) {
|
|
359
|
+
this.sendIpcCommand(['set_property', 'speed', clamped]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
345
362
|
isCurrentlyPlaying() {
|
|
346
363
|
return this.isPlaying;
|
|
347
364
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface TrackInfo {
|
|
2
|
+
title: string;
|
|
3
|
+
artist: string;
|
|
4
|
+
duration: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class ScrobblingService {
|
|
7
|
+
private lastfmApiKey?;
|
|
8
|
+
private lastfmSessionKey?;
|
|
9
|
+
private listenbrainzToken?;
|
|
10
|
+
configure(config: {
|
|
11
|
+
lastfm?: {
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
sessionKey?: string;
|
|
14
|
+
};
|
|
15
|
+
listenbrainz?: {
|
|
16
|
+
token?: string;
|
|
17
|
+
};
|
|
18
|
+
}): void;
|
|
19
|
+
get isEnabled(): boolean;
|
|
20
|
+
scrobble(track: TrackInfo): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export declare const getScrobblingService: () => ScrobblingService;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Scrobbling service — supports Last.fm and ListenBrainz
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { logger } from "../logger/logger.service.js";
|
|
4
|
+
// ---------- Last.fm ----------
|
|
5
|
+
async function lastfmScrobble(title, artist, timestamp, apiKey, sessionKey) {
|
|
6
|
+
const params = {
|
|
7
|
+
method: 'track.scrobble',
|
|
8
|
+
artist,
|
|
9
|
+
track: title,
|
|
10
|
+
timestamp: String(timestamp),
|
|
11
|
+
api_key: apiKey,
|
|
12
|
+
sk: sessionKey,
|
|
13
|
+
};
|
|
14
|
+
const secret = ''; // User must provide shared secret if using real auth
|
|
15
|
+
const sig = buildLastfmSignature(params, secret);
|
|
16
|
+
params['api_sig'] = sig;
|
|
17
|
+
params['format'] = 'json';
|
|
18
|
+
const body = new URLSearchParams(params);
|
|
19
|
+
const response = await fetch('https://ws.audioscrobbler.com/2.0/', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
body,
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(`Last.fm scrobble failed: HTTP ${response.status}`);
|
|
25
|
+
}
|
|
26
|
+
const data = (await response.json());
|
|
27
|
+
if (data.error) {
|
|
28
|
+
throw new Error(`Last.fm error ${data.error}: ${data.message}`);
|
|
29
|
+
}
|
|
30
|
+
logger.info('ScrobblingService', 'Last.fm scrobble successful', {
|
|
31
|
+
title,
|
|
32
|
+
artist,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function buildLastfmSignature(params, secret) {
|
|
36
|
+
const sorted = Object.keys(params)
|
|
37
|
+
.filter(k => k !== 'format' && k !== 'callback')
|
|
38
|
+
.sort()
|
|
39
|
+
.map(k => `${k}${params[k]}`)
|
|
40
|
+
.join('');
|
|
41
|
+
return createHash('md5')
|
|
42
|
+
.update(sorted + secret)
|
|
43
|
+
.digest('hex');
|
|
44
|
+
}
|
|
45
|
+
// ---------- ListenBrainz ----------
|
|
46
|
+
async function listenbrainzScrobble(title, artist, listenedAt, token) {
|
|
47
|
+
const payload = {
|
|
48
|
+
listen_type: 'single',
|
|
49
|
+
payload: [
|
|
50
|
+
{
|
|
51
|
+
listened_at: listenedAt,
|
|
52
|
+
track_metadata: {
|
|
53
|
+
artist_name: artist,
|
|
54
|
+
track_name: title,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
const response = await fetch('https://api.listenbrainz.org/1/submit-listens', {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
Authorization: `Token ${token}`,
|
|
63
|
+
'Content-Type': 'application/json',
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify(payload),
|
|
66
|
+
});
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new Error(`ListenBrainz scrobble failed: HTTP ${response.status}`);
|
|
69
|
+
}
|
|
70
|
+
logger.info('ScrobblingService', 'ListenBrainz scrobble successful', {
|
|
71
|
+
title,
|
|
72
|
+
artist,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// ---------- Main service ----------
|
|
76
|
+
export class ScrobblingService {
|
|
77
|
+
lastfmApiKey;
|
|
78
|
+
lastfmSessionKey;
|
|
79
|
+
listenbrainzToken;
|
|
80
|
+
configure(config) {
|
|
81
|
+
this.lastfmApiKey = config.lastfm?.apiKey;
|
|
82
|
+
this.lastfmSessionKey = config.lastfm?.sessionKey;
|
|
83
|
+
this.listenbrainzToken = config.listenbrainz?.token;
|
|
84
|
+
}
|
|
85
|
+
get isEnabled() {
|
|
86
|
+
return Boolean((this.lastfmApiKey && this.lastfmSessionKey) || this.listenbrainzToken);
|
|
87
|
+
}
|
|
88
|
+
async scrobble(track) {
|
|
89
|
+
if (!this.isEnabled)
|
|
90
|
+
return;
|
|
91
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
92
|
+
const tasks = [];
|
|
93
|
+
if (this.lastfmApiKey && this.lastfmSessionKey) {
|
|
94
|
+
tasks.push(lastfmScrobble(track.title, track.artist, timestamp, this.lastfmApiKey, this.lastfmSessionKey).catch(error => {
|
|
95
|
+
logger.error('ScrobblingService', 'Last.fm scrobble failed', {
|
|
96
|
+
error: error instanceof Error ? error.message : String(error),
|
|
97
|
+
});
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
if (this.listenbrainzToken) {
|
|
101
|
+
tasks.push(listenbrainzScrobble(track.title, track.artist, timestamp, this.listenbrainzToken).catch(error => {
|
|
102
|
+
logger.error('ScrobblingService', 'ListenBrainz scrobble failed', {
|
|
103
|
+
error: error instanceof Error ? error.message : String(error),
|
|
104
|
+
});
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
await Promise.all(tasks);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let instance = null;
|
|
111
|
+
export const getScrobblingService = () => {
|
|
112
|
+
if (!instance)
|
|
113
|
+
instance = new ScrobblingService();
|
|
114
|
+
return instance;
|
|
115
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const SLEEP_TIMER_PRESETS: readonly [5, 10, 15, 30, 60];
|
|
2
|
+
export type SleepTimerPreset = (typeof SLEEP_TIMER_PRESETS)[number];
|
|
3
|
+
declare class SleepTimerService {
|
|
4
|
+
private static instance;
|
|
5
|
+
private timer;
|
|
6
|
+
private endTime;
|
|
7
|
+
private constructor();
|
|
8
|
+
static getInstance(): SleepTimerService;
|
|
9
|
+
start(minutes: number, onExpire: () => void): void;
|
|
10
|
+
cancel(): void;
|
|
11
|
+
/** Returns remaining seconds, or null if no timer active */
|
|
12
|
+
getRemainingSeconds(): number | null;
|
|
13
|
+
isActive(): boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare const getSleepTimerService: () => SleepTimerService;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Sleep timer service - auto-stops playback after a set duration
|
|
2
|
+
import { logger } from "../logger/logger.service.js";
|
|
3
|
+
export const SLEEP_TIMER_PRESETS = [5, 10, 15, 30, 60];
|
|
4
|
+
class SleepTimerService {
|
|
5
|
+
static instance;
|
|
6
|
+
timer = null;
|
|
7
|
+
endTime = null;
|
|
8
|
+
constructor() { }
|
|
9
|
+
static getInstance() {
|
|
10
|
+
if (!SleepTimerService.instance) {
|
|
11
|
+
SleepTimerService.instance = new SleepTimerService();
|
|
12
|
+
}
|
|
13
|
+
return SleepTimerService.instance;
|
|
14
|
+
}
|
|
15
|
+
start(minutes, onExpire) {
|
|
16
|
+
this.cancel();
|
|
17
|
+
this.endTime = Date.now() + minutes * 60 * 1000;
|
|
18
|
+
logger.info('SleepTimerService', 'Timer started', { minutes });
|
|
19
|
+
this.timer = setTimeout(() => {
|
|
20
|
+
logger.info('SleepTimerService', 'Timer expired');
|
|
21
|
+
this.endTime = null;
|
|
22
|
+
this.timer = null;
|
|
23
|
+
onExpire();
|
|
24
|
+
}, minutes * 60 * 1000);
|
|
25
|
+
}
|
|
26
|
+
cancel() {
|
|
27
|
+
if (this.timer) {
|
|
28
|
+
clearTimeout(this.timer);
|
|
29
|
+
this.timer = null;
|
|
30
|
+
this.endTime = null;
|
|
31
|
+
logger.info('SleepTimerService', 'Timer cancelled');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Returns remaining seconds, or null if no timer active */
|
|
35
|
+
getRemainingSeconds() {
|
|
36
|
+
if (!this.endTime)
|
|
37
|
+
return null;
|
|
38
|
+
const remaining = Math.max(0, Math.ceil((this.endTime - Date.now()) / 1000));
|
|
39
|
+
return remaining;
|
|
40
|
+
}
|
|
41
|
+
isActive() {
|
|
42
|
+
return this.timer !== null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export const getSleepTimerService = () => SleepTimerService.getInstance();
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { Track, Album, Artist, Playlist, SearchOptions, SearchResponse } from '../../types/youtube-music.types.ts';
|
|
2
2
|
declare class MusicService {
|
|
3
|
+
private readonly searchCache;
|
|
3
4
|
search(query: string, options?: SearchOptions): Promise<SearchResponse>;
|
|
4
5
|
getTrack(videoId: string): Promise<Track | null>;
|
|
5
6
|
getAlbum(albumId: string): Promise<Album>;
|
|
6
7
|
getArtist(artistId: string): Promise<Artist>;
|
|
7
8
|
getPlaylist(playlistId: string): Promise<Playlist>;
|
|
9
|
+
getTrending(): Promise<Track[]>;
|
|
10
|
+
getExploreSections(): Promise<Array<{
|
|
11
|
+
title: string;
|
|
12
|
+
tracks: Track[];
|
|
13
|
+
}>>;
|
|
8
14
|
getSuggestions(trackId: string): Promise<Track[]>;
|
|
9
15
|
getStreamUrl(videoId: string): Promise<string>;
|
|
10
16
|
private getInvidiousStreamUrl;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Innertube } from 'youtubei.js';
|
|
2
2
|
import { logger } from "../logger/logger.service.js";
|
|
3
|
+
import { getSearchCache } from "../cache/cache.service.js";
|
|
3
4
|
// Initialize YouTube client
|
|
4
5
|
let ytClient = null;
|
|
5
6
|
async function getClient() {
|
|
@@ -9,9 +10,20 @@ async function getClient() {
|
|
|
9
10
|
return ytClient;
|
|
10
11
|
}
|
|
11
12
|
class MusicService {
|
|
13
|
+
searchCache = getSearchCache();
|
|
12
14
|
async search(query, options = {}) {
|
|
13
|
-
const results = [];
|
|
14
15
|
const searchType = options.type || 'all';
|
|
16
|
+
const cacheKey = `search:${searchType}:${options.limit ?? 20}:${query}`;
|
|
17
|
+
// Return cached result if available
|
|
18
|
+
const cached = this.searchCache.get(cacheKey);
|
|
19
|
+
if (cached) {
|
|
20
|
+
logger.debug('MusicService', 'Returning cached search results', {
|
|
21
|
+
query,
|
|
22
|
+
resultCount: cached.results.length,
|
|
23
|
+
});
|
|
24
|
+
return cached;
|
|
25
|
+
}
|
|
26
|
+
const results = [];
|
|
15
27
|
try {
|
|
16
28
|
const yt = await getClient();
|
|
17
29
|
const search = (await yt.search(query));
|
|
@@ -82,10 +94,13 @@ class MusicService {
|
|
|
82
94
|
catch (error) {
|
|
83
95
|
console.error('Search failed:', error);
|
|
84
96
|
}
|
|
85
|
-
|
|
97
|
+
const response = {
|
|
86
98
|
results,
|
|
87
99
|
hasMore: false,
|
|
88
100
|
};
|
|
101
|
+
// Cache the result
|
|
102
|
+
this.searchCache.set(cacheKey, response);
|
|
103
|
+
return response;
|
|
89
104
|
}
|
|
90
105
|
async getTrack(videoId) {
|
|
91
106
|
return {
|
|
@@ -115,6 +130,91 @@ class MusicService {
|
|
|
115
130
|
tracks: [],
|
|
116
131
|
};
|
|
117
132
|
}
|
|
133
|
+
async getTrending() {
|
|
134
|
+
try {
|
|
135
|
+
const yt = await getClient();
|
|
136
|
+
const trending = (await yt.getTrending());
|
|
137
|
+
const tracks = [];
|
|
138
|
+
const sections = trending.sections ?? [];
|
|
139
|
+
for (const section of sections) {
|
|
140
|
+
for (const item of section.items ?? []) {
|
|
141
|
+
const videoId = item.id || item.video_id;
|
|
142
|
+
if (!videoId)
|
|
143
|
+
continue;
|
|
144
|
+
tracks.push({
|
|
145
|
+
videoId,
|
|
146
|
+
title: (typeof item.title === 'string'
|
|
147
|
+
? item.title
|
|
148
|
+
: item.title?.text) ?? 'Unknown',
|
|
149
|
+
artists: [
|
|
150
|
+
{
|
|
151
|
+
artistId: '',
|
|
152
|
+
name: (typeof item.author === 'string'
|
|
153
|
+
? item.author
|
|
154
|
+
: item.author?.name) ?? 'Unknown',
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
duration: (typeof item.duration === 'number'
|
|
158
|
+
? item.duration
|
|
159
|
+
: item.duration?.seconds) ?? 0,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return tracks.slice(0, 25);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
logger.error('MusicService', 'getTrending failed', {
|
|
167
|
+
error: error instanceof Error ? error.message : String(error),
|
|
168
|
+
});
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async getExploreSections() {
|
|
173
|
+
try {
|
|
174
|
+
const yt = await getClient();
|
|
175
|
+
const music = yt.music;
|
|
176
|
+
const explore = (await music.getExplore());
|
|
177
|
+
const result = [];
|
|
178
|
+
for (const section of explore.sections ?? []) {
|
|
179
|
+
const title = (typeof section.header?.title === 'string'
|
|
180
|
+
? section.header.title
|
|
181
|
+
: section.header?.title?.text) ?? 'Featured';
|
|
182
|
+
const tracks = [];
|
|
183
|
+
for (const item of section.contents ?? []) {
|
|
184
|
+
const videoId = item.id || item.video_id;
|
|
185
|
+
if (!videoId)
|
|
186
|
+
continue;
|
|
187
|
+
tracks.push({
|
|
188
|
+
videoId,
|
|
189
|
+
title: (typeof item.title === 'string'
|
|
190
|
+
? item.title
|
|
191
|
+
: item.title?.text) ?? 'Unknown',
|
|
192
|
+
artists: [
|
|
193
|
+
{
|
|
194
|
+
artistId: '',
|
|
195
|
+
name: (typeof item.author === 'string'
|
|
196
|
+
? item.author
|
|
197
|
+
: item.author?.name) ?? 'Unknown',
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
duration: (typeof item.duration === 'number'
|
|
201
|
+
? item.duration
|
|
202
|
+
: item.duration?.seconds) ?? 0,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (tracks.length > 0) {
|
|
206
|
+
result.push({ title, tracks: tracks.slice(0, 10) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
logger.error('MusicService', 'getExploreSections failed', {
|
|
213
|
+
error: error instanceof Error ? error.message : String(error),
|
|
214
|
+
});
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
118
218
|
async getSuggestions(trackId) {
|
|
119
219
|
try {
|
|
120
220
|
const yt = await getClient();
|
|
@@ -11,6 +11,7 @@ const initialState = {
|
|
|
11
11
|
hasSearched: false,
|
|
12
12
|
searchLimit: 10,
|
|
13
13
|
history: [],
|
|
14
|
+
playerMode: 'full',
|
|
14
15
|
};
|
|
15
16
|
function navigationReducer(state, action) {
|
|
16
17
|
switch (action.category) {
|
|
@@ -48,6 +49,11 @@ function navigationReducer(state, action) {
|
|
|
48
49
|
...state,
|
|
49
50
|
searchLimit: Math.max(1, Math.min(50, action.limit)),
|
|
50
51
|
};
|
|
52
|
+
case 'TOGGLE_PLAYER_MODE':
|
|
53
|
+
return {
|
|
54
|
+
...state,
|
|
55
|
+
playerMode: state.playerMode === 'full' ? 'mini' : 'full',
|
|
56
|
+
};
|
|
51
57
|
default:
|
|
52
58
|
return state;
|
|
53
59
|
}
|
|
@@ -13,6 +13,8 @@ type PlayerContextValue = {
|
|
|
13
13
|
setVolume: (volume: number) => void;
|
|
14
14
|
volumeUp: () => void;
|
|
15
15
|
volumeDown: () => void;
|
|
16
|
+
volumeFineUp: () => void;
|
|
17
|
+
volumeFineDown: () => void;
|
|
16
18
|
toggleShuffle: () => void;
|
|
17
19
|
toggleRepeat: () => void;
|
|
18
20
|
setQueue: (queue: Track[]) => void;
|
|
@@ -20,6 +22,9 @@ type PlayerContextValue = {
|
|
|
20
22
|
removeFromQueue: (index: number) => void;
|
|
21
23
|
clearQueue: () => void;
|
|
22
24
|
setQueuePosition: (position: number) => void;
|
|
25
|
+
setSpeed: (speed: number) => void;
|
|
26
|
+
speedUp: () => void;
|
|
27
|
+
speedDown: () => void;
|
|
23
28
|
};
|
|
24
29
|
export declare function PlayerProvider({ children }: {
|
|
25
30
|
children: ReactNode;
|