@supasayan/ytm 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +44 -0
- package/client/index.html +13 -0
- package/client/package-lock.json +1003 -0
- package/client/package.json +22 -0
- package/client/public/favicon.svg +1 -0
- package/client/public/icons.svg +24 -0
- package/client/src/App.jsx +210 -0
- package/client/src/assets/hero.png +0 -0
- package/client/src/assets/typescript.svg +1 -0
- package/client/src/assets/vite.svg +1 -0
- package/client/src/components/Download/DownloadItem.css +246 -0
- package/client/src/components/Download/DownloadItem.jsx +108 -0
- package/client/src/components/Download/FlyingThumbnail.css +33 -0
- package/client/src/components/Download/FlyingThumbnail.jsx +48 -0
- package/client/src/components/Layout/Header.css +91 -0
- package/client/src/components/Layout/Header.jsx +49 -0
- package/client/src/components/Layout/SystemCheckScreen.css +250 -0
- package/client/src/components/Layout/SystemCheckScreen.jsx +110 -0
- package/client/src/components/Player/AudioPlayer.css +280 -0
- package/client/src/components/Player/AudioPlayer.jsx +154 -0
- package/client/src/components/Playlist/PlaylistInput.css +71 -0
- package/client/src/components/Playlist/PlaylistInput.jsx +44 -0
- package/client/src/components/Playlist/PlaylistView.css +150 -0
- package/client/src/components/Playlist/PlaylistView.jsx +104 -0
- package/client/src/components/Search/SearchBar.css +163 -0
- package/client/src/components/Search/SearchBar.jsx +127 -0
- package/client/src/components/Search/SearchResults.css +42 -0
- package/client/src/components/Search/SearchResults.jsx +64 -0
- package/client/src/components/Search/SongCard.css +296 -0
- package/client/src/components/Search/SongCard.jsx +204 -0
- package/client/src/components/Settings/SettingsPanel.css +345 -0
- package/client/src/components/Settings/SettingsPanel.jsx +174 -0
- package/client/src/hooks/useDownload.js +107 -0
- package/client/src/hooks/usePlayer.js +188 -0
- package/client/src/hooks/usePlaylist.js +52 -0
- package/client/src/hooks/useSearch.js +34 -0
- package/client/src/index.css +245 -0
- package/client/src/main.jsx +10 -0
- package/client/src/pages/DownloadsPage.css +37 -0
- package/client/src/pages/DownloadsPage.jsx +80 -0
- package/client/src/pages/PlaylistPage.jsx +46 -0
- package/client/src/pages/SearchPage.jsx +63 -0
- package/client/tsconfig.json +24 -0
- package/client/vite.config.js +12 -0
- package/main.js +79 -0
- package/package.json +48 -0
- package/server/index.js +54 -0
- package/server/package-lock.json +1428 -0
- package/server/package.json +14 -0
- package/server/routes/browse.js +23 -0
- package/server/routes/download.js +339 -0
- package/server/routes/playLocal.js +50 -0
- package/server/routes/playlist.js +28 -0
- package/server/routes/search.js +21 -0
- package/server/routes/stream.js +53 -0
- package/server/services/downloadService.js +143 -0
- package/server/services/ytmusicService.js +62 -0
- package/server/utils/dependencyChecker.js +174 -0
- package/server/utils/paths.js +11 -0
- package/server/utils/sanitize.js +9 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import SongCard from '../Search/SongCard';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import './PlaylistView.css';
|
|
5
|
+
|
|
6
|
+
export default function PlaylistView({ playlist, isLoading, onDownloadAll, onDownloadSingle, onFlyAnimation, downloads, onPlay, currentSong, onRemoveTrack }) {
|
|
7
|
+
const [removingIds, setRemovingIds] = useState(new Set());
|
|
8
|
+
|
|
9
|
+
const handleRemove = (videoId) => {
|
|
10
|
+
setRemovingIds(prev => {
|
|
11
|
+
const next = new Set(prev);
|
|
12
|
+
next.add(videoId);
|
|
13
|
+
return next;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
setTimeout(() => {
|
|
17
|
+
if (onRemoveTrack) {
|
|
18
|
+
onRemoveTrack(videoId);
|
|
19
|
+
}
|
|
20
|
+
setRemovingIds(prev => {
|
|
21
|
+
const next = new Set(prev);
|
|
22
|
+
next.delete(videoId);
|
|
23
|
+
return next;
|
|
24
|
+
});
|
|
25
|
+
}, 350);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (isLoading) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="playlist-view">
|
|
31
|
+
<div className="playlist-header skeleton-header" />
|
|
32
|
+
<div className="playlist-tracks">
|
|
33
|
+
{[...Array(6)].map((_, i) => (
|
|
34
|
+
<div key={i} className="skeleton-card liquid-glass">
|
|
35
|
+
<div className="skeleton-thumbnail" />
|
|
36
|
+
<div className="skeleton-info">
|
|
37
|
+
<div className="skeleton-line skeleton-title-line" />
|
|
38
|
+
<div className="skeleton-line skeleton-meta-line" />
|
|
39
|
+
</div>
|
|
40
|
+
<div className="skeleton-button" />
|
|
41
|
+
</div>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!playlist) return null;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="playlist-view liquid-glass">
|
|
52
|
+
<div className="playlist-header">
|
|
53
|
+
<div className="playlist-info">
|
|
54
|
+
<h2>{playlist.title}</h2>
|
|
55
|
+
<p>{playlist.trackCount} tracks</p>
|
|
56
|
+
</div>
|
|
57
|
+
<button
|
|
58
|
+
className="download-all-btn"
|
|
59
|
+
onClick={() => onDownloadAll(playlist.tracks)}
|
|
60
|
+
>
|
|
61
|
+
⬇ Download All
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div className="playlist-tracks">
|
|
66
|
+
{playlist.tracks.map((song, i) => {
|
|
67
|
+
const dlEntry = downloads ? Array.from(downloads.values()).find(d => d.videoId === song.videoId) : null;
|
|
68
|
+
const isPlaying = currentSong?.videoId === song.videoId;
|
|
69
|
+
const isRemoving = removingIds.has(song.videoId);
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={`track-card-wrapper stagger-item ${isPlaying ? 'playing' : ''} ${isRemoving ? 'removing' : ''}`}
|
|
73
|
+
key={song.videoId || i}
|
|
74
|
+
style={{
|
|
75
|
+
animation: isRemoving
|
|
76
|
+
? 'cardExit 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards'
|
|
77
|
+
: 'fadeSlideUp 0.4s ease-out backwards',
|
|
78
|
+
animationDelay: isRemoving ? '0ms' : `${i * 40}ms`
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<span className="track-number-badge">{i + 1}</span>
|
|
82
|
+
<button
|
|
83
|
+
className="track-remove-btn"
|
|
84
|
+
onClick={() => handleRemove(song.videoId)}
|
|
85
|
+
title="Remove song from list"
|
|
86
|
+
>
|
|
87
|
+
<X size={12} />
|
|
88
|
+
</button>
|
|
89
|
+
<SongCard
|
|
90
|
+
song={song}
|
|
91
|
+
onDownload={onDownloadSingle}
|
|
92
|
+
onFlyAnimation={onFlyAnimation}
|
|
93
|
+
downloadStatus={dlEntry?.status || null}
|
|
94
|
+
downloadPercent={dlEntry?.percent || '0%'}
|
|
95
|
+
onPlay={(song, rect) => onPlay(song, rect, playlist.tracks)}
|
|
96
|
+
isCurrentlyPlaying={isPlaying}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
.search-bar-wrapper {
|
|
2
|
+
margin-bottom: 2rem;
|
|
3
|
+
min-height: 84px; /* matches the size of container to avoid layout shift */
|
|
4
|
+
display: flex;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
align-items: flex-start;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.search-bar-container {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
gap: 1rem;
|
|
13
|
+
padding: 1.25rem 2rem; /* slightly larger side padding for pill shape */
|
|
14
|
+
border-radius: 50px; /* Pill shape */
|
|
15
|
+
width: 100%;
|
|
16
|
+
max-width: 600px; /* Make it smaller so it floods quicker visually */
|
|
17
|
+
background: var(--glass-bg);
|
|
18
|
+
backdrop-filter: blur(var(--glass-blur));
|
|
19
|
+
-webkit-backdrop-filter: blur(var(--glass-blur));
|
|
20
|
+
border: 1px solid var(--glass-border);
|
|
21
|
+
box-shadow: var(--glass-shadow);
|
|
22
|
+
position: relative;
|
|
23
|
+
overflow: hidden;
|
|
24
|
+
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
25
|
+
animation: scalePop 0.4s ease-out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.search-bar-container.floating {
|
|
29
|
+
position: fixed !important;
|
|
30
|
+
top: 75px; /* right below sticky header (70px) */
|
|
31
|
+
left: 50%;
|
|
32
|
+
transform: translateX(-50%);
|
|
33
|
+
width: 90%;
|
|
34
|
+
max-width: 600px;
|
|
35
|
+
z-index: 99;
|
|
36
|
+
border-color: var(--accent-primary);
|
|
37
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
|
|
38
|
+
border-radius: 50px;
|
|
39
|
+
padding: 0.85rem 1.75rem;
|
|
40
|
+
background: var(--bg-secondary); /* make it opaque when floating for visibility */
|
|
41
|
+
animation: slideDownFade 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@keyframes slideDownFade {
|
|
45
|
+
from {
|
|
46
|
+
transform: translate(-50%, -20px) scale(0.95);
|
|
47
|
+
opacity: 0;
|
|
48
|
+
}
|
|
49
|
+
to {
|
|
50
|
+
transform: translate(-50%, 0) scale(1);
|
|
51
|
+
opacity: 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.search-bar-container::before {
|
|
56
|
+
content: '';
|
|
57
|
+
position: absolute;
|
|
58
|
+
inset: 0;
|
|
59
|
+
background: var(--glass-highlight);
|
|
60
|
+
pointer-events: none;
|
|
61
|
+
z-index: 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.search-countdown-flood {
|
|
65
|
+
position: absolute;
|
|
66
|
+
top: 0;
|
|
67
|
+
left: 0;
|
|
68
|
+
height: 100%;
|
|
69
|
+
width: 0%;
|
|
70
|
+
background: linear-gradient(
|
|
71
|
+
to right,
|
|
72
|
+
rgba(99, 102, 241, 0.05) 0%,
|
|
73
|
+
rgba(139, 92, 246, 0.2) 100%
|
|
74
|
+
);
|
|
75
|
+
animation: floodWidth 1s cubic-bezier(0.25, 0.1, 0.25, 1) forwards;
|
|
76
|
+
z-index: 1;
|
|
77
|
+
pointer-events: none;
|
|
78
|
+
mix-blend-mode: screen;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Wavy liquid leading edge */
|
|
82
|
+
.search-countdown-flood::after {
|
|
83
|
+
content: "";
|
|
84
|
+
position: absolute;
|
|
85
|
+
top: 50%;
|
|
86
|
+
right: -30px; /* Center it on the right edge */
|
|
87
|
+
width: 60px;
|
|
88
|
+
height: 120px; /* Taller than the bar so the rotation completely covers the vertical space */
|
|
89
|
+
background: rgba(236, 72, 153, 0.6); /* Vibrant fluorescent pink */
|
|
90
|
+
border-radius: 43%; /* The magical value that makes it wobble like a wave */
|
|
91
|
+
transform-origin: center center;
|
|
92
|
+
animation: liquidRotate 1.5s linear infinite;
|
|
93
|
+
box-shadow:
|
|
94
|
+
-10px 0 20px rgba(236, 72, 153, 0.5),
|
|
95
|
+
0 0 30px rgba(139, 92, 246, 0.8);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@keyframes liquidRotate {
|
|
99
|
+
0% { transform: translateY(-50%) rotate(0deg); }
|
|
100
|
+
100% { transform: translateY(-50%) rotate(360deg); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@keyframes floodWidth {
|
|
104
|
+
0% { width: 0%; }
|
|
105
|
+
100% { width: 100%; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.search-bar-container::after {
|
|
109
|
+
content: '';
|
|
110
|
+
position: absolute;
|
|
111
|
+
inset: -1px;
|
|
112
|
+
border-radius: inherit;
|
|
113
|
+
background: linear-gradient(
|
|
114
|
+
135deg,
|
|
115
|
+
rgba(99, 102, 241, 0.3),
|
|
116
|
+
rgba(168, 85, 247, 0.2),
|
|
117
|
+
rgba(236, 72, 153, 0.15),
|
|
118
|
+
transparent 60%
|
|
119
|
+
);
|
|
120
|
+
opacity: 0;
|
|
121
|
+
transition: opacity 0.4s ease;
|
|
122
|
+
pointer-events: none;
|
|
123
|
+
z-index: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.search-bar-container:hover::after {
|
|
127
|
+
opacity: 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.search-bar-container:focus-within {
|
|
131
|
+
border-color: var(--accent-primary);
|
|
132
|
+
box-shadow: var(--shadow-glow);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.search-icon {
|
|
136
|
+
color: var(--text-secondary);
|
|
137
|
+
flex-shrink: 0;
|
|
138
|
+
z-index: 2;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.search-input {
|
|
142
|
+
flex: 1;
|
|
143
|
+
background: transparent;
|
|
144
|
+
border: none;
|
|
145
|
+
color: var(--text-primary);
|
|
146
|
+
font-size: 1.1rem;
|
|
147
|
+
outline: none;
|
|
148
|
+
z-index: 2;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.search-input::placeholder {
|
|
152
|
+
color: var(--text-muted);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@keyframes spin {
|
|
156
|
+
from { transform: rotate(0deg); }
|
|
157
|
+
to { transform: rotate(360deg); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.spinner-loading {
|
|
161
|
+
animation: spin 1s linear infinite;
|
|
162
|
+
color: var(--accent-primary) !important;
|
|
163
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Search, Loader2 } from 'lucide-react';
|
|
3
|
+
import './SearchBar.css';
|
|
4
|
+
|
|
5
|
+
export default function SearchBar({ onSearch, searchQueryPreset, onClearPreset, isLoading }) {
|
|
6
|
+
const [query, setQuery] = useState('');
|
|
7
|
+
const [isFloating, setIsFloating] = useState(false);
|
|
8
|
+
const [countdownActive, setCountdownActive] = useState(false);
|
|
9
|
+
const [animationTrigger, setAnimationTrigger] = useState(0);
|
|
10
|
+
|
|
11
|
+
const inputRef = useRef(null);
|
|
12
|
+
const containerRef = useRef(null);
|
|
13
|
+
const debounceTimerRef = useRef(null);
|
|
14
|
+
|
|
15
|
+
// Sync state with search query preset (from Search Alternative redirection)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (searchQueryPreset) {
|
|
18
|
+
setQuery(searchQueryPreset);
|
|
19
|
+
if (onClearPreset) {
|
|
20
|
+
onClearPreset();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}, [searchQueryPreset, onClearPreset]);
|
|
24
|
+
|
|
25
|
+
// Debounce search (3 seconds with visual countdown)
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!query.trim()) {
|
|
28
|
+
setCountdownActive(false);
|
|
29
|
+
if (debounceTimerRef.current) {
|
|
30
|
+
clearTimeout(debounceTimerRef.current);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setCountdownActive(true);
|
|
36
|
+
setAnimationTrigger(prev => prev + 1);
|
|
37
|
+
|
|
38
|
+
if (debounceTimerRef.current) {
|
|
39
|
+
clearTimeout(debounceTimerRef.current);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
43
|
+
onSearch(query);
|
|
44
|
+
setCountdownActive(false);
|
|
45
|
+
}, 1000);
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
if (debounceTimerRef.current) {
|
|
49
|
+
clearTimeout(debounceTimerRef.current);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}, [query, onSearch]);
|
|
53
|
+
|
|
54
|
+
// Capture typing anywhere on page and focus search
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const handleGlobalKeyDown = (e) => {
|
|
57
|
+
const activeEl = document.activeElement;
|
|
58
|
+
if (activeEl && (
|
|
59
|
+
activeEl.tagName === 'INPUT' ||
|
|
60
|
+
activeEl.tagName === 'TEXTAREA' ||
|
|
61
|
+
activeEl.isContentEditable
|
|
62
|
+
)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
67
|
+
if (e.key.length !== 1) return; // Only capture single characters
|
|
68
|
+
|
|
69
|
+
if (inputRef.current) {
|
|
70
|
+
inputRef.current.focus({ preventScroll: true });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
window.addEventListener('keydown', handleGlobalKeyDown);
|
|
75
|
+
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
// Monitor visibility of search bar to make it float
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const el = containerRef.current;
|
|
81
|
+
if (!el) return;
|
|
82
|
+
|
|
83
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
84
|
+
setIsFloating(!entry.isIntersecting);
|
|
85
|
+
}, {
|
|
86
|
+
rootMargin: '-70px 0px 0px 0px', // header height is 70px
|
|
87
|
+
threshold: 0
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
observer.observe(el);
|
|
91
|
+
return () => observer.disconnect();
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const handleKeyDown = (e) => {
|
|
95
|
+
if (e.key === 'Enter') {
|
|
96
|
+
if (debounceTimerRef.current) {
|
|
97
|
+
clearTimeout(debounceTimerRef.current);
|
|
98
|
+
}
|
|
99
|
+
onSearch(query);
|
|
100
|
+
setCountdownActive(false);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div ref={containerRef} className="search-bar-wrapper">
|
|
106
|
+
<div className={`search-bar-container liquid-glass ${isFloating ? 'floating' : ''}`}>
|
|
107
|
+
{isLoading ? (
|
|
108
|
+
<Loader2 size={20} className="search-icon spinner-loading" />
|
|
109
|
+
) : (
|
|
110
|
+
<Search size={20} className="search-icon" />
|
|
111
|
+
)}
|
|
112
|
+
<input
|
|
113
|
+
ref={inputRef}
|
|
114
|
+
type="text"
|
|
115
|
+
className="search-input"
|
|
116
|
+
placeholder="Type anywhere to search songs..."
|
|
117
|
+
value={query}
|
|
118
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
119
|
+
onKeyDown={handleKeyDown}
|
|
120
|
+
/>
|
|
121
|
+
{countdownActive && (
|
|
122
|
+
<div key={animationTrigger} className="search-countdown-flood" />
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
.search-results {
|
|
2
|
+
display: grid;
|
|
3
|
+
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
4
|
+
gap: 1rem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.trending-title {
|
|
8
|
+
font-size: 1.2rem;
|
|
9
|
+
font-weight: 600;
|
|
10
|
+
color: var(--text-primary);
|
|
11
|
+
margin-top: 0.5rem;
|
|
12
|
+
margin-bottom: 1.5rem;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: 0.5rem;
|
|
16
|
+
opacity: 0.85;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.trending-icon {
|
|
20
|
+
color: var(--accent-tertiary);
|
|
21
|
+
filter: drop-shadow(0 0 5px rgba(236, 72, 153, 0.4));
|
|
22
|
+
animation: pulse 2s infinite ease-in-out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@media (max-width: 768px) {
|
|
26
|
+
.search-results {
|
|
27
|
+
grid-template-columns: 1fr;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
.error-message {
|
|
34
|
+
background: rgba(239, 68, 68, 0.1);
|
|
35
|
+
color: var(--error);
|
|
36
|
+
padding: 1rem;
|
|
37
|
+
border-radius: var(--radius-sm);
|
|
38
|
+
margin-bottom: 2rem;
|
|
39
|
+
text-align: center;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import SongCard from './SongCard';
|
|
2
|
+
import { Flame, Search } from 'lucide-react';
|
|
3
|
+
import './SearchResults.css';
|
|
4
|
+
|
|
5
|
+
export default function SearchResults({ results, isLoading, onDownload, onFlyAnimation, downloads, onPlay, currentSong, isDefaultTrending }) {
|
|
6
|
+
if (isLoading) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="search-results">
|
|
9
|
+
{[...Array(6)].map((_, i) => (
|
|
10
|
+
<div key={i} className="skeleton-card liquid-glass">
|
|
11
|
+
<div className="skeleton-thumbnail" />
|
|
12
|
+
<div className="skeleton-info">
|
|
13
|
+
<div className="skeleton-line skeleton-title-line" />
|
|
14
|
+
<div className="skeleton-line skeleton-meta-line" />
|
|
15
|
+
</div>
|
|
16
|
+
<div className="skeleton-button" />
|
|
17
|
+
</div>
|
|
18
|
+
))}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!results || results.length === 0) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="empty-state">
|
|
26
|
+
<Search size={48} className="empty-state-icon" />
|
|
27
|
+
<p>No results found</p>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="search-results-container">
|
|
34
|
+
{isDefaultTrending && (
|
|
35
|
+
<h3 className="trending-title">
|
|
36
|
+
<Flame size={18} className="trending-icon" /> World Trending Hits
|
|
37
|
+
</h3>
|
|
38
|
+
)}
|
|
39
|
+
|
|
40
|
+
<div className="search-results">
|
|
41
|
+
{results.map((song, i) => {
|
|
42
|
+
const dlEntry = downloads ? Array.from(downloads.values()).find(d => d.videoId === song.videoId) : null;
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
key={song.videoId || i}
|
|
46
|
+
className="stagger-item"
|
|
47
|
+
style={{ animation: 'fadeSlideUp 0.4s ease-out backwards', animationDelay: `${i * 60}ms` }}
|
|
48
|
+
>
|
|
49
|
+
<SongCard
|
|
50
|
+
song={song}
|
|
51
|
+
onDownload={onDownload}
|
|
52
|
+
onFlyAnimation={onFlyAnimation}
|
|
53
|
+
downloadStatus={dlEntry?.status || null}
|
|
54
|
+
downloadPercent={dlEntry?.percent || '0%'}
|
|
55
|
+
onPlay={(song, rect) => onPlay(song, rect, results)}
|
|
56
|
+
isCurrentlyPlaying={currentSong?.videoId === song.videoId}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|