Volumer: SoundCloud Dashboard

Sleek SoundCloud dashboard: auto client_id, search, favorites, history, and thumbnail fixes. Opens tracks in new tabs

// ==UserScript==
// @name         Volumer: SoundCloud Dashboard
// @namespace    https://twisk.fun/
// @version      1.0
// @description  Sleek SoundCloud dashboard: auto client_id, search, favorites, history, and thumbnail fixes. Opens tracks in new tabs
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      soundcloud.com
// @connect      www.soundcloud.com
// @connect      api-v2.soundcloud.com
// @connect      raw.githubusercontent.com
// @connect      a-v2.sndcdn.com
// @connect      i1.sndcdn.com
// @connect      i2.sndcdn.com
// @connect      cdn.sndcdn.com
// @connect      media.soundcloud.com
// @icon https://github.com/pillowslua/crackduo/blob/main/Untitled%20design.png?raw=true
// @license MIT
// @author Airplane Modz
// ==/UserScript==

(function () {
  'use strict';

  /* ============================
     Helper wrappers for GM_xmlhttpRequest
     - gmRequest: text / json
     - gmRequestBinary: returns Blob
     ============================ */
  function gmRequest(url, options = {}) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: options.method || 'GET',
        url,
        headers: options.headers || {},
        data: options.body || null,
        responseType: options.responseType || 'text',
        onload(res) {
          if (res.status >= 200 && res.status < 400) resolve(res);
          else reject(new Error('HTTP ' + res.status + ' ' + url));
        },
        onerror(err) { reject(err); },
        ontimeout() { reject(new Error('Timeout ' + url)); }
      });
    });
  }

  async function gmFetchJson(url, options = {}) {
    const res = await gmRequest(url, options);
    try { return JSON.parse(res.responseText); }
    catch (e) { throw new Error('Invalid JSON from ' + url); }
  }

  async function gmRequestBinary(url) {
    // returns Blob
    const res = await gmRequest(url, { responseType: 'arraybuffer' });
    const buffer = res.response;
    // Build blob. Try to get content-type header if available
    let contentType = 'image/jpeg';
    if (res.responseHeaders) {
      const m = /content-type:\s*([^\r\n;]+)/i.exec(res.responseHeaders);
      if (m) contentType = m[1].trim();
    }
    try {
      const blob = new Blob([buffer], { type: contentType });
      return blob;
    } catch (e) {
      // fallback: create blob without type
      return new Blob([buffer]);
    }
  }

  /* ============================
     Storage keys + defaults
     ============================ */
  const KEYS = {
    CLIENT_ID: 'sc_client_id_v1',
    THEME: 'sc_theme_v1',
    SONGS: 'sc_songs_v1',
    USERS: 'sc_users_v1',
    SESSION: 'sc_session_v1'
  };

  const DEFAULT_RAW = [
    "https://soundcloud.com/forss/flickermood",
    "https://soundcloud.com/odesza/a-moment-apart",
    "https://soundcloud.com/madeon/imperium"
  ];

  /* ============================
     Auto fetch client_id (persist)
     ============================ */
  async function autoFetchClientId(force = false) {
    const cached = localStorage.getItem(KEYS.CLIENT_ID);
    if (cached && !force) return cached;

    showStatus('Fetching SoundCloud client_id...');
    try {
      const homepage = (await gmRequest('https://soundcloud.com')).responseText;
      const jsMatches = Array.from(homepage.matchAll(/<script[^>]+src="([^"]+\.js)"/g)).map(m => m[1]);
      const uniq = [...new Set(jsMatches)].map(s => s.startsWith('http') ? s : ('https://soundcloud.com' + s));
      for (const url of uniq) {
        try {
          const res = await gmRequest(url);
          const code = res.responseText;
          let m = code.match(/client_id["']?\s*[:=]\s*["']([a-zA-Z0-9-_]{16,})["']/) ||
                  code.match(/client_id\s*:\s*["']([a-zA-Z0-9-_]{16,})["']/) ||
                  code.match(/client_id=([a-zA-Z0-9-_]{16,})/);
          if (m && m[1]) {
            localStorage.setItem(KEYS.CLIENT_ID, m[1]);
            showStatus('client_id obtained');
            setTimeout(hideStatus, 600);
            return m[1];
          }
        } catch (e) {
          // ignore script fetch errors
        }
      }
      throw new Error('client_id not found');
    } catch (e) {
      hideStatus();
      throw e;
    }
  }

  async function ensureClientId() {
    let cid = localStorage.getItem(KEYS.CLIENT_ID);
    if (cid) return cid;
    try {
      cid = await autoFetchClientId(false);
      return cid;
    } catch (e) {
      // ask manual fallback
      const manual = prompt('Auto-fetch client_id failed. Paste SoundCloud client_id (from DevTools):');
      if (manual && manual.trim().length >= 8) { localStorage.setItem(KEYS.CLIENT_ID, manual.trim()); return manual.trim(); }
      throw new Error('No client_id available');
    }
  }

  /* ============================
     Helpers UI: status spinner
     ============================ */
  let statusEl = null;
  function showStatus(text) {
    if (!statusEl) {
      statusEl = document.createElement('div');
      statusEl.style = 'position:fixed;right:18px;top:18px;padding:8px 12px;background:#111;color:#fff;border-radius:8px;z-index:9999999;font-family:sans-serif;box-shadow:0 6px 20px rgba(0,0,0,0.3)';
      document.body.appendChild(statusEl);
      if (!document.getElementById('scdash-spin-style')) {
        const s = document.createElement('style'); s.id = 'scdash-spin-style';
        s.textContent = '@keyframes scdashspin{to{transform:rotate(360deg);}} .scdash-spinner{display:inline-block;width:14px;height:14px;border-radius:50%;border:2px solid rgba(255,255,255,.18);border-top-color:#fff;animation:scdashspin 1s linear infinite;}';
        document.head.appendChild(s);
      }
    }
    statusEl.innerHTML = `<span style="margin-right:8px">${text}</span><span class="scdash-spinner"></span>`;
  }
  function hideStatus() { if (statusEl) { statusEl.remove(); statusEl = null; } }

  /* ============================
     UI skeleton + styles
     ============================ */
  GM_addStyle(`
    .scdash-openbtn{position:fixed;left:18px;bottom:18px;z-index:2147483646;background:#ff5500;color:#fff;border:none;padding:10px 14px;border-radius:24px;box-shadow:0 6px 18px rgba(0,0,0,.25);cursor:pointer;font-weight:700;font-family:Inter,system-ui,Arial;}
    .scdash-modal{position:fixed;top:60px;left:60px;width:820px;height:640px;background:var(--bg);color:var(--fg);z-index:2147483646;border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.4);display:flex;flex-direction:column;resize:both;overflow:hidden;font-family:Inter,system-ui,Arial}
    .scdash-header{display:flex;align-items:center;gap:12px;padding:12px 16px;background:linear-gradient(90deg,#ff7a2f,#ff4f00);color:#fff}
    .scdash-title{font-size:18px;font-weight:800}
    .scdash-body{display:flex;flex:1;overflow:hidden}
    .scdash-left{width:62%;padding:12px;overflow:auto;background:rgba(255,255,255,0.03)}
    .scdash-right{width:38%;padding:12px;overflow:auto;background:#fbfbfc;border-left:1px solid rgba(0,0,0,0.04)}
    .scdash-controls{margin-left:auto;display:flex;gap:8px}
    .scdash-input{width:100%;padding:8px;border-radius:8px;border:1px solid #ddd;margin-bottom:8px}
    .scdash-btn{background:#3182ce;color:#fff;border:none;padding:8px 10px;border-radius:8px;cursor:pointer}
    .sc-track{display:flex;gap:12px;padding:10px;border-radius:8px;margin-bottom:10px;background:#fff;align-items:center;box-shadow:0 2px 6px rgba(0,0,0,.03)}
    .sc-art{width:72px;height:72px;border-radius:8px;background:#eee;overflow:hidden;flex:none;display:flex;align-items:center;justify-content:center}
    .sc-meta{flex:1;min-width:0}
    .sc-title{font-weight:700;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .sc-author{color:#666;font-size:13px;margin-top:4px}
    .sc-actions{display:flex;gap:8px;align-items:center}
    .sc-playbtn{background:#ff5500;color:#fff;border:none;padding:6px 10px;border-radius:8px;cursor:pointer}
    .sc-small{font-size:12px;color:#666}
    .sc-player-area{margin-bottom:12px;background:#fff;border-radius:8px;padding:8px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
    .sc-empty{color:#888;padding:10px;text-align:center}
    .sc-history-item{padding:8px;border-radius:8px;margin-bottom:8px;background:#fff;box-shadow:0 2px 6px rgba(0,0,0,.03)}
    .sc-tabbtn{padding:6px 8px;border-radius:8px;border:1px solid #ddd;cursor:pointer;background:#fff}
    .sc-tabbtn.active{background:#3182ce;color:#fff}
    .sc-search-row{display:flex;gap:8px;margin-bottom:8px}
    :root.sc-dark{--bg:#131313;--fg:#eee}
    :root{--bg:#fff;--fg:#111}
    .scdash-modal{background:var(--bg);color:var(--fg);}
    @media (max-width:900px){ .scdash-modal{width:92%;height:80%} }
  `);

  /* Open button */
  const openBtn = document.createElement('button');
  openBtn.className = 'scdash-openbtn';
  openBtn.textContent = 'SoundCloud Dashboard';
  document.body.appendChild(openBtn);

  /* Save/Restore panel pos/size */
  function savePanelState(panel) {
    try {
      localStorage.setItem('sc_panel_state', JSON.stringify({ left: panel.style.left, top: panel.style.top, width: panel.style.width, height: panel.style.height }));
    } catch (e) {}
  }
  function restorePanelState(panel) {
    try {
      const st = JSON.parse(localStorage.getItem('sc_panel_state') || 'null');
      if (!st) return;
      if (st.left) panel.style.left = st.left;
      if (st.top) panel.style.top = st.top;
      if (st.width) panel.style.width = st.width;
      if (st.height) panel.style.height = st.height;
    } catch (e) {}
  }

  /* ============================
     Business state (users, songs, favs, history)
  ============================ */
  function getUsers() { return JSON.parse(localStorage.getItem(KEYS.USERS) || '{}'); }
  function saveUsers(u) { localStorage.setItem(KEYS.USERS, JSON.stringify(u)); }
  function getSessionUser() { return sessionStorage.getItem(KEYS.SESSION) || null; }
  function setSessionUser(u) { if (u) sessionStorage.setItem(KEYS.SESSION, u); else sessionStorage.removeItem(KEYS.SESSION); }

  function favKey(user) { return 'sc_favs_' + (user || 'public'); }
  function histKey(user) { return 'sc_hist_' + (user || 'public'); }
  function getFavs(user) { return JSON.parse(localStorage.getItem(favKey(user)) || '[]'); }
  function saveFavs(user, arr) { localStorage.setItem(favKey(user), JSON.stringify(arr)); }
  function getHist(user) { return JSON.parse(localStorage.getItem(histKey(user)) || '[]'); }
  function saveHist(user, arr) { localStorage.setItem(histKey(user), JSON.stringify(arr)); }

  function getSongsRaw() { try { return JSON.parse(localStorage.getItem(KEYS.SONGS)) || DEFAULT_RAW.slice(); } catch(e) { return DEFAULT_RAW.slice(); } }
  function saveSongsRaw(arr) { localStorage.setItem(KEYS.SONGS, JSON.stringify(arr)); }

  /* ============================
     Modal creation + behavior
  ============================ */
  let modal = null;
  async function openModal() {
    if (modal) return;
    // ensure client id is fetched before open (as requested)
    try { showStatus('Preparing SoundCloud (fetch client_id)...'); await ensureClientId(); showStatus('Ready'); setTimeout(hideStatus, 500); } catch (e) { hideStatus(); if (!confirm('Auto-fetch client_id failed: '+e.message+'\nContinue without client_id (Search disabled)?')) return; }

    modal = document.createElement('div'); modal.className = 'scdash-modal';
    modal.style.left = '60px'; modal.style.top = '60px';
    modal.innerHTML = `
      <div class="scdash-header">
        <div class="scdash-title">Recommend Songs</div>
        <div class="scdash-controls">
          <button id="sc-refresh-client" class="scdash-btn" title="Refetch client_id">Refresh client_id</button>
          <button id="sc-theme-toggle" class="scdash-btn">Theme</button>
          <button id="sc-join-discord" class="scdash-btn">Join Discord Server</button>
          <button id="sc-close" class="scdash-btn">Close</button>
        </div>
      </div>
      <div class="scdash-body">
        <div class="scdash-left">
          <div style="display:flex;gap:8px;margin-bottom:8px;align-items:center">
            <input id="sc-remote-url" class="scdash-input" placeholder="Remote raw file URL (raw.githubusercontent.com or public raw)">
            <button id="sc-load-remote" class="scdash-btn">Load</button>
          </div>
          <div class="sc-search-row">
            <input id="sc-search-input" class="scdash-input" placeholder="Search SoundCloud (press Enter)" />
            <button id="sc-search-btn" class="scdash-btn">Search</button>
          </div>
          <div id="sc-player-area" class="sc-player-area">
            <div id="sc-embed-holder" class="sc-empty">No track selected</div>
          </div>
          <div style="display:flex;align-items:center;justify-content:space-between">
            <div style="font-weight:700">Recommend Songs</div>
            <div><span id="sc-total">0</span> items</div>
          </div>
          <div id="sc-list" style="margin-top:8px;overflow:auto;max-height:330px"></div>
        </div>
        <div class="scdash-right">
          <div style="margin-bottom:8px">
            <div style="font-weight:800;margin-bottom:6px">Account</div>
            <div id="sc-account-area"></div>
          </div>
          <div style="margin-top:8px">
            <div style="font-weight:800;margin-bottom:6px">Favorites</div>
            <div id="sc-fav-list" style="max-height:220px;overflow:auto"></div>
            <hr style="margin:12px 0">
            <div style="font-weight:800;margin-bottom:6px">History</div>
            <div id="sc-history-list" style="max-height:220px;overflow:auto"></div>
          </div>
        </div>
      </div>
    `;
    document.body.appendChild(modal);
    restorePanelState(modal);

    // draggable header
    const header = modal.querySelector('.scdash-header');
    let dragging=false, ox=0, oy=0;
    header.addEventListener('mousedown', e=>{ dragging=true; ox=e.clientX-modal.offsetLeft; oy=e.clientY-modal.offsetTop; });
    document.addEventListener('mousemove', e=>{ if (!dragging) return; modal.style.left=(e.clientX-ox)+'px'; modal.style.top=(e.clientY-oy)+'px'; });
    document.addEventListener('mouseup', ()=>{ if (dragging) { dragging=false; savePanelState(modal); } });

    // close
    modal.querySelector('#sc-close').addEventListener('click', ()=>{ modal.remove(); modal=null; });

    // theme toggle
    const curTheme = localStorage.getItem(KEYS.THEME) || 'light';
    applyTheme(curTheme);
    modal.querySelector('#sc-theme-toggle').addEventListener('click', ()=> {
      const t = (localStorage.getItem(KEYS.THEME) || 'light') === 'dark' ? 'light' : 'dark';
      applyTheme(t);
    });

    // join discord
    modal.querySelector('#sc-join-discord').addEventListener('click', () => {
      window.open('https://discord.gg/m3EV55SpYw', '_blank');
    });

    // refresh client id
    modal.querySelector('#sc-refresh-client').addEventListener('click', async ()=> {
      try { showStatus('Refetching client_id...'); await autoFetchClientId(true); alert('client_id refreshed'); } catch(e) { alert('Refetch failed: '+e.message); } finally { hideStatus(); }
    });

    // account area
    renderAccountArea();

    // load remote file
    modal.querySelector('#sc-load-remote').addEventListener('click', async ()=>{
      const url = modal.querySelector('#sc-remote-url').value.trim();
      if (!url) return alert('Paste remote raw file URL first.');
      try {
        showStatus('Loading remote file...');
        const res = await gmRequest(url);
        const lines = res.responseText.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
        saveSongsRaw(lines);
        renderSongsList();
        hideStatus();
        alert('Loaded ' + lines.length + ' links.');
      } catch (e) { hideStatus(); alert('Failed to load: ' + e.message); }
    });

    // search handlers
    modal.querySelector('#sc-search-input').addEventListener('keydown', async (e)=>{
      if (e.key === 'Enter') {
        const q = e.target.value.trim(); if (!q) return;
        try { showStatus('Searching...'); const results = await scSearch(q); renderSearchResults(results); } catch (err) { alert('Search error: '+err.message); } finally { hideStatus(); }
      }
    });
    modal.querySelector('#sc-search-btn').addEventListener('click', async ()=>{
      const q = modal.querySelector('#sc-search-input').value.trim(); if (!q) return;
      try { showStatus('Searching...'); const results = await scSearch(q); renderSearchResults(results); } catch (err) { alert('Search error: '+err.message); } finally { hideStatus(); }
    });

    // initial content
    renderSongsList();
    renderFavHistory();
  }

  function applyTheme(t) { localStorage.setItem(KEYS.THEME, t); if (t === 'dark') document.documentElement.classList.add('sc-dark'); else document.documentElement.classList.remove('sc-dark'); }

  /* ============================
     Render / actions for songs list
  ============================ */
  async function renderSongsList() {
    if (!modal) return;
    const list = getSongsRaw();
    const container = modal.querySelector('#sc-list');
    container.innerHTML = '';
    modal.querySelector('#sc-total').textContent = list.length;
    for (const url of list) {
      const row = await createTrackRow(url);
      container.appendChild(row);
    }
  }

  async function createTrackRow(url) {
    const div = document.createElement('div'); div.className = 'sc-track';
    div.innerHTML = `
      <div class="sc-art"><img src="" style="width:100%;height:100%;object-fit:cover"></div>
      <div class="sc-meta"><div class="sc-title">${url}</div><div class="sc-author">${url}</div></div>
      <div class="sc-actions"><button class="sc-playbtn">Open</button><button class="scdash-btn sc-fav-btn">♡</button></div>
    `;
    const img = div.querySelector('.sc-art img');
    const titleEl = div.querySelector('.sc-title');
    const authorEl = div.querySelector('.sc-author');
    // fetch oEmbed metadata via GM (avoids CORS)
    try {
      const oe = await gmFetchJson('https://soundcloud.com/oembed?format=json&url=' + encodeURIComponent(url));
      if (oe.title) titleEl.textContent = oe.title;
      if (oe.author_name) authorEl.textContent = oe.author_name;
      if (oe.thumbnail_url) {
        // fetch image binary and set object URL
        try {
          const blob = await gmRequestBinary(oe.thumbnail_url);
          const obj = URL.createObjectURL(blob);
          // revoke previous if any later - store on dataset
          const prev = img.dataset._objurl;
          if (prev) URL.revokeObjectURL(prev);
          img.dataset._objurl = obj;
          img.src = obj;
        } catch (imgErr) {
          // fallback to direct url (may be blocked), set alt blank
          img.src = oe.thumbnail_url;
        }
      }
    } catch (e) {
      // quietly ignore metadata fetch failing
    }
    // open button
    div.querySelector('.sc-playbtn').addEventListener('click', ()=> {
      window.open(url, '_blank');
      const user = getSessionUser();
      const hist = getHist(user); hist.unshift(url);
      saveHist(user, Array.from(new Set(hist)).slice(0,200));
      renderFavHistory();
    });
    // fav button
    div.querySelector('.sc-fav-btn').addEventListener('click', ()=> {
      const user = getSessionUser();
      const favs = getFavs(user); if (!favs.includes(url)) favs.unshift(url);
      saveFavs(user, favs.slice(0,200));
      renderFavHistory();
    });

    return div;
  }

  /* ============================
     Search (uses scSearch wrapper)
  ============================ */
  async function scSearch(q) {
    const cid = await ensureClientId();
    const url = `https://api-v2.soundcloud.com/search/tracks?q=${encodeURIComponent(q)}&client_id=${encodeURIComponent(cid)}&limit=30`;
    const res = await gmRequest(url);
    try {
      const json = JSON.parse(res.responseText);
      return json.collection || [];
    } catch (e) { return []; }
  }

  function renderSearchResults(results) {
    if (!modal) return;
    const container = modal.querySelector('#sc-list');
    container.innerHTML = '';
    if (!results || results.length === 0) { container.innerHTML = '<div class="sc-empty">No results</div>'; return; }
    for (const t of results) {
      const url = t.permalink_url || (t.user && t.user.permalink ? `https://soundcloud.com/${t.user.permalink}/${t.permalink}` : '');
      const row = document.createElement('div'); row.className = 'sc-track';
      row.innerHTML = `
        <div class="sc-art"><img src="" style="width:100%;height:100%;object-fit:cover"></div>
        <div class="sc-meta"><div class="sc-title">${escapeHtml(t.title)}</div><div class="sc-author">${escapeHtml(t.user && t.user.username ? t.user.username : '')}</div></div>
        <div class="sc-actions"><button class="sc-playbtn">Open</button><button class="scdash-btn sc-fav-btn">♡</button></div>
      `;
      const img = row.querySelector('.sc-art img');
      // artwork_url may contain {size}, remove - use original
      const artwork = t.artwork_url || (t.user && t.user.avatar_url) || '';
      if (artwork) {
        // try to fetch binary and set obj url
        gmRequestBinary(artwork).then(blob => {
          const obj = URL.createObjectURL(blob);
          const prev = img.dataset._objurl; if (prev) URL.revokeObjectURL(prev);
          img.dataset._objurl = obj; img.src = obj;
        }).catch(_ => { img.src = artwork; });
      }
      row.querySelector('.sc-playbtn').addEventListener('click', ()=> {
        window.open(url, '_blank');
        const user = getSessionUser();
        const h = getHist(user); h.unshift(url); saveHist(user, Array.from(new Set(h)).slice(0,200)); renderFavHistory();
      });
      row.querySelector('.sc-fav-btn').addEventListener('click', ()=> {
        const user = getSessionUser();
        const f = getFavs(user); if (!f.includes(url)) f.unshift(url); saveFavs(user, f.slice(0,200)); renderFavHistory();
      });
      container.appendChild(row);
    }
  }

  /* ============================
     Play embed (removed, replaced with open in new tab)
  ============================ */
  function playUrl(url) {
    if (!modal) return;
    const holder = modal.querySelector('#sc-embed-holder');
    holder.innerHTML = `<div class="sc-empty">Track opened in new tab</div>`;
    window.open(url, '_blank');
  }

  /* ============================
     Account area, favorites & history UI
  ============================ */
  function renderAccountArea() {
    if (!modal) return;
    const area = modal.querySelector('#sc-account-area');
    const sessionUser = getSessionUser();
    area.innerHTML = '';
    if (!sessionUser) {
      area.innerHTML = `
        <input id="sc-username" class="scdash-input" placeholder="Username">
        <input id="sc-password" class="scdash-input" placeholder="Password" type="password">
        <div style="display:flex;gap:8px"><button id="sc-signup" class="scdash-btn">Sign Up</button><button id="sc-login" class="scdash-btn">Login</button></div>
      `;
      modal.querySelector('#sc-signup').addEventListener('click', ()=>{
        const u = modal.querySelector('#sc-username').value.trim(); const p = modal.querySelector('#sc-password').value.trim();
        if (!u || !p) return alert('Enter username & password');
        const users = getUsers(); if (users[u]) return alert('User exists'); users[u] = p; saveUsers(users); alert('Created - login now');
      });
      modal.querySelector('#sc-login').addEventListener('click', ()=>{
        const u = modal.querySelector('#sc-username').value.trim(); const p = modal.querySelector('#sc-password').value.trim();
        const users = getUsers(); if (users[u] && users[u] === p) { setSessionUser(u); renderAccountArea(); renderFavHistory(); alert('Logged in as ' + u) } else alert('Invalid');
      });
    } else {
      area.innerHTML = `<div style="font-weight:700">${sessionUser}</div><div style="margin-top:8px"><button id="sc-logout" class="scdash-btn">Logout</button></div>`;
      modal.querySelector('#sc-logout').addEventListener('click', ()=>{ setSessionUser(null); renderAccountArea(); renderFavHistory(); });
    }
  }

  function renderFavHistory() {
    if (!modal) return;
    const user = getSessionUser();
    const favContainer = modal.querySelector('#sc-fav-list');
    const histContainer = modal.querySelector('#sc-history-list');
    const favs = getFavs(user), hist = getHist(user);
    favContainer.innerHTML = ''; histContainer.innerHTML = '';

    if (!favs || favs.length === 0) favContainer.innerHTML = '<div class="sc-empty">No favorites</div>';
    else favs.forEach(f => {
      const item = document.createElement('div'); item.className = 'sc-history-item';
      item.innerHTML = `<div style="font-weight:700">${f}</div><div style="margin-top:6px"><button class="sc-playbtn">Open</button> <button class="scdash-btn sc-remove">Remove</button></div>`;
      item.querySelector('.sc-playbtn').addEventListener('click', ()=> { playUrl(f); const h = getHist(user); h.unshift(f); saveHist(user, Array.from(new Set(h)).slice(0,200)); renderFavHistory(); });
      item.querySelector('.sc-remove').addEventListener('click', ()=> { const arr = getFavs(user).filter(x=>x!==f); saveFavs(user, arr); renderFavHistory(); });
      favContainer.appendChild(item);
    });

    if (!hist || hist.length === 0) histContainer.innerHTML = '<div class="sc-empty">No history</div>';
    else hist.forEach(h => {
      const item = document.createElement('div'); item.className = 'sc-history-item';
      item.innerHTML = `<div>${h}</div><div style="margin-top:6px"><button class="sc-playbtn">Open</button></div>`;
      item.querySelector('.sc-playbtn').addEventListener('click', ()=> playUrl(h));
      histContainer.appendChild(item);
    });
  }

  /* ============================
     Utilities
  ============================ */
  function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }

  /* ============================
     Init / Bind open button
  ============================ */
  openBtn.addEventListener('click', async ()=>{
    try { showStatus('Preparing client_id...'); await ensureClientId(); } catch(e) { /* allow continue with prompt later */ } finally { hideStatus(); }
    openModal();
  });

  // keyboard toggle Ctrl+Shift+S
  document.addEventListener('keydown', (e)=>{ if (e.ctrlKey && e.shiftKey && e.key.toLowerCase()==='s') { if (modal) { modal.remove(); modal=null; } else openBtn.click(); } });

  // initial apply theme
  if ((localStorage.getItem(KEYS.THEME) || 'light') === 'dark') document.documentElement.classList.add('sc-dark');

  // small exposure for debugging
  window.SCDASH = { autoFetchClientId, ensureClientId, scSearch: scSearch, gmRequest, gmRequestBinary };

})();