@involvex/youtube-music-cli 0.0.32 → 0.0.34
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/CHANGELOG.md +12 -0
- package/dist/source/components/layouts/SearchLayout.js +71 -7
- package/dist/source/components/player/PlayerControls.js +40 -2
- package/dist/source/components/settings/Settings.js +57 -13
- package/dist/source/services/config/config.service.js +3 -0
- package/dist/source/services/player/player.service.d.ts +4 -0
- package/dist/source/services/player/player.service.js +30 -2
- package/dist/source/stores/navigation.store.js +17 -0
- package/dist/source/stores/player.store.js +3 -0
- package/dist/source/types/actions.d.ts +8 -1
- package/dist/source/types/config.types.d.ts +4 -0
- package/dist/source/types/navigation.types.d.ts +4 -2
- package/dist/source/types/youtube-music.types.d.ts +7 -0
- package/dist/source/utils/constants.d.ts +7 -0
- package/dist/source/utils/constants.js +7 -0
- package/dist/source/utils/search-filters.d.ts +2 -0
- package/dist/source/utils/search-filters.js +100 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.0.34](https://github.com/involvex/youtube-music-cli/compare/v0.0.33...v0.0.34) (2026-02-22)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- **search:** implement search filters by artist, album, and year ([d3edbe6](https://github.com/involvex/youtube-music-cli/commit/d3edbe6b61fd0afa363e1c19b420d73fd6ebbab7))
|
|
6
|
+
|
|
7
|
+
## [0.0.33](https://github.com/involvex/youtube-music-cli/compare/v0.0.32...v0.0.33) (2026-02-22)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- add gapless playback, crossfade, and equalizer settings ([0fe10f4](https://github.com/involvex/youtube-music-cli/commit/0fe10f42700bb9031e4e4c1eacca1bc3aba5ec4d))
|
|
12
|
+
|
|
1
13
|
## [0.0.32](https://github.com/involvex/youtube-music-cli/compare/v0.0.31...v0.0.32) (2026-02-22)
|
|
2
14
|
|
|
3
15
|
## [0.0.31](https://github.com/involvex/youtube-music-cli/compare/v0.0.30...v0.0.31) (2026-02-22)
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
4
4
|
import { useYouTubeMusic } from "../../hooks/useYouTubeMusic.js";
|
|
5
5
|
import SearchResults from "../search/SearchResults.js";
|
|
6
|
-
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
6
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
|
7
7
|
import React from 'react';
|
|
8
8
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
9
9
|
import SearchBar from "../search/SearchBar.js";
|
|
@@ -12,17 +12,57 @@ import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
|
12
12
|
import { Box, Text } from 'ink';
|
|
13
13
|
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
14
14
|
import { ICONS } from "../../utils/icons.js";
|
|
15
|
+
import TextInput from 'ink-text-input';
|
|
16
|
+
import { applySearchFilters } from "../../utils/search-filters.js";
|
|
17
|
+
const FILTER_LABELS = {
|
|
18
|
+
artist: 'Artist',
|
|
19
|
+
album: 'Album',
|
|
20
|
+
year: 'Year',
|
|
21
|
+
};
|
|
22
|
+
const DURATION_ORDER = [
|
|
23
|
+
'all',
|
|
24
|
+
'short',
|
|
25
|
+
'medium',
|
|
26
|
+
'long',
|
|
27
|
+
];
|
|
15
28
|
function SearchLayout() {
|
|
16
29
|
const { theme } = useTheme();
|
|
17
30
|
const { state: navState, dispatch } = useNavigation();
|
|
18
31
|
const { state: playerState } = usePlayer();
|
|
19
32
|
const { isLoading, error, search } = useYouTubeMusic();
|
|
20
|
-
const [
|
|
33
|
+
const [rawResults, setRawResults] = useState([]);
|
|
34
|
+
const filteredResults = useMemo(() => applySearchFilters(rawResults, navState.searchFilters), [rawResults, navState.searchFilters]);
|
|
21
35
|
const [isTyping, setIsTyping] = useState(true);
|
|
22
36
|
const [isSearching, setIsSearching] = useState(false);
|
|
23
37
|
const [actionMessage, setActionMessage] = useState(null);
|
|
24
38
|
const actionTimeoutRef = useRef(null);
|
|
25
39
|
const lastAutoSearchedQueryRef = useRef(null);
|
|
40
|
+
const [editingFilter, setEditingFilter] = useState(null);
|
|
41
|
+
const [filterDraft, setFilterDraft] = useState('');
|
|
42
|
+
const describeFilterValue = (value) => value?.trim() ? value.trim() : 'Any';
|
|
43
|
+
const handleFilterSubmit = useCallback((value) => {
|
|
44
|
+
if (!editingFilter)
|
|
45
|
+
return;
|
|
46
|
+
dispatch({
|
|
47
|
+
category: 'SET_SEARCH_FILTERS',
|
|
48
|
+
filters: { [editingFilter]: value.trim() },
|
|
49
|
+
});
|
|
50
|
+
setEditingFilter(null);
|
|
51
|
+
setFilterDraft('');
|
|
52
|
+
}, [dispatch, editingFilter]);
|
|
53
|
+
const beginFilterEdit = useCallback((field) => {
|
|
54
|
+
setEditingFilter(field);
|
|
55
|
+
setFilterDraft(navState.searchFilters[field] ?? '');
|
|
56
|
+
}, [navState.searchFilters]);
|
|
57
|
+
const cycleDurationFilter = useCallback(() => {
|
|
58
|
+
const currentIndex = DURATION_ORDER.indexOf(navState.searchFilters.duration ?? 'all');
|
|
59
|
+
const nextIndex = (currentIndex + 1) % DURATION_ORDER.length;
|
|
60
|
+
const nextDuration = DURATION_ORDER[nextIndex];
|
|
61
|
+
dispatch({
|
|
62
|
+
category: 'SET_SEARCH_FILTERS',
|
|
63
|
+
filters: { duration: nextDuration },
|
|
64
|
+
});
|
|
65
|
+
}, [dispatch, navState.searchFilters.duration]);
|
|
26
66
|
// Handle search action
|
|
27
67
|
const performSearch = useCallback(async (query) => {
|
|
28
68
|
if (!query || isSearching)
|
|
@@ -33,7 +73,7 @@ function SearchLayout() {
|
|
|
33
73
|
limit: navState.searchLimit,
|
|
34
74
|
});
|
|
35
75
|
if (response) {
|
|
36
|
-
|
|
76
|
+
setRawResults(response.results);
|
|
37
77
|
dispatch({ category: 'SET_SELECTED_RESULT', index: 0 });
|
|
38
78
|
dispatch({ category: 'SET_HAS_SEARCHED', hasSearched: true });
|
|
39
79
|
// Defer focus switch to avoid consuming the same Enter key
|
|
@@ -58,6 +98,10 @@ function SearchLayout() {
|
|
|
58
98
|
}
|
|
59
99
|
}, [isTyping, dispatch]);
|
|
60
100
|
useKeyBinding(['h'], goToHistory);
|
|
101
|
+
useKeyBinding(KEYBINDINGS.SEARCH_FILTER_ARTIST, () => beginFilterEdit('artist'));
|
|
102
|
+
useKeyBinding(KEYBINDINGS.SEARCH_FILTER_ALBUM, () => beginFilterEdit('album'));
|
|
103
|
+
useKeyBinding(KEYBINDINGS.SEARCH_FILTER_YEAR, () => beginFilterEdit('year'));
|
|
104
|
+
useKeyBinding(KEYBINDINGS.SEARCH_FILTER_DURATION, cycleDurationFilter);
|
|
61
105
|
// Initial search if query is in state (usually from CLI flags)
|
|
62
106
|
useEffect(() => {
|
|
63
107
|
const query = navState.searchQuery.trim();
|
|
@@ -74,6 +118,11 @@ function SearchLayout() {
|
|
|
74
118
|
}, [navState.searchQuery, navState.hasSearched, performSearch]);
|
|
75
119
|
// Handle going back
|
|
76
120
|
const goBack = useCallback(() => {
|
|
121
|
+
if (editingFilter) {
|
|
122
|
+
setEditingFilter(null);
|
|
123
|
+
setFilterDraft('');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
77
126
|
if (!isTyping) {
|
|
78
127
|
setIsTyping(true); // Back to typing if in results
|
|
79
128
|
dispatch({ category: 'SET_HAS_SEARCHED', hasSearched: false });
|
|
@@ -81,7 +130,7 @@ function SearchLayout() {
|
|
|
81
130
|
else {
|
|
82
131
|
dispatch({ category: 'GO_BACK' });
|
|
83
132
|
}
|
|
84
|
-
}, [isTyping, dispatch]);
|
|
133
|
+
}, [editingFilter, isTyping, dispatch]);
|
|
85
134
|
useKeyBinding(KEYBINDINGS.BACK, goBack);
|
|
86
135
|
const handleMixCreated = useCallback((message) => {
|
|
87
136
|
setActionMessage(message);
|
|
@@ -113,16 +162,31 @@ function SearchLayout() {
|
|
|
113
162
|
// Reset search state when leaving view
|
|
114
163
|
useEffect(() => {
|
|
115
164
|
return () => {
|
|
116
|
-
|
|
165
|
+
setRawResults([]);
|
|
117
166
|
dispatch({ category: 'SET_HAS_SEARCHED', hasSearched: false });
|
|
118
167
|
dispatch({ category: 'SET_SEARCH_QUERY', query: '' });
|
|
119
168
|
lastAutoSearchedQueryRef.current = null;
|
|
120
169
|
};
|
|
121
170
|
}, [dispatch]);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (filteredResults.length > 0 &&
|
|
173
|
+
navState.selectedResult >= filteredResults.length) {
|
|
174
|
+
dispatch({ category: 'SET_SELECTED_RESULT', index: 0 });
|
|
175
|
+
}
|
|
176
|
+
}, [dispatch, filteredResults.length, navState.selectedResult]);
|
|
177
|
+
const artistFilterLabel = describeFilterValue(navState.searchFilters.artist);
|
|
178
|
+
const albumFilterLabel = describeFilterValue(navState.searchFilters.album);
|
|
179
|
+
const yearFilterLabel = describeFilterValue(navState.searchFilters.year);
|
|
180
|
+
const durationFilterLabel = navState.searchFilters.duration && navState.searchFilters.duration !== 'all'
|
|
181
|
+
? navState.searchFilters.duration
|
|
182
|
+
: 'Any';
|
|
122
183
|
return (_jsxs(Box, { flexDirection: "column", children: [playerState.currentTrack && (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.dim, children: playerState.isPlaying ? `${ICONS.PLAY} ` : `${ICONS.PAUSE} ` }), _jsx(Text, { color: theme.colors.primary, bold: true, children: playerState.currentTrack.title }), playerState.currentTrack.artists &&
|
|
123
|
-
playerState.currentTrack.artists.length > 0 && (_jsxs(Text, { color: theme.colors.secondary, children: [' • ', playerState.currentTrack.artists.map(a => a.name).join(', ')] }))] })), _jsxs(Text, { color: theme.colors.dim, children: ["Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] }), _jsx(SearchBar, { isActive: isTyping && !isSearching, onInput: input => {
|
|
184
|
+
playerState.currentTrack.artists.length > 0 && (_jsxs(Text, { color: theme.colors.secondary, children: [' • ', playerState.currentTrack.artists.map(a => a.name).join(', ')] }))] })), _jsxs(Text, { color: theme.colors.dim, children: ["Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] }), _jsx(SearchBar, { isActive: !editingFilter && isTyping && !isSearching, onInput: input => {
|
|
124
185
|
void performSearch(input);
|
|
125
|
-
} }), (
|
|
186
|
+
} }), editingFilter ? (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: theme.colors.primary, bold: true, children: ["Set ", FILTER_LABELS[editingFilter], " filter:"] }), _jsx(TextInput, { value: filterDraft, onChange: setFilterDraft, onSubmit: handleFilterSubmit, placeholder: "Type value and hit Enter", focus: true })] }), _jsx(Text, { color: theme.colors.dim, children: "Press Enter to save (empty to clear) or Esc to cancel." })] })) : (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: theme.colors.dim, children: ["Filters: Artist=", artistFilterLabel, ", Album=", albumFilterLabel, ", Year=", yearFilterLabel, ", Duration=", durationFilterLabel, " (Ctrl+A Artist, Ctrl+L Album, Ctrl+Y Year, Ctrl+D Duration)"] }) })), (isLoading || isSearching) && (_jsx(Text, { color: theme.colors.accent, children: "Searching..." })), error && _jsx(Text, { color: theme.colors.error, children: error }), !isLoading && navState.hasSearched && (_jsx(SearchResults, { results: filteredResults, selectedIndex: navState.selectedResult, isActive: !isTyping, onMixCreated: handleMixCreated, onDownloadStatus: handleDownloadStatus })), !isLoading &&
|
|
187
|
+
navState.hasSearched &&
|
|
188
|
+
filteredResults.length === 0 &&
|
|
189
|
+
!error && _jsx(Text, { color: theme.colors.dim, children: "No results found" }), actionMessage && (_jsx(Text, { color: theme.colors.accent, children: actionMessage })), _jsx(Text, { color: theme.colors.dim, children: isTyping
|
|
126
190
|
? 'Type to search, Enter to start, Esc to clear'
|
|
127
191
|
: `Arrows to navigate, Enter to play, M mix, Shift+D download, ]/[ more/fewer results (${navState.searchLimit}), H history, Esc to type` })] }));
|
|
128
192
|
}
|
|
@@ -5,10 +5,23 @@ import { KEYBINDINGS } from "../../utils/constants.js";
|
|
|
5
5
|
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
6
6
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
7
7
|
import { Box, Text } from 'ink';
|
|
8
|
-
import { useEffect } from 'react';
|
|
8
|
+
import { useEffect, useState } from 'react';
|
|
9
9
|
import { logger } from "../../services/logger/logger.service.js";
|
|
10
10
|
import { ICONS } from "../../utils/icons.js";
|
|
11
|
+
import { getConfigService } from "../../services/config/config.service.js";
|
|
11
12
|
let mountCount = 0;
|
|
13
|
+
const CROSSFADE_PRESETS = [0, 1, 2, 3, 5];
|
|
14
|
+
const EQUALIZER_PRESETS = [
|
|
15
|
+
'flat',
|
|
16
|
+
'bass_boost',
|
|
17
|
+
'vocal',
|
|
18
|
+
'bright',
|
|
19
|
+
'warm',
|
|
20
|
+
];
|
|
21
|
+
const formatEqualizerLabel = (preset) => preset
|
|
22
|
+
.split('_')
|
|
23
|
+
.map(segment => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`)
|
|
24
|
+
.join(' ');
|
|
12
25
|
export default function PlayerControls() {
|
|
13
26
|
const instanceId = ++mountCount;
|
|
14
27
|
useEffect(() => {
|
|
@@ -19,6 +32,10 @@ export default function PlayerControls() {
|
|
|
19
32
|
}, [instanceId]);
|
|
20
33
|
const { theme } = useTheme();
|
|
21
34
|
const { state: playerState, pause, resume, next, previous, volumeUp, volumeDown, speedUp, speedDown, toggleShuffle, } = usePlayer();
|
|
35
|
+
const config = getConfigService();
|
|
36
|
+
const [gaplessPlayback, setGaplessPlayback] = useState(config.get('gaplessPlayback') ?? true);
|
|
37
|
+
const [crossfadeDuration, setCrossfadeDuration] = useState(config.get('crossfadeDuration') ?? 0);
|
|
38
|
+
const [equalizerPreset, setEqualizerPreset] = useState(config.get('equalizerPreset') ?? 'flat');
|
|
22
39
|
// DEBUG: Log when callbacks change (detect instability)
|
|
23
40
|
useEffect(() => {
|
|
24
41
|
// Temporarily output to stderr to debug without triggering Ink re-render
|
|
@@ -32,6 +49,24 @@ export default function PlayerControls() {
|
|
|
32
49
|
resume();
|
|
33
50
|
}
|
|
34
51
|
};
|
|
52
|
+
const toggleGaplessPlayback = () => {
|
|
53
|
+
const next = !gaplessPlayback;
|
|
54
|
+
setGaplessPlayback(next);
|
|
55
|
+
config.set('gaplessPlayback', next);
|
|
56
|
+
};
|
|
57
|
+
const cycleCrossfadeDuration = () => {
|
|
58
|
+
const currentIndex = CROSSFADE_PRESETS.indexOf(crossfadeDuration);
|
|
59
|
+
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % CROSSFADE_PRESETS.length;
|
|
60
|
+
const next = CROSSFADE_PRESETS[nextIndex] ?? 0;
|
|
61
|
+
setCrossfadeDuration(next);
|
|
62
|
+
config.set('crossfadeDuration', next);
|
|
63
|
+
};
|
|
64
|
+
const cycleEqualizerPreset = () => {
|
|
65
|
+
const currentIndex = EQUALIZER_PRESETS.indexOf(equalizerPreset);
|
|
66
|
+
const next = EQUALIZER_PRESETS[(currentIndex + 1) % EQUALIZER_PRESETS.length];
|
|
67
|
+
setEqualizerPreset(next);
|
|
68
|
+
config.set('equalizerPreset', next);
|
|
69
|
+
};
|
|
35
70
|
// Keyboard bindings
|
|
36
71
|
useKeyBinding(KEYBINDINGS.PLAY_PAUSE, handlePlayPause);
|
|
37
72
|
useKeyBinding(KEYBINDINGS.NEXT, next);
|
|
@@ -41,5 +76,8 @@ export default function PlayerControls() {
|
|
|
41
76
|
useKeyBinding(KEYBINDINGS.SPEED_UP, speedUp);
|
|
42
77
|
useKeyBinding(KEYBINDINGS.SPEED_DOWN, speedDown);
|
|
43
78
|
useKeyBinding(KEYBINDINGS.SHUFFLE, toggleShuffle);
|
|
44
|
-
|
|
79
|
+
useKeyBinding(KEYBINDINGS.GAPLESS_TOGGLE, toggleGaplessPlayback);
|
|
80
|
+
useKeyBinding(KEYBINDINGS.CROSSFADE_CYCLE, cycleCrossfadeDuration);
|
|
81
|
+
useKeyBinding(KEYBINDINGS.EQUALIZER_CYCLE, cycleEqualizerPreset);
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 2, borderStyle: "classic", borderColor: theme.colors.dim, children: [_jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "\u2190 / b" }), "] Prev"] }), _jsx(Text, { color: theme.colors.primary, children: playerState.isPlaying ? (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Pause"] })) : (_jsxs(Text, { children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Space" }), "] Play"] })) }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "\u2192 / n" }), "] Next"] }), _jsxs(Text, { color: theme.colors.text, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "+/-" }), "] Vol: ", playerState.volume, "%"] }), _jsxs(Text, { color: playerState.shuffle ? theme.colors.primary : theme.colors.dim, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "Shift+S" }), "]", ' ', playerState.shuffle ? `${ICONS.SHUFFLE} ON` : `${ICONS.SHUFFLE} OFF`] }), (playerState.speed ?? 1.0) !== 1.0 && (_jsxs(Text, { color: theme.colors.accent, children: ["[", _jsx(Text, { color: theme.colors.dim, children: "<>" }), "]", ' ', (playerState.speed ?? 1.0).toFixed(2), "x"] }))] }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", paddingX: 2, gap: 2, children: [_jsxs(Text, { color: gaplessPlayback ? theme.colors.primary : theme.colors.dim, children: ["Gapless: ", gaplessPlayback ? 'ON' : 'OFF'] }), _jsxs(Text, { color: theme.colors.text, children: ["Crossfade: ", crossfadeDuration === 0 ? 'Off' : `${crossfadeDuration}s`] }), _jsxs(Text, { color: theme.colors.text, children: ["Equalizer: ", formatEqualizerLabel(equalizerPreset)] })] })] }));
|
|
45
83
|
}
|
|
@@ -13,9 +13,20 @@ import { formatTime } from "../../utils/format.js";
|
|
|
13
13
|
import { useKeyboardBlocker } from "../../hooks/useKeyboardBlocker.js";
|
|
14
14
|
const QUALITIES = ['low', 'medium', 'high'];
|
|
15
15
|
const DOWNLOAD_FORMATS = ['mp3', 'm4a'];
|
|
16
|
+
const CROSSFADE_PRESETS = [0, 1, 2, 3, 5];
|
|
17
|
+
const EQUALIZER_PRESETS = [
|
|
18
|
+
'flat',
|
|
19
|
+
'bass_boost',
|
|
20
|
+
'vocal',
|
|
21
|
+
'bright',
|
|
22
|
+
'warm',
|
|
23
|
+
];
|
|
16
24
|
const SETTINGS_ITEMS = [
|
|
17
25
|
'Stream Quality',
|
|
18
26
|
'Audio Normalization',
|
|
27
|
+
'Gapless Playback',
|
|
28
|
+
'Crossfade Duration',
|
|
29
|
+
'Equalizer Preset',
|
|
19
30
|
'Notifications',
|
|
20
31
|
'Discord Rich Presence',
|
|
21
32
|
'Downloads Enabled',
|
|
@@ -34,6 +45,9 @@ export default function Settings() {
|
|
|
34
45
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
35
46
|
const [quality, setQuality] = useState(config.get('streamQuality') || 'high');
|
|
36
47
|
const [audioNormalization, setAudioNormalization] = useState(config.get('audioNormalization') ?? false);
|
|
48
|
+
const [gaplessPlayback, setGaplessPlayback] = useState(config.get('gaplessPlayback') ?? true);
|
|
49
|
+
const [crossfadeDuration, setCrossfadeDuration] = useState(config.get('crossfadeDuration') ?? 0);
|
|
50
|
+
const [equalizerPreset, setEqualizerPreset] = useState(config.get('equalizerPreset') ?? 'flat');
|
|
37
51
|
const [notifications, setNotifications] = useState(config.get('notifications') ?? false);
|
|
38
52
|
const [discordRpc, setDiscordRpc] = useState(config.get('discordRichPresence') ?? false);
|
|
39
53
|
const [downloadsEnabled, setDownloadsEnabled] = useState(config.get('downloadsEnabled') ?? false);
|
|
@@ -59,6 +73,28 @@ export default function Settings() {
|
|
|
59
73
|
setAudioNormalization(next);
|
|
60
74
|
config.set('audioNormalization', next);
|
|
61
75
|
};
|
|
76
|
+
const toggleGaplessPlayback = () => {
|
|
77
|
+
const next = !gaplessPlayback;
|
|
78
|
+
setGaplessPlayback(next);
|
|
79
|
+
config.set('gaplessPlayback', next);
|
|
80
|
+
};
|
|
81
|
+
const cycleCrossfadeDuration = () => {
|
|
82
|
+
const currentIndex = CROSSFADE_PRESETS.indexOf(crossfadeDuration);
|
|
83
|
+
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % CROSSFADE_PRESETS.length;
|
|
84
|
+
const next = CROSSFADE_PRESETS[nextIndex] ?? 0;
|
|
85
|
+
setCrossfadeDuration(next);
|
|
86
|
+
config.set('crossfadeDuration', next);
|
|
87
|
+
};
|
|
88
|
+
const cycleEqualizerPreset = () => {
|
|
89
|
+
const currentIndex = EQUALIZER_PRESETS.indexOf(equalizerPreset);
|
|
90
|
+
const nextPreset = EQUALIZER_PRESETS[(currentIndex + 1) % EQUALIZER_PRESETS.length];
|
|
91
|
+
setEqualizerPreset(nextPreset);
|
|
92
|
+
config.set('equalizerPreset', nextPreset);
|
|
93
|
+
};
|
|
94
|
+
const formatEqualizerLabel = (preset) => preset
|
|
95
|
+
.split('_')
|
|
96
|
+
.map(segment => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`)
|
|
97
|
+
.join(' ');
|
|
62
98
|
const toggleNotifications = () => {
|
|
63
99
|
const next = !notifications;
|
|
64
100
|
setNotifications(next);
|
|
@@ -100,33 +136,42 @@ export default function Settings() {
|
|
|
100
136
|
toggleNormalization();
|
|
101
137
|
}
|
|
102
138
|
else if (selectedIndex === 2) {
|
|
103
|
-
|
|
139
|
+
toggleGaplessPlayback();
|
|
104
140
|
}
|
|
105
141
|
else if (selectedIndex === 3) {
|
|
106
|
-
|
|
142
|
+
cycleCrossfadeDuration();
|
|
107
143
|
}
|
|
108
144
|
else if (selectedIndex === 4) {
|
|
109
|
-
|
|
145
|
+
cycleEqualizerPreset();
|
|
110
146
|
}
|
|
111
147
|
else if (selectedIndex === 5) {
|
|
112
|
-
|
|
148
|
+
toggleNotifications();
|
|
113
149
|
}
|
|
114
150
|
else if (selectedIndex === 6) {
|
|
115
|
-
|
|
151
|
+
toggleDiscordRpc();
|
|
116
152
|
}
|
|
117
153
|
else if (selectedIndex === 7) {
|
|
118
|
-
|
|
154
|
+
toggleDownloadsEnabled();
|
|
119
155
|
}
|
|
120
156
|
else if (selectedIndex === 8) {
|
|
121
|
-
|
|
157
|
+
setIsEditingDownloadDirectory(true);
|
|
122
158
|
}
|
|
123
159
|
else if (selectedIndex === 9) {
|
|
124
|
-
|
|
160
|
+
cycleDownloadFormat();
|
|
125
161
|
}
|
|
126
162
|
else if (selectedIndex === 10) {
|
|
127
|
-
|
|
163
|
+
cycleSleepTimer();
|
|
128
164
|
}
|
|
129
165
|
else if (selectedIndex === 11) {
|
|
166
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.IMPORT });
|
|
167
|
+
}
|
|
168
|
+
else if (selectedIndex === 12) {
|
|
169
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.EXPORT_PLAYLISTS });
|
|
170
|
+
}
|
|
171
|
+
else if (selectedIndex === 13) {
|
|
172
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.KEYBINDINGS });
|
|
173
|
+
}
|
|
174
|
+
else if (selectedIndex === 14) {
|
|
130
175
|
dispatch({ category: 'NAVIGATE', view: VIEW.PLUGINS });
|
|
131
176
|
}
|
|
132
177
|
};
|
|
@@ -136,19 +181,18 @@ export default function Settings() {
|
|
|
136
181
|
const sleepTimerLabel = isActive && remainingSeconds !== null
|
|
137
182
|
? `Sleep Timer: ${formatTime(remainingSeconds)} remaining (Enter to cancel)`
|
|
138
183
|
: 'Sleep Timer: Off (Enter to set)';
|
|
139
|
-
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Settings" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 0 ? theme.colors.primary : undefined, color: selectedIndex === 0 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 0, children: ["Stream Quality: ", quality.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, children: ["Audio Normalization: ", audioNormalization ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 2 ? theme.colors.primary : undefined, color: selectedIndex === 2 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 2, children: ["
|
|
184
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { borderStyle: "double", borderColor: theme.colors.secondary, paddingX: 1, marginBottom: 1, children: _jsx(Text, { bold: true, color: theme.colors.primary, children: "Settings" }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 0 ? theme.colors.primary : undefined, color: selectedIndex === 0 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 0, children: ["Stream Quality: ", quality.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 1 ? theme.colors.primary : undefined, color: selectedIndex === 1 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 1, children: ["Audio Normalization: ", audioNormalization ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 2 ? theme.colors.primary : undefined, color: selectedIndex === 2 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 2, children: ["Gapless Playback: ", gaplessPlayback ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 3 ? theme.colors.primary : undefined, color: selectedIndex === 3 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 3, children: ["Crossfade: ", crossfadeDuration === 0 ? 'Off' : `${crossfadeDuration}s`] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 4 ? theme.colors.primary : undefined, color: selectedIndex === 4 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 4, children: ["Equalizer: ", formatEqualizerLabel(equalizerPreset)] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 5 ? theme.colors.primary : undefined, color: selectedIndex === 5 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 5, children: ["Desktop Notifications: ", notifications ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 6 ? theme.colors.primary : undefined, color: selectedIndex === 6 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 6, children: ["Discord Rich Presence: ", discordRpc ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 7 ? theme.colors.primary : undefined, color: selectedIndex === 7 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 7, children: ["Download Feature: ", downloadsEnabled ? 'ON' : 'OFF'] }) }), _jsx(Box, { paddingX: 1, children: isEditingDownloadDirectory && selectedIndex === 8 ? (_jsx(TextInput, { value: downloadDirectory, onChange: setDownloadDirectory, onSubmit: value => {
|
|
140
185
|
const normalized = value.trim();
|
|
141
186
|
if (!normalized) {
|
|
142
|
-
setDownloadDirectory(config.get('downloadDirectory') ?? '');
|
|
143
187
|
setIsEditingDownloadDirectory(false);
|
|
144
188
|
return;
|
|
145
189
|
}
|
|
146
190
|
setDownloadDirectory(normalized);
|
|
147
191
|
config.set('downloadDirectory', normalized);
|
|
148
192
|
setIsEditingDownloadDirectory(false);
|
|
149
|
-
}, placeholder: "Download directory", focus: true })) : (_jsxs(Text, { backgroundColor: selectedIndex ===
|
|
193
|
+
}, placeholder: "Download directory", focus: true })) : (_jsxs(Text, { backgroundColor: selectedIndex === 8 ? theme.colors.primary : undefined, color: selectedIndex === 8 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 8, children: ["Download Folder: ", downloadDirectory] })) }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { backgroundColor: selectedIndex === 9 ? theme.colors.primary : undefined, color: selectedIndex === 9 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 9, children: ["Download Format: ", downloadFormat.toUpperCase()] }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 10 ? theme.colors.primary : undefined, color: selectedIndex === 10
|
|
150
194
|
? theme.colors.background
|
|
151
195
|
: isActive
|
|
152
196
|
? theme.colors.accent
|
|
153
|
-
: theme.colors.text, bold: selectedIndex ===
|
|
197
|
+
: theme.colors.text, bold: selectedIndex === 10, children: sleepTimerLabel }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 11 ? theme.colors.primary : undefined, color: selectedIndex === 11 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 11, children: "Import Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 12 ? theme.colors.primary : undefined, color: selectedIndex === 12 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 12, children: "Export Playlists \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 13 ? theme.colors.primary : undefined, color: selectedIndex === 13 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 13, children: "Custom Keybindings \u2192" }) }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { backgroundColor: selectedIndex === 14 ? theme.colors.primary : undefined, color: selectedIndex === 14 ? theme.colors.background : theme.colors.text, bold: selectedIndex === 14, children: "Manage Plugins" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Arrows to navigate, Enter to select, Esc/q to go back" }) })] }));
|
|
154
198
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import type { EqualizerPreset } from '../../types/config.types.ts';
|
|
1
2
|
export type PlayOptions = {
|
|
2
3
|
volume?: number;
|
|
3
4
|
audioNormalization?: boolean;
|
|
4
5
|
proxy?: string;
|
|
6
|
+
gaplessPlayback?: boolean;
|
|
7
|
+
crossfadeDuration?: number;
|
|
8
|
+
equalizerPreset?: EqualizerPreset;
|
|
5
9
|
};
|
|
6
10
|
export type PlayerEventCallback = (event: {
|
|
7
11
|
timePos?: number;
|
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { connect } from 'node:net';
|
|
4
4
|
import { logger } from "../logger/logger.service.js";
|
|
5
|
+
const EQUALIZER_PRESET_FILTERS = {
|
|
6
|
+
flat: [],
|
|
7
|
+
bass_boost: ['equalizer=f=60:width_type=o:width=2:g=5'],
|
|
8
|
+
vocal: ['equalizer=f=2500:width_type=o:width=2:g=3'],
|
|
9
|
+
bright: [
|
|
10
|
+
'equalizer=f=4000:width_type=o:width=2:g=3',
|
|
11
|
+
'equalizer=f=8000:width_type=o:width=2:g=2',
|
|
12
|
+
],
|
|
13
|
+
warm: [
|
|
14
|
+
'equalizer=f=100:width_type=o:width=2:g=4',
|
|
15
|
+
'equalizer=f=250:width_type=o:width=2:g=2',
|
|
16
|
+
],
|
|
17
|
+
};
|
|
5
18
|
class PlayerService {
|
|
6
19
|
static instance;
|
|
7
20
|
mpvProcess = null;
|
|
@@ -212,6 +225,20 @@ class PlayerService {
|
|
|
212
225
|
volume: this.currentVolume,
|
|
213
226
|
ipcPath: this.ipcPath,
|
|
214
227
|
});
|
|
228
|
+
const gapless = options?.gaplessPlayback ?? true;
|
|
229
|
+
const crossfadeDuration = Math.max(0, options?.crossfadeDuration ?? 0);
|
|
230
|
+
const eqPreset = options?.equalizerPreset ?? 'flat';
|
|
231
|
+
const audioFilters = [];
|
|
232
|
+
if (options?.audioNormalization) {
|
|
233
|
+
audioFilters.push('dynaudnorm');
|
|
234
|
+
}
|
|
235
|
+
if (crossfadeDuration > 0) {
|
|
236
|
+
audioFilters.push(`acrossfade=d=${crossfadeDuration}`);
|
|
237
|
+
}
|
|
238
|
+
const presetFilters = EQUALIZER_PRESET_FILTERS[eqPreset] ?? [];
|
|
239
|
+
if (presetFilters.length > 0) {
|
|
240
|
+
audioFilters.push(...presetFilters);
|
|
241
|
+
}
|
|
215
242
|
// Spawn mpv with JSON IPC for better control
|
|
216
243
|
const mpvArgs = [
|
|
217
244
|
'--no-video', // Audio only
|
|
@@ -225,9 +252,10 @@ class PlayerService {
|
|
|
225
252
|
'--cache=yes', // Enable cache for network streams
|
|
226
253
|
'--cache-secs=30', // Buffer 30 seconds ahead
|
|
227
254
|
'--network-timeout=10', // 10s network timeout
|
|
255
|
+
`--gapless-audio=${gapless ? 'yes' : 'no'}`,
|
|
228
256
|
];
|
|
229
|
-
if (
|
|
230
|
-
mpvArgs.push(
|
|
257
|
+
if (audioFilters.length > 0) {
|
|
258
|
+
mpvArgs.push(`--af=${audioFilters.join(',')}`);
|
|
231
259
|
}
|
|
232
260
|
if (options?.proxy) {
|
|
233
261
|
mpvArgs.push(`--http-proxy=${options.proxy}`);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext, useReducer, useMemo, } from 'react';
|
|
3
|
+
const defaultSearchFilters = {
|
|
4
|
+
artist: '',
|
|
5
|
+
album: '',
|
|
6
|
+
year: '',
|
|
7
|
+
duration: 'all',
|
|
8
|
+
};
|
|
3
9
|
const initialState = {
|
|
4
10
|
currentView: 'player',
|
|
5
11
|
previousView: null,
|
|
@@ -12,6 +18,7 @@ const initialState = {
|
|
|
12
18
|
searchLimit: 10,
|
|
13
19
|
history: [],
|
|
14
20
|
playerMode: 'full',
|
|
21
|
+
searchFilters: defaultSearchFilters,
|
|
15
22
|
};
|
|
16
23
|
function navigationReducer(state, action) {
|
|
17
24
|
switch (action.category) {
|
|
@@ -38,6 +45,16 @@ function navigationReducer(state, action) {
|
|
|
38
45
|
return { ...state, searchQuery: action.query };
|
|
39
46
|
case 'SET_SEARCH_CATEGORY':
|
|
40
47
|
return { ...state, searchCategory: action.category };
|
|
48
|
+
case 'SET_SEARCH_FILTERS':
|
|
49
|
+
return {
|
|
50
|
+
...state,
|
|
51
|
+
searchFilters: { ...state.searchFilters, ...action.filters },
|
|
52
|
+
};
|
|
53
|
+
case 'CLEAR_SEARCH_FILTERS':
|
|
54
|
+
return {
|
|
55
|
+
...state,
|
|
56
|
+
searchFilters: defaultSearchFilters,
|
|
57
|
+
};
|
|
41
58
|
case 'SET_SELECTED_RESULT':
|
|
42
59
|
return { ...state, selectedResult: action.index };
|
|
43
60
|
case 'SET_SELECTED_PLAYLIST':
|
|
@@ -386,6 +386,9 @@ function PlayerManager() {
|
|
|
386
386
|
volume: state.volume,
|
|
387
387
|
audioNormalization: config.get('audioNormalization') ?? false,
|
|
388
388
|
proxy: config.get('proxy'),
|
|
389
|
+
gaplessPlayback: config.get('gaplessPlayback') ?? true,
|
|
390
|
+
crossfadeDuration: config.get('crossfadeDuration') ?? 0,
|
|
391
|
+
equalizerPreset: config.get('equalizerPreset') ?? 'flat',
|
|
389
392
|
});
|
|
390
393
|
logger.info('PlayerManager', 'Playback started successfully', {
|
|
391
394
|
attempt,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Track } from './youtube-music.types.ts';
|
|
1
|
+
import type { SearchFilters, Track } from './youtube-music.types.ts';
|
|
2
2
|
export interface PlayAction {
|
|
3
3
|
readonly category: 'PLAY';
|
|
4
4
|
track: Track;
|
|
@@ -111,6 +111,13 @@ export interface SetSearchCategoryAction {
|
|
|
111
111
|
readonly category: 'SET_SEARCH_CATEGORY';
|
|
112
112
|
searchType: string;
|
|
113
113
|
}
|
|
114
|
+
export interface SetSearchFiltersAction {
|
|
115
|
+
readonly category: 'SET_SEARCH_FILTERS';
|
|
116
|
+
filters: Partial<SearchFilters>;
|
|
117
|
+
}
|
|
118
|
+
export interface ClearSearchFiltersAction {
|
|
119
|
+
readonly category: 'CLEAR_SEARCH_FILTERS';
|
|
120
|
+
}
|
|
114
121
|
export interface SetSelectedResultAction {
|
|
115
122
|
readonly category: 'SET_SELECTED_RESULT';
|
|
116
123
|
index: number;
|
|
@@ -3,6 +3,7 @@ import type { Theme } from './theme.types.ts';
|
|
|
3
3
|
import type { WebServerConfig } from './web.types.ts';
|
|
4
4
|
export type RepeatMode = 'off' | 'all' | 'one';
|
|
5
5
|
export type DownloadFormat = 'mp3' | 'm4a';
|
|
6
|
+
export type EqualizerPreset = 'flat' | 'bass_boost' | 'vocal' | 'bright' | 'warm';
|
|
6
7
|
export interface KeybindingConfig {
|
|
7
8
|
keys: string[];
|
|
8
9
|
description: string;
|
|
@@ -20,6 +21,9 @@ export interface Config {
|
|
|
20
21
|
customTheme?: Theme;
|
|
21
22
|
streamQuality?: 'low' | 'medium' | 'high';
|
|
22
23
|
audioNormalization?: boolean;
|
|
24
|
+
gaplessPlayback?: boolean;
|
|
25
|
+
crossfadeDuration?: number;
|
|
26
|
+
equalizerPreset?: EqualizerPreset;
|
|
23
27
|
notifications?: boolean;
|
|
24
28
|
scrobbling?: {
|
|
25
29
|
lastfm?: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { NavigateAction, GoBackAction, SetSearchQueryAction, SetSearchCategoryAction, SetSelectedResultAction, SetSelectedPlaylistAction, SetHasSearchedAction, SetSearchLimitAction, TogglePlayerModeAction, DetachAction } from './actions.ts';
|
|
1
|
+
import type { NavigateAction, GoBackAction, SetSearchQueryAction, SetSearchCategoryAction, SetSearchFiltersAction, ClearSearchFiltersAction, SetSelectedResultAction, SetSelectedPlaylistAction, SetHasSearchedAction, SetSearchLimitAction, TogglePlayerModeAction, DetachAction } from './actions.ts';
|
|
2
|
+
import type { SearchFilters } from './youtube-music.types.ts';
|
|
2
3
|
export interface NavigationState {
|
|
3
4
|
currentView: string;
|
|
4
5
|
previousView: string | null;
|
|
@@ -11,5 +12,6 @@ export interface NavigationState {
|
|
|
11
12
|
searchLimit: number;
|
|
12
13
|
history: string[];
|
|
13
14
|
playerMode: 'full' | 'mini';
|
|
15
|
+
searchFilters: SearchFilters;
|
|
14
16
|
}
|
|
15
|
-
export type NavigationAction = NavigateAction | GoBackAction | SetSearchQueryAction | SetSearchCategoryAction | SetSelectedResultAction | SetSelectedPlaylistAction | SetHasSearchedAction | SetSearchLimitAction | TogglePlayerModeAction | DetachAction;
|
|
17
|
+
export type NavigationAction = NavigateAction | GoBackAction | SetSearchQueryAction | SetSearchCategoryAction | SetSearchFiltersAction | ClearSearchFiltersAction | SetSelectedResultAction | SetSelectedPlaylistAction | SetHasSearchedAction | SetSearchLimitAction | TogglePlayerModeAction | DetachAction;
|
|
@@ -33,3 +33,10 @@ export interface SearchOptions {
|
|
|
33
33
|
limit?: number;
|
|
34
34
|
continuation?: string;
|
|
35
35
|
}
|
|
36
|
+
export type SearchDurationFilter = 'all' | 'short' | 'medium' | 'long';
|
|
37
|
+
export interface SearchFilters {
|
|
38
|
+
artist?: string;
|
|
39
|
+
album?: string;
|
|
40
|
+
year?: string;
|
|
41
|
+
duration?: SearchDurationFilter;
|
|
42
|
+
}
|
|
@@ -47,6 +47,9 @@ export declare const KEYBINDINGS: {
|
|
|
47
47
|
readonly VOLUME_FINE_DOWN: readonly ["shift+-"];
|
|
48
48
|
readonly SHUFFLE: readonly ["shift+s"];
|
|
49
49
|
readonly REPEAT: readonly ["r"];
|
|
50
|
+
readonly GAPLESS_TOGGLE: readonly ["shift+g"];
|
|
51
|
+
readonly CROSSFADE_CYCLE: readonly ["shift+c"];
|
|
52
|
+
readonly EQUALIZER_CYCLE: readonly ["shift+e"];
|
|
50
53
|
readonly SEEK_FORWARD: readonly ["shift+right"];
|
|
51
54
|
readonly SEEK_BACKWARD: readonly ["shift+left"];
|
|
52
55
|
readonly SPEED_UP: readonly [">"];
|
|
@@ -60,6 +63,10 @@ export declare const KEYBINDINGS: {
|
|
|
60
63
|
readonly PREV_RESULT: readonly ["shift+tab"];
|
|
61
64
|
readonly INCREASE_RESULTS: readonly ["]"];
|
|
62
65
|
readonly DECREASE_RESULTS: readonly ["["];
|
|
66
|
+
readonly SEARCH_FILTER_ARTIST: readonly ["ctrl+a"];
|
|
67
|
+
readonly SEARCH_FILTER_ALBUM: readonly ["ctrl+l"];
|
|
68
|
+
readonly SEARCH_FILTER_YEAR: readonly ["ctrl+y"];
|
|
69
|
+
readonly SEARCH_FILTER_DURATION: readonly ["ctrl+d"];
|
|
63
70
|
readonly ADD_TO_PLAYLIST: readonly ["a"];
|
|
64
71
|
readonly REMOVE_FROM_PLAYLIST: readonly ["d"];
|
|
65
72
|
readonly CREATE_PLAYLIST: readonly ["c"];
|
|
@@ -56,6 +56,9 @@ export const KEYBINDINGS = {
|
|
|
56
56
|
VOLUME_FINE_DOWN: ['shift+-'], // Fine-grained -1 step
|
|
57
57
|
SHUFFLE: ['shift+s'],
|
|
58
58
|
REPEAT: ['r'],
|
|
59
|
+
GAPLESS_TOGGLE: ['shift+g'],
|
|
60
|
+
CROSSFADE_CYCLE: ['shift+c'],
|
|
61
|
+
EQUALIZER_CYCLE: ['shift+e'],
|
|
59
62
|
SEEK_FORWARD: ['shift+right'],
|
|
60
63
|
SEEK_BACKWARD: ['shift+left'],
|
|
61
64
|
SPEED_UP: ['>'],
|
|
@@ -71,6 +74,10 @@ export const KEYBINDINGS = {
|
|
|
71
74
|
PREV_RESULT: ['shift+tab'],
|
|
72
75
|
INCREASE_RESULTS: [']'],
|
|
73
76
|
DECREASE_RESULTS: ['['],
|
|
77
|
+
SEARCH_FILTER_ARTIST: ['ctrl+a'],
|
|
78
|
+
SEARCH_FILTER_ALBUM: ['ctrl+l'],
|
|
79
|
+
SEARCH_FILTER_YEAR: ['ctrl+y'],
|
|
80
|
+
SEARCH_FILTER_DURATION: ['ctrl+d'],
|
|
74
81
|
// Playlist
|
|
75
82
|
ADD_TO_PLAYLIST: ['a'],
|
|
76
83
|
REMOVE_FROM_PLAYLIST: ['d'],
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const DURATION_BUCKETS = {
|
|
2
|
+
short: { min: 0, max: 180 },
|
|
3
|
+
medium: { min: 181, max: 300 },
|
|
4
|
+
long: { min: 301, max: Number.POSITIVE_INFINITY },
|
|
5
|
+
};
|
|
6
|
+
function includesIgnoreCase(value, filter) {
|
|
7
|
+
return Boolean(value && value.toLowerCase().includes(filter));
|
|
8
|
+
}
|
|
9
|
+
function isSongResult(result) {
|
|
10
|
+
return result.type === 'song';
|
|
11
|
+
}
|
|
12
|
+
function isAlbumResult(result) {
|
|
13
|
+
return result.type === 'album';
|
|
14
|
+
}
|
|
15
|
+
function isArtistResult(result) {
|
|
16
|
+
return result.type === 'artist';
|
|
17
|
+
}
|
|
18
|
+
function isPlaylistResult(result) {
|
|
19
|
+
return result.type === 'playlist';
|
|
20
|
+
}
|
|
21
|
+
function matchesArtistFilter(result, filter) {
|
|
22
|
+
if (!filter)
|
|
23
|
+
return true;
|
|
24
|
+
if (isSongResult(result)) {
|
|
25
|
+
const track = result.data;
|
|
26
|
+
if (track.artists.some(artist => includesIgnoreCase(artist.name, filter))) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (track.album?.artists?.some(artist => includesIgnoreCase(artist.name, filter))) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (isAlbumResult(result)) {
|
|
34
|
+
return result.data.artists.some(artist => includesIgnoreCase(artist.name, filter));
|
|
35
|
+
}
|
|
36
|
+
if (isArtistResult(result)) {
|
|
37
|
+
return includesIgnoreCase(result.data.name, filter);
|
|
38
|
+
}
|
|
39
|
+
if (isPlaylistResult(result)) {
|
|
40
|
+
return includesIgnoreCase(result.data.name, filter);
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
function matchesAlbumFilter(result, filter) {
|
|
45
|
+
if (!filter)
|
|
46
|
+
return true;
|
|
47
|
+
if (isSongResult(result)) {
|
|
48
|
+
return includesIgnoreCase(result.data.album?.name, filter);
|
|
49
|
+
}
|
|
50
|
+
if (isAlbumResult(result)) {
|
|
51
|
+
return includesIgnoreCase(result.data.name, filter);
|
|
52
|
+
}
|
|
53
|
+
if (isPlaylistResult(result)) {
|
|
54
|
+
return includesIgnoreCase(result.data.name, filter);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
function matchesYearFilter(result, filter) {
|
|
59
|
+
if (!filter)
|
|
60
|
+
return true;
|
|
61
|
+
const normalizedFilter = filter.toLowerCase();
|
|
62
|
+
const textSources = [];
|
|
63
|
+
if (isSongResult(result)) {
|
|
64
|
+
textSources.push(result.data.title, result.data.album?.name);
|
|
65
|
+
}
|
|
66
|
+
if (isAlbumResult(result)) {
|
|
67
|
+
textSources.push(result.data.name);
|
|
68
|
+
textSources.push(...result.data.artists.map(artist => artist.name));
|
|
69
|
+
}
|
|
70
|
+
if (isArtistResult(result)) {
|
|
71
|
+
textSources.push(result.data.name);
|
|
72
|
+
}
|
|
73
|
+
if (isPlaylistResult(result)) {
|
|
74
|
+
textSources.push(result.data.name);
|
|
75
|
+
}
|
|
76
|
+
return textSources.some(source => includesIgnoreCase(source, normalizedFilter));
|
|
77
|
+
}
|
|
78
|
+
function matchesDurationFilter(result, filter) {
|
|
79
|
+
if (!filter || filter === 'all') {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (!isSongResult(result)) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const duration = result.data.duration ?? 0;
|
|
86
|
+
const range = DURATION_BUCKETS[filter];
|
|
87
|
+
return duration >= range.min && duration <= range.max;
|
|
88
|
+
}
|
|
89
|
+
export function applySearchFilters(results, filters) {
|
|
90
|
+
const artistFilter = filters.artist?.trim().toLowerCase() ?? '';
|
|
91
|
+
const albumFilter = filters.album?.trim().toLowerCase() ?? '';
|
|
92
|
+
const yearFilter = filters.year?.trim() ?? '';
|
|
93
|
+
const durationFilter = filters.duration;
|
|
94
|
+
return results.filter(result => {
|
|
95
|
+
return (matchesArtistFilter(result, artistFilter) &&
|
|
96
|
+
matchesAlbumFilter(result, albumFilter) &&
|
|
97
|
+
matchesYearFilter(result, yearFilter) &&
|
|
98
|
+
matchesDurationFilter(result, durationFilter));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
Binary file
|