@involvex/youtube-music-cli 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/source/components/common/ShortcutsBar.js +3 -1
  2. package/dist/source/components/config/KeybindingsLayout.d.ts +1 -0
  3. package/dist/source/components/config/KeybindingsLayout.js +107 -0
  4. package/dist/source/components/layouts/ExploreLayout.d.ts +1 -0
  5. package/dist/source/components/layouts/ExploreLayout.js +72 -0
  6. package/dist/source/components/layouts/LyricsLayout.d.ts +1 -0
  7. package/dist/source/components/layouts/LyricsLayout.js +89 -0
  8. package/dist/source/components/layouts/MainLayout.js +39 -1
  9. package/dist/source/components/layouts/MiniPlayerLayout.d.ts +1 -0
  10. package/dist/source/components/layouts/MiniPlayerLayout.js +19 -0
  11. package/dist/source/components/layouts/PlayerLayout.js +1 -2
  12. package/dist/source/components/layouts/SearchLayout.js +10 -3
  13. package/dist/source/components/layouts/TrendingLayout.d.ts +1 -0
  14. package/dist/source/components/layouts/TrendingLayout.js +59 -0
  15. package/dist/source/components/player/NowPlaying.js +28 -5
  16. package/dist/source/components/player/PlayerControls.js +4 -2
  17. package/dist/source/components/player/ProgressBar.js +6 -5
  18. package/dist/source/components/player/QueueList.d.ts +1 -1
  19. package/dist/source/components/player/QueueList.js +11 -5
  20. package/dist/source/components/search/SearchBar.js +4 -1
  21. package/dist/source/components/search/SearchHistory.d.ts +5 -0
  22. package/dist/source/components/search/SearchHistory.js +35 -0
  23. package/dist/source/components/settings/Settings.js +74 -11
  24. package/dist/source/config/themes.config.js +60 -0
  25. package/dist/source/hooks/usePlayer.d.ts +5 -0
  26. package/dist/source/hooks/usePlaylist.d.ts +2 -1
  27. package/dist/source/hooks/usePlaylist.js +8 -2
  28. package/dist/source/hooks/useSleepTimer.d.ts +9 -0
  29. package/dist/source/hooks/useSleepTimer.js +48 -0
  30. package/dist/source/services/cache/cache.service.d.ts +14 -0
  31. package/dist/source/services/cache/cache.service.js +67 -0
  32. package/dist/source/services/config/config.service.d.ts +2 -0
  33. package/dist/source/services/config/config.service.js +17 -0
  34. package/dist/source/services/discord/discord-rpc.service.d.ts +17 -0
  35. package/dist/source/services/discord/discord-rpc.service.js +95 -0
  36. package/dist/source/services/lyrics/lyrics.service.d.ts +22 -0
  37. package/dist/source/services/lyrics/lyrics.service.js +93 -0
  38. package/dist/source/services/mpris/mpris.service.d.ts +20 -0
  39. package/dist/source/services/mpris/mpris.service.js +78 -0
  40. package/dist/source/services/notification/notification.service.d.ts +14 -0
  41. package/dist/source/services/notification/notification.service.js +57 -0
  42. package/dist/source/services/player/player.service.d.ts +3 -0
  43. package/dist/source/services/player/player.service.js +20 -3
  44. package/dist/source/services/plugin/plugin-installer.service.js +2 -1
  45. package/dist/source/services/scrobbling/scrobbling.service.d.ts +23 -0
  46. package/dist/source/services/scrobbling/scrobbling.service.js +115 -0
  47. package/dist/source/services/sleep-timer/sleep-timer.service.d.ts +16 -0
  48. package/dist/source/services/sleep-timer/sleep-timer.service.js +45 -0
  49. package/dist/source/services/youtube-music/api.d.ts +6 -0
  50. package/dist/source/services/youtube-music/api.js +102 -2
  51. package/dist/source/stores/navigation.store.js +6 -0
  52. package/dist/source/stores/player.store.d.ts +5 -0
  53. package/dist/source/stores/player.store.js +151 -27
  54. package/dist/source/types/actions.d.ts +13 -0
  55. package/dist/source/types/config.types.d.ts +15 -1
  56. package/dist/source/types/navigation.types.d.ts +3 -2
  57. package/dist/source/types/player.types.d.ts +3 -2
  58. package/dist/source/utils/constants.d.ts +9 -0
  59. package/dist/source/utils/constants.js +9 -0
  60. package/dist/youtube-music-cli.exe +0 -0
  61. package/package.json +5 -2
@@ -0,0 +1,93 @@
1
+ // Lyrics service using LRCLIB API (https://lrclib.net)
2
+ // Free, no authentication required
3
+ import { logger } from "../logger/logger.service.js";
4
+ const LRCLIB_BASE = 'https://lrclib.net/api';
5
+ class LyricsService {
6
+ static instance;
7
+ cache = new Map();
8
+ constructor() { }
9
+ static getInstance() {
10
+ if (!LyricsService.instance) {
11
+ LyricsService.instance = new LyricsService();
12
+ }
13
+ return LyricsService.instance;
14
+ }
15
+ /** Parse LRC format into timed lines */
16
+ parseLrc(lrc) {
17
+ const lines = [];
18
+ const lineRegex = /\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/;
19
+ for (const rawLine of lrc.split('\n')) {
20
+ const match = lineRegex.exec(rawLine.trim());
21
+ if (match) {
22
+ const minutes = Number.parseInt(match[1], 10);
23
+ const seconds = Number.parseInt(match[2], 10);
24
+ const centiseconds = Number.parseInt(match[3].padEnd(3, '0'), 10);
25
+ const time = minutes * 60 + seconds + centiseconds / 1000;
26
+ const text = match[4].trim();
27
+ lines.push({ time, text });
28
+ }
29
+ }
30
+ return lines.sort((a, b) => a.time - b.time);
31
+ }
32
+ async getLyrics(trackName, artistName, duration) {
33
+ const cacheKey = `${trackName}::${artistName}`;
34
+ if (this.cache.has(cacheKey)) {
35
+ return this.cache.get(cacheKey) ?? null;
36
+ }
37
+ try {
38
+ const params = new URLSearchParams({
39
+ track_name: trackName,
40
+ artist_name: artistName,
41
+ ...(duration ? { duration: String(Math.round(duration)) } : {}),
42
+ });
43
+ const response = await fetch(`${LRCLIB_BASE}/get?${params.toString()}`);
44
+ if (!response.ok) {
45
+ if (response.status === 404) {
46
+ logger.debug('LyricsService', 'No lyrics found', {
47
+ trackName,
48
+ artistName,
49
+ });
50
+ this.cache.set(cacheKey, null);
51
+ return null;
52
+ }
53
+ throw new Error(`LRCLIB API error: ${response.status}`);
54
+ }
55
+ const data = (await response.json());
56
+ const lyrics = {
57
+ synced: data.syncedLyrics ? this.parseLrc(data.syncedLyrics) : null,
58
+ plain: data.plainLyrics ?? null,
59
+ };
60
+ this.cache.set(cacheKey, lyrics);
61
+ logger.info('LyricsService', 'Lyrics loaded', {
62
+ trackName,
63
+ hasSynced: !!lyrics.synced,
64
+ hasPlain: !!lyrics.plain,
65
+ });
66
+ return lyrics;
67
+ }
68
+ catch (error) {
69
+ logger.warn('LyricsService', 'Failed to fetch lyrics', {
70
+ error: error instanceof Error ? error.message : String(error),
71
+ });
72
+ this.cache.set(cacheKey, null);
73
+ return null;
74
+ }
75
+ }
76
+ /** Get the current lyric line index based on playback position */
77
+ getCurrentLineIndex(lines, currentTime) {
78
+ let index = 0;
79
+ for (let i = 0; i < lines.length; i++) {
80
+ if (lines[i].time <= currentTime) {
81
+ index = i;
82
+ }
83
+ else {
84
+ break;
85
+ }
86
+ }
87
+ return index;
88
+ }
89
+ clearCache() {
90
+ this.cache.clear();
91
+ }
92
+ }
93
+ export const getLyricsService = () => LyricsService.getInstance();
@@ -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
- this.mpvProcess = spawn('mpv', [
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
- playUrl,
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
  }
@@ -111,7 +111,8 @@ class PluginInstallerService {
111
111
  windowsHide: true,
112
112
  });
113
113
  }
114
- const pluginSourceDir = join(tempDir, 'plugins', pluginName);
114
+ // Plugin is at root of repo (e.g., adblock/, lyrics/, etc.)
115
+ const pluginSourceDir = join(tempDir, pluginName);
115
116
  if (!existsSync(pluginSourceDir)) {
116
117
  rmSync(tempDir, { recursive: true, force: true });
117
118
  return {
@@ -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;