@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.
Files changed (60) hide show
  1. package/bin/cli.js +44 -0
  2. package/client/index.html +13 -0
  3. package/client/package-lock.json +1003 -0
  4. package/client/package.json +22 -0
  5. package/client/public/favicon.svg +1 -0
  6. package/client/public/icons.svg +24 -0
  7. package/client/src/App.jsx +210 -0
  8. package/client/src/assets/hero.png +0 -0
  9. package/client/src/assets/typescript.svg +1 -0
  10. package/client/src/assets/vite.svg +1 -0
  11. package/client/src/components/Download/DownloadItem.css +246 -0
  12. package/client/src/components/Download/DownloadItem.jsx +108 -0
  13. package/client/src/components/Download/FlyingThumbnail.css +33 -0
  14. package/client/src/components/Download/FlyingThumbnail.jsx +48 -0
  15. package/client/src/components/Layout/Header.css +91 -0
  16. package/client/src/components/Layout/Header.jsx +49 -0
  17. package/client/src/components/Layout/SystemCheckScreen.css +250 -0
  18. package/client/src/components/Layout/SystemCheckScreen.jsx +110 -0
  19. package/client/src/components/Player/AudioPlayer.css +280 -0
  20. package/client/src/components/Player/AudioPlayer.jsx +154 -0
  21. package/client/src/components/Playlist/PlaylistInput.css +71 -0
  22. package/client/src/components/Playlist/PlaylistInput.jsx +44 -0
  23. package/client/src/components/Playlist/PlaylistView.css +150 -0
  24. package/client/src/components/Playlist/PlaylistView.jsx +104 -0
  25. package/client/src/components/Search/SearchBar.css +163 -0
  26. package/client/src/components/Search/SearchBar.jsx +127 -0
  27. package/client/src/components/Search/SearchResults.css +42 -0
  28. package/client/src/components/Search/SearchResults.jsx +64 -0
  29. package/client/src/components/Search/SongCard.css +296 -0
  30. package/client/src/components/Search/SongCard.jsx +204 -0
  31. package/client/src/components/Settings/SettingsPanel.css +345 -0
  32. package/client/src/components/Settings/SettingsPanel.jsx +174 -0
  33. package/client/src/hooks/useDownload.js +107 -0
  34. package/client/src/hooks/usePlayer.js +188 -0
  35. package/client/src/hooks/usePlaylist.js +52 -0
  36. package/client/src/hooks/useSearch.js +34 -0
  37. package/client/src/index.css +245 -0
  38. package/client/src/main.jsx +10 -0
  39. package/client/src/pages/DownloadsPage.css +37 -0
  40. package/client/src/pages/DownloadsPage.jsx +80 -0
  41. package/client/src/pages/PlaylistPage.jsx +46 -0
  42. package/client/src/pages/SearchPage.jsx +63 -0
  43. package/client/tsconfig.json +24 -0
  44. package/client/vite.config.js +12 -0
  45. package/main.js +79 -0
  46. package/package.json +48 -0
  47. package/server/index.js +54 -0
  48. package/server/package-lock.json +1428 -0
  49. package/server/package.json +14 -0
  50. package/server/routes/browse.js +23 -0
  51. package/server/routes/download.js +339 -0
  52. package/server/routes/playLocal.js +50 -0
  53. package/server/routes/playlist.js +28 -0
  54. package/server/routes/search.js +21 -0
  55. package/server/routes/stream.js +53 -0
  56. package/server/services/downloadService.js +143 -0
  57. package/server/services/ytmusicService.js +62 -0
  58. package/server/utils/dependencyChecker.js +174 -0
  59. package/server/utils/paths.js +11 -0
  60. package/server/utils/sanitize.js +9 -0
@@ -0,0 +1,280 @@
1
+ .audio-player {
2
+ position: fixed !important;
3
+ bottom: 0;
4
+ left: 0;
5
+ right: 0;
6
+ height: 90px;
7
+ z-index: 200;
8
+ border-top: 1px solid var(--glass-border);
9
+ box-shadow: 0 -4px 30px rgba(0, 0, 0, 0.5);
10
+ display: flex;
11
+ align-items: center;
12
+ background-color: var(--bg-primary);
13
+ backdrop-filter: none;
14
+ -webkit-backdrop-filter: none;
15
+ }
16
+
17
+ .player-enter {
18
+ animation: slideUp 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
19
+ }
20
+
21
+ .player-exit {
22
+ animation: slideDown 0.3s ease-in forwards;
23
+ }
24
+
25
+ @keyframes slideUp {
26
+ from { transform: translateY(100%); }
27
+ to { transform: translateY(0); }
28
+ }
29
+
30
+ @keyframes slideDown {
31
+ from { transform: translateY(0); }
32
+ to { transform: translateY(100%); }
33
+ }
34
+
35
+ .player-content {
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ width: 100%;
40
+ max-width: 1400px;
41
+ margin: 0 auto;
42
+ padding: 0 1.5rem;
43
+ height: 100%;
44
+ }
45
+
46
+ /* LEFT: Song Info */
47
+ .player-songinfo {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 1rem;
51
+ width: 30%;
52
+ min-width: 200px;
53
+ }
54
+
55
+ .player-thumb {
56
+ width: 56px;
57
+ height: 56px;
58
+ border-radius: var(--radius-sm);
59
+ object-fit: cover;
60
+ box-shadow: var(--shadow-md);
61
+ animation: scalePop 0.5s ease-out;
62
+ }
63
+
64
+ .player-thumb-placeholder {
65
+ width: 56px;
66
+ height: 56px;
67
+ border-radius: var(--radius-sm);
68
+ background: var(--bg-tertiary);
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ font-size: 1.5rem;
73
+ }
74
+
75
+ .player-meta {
76
+ display: flex;
77
+ flex-direction: column;
78
+ overflow: hidden;
79
+ }
80
+
81
+ .player-title {
82
+ font-size: 15px;
83
+ color: var(--text-primary);
84
+ white-space: nowrap;
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
87
+ margin: 0;
88
+ }
89
+
90
+ .player-artist {
91
+ font-size: 13px;
92
+ color: var(--text-secondary);
93
+ white-space: nowrap;
94
+ overflow: hidden;
95
+ text-overflow: ellipsis;
96
+ margin: 0;
97
+ }
98
+
99
+ /* CENTER: Controls & Seekbar */
100
+ .player-center {
101
+ display: flex;
102
+ flex-direction: column;
103
+ align-items: center;
104
+ justify-content: center;
105
+ width: 40%;
106
+ max-width: 600px;
107
+ }
108
+
109
+ .player-controls {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 1.5rem;
113
+ margin-bottom: 0.5rem;
114
+ }
115
+
116
+ .player-btn {
117
+ color: var(--text-secondary);
118
+ transition: all var(--transition-fast);
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ }
123
+
124
+ .player-btn:hover {
125
+ color: var(--text-primary);
126
+ transform: scale(1.1);
127
+ }
128
+
129
+ .player-btn:active {
130
+ transform: scale(0.95);
131
+ }
132
+
133
+ .player-btn:disabled {
134
+ opacity: 0.3;
135
+ cursor: not-allowed;
136
+ transform: none !important;
137
+ color: var(--text-muted);
138
+ }
139
+
140
+ .player-btn.play-pause {
141
+ width: 40px;
142
+ height: 40px;
143
+ border-radius: 50%;
144
+ background: var(--text-primary);
145
+ color: var(--bg-primary);
146
+ }
147
+
148
+ .player-btn.play-pause:hover {
149
+ transform: scale(1.05);
150
+ box-shadow: 0 0 15px rgba(255, 255, 255, 0.3);
151
+ }
152
+
153
+ .player-seekbar-container {
154
+ display: flex;
155
+ align-items: center;
156
+ width: 100%;
157
+ gap: 0.75rem;
158
+ }
159
+
160
+ .time-display {
161
+ font-size: 12px;
162
+ color: var(--text-secondary);
163
+ font-variant-numeric: tabular-nums;
164
+ min-width: 40px;
165
+ text-align: center;
166
+ }
167
+
168
+ /* Custom Range Input (Seekbar & Volume) */
169
+ input[type=range] {
170
+ -webkit-appearance: none;
171
+ width: 100%;
172
+ background: transparent;
173
+ cursor: pointer;
174
+ height: 16px; /* easier to grab */
175
+ }
176
+
177
+ input[type=range]:focus {
178
+ outline: none;
179
+ }
180
+
181
+ input[type=range]::-webkit-slider-runnable-track {
182
+ width: 100%;
183
+ height: 4px;
184
+ border-radius: 2px;
185
+ background: var(--bg-tertiary);
186
+ transition: all var(--transition-fast);
187
+ }
188
+
189
+ .player-seekbar::-webkit-slider-runnable-track {
190
+ background: linear-gradient(to right, var(--accent-primary) var(--progress), var(--bg-tertiary) var(--progress));
191
+ }
192
+
193
+ .player-seekbar::-moz-range-track {
194
+ background: linear-gradient(to right, var(--accent-primary) var(--progress), var(--bg-tertiary) var(--progress));
195
+ }
196
+
197
+ .player-volume::-webkit-slider-runnable-track {
198
+ background: linear-gradient(to right, var(--text-primary) var(--volume-progress), var(--bg-tertiary) var(--volume-progress));
199
+ }
200
+
201
+ .player-volume::-moz-range-track {
202
+ background: linear-gradient(to right, var(--text-primary) var(--volume-progress), var(--bg-tertiary) var(--volume-progress));
203
+ }
204
+
205
+ input[type=range]::-webkit-slider-thumb {
206
+ -webkit-appearance: none;
207
+ height: 12px;
208
+ width: 12px;
209
+ border-radius: 50%;
210
+ background: #fff;
211
+ margin-top: -4px; /* (track height / 2) - (thumb height / 2) */
212
+ box-shadow: 0 0 10px rgba(0,0,0,0.5);
213
+ opacity: 1;
214
+ transition: transform 0.2s, background-color 0.2s;
215
+ }
216
+
217
+ input[type=range]:hover::-webkit-slider-thumb {
218
+ transform: scale(1.25);
219
+ background-color: var(--accent-primary);
220
+ }
221
+
222
+ input[type=range]:active::-webkit-slider-thumb {
223
+ transform: scale(1.4);
224
+ }
225
+
226
+ input[type=range]:disabled {
227
+ cursor: not-allowed;
228
+ opacity: 0.6;
229
+ }
230
+
231
+ /* RIGHT: Volume & Close */
232
+ .player-right {
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: flex-end;
236
+ gap: 2rem;
237
+ width: 30%;
238
+ min-width: 200px;
239
+ }
240
+
241
+ .player-volume-container {
242
+ display: flex;
243
+ align-items: center;
244
+ gap: 0.75rem;
245
+ width: 120px;
246
+ color: var(--text-secondary);
247
+ }
248
+
249
+ .player-close {
250
+ color: var(--text-secondary);
251
+ font-size: 1.25rem;
252
+ transition: color var(--transition-fast);
253
+ padding: 0.5rem;
254
+ }
255
+
256
+ .player-close:hover {
257
+ color: #ef4444;
258
+ }
259
+
260
+ @media (max-width: 768px) {
261
+ .player-right, .time-display {
262
+ display: none;
263
+ }
264
+ .player-songinfo {
265
+ width: 50%;
266
+ }
267
+ .player-center {
268
+ width: 50%;
269
+ align-items: flex-end;
270
+ }
271
+ .player-controls {
272
+ margin-right: 1rem;
273
+ }
274
+ .player-seekbar-container {
275
+ position: absolute;
276
+ top: -8px;
277
+ left: 0;
278
+ width: 100%;
279
+ }
280
+ }
@@ -0,0 +1,154 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Music } from 'lucide-react';
3
+ import './AudioPlayer.css';
4
+
5
+ function formatTime(seconds) {
6
+ if (!seconds || isNaN(seconds) || !isFinite(seconds)) return '0:00';
7
+ const m = Math.floor(seconds / 60);
8
+ const s = Math.floor(seconds % 60);
9
+ return `${m}:${s.toString().padStart(2, '0')}`;
10
+ }
11
+
12
+ export default function AudioPlayer({ player }) {
13
+ const {
14
+ audioRef,
15
+ currentSong,
16
+ source,
17
+ audioUrl,
18
+ isPlaying,
19
+ currentTime,
20
+ duration: audioDuration,
21
+ volume,
22
+ isPlayerVisible,
23
+ pause,
24
+ resume,
25
+ seek,
26
+ setVolume,
27
+ stop,
28
+ queue,
29
+ playNext,
30
+ playPrevious
31
+ } = player;
32
+
33
+ const [isDragging, setIsDragging] = useState(false);
34
+ const [dragTime, setDragTime] = useState(0);
35
+
36
+ // Use song duration if audio duration is not available (common for streams)
37
+ const displayDuration = (audioDuration && isFinite(audioDuration))
38
+ ? audioDuration
39
+ : ((currentSong && currentSong.duration) || 0);
40
+
41
+ const displayTime = isDragging ? dragTime : currentTime;
42
+ const progressPercent = displayDuration > 0 ? (displayTime / displayDuration) * 100 : 0;
43
+
44
+ const handleSeekChange = (e) => {
45
+ const newTime = parseFloat(e.target.value);
46
+ setDragTime(newTime);
47
+ };
48
+
49
+ const handleSeekEnd = (e) => {
50
+ setIsDragging(false);
51
+ seek(parseFloat(e.target.value));
52
+ };
53
+
54
+ const handleSeekStart = () => {
55
+ setIsDragging(true);
56
+ };
57
+
58
+ return (
59
+ <div className={`audio-player liquid-glass ${isPlayerVisible && currentSong ? 'player-enter' : 'player-exit'}`}>
60
+ <audio ref={audioRef} src={audioUrl || undefined} style={{ display: 'none' }} />
61
+
62
+ {currentSong && (
63
+ <div className="player-content">
64
+ <div className="player-songinfo">
65
+ {currentSong.thumbnail ? (
66
+ <img src={currentSong.thumbnail} alt="Album Art" className="player-thumb" />
67
+ ) : (
68
+ <div className="player-thumb-placeholder">
69
+ <Music size={24} className="placeholder-icon" />
70
+ </div>
71
+ )}
72
+ <div className="player-meta">
73
+ <h4 className="player-title">{currentSong.title}</h4>
74
+ <p className="player-artist">{currentSong.artist}</p>
75
+ </div>
76
+ </div>
77
+
78
+ <div className="player-center">
79
+ <div className="player-controls">
80
+ <button
81
+ className="player-btn"
82
+ onClick={playPrevious}
83
+ disabled={queue.length <= 1}
84
+ title={queue.length <= 1 ? "Previous (No queue)" : "Previous track"}
85
+ >
86
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
87
+ </button>
88
+
89
+ <button
90
+ className="player-btn play-pause"
91
+ onClick={isPlaying ? pause : resume}
92
+ >
93
+ {isPlaying ? (
94
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
95
+ ) : (
96
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
97
+ )}
98
+ </button>
99
+
100
+ <button
101
+ className="player-btn"
102
+ onClick={playNext}
103
+ disabled={queue.length <= 1}
104
+ title={queue.length <= 1 ? "Next (No queue)" : "Next track"}
105
+ >
106
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
107
+ </button>
108
+ </div>
109
+
110
+ <div className="player-seekbar-container">
111
+ <span className="time-display">{formatTime(displayTime)}</span>
112
+ <input
113
+ type="range"
114
+ className="player-seekbar"
115
+ min="0"
116
+ max={displayDuration || 100}
117
+ value={displayTime}
118
+ onMouseDown={handleSeekStart}
119
+ onMouseUp={handleSeekEnd}
120
+ onTouchStart={handleSeekStart}
121
+ onTouchEnd={handleSeekEnd}
122
+ onChange={handleSeekChange}
123
+ style={{ '--progress': `${progressPercent}%` }}
124
+ disabled={source === 'stream'} // Streams are usually not forward-seekable reliably via this method
125
+ title={source === 'stream' ? 'Seeking is disabled for live streams' : ''}
126
+ />
127
+ <span className="time-display">{formatTime(displayDuration)}</span>
128
+ </div>
129
+ </div>
130
+
131
+ <div className="player-right">
132
+ <div className="player-volume-container">
133
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path></svg>
134
+ <input
135
+ type="range"
136
+ className="player-volume"
137
+ min="0"
138
+ max="1"
139
+ step="0.01"
140
+ value={volume}
141
+ onChange={(e) => setVolume(parseFloat(e.target.value))}
142
+ style={{ '--volume-progress': `${volume * 100}%` }}
143
+ />
144
+ </div>
145
+
146
+ <button className="player-close" onClick={stop} title="Close Player">
147
+
148
+ </button>
149
+ </div>
150
+ </div>
151
+ )}
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,71 @@
1
+ .playlist-input-container {
2
+ max-width: 600px;
3
+ margin: 0 auto 2rem;
4
+ display: flex;
5
+ flex-direction: column;
6
+ align-items: center;
7
+ }
8
+
9
+ .playlist-form {
10
+ display: flex;
11
+ align-items: center;
12
+ gap: 1rem;
13
+ padding: 0.5rem 0.5rem 0.5rem 1.5rem; /* left padding for icon */
14
+ border-radius: 50px;
15
+ width: 100%;
16
+ transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
17
+ animation: scalePop 0.4s ease-out;
18
+ }
19
+
20
+ .playlist-form:focus-within {
21
+ border-color: var(--accent-primary);
22
+ box-shadow: var(--shadow-glow);
23
+ }
24
+
25
+ .playlist-icon {
26
+ color: var(--text-secondary);
27
+ flex-shrink: 0;
28
+ z-index: 2;
29
+ }
30
+
31
+ .playlist-input {
32
+ flex: 1;
33
+ background: transparent;
34
+ border: none;
35
+ color: var(--text-primary);
36
+ font-size: 1.05rem;
37
+ outline: none;
38
+ z-index: 2;
39
+ }
40
+
41
+ .playlist-input::placeholder {
42
+ color: var(--text-muted);
43
+ }
44
+
45
+ .playlist-fetch-btn {
46
+ background: var(--accent-gradient);
47
+ color: white;
48
+ padding: 0.75rem 1.5rem;
49
+ border-radius: var(--radius-full);
50
+ font-weight: 600;
51
+ transition: all var(--transition-fast);
52
+ flex-shrink: 0;
53
+ z-index: 2;
54
+ }
55
+
56
+ .playlist-fetch-btn:hover {
57
+ transform: scale(1.05);
58
+ box-shadow: 0 0 15px rgba(139, 92, 246, 0.4);
59
+ }
60
+
61
+ .playlist-fetch-btn:active {
62
+ transform: scale(0.95);
63
+ }
64
+
65
+ .validation-error {
66
+ color: var(--error);
67
+ font-size: 0.85rem;
68
+ margin-top: 0.5rem;
69
+ align-self: flex-start;
70
+ padding-left: 1.5rem;
71
+ }
@@ -0,0 +1,44 @@
1
+ import { useState } from 'react';
2
+ import { Link, Loader2 } from 'lucide-react';
3
+ import './PlaylistInput.css';
4
+
5
+ export default function PlaylistInput({ onFetch, isLoading }) {
6
+ const [url, setUrl] = useState('');
7
+ const [validationError, setValidationError] = useState('');
8
+
9
+ const handleSubmit = (e) => {
10
+ e.preventDefault();
11
+ if (!url.includes('list=')) {
12
+ setValidationError('Invalid URL. It must contain a "list=" parameter.');
13
+ return;
14
+ }
15
+ setValidationError('');
16
+ onFetch(url);
17
+ };
18
+
19
+ return (
20
+ <div className="playlist-input-container">
21
+ <form className="playlist-form liquid-glass" onSubmit={handleSubmit}>
22
+ {isLoading ? (
23
+ <Loader2 size={20} className="playlist-icon spinner" style={{ animation: 'spin 1s linear infinite', color: 'var(--accent-primary)' }} />
24
+ ) : (
25
+ <Link size={20} className="playlist-icon" />
26
+ )}
27
+ <input
28
+ type="text"
29
+ className="playlist-input"
30
+ placeholder="Paste a YouTube Music playlist URL..."
31
+ value={url}
32
+ onChange={(e) => setUrl(e.target.value)}
33
+ disabled={isLoading}
34
+ />
35
+ <button type="submit" className="playlist-fetch-btn" disabled={isLoading}>
36
+ {isLoading ? 'Fetching...' : 'Fetch Playlist'}
37
+ </button>
38
+ </form>
39
+ {validationError && (
40
+ <p className="validation-error">{validationError}</p>
41
+ )}
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,150 @@
1
+ .playlist-view {
2
+ background: transparent;
3
+ border-radius: var(--radius-lg);
4
+ overflow: hidden;
5
+ }
6
+
7
+ .playlist-header {
8
+ padding: 1.5rem 2rem;
9
+ background: rgba(255, 255, 255, 0.02);
10
+ border-bottom: 1px solid var(--glass-border);
11
+ display: flex;
12
+ justify-content: space-between;
13
+ align-items: center;
14
+ }
15
+
16
+ .playlist-info h2 {
17
+ font-size: 1.5rem;
18
+ font-weight: 700;
19
+ color: var(--text-primary);
20
+ margin: 0;
21
+ }
22
+
23
+ .playlist-info p {
24
+ font-size: 13px;
25
+ color: var(--text-secondary);
26
+ margin: 0.25rem 0 0 0;
27
+ }
28
+
29
+ .download-all-btn {
30
+ background: var(--accent-gradient);
31
+ color: white;
32
+ padding: 0.6rem 1.25rem;
33
+ border-radius: var(--radius-full);
34
+ font-weight: 600;
35
+ font-size: 0.9rem;
36
+ transition: all var(--transition-fast);
37
+ border: 1px solid rgba(255, 255, 255, 0.1);
38
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.25);
39
+ }
40
+
41
+ .download-all-btn:hover {
42
+ transform: scale(1.03);
43
+ box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
44
+ }
45
+
46
+ .download-all-btn:active {
47
+ transform: scale(0.97);
48
+ }
49
+
50
+ .playlist-tracks {
51
+ padding: 1.5rem;
52
+ display: grid;
53
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
54
+ gap: 1.5rem;
55
+ }
56
+
57
+ .track-card-wrapper {
58
+ position: relative;
59
+ min-width: 0;
60
+ }
61
+
62
+ .track-number-badge {
63
+ position: absolute;
64
+ top: -8px;
65
+ left: -8px;
66
+ width: 24px;
67
+ height: 24px;
68
+ border-radius: 50%;
69
+ background: var(--bg-secondary);
70
+ border: 1px solid var(--glass-border);
71
+ color: var(--text-secondary);
72
+ font-size: 11px;
73
+ font-weight: 700;
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ z-index: 10;
78
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
79
+ transition: all var(--transition-fast);
80
+ }
81
+
82
+ .track-card-wrapper.playing .track-number-badge {
83
+ border-color: var(--accent-primary);
84
+ color: var(--accent-primary);
85
+ box-shadow: 0 0 10px rgba(99, 102, 241, 0.4);
86
+ background: var(--bg-tertiary);
87
+ }
88
+
89
+ .track-remove-btn {
90
+ position: absolute;
91
+ top: -8px;
92
+ right: -8px;
93
+ width: 24px;
94
+ height: 24px;
95
+ border-radius: 50%;
96
+ background: var(--bg-secondary);
97
+ border: 1px solid var(--glass-border);
98
+ color: var(--text-muted);
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: center;
102
+ z-index: 10;
103
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
104
+ transition: all var(--transition-fast);
105
+ cursor: pointer;
106
+ opacity: 0.7;
107
+ }
108
+
109
+ .track-card-wrapper:hover .track-remove-btn {
110
+ opacity: 1;
111
+ }
112
+
113
+ .track-remove-btn:hover {
114
+ background: rgba(239, 68, 68, 0.15);
115
+ border-color: #ef4444;
116
+ color: #ef4444;
117
+ transform: scale(1.1);
118
+ box-shadow: 0 0 10px rgba(239, 68, 68, 0.35);
119
+ }
120
+
121
+ .track-remove-btn:active {
122
+ transform: scale(0.9);
123
+ }
124
+
125
+ @keyframes cardExit {
126
+ 0% {
127
+ opacity: 1;
128
+ transform: scale(1);
129
+ filter: blur(0);
130
+ }
131
+ 100% {
132
+ opacity: 0;
133
+ transform: scale(0.8) translateY(10px);
134
+ filter: blur(4px);
135
+ }
136
+ }
137
+
138
+ .track-card-wrapper.removing {
139
+ pointer-events: none;
140
+ }
141
+
142
+ /* Skeleton tweaks */
143
+ .skeleton-header {
144
+ height: 90px;
145
+ background: var(--bg-secondary);
146
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
147
+ border-bottom: 1px solid var(--glass-border);
148
+ position: relative;
149
+ overflow: hidden;
150
+ }