LCOV - code coverage report
Current view: top level - src/components/SongRanker.jsx - SongRanker.jsx (source / functions) Hit Total Coverage
Test: changed-lcov.info Lines: 0 307 0.0 %
Date: 2025-11-22 00:01:26 Functions: 1 1 100.0 %

          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;

Generated by: LCOV version 1.15.alpha0w