Crate

Detects "Artist - Song" style mentions in Reddit comments, links them to Spotify, and lets you collect them into a playlist

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Crate
// @version      2.3.1
// @description  Detects "Artist - Song" style mentions in Reddit comments, links them to Spotify, and lets you collect them into a playlist
// @match        https://www.reddit.com/*
// @match        https://old.reddit.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_listValues
// @grant        GM_deleteValue
// @connect      accounts.spotify.com
// @connect      api.spotify.com
// @license      MIT
// @namespace https://greasyfork.org/users/1561930
// ==/UserScript==

(function () {
  'use strict';

  /* ---------------------------------------------------------------------
   * SETUP
   * 1. Go to https://developer.spotify.com/dashboard and create an app.
   * 2. Grab the Client ID and Client Secret (secret is only used for the
   *    read-only search lookups; playlist actions use a separate login
   *    flow that never touches the secret).
   * 3. In that app's settings, add this exact Redirect URI:
   *      https://www.reddit.com/
   *    (Spotify requires one to be registered for the "connect your
   *    account" login step used by the playlist feature.)
   * 4. The first time this script runs, it will prompt you once for the
   *    Client ID/Secret and store them locally via GM_setValue.
   * ------------------------------------------------------------------- */

  const REDIRECT_URI = 'https://www.reddit.com/';
  const USER_SCOPES = 'playlist-modify-public playlist-modify-private playlist-read-private';
  const CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 1 week cache for track lookups
  const REQUEST_DELAY_MS = 300; // throttle between Spotify search calls

  // ---- Handle the OAuth redirect landing (runs in the login popup) --------
  // If this page load is Spotify sending us back with ?code=..., grab it,
  // hand it to the window that opened the popup, and stop; don't run the
  // rest of the script (comment scanning) on this transient redirect page.
  if (handleAuthRedirectIfPresent()) return;

  function handleAuthRedirectIfPresent() {
    const params = new URLSearchParams(window.location.search);
    const code = params.get('code');
    const state = params.get('state');
    if (!code || !window.opener) return false;
    const expectedState = GM_getValue('spotify_pkce_state', null);
    if (state && state === expectedState) {
      window.opener.postMessage({ type: 'spotify-auth-code', code }, '*');
    }
    document.documentElement.innerHTML =
      '<body style="font-family:sans-serif;padding:2rem;background:#111;color:#eee;">Spotify connected. This window will close automatically.</body>';
    setTimeout(() => window.close(), 1200);
    return true;
  }

  // Shared character-class fragments; every regex below is built from these
  // so a future "allow one more punctuation mark" fix only happens in one place.
  const WORD_CHARS = "A-Za-z0-9&.'’%()/: ";
  const TITLE_EXTRA_CHARS = ',!';
  const TERMINATOR = '[.,!?;)\\n]';

  function buildDashRegex(artistStart) {
    return new RegExp(
      `\\b([${artistStart}][${WORD_CHARS}]{1,40}?)\\s*[-–—]\\s+` +
      `([A-Za-z0-9][${WORD_CHARS}${TITLE_EXTRA_CHARS}]{1,40}?)` +
      `(?=${TERMINATOR}|$|\\s+[${artistStart}][${WORD_CHARS}]{0,40}?\\s*[-–—]\\s)`,
      'g'
    );
  }

  // Matches things like: "Bad Omens - Died For You", "Northlane- Talking Heads"
  const SONG_REGEX = buildDashRegex('A-Z');

  // Looser variant used only for the manual "scan for more" action in the
  // Unmatched review panel; allows a lowercase-starting artist too. Never
  // auto-linked in-page; results only ever land in the review list.
  const LOOSE_SONG_REGEX = buildDashRegex('A-Za-z');

  // "Song Title by Artist", no dash at all. Much noisier ("inspired by",
  // "surrounded by trees" also match "by"), so this only ever feeds the
  // manual review list, never auto-linked.
  const LOOSE_BY_REGEX = new RegExp(
    `\\b([A-Z][${WORD_CHARS}]{1,60}?)\\s+by\\s+([A-Z][${WORD_CHARS}]{1,40}?)(?=${TERMINATOR}|$)`,
    'gi'
  );

  const SPOTIFY_SVG = `<svg viewBox="0 0 24 24" width="14" height="14" style="vertical-align:middle;" fill="#1DB954" fill-rule="evenodd"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141 4.32-1.32 9.719-.66 13.439 1.621.361.24.54.78.301 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.6.18-1.2.72-1.38C8.64 5.94 15.6 6.24 19.681 8.64c.539.3.719 1.02.42 1.56-.3.421-1.02.599-1.62.3z"/></svg>`;

  // Same mark, red, used inline for anything sitting in Unmatched: no
  // confident match yet, or none found at all.
  const SPOTIFY_SVG_RED = SPOTIFY_SVG.replace('fill="#1DB954"', 'fill="#e0384a"');

  GM_addStyle(`
    :root {
      --sl-bg: #16141c;
      --sl-surface: rgba(23, 20, 30, 0.92);
      --sl-elevated: #26222f;
      --sl-elevated-hover: #302b3a;
      --sl-border: rgba(255,255,255,0.08);
      --sl-border-strong: rgba(255,255,255,0.16);
      --sl-text: #f3f1f7;
      --sl-text-muted: #9c96ab;
      --sl-accent: #1DB954;
      --sl-accent-hover: #22e065;
      --sl-accent-soft: rgba(29,185,84,0.16);
      --sl-radius-lg: 18px;
      --sl-radius-md: 12px;
      --sl-radius-sm: 9px;
      --sl-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    }

    .spotify-link-icon {
      display: inline-flex;
      align-items: center;
      margin-left: 4px;
      text-decoration: none;
      opacity: 0.85;
      transition: opacity 0.15s ease, transform 0.15s ease;
    }
    .spotify-link-icon:hover { opacity: 1; transform: scale(1.15); }

    #sl-toggle-wrap {
      position: fixed;
      right: 20px;
      bottom: 20px;
      z-index: 999998;
      width: 52px;
      height: 52px;
      transition: transform 0.22s ease, opacity 0.22s ease;
    }
    #sl-toggle-wrap.sl-tucked {
      opacity: 0.5;
      transform: translateX(32px); /* mostly off-screen, a sliver left peeking out near the edge */
    }
    #sl-toggle-wrap.sl-tucked:hover {
      opacity: 1;
      transform: translateX(0);
    }
    #sl-toggle-wrap.sl-tucked #sl-badge { display: none; } /* nothing to count while disabled here */
    #sl-toggle-btn {
      position: absolute;
      inset: 0;
      border-radius: 50%;
      background: transparent !important;
      border: none !important;
      cursor: pointer;
      filter: drop-shadow(0 6px 16px rgba(0,0,0,0.55));
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0;
      transition: transform 0.18s cubic-bezier(.34,1.56,.64,1);
    }
    #sl-toggle-btn:hover { transform: scale(1.08) rotate(-4deg); }
    #sl-toggle-btn:active { transform: scale(0.95); }
    .sl-toggle-icon { width: 34px; height: 34px; display: block; }
    #sl-badge {
      position: absolute;
      top: -6px;
      right: -6px;
      background: #fff;
      color: #111;
      font: 700 11px/1.6 var(--sl-font);
      border-radius: 999px;
      padding: 1px 6px;
      min-width: 16px;
      text-align: center;
      box-shadow: 0 0 0 3px var(--sl-bg), 0 2px 6px rgba(0,0,0,0.5);
      pointer-events: none;
      z-index: 1;
    }

    #sl-panel {
      position: fixed;
      right: 20px;
      bottom: 84px;
      width: 320px;
      height: min(78vh, 680px);
      background: var(--sl-surface);
      backdrop-filter: blur(22px) saturate(160%);
      -webkit-backdrop-filter: blur(22px) saturate(160%);
      color: var(--sl-text);
      border-radius: var(--sl-radius-lg);
      border: 1px solid var(--sl-border-strong);
      box-shadow: 0 24px 60px -16px rgba(0,0,0,0.65), 0 4px 16px rgba(0,0,0,0.3);
      z-index: 999999;
      display: flex;
      flex-direction: column;
      overflow: hidden;
      font-family: var(--sl-font);
      font-size: 13px;
      opacity: 0;
      transform: translateY(10px) scale(0.97);
      pointer-events: none;
      transition: opacity 0.18s ease, transform 0.18s cubic-bezier(.2,.9,.3,1.1);
    }
    #sl-panel.sl-open { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
    #sl-panel button, #sl-panel input, #sl-panel select {
      font-family: inherit;
    }
    #sl-toggle-btn { font-family: var(--sl-font); }

    .sl-panel-header {
      padding: 14px 16px;
      display: flex;
      justify-content: space-between;
      align-items: center;
      border-bottom: 1px solid var(--sl-border);
      flex-shrink: 0;
    }
    .sl-wordmark { display: flex; align-items: center; gap: 7px; }
    .sl-wordmark-icon { width: 17px; height: 17px; color: var(--sl-accent); flex-shrink: 0; }
    #sl-header-title { font-weight: 700; font-size: 14.5px; letter-spacing: 0.2px; }

    .sl-live-tooltip {
      position: fixed;
      z-index: 2147483647;
      background: var(--sl-elevated);
      color: var(--sl-text);
      padding: 5px 9px;
      border-radius: 6px;
      font: 500 11px/1.4 var(--sl-font);
      white-space: nowrap;
      pointer-events: none;
      box-shadow: 0 4px 14px rgba(0,0,0,0.45);
      border: 1px solid var(--sl-border);
      opacity: 0;
      transform: translateY(4px);
      transition: opacity 0.1s ease, transform 0.1s ease;
    }
    .sl-live-tooltip.sl-live-tooltip-visible {
      opacity: 1;
      transform: translateY(0);
      transition: opacity 0.12s ease 0.3s, transform 0.12s ease 0.3s;
    }
    .sl-panel-header button {
      background: transparent !important; border: none !important; color: var(--sl-text-muted); cursor: pointer; font-size: 15px;
      width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
      border-radius: 50%; transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
    }
    .sl-panel-header button:hover { background: var(--sl-elevated) !important; color: var(--sl-text); }
    .sl-panel-header button:active { transform: scale(0.9); }
    #sl-settings-btn:hover { transform: rotate(45deg); }
    .sl-header-actions { display: flex; gap: 2px; align-items: center; }

    #sl-unmatched-toolbar { padding: 10px 14px; border-bottom: 1px solid var(--sl-border); flex-shrink: 0; }
    #sl-unmatched-toolbar .sl-btn { width: 100%; }
    #sl-list-view { flex: 1; display: flex; flex-direction: column; min-height: 0; }
    #sl-disabled-view { flex: 1; display: flex; flex-direction: column; min-height: 0; }
    .sl-disabled-body {
      flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
      text-align: center; padding: 28px 24px; gap: 6px;
    }
    .sl-disabled-icon { width: 32px; height: 32px; color: var(--sl-text-muted); opacity: 0.5; margin-bottom: 8px; }
    .sl-disabled-title { font-weight: 700; font-size: 14px; color: var(--sl-text); }
    .sl-disabled-sub { color: var(--sl-text-muted); font-size: 12.5px; line-height: 1.5; margin-bottom: 10px; max-width: 240px; }
    .sl-disabled-body .sl-btn { width: 220px; }

    .sl-track-list { flex: 1; overflow-y: auto; padding: 8px; min-height: 0; }
    .sl-track-list::-webkit-scrollbar, #sl-settings-view::-webkit-scrollbar { width: 7px; }
    .sl-track-list::-webkit-scrollbar-track, #sl-settings-view::-webkit-scrollbar-track { background: transparent; }
    .sl-track-list::-webkit-scrollbar-thumb, #sl-settings-view::-webkit-scrollbar-thumb {
      background: var(--sl-elevated); border-radius: 10px;
    }
    .sl-track-list::-webkit-scrollbar-thumb:hover, #sl-settings-view::-webkit-scrollbar-thumb:hover { background: var(--sl-elevated-hover); }

    .sl-track-item {
      display: flex; align-items: center; gap: 10px;
      padding: 7px 8px; border-radius: var(--sl-radius-sm);
      transition: background 0.15s ease, transform 0.15s ease;
    }
    .sl-track-item:hover { background: var(--sl-elevated); transform: translateX(2px); }
    .sl-track-item input[type="checkbox"] { accent-color: var(--sl-accent); width: 15px; height: 15px; flex-shrink: 0; }
    .sl-track-item img { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; background: var(--sl-elevated); object-fit: cover; box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
    .sl-track-meta { overflow: hidden; }
    .sl-track-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; }
    .sl-track-artist { color: var(--sl-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
    .sl-track-source {
      color: var(--sl-text-muted); opacity: 0.7; font-size: 11px; font-style: italic;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px;
    }
    .sl-empty {
      padding: 40px 20px; color: var(--sl-text-muted); text-align: center; line-height: 1.5;
      display: flex; flex-direction: column; align-items: center; gap: 12px;
    }
    .sl-empty-icon { width: 30px; height: 30px; opacity: 0.35; }

    .sl-tabs {
      display: flex; gap: 3px; margin: 10px 12px 0; padding: 3px;
      background: var(--sl-elevated); border-radius: var(--sl-radius-md); flex-shrink: 0;
    }
    .sl-tab {
      flex: 1; display: flex; align-items: center; justify-content: center; text-align: center;
      background: transparent !important; border: none !important; color: var(--sl-text-muted); cursor: pointer;
      padding: 7px 6px; font-size: 12.5px; font-weight: 600; border-radius: var(--sl-radius-sm);
      transition: background 0.18s ease, color 0.18s ease;
    }
    .sl-tab:hover { color: var(--sl-text); }
    .sl-tab.active {
      color: #06170c; font-weight: 700;
      background: linear-gradient(135deg, var(--sl-accent-hover), var(--sl-accent)) !important;
      box-shadow: 0 2px 10px rgba(29,185,84,0.35);
    }

    .sl-unmatched-item {
      margin: 0 4px 6px; padding: 10px 10px; border-radius: var(--sl-radius-md);
      background: rgba(255,255,255,0.02); border: 1px solid var(--sl-border);
    }
    .sl-unmatched-query { font-size: 12.5px; color: var(--sl-text); margin-bottom: 6px; font-weight: 500; }
    .sl-flag-badge {
      display: inline-block; margin-left: 6px; padding: 2px 7px; border-radius: 10px;
      background: rgba(240, 165, 40, 0.18); color: #f0a528; font-size: 10px; font-weight: 700;
      letter-spacing: 0.2px; text-transform: uppercase; vertical-align: middle;
    }
    .sl-unmatched-row { display: flex; gap: 6px; }
    .sl-unmatched-input {
      flex: 1; box-sizing: border-box; padding: 6px 9px; background: var(--sl-elevated);
      color: var(--sl-text); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm); font-size: 12.5px;
      transition: border-color 0.15s ease, box-shadow 0.15s ease;
    }
    .sl-unmatched-input:focus { outline: none; border-color: var(--sl-accent); box-shadow: 0 0 0 3px var(--sl-accent-soft); }
    .sl-unmatched-status { margin-top: 4px; font-size: 11.5px; color: var(--sl-text-muted); }
    .sl-unmatched-preview {
      display: flex; align-items: center; gap: 8px; margin-top: 8px;
      padding: 8px; background: var(--sl-elevated); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm);
    }
    .sl-unmatched-preview img { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; background: #333; object-fit: cover; }
    .sl-unmatched-preview-meta { flex: 1; overflow: hidden; }
    .sl-unmatched-preview-actions { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
    .sl-unmatched-preview-actions .sl-btn { padding: 5px 10px; font-size: 11.5px; margin-top: 0; }

    .sl-panel-footer { border-top: 1px solid var(--sl-border); padding: 12px 14px; flex-shrink: 0; }
    .sl-btn {
      display: flex; align-items: center; justify-content: center;
      width: 100%; box-sizing: border-box; padding: 10px 14px; margin-top: 8px;
      background: linear-gradient(135deg, var(--sl-accent-hover), var(--sl-accent)) !important;
      color: #06170c !important; border: none !important; border-radius: 20px;
      font-weight: 700; font-size: 13px; line-height: 1; letter-spacing: 0.2px;
      cursor: pointer; box-shadow: 0 2px 10px rgba(29,185,84,0.3);
      transition: filter 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease;
    }
    .sl-btn:hover:not(:disabled) { filter: brightness(1.08); transform: translateY(-1px); box-shadow: 0 4px 14px rgba(29,185,84,0.4); }
    .sl-btn:active:not(:disabled) { transform: scale(0.98) translateY(0); }
    .sl-btn:disabled { opacity: 0.45; cursor: default; box-shadow: none; }
    .sl-btn.sl-secondary {
      background: transparent !important; color: var(--sl-text) !important; box-shadow: none;
      border: 1px solid var(--sl-border-strong) !important;
    }
    .sl-btn.sl-secondary:hover:not(:disabled) { background: var(--sl-elevated) !important; filter: none; }
    select#sl-playlist-select, input#sl-new-playlist-name {
      width: 100%; box-sizing: border-box; padding: 8px 9px; margin-top: 8px; background: var(--sl-elevated);
      color: var(--sl-text); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm); font-size: 13px;
      font-family: var(--sl-font);
      transition: border-color 0.15s ease, box-shadow 0.15s ease;
    }
    select#sl-playlist-select {
      appearance: none; -webkit-appearance: none;
      padding-right: 30px;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239c96ab' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
      background-repeat: no-repeat;
      background-position: right 8px center;
      background-size: 16px;
      cursor: pointer;
    }
    select#sl-playlist-select:focus, input#sl-new-playlist-name:focus {
      outline: none; border-color: var(--sl-accent); box-shadow: 0 0 0 3px var(--sl-accent-soft);
    }
    .sl-status { margin-top: 8px; color: var(--sl-text-muted); font-size: 12px; line-height: 1.5; }
    .sl-status a { color: var(--sl-accent); }
    .sl-segmented {
      display: flex; gap: 3px; padding: 3px; margin-top: 2px;
      background: var(--sl-elevated); border-radius: var(--sl-radius-md);
    }
    .sl-segment {
      flex: 1; display: flex; align-items: center; justify-content: center; text-align: center;
      background: transparent !important; border: none !important; color: var(--sl-text-muted); cursor: pointer;
      padding: 7px 6px; font-size: 12px; font-weight: 600; border-radius: var(--sl-radius-sm);
      transition: background 0.18s ease, color 0.18s ease;
    }
    .sl-segment:hover { color: var(--sl-text); }
    .sl-segment.active {
      color: #06170c; font-weight: 700;
      background: linear-gradient(135deg, var(--sl-accent-hover), var(--sl-accent)) !important;
      box-shadow: 0 2px 10px rgba(29,185,84,0.3);
    }

    #sl-settings-view { flex: 1; overflow-y: auto; min-height: 0; }
    .sl-settings-body { padding: 4px 0; }
    .sl-field-label { display: block; margin-top: 12px; margin-bottom: 4px; color: var(--sl-text-muted); font-size: 12px; }
    .sl-field-label:first-child { margin-top: 0; }
    .sl-settings-body input[type="text"], .sl-settings-body input[type="password"] {
      width: 100%; box-sizing: border-box; padding: 8px 9px; background: var(--sl-elevated);
      color: var(--sl-text); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm); font-size: 13px;
      transition: border-color 0.15s ease, box-shadow 0.15s ease;
    }
    .sl-settings-body input[type="text"]:focus, .sl-settings-body input[type="password"]:focus {
      outline: none; border-color: var(--sl-accent); box-shadow: 0 0 0 3px var(--sl-accent-soft);
    }
    .sl-settings-section { border-bottom: 1px solid var(--sl-border); }
    .sl-settings-section:last-child { border-bottom: none; }
    .sl-settings-section summary {
      list-style: none; cursor: pointer; padding: 13px 16px;
      font-weight: 600; font-size: 13px; display: flex; align-items: center;
      justify-content: space-between; user-select: none; transition: background 0.15s ease;
    }
    .sl-settings-section summary::-webkit-details-marker { display: none; }
    .sl-settings-section summary::after {
      content: '›'; transform: rotate(90deg); transition: transform 0.2s ease;
      color: var(--sl-text-muted); font-size: 16px;
    }
    .sl-settings-section[open] summary::after { transform: rotate(-90deg); }
    .sl-settings-section summary:hover { background: var(--sl-elevated); }
    .sl-settings-section-body { padding: 0 16px 18px; }

    .sl-hint { margin-top: 10px; font-size: 12px; color: var(--sl-text-muted); line-height: 1.6; }
    .sl-hint a { color: var(--sl-accent); }
    .sl-code-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
    .sl-code-row code {
      flex: 1; background: var(--sl-elevated); border: 1px solid var(--sl-border); border-radius: var(--sl-radius-sm);
      padding: 6px 9px; font-size: 12px; color: var(--sl-text); overflow-x: auto; white-space: nowrap;
    }
    .sl-code-row button {
      display: flex; align-items: center; justify-content: center; text-align: center;
      background: var(--sl-elevated) !important; color: var(--sl-text) !important; border: 1px solid var(--sl-border) !important; border-radius: var(--sl-radius-sm);
      padding: 6px 11px; font-size: 12px; cursor: pointer; flex-shrink: 0; transition: background 0.15s ease;
    }
    .sl-code-row button:hover { background: var(--sl-elevated-hover) !important; }

    .sl-sub-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
    .sl-chip {
      display: flex; align-items: center; gap: 5px;
      background: var(--sl-elevated); border: 1px solid var(--sl-border); border-radius: 14px;
      padding: 4px 7px 4px 11px; font-size: 12px; transition: border-color 0.15s ease;
    }
    .sl-chip:hover { border-color: var(--sl-border-strong); }
    .sl-chip button {
      display: flex; align-items: center; justify-content: center;
      background: transparent !important; border: none !important; color: var(--sl-text-muted); cursor: pointer;
      font-size: 11px; line-height: 1; padding: 3px; border-radius: 50%; transition: color 0.15s ease, background 0.15s ease;
    }
    .sl-chip button:hover { color: var(--sl-text); background: rgba(255,255,255,0.08); }
    .sl-empty-chip { color: var(--sl-text-muted); font-size: 12px; }
    .sl-sub-add-row { display: flex; gap: 6px; margin-top: 8px; }
    .sl-sub-add-row input { flex: 1; margin-top: 0; }
    .sl-btn-inline { width: auto; margin-top: 0; padding: 7px 14px; white-space: nowrap; }

    @media (prefers-reduced-motion: reduce) {
      #sl-toggle-btn, #sl-toggle-wrap, .sl-track-item, .sl-btn, .spotify-link-icon { transition: none !important; }
      #sl-toggle-btn:hover { transform: none; }
    }
  `);

  // ---- Credential handling (app-level, for search only) --------------------
  // Credentials are entered via the Settings panel (gear icon) and just read
  // from storage here.

  function getCredentials() {
    return {
      id: GM_getValue('spotify_client_id', ''),
      secret: GM_getValue('spotify_client_secret', ''),
    };
  }

  function hasCredentials() {
    const { id, secret } = getCredentials();
    return !!(id && secret);
  }

  const CACHE_KEY_PREFIX = 'spotify_cache_v2_';

  function countCachedLookups() {
    if (typeof GM_listValues !== 'function') return 0;
    return GM_listValues().filter((k) => k.startsWith(CACHE_KEY_PREFIX)).length;
  }

  function clearSpotifyCache() {
    if (typeof GM_listValues !== 'function') return 0;
    const keys = GM_listValues().filter((k) => k.startsWith(CACHE_KEY_PREFIX));
    keys.forEach((k) => GM_deleteValue(k));
    return keys.length;
  }

  // ---- Subreddit filter -----------------------------------------------------

  function getSubredditFilter() {
    try {
      return JSON.parse(GM_getValue('sl_subreddit_filter', '[]'));
    } catch (e) {
      return [];
    }
  }

  function currentSubreddit() {
    const m = location.pathname.match(/^\/r\/([^/]+)/i);
    return m ? m[1].toLowerCase() : '';
  }

  function isSubredditAllowed() {
    const filter = getSubredditFilter();
    if (filter.length === 0) return true; // no filter set = run everywhere
    return filter.includes(currentSubreddit());
  }

  // Visually "tucks" the floating toggle mostly off-screen near the edge
  // when the tool isn't enabled on the current subreddit, so it's obvious
  // at a glance whether anything is actually active here: full visibility
  // when enabled, a barely-there sliver when it's not.
  function updateToggleTuckedState() {
    const wrap = document.getElementById('sl-toggle-wrap');
    const toggle = document.getElementById('sl-toggle-btn');
    if (!wrap || !toggle) return;
    const enabled = isSubredditAllowed();
    wrap.classList.toggle('sl-tucked', !enabled);
    toggle.setAttribute(
      'data-tooltip',
      enabled ? 'Crate: detected Spotify tracks' : `Not enabled on r/${currentSubreddit() || 'this subreddit'}, click to add it`
    );
  }

  function saveSubredditFilter(list) {
    GM_setValue('sl_subreddit_filter', JSON.stringify(list));
  }

  function addSubreddit(name) {
    const clean = name.trim().replace(/^\/?r\//i, '').toLowerCase();
    if (!clean) return;
    const filter = getSubredditFilter();
    if (!filter.includes(clean)) {
      filter.push(clean);
      saveSubredditFilter(filter);
    }
    updateToggleTuckedState();
  }

  function removeSubreddit(name) {
    saveSubredditFilter(getSubredditFilter().filter((s) => s !== name));
    updateToggleTuckedState();
  }

  function renderSubredditChips(view) {
    const chipsEl = view.querySelector('#sl-sub-chips');
    const filter = getSubredditFilter();

    chipsEl.innerHTML = filter.length
      ? filter.map((s) => `<span class="sl-chip">r/${escapeHtml(s)}<button data-sub="${escapeHtml(s)}" title="Remove">✕</button></span>`).join('')
      : '<span class="sl-empty-chip">No filter, running on all subreddits</span>';

    chipsEl.querySelectorAll('button[data-sub]').forEach((btn) => {
      btn.addEventListener('click', () => {
        removeSubreddit(btn.getAttribute('data-sub'));
        renderSubredditChips(view);
      });
    });

    const cur = currentSubreddit();
    const addCurrentBtn = view.querySelector('#sl-sub-add-current-btn');
    if (cur && !filter.includes(cur)) {
      addCurrentBtn.style.display = '';
      addCurrentBtn.textContent = `+ Add current subreddit (r/${cur})`;
      addCurrentBtn.onclick = () => {
        addSubreddit(cur);
        renderSubredditChips(view);
      };
    } else {
      addCurrentBtn.style.display = 'none';
    }
  }

  // ---- App token (client credentials flow, read-only search) --------------

  let tokenPromise = null;

  function getToken() {
    const cachedToken = GM_getValue('spotify_token', null);
    const cachedExpiry = GM_getValue('spotify_token_expiry', 0);
    if (cachedToken && Date.now() < cachedExpiry) return Promise.resolve(cachedToken);
    if (tokenPromise) return tokenPromise;

    const { id, secret } = getCredentials();
    if (!id || !secret) return Promise.reject(new Error('No Spotify credentials provided'));

    tokenPromise = postTokenRequest(
      { grant_type: 'client_credentials' },
      { Authorization: 'Basic ' + btoa(`${id}:${secret}`) }
    ).then((json) => {
      GM_setValue('spotify_token', json.access_token);
      GM_setValue('spotify_token_expiry', Date.now() + (json.expires_in - 60) * 1000);
      return json.access_token;
    }).finally(() => { tokenPromise = null; });

    return tokenPromise;
  }

  // ---- Match confidence check -----------------------------------------------
  // Spotify's search is fuzzy, and for unusual or garbled queries it will
  // sometimes return a completely unrelated top result rather than nothing.
  // Before trusting a result enough to auto-insert it, compare what Spotify
  // returned against what was actually detected in the comment.

  function normalizeForCompare(s) {
    return (s || '')
      .toLowerCase()
      .replace(/\(.*?\)/g, '')
      .replace(/feat\.?.*$/i, '')
      .replace(/[^a-z0-9]+/g, ' ')
      .trim()
      .replace(/\s+/g, ' ');
  }

  function levenshtein(a, b) {
    const m = a.length, n = b.length;
    if (m === 0) return n;
    if (n === 0) return m;
    const dp = new Array(n + 1);
    for (let j = 0; j <= n; j++) dp[j] = j;
    for (let i = 1; i <= m; i++) {
      let prev = dp[0];
      dp[0] = i;
      for (let j = 1; j <= n; j++) {
        const temp = dp[j];
        dp[j] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[j], dp[j - 1]);
        prev = temp;
      }
    }
    return dp[n];
  }

  function textSimilarity(a, b) {
    a = normalizeForCompare(a);
    b = normalizeForCompare(b);
    if (!a || !b) return 0;
    if (a === b) return 1;
    return 1 - levenshtein(a, b) / Math.max(a.length, b.length);
  }

  // Tuned against real false-match examples: every bad match seen so far had
  // artist similarity under 0.2; every genuine match (including a multi-artist
  // collab) scored at least 0.29. Deliberately biased toward flagging more
  // rather than fewer, since a flagged match just costs one confirmation
  // click, while a wrong auto-inserted one is silently bad data.
  function isConfidentMatch(detectedArtist, detectedTitle, track) {
    const titleSim = textSimilarity(detectedTitle, track.name);
    const artistSim = textSimilarity(detectedArtist, track.artists);
    return titleSim >= 0.3 && artistSim >= 0.25;
  }

  // ---- Track search with local cache ----------------------------------------

  function searchTrackCached(query, opts = {}) {
    const cacheKey = CACHE_KEY_PREFIX + query.toLowerCase();
    if (!opts.bypassCache) {
      const cached = GM_getValue(cacheKey, null);
      if (cached) {
        const parsed = JSON.parse(cached);
        if (Date.now() - parsed.ts < CACHE_TTL_MS) return Promise.resolve(parsed.track);
      }
    }
    return getToken().then((token) => {
      return new Promise((resolve) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url: `https://api.spotify.com/v1/search?type=track&limit=1&q=${encodeURIComponent(query)}`,
          headers: { Authorization: `Bearer ${token}` },
          onload: (res) => {
            if (res.status < 200 || res.status >= 300) {
              // API error (rate-limited, expired token, etc.), don't cache
              // this as "no match," or a transient failure would silently
              // poison the result for a full week.
              resolve(null);
              return;
            }
            try {
              const json = JSON.parse(res.responseText);
              const t = json.tracks && json.tracks.items && json.tracks.items[0];
              const track = t ? {
                id: t.id,
                name: t.name,
                artists: t.artists.map((a) => a.name).join(', '),
                image: (t.album.images[t.album.images.length - 1] || {}).url || '',
                url: t.external_urls.spotify,
                uri: t.uri,
              } : null;
              GM_setValue(cacheKey, JSON.stringify({ track, ts: Date.now() }));
              resolve(track);
            } catch (e) {
              resolve(null);
            }
          },
          onerror: () => resolve(null),
        });
      });
    }).catch(() => null);
  }

  // ---- Queue to throttle requests -------------------------------------------
  // Bounded concurrency rather than strictly one-at-a-time: a thread with 30
  // song mentions used to take a flat 9s (30 * 300ms) to resolve. With 3
  // workers each still individually spaced by REQUEST_DELAY_MS, that drops
  // to roughly a third of the time without meaningfully increasing load on
  // Spotify's API (still well under any reasonable rate limit).

  const queue = [];
  const MAX_CONCURRENT_REQUESTS = 3;
  let activeWorkers = 0;

  function enqueue(fn) {
    queue.push(fn);
    pumpQueue();
  }

  function pumpQueue() {
    while (activeWorkers < MAX_CONCURRENT_REQUESTS && queue.length > 0) {
      activeWorkers++;
      const fn = queue.shift();
      fn().finally(() => {
        setTimeout(() => {
          activeWorkers--;
          pumpQueue();
        }, REQUEST_DELAY_MS);
      });
    }
  }

  // ---- DOM scanning -----------------------------------------------------

  const PROCESSED_ATTR = 'data-sl-processed';

  function candidateTextNodes(root) {
    return root.querySelectorAll(`p:not([${PROCESSED_ATTR}]), div.md > *:not([${PROCESSED_ATTR}])`);
  }

  function isVisible(el) {
    // Filters out visually-hidden accessibility duplicates that new-Reddit's
    // web components sometimes render alongside the real comment text.
    // Checking getClientRects() alone isn't enough; visibility:hidden and
    // clipped "sr-only" elements still report non-empty rects.
    if (!el || !(el instanceof Element)) return true;
    if (el.hasAttribute('aria-hidden') && el.getAttribute('aria-hidden') === 'true') return false;
    const cls = el.className && el.className.toString().toLowerCase();
    if (cls && /sr-only|visually-hidden|screen-reader/.test(cls)) return false;

    const style = window.getComputedStyle(el);
    if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return false;

    const rect = el.getBoundingClientRect();
    if (rect.width <= 1 && rect.height <= 1) return false; // classic clipped sr-only pattern

    if (el.getClientRects().length === 0) return false;
    return true;
  }

  function nearestStableId(el) {
    // Reddit tags comment containers with an id like "t1_xxxxx" (old Reddit)
    // or a thingid/id attribute on the custom element (new Reddit). Used to
    // tell "the same comment rendered twice" apart from "two different
    // comments that happen to mention the same song."
    if (!el) return null;
    const withId = el.closest('[id]');
    if (withId && withId.id) return withId.id;
    const withThingId = el.closest('[thingid]');
    if (withThingId) return withThingId.getAttribute('thingid');
    return null;
  }

  const seenCommentQueries = new Set();

  function makeIcon(track) {
    const a = document.createElement('a');
    a.href = track.url;
    a.target = '_blank';
    a.rel = 'noopener noreferrer';
    a.className = 'spotify-link-icon';
    a.title = 'Open on Spotify';
    a.innerHTML = SPOTIFY_SVG;
    return a;
  }

  function makeUnmatchedIcon(query) {
    const a = document.createElement('a');
    a.href = `https://open.spotify.com/search/${encodeURIComponent(query)}`;
    a.target = '_blank';
    a.rel = 'noopener noreferrer';
    a.className = 'spotify-link-icon';
    a.setAttribute('data-tooltip', 'Not confidently matched, check it in Crate');
    a.innerHTML = SPOTIFY_SVG_RED;
    return a;
  }

  function getTextNodesIn(el) {
    const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
    const nodes = [];
    let n;
    while ((n = walker.nextNode())) nodes.push(n);
    return nodes;
  }

  // Splits matched spans out of a text node (in descending order, so earlier
  // indices stay valid), skipping anything already seen for this comment,
  // and hands each surviving match a placeholder comment node to replace
  // once resolved. Shared by both the automatic strict pass and the manual
  // loose scan; they only differ in what happens after (auto-search vs.
  // straight to the review list).
  // Reddit's own placeholder text for deleted/removed comments happens to
  // grammatically match "Title by Artist" ("Comment deleted by user"), so
  // it needs an explicit exclusion rather than relying on the regex alone.
  const SYSTEM_MESSAGE_RE = /^comment (deleted|removed) by \S+$/i;

  function splitMatchesIntoPlaceholders(textNode, matches, onMatch) {
    const stableId = nearestStableId(textNode.parentElement);
    const sorted = [...matches].sort((a, b) => b.index - a.index);

    for (const m of sorted) {
      if (SYSTEM_MESSAGE_RE.test((m.raw || '').trim())) continue;

      const query = `${m.artist} ${m.title}`;
      if (query.length < 5) continue;

      if (stableId) {
        const dedupeKey = `${stableId}::${query.toLowerCase()}`;
        if (seenCommentQueries.has(dedupeKey)) continue; // same comment rendered twice, or already found by an earlier pass
        seenCommentQueries.add(dedupeKey);
      }

      const matchEnd = m.index + m.length;
      const remainder = textNode.splitText(matchEnd);
      const placeholder = document.createComment('sl-pending');
      textNode.parentNode.insertBefore(placeholder, remainder);
      onMatch(query, placeholder, m.artist, m.title);
    }
  }

  function toMatchList(regexMatches) {
    return [...regexMatches].map((m) => ({ index: m.index, length: m[0].length, artist: m[1].trim(), title: m[2].trim(), raw: m[0] }));
  }

  function processTextNode(textNode) {
    const text = textNode.nodeValue;
    if (!text || text.length < 6) return;
    const matches = toMatchList(text.matchAll(SONG_REGEX));
    if (matches.length === 0) return;

    splitMatchesIntoPlaceholders(textNode, matches, (query, placeholder, artist, title) => {
      enqueue(() =>
        searchTrackCached(query).then((track) => {
          if (!placeholder.parentNode) return;
          if (track && isConfidentMatch(artist, title, track)) {
            placeholder.parentNode.replaceChild(makeIcon(track), placeholder);
            registerTrack(track, query);
          } else if (track) {
            // Spotify found something, but it doesn't look like a real match
            // for what was actually detected, flag it for review instead
            // of trusting it blindly.
            registerUnmatched(query, placeholder, track);
          } else {
            registerUnmatched(query, placeholder);
          }
        })
      );
    });
  }

  function processElement(el) {
    el.setAttribute(PROCESSED_ATTR, '1');
    if (!isVisible(el)) return;
    getTextNodesIn(el).forEach(processTextNode);
  }

  function scan(root = document.body) {
    if (!isSubredditAllowed()) return;
    candidateTextNodes(root).forEach(processElement);
  }

  function collectLooseMatches(text) {
    const found = [
      ...toMatchList(text.matchAll(LOOSE_SONG_REGEX)),
      // LOOSE_BY_REGEX captures "Title by Artist"; groups are reversed relative to the dash pattern.
      ...[...text.matchAll(LOOSE_BY_REGEX)].map((m) => ({ index: m.index, length: m[0].length, artist: m[2].trim(), title: m[1].trim(), raw: m[0] })),
    ];
    found.sort((a, b) => b.index - a.index);

    // Drop overlaps between the two patterns (keep whichever sorted first).
    const kept = [];
    for (const m of found) {
      const end = m.index + m.length;
      const overlaps = kept.some((k) => !(end <= k.index || m.index >= k.index + k.length));
      if (!overlaps) kept.push(m);
    }
    return kept;
  }

  function processLooseTextNode(textNode) {
    const text = textNode.nodeValue;
    if (!text || text.length < 6) return;
    const matches = collectLooseMatches(text);
    if (matches.length === 0) return;

    // Loose matches go straight to the review list, never auto-searched
    // or auto-linked in-page, since the false-positive rate is higher.
    splitMatchesIntoPlaceholders(textNode, matches, (query, placeholder) => registerUnmatched(query, placeholder));
  }

  function looseScan() {
    if (!isSubredditAllowed()) return;
    document.querySelectorAll(`[${PROCESSED_ATTR}]`).forEach((el) => {
      if (!isVisible(el)) return;
      getTextNodesIn(el).forEach(processLooseTextNode);
    });
  }

  // Rather than scanning synchronously inside the observer callback (which
  // during a heavy insertion burst, like Reddit loading a page of comments
  // at once, means many interleaved layout reads and DOM writes back to
  // back), collect what changed and process it once per frame.
  const pendingScanNodes = new Set();
  let scanFlushScheduled = false;

  function scheduleScanFlush() {
    if (scanFlushScheduled) return;
    scanFlushScheduled = true;
    requestAnimationFrame(() => {
      scanFlushScheduled = false;
      const nodes = [...pendingScanNodes];
      pendingScanNodes.clear();
      nodes.forEach((node) => { if (node.isConnected) scan(node); });
    });
  }

  const observer = new MutationObserver((mutations) => {
    for (const m of mutations) {
      for (const node of m.addedNodes) {
        if (node.nodeType === 1) pendingScanNodes.add(node);
      }
    }
    if (pendingScanNodes.size > 0) scheduleScanFlush();
  });

  // =========================================================================
  // TRACK STORE + SIDEBAR
  // =========================================================================

  const trackStore = new Map(); // id -> track
  const selectedIds = new Set();
  const unmatchedStore = new Map(); // uid -> { id, query, placeholder }
  let unmatchedCounter = 0;

  function registerTrack(track, sourceText) {
    if (!track || !track.id || trackStore.has(track.id)) return;
    trackStore.set(track.id, sourceText ? { ...track, sourceText } : track);
    selectedIds.add(track.id); // default: selected
    renderTrackList();
    updateBadge();
  }

  function registerUnmatched(query, placeholder, suggestedTrack) {
    const uid = 'u' + (++unmatchedCounter);
    let iconEl = placeholder;
    if (placeholder && placeholder.parentNode) {
      iconEl = makeUnmatchedIcon(query);
      placeholder.parentNode.replaceChild(iconEl, placeholder);
    }
    unmatchedStore.set(uid, { id: uid, query, placeholder: iconEl, suggestedTrack: suggestedTrack || null });
    renderUnmatchedList();
  }

  function updateBadge() {
    const badge = document.getElementById('sl-badge');
    if (badge) badge.textContent = trackStore.size;
  }

  function renderTrackList() {
    const list = document.getElementById('sl-track-list');
    if (!list) return;
    if (trackStore.size === 0) {
      list.innerHTML = `
        <div class="sl-empty">
          <svg viewBox="0 0 24 24" class="sl-empty-icon" fill="none"><path d="M3 8.5 5.5 3h13L21 8.5M3 8.5V19a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8.5M3 8.5h18M9 8.5V12a3 3 0 0 0 6 0V8.5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
          <div>No tracks detected yet. Scroll through some comments.</div>
        </div>
      `;
      return;
    }
    list.innerHTML = '';
    trackStore.forEach((track) => {
      const item = document.createElement('label');
      item.className = 'sl-track-item';
      item.innerHTML = `
        <input type="checkbox" data-id="${track.id}" ${selectedIds.has(track.id) ? 'checked' : ''}>
        <img src="${track.image}" alt="">
        <div class="sl-track-meta">
          <div class="sl-track-name">${escapeHtml(track.name)}</div>
          <div class="sl-track-artist">${escapeHtml(track.artists)}</div>
          ${track.sourceText ? `<div class="sl-track-source">"${escapeHtml(track.sourceText)}"</div>` : ''}
        </div>
      `;
      item.querySelector('input').addEventListener('change', (e) => {
        if (e.target.checked) selectedIds.add(track.id);
        else selectedIds.delete(track.id);
      });
      list.appendChild(item);
    });
  }

  const escapeScratchEl = document.createElement('div');
  function escapeHtml(str) {
    escapeScratchEl.textContent = str;
    return escapeScratchEl.innerHTML;
  }

  function renderUnmatchedList() {
    const list = document.getElementById('sl-unmatched-list');
    const countEl = document.getElementById('sl-unmatched-count');
    if (countEl) countEl.textContent = unmatchedStore.size;
    if (!list) return;

    if (unmatchedStore.size === 0) {
      list.innerHTML = `
        <div class="sl-empty">
          <svg viewBox="0 0 24 24" class="sl-empty-icon" fill="none"><path d="M20 6 9 17l-5-5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
          <div>Nothing unmatched right now.</div>
        </div>
      `;
      return;
    }
    list.innerHTML = '';
    unmatchedStore.forEach((entry) => {
      const item = document.createElement('div');
      item.className = 'sl-unmatched-item';
      item.innerHTML = `
        <div class="sl-unmatched-query">
          ${escapeHtml(entry.query)}
          ${entry.suggestedTrack ? '<span class="sl-flag-badge">needs a look</span>' : ''}
        </div>
        <div class="sl-unmatched-row">
          <input type="text" class="sl-unmatched-input" value="${escapeHtml(entry.query)}">
          <button class="sl-btn sl-secondary sl-btn-inline sl-search-btn">Search</button>
        </div>
        <div class="sl-unmatched-preview" style="display:none;"></div>
        <div class="sl-unmatched-status"></div>
      `;
      const input = item.querySelector('.sl-unmatched-input');
      const searchBtn = item.querySelector('.sl-search-btn');
      const preview = item.querySelector('.sl-unmatched-preview');
      const status = item.querySelector('.sl-unmatched-status');

      function showPreview(track) {
        status.textContent = '';
        preview.style.display = '';
        preview.innerHTML = `
          <img src="${track.image}" alt="">
          <div class="sl-unmatched-preview-meta">
            <div class="sl-track-name">${escapeHtml(track.name)}</div>
            <div class="sl-track-artist">${escapeHtml(track.artists)}</div>
          </div>
          <div class="sl-unmatched-preview-actions">
            <button class="sl-btn sl-btn-inline sl-confirm-btn">This one</button>
            <button class="sl-btn sl-secondary sl-btn-inline sl-reject-btn">Not it</button>
          </div>
        `;
        preview.querySelector('.sl-confirm-btn').addEventListener('click', () => {
          if (entry.placeholder.parentNode) {
            entry.placeholder.parentNode.replaceChild(makeIcon(track), entry.placeholder);
          }
          registerTrack(track, entry.query);
          unmatchedStore.delete(entry.id);
          renderUnmatchedList();
        });
        preview.querySelector('.sl-reject-btn').addEventListener('click', () => {
          preview.style.display = 'none';
          status.textContent = 'Try a different search.';
        });
      }

      const doSearch = () => {
        const q = input.value.trim();
        if (!q) return;
        searchBtn.disabled = true;
        preview.style.display = 'none';
        status.textContent = 'Searching…';
        searchTrackCached(q, { bypassCache: true }).then((track) => {
          searchBtn.disabled = false;
          if (!track) {
            status.textContent = 'Still no match, try adjusting the search.';
            return;
          }
          // Show what was found and wait for explicit confirmation before
          // touching the actual comment or adding it to Matched, a blind
          // top search result can easily be the wrong song.
          showPreview(track);
        });
      };

      searchBtn.addEventListener('click', doSearch);
      input.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });

      if (entry.suggestedTrack) {
        // Spotify found something for this one already, but it didn't look
        // enough like a real match to trust automatically, show it as a
        // flagged suggestion up front instead of making them search again.
        status.textContent = "Found a possible match, but it doesn't look quite right. Double-check it:";
        showPreview(entry.suggestedTrack);
      }

      list.appendChild(item);
    });
  }

  function buildSidebar() {
    const wrap = document.createElement('div');
    wrap.id = 'sl-toggle-wrap';

    const toggle = document.createElement('button');
    toggle.id = 'sl-toggle-btn';
    toggle.setAttribute('data-tooltip', 'Crate: detected Spotify tracks');
    toggle.innerHTML =
      '<svg class="sl-toggle-icon" viewBox="0 0 24 24" fill="#1DB954" fill-rule="evenodd"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141 4.32-1.32 9.719-.66 13.439 1.621.361.24.54.78.301 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.6.18-1.2.72-1.38C8.64 5.94 15.6 6.24 19.681 8.64c.539.3.719 1.02.42 1.56-.3.421-1.02.599-1.62.3z"/></svg>';
    wrap.appendChild(toggle);

    const badge = document.createElement('span');
    badge.id = 'sl-badge';
    badge.textContent = '0';
    wrap.appendChild(badge); // sibling of the button, not inside it; filter on the button can't clip this

    document.body.appendChild(wrap);

    const panel = document.createElement('div');
    panel.id = 'sl-panel';
    panel.innerHTML = `
      <div class="sl-panel-header">
        <div class="sl-wordmark">
          <svg viewBox="0 0 24 24" class="sl-wordmark-icon" fill="none"><path d="M3 8.5 5.5 3h13L21 8.5M3 8.5V19a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8.5M3 8.5h18M9 8.5V12a3 3 0 0 0 6 0V8.5" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
          <span id="sl-header-title">Crate</span>
        </div>
        <div class="sl-header-actions">
          <button id="sl-clear-btn" data-tooltip="Clear this list">🗑</button>
          <button id="sl-settings-btn" data-tooltip="Settings">⚙</button>
          <button id="sl-close-btn" data-tooltip="Close">✕</button>
        </div>
      </div>
      <div id="sl-list-view">
        <div class="sl-tabs">
          <button class="sl-tab active" data-tab="matched">Matched</button>
          <button class="sl-tab" data-tab="unmatched">Unmatched (<span id="sl-unmatched-count">0</span>)</button>
        </div>
        <div id="sl-unmatched-toolbar">
          <button class="sl-btn sl-secondary sl-btn-inline" id="sl-loose-scan-btn">Scan for more (loose)</button>
        </div>
        <div id="sl-track-list" class="sl-track-list"></div>
        <div id="sl-unmatched-list" class="sl-track-list" style="display:none;"></div>
        <div class="sl-panel-footer" id="sl-footer"></div>
      </div>
      <div id="sl-disabled-view" style="display:none;"></div>
      <div id="sl-settings-view" style="display:none;"></div>
    `;
    document.body.appendChild(panel);

    toggle.addEventListener('click', () => {
      const isOpening = !panel.classList.contains('sl-open');
      panel.classList.toggle('sl-open');
      if (isOpening) showPanelForCurrentSubreddit();
    });
    panel.querySelector('#sl-close-btn').addEventListener('click', () => panel.classList.remove('sl-open'));
    panel.querySelector('#sl-settings-btn').addEventListener('click', () => toggleSettingsView());
    panel.querySelector('#sl-loose-scan-btn').addEventListener('click', (e) => {
      e.currentTarget.textContent = 'Scanning…';
      e.currentTarget.disabled = true;
      looseScan();
      panel.querySelector('.sl-tab[data-tab="unmatched"]').click();
      setTimeout(() => {
        e.currentTarget.textContent = 'Scan for more (loose)';
        e.currentTarget.disabled = false;
      }, 400);
    });
    panel.querySelector('#sl-clear-btn').addEventListener('click', () => {
      const showingMatched = panel.querySelector('#sl-track-list').style.display !== 'none';
      if (showingMatched) {
        trackStore.clear();
        selectedIds.clear();
        renderTrackList();
        updateBadge();
      } else {
        unmatchedStore.clear();
        renderUnmatchedList();
      }
    });
    panel.querySelectorAll('.sl-tab').forEach((tab) => {
      tab.addEventListener('click', () => {
        panel.querySelectorAll('.sl-tab').forEach((t) => t.classList.remove('active'));
        tab.classList.add('active');
        const showMatched = tab.dataset.tab === 'matched';
        panel.querySelector('#sl-track-list').style.display = showMatched ? '' : 'none';
        panel.querySelector('#sl-unmatched-list').style.display = showMatched ? 'none' : '';
      });
    });

    renderTrackList();
    renderUnmatchedList();
    renderFooter();
  }

  function showPanelForCurrentSubreddit() {
    const listView = document.getElementById('sl-list-view');
    const disabledView = document.getElementById('sl-disabled-view');
    const settingsView = document.getElementById('sl-settings-view');
    const title = document.getElementById('sl-header-title');
    const settingsBtn = document.getElementById('sl-settings-btn');
    const clearBtn = document.getElementById('sl-clear-btn');

    settingsView.style.display = 'none'; // always reset out of settings on a fresh open
    title.textContent = 'Crate';
    settingsBtn.textContent = '⚙';
    settingsBtn.setAttribute('data-tooltip', 'Settings');

    if (isSubredditAllowed()) {
      disabledView.style.display = 'none';
      listView.style.display = '';
      clearBtn.style.display = '';
    } else {
      disabledView.style.display = '';
      listView.style.display = 'none';
      clearBtn.style.display = 'none';
      renderDisabledView();
    }
  }

  function jumpToSubredditFilterSettings() {
    document.getElementById('sl-disabled-view').style.display = 'none';
    toggleSettingsView();
    setTimeout(() => {
      const subSection = [...document.querySelectorAll('.sl-settings-section')]
        .find((d) => d.querySelector('summary') && d.querySelector('summary').textContent.includes('Subreddit Filter'));
      if (subSection) {
        subSection.open = true;
        subSection.scrollIntoView({ block: 'nearest' });
      }
    }, 0);
  }

  function renderDisabledView() {
    const view = document.getElementById('sl-disabled-view');
    const sub = currentSubreddit();
    const label = sub ? `r/${sub}` : 'this subreddit';
    view.innerHTML = `
      <div class="sl-disabled-body">
        <svg viewBox="0 0 24 24" class="sl-disabled-icon" fill="none"><path d="M3 8.5 5.5 3h13L21 8.5M3 8.5V19a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8.5M3 8.5h18M9 8.5V12a3 3 0 0 0 6 0V8.5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
        <div class="sl-disabled-title">${escapeHtml(label)} isn't in the list</div>
        <div class="sl-disabled-sub">Crate only runs on subreddits you've added, so it stays out of the way everywhere else.</div>
        <button class="sl-btn" id="sl-enable-here-btn">Add ${escapeHtml(label)}</button>
        <button class="sl-btn sl-secondary" id="sl-manage-subs-btn">Manage subreddit list</button>
      </div>
    `;
    view.querySelector('#sl-enable-here-btn').addEventListener('click', () => {
      addSubreddit(sub);
      showPanelForCurrentSubreddit();
      scan(); // this page was never scanned while disabled, so run it now
    });
    view.querySelector('#sl-manage-subs-btn').addEventListener('click', jumpToSubredditFilterSettings);
  }

  function toggleSettingsView() {
    const listView = document.getElementById('sl-list-view');
    const disabledView = document.getElementById('sl-disabled-view');
    const settingsView = document.getElementById('sl-settings-view');
    const title = document.getElementById('sl-header-title');
    const settingsBtn = document.getElementById('sl-settings-btn');
    const clearBtn = document.getElementById('sl-clear-btn');
    const showingSettings = settingsView.style.display !== 'none';
    if (showingSettings) {
      settingsView.style.display = 'none';
      title.textContent = 'Crate';
      settingsBtn.textContent = '⚙';
      settingsBtn.setAttribute('data-tooltip', 'Settings');
      // Return to whichever view is actually correct for this subreddit,
      // rather than assuming the list view (it may not be enabled here).
      if (isSubredditAllowed()) {
        listView.style.display = '';
        disabledView.style.display = 'none';
        clearBtn.style.display = '';
      } else {
        listView.style.display = 'none';
        disabledView.style.display = '';
        clearBtn.style.display = 'none';
        renderDisabledView();
      }
    } else {
      renderSettings();
      settingsView.style.display = '';
      listView.style.display = 'none';
      disabledView.style.display = 'none';
      title.textContent = 'Settings';
      settingsBtn.textContent = '←';
      settingsBtn.setAttribute('data-tooltip', 'Back to tracks');
      clearBtn.style.display = 'none'; // clear-list doesn't apply while viewing settings
    }
  }

  function renderSettings() {
    const view = document.getElementById('sl-settings-view');
    const { id, secret } = getCredentials();

    view.innerHTML = `
      <div class="sl-settings-body">
        <details class="sl-settings-section" ${hasCredentials() ? '' : 'open'}>
          <summary>Spotify API Credentials</summary>
          <div class="sl-settings-section-body">
            <label class="sl-field-label">Spotify Client ID</label>
            <input id="sl-set-clientid" type="text" placeholder="Client ID" value="${escapeHtml(id)}">
            <label class="sl-field-label">Spotify Client Secret</label>
            <input id="sl-set-clientsecret" type="password" placeholder="Client Secret" value="${escapeHtml(secret)}">
            <button class="sl-btn" id="sl-save-creds-btn">Save Credentials</button>
            <div class="sl-status" id="sl-cred-status">
              <a href="https://developer.spotify.com/dashboard" target="_blank" rel="noopener noreferrer">Open Spotify Developer Dashboard →</a>
            </div>

            <div class="sl-hint">
              <div>In your app's <a href="https://developer.spotify.com/dashboard" target="_blank" rel="noopener noreferrer">Settings</a>, add this exact Redirect URI:</div>
              <div class="sl-code-row">
                <code>${REDIRECT_URI}</code>
                <button id="sl-copy-redirect" title="Copy">Copy</button>
              </div>
            </div>
          </div>
        </details>

        <details class="sl-settings-section">
          <summary>Subreddit Filter</summary>
          <div class="sl-settings-section-body">
            <label class="sl-field-label">Only run on these subreddits</label>
            <div class="sl-sub-chips" id="sl-sub-chips"></div>
            <div class="sl-sub-add-row">
              <input id="sl-sub-input" type="text" placeholder="subreddit name">
              <button class="sl-btn sl-secondary sl-btn-inline" id="sl-sub-add-btn">Add</button>
            </div>
            <button class="sl-btn sl-secondary" id="sl-sub-add-current-btn" style="display:none;"></button>
            <div class="sl-status" id="sl-subs-status">Leave empty to run on every subreddit.</div>
          </div>
        </details>

        <details class="sl-settings-section">
          <summary>Advanced</summary>
          <div class="sl-settings-section-body">
            <div class="sl-status" id="sl-cache-status" style="margin-top:0;">
              ${countCachedLookups()} cached search result(s) stored locally.
            </div>
            <button class="sl-btn sl-secondary" id="sl-clear-cache-btn">Clear Search Cache</button>
          </div>
        </details>

        ${isConnected() ? `
          <details class="sl-settings-section">
            <summary>Account</summary>
            <div class="sl-settings-section-body">
              <button class="sl-btn sl-secondary" id="sl-disconnect-btn">Disconnect Spotify Account</button>
            </div>
          </details>
        ` : ''}
      </div>
    `;

    view.querySelector('#sl-clear-cache-btn').addEventListener('click', () => {
      const n = clearSpotifyCache();
      view.querySelector('#sl-cache-status').textContent = `Cleared ${n} cached result(s).`;
    });

    view.querySelector('#sl-save-creds-btn').addEventListener('click', () => {
      const newId = view.querySelector('#sl-set-clientid').value.trim();
      const newSecret = view.querySelector('#sl-set-clientsecret').value.trim();
      GM_setValue('spotify_client_id', newId);
      GM_setValue('spotify_client_secret', newSecret);
      view.querySelector('#sl-cred-status').textContent = 'Saved.';
      renderFooter();
    });

    view.querySelector('#sl-copy-redirect').addEventListener('click', (e) => {
      GM_setClipboard(REDIRECT_URI);
      const btn = e.currentTarget;
      btn.textContent = 'Copied!';
      setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
    });

    renderSubredditChips(view);

    view.querySelector('#sl-sub-add-btn').addEventListener('click', () => {
      const input = view.querySelector('#sl-sub-input');
      addSubreddit(input.value);
      input.value = '';
      renderSubredditChips(view);
    });
    view.querySelector('#sl-sub-input').addEventListener('keydown', (e) => {
      if (e.key === 'Enter') view.querySelector('#sl-sub-add-btn').click();
    });

    const disconnectBtn = view.querySelector('#sl-disconnect-btn');
    if (disconnectBtn) {
      disconnectBtn.addEventListener('click', () => {
        GM_setValue('spotify_user_token', null);
        GM_setValue('spotify_user_refresh', null);
        GM_setValue('spotify_user_token_expiry', 0);
        renderSettings();
        renderFooter();
      });
    }
  }

  function renderFooter() {
    const footer = document.getElementById('sl-footer');
    if (!footer) return;

    if (!hasCredentials()) {
      footer.innerHTML = `<div class="sl-status">Add your Spotify Client ID/Secret in <a href="#" id="sl-goto-settings">Settings</a> to get started.</div>`;
      footer.querySelector('#sl-goto-settings').addEventListener('click', (e) => {
        e.preventDefault();
        toggleSettingsView();
      });
      return;
    }

    if (!isConnected()) {
      footer.innerHTML = `<button class="sl-btn" id="sl-connect-btn">Connect Spotify Account</button>`;
      footer.querySelector('#sl-connect-btn').addEventListener('click', async () => {
        footer.querySelector('#sl-connect-btn').textContent = 'Connecting…';
        try {
          await connectSpotifyAccount();
          renderFooter();
        } catch (e) {
          setStatus('Connection failed, see console for details.');
          console.error(e);
        }
      });
      return;
    }

    footer.innerHTML = `
      <div class="sl-segmented" id="sl-target-toggle">
        <button type="button" class="sl-segment active" data-value="existing">Existing playlist</button>
        <button type="button" class="sl-segment" data-value="new">New playlist</button>
      </div>
      <select id="sl-playlist-select"><option>Loading playlists…</option></select>
      <input id="sl-new-playlist-name" type="text" placeholder="New playlist name" style="display:none;">
      <button class="sl-btn" id="sl-add-btn">Add Selected Tracks</button>
      <div class="sl-status" id="sl-status"></div>
    `;

    const select = footer.querySelector('#sl-playlist-select');
    const nameInput = footer.querySelector('#sl-new-playlist-name');
    const targetToggle = footer.querySelector('#sl-target-toggle');
    let selectedTarget = 'existing';

    targetToggle.querySelectorAll('.sl-segment').forEach((seg) => {
      seg.addEventListener('click', () => {
        targetToggle.querySelectorAll('.sl-segment').forEach((s) => s.classList.remove('active'));
        seg.classList.add('active');
        selectedTarget = seg.dataset.value;
        const isNew = selectedTarget === 'new';
        select.style.display = isNew ? 'none' : '';
        nameInput.style.display = isNew ? '' : 'none';
      });
    });

    fetchMyPlaylists()
      .then((playlists) => {
        select.innerHTML = playlists.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
      })
      .catch(() => { select.innerHTML = '<option>Could not load playlists</option>'; });

    footer.querySelector('#sl-add-btn').addEventListener('click', async () => {
      const btn = footer.querySelector('#sl-add-btn');
      const tracks = [...selectedIds].map((id) => trackStore.get(id)).filter(Boolean);
      if (tracks.length === 0) { setStatus('Select at least one track first.'); return; }

      btn.disabled = true;
      setStatus('Working…');
      try {
        const isNew = selectedTarget === 'new';
        let playlistId;
        let existingUris = new Set();
        if (isNew) {
          const name = nameInput.value.trim() || 'Reddit finds';
          const me = await getMe();
          const playlist = await createPlaylist(me.id, name);
          playlistId = playlist.id;
          // brand new playlist, nothing to dedupe against
        } else {
          playlistId = select.value;
          setStatus('Checking for duplicates…');
          existingUris = await getPlaylistTrackUris(playlistId);
        }

        const newTracks = tracks.filter((t) => !existingUris.has(t.uri));
        const skipped = tracks.length - newTracks.length;

        if (newTracks.length > 0) {
          setStatus('Adding tracks…');
          await addTracksToPlaylist(playlistId, newTracks.map((t) => t.uri));
        }

        if (skipped > 0 && newTracks.length > 0) {
          setStatus(`Added ${newTracks.length} track(s), skipped ${skipped} already in the playlist.`);
        } else if (skipped > 0) {
          setStatus(`All ${skipped} selected track(s) were already in the playlist, nothing added.`);
        } else {
          setStatus(`Added ${newTracks.length} track(s) to the playlist.`);
        }
      } catch (e) {
        console.error(e);
        setStatus('Something went wrong, see console for details.');
      } finally {
        btn.disabled = false;
      }
    });
  }

  function setStatus(msg) {
    const el = document.getElementById('sl-status');
    if (el) el.textContent = msg;
  }

  // =========================================================================
  // SPOTIFY USER LOGIN (Authorization Code + PKCE), needed for playlists
  // =========================================================================

  function isConnected() {
    return !!GM_getValue('spotify_user_refresh', null);
  }

  function base64UrlEncode(buffer) {
    let str = '';
    const bytes = new Uint8Array(buffer);
    for (let i = 0; i < bytes.byteLength; i++) str += String.fromCharCode(bytes[i]);
    return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  }

  function generateRandomString(length) {
    const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let text = '';
    crypto.getRandomValues(new Uint8Array(length)).forEach((v) => { text += possible[v % possible.length]; });
    return text;
  }

  async function sha256(plain) {
    return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain));
  }

  async function connectSpotifyAccount() {
    const { id } = getCredentials();
    if (!id) throw new Error('No client ID set');

    const verifier = generateRandomString(64);
    const challenge = base64UrlEncode(await sha256(verifier));
    const state = generateRandomString(16);
    GM_setValue('spotify_pkce_verifier', verifier);
    GM_setValue('spotify_pkce_state', state);

    const params = new URLSearchParams({
      client_id: id,
      response_type: 'code',
      redirect_uri: REDIRECT_URI,
      code_challenge_method: 'S256',
      code_challenge: challenge,
      scope: USER_SCOPES,
      state,
    });
    window.open(`https://accounts.spotify.com/authorize?${params}`, 'spotify-auth', 'width=480,height=720');

    return new Promise((resolve, reject) => {
      function handler(event) {
        if (event.data && event.data.type === 'spotify-auth-code') {
          window.removeEventListener('message', handler);
          exchangeCodeForToken(event.data.code).then(resolve).catch(reject);
        }
      }
      window.addEventListener('message', handler);
    });
  }

  // Shared by getToken/exchangeCodeForToken/getUserToken below; all three
  // POST to the same endpoint and only differ in grant params/headers.
  function postTokenRequest(bodyParams, extraHeaders) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'POST',
        url: 'https://accounts.spotify.com/api/token',
        headers: Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, extraHeaders || {}),
        data: new URLSearchParams(bodyParams).toString(),
        onload: (res) => {
          try {
            const json = JSON.parse(res.responseText);
            if (json.access_token) resolve(json);
            else reject(new Error('Token request failed: ' + res.responseText));
          } catch (e) {
            reject(e);
          }
        },
        onerror: reject,
      });
    });
  }

  function exchangeCodeForToken(code) {
    const { id } = getCredentials();
    const verifier = GM_getValue('spotify_pkce_verifier', '');
    return postTokenRequest({
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: id,
      code_verifier: verifier,
    }).then((json) => {
      GM_setValue('spotify_user_token', json.access_token);
      GM_setValue('spotify_user_refresh', json.refresh_token);
      GM_setValue('spotify_user_token_expiry', Date.now() + (json.expires_in - 60) * 1000);
      return json.access_token;
    });
  }

  function getUserToken() {
    const token = GM_getValue('spotify_user_token', null);
    const expiry = GM_getValue('spotify_user_token_expiry', 0);
    if (token && Date.now() < expiry) return Promise.resolve(token);

    const refresh = GM_getValue('spotify_user_refresh', null);
    if (!refresh) return Promise.reject(new Error('not_connected'));
    const { id } = getCredentials();

    return postTokenRequest({ grant_type: 'refresh_token', refresh_token: refresh, client_id: id })
      .then((json) => {
        GM_setValue('spotify_user_token', json.access_token);
        GM_setValue('spotify_user_token_expiry', Date.now() + (json.expires_in - 60) * 1000);
        if (json.refresh_token) GM_setValue('spotify_user_refresh', json.refresh_token);
        return json.access_token;
      });
  }

  function apiRequest(method, path, body) {
    return getUserToken().then((token) => new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method,
        url: `https://api.spotify.com/v1${path}`,
        headers: {
          Authorization: `Bearer ${token}`,
          'Content-Type': 'application/json',
        },
        data: body ? JSON.stringify(body) : undefined,
        onload: (res) => {
          try {
            resolve(res.responseText ? JSON.parse(res.responseText) : {});
          } catch (e) { reject(e); }
        },
        onerror: reject,
      });
    }));
  }

  function getMe() { return apiRequest('GET', '/me'); }

  function fetchMyPlaylists() {
    return apiRequest('GET', '/me/playlists?limit=50').then((json) => json.items || []);
  }

  function createPlaylist(userId, name) {
    return apiRequest('POST', `/users/${encodeURIComponent(userId)}/playlists`, { name, public: false });
  }

  function getPlaylistTrackUris(playlistId) {
    const uris = new Set();
    function fetchPage(offset) {
      return apiRequest('GET', `/playlists/${playlistId}/tracks?fields=items(track(uri)),next,total&limit=100&offset=${offset}`)
        .then((json) => {
          (json.items || []).forEach((item) => { if (item.track && item.track.uri) uris.add(item.track.uri); });
          if (json.next) return fetchPage(offset + 100);
        });
    }
    return fetchPage(0).then(() => uris);
  }

  function addTracksToPlaylist(playlistId, uris) {
    // Spotify allows max 100 URIs per request.
    const chunks = [];
    for (let i = 0; i < uris.length; i += 100) chunks.push(uris.slice(i, i + 100));
    return chunks.reduce(
      (p, chunk) => p.then(() => apiRequest('POST', `/playlists/${playlistId}/tracks`, { uris: chunk })),
      Promise.resolve()
    );
  }

  // ---- Tooltips -------------------------------------------------------------
  // Positioned via JS against the viewport (not CSS ::after nested inside the
  // panel) so hover/rotate effects on the target button, or the panel's own
  // overflow:hidden clipping, can never interfere with where it renders.

  function initTooltips() {
    let tipEl = null;

    function place(target) {
      if (!tipEl) return;
      const rect = target.getBoundingClientRect();
      const tipRect = tipEl.getBoundingClientRect();
      let top = rect.bottom + 8;
      let left = rect.right - tipRect.width;
      if (top + tipRect.height > window.innerHeight - 4) top = rect.top - tipRect.height - 8;
      if (left < 4) left = 4;
      tipEl.style.top = `${top}px`;
      tipEl.style.left = `${left}px`;
    }

    document.addEventListener('mouseover', (e) => {
      const target = e.target.closest('[data-tooltip]');
      if (!target || tipEl) return;
      const text = target.getAttribute('data-tooltip');
      if (!text) return;
      tipEl = document.createElement('div');
      tipEl.className = 'sl-live-tooltip';
      tipEl.textContent = text;
      document.body.appendChild(tipEl);
      place(target);
      requestAnimationFrame(() => tipEl && tipEl.classList.add('sl-live-tooltip-visible'));
    });

    document.addEventListener('mouseout', (e) => {
      const target = e.target.closest('[data-tooltip]');
      if (!target || !tipEl) return;
      tipEl.remove();
      tipEl = null;
    });
  }

  // ---- Boot ---------------------------------------------------------------

  window.addEventListener('load', () => {
    buildSidebar();
    initTooltips();
    updateToggleTuckedState();
    scan();
    observer.observe(document.body, { childList: true, subtree: true });
    // Reddit is a single-page app; the URL (and therefore the current
    // subreddit) can change without a full reload, so re-check periodically
    // rather than only once at load.
    setInterval(updateToggleTuckedState, 2000);
  });
})();