@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,188 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react';
2
+
3
+ export function usePlayer() {
4
+ const [currentSong, setCurrentSong] = useState(null);
5
+ const [source, setSource] = useState(null); // 'stream' | 'local'
6
+ const [audioUrl, setAudioUrl] = useState(null);
7
+ const [isPlaying, setIsPlaying] = useState(false);
8
+ const [currentTime, setCurrentTime] = useState(0);
9
+ const [duration, setDuration] = useState(0);
10
+ const [volume, setVolumeState] = useState(0.8);
11
+ const [isPlayerVisible, setIsPlayerVisible] = useState(false);
12
+ const [queue, setQueue] = useState([]);
13
+
14
+ const audioRef = useRef(null);
15
+ const onTrackEndedRef = useRef(null);
16
+
17
+ // playNext definition
18
+ const playNext = useCallback(() => {
19
+ if (queue.length === 0) return;
20
+ const currentIndex = queue.findIndex(item => item.videoId === currentSong?.videoId);
21
+ if (currentIndex === -1) return;
22
+
23
+ const nextIndex = (currentIndex + 1) % queue.length;
24
+ const nextSong = queue[nextIndex];
25
+
26
+ const isLocal = !!(nextSong.filePath || nextSong.status === 'completed');
27
+ const filePath = nextSong.filePath;
28
+
29
+ play(nextSong, isLocal ? 'local' : 'stream', queue, filePath);
30
+ }, [queue, currentSong]);
31
+
32
+ const playPrevious = useCallback(() => {
33
+ if (queue.length === 0) return;
34
+ const currentIndex = queue.findIndex(item => item.videoId === currentSong?.videoId);
35
+ if (currentIndex === -1) return;
36
+
37
+ const prevIndex = (currentIndex - 1 + queue.length) % queue.length;
38
+ const prevSong = queue[prevIndex];
39
+
40
+ const isLocal = !!(prevSong.filePath || prevSong.status === 'completed');
41
+ const filePath = prevSong.filePath;
42
+
43
+ play(prevSong, isLocal ? 'local' : 'stream', queue, filePath);
44
+ }, [queue, currentSong]);
45
+
46
+ // Keep track-ended reference up-to-date to avoid effect re-binding cycles
47
+ useEffect(() => {
48
+ onTrackEndedRef.current = playNext;
49
+ }, [playNext]);
50
+
51
+ // Sync state with audio element events
52
+ useEffect(() => {
53
+ const audio = audioRef.current;
54
+ if (!audio) return;
55
+
56
+ const handleTimeUpdate = () => setCurrentTime(audio.currentTime);
57
+
58
+ const updateDuration = () => {
59
+ if (audio.duration && isFinite(audio.duration)) {
60
+ setDuration(audio.duration);
61
+ }
62
+ };
63
+
64
+ const handleEnded = () => {
65
+ setIsPlaying(false);
66
+ if (onTrackEndedRef.current) {
67
+ onTrackEndedRef.current();
68
+ }
69
+ };
70
+ const handlePause = () => setIsPlaying(false);
71
+ const handlePlay = () => setIsPlaying(true);
72
+
73
+ audio.addEventListener('timeupdate', handleTimeUpdate);
74
+ audio.addEventListener('loadedmetadata', updateDuration);
75
+ audio.addEventListener('durationchange', updateDuration);
76
+ audio.addEventListener('ended', handleEnded);
77
+ audio.addEventListener('pause', handlePause);
78
+ audio.addEventListener('play', handlePlay);
79
+
80
+ // Initial check in case metadata loaded before effect binded
81
+ updateDuration();
82
+
83
+ // Initial volume
84
+ audio.volume = volume;
85
+
86
+ return () => {
87
+ audio.removeEventListener('timeupdate', handleTimeUpdate);
88
+ audio.removeEventListener('loadedmetadata', updateDuration);
89
+ audio.removeEventListener('durationchange', updateDuration);
90
+ audio.removeEventListener('ended', handleEnded);
91
+ audio.removeEventListener('pause', handlePause);
92
+ audio.removeEventListener('play', handlePlay);
93
+ };
94
+ }, [audioRef, volume, audioUrl]);
95
+
96
+ const play = useCallback((song, srcType, queueList = [], localFilePath = null) => {
97
+ if (audioRef.current) {
98
+ audioRef.current.pause();
99
+ audioRef.current.removeAttribute('src');
100
+ audioRef.current.load();
101
+ }
102
+
103
+ setCurrentSong(song);
104
+ setSource(srcType);
105
+ setQueue(queueList);
106
+ setIsPlayerVisible(true);
107
+ setCurrentTime(0);
108
+ setDuration(0);
109
+
110
+ let newUrl = '';
111
+ if (srcType === 'stream') {
112
+ newUrl = `/api/stream/${song.videoId}`;
113
+ } else if (srcType === 'local' && localFilePath) {
114
+ newUrl = `/api/play-local?path=${encodeURIComponent(localFilePath)}`;
115
+ }
116
+
117
+ setAudioUrl(newUrl);
118
+
119
+ // When URL changes, React will re-render the <audio src={newUrl}>
120
+ // We need to wait for the DOM to update before calling play()
121
+ setTimeout(() => {
122
+ if (audioRef.current) {
123
+ audioRef.current.play().catch(err => console.error("Auto-play prevented", err));
124
+ }
125
+ }, 50);
126
+ }, []);
127
+
128
+ const pause = useCallback(() => {
129
+ if (audioRef.current) {
130
+ audioRef.current.pause();
131
+ }
132
+ }, []);
133
+
134
+ const resume = useCallback(() => {
135
+ if (audioRef.current) {
136
+ audioRef.current.play();
137
+ }
138
+ }, []);
139
+
140
+ const seek = useCallback((time) => {
141
+ if (audioRef.current) {
142
+ audioRef.current.currentTime = time;
143
+ setCurrentTime(time);
144
+ }
145
+ }, []);
146
+
147
+ const setVolume = useCallback((v) => {
148
+ if (audioRef.current) {
149
+ audioRef.current.volume = v;
150
+ }
151
+ setVolumeState(v);
152
+ }, []);
153
+
154
+ const stop = useCallback(() => {
155
+ if (audioRef.current) {
156
+ audioRef.current.pause();
157
+ // Remove src to abort stream
158
+ audioRef.current.removeAttribute('src');
159
+ audioRef.current.load();
160
+ }
161
+ setCurrentSong(null);
162
+ setAudioUrl(null);
163
+ setQueue([]);
164
+ setIsPlayerVisible(false);
165
+ setIsPlaying(false);
166
+ }, []);
167
+
168
+ return {
169
+ audioRef,
170
+ currentSong,
171
+ source,
172
+ audioUrl,
173
+ isPlaying,
174
+ currentTime,
175
+ duration,
176
+ volume,
177
+ isPlayerVisible,
178
+ queue,
179
+ playNext,
180
+ playPrevious,
181
+ play,
182
+ pause,
183
+ resume,
184
+ seek,
185
+ setVolume,
186
+ stop
187
+ };
188
+ }
@@ -0,0 +1,52 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ export function usePlaylist() {
4
+ const [playlist, setPlaylist] = useState(null);
5
+ const [isLoading, setIsLoading] = useState(false);
6
+ const [error, setError] = useState(null);
7
+
8
+ const fetchPlaylist = useCallback(async (url) => {
9
+ if (!url) {
10
+ setPlaylist(null);
11
+ return;
12
+ }
13
+
14
+ setIsLoading(true);
15
+ setError(null);
16
+
17
+ try {
18
+ const res = await fetch('/api/playlist', {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ url })
22
+ });
23
+
24
+ if (!res.ok) {
25
+ const errorData = await res.json();
26
+ throw new Error(errorData.error || 'Failed to fetch playlist');
27
+ }
28
+
29
+ const data = await res.json();
30
+ setPlaylist(data);
31
+ } catch (err) {
32
+ setError(err.message);
33
+ setPlaylist(null);
34
+ } finally {
35
+ setIsLoading(false);
36
+ }
37
+ }, []);
38
+
39
+ const removeTrack = useCallback((videoId) => {
40
+ setPlaylist(prev => {
41
+ if (!prev) return null;
42
+ const updatedTracks = prev.tracks.filter(t => t.videoId !== videoId);
43
+ return {
44
+ ...prev,
45
+ trackCount: updatedTracks.length,
46
+ tracks: updatedTracks
47
+ };
48
+ });
49
+ }, []);
50
+
51
+ return { playlist, isLoading, error, fetchPlaylist, removeTrack };
52
+ }
@@ -0,0 +1,34 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ export function useSearch() {
4
+ const [results, setResults] = useState([]);
5
+ const [isLoading, setIsLoading] = useState(false);
6
+ const [error, setError] = useState(null);
7
+
8
+ const search = useCallback(async (query) => {
9
+ if (!query) {
10
+ setResults([]);
11
+ return;
12
+ }
13
+
14
+ setIsLoading(true);
15
+ setError(null);
16
+
17
+ try {
18
+ const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
19
+ if (!res.ok) {
20
+ const errorData = await res.json();
21
+ throw new Error(errorData.error || 'Failed to search');
22
+ }
23
+ const data = await res.json();
24
+ setResults(data.results || []);
25
+ } catch (err) {
26
+ setError(err.message);
27
+ setResults([]);
28
+ } finally {
29
+ setIsLoading(false);
30
+ }
31
+ }, []);
32
+
33
+ return { results, isLoading, error, search };
34
+ }
@@ -0,0 +1,245 @@
1
+ :root {
2
+ /* Background /* Colors */
3
+ --bg-primary: #0f172a;
4
+ --bg-secondary: #1e293b;
5
+ --bg-tertiary: #334155;
6
+ --bg-hover: #475569;
7
+
8
+ --accent-primary: #6366f1;
9
+ --accent-secondary: #8b5cf6;
10
+ --accent-tertiary: #ec4899;
11
+ --accent-hover: #4f46e5;
12
+ --accent-gradient: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 50%, var(--accent-tertiary) 100%);
13
+
14
+ --text-primary: #f8fafc;
15
+ --text-secondary: #94a3b8;
16
+ --text-muted: #64748b;
17
+
18
+ --border: #334155;
19
+ --border-hover: #475569;
20
+
21
+ /* Layout */
22
+ --radius-sm: 8px;
23
+ --radius-md: 12px;
24
+ --radius-lg: 16px;
25
+ --radius-full: 9999px;
26
+
27
+ /* Shadows */
28
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
29
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
30
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
31
+ --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.15);
32
+
33
+ /* Liquid Glass */
34
+ --glass-bg: rgba(255, 255, 255, 0.03);
35
+ --glass-border: rgba(255, 255, 255, 0.12);
36
+ --glass-highlight: linear-gradient(
37
+ 135deg,
38
+ rgba(255, 255, 255, 0.12) 0%,
39
+ rgba(255, 255, 255, 0.04) 40%,
40
+ rgba(255, 255, 255, 0) 60%,
41
+ rgba(255, 255, 255, 0.06) 100%
42
+ );
43
+ --glass-blur: 20px;
44
+ --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.08);
45
+
46
+ /* Transitions */
47
+ --transition-fast: 0.15s ease;
48
+ --transition-normal: 0.25s ease;
49
+ --transition-slow: 0.4s ease;
50
+ }
51
+
52
+ @keyframes pulse {
53
+ 0%, 100% { transform: scale(1); }
54
+ 50% { transform: scale(1.15); }
55
+ }
56
+
57
+ @keyframes equalizerBounce {
58
+ 0%, 100% { height: 4px; }
59
+ 50% { height: 16px; }
60
+ }
61
+
62
+ * {
63
+ box-sizing: border-box;
64
+ margin: 0;
65
+ padding: 0;
66
+ }
67
+
68
+ body {
69
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
70
+ background-color: var(--bg-primary);
71
+ color: var(--text-primary);
72
+ line-height: 1.5;
73
+ }
74
+
75
+ /* Base styles */
76
+ h1, h2, h3, h4, h5, h6 {
77
+ margin: 0;
78
+ font-weight: 600;
79
+ }
80
+
81
+ .app {
82
+ min-height: 100vh;
83
+ display: flex;
84
+ flex-direction: column;
85
+ }
86
+
87
+ a {
88
+ color: var(--accent-primary);
89
+ text-decoration: none;
90
+ }
91
+
92
+ a:hover {
93
+ color: var(--accent-hover);
94
+ }
95
+
96
+ button {
97
+ cursor: pointer;
98
+ font-family: inherit;
99
+ border: none;
100
+ background: none;
101
+ }
102
+
103
+ .liquid-glass {
104
+ background: var(--glass-bg);
105
+ backdrop-filter: blur(var(--glass-blur));
106
+ -webkit-backdrop-filter: blur(var(--glass-blur));
107
+ border: 1px solid var(--glass-border);
108
+ box-shadow: var(--glass-shadow);
109
+ border-radius: var(--radius-md);
110
+ position: relative;
111
+ overflow: hidden;
112
+ }
113
+
114
+ .liquid-glass::before {
115
+ content: '';
116
+ position: absolute;
117
+ top: 0;
118
+ left: 0;
119
+ right: 0;
120
+ bottom: 0;
121
+ background: var(--glass-highlight);
122
+ pointer-events: none;
123
+ z-index: 1;
124
+ }
125
+
126
+ /* SKELETON LOADER SYSTEM */
127
+ .skeleton-card {
128
+ display: flex;
129
+ align-items: center;
130
+ padding: 0.75rem;
131
+ border-radius: var(--radius-md);
132
+ border: 1px solid var(--border);
133
+ gap: 1rem;
134
+ height: 84px;
135
+ box-sizing: border-box;
136
+ }
137
+
138
+ .skeleton-thumbnail {
139
+ width: 60px;
140
+ height: 60px;
141
+ border-radius: var(--radius-sm);
142
+ background: var(--bg-secondary);
143
+ position: relative;
144
+ overflow: hidden;
145
+ }
146
+
147
+ .skeleton-info {
148
+ flex: 1;
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 0.5rem;
152
+ min-width: 0;
153
+ }
154
+
155
+ .skeleton-line {
156
+ height: 12px;
157
+ border-radius: var(--radius-full);
158
+ background: var(--bg-secondary);
159
+ position: relative;
160
+ overflow: hidden;
161
+ }
162
+
163
+ .skeleton-title-line {
164
+ width: 60%;
165
+ height: 14px;
166
+ }
167
+
168
+ .skeleton-meta-line {
169
+ width: 40%;
170
+ height: 10px;
171
+ }
172
+
173
+ .skeleton-button {
174
+ width: 40px;
175
+ height: 40px;
176
+ border-radius: var(--radius-sm);
177
+ background: var(--bg-secondary);
178
+ position: relative;
179
+ overflow: hidden;
180
+ }
181
+
182
+ .skeleton-thumbnail::after,
183
+ .skeleton-line::after,
184
+ .skeleton-button::after,
185
+ .skeleton-header::after {
186
+ content: '';
187
+ position: absolute;
188
+ top: 0;
189
+ right: 0;
190
+ bottom: 0;
191
+ left: 0;
192
+ transform: translateX(-100%);
193
+ background: linear-gradient(
194
+ 90deg,
195
+ rgba(255, 255, 255, 0) 0%,
196
+ rgba(255, 255, 255, 0.04) 20%,
197
+ rgba(255, 255, 255, 0.08) 60%,
198
+ rgba(255, 255, 255, 0) 100%
199
+ );
200
+ animation: shimmer 1.5s infinite ease-in-out;
201
+ }
202
+
203
+ @keyframes shimmer {
204
+ 100% {
205
+ transform: translateX(100%);
206
+ }
207
+ }
208
+
209
+ /* Unified Empty State Styling */
210
+ .empty-state {
211
+ display: flex;
212
+ flex-direction: column;
213
+ align-items: center;
214
+ justify-content: center;
215
+ padding: 5rem 2rem;
216
+ text-align: center;
217
+ background: var(--glass-bg);
218
+ border: 1px solid var(--glass-border);
219
+ border-radius: var(--radius-md);
220
+ backdrop-filter: blur(10px);
221
+ -webkit-backdrop-filter: blur(10px);
222
+ box-shadow: var(--glass-shadow);
223
+ margin-top: 1rem;
224
+ }
225
+
226
+ .empty-state-icon {
227
+ color: var(--text-muted);
228
+ margin-bottom: 1.25rem;
229
+ opacity: 0.4;
230
+ animation: pulse-slow 2.5s infinite ease-in-out;
231
+ }
232
+
233
+ .empty-state p {
234
+ margin: 0;
235
+ font-size: 1.1rem;
236
+ color: var(--text-secondary);
237
+ font-weight: 500;
238
+ letter-spacing: -0.01em;
239
+ }
240
+
241
+ @keyframes pulse-slow {
242
+ 0% { transform: scale(1); opacity: 0.4; }
243
+ 50% { transform: scale(1.05); opacity: 0.6; }
244
+ 100% { transform: scale(1); opacity: 0.4; }
245
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('app')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
@@ -0,0 +1,37 @@
1
+ .downloads-page {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 2rem;
5
+ }
6
+
7
+ .downloads-section {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 1rem;
11
+ }
12
+
13
+ .section-title {
14
+ font-size: 1.15rem;
15
+ font-weight: 600;
16
+ color: var(--text-primary);
17
+ margin: 0;
18
+ padding-bottom: 0.25rem;
19
+ }
20
+
21
+ .history-section .section-title {
22
+ color: var(--text-secondary);
23
+ }
24
+
25
+ .downloads-list {
26
+ display: grid;
27
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
28
+ gap: 1rem;
29
+ }
30
+
31
+ @media (max-width: 768px) {
32
+ .downloads-list {
33
+ grid-template-columns: 1fr;
34
+ }
35
+ }
36
+
37
+
@@ -0,0 +1,80 @@
1
+ import DownloadItem from '../components/Download/DownloadItem';
2
+ import { DownloadCloud } from 'lucide-react';
3
+ import './DownloadsPage.css';
4
+
5
+ export default function DownloadsPage({ downloads, onPlay, currentSong, onSearchAlternative, onClearQueue }) {
6
+ const downloadArray = downloads ? Array.from(downloads.values()).reverse() : [];
7
+ const activeDownloads = downloadArray.filter(d => d.status === 'queued' || d.status === 'downloading');
8
+ // UI Freeze Protection: Cap rendered completed downloads to the most recent 50
9
+ const historyDownloads = downloadArray.filter(d => d.status === 'completed' || d.status === 'error').slice(0, 50);
10
+
11
+ if (downloadArray.length === 0) {
12
+ return (
13
+ <div className="downloads-page">
14
+ <div className="empty-state">
15
+ <DownloadCloud size={48} className="empty-state-icon" />
16
+ <p>No downloads yet</p>
17
+ </div>
18
+ </div>
19
+ );
20
+ }
21
+
22
+ const queuedCount = activeDownloads.filter(d => d.status === 'queued').length;
23
+
24
+ return (
25
+ <div className="downloads-page">
26
+ {activeDownloads.length > 0 && (
27
+ <section className="downloads-section">
28
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
29
+ <h3 className="section-title" style={{ margin: 0 }}>Active Downloads</h3>
30
+ {queuedCount > 0 && (
31
+ <button
32
+ onClick={onClearQueue}
33
+ style={{
34
+ background: 'rgba(255, 59, 48, 0.2)',
35
+ color: '#ff3b30',
36
+ border: '1px solid rgba(255, 59, 48, 0.4)',
37
+ padding: '8px 16px',
38
+ borderRadius: '20px',
39
+ cursor: 'pointer',
40
+ fontWeight: '600',
41
+ transition: 'all 0.2s ease'
42
+ }}
43
+ >
44
+ Clear Pending Queue ({queuedCount})
45
+ </button>
46
+ )}
47
+ </div>
48
+ <div className="downloads-list">
49
+ {activeDownloads.map(dl => (
50
+ <DownloadItem
51
+ key={dl.downloadId}
52
+ download={dl}
53
+ onPlay={onPlay}
54
+ isCurrentlyPlaying={currentSong?.videoId === dl.videoId}
55
+ onSearchAlternative={onSearchAlternative}
56
+ />
57
+ ))}
58
+ </div>
59
+ </section>
60
+ )}
61
+
62
+ {historyDownloads.length > 0 && (
63
+ <section className="downloads-section history-section">
64
+ <h3 className="section-title">Download History</h3>
65
+ <div className="downloads-list">
66
+ {historyDownloads.map(dl => (
67
+ <DownloadItem
68
+ key={dl.downloadId}
69
+ download={dl}
70
+ onPlay={(song, rect) => onPlay(dl, rect, historyDownloads)}
71
+ isCurrentlyPlaying={currentSong?.videoId === dl.videoId}
72
+ onSearchAlternative={onSearchAlternative}
73
+ />
74
+ ))}
75
+ </div>
76
+ </section>
77
+ )}
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,46 @@
1
+ import { usePlaylist } from '../hooks/usePlaylist';
2
+ import PlaylistInput from '../components/Playlist/PlaylistInput';
3
+ import PlaylistView from '../components/Playlist/PlaylistView';
4
+ import { ListMusic } from 'lucide-react';
5
+
6
+ export default function PlaylistPage({ outputDir, onDownloadSingle, onBulkDownload, onFlyAnimation, downloads, onPlay, currentSong }) {
7
+ const { playlist, isLoading, error, fetchPlaylist, removeTrack } = usePlaylist();
8
+
9
+ const handleDownloadAll = (tracks) => {
10
+ onBulkDownload(tracks, outputDir);
11
+ };
12
+
13
+ const handleDownloadSingle = (song) => {
14
+ onDownloadSingle(song, outputDir);
15
+ };
16
+
17
+ return (
18
+ <div className="playlist-page">
19
+ <PlaylistInput onFetch={fetchPlaylist} isLoading={isLoading} />
20
+
21
+ {error && (
22
+ <div className="error-message">
23
+ <p>⚠️ {error}</p>
24
+ </div>
25
+ )}
26
+
27
+ {!playlist && !isLoading && (
28
+ <div className="empty-state">
29
+ <ListMusic size={48} className="empty-state-icon" />
30
+ <p>No playlist loaded yet</p>
31
+ </div>
32
+ )}
33
+
34
+ <PlaylistView
35
+ playlist={playlist}
36
+ onDownloadSingle={(song) => onDownloadSingle(song, outputDir)}
37
+ onDownloadAll={() => onBulkDownload(playlist.tracks, outputDir)}
38
+ onFlyAnimation={onFlyAnimation}
39
+ downloads={downloads}
40
+ onPlay={onPlay}
41
+ currentSong={currentSong}
42
+ onRemoveTrack={removeTrack}
43
+ />
44
+ </div>
45
+ );
46
+ }