@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,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
+ }