Line data Source code
1 : // Filepath: Melodex/melodex-front-end/src/components/SongRanker.jsx
2 0 : import React, { useState, useEffect, useRef } from 'react';
3 0 : import { useSongContext } from '../contexts/SongContext';
4 0 : import { useVolumeContext } from '../contexts/VolumeContext';
5 0 : import SongFilter from './SongFilter';
6 0 : import '../index.css';
7 :
8 0 : export const SongRanker = ({ mode }) => {
9 0 : const {
10 0 : currentPair,
11 0 : setCurrentPair,
12 0 : selectSong,
13 0 : skipSong,
14 0 : loading,
15 0 : setLoading,
16 0 : setMode,
17 0 : refreshPair,
18 0 : generateNewSongs,
19 0 : fetchReRankingData,
20 0 : getNextPair,
21 0 : songList,
22 0 : setSongList,
23 0 : userID: contextUserID,
24 0 : setIsRankPageActive,
25 0 : setLastFilters,
26 0 : setFiltersApplied
27 0 : } = useSongContext();
28 :
29 0 : const { volume, setVolume, playingAudioRef, setPlayingAudioRef } = useVolumeContext();
30 0 : const [applied, setApplied] = useState(false);
31 0 : const [enrichedPair, setEnrichedPair] = useState([]);
32 0 : const [showFilter, setShowFilter] = useState(mode === 'new');
33 0 : const [isProcessing, setIsProcessing] = useState(false);
34 0 : const [fetchError, setFetchError] = useState(null);
35 0 : const [selectedGenre, setSelectedGenreState] = useState('');
36 0 : const audioRefs = useRef([]);
37 :
38 0 : useEffect(() => {
39 0 : setMode(mode);
40 0 : setApplied(false);
41 0 : setCurrentPair([]);
42 0 : setEnrichedPair([]);
43 0 : setFetchError(null);
44 0 : setSelectedGenreState('');
45 : // Only auto-activate for rerank. Rank becomes active AFTER Apply.
46 0 : setIsRankPageActive(mode === 'rerank');
47 0 : setFiltersApplied(false);
48 0 : }, [mode, setMode, setCurrentPair, setIsRankPageActive, setFiltersApplied]);
49 :
50 0 : useEffect(() => {
51 0 : if (mode === 'rerank' && !applied && !loading && currentPair.length === 0 && contextUserID) {
52 0 : console.log('Initial fetch triggered for /rerank');
53 0 : handleApply({ genre: 'any', subgenre: 'any', decade: 'all decades' });
54 0 : }
55 0 : }, [mode, applied, loading, currentPair, contextUserID]);
56 :
57 0 : useEffect(() => {
58 0 : if (mode === 'new' && applied && currentPair.length === 0 && !loading && songList.length > 0) {
59 0 : getNextPair();
60 0 : }
61 0 : }, [mode, applied, currentPair, loading, songList, getNextPair]);
62 :
63 0 : useEffect(() => {
64 0 : if (currentPair.length > 0 && !loading && (mode !== 'new' || applied)) {
65 0 : console.log('Starting enrichment for currentPair:', currentPair);
66 0 : setIsProcessing(true);
67 0 : const controller = new AbortController();
68 0 : const timeoutId = setTimeout(() => {
69 0 : controller.abort();
70 0 : console.log('Fetch timed out after 10s');
71 0 : }, 10000);
72 :
73 0 : const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api'}/user-songs/deezer-info`;
74 0 : fetch(url, {
75 0 : method: 'POST',
76 0 : headers: { 'Content-Type': 'application/json' },
77 0 : body: JSON.stringify({ songs: currentPair }),
78 0 : signal: controller.signal,
79 0 : })
80 0 : .then(response => {
81 0 : clearTimeout(timeoutId);
82 0 : if (!response.ok) {
83 0 : throw new Error(`HTTP error enriching songs! Status: ${response.status}`);
84 0 : }
85 0 : return response.text();
86 0 : })
87 0 : .then(text => {
88 0 : console.log('Raw Deezer response:', text);
89 0 : const freshSongs = JSON.parse(text);
90 0 : console.log('Enriched songs:', freshSongs);
91 0 : setEnrichedPair(freshSongs);
92 0 : })
93 0 : .catch(error => {
94 0 : console.error('Enrichment error:', error);
95 0 : setEnrichedPair(currentPair);
96 0 : })
97 0 : .finally(() => {
98 0 : console.log('Enrichment complete, resetting isProcessing');
99 0 : setIsProcessing(false);
100 0 : });
101 0 : }
102 0 : }, [currentPair, loading, mode, applied]);
103 :
104 : // Synchronize volume across all audio players
105 0 : useEffect(() => {
106 0 : audioRefs.current.forEach(audio => {
107 0 : if (audio) {
108 0 : audio.volume = volume;
109 0 : }
110 0 : });
111 0 : }, [volume, enrichedPair]);
112 :
113 0 : const handleApply = async (filters) => {
114 0 : setShowFilter(false);
115 0 : setApplied(false);
116 0 : setEnrichedPair([]);
117 0 : setIsProcessing(true);
118 0 : setFetchError(null);
119 0 : setSelectedGenreState(filters.genre === 'any' ? '' : filters.genre);
120 :
121 0 : setIsRankPageActive(mode === 'new');
122 0 : setLastFilters(filters);
123 0 : setFiltersApplied(mode === 'new'); // only true for /rank
124 :
125 0 : try {
126 0 : if (mode === 'new') {
127 0 : let newSongs = await generateNewSongs(filters);
128 0 : console.log('New songs fetched:', newSongs);
129 0 : if (newSongs.length === 0) {
130 0 : console.log('Retrying generateNewSongs due to empty result');
131 0 : newSongs = await generateNewSongs(filters);
132 0 : if (newSongs.length === 0) {
133 0 : setFetchError('Unable to load new songs from the server. Please try again later or contact support.');
134 0 : }
135 0 : }
136 0 : setSongList(newSongs);
137 0 : setApplied(true);
138 0 : } else if (mode === 'rerank') {
139 0 : const timeoutPromise = new Promise((_, reject) =>
140 0 : setTimeout(() => reject(new Error('Fetch timeout')), 10000)
141 0 : );
142 0 : const fetchPromise = fetchReRankingData(filters.genre, filters.subgenre);
143 0 : await Promise.race([fetchPromise, timeoutPromise]);
144 0 : setApplied(true);
145 0 : }
146 0 : } catch (error) {
147 0 : console.error('Error in handleApply:', error);
148 0 : setFetchError('An error occurred while fetching songs. Please try again or contact support.');
149 0 : setApplied(true);
150 0 : } finally {
151 0 : setLoading(false);
152 0 : setIsProcessing(false);
153 0 : }
154 0 : };
155 :
156 0 : const handlePick = async (winnerId) => {
157 0 : setIsProcessing(true);
158 0 : const loserId = enrichedPair.find(s => s.deezerID !== winnerId)?.deezerID;
159 0 : await selectSong(winnerId, loserId, () => setIsProcessing(false));
160 0 : };
161 :
162 0 : const handleSkip = async (songId) => {
163 0 : setIsProcessing(true);
164 0 : await skipSong(songId, () => setIsProcessing(false));
165 0 : };
166 :
167 0 : const handleRefreshPair = async () => {
168 0 : setIsProcessing(true);
169 0 : await refreshPair(() => setIsProcessing(false));
170 0 : };
171 :
172 0 : return (
173 0 : <div className="song-ranker-container">
174 0 : <div
175 0 : className={`filter-container ${showFilter ? 'visible' : 'hidden'} ${mode === 'new' ? 'rank-mode' : 'rerank-mode'}`}
176 0 : style={{ margin: '0 auto' }}
177 : >
178 0 : <SongFilter onApply={handleApply} isRankPage={mode === 'new'} onHide={() => setShowFilter(false)} />
179 0 : </div>
180 0 : <div style={{ display: 'flex', justifyContent: 'center', margin: '0' }}>
181 0 : <button className="filter-toggle" onClick={() => setShowFilter(prev => !prev)}>
182 0 : <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
183 0 : <rect y="4" width="20" height="2" rx="1" fill="#bdc3c7" className="filter-line" />
184 0 : <rect y="9" width="20" height="2" rx="1" fill="#bdc3c7" className="filter-line" />
185 0 : <rect y="14" width="20" height="2" rx="1" fill="#bdc3c7" className="filter-line" />
186 0 : </svg>
187 0 : </button>
188 0 : </div>
189 0 : {(isProcessing || (loading && mode === 'rerank')) ? (
190 0 : <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '50vh' }}>
191 0 : <div
192 0 : style={{
193 0 : border: '4px solid #ecf0f1',
194 0 : borderTop: '4px solid #3498db',
195 0 : borderRadius: '50%',
196 0 : width: '40px',
197 0 : height: '40px',
198 0 : animation: 'spin 1s linear infinite',
199 0 : }}
200 0 : ></div>
201 0 : <p style={{ marginTop: '1rem', fontSize: '1.2em', color: '#7f8c8d', fontWeight: '600' }}>Loading songs...</p>
202 0 : </div>
203 0 : ) : !applied && mode === 'new' ? (
204 0 : <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '50vh' }}>
205 0 : <p style={{ fontSize: '1.2em', color: '#7f8c8d', fontWeight: '600' }}>
206 : Select filters to rank songs
207 0 : </p>
208 0 : </div>
209 0 : ) : fetchError ? (
210 0 : <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '50vh' }}>
211 0 : <p style={{ fontSize: '1.2em', color: '#e74c3c', fontWeight: '600' }}>
212 0 : {fetchError}
213 0 : </p>
214 0 : <button
215 0 : onClick={() => handleApply({ genre: 'any', subgenre: 'any', decade: 'all decades' })}
216 0 : style={{
217 0 : marginTop: '1rem',
218 0 : background: '#3498db',
219 0 : color: '#fff',
220 0 : padding: "0.5rem 1rem",
221 0 : borderRadius: '0.5rem',
222 0 : border: 'none',
223 0 : cursor: 'pointer',
224 0 : }}
225 0 : >
226 : Retry
227 0 : </button>
228 0 : </div>
229 0 : ) : !Array.isArray(enrichedPair) || enrichedPair.length === 0 ? (
230 0 : <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '50vh' }}>
231 0 : <p style={{ fontSize: '1.2em', color: '#7f8c8d', fontWeight: '600' }}>
232 : No songs available to rank.
233 0 : </p>
234 0 : <button
235 0 : onClick={() => handleApply({ genre: 'any', subgenre: 'any', decade: 'all decades' })}
236 0 : style={{
237 0 : marginTop: '1rem',
238 0 : background: '#3498db',
239 0 : color: '#fff',
240 0 : padding: '0.5rem 1rem',
241 0 : borderRadius: '0.5rem',
242 0 : border: 'none',
243 0 : cursor: 'pointer',
244 0 : }}
245 0 : >
246 : Retry
247 0 : </button>
248 0 : </div>
249 : ) : (
250 0 : <div className="song-ranker-wrapper">
251 0 : <h2
252 0 : style={{
253 0 : textAlign: 'center',
254 0 : color: '#141820',
255 0 : marginBottom: '1.5rem',
256 0 : marginTop: '4rem',
257 0 : }}
258 : >
259 0 : {mode === 'new' ? (selectedGenre ? `Rank ${selectedGenre} Songs` : 'Rank All Songs') : (selectedGenre ? `Rerank ${selectedGenre} Songs` : 'Re-rank All Songs')}
260 0 : </h2>
261 0 : <div className="song-pair" key="song-pair">
262 0 : {enrichedPair.map((song, index) => (
263 0 : <div key={song.deezerID} className="song-card-container">
264 0 : <div
265 0 : className="song-box"
266 0 : onClick={() => handlePick(song.deezerID)}
267 0 : style={{ cursor: 'pointer' }}
268 : >
269 0 : <img src={song.albumCover} alt="Album Cover" className="album-cover" />
270 0 : <div className="song-details">
271 0 : <p className="song-name">{song.songName}</p>
272 0 : <p className="song-artist">{song.artist}</p>
273 0 : {song.previewURL ? (
274 0 : <audio
275 0 : ref={(el) => (audioRefs.current[index] = el)}
276 0 : controls
277 0 : src={song.previewURL}
278 0 : className="custom-audio-player"
279 0 : onVolumeChange={(e) => setVolume(e.target.volume)}
280 0 : onPlay={(e) => {
281 0 : if (playingAudioRef && playingAudioRef !== e.target) {
282 0 : playingAudioRef.pause();
283 0 : }
284 0 : setPlayingAudioRef(e.target);
285 0 : }}
286 0 : onError={(e) => {
287 0 : console.debug('Audio preview failed to load:', song.songName, e.target.error);
288 0 : e.target.style.display = 'none';
289 0 : e.target.nextSibling.style.display = 'block';
290 0 : }}
291 0 : onCanPlay={(e) => {
292 0 : console.log('Audio can play for:', song.songName);
293 0 : e.target.style.display = 'block';
294 0 : e.target.nextSibling.style.display = 'none';
295 0 : }}
296 0 : />
297 : ) : (
298 0 : <span className="preview-unavailable" style={{ display: 'block' }}>Preview unavailable</span>
299 : )}
300 0 : <span className="preview-unavailable" style={{ display: 'none' }}>Preview unavailable</span>
301 0 : </div>
302 0 : </div>
303 0 : <button
304 0 : className="refresh-icon-btn"
305 0 : onClick={() => handleSkip(song.deezerID)}
306 0 : disabled={loading || isProcessing}
307 0 : title={`Refresh ${song.songName}`}
308 : >
309 0 : <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
310 0 : <path
311 0 : d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4.01 7.58 4.01 12C4.01 16.42 7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12C6 8.69 8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z"
312 0 : fill="#bdc3c7"
313 0 : />
314 0 : </svg>
315 0 : </button>
316 0 : </div>
317 0 : ))}
318 0 : </div>
319 0 : <div style={{ display: 'flex', justifyContent: 'center', marginTop: '1rem' }}>
320 0 : <button
321 0 : className="refresh-icon-btn"
322 0 : onClick={handleRefreshPair}
323 0 : disabled={loading || isProcessing}
324 0 : title="Refresh Pair"
325 : >
326 0 : <svg width="25" height="25" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
327 0 : <path
328 0 : d="M17.65 6.35C16.2 4.9 14.21 4 12 4C7.58 4 4.01 7.58 4.01 12C4.01 16.42 7.58 20 12 20C15.73 20 18.84 17.45 19.73 14H17.65C16.83 16.33 14.61 18 12 18C8.69 18 6 15.31 6 12C6 8.69 8.69 6 12 6C13.66 6 15.14 6.69 16.22 7.78L13 11H20V4L17.65 6.35Z"
329 0 : fill="#bdc3c7"
330 0 : />
331 0 : </svg>
332 0 : </button>
333 0 : </div>
334 0 : </div>
335 : )}
336 0 : </div>
337 : );
338 0 : };
339 :
340 0 : export default SongRanker;
|