LCOV - code coverage report
Current view: top level - src/components/Rankings.jsx - Rankings.jsx (source / functions) Hit Total Coverage
Test: changed-lcov.info Lines: 921 1193 77.2 %
Date: 2025-11-22 00:01:26 Functions: 21 30 70.0 %

          Line data    Source code
       1             : // Filepath: melodex-front-end/src/components/Rankings.jsx
       2           1 : import React, {
       3             :   useCallback,
       4             :   useEffect,
       5             :   useMemo,
       6             :   useRef,
       7             :   useState,
       8             : } from "react";
       9           1 : import { useSongContext } from "../contexts/SongContext";
      10           1 : import { useVolumeContext } from "../contexts/VolumeContext";
      11           1 : import SongFilter from "./SongFilter";
      12           1 : import "../index.css";
      13           1 : import { formatDefaultPlaylistName } from "../utils/formatDefaultPlaylistName";
      14           1 : import { buildDeepLink } from "../utils/deeplink";
      15             : 
      16             : // ===== Exportable helper so UI tests can import it =====
      17           0 : export async function ensureSpotifyConnected(
      18           0 :   authRoot = "",
      19           0 :   { aggressive = true } = {}
      20           0 : ) {
      21           0 :   const base = String(authRoot || "").replace(/\/+$/, "");
      22             : 
      23           0 :   try {
      24           0 :     const r = await fetch(`${base}/auth/session`, {
      25           0 :       credentials: "include",
      26           0 :       cache: "no-store",
      27           0 :     });
      28             : 
      29             :     // Hard auth failures: only redirect in aggressive mode (user explicitly clicked).
      30           0 :     if (r.status === 401 || r.status === 403) {
      31           0 :       return aggressive
      32           0 :         ? { shouldRedirect: true, to: `${base}/auth/start` }
      33           0 :         : { shouldRedirect: false };
      34           0 :     }
      35             : 
      36           0 :     if (r.status === 200) {
      37           0 :       let data = null;
      38           0 :       try {
      39           0 :         data = await r.json();
      40           0 :       } catch {
      41             :         // If body is weird but 200, assume "probably fine" to avoid loops.
      42           0 :       }
      43             : 
      44           0 :       if (data && data.connected === true) {
      45             :         // Definitely connected.
      46           0 :         return { shouldRedirect: false };
      47           0 :       }
      48             : 
      49             :       // 200 but not explicitly connected:
      50             :       // - In aggressive mode (CTA click) → start OAuth.
      51             :       // - In non-aggressive mode (auto-resume) → DO NOT redirect (prevents loops).
      52           0 :       return aggressive
      53           0 :         ? { shouldRedirect: true, to: `${base}/auth/start` }
      54           0 :         : { shouldRedirect: false };
      55           0 :     }
      56             : 
      57             :     // Any other status (304/5xx/etc): never auto-redirect; avoids infinite loops.
      58           0 :     return { shouldRedirect: false };
      59           0 :   } catch {
      60             :     // Network or other error → don't spin.
      61           0 :     return { shouldRedirect: false };
      62           0 :   }
      63           0 : }
      64             : 
      65           1 : const Rankings = () => {
      66         210 :   const { rankedSongs, fetchRankedSongs, loading, userID } = useSongContext();
      67         210 :   const { volume, setVolume, playingAudioRef, setPlayingAudioRef } =
      68         210 :     useVolumeContext();
      69             : 
      70         210 :   const [applied, setApplied] = useState(false);
      71         210 :   const [enrichedSongs, setEnrichedSongs] = useState([]);
      72         210 :   const [filteredSongs, setFilteredSongs] = useState([]);
      73         210 :   const [showFilter, setShowFilter] = useState(true);
      74         210 :   const [isFetching, setIsFetching] = useState(false);
      75         210 :   const [selectedGenre, setSelectedGenre] = useState("any");
      76         210 :   const [selectedSubgenre, setSelectedSubgenre] = useState("any");
      77         210 :   const [lastAppliedFilters, setLastAppliedFilters] = useState({
      78         210 :     genre: "any",
      79         210 :     subgenre: "any",
      80         210 :   });
      81             : 
      82             :   // Inline export selection mode
      83         210 :   const [selectionMode, setSelectionMode] = useState(false);
      84         210 :   const [selected, setSelected] = useState(new Set());
      85         210 :   const [playlistName, setPlaylistName] = useState(() =>
      86          24 :     formatDefaultPlaylistName()
      87         210 :   );
      88         210 :   const [playlistDescription, setPlaylistDescription] = useState("");
      89         210 :   const [exporting, setExporting] = useState(false);
      90         210 :   const [exportSuccessUrl, setExportSuccessUrl] = useState("");
      91             : 
      92             :   // Export progress states (idle → loading → success|error)
      93         210 :   const ExportState = {
      94         210 :     Idle: "idle",
      95         210 :     Validating: "validating",
      96         210 :     Creating: "creating",
      97         210 :     Adding: "adding",
      98         210 :     Success: "success",
      99         210 :     Error: "error",
     100         210 :   };
     101         210 :   const [exportState, setExportState] = useState(ExportState.Idle);
     102         210 :   const [exportError, setExportError] = useState(null);
     103             : 
     104             :   // Map<stableKey, HTMLAudioElement>
     105         210 :   const audioRefs = useRef(new Map());
     106         210 :   const rehydratingRef = useRef(new Set());
     107         210 :   const didRunFixRef = useRef(false);
     108         210 :   const recentlyDoneRef = useRef(new Map()); // key -> timestamp (ms)
     109         210 :   const autoInitRef = useRef(false); // avoid StrictMode/HMR double-run
     110         210 :   const rehydrateAvailableRef = useRef(null); // null=unknown, true/false after first attempt
     111             : 
     112         210 :   const RECENTLY_DONE_WINDOW_MS = 5 * 60 * 1000;
     113         210 :   const isCypressEnv =
     114         210 :     typeof window !== "undefined" &&
     115         210 :     !!window.Cypress &&
     116         210 :     window.__E2E_REQUIRE_AUTH__ === false;
     117             : 
     118         210 :   const isJsdom =
     119         210 :     typeof navigator !== "undefined" && /jsdom/i.test(navigator.userAgent);
     120             : 
     121         210 :   const hasInjectedRanked = () => {
     122          54 :     try {
     123          54 :       return (
     124          54 :         typeof window !== "undefined" && Array.isArray(window.__TEST_RANKED__)
     125             :       );
     126          54 :     } catch {
     127           0 :       return false;
     128           0 :     }
     129          54 :   };
     130             : 
     131             :   // ---- API base (handles with/without trailing /api) ----
     132         210 :   const RAW_BASE =
     133         210 :     import.meta.env.VITE_API_BASE_URL ??
     134         210 :     import.meta.env.VITE_API_BASE ??
     135         210 :     window.__API_BASE__ ??
     136         210 :     "http://localhost:8080";
     137             : 
     138         210 :   const normalizeNoTrail = (s) => String(s).replace(/\/+$/, "");
     139         210 :   const baseNoTrail = normalizeNoTrail(RAW_BASE);
     140         210 :   const API_ROOT = /\/api$/.test(baseNoTrail)
     141           0 :     ? baseNoTrail
     142         210 :     : `${baseNoTrail}/api`;
     143         210 :   const hasApiSuffix = /\/api$/.test(baseNoTrail);
     144         210 :   const AUTH_ROOT = hasApiSuffix
     145           0 :     ? baseNoTrail.replace(/\/api$/, "")
     146         210 :     : baseNoTrail;
     147             : 
     148         210 :   const joinUrl = (...parts) =>
     149          23 :     parts
     150          23 :       .map((p, i) =>
     151          69 :         i === 0
     152          23 :           ? String(p).replace(/\/+$/, "")
     153          46 :           : String(p).replace(/^\/+|\/+$/g, "")
     154          23 :       )
     155          23 :       .filter(Boolean)
     156          23 :       .join("/");
     157             : 
     158         210 :   function recentlyDone(key) {
     159          28 :     const ts = recentlyDoneRef.current.get(key) || 0;
     160          28 :     return Date.now() - ts < RECENTLY_DONE_WINDOW_MS;
     161          28 :   }
     162             : 
     163             :   // --- OAuth resume helpers ---
     164         210 :   const EXPORT_INTENT_KEY = "melodex.intent";
     165             : 
     166         210 :   const markExportIntent = () => {
     167           0 :     try {
     168           0 :       sessionStorage.setItem(EXPORT_INTENT_KEY, "export");
     169           0 :     } catch {}
     170           0 :   };
     171             : 
     172         210 :   const consumeExportIntent = () => {
     173          23 :     try {
     174          23 :       const v = sessionStorage.getItem(EXPORT_INTENT_KEY);
     175          23 :       if (v === "export") {
     176           0 :         sessionStorage.removeItem(EXPORT_INTENT_KEY);
     177           0 :         return true;
     178           0 :       }
     179          23 :     } catch {}
     180          23 :     return false;
     181          23 :   };
     182             : 
     183             :   // ===== Helpers =====
     184         210 :   const stableKey = (s) => {
     185         351 :     if (!s) return "na__";
     186         351 :     if (s._id) return `id_${String(s._id)}`;
     187         204 :     if (s.deezerID != null) return `dz_${String(s.deezerID)}`;
     188           0 :     const norm = (x) =>
     189           0 :       String(x || "")
     190           0 :         .toLowerCase()
     191           0 :         .trim()
     192           0 :         .replace(/\s+/g, " ");
     193           0 :     return `na_${norm(s.songName)}__${norm(s.artist)}`;
     194         351 :   };
     195             : 
     196             :   // Seed selection with all visible songs
     197         210 :   const seedSelectedAll = useCallback((songs) => {
     198          25 :     const next = new Set();
     199          25 :     songs.forEach((s) => next.add(stableKey(s)));
     200          25 :     setSelected(next);
     201         210 :   }, []);
     202             : 
     203             :   // Parse Deezer preview expiry for logging + validation
     204         210 :   function parsePreviewExpiry(url) {
     205         218 :     if (!url || typeof url !== "string")
     206         218 :       return { exp: null, now: Math.floor(Date.now() / 1000), ttl: null };
     207         218 :     try {
     208         218 :       const qs = url.split("?")[1] || "";
     209         218 :       const params = new URLSearchParams(qs);
     210         218 :       const hdnea = params.get("hdnea") || "";
     211         218 :       const m = /exp=(\d+)/.exec(hdnea);
     212         218 :       const now = Math.floor(Date.now() / 1000);
     213         218 :       if (!m) return { exp: null, now, ttl: null };
     214          68 :       const exp = parseInt(m[1], 10);
     215          68 :       return { exp, now, ttl: exp - now };
     216          68 :     } catch {
     217           0 :       return { exp: null, now: Math.floor(Date.now() / 1000), ttl: null };
     218           0 :     }
     219         218 :   }
     220             : 
     221         210 :   function isPreviewValid(url) {
     222         190 :     const { ttl } = parsePreviewExpiry(url);
     223         190 :     if (ttl === null) return true;
     224          58 :     return ttl > 60;
     225         190 :   }
     226             : 
     227         210 :   function msSince(d) {
     228           0 :     if (!d) return Number.POSITIVE_INFINITY;
     229           0 :     const t = typeof d === "string" ? Date.parse(d) : d;
     230           0 :     if (Number.isNaN(t)) return Number.POSITIVE_INFINITY;
     231           0 :     return Date.now() - t;
     232           0 :   }
     233             : 
     234         210 :   const REFRESH_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
     235             : 
     236             :   // ===== Network ops =====
     237         210 :   const rehydrateSong = async (song) => {
     238           0 :     const key = stableKey(song);
     239           0 :     try {
     240           0 :       const effectiveUserID = userID || (isCypressEnv ? "e2e-user" : null);
     241           0 :       if (!effectiveUserID || !song) return;
     242           0 :       if (rehydrateAvailableRef.current === false) return;
     243             : 
     244           0 :       if (recentlyDone(key)) {
     245           0 :         console.log("[Rankings] rehydrateSong skipped (recently done)", {
     246           0 :           key,
     247           0 :         });
     248           0 :         return;
     249           0 :       }
     250             : 
     251           0 :       if (rehydratingRef.current.has(key)) {
     252           0 :         console.log("[Rankings] rehydrateSong skipped (already in-flight)", {
     253           0 :           key,
     254           0 :           song: { n: song.songName, a: song.artist },
     255           0 :         });
     256           0 :         return;
     257           0 :       }
     258           0 :       rehydratingRef.current.add(key);
     259             : 
     260           0 :       const endpoint = joinUrl(API_ROOT, "user-songs", "rehydrate");
     261             : 
     262           0 :       const ttlInfo = parsePreviewExpiry(song.previewURL);
     263           0 :       console.log("[Rankings] rehydrateSong POST", {
     264           0 :         endpoint,
     265           0 :         song: {
     266           0 :           id: song._id,
     267           0 :           deezerID: song.deezerID,
     268           0 :           name: song.songName,
     269           0 :           artist: song.artist,
     270           0 :         },
     271           0 :         previewTTL: ttlInfo,
     272           0 :       });
     273             : 
     274           0 :       const res = await fetch(endpoint, {
     275           0 :         method: "POST",
     276           0 :         headers: { "Content-Type": "application/json" },
     277           0 :         body: JSON.stringify({
     278           0 :           userID: effectiveUserID,
     279           0 :           songId: song._id,
     280           0 :           fallbackDeezerID: song.deezerID,
     281           0 :           songName: song.songName,
     282           0 :           artist: song.artist,
     283           0 :         }),
     284           0 :       });
     285             : 
     286           0 :       if (!res.ok) {
     287           0 :         const txt = await res.text();
     288           0 :         console.warn("[Rankings] rehydrateSong FAILED", {
     289           0 :           status: res.status,
     290           0 :           body: txt,
     291           0 :         });
     292           0 :         recentlyDoneRef.current.set(key, Date.now());
     293           0 :         if (res.status === 404) {
     294           0 :           rehydrateAvailableRef.current = false;
     295           0 :         }
     296           0 :         throw new Error(`rehydrate failed ${res.status} ${txt}`);
     297           0 :       }
     298             : 
     299           0 :       rehydrateAvailableRef.current = true;
     300             : 
     301           0 :       const updated = await res.json();
     302             : 
     303           0 :       const matches = (s) =>
     304           0 :         (s._id && updated._id && String(s._id) === String(updated._id)) ||
     305           0 :         (!!s.deezerID &&
     306           0 :           !!updated.deezerID &&
     307           0 :           String(s.deezerID) === String(updated.deezerID)) ||
     308           0 :         (s.songName === song.songName && s.artist === song.artist);
     309             : 
     310           0 :       setEnrichedSongs((prev) =>
     311           0 :         prev.map((s) => (matches(s) ? { ...s, ...updated } : s))
     312           0 :       );
     313           0 :       setFilteredSongs((prev) =>
     314           0 :         prev.map((s) => (matches(s) ? { ...s, ...updated } : s))
     315           0 :       );
     316             : 
     317           0 :       const audioEl = audioRefs.current.get(key);
     318           0 :       if (audioEl && updated.previewURL) {
     319           0 :         audioEl.src = updated.previewURL;
     320           0 :         audioEl.load();
     321           0 :       }
     322             : 
     323           0 :       recentlyDoneRef.current.set(key, Date.now());
     324             : 
     325           0 :       console.log("[Rankings] rehydrateSong OK", {
     326           0 :         song: { id: song._id, name: song.songName, artist: song.artist },
     327           0 :         updatedPreviewTTL: parsePreviewExpiry(updated.previewURL),
     328           0 :         updatedDeezerID: updated.deezerID,
     329           0 :         lastDeezerRefresh: updated.lastDeezerRefresh,
     330           0 :       });
     331           0 :     } catch (e) {
     332           0 :       console.warn("[Rankings] Rehydrate error:", e);
     333           0 :     } finally {
     334           0 :       rehydratingRef.current.delete(key);
     335           0 :     }
     336           0 :   };
     337             : 
     338             :   // ===== Initial fetch =====
     339         210 :   useEffect(() => {
     340          60 :     if ((userID || isCypressEnv) && !applied) {
     341          36 :       const params = new URLSearchParams(window.location.search || "");
     342          36 :       const initialGenre = params.get("genre") || "any";
     343          36 :       const initialSubgenre = params.get("subgenre") || "any";
     344             : 
     345          36 :       console.log("Initial fetch triggered for /rankings with filters", {
     346          36 :         genre: initialGenre,
     347          36 :         subgenre: initialSubgenre,
     348          36 :       });
     349             : 
     350          36 :       handleApply({
     351          36 :         genre: initialGenre,
     352          36 :         subgenre: initialSubgenre,
     353          36 :         decade: "all decades",
     354          36 :       });
     355          36 :     }
     356         210 :   }, [userID, applied]);
     357             : 
     358             :   // ===== “Show immediately, fix in background once” =====
     359         210 :   const enrichAndFilterSongs = useCallback(() => {
     360          54 :     if (isCypressEnv && hasInjectedRanked()) {
     361           6 :       try {
     362           6 :         const injected = window.__TEST_RANKED__ || [];
     363           6 :         setEnrichedSongs(injected);
     364           6 :         setFilteredSongs(injected);
     365           6 :       } catch {}
     366           6 :       setIsFetching(false);
     367           6 :       return;
     368           6 :     }
     369          54 :     if (!applied || !Array.isArray(rankedSongs)) return;
     370             : 
     371          23 :     setEnrichedSongs(rankedSongs);
     372          23 :     setFilteredSongs(rankedSongs);
     373          23 :     setIsFetching(false);
     374             : 
     375          23 :     try {
     376          23 :       let valid = 0,
     377          23 :         expired = 0,
     378          23 :         missing = 0;
     379          23 :       const samples = [];
     380          23 :       rankedSongs.forEach((s, i) => {
     381          50 :         if (!s.previewURL) {
     382          37 :           missing++;
     383          37 :           if (samples.length < 5)
     384          37 :             samples.push({
     385          37 :               i,
     386          37 :               name: s.songName,
     387          37 :               artist: s.artist,
     388          37 :               reason: "no previewURL",
     389          37 :             });
     390          50 :         } else if (isPreviewValid(s.previewURL)) {
     391          13 :           valid++;
     392          13 :         } else {
     393           0 :           expired++;
     394           0 :           if (samples.length < 5) {
     395           0 :             const { ttl, exp, now } = parsePreviewExpiry(s.previewURL);
     396           0 :             samples.push({
     397           0 :               i,
     398           0 :               name: s.songName,
     399           0 :               artist: s.artist,
     400           0 :               ttl,
     401           0 :               exp,
     402           0 :               now,
     403           0 :               reason: "expired",
     404           0 :             });
     405           0 :           }
     406           0 :         }
     407          23 :       });
     408          23 :       console.log("[Rankings] Snapshot after fetch:", {
     409          23 :         valid,
     410          23 :         expired,
     411          23 :         missing,
     412          23 :         sample: samples,
     413          23 :       });
     414          48 :     } catch (e) {
     415           0 :       console.log("[Rankings] Snapshot logging error:", e);
     416           0 :     }
     417             : 
     418          23 :     if (didRunFixRef.current) return;
     419          23 :     didRunFixRef.current = true;
     420             : 
     421          23 :     const url = joinUrl(API_ROOT, "user-songs", "deezer-info");
     422             : 
     423          23 :     const candidates = rankedSongs.filter(
     424          23 :       (s) => !s.deezerID || !s.albumCover || !s.previewURL
     425          23 :     );
     426          23 :     console.log("[Rankings] Background fix pass: candidates", {
     427          23 :       total: rankedSongs.length,
     428          23 :       candidates: candidates.length,
     429          23 :       sample: candidates.slice(0, 5).map((s) => ({
     430          37 :         name: s.songName,
     431          37 :         artist: s.artist,
     432          37 :         deezerID: s.deezerID,
     433          37 :         hasCover: !!s.albumCover,
     434          37 :         hasPreview: !!s.previewURL,
     435          23 :       })),
     436          23 :     });
     437             : 
     438          23 :     const BATCH_SIZE = 10;
     439          23 :     const CONCURRENCY = 2;
     440             : 
     441          23 :     let cursor = 0;
     442          23 :     let active = 0;
     443          23 :     let cancelled = false;
     444             : 
     445          23 :     const runNext = () => {
     446          38 :       if (cancelled) return;
     447          38 :       if (cursor >= candidates.length && active === 0) return;
     448             : 
     449          38 :       while (active < CONCURRENCY && cursor < candidates.length) {
     450          15 :         const slice = candidates.slice(cursor, cursor + BATCH_SIZE);
     451          15 :         cursor += BATCH_SIZE;
     452          15 :         active += 1;
     453             : 
     454          15 :         console.log("[Rankings] deezer-info POST", {
     455          15 :           url,
     456          15 :           sliceCount: slice.length,
     457          15 :         });
     458             : 
     459          15 :         fetch(url, {
     460          15 :           method: "POST",
     461          15 :           headers: { "Content-Type": "application/json" },
     462          15 :           body: JSON.stringify({ songs: slice }),
     463          15 :         })
     464          15 :           .then(async (r) => {
     465          15 :             if (!r.ok) throw new Error(`deezer-info ${r.status}`);
     466          15 :             const batch = await r.json();
     467          15 :             const list = Array.isArray(batch) ? batch : [];
     468          15 :             console.log("[Rankings] deezer-info OK", { returned: list.length });
     469             : 
     470          15 :             setEnrichedSongs((prev) =>
     471          24 :               prev.map((s) => {
     472          37 :                 const repl = list.find(
     473          37 :                   (b) =>
     474          18 :                     (b._id && s._id && String(b._id) === String(s._id)) ||
     475          18 :                     (!!b.deezerID &&
     476          18 :                       String(b.deezerID) === String(s.deezerID)) ||
     477           9 :                     (b.songName === s.songName && b.artist === s.artist)
     478          37 :                 );
     479          37 :                 return repl ? { ...s, ...repl } : s;
     480          24 :               })
     481          15 :             );
     482          15 :             setFilteredSongs((prev) =>
     483          24 :               prev.map((s) => {
     484          37 :                 const repl = list.find(
     485          37 :                   (b) =>
     486          18 :                     (b._id && s._id && String(b._id) === String(s._id)) ||
     487          18 :                     (!!b.deezerID &&
     488          18 :                       String(b.deezerID) === String(s.deezerID)) ||
     489           9 :                     (b.songName === s.songName && b.artist === s.artist)
     490          37 :                 );
     491          37 :                 return repl ? { ...s, ...repl } : s;
     492          24 :               })
     493          15 :             );
     494          15 :           })
     495          15 :           .catch((err) => {
     496           0 :             console.log(
     497           0 :               "[Rankings] deezer-info error (ignored, UI will self-heal on play)",
     498           0 :               err?.message || err
     499           0 :             );
     500          15 :           })
     501          15 :           .finally(() => {
     502          15 :             active -= 1;
     503          15 :             runNext();
     504          15 :           });
     505          15 :       }
     506          38 :     };
     507             : 
     508          23 :     runNext();
     509             : 
     510          23 :     return () => {
     511          23 :       cancelled = true;
     512          23 :     };
     513         210 :   }, [applied, rankedSongs]);
     514             : 
     515         210 :   useEffect(() => {
     516          54 :     return enrichAndFilterSongs();
     517         210 :   }, [enrichAndFilterSongs]);
     518             : 
     519             :   // ===== Auto rehydrate only when expired + cooldown =====
     520         210 :   useEffect(() => {
     521          95 :     if (!Array.isArray(filteredSongs) || filteredSongs.length === 0) return;
     522          24 :     if (autoInitRef.current) return;
     523          22 :     autoInitRef.current = true;
     524          22 :     if (rehydrateAvailableRef.current === false) return;
     525             : 
     526          22 :     filteredSongs.forEach((s) => {
     527          54 :       if (!s.previewURL) return;
     528          28 :       const key = stableKey(s);
     529          54 :       if (rehydratingRef.current.has(key) || recentlyDone(key)) return;
     530          28 :       const { ttl } = parsePreviewExpiry(s.previewURL);
     531             : 
     532          28 :       if (
     533          28 :         !isPreviewValid(s.previewURL) &&
     534           0 :         msSince(s.lastDeezerRefresh) > REFRESH_COOLDOWN_MS
     535          54 :       ) {
     536           0 :         console.log("[Rankings] AUTO rehydrate (expired + cooldown)", {
     537           0 :           name: s.songName,
     538           0 :           artist: s.artist,
     539           0 :           deezerID: s.deezerID,
     540           0 :           ttl,
     541           0 :           lastDeezerRefresh: s.lastDeezerRefresh,
     542           0 :         });
     543           0 :         rehydrateSong(s);
     544           0 :       }
     545          22 :     });
     546         210 :   }, [filteredSongs]);
     547             : 
     548             :   // Keep volume in sync
     549         210 :   useEffect(() => {
     550          95 :     audioRefs.current.forEach((audio) => {
     551          28 :       if (audio) audio.volume = volume;
     552          95 :     });
     553         210 :   }, [volume, filteredSongs]);
     554             : 
     555             :   // ===== UI actions =====
     556             : 
     557             :   // TEST-ONLY: Allow unit tests to inject ranked songs without hitting network
     558         210 :   const getTestRanked = () => {
     559          38 :     try {
     560          38 :       if (
     561          38 :         typeof window !== "undefined" &&
     562          38 :         Array.isArray(window.__TEST_RANKED__)
     563          38 :       ) {
     564           3 :         return window.__TEST_RANKED__;
     565           3 :       }
     566          38 :     } catch {}
     567          35 :     return null;
     568          38 :   };
     569             : 
     570         210 :   const handleApply = async (filters) => {
     571          38 :     const effectiveUserID = userID || (isCypressEnv ? "e2e-user" : null);
     572          38 :     if (!effectiveUserID) {
     573           0 :       console.log("No userID available, skipping fetch");
     574           0 :       return;
     575           0 :     }
     576             : 
     577          38 :     didRunFixRef.current = false;
     578             : 
     579          38 :     setShowFilter(false);
     580          38 :     setEnrichedSongs([]);
     581          38 :     setFilteredSongs([]);
     582          38 :     setIsFetching(true);
     583          38 :     setSelectedGenre(filters.genre);
     584          38 :     setSelectedSubgenre(filters.subgenre);
     585          38 :     setLastAppliedFilters({ genre: filters.genre, subgenre: filters.subgenre });
     586             : 
     587          38 :     try {
     588             :       // TEST-ONLY injection path (skips network)
     589          38 :       const injected = getTestRanked();
     590          38 :       if (injected) {
     591           3 :         setApplied(true);
     592           3 :         setIsFetching(false);
     593           3 :         setEnrichedSongs(injected);
     594           3 :         setFilteredSongs(injected);
     595           3 :         return;
     596           3 :       }
     597             : 
     598          35 :       const timeoutPromise = new Promise((_, reject) =>
     599          35 :         setTimeout(() => reject(new Error("Fetch timeout")), 60000)
     600          35 :       );
     601          35 :       await Promise.race([
     602             :         // ⬆️ Respect current filters passed from SongFilter
     603          35 :         fetchRankedSongs({
     604          35 :           userID: effectiveUserID,
     605          38 :           genre: filters?.genre ?? "any",
     606          38 :           subgenre: filters?.subgenre ?? "any",
     607          38 :         }),
     608          38 :         timeoutPromise,
     609          38 :       ]);
     610          35 :       setApplied(true);
     611          35 :     } catch (error) {
     612           0 :       console.error("handleApply error:", error);
     613           0 :       setApplied(true);
     614           0 :       setFilteredSongs([]);
     615           0 :     }
     616          38 :   };
     617             : 
     618         210 :   const sortedSongs = [...filteredSongs].sort((a, b) => b.ranking - a.ranking);
     619             : 
     620             :   // Auto-open Export after OAuth if we detect intent or ?export=1
     621         210 :   useEffect(() => {
     622             :     // IMPORTANT: don't consume the intent until we actually have songs
     623          49 :     if (!sortedSongs || sortedSongs.length === 0) return;
     624             : 
     625          49 :     const params = new URLSearchParams(window.location.search || "");
     626          49 :     const wantsExport = params.get("export") === "1" || consumeExportIntent();
     627          49 :     if (!wantsExport) return;
     628             : 
     629           0 :     (async () => {
     630           0 :       const decision = await ensureSpotifyConnected(AUTH_ROOT, {
     631           0 :         aggressive: false,
     632           0 :       });
     633           0 :       if (decision.shouldRedirect) {
     634             :         // Still not connected? Kick off OAuth again and keep intent
     635           0 :         markExportIntent();
     636           0 :         window.location.href = decision.to;
     637           0 :         return;
     638           0 :       }
     639             : 
     640             :       // Connected → open selection mode with all visible songs
     641           0 :       seedSelectedAll(sortedSongs);
     642           0 :       setExportSuccessUrl("");
     643           0 :       setSelectionMode(true);
     644           0 :       setPlaylistName((prev) =>
     645           0 :         String(prev || "").trim() ? prev : formatDefaultPlaylistName()
     646           0 :       );
     647             : 
     648             :       // Clean the URL (drop ?export=1) to avoid sticky behavior on refresh
     649           0 :       if (params.get("export") === "1") {
     650           0 :         const clean = window.location.pathname + window.location.hash;
     651           0 :         window.history.replaceState({}, "", clean);
     652           0 :       }
     653           0 :     })();
     654             :     // When songs list changes (initial load), this runs once
     655         210 :   }, [sortedSongs.length]);
     656             : 
     657         210 :   async function onExportClick() {
     658             :     // Cypress/jsdom fast path (tests can bypass auth)
     659          25 :     if (isCypressEnv) {
     660          25 :       seedSelectedAll(sortedSongs);
     661          25 :       setExportSuccessUrl("");
     662          25 :       setExportError(null);
     663          25 :       setExportState(ExportState.Idle); // reset when opening fresh
     664          25 :       setSelectionMode(true);
     665          25 :       setPlaylistName((prev) =>
     666          25 :         String(prev || "").trim() ? prev : formatDefaultPlaylistName()
     667          25 :       );
     668          25 :       return;
     669          25 :     }
     670             : 
     671             :     // Real browser path: ensure we have a Spotify session first
     672           0 :     const decision = await ensureSpotifyConnected(AUTH_ROOT, {
     673           0 :       aggressive: true,
     674           0 :     });
     675             : 
     676           0 :     if (decision.shouldRedirect) {
     677             :       // Remember that the user clicked Export so we can resume after OAuth
     678           0 :       markExportIntent();
     679           0 :       window.location.href = decision.to;
     680           0 :       return;
     681           0 :     }
     682             : 
     683          25 :     if (!sortedSongs || sortedSongs.length === 0) {
     684           0 :       return;
     685           0 :     }
     686             : 
     687           0 :     seedSelectedAll(sortedSongs);
     688           0 :     setExportSuccessUrl("");
     689           0 :     setExportError(null);
     690           0 :     setExportState(ExportState.Idle); // reset when opening from UI
     691           0 :     setSelectionMode(true);
     692           0 :     setPlaylistName((prev) =>
     693           0 :       String(prev || "").trim() ? prev : formatDefaultPlaylistName()
     694           0 :     );
     695          25 :   }
     696             : 
     697         210 :   const onCancelSelection = () => {
     698           2 :     setSelectionMode(false);
     699           2 :     setSelected(new Set());
     700           2 :     setPlaylistName("");
     701           2 :     setPlaylistDescription("");
     702           2 :     setExporting(false);
     703           2 :     setExportSuccessUrl("");
     704           2 :     setExportError(null);
     705           2 :     setExportState(ExportState.Idle); // <- reset so next session starts clean
     706           2 :   };
     707             : 
     708         210 :   const doExport = async () => {
     709           8 :     if (exporting) return;
     710             : 
     711           8 :     const chosen = sortedSongs.filter((s) => selected.has(stableKey(s)));
     712           8 :     if (chosen.length === 0) return;
     713             : 
     714             :     // reset any prior terminal states
     715           8 :     setExportError(null);
     716           8 :     setExportState(ExportState.Validating);
     717             : 
     718             :     // default name (use your existing genre/subgenre rule; fall back to util if you added it)
     719           8 :     const defaultNameParts = [];
     720           8 :     if (selectedGenre !== "any") defaultNameParts.push(selectedGenre);
     721           8 :     if (selectedSubgenre !== "any") defaultNameParts.push(selectedSubgenre);
     722           8 :     const defaultName =
     723           8 :       defaultNameParts.length > 0
     724           0 :         ? `${defaultNameParts.join(" ")} Playlist`
     725           8 :         : "Melodex Playlist";
     726             : 
     727             :     // Build URIs for the current stub path (uses cached spotifyUri if present; otherwise dz/_id fallback)
     728           8 :     const stubUris = chosen
     729           8 :       .filter((s) => s && (s.spotifyUri || s.deezerID || s._id))
     730           8 :       .map((s) => s.spotifyUri || `spotify:track:${s.deezerID || s._id}`);
     731             : 
     732             :     // Also include a rich items array to support the real mapping path later
     733           8 :     const items = chosen.map((s) => ({
     734          16 :       deezerID: s.deezerID ?? s._id ?? null,
     735          16 :       songName: s.songName,
     736          16 :       artist: s.artist,
     737          16 :       isrc: s.isrc ?? null,
     738          16 :       spotifyUri: s.spotifyUri ?? null, // cached hit if you have it
     739          16 :       ranking: s.ranking ?? null,
     740           8 :     }));
     741             : 
     742           8 :     const payload = {
     743           8 :       name: (playlistName || "").trim() || formatDefaultPlaylistName(),
     744           8 :       description: (playlistDescription || "").trim(),
     745             :       // keep both so tests + future real mapping are happy:
     746           8 :       uris: stubUris, // legacy/stub consumers
     747           8 :       ...(isCypressEnv ? { __testUris: stubUris } : {}), // only in Cypress/jsdom
     748           8 :       items, // real mapping path will use this
     749             :       // include filters if your backend reads them (optional):
     750             :       // filters: { genre: selectedGenre, subgenre: selectedSubgenre }
     751           8 :     };
     752             : 
     753             :     // expose for Cypress when present
     754           8 :     if (isCypressEnv && typeof window !== "undefined") {
     755           8 :       window.__LAST_EXPORT_PAYLOAD__ = payload;
     756           8 :     }
     757             : 
     758           8 :     try {
     759           8 :       setExporting(true);
     760           8 :       setExportState(ExportState.Creating);
     761             : 
     762             :       // fix endpoint path to match backend/tests
     763           8 :       const res = await fetch(`${API_ROOT}/playlist/export`, {
     764           8 :         method: "POST",
     765           8 :         headers: { "Content-Type": "application/json" },
     766           8 :         credentials: "include",
     767           8 :         body: JSON.stringify(payload),
     768           8 :       });
     769             : 
     770           7 :       let data = null;
     771           7 :       try {
     772           7 :         data = await res.json();
     773           8 :       } catch {}
     774             : 
     775           8 :       if (!res.ok || (data && data.ok === false)) {
     776             :         // Surface shaped error to UI (E2E looks for message + recovery guidance)
     777           0 :         const msg = data?.message || `Export failed ${res.status}`;
     778           0 :         const hint = data?.hint || "Please retry or adjust your selection.";
     779           0 :         setExportState(ExportState.Error);
     780           0 :         setExportError(`${msg} — ${hint}`);
     781           0 :         return;
     782           0 :       }
     783             : 
     784           8 :       const ok = data?.ok === true;
     785           8 :       const playlistUrl = data?.playlistUrl;
     786           8 :       if (ok && playlistUrl) {
     787           6 :         setExportState(ExportState.Success);
     788           6 :         setExportSuccessUrl(playlistUrl);
     789           7 :       } else {
     790             :         // Defensive: treat unexpected shape as an error
     791           1 :         setExportState(ExportState.Error);
     792           1 :         setExportError("Unexpected response from server — please try again.");
     793           1 :       }
     794           8 :     } catch (e) {
     795           1 :       console.error("Export error:", e);
     796           1 :       setExportState(ExportState.Error);
     797           1 :       setExportError(e?.message || "Something went wrong — please try again.");
     798           8 :     } finally {
     799           8 :       setExporting(false);
     800           8 :     }
     801           8 :   };
     802             : 
     803         210 :   const toggleFilter = () => setShowFilter((prev) => !prev);
     804             : 
     805         210 :   const getRankPositions = (songs) => {
     806         210 :     if (!Array.isArray(songs)) {
     807           0 :       console.error("getRankPositions: songs is not an array", songs);
     808           0 :       return [];
     809           0 :     }
     810         210 :     const sortedSongs = [...songs].sort((a, b) => {
     811         150 :       if (typeof a.ranking !== "number" || typeof b.ranking !== "number") {
     812           0 :         console.error("Invalid ranking value", a, b);
     813           0 :         return 0;
     814           0 :       }
     815         150 :       return b.ranking - a.ranking;
     816         210 :     });
     817         210 :     const positions = [];
     818         210 :     let currentRank = 1;
     819         210 :     let previousRanking = null;
     820             : 
     821         210 :     sortedSongs.forEach((song) => {
     822         251 :       if (previousRanking === null || song.ranking !== previousRanking) {
     823         251 :         positions.push(currentRank);
     824         251 :         currentRank += 1;
     825         251 :       } else {
     826           0 :         positions.push(positions[positions.length - 1]);
     827           0 :       }
     828         251 :       previousRanking = song.ranking;
     829         210 :     });
     830             : 
     831         210 :     return positions;
     832         210 :   };
     833             : 
     834         210 :   const rankPositions = getRankPositions(sortedSongs);
     835         210 :   const zeroSelected = selectionMode && selected.size === 0;
     836             : 
     837         210 :   return (
     838         210 :     <div
     839         210 :       className="rankings-container"
     840         210 :       style={{ maxWidth: "1200px", width: "100%" }}
     841             :     >
     842         210 :       <div
     843         210 :         className={`filter-container ${showFilter ? "visible" : "hidden"}`}
     844         210 :         style={{ width: "550px", margin: "0 auto" }}
     845             :       >
     846         210 :         <SongFilter
     847         210 :           onApply={handleApply}
     848         210 :           isRankPage={false}
     849         210 :           onHide={toggleFilter}
     850         210 :         />
     851         210 :       </div>
     852             : 
     853         210 :       <div
     854         210 :         style={{
     855         210 :           display: "flex",
     856         210 :           justifyContent: "center",
     857         210 :           margin: "0",
     858         210 :           transition: "transform 0.3s ease",
     859         210 :           transform: showFilter ? "translateY(0.5rem)" : "translateY(0)",
     860         210 :         }}
     861             :       >
     862         210 :         <button
     863         210 :           className="filter-toggle"
     864         210 :           data-testid="filter-toggle"
     865         210 :           aria-label="Toggle filters"
     866         210 :           onClick={toggleFilter}
     867             :         >
     868         210 :           <svg
     869         210 :             width="20"
     870         210 :             height="20"
     871         210 :             viewBox="0 0 20 20"
     872         210 :             fill="none"
     873         210 :             xmlns="http://www.w3.org/2000/svg"
     874             :           >
     875         210 :             <rect
     876         210 :               y="4"
     877         210 :               width="20"
     878         210 :               height="2"
     879         210 :               rx="1"
     880         210 :               fill="#bdc3c7"
     881         210 :               className="filter-line"
     882         210 :             />
     883         210 :             <rect
     884         210 :               y="9"
     885         210 :               width="20"
     886         210 :               height="2"
     887         210 :               rx="1"
     888         210 :               fill="#bdc3c7"
     889         210 :               className="filter-line"
     890         210 :             />
     891         210 :             <rect
     892         210 :               y="14"
     893         210 :               width="20"
     894         210 :               height="2"
     895         210 :               rx="1"
     896         210 :               fill="#bdc3c7"
     897         210 :               className="filter-line"
     898         210 :             />
     899         210 :           </svg>
     900         210 :         </button>
     901         210 :       </div>
     902             : 
     903         210 :       {loading || isFetching ? (
     904          79 :         <div
     905          79 :           style={{
     906          79 :             display: "flex",
     907          79 :             flexDirection: "column",
     908          79 :             alignItems: "center",
     909          79 :             justifyContent: "center",
     910          79 :             minHeight: "50vh",
     911          79 :           }}
     912             :         >
     913          79 :           <div
     914          79 :             style={{
     915          79 :               border: "4px solid #ecf0f1",
     916          79 :               borderTop: "4px solid #3498db",
     917          79 :               borderRadius: "50%",
     918          79 :               width: "40px",
     919          79 :               height: "40px",
     920          79 :               animation: "spin 1s linear infinite",
     921          79 :             }}
     922          79 :           ></div>
     923          79 :           <p
     924          79 :             style={{
     925          79 :               marginTop: "1rem",
     926          79 :               fontSize: "1.2em",
     927          79 :               color: "#7f8c8d",
     928          79 :               fontWeight: "600",
     929          79 :             }}
     930          79 :           ></p>
     931          79 :         </div>
     932         131 :       ) : applied ? (
     933         107 :         <div style={{ width: "100%", maxWidth: "1200px", margin: "0 auto" }}>
     934         107 :           <h2
     935         107 :             style={{
     936         107 :               textAlign: "center",
     937         107 :               color: "#141820",
     938         107 :               marginBottom: "1.0rem",
     939         107 :               marginTop: "4rem",
     940         107 :             }}
     941             :           >
     942         107 :             {selectionMode
     943          73 :               ? "Export to Spotify"
     944          34 :               : (selectedSubgenre !== "any"
     945           0 :                   ? selectedSubgenre
     946          34 :                   : selectedGenre !== "any"
     947           2 :                   ? selectedGenre
     948          34 :                   : "") + " Rankings"}
     949         107 :           </h2>
     950             : 
     951             :           {/* Live selection summary (AC-03.2) */}
     952         107 :           {selectionMode && (
     953          73 :             <div
     954          73 :               data-testid="selection-summary"
     955             :               /* also expose a stable 'selected-count' node the tests can read directly */
     956          73 :               aria-live="polite"
     957          73 :               data-count={selected?.size ?? 0}
     958          73 :               style={{
     959          73 :                 textAlign: "center",
     960          73 :                 fontSize: "0.95rem",
     961          73 :                 color: "#7f8c8d",
     962          73 :                 marginTop: "-0.5rem",
     963          73 :                 marginBottom: "0.75rem",
     964          73 :               }}
     965          73 :             >
     966          73 :               Selected: {selected?.size ?? 0}
     967          73 :             </div>
     968             :           )}
     969             : 
     970             :           {/* Inline selection controls / CTA */}
     971         107 :           <div
     972         107 :             style={{
     973         107 :               display: "flex",
     974         107 :               justifyContent: "center",
     975         107 :               marginBottom: "1.25rem",
     976         107 :               gap: "0.75rem",
     977         107 :             }}
     978             :           >
     979         107 :             {!selectionMode ? (
     980          34 :               <button
     981          34 :                 onClick={onExportClick}
     982          34 :                 data-testid="export-spotify-cta"
     983             :                 /* alias for tests that expect 'enter-selection' */
     984          34 :                 aria-describedby="enter-selection"
     985          34 :                 aria-label="Export ranked songs to Spotify"
     986          34 :                 style={{
     987          34 :                   padding: "0.6rem 1rem",
     988          34 :                   fontWeight: 600,
     989          34 :                   borderRadius: 8,
     990          34 :                   border: "1px solid #3498db",
     991          34 :                 }}
     992          34 :               >
     993             :                 Export to Spotify
     994          34 :               </button>
     995             :             ) : (
     996          73 :               <form
     997          73 :                 onSubmit={(e) => {
     998           8 :                   e.preventDefault();
     999           8 :                   if (!zeroSelected) doExport();
    1000           8 :                 }}
    1001          73 :                 data-testid="selection-mode-root"
    1002          73 :                 style={{
    1003          73 :                   display: "grid",
    1004          73 :                   gridTemplateColumns: "1fr 1fr auto auto",
    1005          73 :                   gap: "0.5rem",
    1006          73 :                   alignItems: "center",
    1007          73 :                   width: "100%",
    1008          73 :                   maxWidth: 900,
    1009          73 :                 }}
    1010             :               >
    1011          73 :                 <input
    1012          73 :                   type="text"
    1013          73 :                   name="playlistName"
    1014          73 :                   placeholder="Playlist name"
    1015          73 :                   value={playlistName}
    1016          73 :                   onChange={(e) => setPlaylistName(e.target.value)}
    1017          73 :                   aria-label="Playlist name"
    1018          73 :                   data-testid="playlist-name"
    1019          73 :                   style={{
    1020          73 :                     padding: "0.5rem",
    1021          73 :                     borderRadius: 8,
    1022          73 :                     border: "1px solid #ddd",
    1023          73 :                   }}
    1024          73 :                 />
    1025          73 :                 <textarea
    1026          73 :                   name="playlistDescription"
    1027          73 :                   placeholder="Description (optional)"
    1028          73 :                   value={playlistDescription}
    1029          73 :                   onChange={(e) => setPlaylistDescription(e.target.value)}
    1030          73 :                   aria-label="Description"
    1031          73 :                   rows={1}
    1032          73 :                   data-testid="playlist-description"
    1033          73 :                   style={{
    1034          73 :                     padding: "0.5rem",
    1035          73 :                     borderRadius: 8,
    1036          73 :                     border: "1px solid #ddd",
    1037          73 :                     resize: "vertical",
    1038          73 :                   }}
    1039          73 :                 />
    1040             : 
    1041             :                 {/* Empty-selection hint */}
    1042          73 :                 {zeroSelected && (
    1043           8 :                   <p
    1044           8 :                     data-testid="export-hint-empty"
    1045           8 :                     role="alert"
    1046           8 :                     aria-live="polite"
    1047           8 :                     style={{
    1048           8 :                       margin: 0,
    1049           8 :                       fontSize: "0.9rem",
    1050           8 :                       opacity: 0.8,
    1051           8 :                       justifySelf: "end",
    1052           8 :                     }}
    1053           8 :                   >
    1054             :                     Select at least one song to export.
    1055           8 :                   </p>
    1056             :                 )}
    1057             : 
    1058          73 :                 <button
    1059          73 :                   type="submit"
    1060          73 :                   data-testid="export-confirm"
    1061          73 :                   disabled={
    1062          73 :                     zeroSelected ||
    1063          65 :                     exporting ||
    1064          58 :                     exportState === ExportState.Success
    1065             :                   }
    1066          73 :                   style={{
    1067          73 :                     padding: "0.6rem 1rem",
    1068          73 :                     fontWeight: 600,
    1069          73 :                     borderRadius: 8,
    1070          73 :                     border: "1px solid #2ecc71",
    1071          73 :                     opacity:
    1072          73 :                       zeroSelected ||
    1073          65 :                       exporting ||
    1074          58 :                       exportState === ExportState.Success
    1075          21 :                         ? 0.6
    1076          52 :                         : 1,
    1077          73 :                     cursor:
    1078          73 :                       zeroSelected ||
    1079          65 :                       exporting ||
    1080          58 :                       exportState === ExportState.Success
    1081          21 :                         ? "not-allowed"
    1082          52 :                         : "pointer",
    1083          73 :                   }}
    1084             :                 >
    1085          73 :                   {exporting ? "Exporting…" : "Export"}
    1086          73 :                 </button>
    1087             : 
    1088          73 :                 <button
    1089          73 :                   type="button"
    1090          73 :                   onClick={onCancelSelection}
    1091          73 :                   data-testid="export-cancel"
    1092             :                   /* alias for tests that expect 'exit-selection' */
    1093          73 :                   aria-describedby="exit-selection"
    1094          73 :                   aria-label="Cancel selection mode"
    1095          73 :                   style={{
    1096          73 :                     padding: "0.6rem 1rem",
    1097          73 :                     borderRadius: 8,
    1098          73 :                     border: "1px solid #aaa",
    1099          73 :                   }}
    1100          73 :                 >
    1101             :                   Cancel
    1102          73 :                 </button>
    1103          73 :               </form>
    1104             :             )}
    1105         107 :           </div>
    1106             : 
    1107             :           {/* Progress readout — only during in-flight */}
    1108         107 :           {selectionMode &&
    1109          73 :             (exportState === ExportState.Validating ||
    1110          73 :               exportState === ExportState.Creating ||
    1111          66 :               exportState === ExportState.Adding) && (
    1112           7 :               <div
    1113           7 :                 data-testid="export-progress"
    1114           7 :                 style={{
    1115           7 :                   textAlign: "center",
    1116           7 :                   marginTop: "-0.5rem",
    1117           7 :                   marginBottom: "0.75rem",
    1118           7 :                   color: "#7f8c8d",
    1119           7 :                 }}
    1120             :               >
    1121           7 :                 {exportState === ExportState.Validating && "Validating…"}
    1122           7 :                 {exportState === ExportState.Creating && "Creating playlist…"}
    1123           7 :                 {exportState === ExportState.Adding && "Adding tracks…"}
    1124           7 :               </div>
    1125             :             )}
    1126             : 
    1127             :           {/* Error banner + Retry (E2E-004 depends on these test ids) */}
    1128         107 :           {selectionMode && exportState === ExportState.Error && (
    1129           2 :             <div
    1130           2 :               data-testid="export-error"
    1131           2 :               role="alert"
    1132           2 :               aria-live="assertive"
    1133           2 :               style={{
    1134           2 :                 textAlign: "center",
    1135           2 :                 marginTop: "-0.25rem",
    1136           2 :                 marginBottom: "0.75rem",
    1137           2 :                 color: "#e74c3c",
    1138           2 :                 background: "rgba(231, 76, 60, 0.08)",
    1139           2 :                 padding: "0.5rem 0.75rem",
    1140           2 :                 borderRadius: 8,
    1141           2 :                 border: "1px solid rgba(231, 76, 60, 0.35)",
    1142           2 :               }}
    1143             :             >
    1144           2 :               <strong style={{ marginRight: 6 }}>Export failed:</strong>
    1145           2 :               <span>
    1146           2 :                 {String(
    1147           2 :                   exportError || "Something went wrong — please try again."
    1148           2 :                 )}
    1149           2 :               </span>
    1150           2 :               <div style={{ marginTop: "0.5rem" }}>
    1151           2 :                 <button
    1152           2 :                   data-testid="export-retry"
    1153           2 :                   onClick={() => !exporting && !zeroSelected && doExport()}
    1154           2 :                   style={{
    1155           2 :                     padding: "0.4rem 0.8rem",
    1156           2 :                     borderRadius: 8,
    1157           2 :                     border: "1px solid #e74c3c",
    1158           2 :                   }}
    1159           2 :                 >
    1160             :                   Retry
    1161           2 :                 </button>
    1162           2 :               </div>
    1163           2 :             </div>
    1164             :           )}
    1165             : 
    1166         107 :           {exportSuccessUrl && (
    1167           7 :             <p style={{ textAlign: "center", marginBottom: "1rem" }}>
    1168           7 :               Playlist created:{" "}
    1169           7 :               {(() => {
    1170           7 :                 const links = buildDeepLink(null, exportSuccessUrl);
    1171           7 :                 return (
    1172           7 :                   <a
    1173           7 :                     href={links.web}
    1174           7 :                     target="_blank"
    1175           7 :                     rel="noopener noreferrer"
    1176           7 :                     data-testid="export-success-link"
    1177           7 :                   >
    1178             :                     Open in Spotify
    1179           7 :                   </a>
    1180             :                 );
    1181           7 :               })()}
    1182           7 :             </p>
    1183             :           )}
    1184             : 
    1185         107 :           {filteredSongs.length === 0 ? (
    1186           6 :             <p
    1187           6 :               style={{
    1188           6 :                 textAlign: "center",
    1189           6 :                 fontSize: "1.2em",
    1190           6 :                 color: "#7f8c8d",
    1191           6 :               }}
    1192           6 :             >
    1193             :               No ranked songs yet for this filter.
    1194           6 :             </p>
    1195             :           ) : (
    1196         101 :             <ul
    1197         101 :               style={{
    1198         101 :                 listStyle: "none",
    1199         101 :                 padding: 0,
    1200         101 :                 display: "grid",
    1201         101 :                 gap: "1.5rem",
    1202         101 :                 gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
    1203         101 :                 width: "100%",
    1204         101 :               }}
    1205             :             >
    1206         101 :               {sortedSongs.map((song, index) => {
    1207         251 :                 const k = stableKey(song);
    1208         251 :                 const isChecked = selected.has(k);
    1209         251 :                 return (
    1210         251 :                   <li
    1211         251 :                     data-testid="song-card"
    1212         251 :                     key={k}
    1213         251 :                     className="song-box"
    1214         251 :                     style={{
    1215         251 :                       background: "white",
    1216         251 :                       borderRadius: "12px",
    1217         251 :                       padding: "1.5rem",
    1218         251 :                       boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
    1219         251 :                       display: "flex",
    1220         251 :                       alignItems: "center",
    1221         251 :                       gap: "1rem",
    1222         251 :                       position: "relative",
    1223         251 :                     }}
    1224             :                   >
    1225             :                     {/* Inline selection checkbox (left side) */}
    1226         251 :                     {selectionMode && (
    1227         175 :                       <input
    1228         175 :                         type="checkbox"
    1229         175 :                         data-testid={`song-checkbox-${k}`}
    1230         175 :                         checked={isChecked}
    1231         175 :                         onChange={(e) => {
    1232          25 :                           const next = new Set(selected);
    1233          25 :                           if (e.target.checked) next.add(k);
    1234          20 :                           else next.delete(k);
    1235          25 :                           setSelected(next);
    1236          25 :                         }}
    1237         175 :                         aria-label={`Select ${song.songName} by ${song.artist}`}
    1238         175 :                         style={{ transform: "scale(1.2)" }}
    1239         175 :                       />
    1240             :                     )}
    1241         251 :                     <span
    1242         251 :                       style={{
    1243         251 :                         fontSize: "1.5rem",
    1244         251 :                         fontWeight: "700",
    1245         251 :                         color: "#3498db",
    1246         251 :                         minWidth: "2rem",
    1247         251 :                         textAlign: "center",
    1248         251 :                       }}
    1249             :                     >
    1250         251 :                       {rankPositions[index]}
    1251         251 :                     </span>
    1252             : 
    1253         251 :                     <img
    1254         251 :                       src={song.albumCover || "/placeholder-cover.png"}
    1255         251 :                       alt="Album Cover"
    1256         251 :                       style={{
    1257         251 :                         width: "80px",
    1258         251 :                         height: "80px",
    1259         251 :                         borderRadius: "8px",
    1260         251 :                       }}
    1261         251 :                     />
    1262             : 
    1263         251 :                     <div style={{ flex: 1 }}>
    1264         251 :                       <p
    1265         251 :                         style={{
    1266         251 :                           fontSize: "1.1rem",
    1267         251 :                           fontWeight: "600",
    1268         251 :                           color: "#141820",
    1269         251 :                           margin: "0",
    1270         251 :                         }}
    1271             :                       >
    1272         251 :                         {song.songName}
    1273         251 :                       </p>
    1274         251 :                       <p
    1275         251 :                         style={{
    1276         251 :                           fontSize: "1rem",
    1277         251 :                           color: "#7f8c8d",
    1278         251 :                           margin: "0.25rem 0",
    1279         251 :                         }}
    1280             :                       >
    1281         251 :                         {song.artist}
    1282         251 :                       </p>
    1283         251 :                       <p
    1284         251 :                         style={{
    1285         251 :                           fontSize: "0.9rem",
    1286         251 :                           color: "#3498db",
    1287         251 :                           margin: "0",
    1288         251 :                         }}
    1289         251 :                       >
    1290         251 :                         Score: {song.ranking}
    1291         251 :                       </p>
    1292             : 
    1293         251 :                       {song.previewURL && isPreviewValid(song.previewURL) ? (
    1294         149 :                         <>
    1295         149 :                           <audio
    1296         149 :                             ref={(el) => {
    1297         286 :                               if (el) audioRefs.current.set(k, el);
    1298         143 :                               else audioRefs.current.delete(k);
    1299         286 :                             }}
    1300         149 :                             controls
    1301         149 :                             src={song.previewURL}
    1302         149 :                             className="custom-audio-player"
    1303         149 :                             style={{ marginTop: "0.5rem" }}
    1304         149 :                             onVolumeChange={(e) => setVolume(e.target.volume)}
    1305         149 :                             onPlay={(e) => {
    1306           0 :                               if (
    1307           0 :                                 playingAudioRef &&
    1308           0 :                                 playingAudioRef !== e.target
    1309           0 :                               ) {
    1310           0 :                                 playingAudioRef.pause();
    1311           0 :                               }
    1312           0 :                               setPlayingAudioRef(e.target);
    1313           0 :                             }}
    1314         149 :                             onError={(e) => {
    1315           0 :                               const { ttl, exp, now } = parsePreviewExpiry(
    1316           0 :                                 song.previewURL
    1317           0 :                               );
    1318           0 :                               console.log(
    1319           0 :                                 "[Rankings] <audio> onError → rehydrate (likely expired / fetch fail)",
    1320           0 :                                 {
    1321           0 :                                   name: song.songName,
    1322           0 :                                   artist: song.artist,
    1323           0 :                                   deezerID: song.deezerID,
    1324           0 :                                   ttl,
    1325           0 :                                   exp,
    1326           0 :                                   now,
    1327           0 :                                 }
    1328           0 :                               );
    1329           0 :                               e.currentTarget.style.display = "none";
    1330           0 :                               const overlay =
    1331           0 :                                 e.currentTarget.nextElementSibling;
    1332           0 :                               if (overlay) overlay.style.display = "block";
    1333           0 :                               rehydrateSong(song);
    1334           0 :                             }}
    1335         149 :                             onCanPlay={(e) => {
    1336           0 :                               console.log("[Rankings] <audio> onCanPlay", {
    1337           0 :                                 name: song.songName,
    1338           0 :                                 artist: song.artist,
    1339           0 :                                 ttl: parsePreviewExpiry(song.previewURL),
    1340           0 :                               });
    1341           0 :                               e.currentTarget.style.display = "block";
    1342           0 :                               const overlay =
    1343           0 :                                 e.currentTarget.nextElementSibling;
    1344           0 :                               if (overlay) overlay.style.display = "none";
    1345           0 :                             }}
    1346         149 :                           />
    1347             :                           {/* overlay during rehydrate */}
    1348         149 :                           <span
    1349         149 :                             style={{
    1350         149 :                               display: "none",
    1351         149 :                               color: "#e74c3c",
    1352         149 :                               background: "rgba(255,255,255,0.9)",
    1353         149 :                               padding: "0.25rem 0.5rem",
    1354         149 :                               borderRadius: "6px",
    1355         149 :                               fontSize: "0.9rem",
    1356         149 :                               position: "absolute",
    1357         149 :                               top: "50%",
    1358         149 :                               left: "50%",
    1359         149 :                               transform: "translate(-50%, -50%)",
    1360         149 :                               boxShadow: "0 2px 6px rgba(0,0,0,0.08)",
    1361         149 :                             }}
    1362         149 :                           >
    1363             :                             Refreshing preview…
    1364         149 :                           </span>
    1365         149 :                         </>
    1366             :                       ) : (
    1367         102 :                         <span
    1368         102 :                           style={{
    1369         102 :                             display: "block",
    1370         102 :                             color: "#e74c3c",
    1371         102 :                             fontSize: "0.9rem",
    1372         102 :                             marginTop: "0.5rem",
    1373         102 :                           }}
    1374             :                         >
    1375             :                           {/* No preview available */}
    1376         102 :                         </span>
    1377             :                       )}
    1378         251 :                     </div>
    1379         251 :                   </li>
    1380             :                 );
    1381         101 :               })}
    1382         101 :             </ul>
    1383             :           )}
    1384         107 :         </div>
    1385             :       ) : (
    1386          24 :         <div
    1387          24 :           style={{
    1388          24 :             display: "flex",
    1389          24 :             flexDirection: "column",
    1390          24 :             alignItems: "center",
    1391          24 :             justifyContent: "center",
    1392          24 :             minHeight: "50vh",
    1393          24 :           }}
    1394             :         >
    1395          24 :           <div
    1396          24 :             style={{
    1397          24 :               border: "4px solid #ecf0f1",
    1398          24 :               borderTop: "4px solid #3498db",
    1399          24 :               borderRadius: "50%",
    1400          24 :               width: "40px",
    1401          24 :               height: "40px",
    1402          24 :               animation: "spin 1s linear infinite",
    1403          24 :             }}
    1404          24 :           ></div>
    1405          24 :           <p
    1406          24 :             style={{
    1407          24 :               marginTop: "1rem",
    1408          24 :               fontSize: "1.2em",
    1409          24 :               color: "#7f8c8d",
    1410          24 :               fontWeight: "600",
    1411          24 :             }}
    1412          24 :           >
    1413             :             Loading user data...
    1414          24 :           </p>
    1415          24 :         </div>
    1416             :       )}
    1417         210 :     </div>
    1418             :   );
    1419         210 : };
    1420             : 
    1421           1 : export default Rankings;

Generated by: LCOV version 1.15.alpha0w