@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.
Files changed (56) 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/SearchLayout.js +10 -3
  12. package/dist/source/components/layouts/TrendingLayout.d.ts +1 -0
  13. package/dist/source/components/layouts/TrendingLayout.js +59 -0
  14. package/dist/source/components/player/NowPlaying.js +21 -1
  15. package/dist/source/components/player/PlayerControls.js +4 -2
  16. package/dist/source/components/search/SearchBar.js +4 -1
  17. package/dist/source/components/search/SearchHistory.d.ts +5 -0
  18. package/dist/source/components/search/SearchHistory.js +35 -0
  19. package/dist/source/components/settings/Settings.js +74 -11
  20. package/dist/source/config/themes.config.js +60 -0
  21. package/dist/source/hooks/usePlayer.d.ts +5 -0
  22. package/dist/source/hooks/usePlaylist.d.ts +2 -1
  23. package/dist/source/hooks/usePlaylist.js +8 -2
  24. package/dist/source/hooks/useSleepTimer.d.ts +9 -0
  25. package/dist/source/hooks/useSleepTimer.js +48 -0
  26. package/dist/source/services/cache/cache.service.d.ts +14 -0
  27. package/dist/source/services/cache/cache.service.js +67 -0
  28. package/dist/source/services/config/config.service.d.ts +2 -0
  29. package/dist/source/services/config/config.service.js +17 -0
  30. package/dist/source/services/discord/discord-rpc.service.d.ts +17 -0
  31. package/dist/source/services/discord/discord-rpc.service.js +95 -0
  32. package/dist/source/services/lyrics/lyrics.service.d.ts +22 -0
  33. package/dist/source/services/lyrics/lyrics.service.js +93 -0
  34. package/dist/source/services/mpris/mpris.service.d.ts +20 -0
  35. package/dist/source/services/mpris/mpris.service.js +78 -0
  36. package/dist/source/services/notification/notification.service.d.ts +14 -0
  37. package/dist/source/services/notification/notification.service.js +57 -0
  38. package/dist/source/services/player/player.service.d.ts +3 -0
  39. package/dist/source/services/player/player.service.js +20 -3
  40. package/dist/source/services/scrobbling/scrobbling.service.d.ts +23 -0
  41. package/dist/source/services/scrobbling/scrobbling.service.js +115 -0
  42. package/dist/source/services/sleep-timer/sleep-timer.service.d.ts +16 -0
  43. package/dist/source/services/sleep-timer/sleep-timer.service.js +45 -0
  44. package/dist/source/services/youtube-music/api.d.ts +6 -0
  45. package/dist/source/services/youtube-music/api.js +102 -2
  46. package/dist/source/stores/navigation.store.js +6 -0
  47. package/dist/source/stores/player.store.d.ts +5 -0
  48. package/dist/source/stores/player.store.js +141 -24
  49. package/dist/source/types/actions.d.ts +13 -0
  50. package/dist/source/types/config.types.d.ts +15 -1
  51. package/dist/source/types/navigation.types.d.ts +3 -2
  52. package/dist/source/types/player.types.d.ts +3 -2
  53. package/dist/source/utils/constants.d.ts +9 -0
  54. package/dist/source/utils/constants.js +9 -0
  55. package/dist/youtube-music-cli.exe +0 -0
  56. 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
- 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
  }
@@ -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
- return {
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;