VOD Highlight Analyzer

Analyzes chat activity of YouTube, Twitch and Kick VODs and displays a clickable density chart to jump to highlights.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         VOD Highlight Analyzer
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Analyzes chat activity of YouTube, Twitch and Kick VODs and displays a clickable density chart to jump to highlights.
// @author       TheDarkEnjoyer
// @match        *://*.youtube.com/*
// @match        *://*.twitch.tv/*
// @match        *://*.kick.com/*
// @grant        none
// @run-at       document-end
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';

  // Prevent script from running in iframes / embedded players on other sites
  if (window.self !== window.top) return;

  const CURRENT_VERSION = '2.1';

  const isYouTube = window.location.hostname.includes('youtube.com');
  const isTwitch = window.location.hostname.includes('twitch.tv');
  const isKick = window.location.hostname.includes('kick.com');

  // Trusted Types Policy for YouTube CSP compliance
  let policy = null;
  if (window.trustedTypes && window.trustedTypes.createPolicy) {
    try {
      policy = window.trustedTypes.createPolicy('ha-policy', {
        createHTML: (string) => string
      });
    } catch (e) {
      console.warn('Highlight Analyzer: Trusted Types policy creation failed.', e);
    }
  }

  function sanitizeHTML(htmlString) {
    return policy ? policy.createHTML(htmlString) : htmlString;
  }

  // State variables
  let state = {
    isScanning: false,
    isPaused: false,
    progress: 0,
    totalMessages: 0,
    peakRate: 0,
    averageRate: 0,
    messages: [], // Array of { offset: Number, text: String, author: String }
    binnedData: [], // Binned data for chart [{x: timeSec, y: count, messages: []}]
    filteredBinnedData: [], // Filtered binned data [{x: timeSec, y: count}]
    spikes: [], // Top detected spikes
    duration: 0,
    apiKey: null,
    context: null,
    initialToken: null,
    chart: null,
    abortController: null,
    filterQuery: "", // Active search query
    customFilters: [], // Configured custom tags
    emoteCounts: {}, // Track frequency of custom emojis { ':shortcut:': count }
    detectedEmoteFilters: [], // Top auto-detected emote filters
    maxAutoFilters: 10,
    includeStandardEmojisInAutoFilters: true,
    mainGain: 1.0, // Main graph peak amplification gain multiplier
    filterGain: 1.0, // Filtered graph peak amplification gain multiplier
    seekOffset: 10, // Lead-in seek offset in seconds
    seekMode: 'manual', // Seek mode: 'manual' (constant buffer) or 'auto' (dynamic valley detection)
    isCachedLoad: false, // Tracks if current data is loaded from IndexedDB cache
    tooltipMessageOffset: 0, // Current index offset for rotating tooltip comments
    hoveredBinIndex: null, // Currently hovered chart bin index
    hoveredCaretX: null, // Bounding caret X for HTML tooltip positioning
    hoveredCaretY: null, // Bounding caret Y for HTML tooltip positioning
    isCollapsed: true, // Panel starts collapsed by default
    blacklistEnabled: false,
    blacklistQuery: "",
    blacklistCaseSensitive: false,
    searchQuery: "",
    sentimentEnabled: true,
    sentimentGain: 1.0,
    dynamicEmoteSentiment: {},
    sentimentBinnedData: [],
    customEmoteCurves: {},
    anchorPositiveCurve: [],
    anchorLaughterCurve: [],
    anchorNegativeCurve: [],
    showSuperchatsOnGraph: true,
    showSuperchatTeal: true,
    showSuperchatYellow: true,
    showSuperchatPink: true,
    showSuperchatRed: true,
    filterSuperchatsOnly: false,
    superChatPoints: [],
    hoveredSuperChatPoint: null,
    maxSpikes: 5,
    removedSpikes: []
  };
  window.HighlightAnalyzerState = state; // Expose state for automated testing
  state.update = () => { updateBinsAndSpikes(); updateUI(); };

  // Zero-second token pre-fetching promise
  let zeroTokenPromise = null;
  // Tooltip comments rotation interval
  let tooltipRotationInterval = null;

  // Configuration constants
  const FETCH_DELAY_MS = 150;
  const NUM_BINS = 150; // Dynamic resolution for chart
  const MIN_PEAK_SPACING_SEC = 90; // Peak distance spacing for highlight spikes

  const DEFAULT_FILTERS = [
    { id: 'laughter', name: '😂 Laughter', keywords: 'lol,lmao,😂,haha,xd,keke' },
    { id: 'shock', name: '😮 Shock', keywords: 'wow,pog,wtf,😮,omg,no way' },
    { id: 'hype', name: '🔥 Hype', keywords: 'hype,let\'s go,letsgo,🔥,ez,w ' }
  ];

  const SENTIMENT_ANCHORS = {
    positive: ['🔥', '😮', '😱', 'pog', 'hype', 'wow', 'pogchamp', 'letsgo', 'let\'s go', 'ez', 'w'],
    laughter: ['😂', '🤣', 'lol', 'lmao', 'haha', 'xd', 'keke', 'lul', 'lulw'],
    negative: ['😭', '😢', '😡', '🤬', 'wtf', 'cringe', 'fail', 'noob', 'babyrage', 'biblethump', 'wutface', 'residentsleeper', 'notlikethis']
  };

  const BASE_EMOTE_SENTIMENT = {
    // Joy / Laughter (Positive)
    '😂': 0.8, '🤣': 0.9, 'lol': 0.5, 'lmao': 0.7, 'haha': 0.5, 'xd': 0.5, 'keke': 0.4, 'lul': 0.8, 'lulw': 0.8, 'kekw': 0.8,
    // Hype / Excitement (Positive)
    '🔥': 1.0, 'wow': 0.8, 'pog': 1.2, 'pogchamp': 1.5, 'pogu': 1.2, 'pagman': 1.2, 'peepoarrive': 0.8, 'hypers': 1.0, 'ez': 0.6, 'w': 0.6,
    // Love / Appreciation (Positive)
    '<3': 1.0, 'ayaya': 0.8, 'heyguys': 0.6, 'seemsgood': 0.7, 'kappapride': 0.8, 'feelsgoodman': 1.0, 'bloodtrail': 0.7, 'blessrng': 0.5,
    // Sadness / Crying (Negative)
    '😭': -0.6, '😢': -0.7, 'biblethump': -0.8, 'sadge': -0.8, 'peeposad': -0.8, 'feelsbadman': -0.8,
    // Anger / Rage (Negative)
    '😡': -0.8, '🤬': -0.9, 'babyrage': -0.8, 'rage': -0.8, 'malting': -0.7,
    // Shock / Fear / Disgust (Negative/Skeptical)
    'wutface': -1.2, 'notlikethis': -1.0, 'residentsleeper': -1.0, 'coolstorybob': -0.8, 'wtf': -0.8, 'cringe': -0.9, 'fail': -0.7, 'kappa': -0.3
  };

  // Twitch specific helper functions
  function getTwitchVideoId() {
    const match = window.location.pathname.match(/\/videos?\/(\d+)/);
    return match ? match[1] : null;
  }

  function extractTwitchMetadata() {
    const videoId = getTwitchVideoId();
    if (videoId) {
      state.initialToken = "twitch"; // truthy value to allow insertion
      state.duration = getActiveVideoDuration() || 3600;
    } else {
      state.initialToken = null;
    }
  }

  // Kick specific helper functions
  function getKickVideoId() {
    const match = window.location.pathname.match(/\/videos?\/([a-f0-9-]+)/);
    return match ? match[1] : null;
  }

  function getKickChannelSlug() {
    const match = window.location.pathname.match(/\/([^\/]+)\/videos?\/[a-f0-9-]+/);
    return match ? match[1] : null;
  }

  function extractKickMetadata() {
    const videoId = getKickVideoId();
    if (videoId) {
      state.initialToken = "kick"; // truthy value to allow insertion
      state.duration = getActiveVideoDuration() || 3600;
    } else {
      state.initialToken = null;
    }
  }

  async function fetchKickChannelInfo(channelSlug) {
    const response = await fetch(`https://kick.com/api/v2/channels/${channelSlug}`);
    if (!response.ok) {
      throw new Error(`Failed to fetch Kick channel info: ${response.status}`);
    }
    return await response.json();
  }

  async function fetchKickVideoInfo(videoUuid) {
    const response = await fetch(`https://kick.com/api/v1/video/${videoUuid}`);
    if (!response.ok) {
      throw new Error(`Failed to fetch Kick video info: ${response.status}`);
    }
    return await response.json();
  }

  async function fetchKickCommentsPage(channelId, startTimeStr, cursor, signal) {
    let url;
    if (cursor) {
      url = `https://web.kick.com/api/v1/chat/${channelId}/history?cursor=${cursor}`;
    } else {
      url = `https://web.kick.com/api/v1/chat/${channelId}/history?start_time=${encodeURIComponent(startTimeStr)}`;
    }
    const response = await fetch(url, { signal });
    if (!response.ok) {
      throw new Error(`Failed to fetch Kick chat history: ${response.status}`);
    }
    return await response.json();
  }

  async function fetchTwitchCommentsPage(videoId, offsetSeconds, cursor, integrityToken, deviceId, signal) {
    const payload = [{
      operationName: "VideoCommentsByOffsetOrCursor",
      variables: {
        videoID: videoId
      },
      extensions: {
        persistedQuery: {
          version: 1,
          sha256Hash: "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
        }
      }
    }];

    if (cursor) {
      payload[0].variables.cursor = cursor;
    } else {
      payload[0].variables.contentOffsetSeconds = offsetSeconds;
    }

    const headers = {
      "Content-Type": "application/json",
      "Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko"
    };

    if (integrityToken) {
      headers["Client-Integrity"] = integrityToken;
    }
    if (deviceId) {
      headers["X-Device-Id"] = deviceId;
    }

    const response = await fetch("https://gql.twitch.tv/gql", {
      method: "POST",
      signal,
      headers,
      body: JSON.stringify(payload)
    });

    if (!response.ok) {
      throw new Error(`HTTP error ${response.status}`);
    }

    const result = await response.json();

    // Check if there are GraphQL-level errors (like integrity failure)
    if (result[0]?.errors && result[0].errors.length > 0) {
      throw new Error(result[0].errors[0].message || "GraphQL query error");
    }

    return result[0]?.data?.video?.comments;
  }

  // Styles Injection
  const STYLES = `
    #highlight-analyzer-panel {
      background: var(--yt-spec-general-background-a, #0f0f0f);
      border: 1px solid var(--yt-spec-10-percent-layer, rgba(255, 255, 255, 0.1));
      border-radius: 12px;
      padding: 16px;
      margin: 16px 0;
      font-family: Roboto, Arial, sans-serif;
      color: var(--yt-spec-text-primary, #fff);
      box-sizing: border-box;
      transition: all 0.3s ease;
    }
    .ha-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 12px;
    }
    .ha-title-row {
      display: flex;
      align-items: center;
      gap: 10px;
    }
    .ha-title {
      font-size: 18px;
      font-weight: 600;
      margin: 0;
      color: var(--yt-spec-text-primary, #fff);
    }
    .ha-badge {
      font-size: 11px;
      padding: 2px 6px;
      border-radius: 4px;
      font-weight: 500;
      text-transform: uppercase;
    }
    .ha-badge.scanning {
      background: rgba(255, 0, 0, 0.2);
      color: #ff4e4e;
      border: 1px solid rgba(255, 0, 0, 0.4);
    }
    .ha-badge.paused {
      background: rgba(255, 165, 0, 0.2);
      color: #ffa500;
      border: 1px solid rgba(255, 165, 0, 0.4);
    }
    .ha-badge.loaded {
      background: rgba(0, 255, 0, 0.15);
      color: #39ff14;
      border: 1px solid rgba(0, 255, 0, 0.3);
    }
    .ha-badge.cached {
      background: rgba(0, 191, 255, 0.15);
      color: #00bfff;
      border: 1px solid rgba(0, 191, 255, 0.3);
    }
    .ha-badge.idle {
      background: rgba(255, 255, 255, 0.1);
      color: var(--yt-spec-text-secondary, #aaa);
      border: 1px solid rgba(255, 255, 255, 0.2);
    }
    .ha-controls {
      display: flex;
      gap: 8px;
    }
    .ha-btn {
      background: var(--yt-spec-10-percent-layer, rgba(255, 255, 255, 0.1));
      color: var(--yt-spec-text-primary, #fff);
      border: none;
      padding: 6px 14px;
      border-radius: 18px;
      font-size: 13px;
      font-weight: 500;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 4px;
      transition: background 0.2s ease;
    }
    .ha-btn:hover {
      background: var(--yt-spec-30-percent-layer, rgba(255, 255, 255, 0.2));
    }
    .ha-btn-primary {
      background: var(--yt-spec-badge-red, #ff0000);
      color: #fff;
    }
    .ha-btn-primary:hover {
      background: #cc0000;
    }
    .ha-progress-track {
      background: rgba(255, 255, 255, 0.08);
      height: 4px;
      border-radius: 2px;
      margin-bottom: 16px;
      overflow: hidden;
      display: none;
    }
    .ha-progress-bar {
      background: var(--yt-spec-badge-red, #ff0000);
      height: 100%;
      width: 0%;
      transition: width 0.3s ease;
    }
    .ha-chart-container {
      position: relative;
      width: 100%;
      height: 200px;
      margin-bottom: 16px;
    }

    /* Settings panel */
    .ha-settings-panel {
      background: rgba(255, 255, 255, 0.02);
      border: 1px solid rgba(255, 255, 255, 0.05);
      border-radius: 8px;
      padding: 8px 12px;
      margin-bottom: 16px;
    }
    .ha-settings-title {
      font-size: 13px;
      font-weight: 500;
      color: var(--yt-spec-text-secondary, #aaa);
      cursor: pointer;
      user-select: none;
      outline: none;
    }
    .ha-settings-content {
      margin-top: 10px;
      display: flex;
      flex-direction: column;
      gap: 10px;
    }
    .ha-setting-row {
      display: flex;
      align-items: center;
      gap: 12px;
      font-size: 12px;
    }
    .ha-setting-label {
      width: 150px;
      color: var(--yt-spec-text-secondary, #aaa);
      cursor: help;
      border-bottom: 1px dotted rgba(255, 255, 255, 0.35);
      display: inline-block;
    }
    .ha-setting-slider {
      flex: 1;
      height: 4px;
      background: rgba(255, 255, 255, 0.1);
      border-radius: 2px;
      outline: none;
      -webkit-appearance: none;
    }
    .ha-setting-slider::-webkit-slider-thumb {
      -webkit-appearance: none;
      width: 12px;
      height: 12px;
      background: var(--yt-spec-badge-red, #ff0000);
      border-radius: 50%;
      cursor: pointer;
      transition: transform 0.1s;
    }
    .ha-setting-slider::-webkit-slider-thumb:hover {
      transform: scale(1.2);
    }
    #ha-slider-filter-gain::-webkit-slider-thumb {
      background: #ffa500;
    }
    .ha-setting-value {
      width: 100px;
      text-align: right;
      font-family: monospace;
      color: var(--yt-spec-text-primary, #fff);
    }

    /* Filters Subsystem Styles */
    .ha-filter-section {
      background: rgba(255, 255, 255, 0.02);
      border: 1px solid rgba(255, 255, 255, 0.05);
      border-radius: 8px;
      padding: 12px;
      margin-bottom: 16px;
    }
    .ha-filter-row {
      display: flex;
      gap: 8px;
      margin-bottom: 10px;
    }
    .ha-filter-input {
      flex: 1;
      background: rgba(255, 255, 255, 0.05);
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 6px;
      padding: 6px 12px;
      font-size: 13px;
      color: #fff;
      outline: none;
    }
    .ha-filter-input:focus {
      border-color: rgba(255, 0, 0, 0.4);
      background: rgba(255, 255, 255, 0.07);
    }
    .ha-tags-container {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      align-items: center;
    }
    .ha-tag-pill {
      background: rgba(255, 255, 255, 0.05);
      border: 1px solid rgba(255, 255, 255, 0.08);
      border-radius: 14px;
      padding: 4px 10px;
      font-size: 12px;
      display: inline-flex;
      align-items: center;
      gap: 6px;
      cursor: pointer;
      user-select: none;
      transition: all 0.2s ease;
    }
    .ha-tag-pill:hover {
      background: rgba(255, 255, 255, 0.1);
      border-color: rgba(255, 255, 255, 0.15);
    }
    .ha-tag-pill.active {
      background: rgba(255, 165, 0, 0.15);
      border-color: rgba(255, 165, 0, 0.4);
      color: #ffa500;
    }
    .ha-tag-pill.auto-emote {
      border: 1px dashed rgba(255, 165, 0, 0.4);
      background: rgba(255, 165, 0, 0.04);
    }
    .ha-tag-pill.auto-emote:hover {
      background: rgba(255, 165, 0, 0.08);
      border-color: rgba(255, 165, 0, 0.6);
    }
    .ha-tag-edit-btn, .ha-tag-delete-btn {
      font-size: 10px;
      opacity: 0.4;
      cursor: pointer;
      padding: 2px;
      border-radius: 50%;
      display: inline-flex;
      justify-content: center;
      align-items: center;
      width: 14px;
      height: 14px;
      transition: all 0.15s ease;
    }
    .ha-tag-edit-btn:hover {
      opacity: 1;
      background: rgba(255, 255, 255, 0.15);
      color: #fff;
    }
    .ha-tag-delete-btn:hover {
      opacity: 1;
      background: rgba(255, 0, 0, 0.25);
      color: #ff4e4e;
    }
    .ha-add-tag-btn {
      background: transparent;
      border: 1px dashed rgba(255, 255, 255, 0.2);
      color: var(--yt-spec-text-secondary, #aaa);
      border-radius: 14px;
      padding: 4px 10px;
      font-size: 12px;
      cursor: pointer;
      display: inline-flex;
      align-items: center;
      height: 24px;
      transition: all 0.2s ease;
    }
    .ha-add-tag-btn:hover {
      border-color: rgba(255, 255, 255, 0.4);
      color: #fff;
      background: rgba(255, 255, 255, 0.03);
    }
    .ha-filter-form {
      background: rgba(0, 0, 0, 0.3);
      border: 1px solid rgba(255, 255, 255, 0.08);
      border-radius: 6px;
      padding: 10px;
      margin-top: 8px;
      display: flex;
      flex-direction: column;
      gap: 8px;
    }
    .ha-form-row {
      display: flex;
      gap: 8px;
    }
    .ha-form-input {
      background: rgba(255, 255, 255, 0.04);
      border: 1px solid rgba(255, 255, 255, 0.08);
      border-radius: 4px;
      padding: 4px 8px;
      font-size: 12px;
      color: #fff;
      outline: none;
    }
    .ha-form-input:focus {
      border-color: rgba(255, 165, 0, 0.4);
    }
    .ha-form-buttons {
      display: flex;
      justify-content: flex-end;
      gap: 6px;
    }
    .ha-form-btn {
      border: none;
      padding: 4px 10px;
      border-radius: 4px;
      font-size: 11px;
      font-weight: 500;
      cursor: pointer;
    }
    .ha-form-btn-save {
      background: #ffa500;
      color: #000;
    }
    .ha-form-btn-save:hover {
      background: #e59400;
    }
    .ha-form-btn-cancel {
      background: rgba(255, 255, 255, 0.08);
      color: #fff;
    }
    .ha-form-btn-cancel:hover {
      background: rgba(255, 255, 255, 0.15);
    }

    .ha-stats-row {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
      gap: 12px;
      margin-bottom: 16px;
    }
    .ha-stat-card {
      background: rgba(255, 255, 255, 0.03);
      border: 1px solid rgba(255, 255, 255, 0.05);
      border-radius: 8px;
      padding: 10px;
      text-align: center;
      position: relative;
    }
    .ha-stat-value {
      font-size: 16px;
      font-weight: 600;
      color: var(--yt-spec-text-primary, #fff);
      margin-top: 4px;
    }
    .ha-stat-label {
      font-size: 11px;
      color: var(--yt-spec-text-secondary, #aaa);
    }
    .ha-highlights-panel {
      background: rgba(255, 255, 255, 0.02);
      border: 1px solid rgba(255, 255, 255, 0.05);
      border-radius: 8px;
      padding: 12px;
    }
    .ha-highlights-title {
      font-size: 14px;
      font-weight: 600;
      margin: 0 0 10px 0;
      color: var(--yt-spec-text-primary, #fff);
      display: flex;
      align-items: center;
      gap: 6px;
    }
    .ha-spikes-list {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    .ha-spike-pill {
      background: rgba(255, 255, 255, 0.06);
      border: 1px solid rgba(255, 255, 255, 0.08);
      color: var(--yt-spec-text-primary, #fff);
      padding: 6px 12px;
      border-radius: 16px;
      font-size: 12px;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 6px;
      transition: all 0.2s ease;
    }
    .ha-spike-pill:hover {
      background: rgba(255, 0, 0, 0.15);
      border-color: rgba(255, 0, 0, 0.4);
      transform: translateY(-1px);
    }
    .ha-spike-pill.hype {
      border-color: rgba(255, 78, 78, 0.4);
      background: rgba(255, 78, 78, 0.08);
    }
    .ha-spike-pill.hype:hover {
      background: rgba(255, 78, 78, 0.2);
      border-color: rgba(255, 78, 78, 0.6);
    }
    .ha-spike-pill.funny {
      border-color: rgba(255, 215, 0, 0.4);
      background: rgba(255, 215, 0, 0.08);
    }
    .ha-spike-pill.funny:hover {
      background: rgba(255, 215, 0, 0.2);
      border-color: rgba(255, 215, 0, 0.6);
    }
    .ha-spike-pill.fail {
      border-color: rgba(147, 112, 219, 0.4);
      background: rgba(147, 112, 219, 0.08);
    }
    .ha-spike-pill.fail:hover {
      background: rgba(147, 112, 219, 0.2);
      border-color: rgba(147, 112, 219, 0.6);
    }
    .ha-spike-pill.hype .ha-spike-time {
      color: #ff4e4e;
    }
    .ha-spike-pill.funny .ha-spike-time {
      color: #ffd700;
    }
    .ha-spike-pill.fail .ha-spike-time {
      color: #ba55d3;
    }
    .ha-spike-time {
      font-weight: bold;
      color: #ff4e4e;
    }
    .ha-spike-rate {
      color: var(--yt-spec-text-secondary, #aaa);
      font-size: 11px;
    }
    .ha-error {
      background: rgba(255, 0, 0, 0.1);
      border: 1px solid rgba(255, 0, 0, 0.2);
      color: #ff4e4e;
      border-radius: 6px;
      padding: 8px 12px;
      font-size: 13px;
      margin-bottom: 12px;
      display: none;
    }

    #ha-chart-tooltip {
      position: absolute;
      background: rgba(15, 15, 15, 0.95);
      backdrop-filter: blur(8px);
      border: 1px solid rgba(255, 255, 255, 0.15);
      border-radius: 8px;
      color: #fff;
      padding: 10px;
      pointer-events: none;
      font-family: Roboto, Arial, sans-serif;
      font-size: 11px;
      width: 380px;
      min-height: 170px;
      height: auto;
      box-sizing: border-box;
      box-shadow: 0 8px 24px rgba(0,0,0,0.6);
      transition: opacity 0.1s ease;
      z-index: 10000;
      display: flex;
      flex-direction: column;
      gap: 6px;
      opacity: 0;
    }
    .ha-tooltip-title {
      font-weight: bold;
      font-size: 12px;
      color: #ff4e4e;
      border-bottom: 1px solid rgba(255, 255, 255, 0.1);
      padding-bottom: 4px;
      display: flex;
      justify-content: space-between;
    }
    .ha-tooltip-metrics {
      display: flex;
      flex-direction: column;
      gap: 2px;
      font-size: 11px;
      color: #aaa;
    }
    .ha-tooltip-metric {
      display: flex;
      justify-content: space-between;
    }
    .ha-tooltip-metric-label {
      color: #aaa;
    }
    .ha-tooltip-metric-value {
      color: #fff;
      font-weight: 600;
    }
    .ha-tooltip-comments {
      display: flex;
      flex-direction: column;
      gap: 3px;
      margin-top: 2px;
      border-top: 1px dashed rgba(255, 255, 255, 0.1);
      padding-top: 4px;
    }
    .ha-tooltip-comment {
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      font-size: 11px;
      color: #eee;
    }
    .ha-tooltip-author {
      font-weight: bold;
      color: #ffa500;
      margin-right: 4px;
    }

    /* Search logs subsystem styles */
    .ha-search-panel {
      background: rgba(255, 255, 255, 0.02);
      border: 1px solid rgba(255, 255, 255, 0.05);
      border-radius: 8px;
      padding: 8px 12px;
      margin-bottom: 16px;
    }
    .ha-search-results-container {
      max-height: 200px;
      overflow-y: auto;
      display: flex;
      flex-direction: column;
      gap: 4px;
      margin-top: 10px;
      padding-right: 4px;
    }
    .ha-search-results-container::-webkit-scrollbar {
      width: 6px;
    }
    .ha-search-results-container::-webkit-scrollbar-track {
      background: rgba(255, 255, 255, 0.02);
      border-radius: 3px;
    }
    .ha-search-results-container::-webkit-scrollbar-thumb {
      background: rgba(255, 255, 255, 0.15);
      border-radius: 3px;
    }
    .ha-search-results-container::-webkit-scrollbar-thumb:hover {
      background: rgba(255, 255, 255, 0.3);
    }
    .ha-search-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 6px 10px;
      background: rgba(255, 255, 255, 0.02);
      border: 1px solid rgba(255, 255, 255, 0.04);
      border-radius: 6px;
      gap: 12px;
      transition: all 0.2s ease;
    }
    .ha-search-item:hover {
      background: rgba(255, 255, 255, 0.06);
      border-color: rgba(255, 255, 255, 0.08);
    }
  `;

  // Inject general styles
  function injectStyles() {
    if (document.getElementById('ha-global-styles')) return;
    const styleEl = document.createElement('style');
    styleEl.id = 'ha-global-styles';
    styleEl.textContent = STYLES;
    document.head.appendChild(styleEl);
  }

  // Load filters from localStorage with fallback defaults
  function loadFilters() {
    try {
      const stored = localStorage.getItem('ha_custom_filters');
      if (stored) {
        state.customFilters = JSON.parse(stored);
      } else {
        state.customFilters = [...DEFAULT_FILTERS];
        localStorage.setItem('ha_custom_filters', JSON.stringify(state.customFilters));
      }
    } catch (e) {
      console.warn('Highlight Analyzer: Failed to load filters from localStorage.', e);
      state.customFilters = [...DEFAULT_FILTERS];
    }
  }

  // Save custom filters to localStorage
  function saveFilters() {
    try {
      localStorage.setItem('ha_custom_filters', JSON.stringify(state.customFilters));
    } catch (e) {
      console.error('Highlight Analyzer: Failed to save filters to localStorage.', e);
    }
  }

  // Load seek offset from localStorage
  function loadSeekOffset() {
    try {
      const stored = localStorage.getItem('ha_seek_offset');
      if (stored !== null) {
        state.seekOffset = parseInt(stored, 10);
      }
      const mode = localStorage.getItem('ha_seek_mode');
      if (mode !== null) {
        state.seekMode = mode;
      } else {
        state.seekMode = 'manual';
      }
    } catch (e) {
      console.warn('Highlight Analyzer: Failed to load seek settings from localStorage.', e);
    }
  }

  // Save seek offset to localStorage
  function saveSeekOffset() {
    try {
      localStorage.setItem('ha_seek_offset', state.seekOffset.toString());
      localStorage.setItem('ha_seek_mode', state.seekMode);
    } catch (e) {
      console.error('Highlight Analyzer: Failed to save seek settings to localStorage.', e);
    }
  }

  function getCurrentVideoId() {
    if (isYouTube) {
      return new URLSearchParams(window.location.search).get('v');
    } else if (isTwitch) {
      return getTwitchVideoId();
    } else if (isKick) {
      return getKickVideoId();
    }
    return null;
  }

  function loadRemovedSpikes() {
    const videoId = getCurrentVideoId();
    if (!videoId) {
      state.removedSpikes = [];
      return;
    }
    try {
      const data = localStorage.getItem('ha_removed_spikes_' + videoId);
      state.removedSpikes = data ? JSON.parse(data) : [];
    } catch (e) {
      console.warn('Highlight Analyzer: Failed to load removed spikes.', e);
      state.removedSpikes = [];
    }
  }

  function saveRemovedSpikes() {
    const videoId = getCurrentVideoId();
    if (!videoId) return;
    try {
      localStorage.setItem('ha_removed_spikes_' + videoId, JSON.stringify(state.removedSpikes));
    } catch (e) {
      console.error('Highlight Analyzer: Failed to save removed spikes.', e);
    }
  }

  // Load settings from localStorage
  function loadSettings() {
    try {
      const blacklistEnabled = localStorage.getItem('ha_blacklist_enabled');
      if (blacklistEnabled !== null) {
        state.blacklistEnabled = blacklistEnabled === 'true';
      } else {
        state.blacklistEnabled = false;
      }

      const blacklistCaseSensitive = localStorage.getItem('ha_blacklist_case_sensitive');
      if (blacklistCaseSensitive !== null) {
        state.blacklistCaseSensitive = blacklistCaseSensitive === 'true';
      } else {
        state.blacklistCaseSensitive = false;
      }

      const blacklistQuery = localStorage.getItem('ha_blacklist_query');
      if (blacklistQuery !== null) {
        state.blacklistQuery = blacklistQuery;
      } else {
        state.blacklistQuery = "";
      }

      const sentimentEnabled = localStorage.getItem('ha_sentiment_enabled');
      if (sentimentEnabled !== null) {
        state.sentimentEnabled = sentimentEnabled === 'true';
      } else {
        state.sentimentEnabled = true;
      }

      const includeStandardEmojis = localStorage.getItem('ha_include_standard_emojis');
      if (includeStandardEmojis !== null) {
        state.includeStandardEmojisInAutoFilters = includeStandardEmojis === 'true';
      } else {
        state.includeStandardEmojisInAutoFilters = true;
      }

      const maxAutoFilters = localStorage.getItem('ha_max_auto_filters');
      if (maxAutoFilters !== null) {
        state.maxAutoFilters = parseInt(maxAutoFilters, 10);
      } else {
        state.maxAutoFilters = 10;
      }

      const showSuperchatsOnGraph = localStorage.getItem('ha_show_superchats_on_graph');
      if (showSuperchatsOnGraph !== null) {
        state.showSuperchatsOnGraph = showSuperchatsOnGraph === 'true';
      } else {
        state.showSuperchatsOnGraph = true;
      }

      const sentimentGain = localStorage.getItem('ha_sentiment_gain');
      if (sentimentGain !== null) {
        state.sentimentGain = parseFloat(sentimentGain);
      } else {
        state.sentimentGain = 1.0;
      }

      const showSuperchatTeal = localStorage.getItem('ha_show_superchat_teal');
      state.showSuperchatTeal = showSuperchatTeal !== null ? showSuperchatTeal === 'true' : true;

      const showSuperchatYellow = localStorage.getItem('ha_show_superchat_yellow');
      state.showSuperchatYellow = showSuperchatYellow !== null ? showSuperchatYellow === 'true' : true;

      const showSuperchatPink = localStorage.getItem('ha_show_superchat_pink');
      state.showSuperchatPink = showSuperchatPink !== null ? showSuperchatPink === 'true' : true;

      const showSuperchatRed = localStorage.getItem('ha_show_superchat_red');
      state.showSuperchatRed = showSuperchatRed !== null ? showSuperchatRed === 'true' : true;

      const maxSpikes = localStorage.getItem('ha_max_spikes');
      if (maxSpikes !== null) {
        state.maxSpikes = parseInt(maxSpikes, 10);
      } else {
        state.maxSpikes = 5;
      }
    } catch (e) {
      console.warn('Highlight Analyzer: Failed to load settings from localStorage.', e);
    }
  }

  // Save settings to localStorage
  function saveSettings() {
    try {
      localStorage.setItem('ha_blacklist_enabled', state.blacklistEnabled.toString());
      localStorage.setItem('ha_blacklist_case_sensitive', state.blacklistCaseSensitive.toString());
      localStorage.setItem('ha_blacklist_query', state.blacklistQuery);
      localStorage.setItem('ha_sentiment_enabled', state.sentimentEnabled.toString());
      localStorage.setItem('ha_include_standard_emojis', state.includeStandardEmojisInAutoFilters.toString());
      localStorage.setItem('ha_max_auto_filters', state.maxAutoFilters.toString());
      localStorage.setItem('ha_show_superchats_on_graph', state.showSuperchatsOnGraph.toString());
      localStorage.setItem('ha_sentiment_gain', state.sentimentGain.toString());
      localStorage.setItem('ha_show_superchat_teal', state.showSuperchatTeal.toString());
      localStorage.setItem('ha_show_superchat_yellow', state.showSuperchatYellow.toString());
      localStorage.setItem('ha_show_superchat_pink', state.showSuperchatPink.toString());
      localStorage.setItem('ha_show_superchat_red', state.showSuperchatRed.toString());
      localStorage.setItem('ha_max_spikes', state.maxSpikes.toString());
    } catch (e) {
      console.error('Highlight Analyzer: Failed to save settings to localStorage.', e);
    }
  }

  function showChangelogIfNeeded() {
    try {
      const lastSeen = localStorage.getItem('ha_last_seen_version');
      if (lastSeen === CURRENT_VERSION) return;

      const overlay = document.createElement('div');
      overlay.id = 'ha-changelog-overlay';
      overlay.style.cssText = `
        position: fixed;
        top: 0;
        left: 0;
        width: 100vw;
        height: 100vh;
        background: rgba(0, 0, 0, 0.7);
        backdrop-filter: blur(8px);
        -webkit-backdrop-filter: blur(8px);
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 9999999;
        opacity: 0;
        transition: opacity 0.3s ease;
      `;

      const modal = document.createElement('div');
      modal.id = 'ha-changelog-modal';
      modal.style.cssText = `
        width: 480px;
        max-width: 90%;
        background: linear-gradient(145deg, #1e1e24, #121214);
        border: 1px solid rgba(255, 255, 255, 0.1);
        border-radius: 12px;
        padding: 24px;
        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.7);
        color: #eee;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        transform: scale(0.9);
        transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
        display: flex;
        flex-direction: column;
        gap: 16px;
      `;

      // Header
      const header = document.createElement('div');
      header.style.cssText = `
        display: flex;
        flex-direction: column;
        gap: 4px;
        border-bottom: 1px solid rgba(255, 255, 255, 0.08);
        padding-bottom: 12px;
      `;

      const title = document.createElement('h2');
      title.textContent = `🚀 Highlight Analyzer Updated!`;
      title.style.cssText = `
        margin: 0;
        font-size: 20px;
        font-weight: 700;
        background: linear-gradient(to right, #ffa500, #ff5722);
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
      `;

      const versionBadge = document.createElement('span');
      versionBadge.textContent = `Version ${CURRENT_VERSION} Update`;
      versionBadge.style.cssText = `
        font-size: 12px;
        color: #ffa500;
        font-weight: 600;
      `;

      header.appendChild(title);
      header.appendChild(versionBadge);

      // Body / Content
      const body = document.createElement('div');
      body.style.cssText = `
        font-size: 13px;
        line-height: 1.5;
        display: flex;
        flex-direction: column;
        gap: 12px;
        max-height: 320px;
        overflow-y: auto;
        padding-right: 4px;
      `;

      const intro = document.createElement('p');
      intro.textContent = "Welcome to the version 2.1 update! We've added several highly requested features to make highlight navigation even better:";
      intro.style.cssText = `margin: 0; color: #ccc;`;
      body.appendChild(intro);

      const list = document.createElement('ul');
      list.style.cssText = `
        margin: 0;
        padding-left: 20px;
        display: flex;
        flex-direction: column;
        gap: 10px;
        color: #ddd;
      `;

      const features = [
        {
          title: "🎯 Interactive Spike Hovering",
          desc: "Hovering over any highlight spike pill highlights its corresponding data point on the chart and triggers the detailed HTML tooltip."
        },
        {
          title: "⚙️ Configurable Max Spikes",
          desc: "Added a setting in the settings panel to customize how many auto-detected highlight spikes are displayed at once (default is 5)."
        },
        {
          title: "❌ Right-Click Spike Removal & Caching",
          desc: "Right-click any highlight spike pill to remove it from the list. The analyzer dynamically populates the next highest peak, cached per VOD in localStorage."
        },
        {
          title: "🔒 Third-Party Embed Protection",
          desc: "The analyzer now isolates itself to run only on main pages, preventing popups and overlays from appearing in YouTube/Twitch embeds on other websites."
        }
      ];

      features.forEach(f => {
        const item = document.createElement('li');
        item.style.cssText = `margin: 0;`;

        const titleSpan = document.createElement('strong');
        titleSpan.textContent = f.title + ": ";
        titleSpan.style.cssText = `color: #ffa500; display: block; margin-bottom: 2px;`;

        const descSpan = document.createElement('span');
        descSpan.textContent = f.desc;
        descSpan.style.cssText = `color: #bbb;`;

        item.appendChild(titleSpan);
        item.appendChild(descSpan);
        list.appendChild(item);
      });

      body.appendChild(list);

      // Footer / Button
      const footer = document.createElement('div');
      footer.style.cssText = `
        display: flex;
        justify-content: flex-end;
        border-top: 1px solid rgba(255, 255, 255, 0.08);
        padding-top: 12px;
        margin-top: 4px;
      `;

      const btn = document.createElement('button');
      btn.textContent = "Got It!";
      btn.style.cssText = `
        background: linear-gradient(135deg, #ff9800, #f57c00);
        color: #fff;
        border: none;
        border-radius: 6px;
        padding: 8px 20px;
        font-size: 13px;
        font-weight: 600;
        cursor: pointer;
        transition: transform 0.2s ease, box-shadow 0.2s ease;
        box-shadow: 0 4px 12px rgba(255, 152, 0, 0.2);
      `;
      btn.addEventListener('mouseover', () => {
        btn.style.transform = 'translateY(-1px)';
        btn.style.boxShadow = '0 6px 16px rgba(255, 152, 0, 0.3)';
      });
      btn.addEventListener('mouseout', () => {
        btn.style.transform = 'translateY(0)';
        btn.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.2)';
      });
      btn.addEventListener('click', () => {
        localStorage.setItem('ha_last_seen_version', CURRENT_VERSION);
        overlay.style.opacity = '0';
        modal.style.transform = 'scale(0.9)';
        overlay.addEventListener('transitionend', () => {
          overlay.remove();
        });
      });

      footer.appendChild(btn);

      modal.appendChild(header);
      modal.appendChild(body);
      modal.appendChild(footer);
      overlay.appendChild(modal);

      document.body.appendChild(overlay);

      // Trigger transition
      setTimeout(() => {
        overlay.style.opacity = '1';
        modal.style.transform = 'scale(1)';
      }, 50);

    } catch (e) {
      console.warn('Highlight Analyzer: Failed to render changelog popup.', e);
    }
  }

  const SUPERCHAT_COLOR_MAP = {
    teal: '#00bfa5',    // teal
    yellow: '#ffca28',  // yellow
    pink: '#e91e63',    // pink
    red: '#ff0000'      // red
  };

  function getSuperChatColorCategory(headerBgColorInt) {
    if (!headerBgColorInt) return 'teal';
    const rgb = headerBgColorInt & 0xFFFFFF;
    const r = (rgb >> 16) & 0xFF;
    const g = (rgb >> 8) & 0xFF;
    const b = rgb & 0xFF;

    // RGB to HSL
    const rNorm = r / 255;
    const gNorm = g / 255;
    const bNorm = b / 255;
    const max = Math.max(rNorm, gNorm, bNorm);
    const min = Math.min(rNorm, gNorm, bNorm);
    let h = 0;
    const d = max - min;
    if (d !== 0) {
      switch (max) {
        case rNorm: h = (gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0); break;
        case gNorm: h = (bNorm - rNorm) / d + 2; break;
        case bNorm: h = (rNorm - gNorm) / d + 4; break;
      }
      h /= 6;
    }
    const hueDegrees = h * 360;

    if (hueDegrees >= 140 && hueDegrees < 270) {
      return 'teal';
    } else if (hueDegrees >= 20 && hueDegrees < 55) {
      return 'yellow';
    } else if (hueDegrees >= 270 && hueDegrees < 345) {
      return 'pink';
    } else {
      return 'red';
    }
  }

  // IndexedDB Cache for storing last N scanned VOD chats
  const DB_NAME = 'ha_vod_cache_db';
  const DB_VERSION = 1;
  const STORE_NAME = 'vod_caches';

  function openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(DB_NAME, DB_VERSION);
      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve(request.result);
      request.onupgradeneeded = (e) => {
        const db = e.target.result;
        if (!db.objectStoreNames.contains(STORE_NAME)) {
          db.createObjectStore(STORE_NAME, { keyPath: 'videoId' });
        }
      };
    });
  }

  async function getCachedVod(videoId) {
    try {
      const db = await openDB();
      return new Promise((resolve, reject) => {
        const tx = db.transaction(STORE_NAME, 'readonly');
        const store = tx.objectStore(STORE_NAME);
        const request = store.get(videoId);
        request.onerror = () => reject(request.error);
        request.onsuccess = () => resolve(request.result);
      });
    } catch (e) {
      console.warn('Highlight Analyzer: Failed to read from IndexedDB.', e);
      return null;
    }
  }

  async function getCacheStats() {
    try {
      const db = await openDB();
      return new Promise((resolve, reject) => {
        const tx = db.transaction(STORE_NAME, 'readonly');
        const store = tx.objectStore(STORE_NAME);
        const request = store.getAll();
        request.onerror = () => reject(request.error);
        request.onsuccess = () => {
          const results = request.result || [];
          const count = results.length;
          let totalBytes = 0;
          results.forEach(entry => {
            try {
              const str = JSON.stringify(entry);
              totalBytes += new Blob([str]).size;
            } catch (err) {
              // fallback
            }
          });
          const mb = totalBytes / (1024 * 1024);
          resolve({ count, sizeMB: mb.toFixed(2) });
        };
      });
    } catch (e) {
      console.warn('Highlight Analyzer: Failed to calculate cache stats.', e);
      return { count: 0, sizeMB: '0.00' };
    }
  }

  async function clearVodCache() {
    try {
      const db = await openDB();
      return new Promise((resolve, reject) => {
        const tx = db.transaction(STORE_NAME, 'readwrite');
        const store = tx.objectStore(STORE_NAME);
        const request = store.clear();
        request.onerror = () => reject(request.error);
        request.onsuccess = () => resolve();
      });
    } catch (e) {
      console.error('Highlight Analyzer: Failed to clear IndexedDB cache.', e);
    }
  }

  async function updateCacheStatsUI() {
    const el = document.getElementById('ha-cache-stats');
    if (!el) return;
    const stats = await getCacheStats();
    el.textContent = `Cached Streams: ${stats.count} (Size: ${stats.sizeMB} MB)`;
  }

  async function saveCachedVod(videoId, data) {
    try {
      const db = await openDB();
      const tx = db.transaction(STORE_NAME, 'readwrite');
      const store = tx.objectStore(STORE_NAME);

      await new Promise((resolve, reject) => {
        const request = store.put({
          videoId,
          timestamp: Date.now(),
          ...data
        });
        request.onerror = () => reject(request.error);
        request.onsuccess = () => resolve();
      });
      console.log(`Highlight Analyzer: Cached VOD chat data for ${videoId}`);
      updateCacheStatsUI();
    } catch (e) {
      console.warn('Highlight Analyzer: Failed to write to IndexedDB.', e);
    }
  }

  // Format seconds to HH:MM:SS
  function formatTime(sec) {
    if (isNaN(sec) || sec < 0) return '0:00';
    const hrs = Math.floor(sec / 3600);
    const mins = Math.floor((sec % 3600) / 60);
    const secs = Math.floor(sec % 60);

    if (hrs > 0) {
      return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
    }
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  }

  // Format peak gain/amplification values to descriptive labels
  function formatAmplificationLabel(val) {
    if (val === 1.0) return '1.0x (Normal)';
    if (val > 1.0) return `${val.toFixed(1)}x (Amplified)`;
    return `${val.toFixed(1)}x (Reduced)`;
  }


  // Seek video to specific offset (with manual lead-in buffer or dynamic valley detection)
  function seekTo(sec) {
    const video = document.querySelector('ytd-player video') || document.querySelector('video');
    if (video) {
      let targetTime;
      if (state.seekMode === 'auto') {
        targetTime = getAutoSeekTime(sec);
      } else {
        const offset = typeof state.seekOffset === 'number' ? state.seekOffset : 10;
        targetTime = Math.max(0, sec - offset);
      }
      video.currentTime = targetTime;
      video.play();
    }
  }

  // Dynamic valley seek logic: finds the timestamp of lowest chat activity in the 60 seconds preceding the click
  function getAutoSeekTime(sec) {
    if (!state.messages || state.messages.length === 0) {
      return Math.max(0, sec - 10);
    }

    const T = sec;
    // Search window: [T - 60, T - 5]
    const searchStart = Math.max(0, T - 60);
    const searchEnd = Math.max(0, T - 5);
    if (searchEnd <= searchStart) {
      return searchStart;
    }

    // Get messages in boundary [T - 70, T] to speed up counts
    const boundaryStart = Math.max(0, T - 70);
    const boundaryEnd = T;

    // Binary search for index of first message in boundary
    let startIdx = 0;
    let low = 0, high = state.messages.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (state.messages[mid].offset >= boundaryStart) {
        startIdx = mid;
        high = mid - 1;
      } else {
        low = mid + 1;
      }
    }

    const offsets = [];
    for (let i = startIdx; i < state.messages.length; i++) {
      const offset = state.messages[i].offset;
      if (offset > boundaryEnd) break;
      offsets.push(offset);
    }

    if (offsets.length === 0) {
      return Math.max(0, T - 10);
    }

    // Find the second t in [searchStart, searchEnd] that has the minimum local chat rate (using a 10s sliding window)
    let minRate = Infinity;
    let minTime = searchStart;

    for (let t = Math.floor(searchStart); t <= Math.floor(searchEnd); t++) {
      // Count messages in [t - 5, t + 5]
      let count = 0;
      const wStart = t - 5;
      const wEnd = t + 5;
      for (let i = 0; i < offsets.length; i++) {
        if (offsets[i] >= wStart && offsets[i] <= wEnd) {
          count++;
        }
      }

      if (count < minRate) {
        minRate = count;
        minTime = t;
      }
    }

    // Seek to the time of lowest activity. We add a tiny 2-second offset to the start of the window (t - 5) or just seek to t - 2.
    return Math.max(0, minTime - 2);
  }

  // Debounce helper
  function debounce(func, delay) {
    let timeoutId;
    return function(...args) {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => func.apply(this, args), delay);
    };
  }

  // Search message logs and render results
  function performSearch(query) {
    const resultsContainer = document.getElementById('ha-search-results');
    if (!resultsContainer) return;

    const isSuperchatOnly = state.filterSuperchatsOnly;
    const hasQuery = query && query.trim() !== "";

    if (!hasQuery && !isSuperchatOnly) {
      resultsContainer.innerHTML = sanitizeHTML(`<div style="color: var(--yt-spec-text-secondary, #aaa); padding: 4px;">Type something to search...</div>`);
      return;
    }

    const searchTerms = hasQuery ? query.toLowerCase().trim().split(/\s+/) : [];
    const matches = [];

    // Check all messages
    for (let i = 0; i < state.messages.length; i++) {
      const msg = state.messages[i];
      if (isSuperchatOnly && !msg.isSuperChat) {
        continue;
      }

      let isMatch = true;
      if (hasQuery) {
        const authorMatch = msg.author.toLowerCase();
        const textMatch = msg.text.toLowerCase();
        isMatch = searchTerms.every(term => authorMatch.includes(term) || textMatch.includes(term));
      }

      if (isMatch) {
        matches.push(msg);
        if (matches.length >= 100) break; // limit to 100 results
      }
    }

    if (matches.length === 0) {
      resultsContainer.innerHTML = sanitizeHTML(`<div style="color: var(--yt-spec-text-secondary, #aaa); padding: 4px;">No matching messages found.</div>`);
      return;
    }

    resultsContainer.textContent = '';
    const fragment = document.createDocumentFragment();
    matches.forEach(msg => {
      const div = document.createElement('div');
      div.className = 'ha-search-item';

      const isSC = msg.isSuperChat;
      const scColorHex = isSC ? (SUPERCHAT_COLOR_MAP[msg.superChatColor] || '#ffa500') : '#ffa500';

      let badgeHtml = '';
      if (isSC && msg.superChatAmount) {
        badgeHtml = `<span style="background-color: ${scColorHex}; color: #fff; padding: 1px 5px; border-radius: 3px; font-size: 10px; font-weight: bold; margin-right: 6px; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);">${msg.superChatAmount}</span>`;
      }

      const authorClean = msg.author.startsWith('@') ? msg.author.slice(1) : msg.author;
      const textSpan = document.createElement('span');
      textSpan.style.cssText = 'flex: 1; word-break: break-word; color: #eee;';

      if (isSC) {
        textSpan.innerHTML = sanitizeHTML(`${badgeHtml}<strong style="color: ${scColorHex};">${authorClean}:</strong> ${msg.text || '<i>(No message)</i>'}`);
        div.style.borderLeft = `3px solid ${scColorHex}`;
        div.style.paddingLeft = '6px';
        div.style.backgroundColor = 'rgba(255, 255, 255, 0.03)';
      } else {
        textSpan.innerHTML = sanitizeHTML(`<strong style="color: #ffa500;">${authorClean}:</strong> ${msg.text}`);
      }

      const btn = document.createElement('button');
      btn.className = 'ha-btn';
      btn.style.cssText = 'padding: 2px 8px; font-size: 11px; flex-shrink: 0; white-space: nowrap;';
      btn.textContent = `⏱ ${formatTime(msg.offset)}`;
      btn.addEventListener('click', () => seekTo(msg.offset));

      div.appendChild(textSpan);
      div.appendChild(btn);
      fragment.appendChild(div);
    });
    resultsContainer.appendChild(fragment);
  }

  // Helper to recursively scan object for continuation keys
  function findContinuationToken(obj) {
    if (!obj || typeof obj !== 'object') return null;
    if (obj.continuation && typeof obj.continuation === 'string') {
      return obj.continuation;
    }
    for (const key in obj) {
      if (Object.prototype.hasOwnProperty.call(obj, key)) {
        const result = findContinuationToken(obj[key]);
        if (result) return result;
      }
    }
    return null;
  }

  // Extract JSON by tracking matching braces to be completely robust to internal semicolons/quotes
  function extractJSONByBraces(str, startIndex) {
    let depth = 0;
    let inString = false;
    let escape = false;

    for (let i = startIndex; i < str.length; i++) {
      const char = str[i];
      if (escape) {
        escape = false;
        continue;
      }
      if (char === '\\') {
        escape = true;
        continue;
      }
      if (char === '"') {
        inString = !inString;
        continue;
      }
      if (!inString) {
        if (char === '{') {
          depth++;
        } else if (char === '}') {
          depth--;
          if (depth === 0) {
            return str.slice(startIndex, i + 1);
          }
        }
      }
    }
    return null;
  }

  // Parse ytInitialData from fetched HTML watch page
  function extractInitialDataFromHTML(html) {
    const marker = 'ytInitialData =';
    const startIndex = html.indexOf(marker);
    if (startIndex === -1) return null;
    const dataStart = html.indexOf('{', startIndex);
    if (dataStart === -1) return null;
    const jsonStr = extractJSONByBraces(html, dataStart);
    if (!jsonStr) return null;
    try {
      return JSON.parse(jsonStr);
    } catch (e) {
      console.error('Highlight Analyzer: Failed to parse extracted ytInitialData JSON', e);
      return null;
    }
  }

  // Fetch watch page without t parameter to get clean 0-second chat replay token
  async function getZeroToken() {
    const urlParams = new URLSearchParams(window.location.search);
    const videoId = urlParams.get('v');
    if (!videoId) return null;

    // Force 0-seconds in URL to override watch history and get the beginning token
    const cleanUrl = window.location.origin + window.location.pathname + '?v=' + videoId + '&t=0s';

    // 1. Try anonymous fetch first (no cookies) to bypass watch history personalization
    try {
      const res = await fetch(cleanUrl, { credentials: 'omit' });
      if (res.ok) {
        const html = await res.text();
        const parsedData = extractInitialDataFromHTML(html);
        const conversationBar = parsedData?.contents?.twoColumnWatchNextResults?.conversationBar;
        const token = conversationBar ? findContinuationToken(conversationBar) : null;
        if (token) {
          console.log('Highlight Analyzer: Successfully fetched 0-second token anonymously.');
          return token;
        }
      }
    } catch (e) {
      console.warn('Highlight Analyzer: Anonymous token fetch failed, trying authenticated:', e);
    }

    // 2. Fallback to authenticated fetch if anonymous fails (e.g. members-only/age-restricted VODs)
    try {
      const res = await fetch(cleanUrl);
      if (!res.ok) return null;
      const html = await res.text();
      const parsedData = extractInitialDataFromHTML(html);
      const conversationBar = parsedData?.contents?.twoColumnWatchNextResults?.conversationBar;
      return conversationBar ? findContinuationToken(conversationBar) : null;
    } catch (e) {
      console.warn('Highlight Analyzer: Authenticated token fetch failed:', e);
      return null;
    }
  }

  // Start pre-fetching in background
  function triggerZeroTokenFetch() {
    if (!isYouTube) return;
    zeroTokenPromise = getZeroToken();
  }

  // Start rotating comments shown in the active tooltip
  function startTooltipRotation() {
    if (tooltipRotationInterval) return; // Already running

    tooltipRotationInterval = setInterval(() => {
      if (!state.chart || state.hoveredBinIndex === null) {
        stopTooltipRotation();
        return;
      }

      const bin = state.binnedData[state.hoveredBinIndex];
      if (!bin || !bin.messages || bin.messages.length <= 4) {
        return;
      }

      // Increment offset to rotate comments
      state.tooltipMessageOffset = (state.tooltipMessageOffset + 4) % bin.messages.length;

      // Update custom HTML tooltip content directly
      renderHTMLTooltip(state.chart);
    }, 500);
  }

  // Stop comments rotation
  function stopTooltipRotation() {
    if (tooltipRotationInterval) {
      clearInterval(tooltipRotationInterval);
      tooltipRotationInterval = null;
    }
    state.tooltipMessageOffset = 0;
  }

  // Helper to extract chat replay token from the active iframe if present
  function getContinuationFromIframe() {
    const iframe = document.getElementById('chatframe') || document.querySelector('iframe[src*="live_chat"]');
    if (iframe && iframe.src) {
      try {
        const url = new URL(iframe.src);
        const continuation = url.searchParams.get('continuation');
        if (continuation) return continuation;
      } catch (e) {
        console.warn('Highlight Analyzer: Failed to parse iframe src URL', e);
      }
    }
    return null;
  }

  // Get active video duration prioritizing HTML5 video element over stale player response
  function getActiveVideoDuration() {
    const video = document.querySelector('ytd-player video') || document.querySelector('video');
    const isAd = document.querySelector('.ad-showing, .ad-interrupting, .html5-ad-producting, .ytp-ad-player-overlay');

    if (isYouTube) {
      if (video && !isNaN(video.duration) && video.duration > 0) {
        if (!isAd || video.duration > 180) {
          return video.duration;
        }
      }
      // Fallback to initial player response
      const metaDuration = parseInt(window.ytInitialPlayerResponse?.videoDetails?.lengthSeconds || 0, 10);
      return metaDuration;
    } else if (isTwitch || isKick) {
      if (video && !isNaN(video.duration) && video.duration > 0) {
        return video.duration;
      }
    }
    return state.duration;
  }

  // Extract initial setup metadata
  function extractYouTubeMetadata() {
    const urlParams = new URLSearchParams(window.location.search);
    if (!urlParams.get('v')) {
      state.initialToken = null;
      return;
    }

    // 1. API Key
    state.apiKey = window.ytcfg?.get('INNERTUBE_API_KEY');
    if (!state.apiKey && window.ytcfg?.data_) {
      state.apiKey = window.ytcfg.data_.INNERTUBE_API_KEY;
    }
    if (!state.apiKey) {
      const match = document.documentElement.innerHTML.match(/"INNERTUBE_API_KEY"\s*:\s*"([^"]+)"/);
      if (match) state.apiKey = match[1];
    }

    // 2. Context
    state.context = window.ytcfg?.get('INNERTUBE_CONTEXT');
    if (!state.context && window.ytcfg?.data_) {
      state.context = window.ytcfg.data_.INNERTUBE_CONTEXT;
    }

    // 3. Conversation Bar / Initial Replay Token
    const iframeToken = getContinuationFromIframe();
    if (iframeToken) {
      state.initialToken = iframeToken;
    } else {
      const conversationBar = window.ytInitialData?.contents?.twoColumnWatchNextResults?.conversationBar;
      state.initialToken = conversationBar ? findContinuationToken(conversationBar) : null;
    }

    // 4. Video Duration
    state.duration = getActiveVideoDuration();
  }

  // Unified metadata extraction for all platforms
  function extractMetadata() {
    if (isYouTube) {
      extractYouTubeMetadata();
    } else if (isTwitch) {
      extractTwitchMetadata();
    } else if (isKick) {
      extractKickMetadata();
    }
  }

  function generateEmoteFilters() {
    const filteredEmoteCounts = {};
    Object.entries(state.emoteCounts).forEach(([emote, count]) => {
      const isCustom = emote.startsWith(':') && emote.endsWith(':');
      if (isCustom || state.includeStandardEmojisInAutoFilters) {
        filteredEmoteCounts[emote] = count;
      }
    });

    const sortedEmotes = Object.entries(filteredEmoteCounts)
      .sort((a, b) => b[1] - a[1])
      .slice(0, state.maxAutoFilters || 10)
      .filter(entry => entry[1] >= 2); // At least 2 uses to filter noise

    state.detectedEmoteFilters = sortedEmotes.map(entry => {
      const emoteName = entry[0];
      const count = entry[1];

      // Determine sentiment label
      let sentimentSuffix = '';
      const lowerEmote = emoteName.toLowerCase();
      let score = null;
      if (BASE_EMOTE_SENTIMENT.hasOwnProperty(lowerEmote)) {
        score = BASE_EMOTE_SENTIMENT[lowerEmote];
      } else if (state.dynamicEmoteSentiment.hasOwnProperty(emoteName)) {
        score = state.dynamicEmoteSentiment[emoteName];
      }

      if (score !== null) {
        let emoji = '💬';
        if (score > 0.3) emoji = '🔥';
        else if (score > 0) emoji = '👍';
        else if (score < -0.3) emoji = '⚠️';
        else if (score < 0) emoji = '👎';
        sentimentSuffix = ` [${emoji} ${score > 0 ? '+' : ''}${score}]`;
      }

      return {
        id: 'emote_' + emoteName.replace(/[^a-zA-Z0-9]/g, ''),
        name: `${emoteName} (${count})${sentimentSuffix}`,
        keywords: emoteName,
        isAuto: true
      };
    });
  }

  // Apply vertical gain (linear amplification) to the data
  function transformData(dataArray, power) {
    if (dataArray.length === 0) return [];
    return dataArray.map(d => {
      const origY = d.rawY ?? d.y;
      const newY = parseFloat((origY * power).toFixed(1));
      return {
        x: d.x,
        y: newY,
        rawY: origY,
        messages: d.messages
      };
    });
  }

  // Calculate Pearson correlation coefficient between two arrays of equal length
  function getCorrelation(arr1, arr2) {
    const n = arr1.length;
    let sum1 = 0, sum2 = 0, sum1Sq = 0, sum2Sq = 0, pSum = 0;
    for (let i = 0; i < n; i++) {
      sum1 += arr1[i];
      sum2 += arr2[i];
      sum1Sq += arr1[i] * arr1[i];
      sum2Sq += arr2[i] * arr2[i];
      pSum += arr1[i] * arr2[i];
    }
    const num = pSum - (sum1 * sum2 / n);
    const den = Math.sqrt((sum1Sq - sum1 * sum1 / n) * (sum2Sq - sum2 * sum2 / n));
    return den === 0 ? 0 : num / den;
  }

  // Bootstrap sentiment scores for top custom/unrecognized emotes using baseline anchors
  function bootstrapEmoteSentiments() {
    if (state.messages.length === 0) return;

    const duration = state.duration || 3600;
    const binSize = duration / NUM_BINS;

    const posCurve = new Array(NUM_BINS).fill(0);
    const laughCurve = new Array(NUM_BINS).fill(0);
    const negCurve = new Array(NUM_BINS).fill(0);

    const topEmotes = Object.entries(state.emoteCounts)
      .sort((a, b) => b[1] - a[1])
      .slice(0, 15)
      .map(entry => entry[0]);

    const emoteCurves = {};
    topEmotes.forEach(emote => {
      emoteCurves[emote] = new Array(NUM_BINS).fill(0);
    });

    state.messages.forEach(msg => {
      const binIdx = Math.min(NUM_BINS - 1, Math.floor(msg.offset / binSize));
      if (binIdx < 0 || binIdx >= NUM_BINS) return;

      const textLower = msg.text.toLowerCase();

      let isPos = false, isLaugh = false, isNeg = false;
      SENTIMENT_ANCHORS.positive.forEach(a => { if (textLower.includes(a)) isPos = true; });
      SENTIMENT_ANCHORS.laughter.forEach(a => { if (textLower.includes(a)) isLaugh = true; });
      SENTIMENT_ANCHORS.negative.forEach(a => { if (textLower.includes(a)) isNeg = true; });

      if (isPos) posCurve[binIdx]++;
      if (isLaugh) laughCurve[binIdx]++;
      if (isNeg) negCurve[binIdx]++;

      const tokens = msg.text.split(/\s+/);
      tokens.forEach(token => {
        if (emoteCurves.hasOwnProperty(token)) {
          emoteCurves[token][binIdx]++;
        }
      });
    });

    const newDynamicSentiment = {};
    topEmotes.forEach(emote => {
      if (BASE_EMOTE_SENTIMENT.hasOwnProperty(emote.toLowerCase())) return;

      const curve = emoteCurves[emote];
      const corrPos = getCorrelation(curve, posCurve);
      const corrLaugh = getCorrelation(curve, laughCurve);
      const corrNeg = getCorrelation(curve, negCurve);

      let score = 0;
      if (corrLaugh > 0.35 || corrPos > 0.35) {
        score = Math.max(corrLaugh, corrPos);
      }
      if (corrNeg > 0.35) {
        score = score - corrNeg;
      }

      if (Math.abs(score) > 0.15) {
        newDynamicSentiment[emote] = parseFloat(score.toFixed(2));
      }
    });

    state.dynamicEmoteSentiment = newDynamicSentiment;
  }

  // Calculate message count density in time bins
  function updateBinsAndSpikes() {
    // Refresh duration dynamically
    state.duration = getActiveVideoDuration();

    const duration = state.duration || 3600; // Fallback to 1 hour
    const binSize = duration / NUM_BINS;

    // Initialize bins
    const bins = Array.from({ length: NUM_BINS }, (_, i) => ({
      time: (i + 0.5) * binSize,
      count: 0,
      messages: [], // Array of messages in this bin for hover tooltips
      filteredCount: 0, // Count of messages matching search filter
      sentimentSum: 0,
      sentimentCount: 0
    }));

    // Trigger correlation bootstrapping if scan completes, is paused, or periodically
    const messageCount = state.messages.length;
    const shouldBootstrap = !state.isScanning || state.isPaused || (messageCount > 0 && messageCount % 1000 === 0) || state.isCachedLoad;
    if (shouldBootstrap) {
      bootstrapEmoteSentiments();
    }

    const filterQuery = state.filterQuery.toLowerCase().trim();
    const keywords = filterQuery ? filterQuery.split(',').map(k => k.trim()).filter(Boolean) : [];

    const blacklistQuery = state.blacklistQuery.trim();
    const blacklistKeywords = state.blacklistEnabled && blacklistQuery
      ? blacklistQuery.split(',').map(k => state.blacklistCaseSensitive ? k.trim() : k.trim().toLowerCase()).filter(Boolean)
      : [];

    // Populate bins
    state.messages.forEach(msg => {
      // Apply blacklist check
      if (blacklistKeywords.length > 0) {
        const msgText = state.blacklistCaseSensitive ? msg.text : msg.text.toLowerCase();
        const matchesBlacklist = blacklistKeywords.some(kw => msgText.includes(kw));
        if (matchesBlacklist) return;
      }

      const binIdx = Math.min(NUM_BINS - 1, Math.floor(msg.offset / binSize));
      if (binIdx >= 0 && binIdx < NUM_BINS) {
        bins[binIdx].count++;
        bins[binIdx].messages.push(msg);

        // Calculate sentiment score for this message
        let msgSentiment = 0;
        let sentimentMatches = 0;
        const tokens = msg.text.split(/\s+/);
        tokens.forEach(token => {
          const lowerToken = token.toLowerCase();
          if (BASE_EMOTE_SENTIMENT.hasOwnProperty(lowerToken)) {
            msgSentiment += BASE_EMOTE_SENTIMENT[lowerToken];
            sentimentMatches++;
          } else if (state.dynamicEmoteSentiment.hasOwnProperty(token)) {
            msgSentiment += state.dynamicEmoteSentiment[token];
            sentimentMatches++;
          }
        });

        if (sentimentMatches > 0) {
          const score = msgSentiment / sentimentMatches;
          msg.sentiment = score;
          bins[binIdx].sentimentSum += score;
          bins[binIdx].sentimentCount++;
        } else {
          msg.sentiment = 0;
        }

        // Apply filter check
        if (keywords.length > 0) {
          const msgText = msg.text.toLowerCase();
          const matches = keywords.some(kw => msgText.includes(kw));
          if (matches) {
            bins[binIdx].filteredCount++;
          }
        }
      }
    });

    // Convert to density: Messages per Minute (MPM)
    const factor = binSize > 0 ? (60 / binSize) : 1;

    const rawBinned = bins.map(b => ({
      x: b.time,
      y: parseFloat((b.count * factor).toFixed(1)),
      messages: b.messages
    }));

    const rawFiltered = bins.map(b => ({
      x: b.time,
      y: parseFloat((b.filteredCount * factor).toFixed(1))
    }));

    state.sentimentBinnedData = bins.map(b => ({
      x: b.time,
      y: b.sentimentCount > 0 ? parseFloat((b.sentimentSum / b.sentimentCount).toFixed(2)) : 0
    }));

    // Apply peak amplification gain to datasets
    state.binnedData = transformData(rawBinned, state.mainGain);
    state.filteredBinnedData = transformData(rawFiltered, state.filterGain);

    // Populate superchat points for the chart
    const superChatPoints = [];
    state.messages.forEach(msg => {
      if (msg.isSuperChat) {
        // Filter by color subtoggles
        if (msg.superChatColor === 'teal' && !state.showSuperchatTeal) return;
        if (msg.superChatColor === 'yellow' && !state.showSuperchatYellow) return;
        if (msg.superChatColor === 'pink' && !state.showSuperchatPink) return;
        if (msg.superChatColor === 'red' && !state.showSuperchatRed) return;

        const binIdx = Math.min(NUM_BINS - 1, Math.floor(msg.offset / binSize));
        const binVal = state.binnedData[binIdx] ? state.binnedData[binIdx].y : 0;
        superChatPoints.push({
          x: msg.offset,
          y: binVal,
          colorHex: SUPERCHAT_COLOR_MAP[msg.superChatColor] || '#ffa500',
          amount: msg.superChatAmount,
          author: msg.author,
          text: msg.text,
          offset: msg.offset
        });
      }
    });

    // Sort by X (time offset)
    superChatPoints.sort((a, b) => a.x - b.x);

    // Stacking pass to prevent overlapping mess on the graph
    const visualWidthSec = duration * 0.012; // 1.2% of total duration as horizontal overlap threshold
    const maxVal = state.binnedData.reduce((max, b) => b.y > max ? b.y : max, 10);
    const stackHeight = maxVal * 0.07; // 7% of max y as vertical stack step

    const placed = [];
    superChatPoints.forEach(p => {
      // Find placed points that are horizontally close
      const closePoints = placed.filter(other => Math.abs(other.x - p.x) < visualWidthSec);
      if (closePoints.length > 0) {
        // Sort close points by their y-value
        closePoints.sort((a, b) => a.y - b.y);
        const highest = closePoints[closePoints.length - 1];
        p.y = highest.y + stackHeight;
      }
      placed.push(p);
    });

    state.superChatPoints = superChatPoints;

    // Generate Dynamic Emote filters from current state
    generateEmoteFilters();

    // Find stats (using original untransformed values for accuracy)
    let nonBlacklistedCount = 0;
    bins.forEach(b => {
      nonBlacklistedCount += b.count;
    });
    state.totalMessages = nonBlacklistedCount;
    const rates = rawBinned.map(b => b.y);
    state.peakRate = rates.length ? Math.max(...rates) : 0;
    const totalDurationMin = duration / 60;
    state.averageRate = totalDurationMin > 0 ? parseFloat((state.totalMessages / totalDurationMin).toFixed(1)) : 0;

    // Find Peaks (Spikes) with spacing constraints (NMS) using raw data values
    const localPeaks = [];
    const windowSize = 2; // Look at neighbors

    for (let i = windowSize; i < NUM_BINS - windowSize; i++) {
      const currentVal = rawBinned[i].y;
      if (currentVal <= 0) continue;

      let isPeak = true;
      for (let w = -windowSize; w <= windowSize; w++) {
        if (w === 0) continue;
        if (rawBinned[i + w].y >= currentVal) {
          isPeak = false;
          break;
        }
      }

      // Peak must be higher than average * 1.2 to be considered a highlight
      if (isPeak && currentVal > state.averageRate * 1.2) {
        const binSentiment = state.sentimentBinnedData[i].y;

        let laughterCount = 0;
        bins[i].messages.forEach(m => {
          const textLower = m.text.toLowerCase();
          SENTIMENT_ANCHORS.laughter.forEach(a => {
            if (textLower.includes(a)) laughterCount++;
          });
        });
        const laughterRatio = bins[i].messages.length > 0 ? laughterCount / bins[i].messages.length : 0;

        let category = 'general';
        let emoji = '💬';
        if (laughterRatio > 0.25 || binSentiment > 0.4) {
          if (laughterRatio > 0.25) {
            category = 'funny';
            emoji = '😂';
          } else {
            category = 'hype';
            emoji = '🔥';
          }
        } else if (binSentiment < -0.3) {
          category = 'fail';
          emoji = '⚠️';
        }

        localPeaks.push({
          time: rawBinned[i].x,
          rate: currentVal,
          sentiment: binSentiment,
          category: category,
          emoji: emoji
        });
      }
    }

    // Sort peaks by rate descending
    localPeaks.sort((a, b) => b.rate - a.rate);

    // Filter peaks with spacing constraint and removal constraint
    const filteredSpikes = [];
    const maxSpikes = state.maxSpikes || 5;
    for (const peak of localPeaks) {
      // Exclude removed/blacklisted spikes
      const isRemoved = state.removedSpikes && state.removedSpikes.some(t => Math.abs(t - peak.time) < 0.1);
      if (isRemoved) continue;

      let tooClose = false;
      for (const selected of filteredSpikes) {
        if (Math.abs(peak.time - selected.time) < MIN_PEAK_SPACING_SEC) {
          tooClose = true;
          break;
        }
      }
      if (!tooClose) {
        filteredSpikes.push(peak);
      }
      if (filteredSpikes.length >= maxSpikes) break;
    }

    // Sort spikes by time order before showing in list
    state.spikes = filteredSpikes.sort((a, b) => a.time - b.time);
  }

  // Update UI panel elements
  function updateUI() {
    const panel = document.getElementById('highlight-analyzer-panel');
    if (!panel) return;

    // Update status badge
    const badge = panel.querySelector('.ha-badge');
    badge.className = 'ha-badge';
    if (state.isScanning) {
      if (state.isPaused) {
        badge.classList.add('paused');
        badge.textContent = 'Paused';
      } else {
        badge.classList.add('scanning');
        badge.textContent = 'Scanning Chat...';
      }
    } else if (state.totalMessages > 0 && !state.abortController) {
      if (state.isCachedLoad) {
        badge.classList.add('cached');
        badge.textContent = 'Loaded from Cache';
      } else {
        badge.classList.add('loaded');
        badge.textContent = 'Fully Loaded';
      }
    } else {
      badge.classList.add('idle');
      badge.textContent = 'Ready';
    }

    // Update progress bar
    const progressTrack = panel.querySelector('.ha-progress-track');
    const progressBar = panel.querySelector('.ha-progress-bar');
    if (state.isScanning) {
      progressTrack.style.display = 'block';
      progressBar.style.width = `${state.progress}%`;
    } else {
      progressTrack.style.display = 'none';
    }

    // Update stats cards
    panel.querySelector('#ha-stat-total').textContent = state.totalMessages.toLocaleString();
    panel.querySelector('#ha-stat-average').textContent = `${state.averageRate}/min`;
    panel.querySelector('#ha-stat-peak').textContent = `${state.peakRate}/min`;

    // Update control buttons text/states
    const btnScan = panel.querySelector('#ha-btn-scan');
    const btnPause = panel.querySelector('#ha-btn-pause');
    const btnStop = panel.querySelector('#ha-btn-stop');

    if (state.isScanning) {
      btnScan.style.display = 'none';
      btnPause.style.display = 'inline-flex';
      btnPause.textContent = state.isPaused ? '▶ Resume' : '⏸ Pause';
      btnStop.style.display = 'inline-flex';
    } else {
      btnScan.style.display = 'inline-flex';
      btnScan.textContent = state.totalMessages > 0 ? '🔄 Re-scan' : '🚀 Load Chat';
      btnPause.style.display = 'none';
      btnStop.style.display = 'none';
    }

    // Render quick-filter tags list (both custom and auto-emotes)
    renderTagsUI();

    // Update spike list tags
    const spikesList = panel.querySelector('.ha-spikes-list');
    spikesList.textContent = '';

    if (state.spikes.length === 0) {
      spikesList.innerHTML = sanitizeHTML(`<li style="font-size:12px; color:var(--yt-spec-text-secondary, #aaa);">No highlights detected yet. Start scanning.</li>`);
    } else {
      state.spikes.forEach(spike => {
        const li = document.createElement('li');
        li.className = `ha-spike-pill ${spike.category || 'general'}`;
        const pillEmoji = spike.emoji || '⏱';
        li.innerHTML = sanitizeHTML(`
          <span class="ha-spike-time">${pillEmoji} ${formatTime(spike.time)}</span>
          <span class="ha-spike-rate">${Math.round(spike.rate)}/min</span>
        `);
        li.addEventListener('click', () => seekTo(spike.time));

        // Hover to highlight point on chart and show tooltip
        li.addEventListener('mouseover', () => {
          if (state.chart && state.binnedData && state.binnedData.length > 0) {
            const chart = state.chart;
            const binIdx = state.binnedData.reduce((closestIdx, bin, idx, arr) => {
              if (closestIdx === -1) return idx;
              return Math.abs(bin.x - spike.time) < Math.abs(arr[closestIdx].x - spike.time) ? idx : closestIdx;
            }, -1);

            if (binIdx !== -1) {
              const meta = chart.getDatasetMeta(0); // main dataset is index 0
              if (meta && meta.data && meta.data[binIdx]) {
                chart.setActiveElements([{ datasetIndex: 0, index: binIdx }]);
                const point = meta.data[binIdx];
                chart.tooltip.setActiveElements([{ datasetIndex: 0, index: binIdx }], {
                  x: point.x,
                  y: point.y
                });
                chart.update('none');
              }
            }
          }
        });

        li.addEventListener('mouseout', () => {
          if (state.chart) {
            const chart = state.chart;
            chart.setActiveElements([]);
            chart.tooltip.setActiveElements([], { x: 0, y: 0 });
            chart.update('none');
          }
        });

        // Right-click to remove spike from the list and load the next highest
        li.addEventListener('contextmenu', (e) => {
          e.preventDefault();
          if (!state.removedSpikes) {
            state.removedSpikes = [];
          }
          state.removedSpikes.push(spike.time);
          saveRemovedSpikes();
          state.update();
        });

        spikesList.appendChild(li);
      });
    }

    // Refresh Chart
    renderChart();
  }

  // Render quick-filter pills container
  function renderTagsUI() {
    const container = document.querySelector('.ha-tags-container');
    if (!container) return;

    // Clear tag elements except the add button
    const oldPills = container.querySelectorAll('.ha-tag-pill');
    oldPills.forEach(p => p.remove());

    const addBtn = container.querySelector('.ha-add-tag-btn');

    // Helper to append a tag pill
    function appendPill(filter) {
      const pill = document.createElement('div');
      pill.className = 'ha-tag-pill';
      if (filter.isAuto) {
        pill.classList.add('auto-emote');
      }
      if (state.filterQuery === filter.keywords) {
        pill.classList.add('active');
      }

      if (filter.isAuto) {
        pill.innerHTML = sanitizeHTML(`
          <span class="ha-tag-name">${filter.name}</span>
        `);
      } else {
        pill.innerHTML = sanitizeHTML(`
          <span class="ha-tag-name">${filter.name}</span>
          <span class="ha-tag-edit-btn" data-id="${filter.id}" title="Edit Filter">✏️</span>
          <span class="ha-tag-delete-btn" data-id="${filter.id}" title="Delete Filter">🗑️</span>
        `);
      }

      // Click to filter behavior
      pill.addEventListener('click', (e) => {
        if (e.target.classList.contains('ha-tag-edit-btn') || e.target.classList.contains('ha-tag-delete-btn')) return;

        const input = document.getElementById('ha-filter-input');
        if (state.filterQuery === filter.keywords) {
          state.filterQuery = "";
          if (input) input.value = "";
        } else {
          state.filterQuery = filter.keywords;
          if (input) input.value = filter.keywords;
        }
        updateBinsAndSpikes();
        updateUI();
      });

      if (!filter.isAuto) {
        // Edit listener
        pill.querySelector('.ha-tag-edit-btn').addEventListener('click', (e) => {
          e.stopPropagation();
          openFilterForm(filter.id);
        });

        // Delete listener
        pill.querySelector('.ha-tag-delete-btn').addEventListener('click', (e) => {
          e.stopPropagation();
          deleteFilter(filter.id);
        });
      }

      container.insertBefore(pill, addBtn);
    }

    // Render persistent local filters, followed by dynamic auto-emotes
    state.customFilters.forEach(appendPill);
    state.detectedEmoteFilters.forEach(appendPill);
  }

  // Delete filter from list and save
  function deleteFilter(id) {
    const filter = state.customFilters.find(f => f.id === id);
    if (!filter) return;

    if (confirm(`Are you sure you want to delete the "${filter.name}" filter?`)) {
      if (state.filterQuery === filter.keywords) {
        state.filterQuery = "";
        const input = document.getElementById('ha-filter-input');
        if (input) input.value = "";
      }
      state.customFilters = state.customFilters.filter(f => f.id !== id);
      saveFilters();
      updateBinsAndSpikes();
      updateUI();
    }
  }

  // Open inline creation/edit form
  let activeEditingId = null;
  function openFilterForm(id = null) {
    const form = document.getElementById('ha-filter-form');
    const inputName = document.getElementById('ha-form-name');
    const inputKeywords = document.getElementById('ha-form-keywords');
    if (!form || !inputName || !inputKeywords) return;

    activeEditingId = id;
    if (id) {
      const filter = state.customFilters.find(f => f.id === id);
      if (filter) {
        inputName.value = filter.name;
        inputKeywords.value = filter.keywords;
      }
    } else {
      inputName.value = "";
      inputKeywords.value = "";
    }

    form.style.display = 'flex';
    inputName.focus();
  }

  function closeFilterForm() {
    const form = document.getElementById('ha-filter-form');
    if (form) form.style.display = 'none';
    activeEditingId = null;
  }

  function saveFilterForm() {
    const inputName = document.getElementById('ha-form-name');
    const inputKeywords = document.getElementById('ha-form-keywords');
    if (!inputName || !inputKeywords) return;

    const name = inputName.value.trim();
    const keywords = inputKeywords.value.trim().toLowerCase();

    if (!name || !keywords) {
      alert("Please enter both a tag name and some keywords.");
      return;
    }

    if (activeEditingId) {
      // Edit mode
      const filter = state.customFilters.find(f => f.id === activeEditingId);
      if (filter) {
        filter.name = name;
        filter.keywords = keywords;
      }
    } else {
      // Add mode
      const newId = 'custom_' + Date.now();
      state.customFilters.push({
        id: newId,
        name: name,
        keywords: keywords
      });
    }

    saveFilters();
    closeFilterForm();
    updateBinsAndSpikes();
    updateUI();
  }

  // Render or update custom HTML tooltip
  function renderHTMLTooltip(chart) {
    const canvas = chart.canvas;
    const container = canvas.parentNode;
    let tooltipEl = container.querySelector('#ha-chart-tooltip');
    if (!tooltipEl) {
      tooltipEl = document.createElement('div');
      tooltipEl.id = 'ha-chart-tooltip';
      container.appendChild(tooltipEl);
    }

    if (state.hoveredSuperChatPoint) {
      const sc = state.hoveredSuperChatPoint;
      const timeStr = formatTime(sc.x);
      const scColorHex = sc.colorHex || '#ffa500';
      const authorClean = sc.author.startsWith('@') ? sc.author.slice(1) : sc.author;

      let html = `
        <div class="ha-tooltip-title" style="border-bottom: 2px solid ${scColorHex}; padding-bottom: 6px; display: flex; align-items: center; justify-content: space-between;">
          <span style="font-weight: 600;">⏱ Time: ${timeStr}</span>
          <span style="background-color: ${scColorHex}; color: #fff; padding: 2px 8px; border-radius: 4px; font-weight: bold; font-size: 11px; text-shadow: 1px 1px 2px rgba(0,0,0,0.5); margin-left: auto;">Super Chat ${sc.amount}</span>
        </div>
        <div class="ha-tooltip-comments" style="margin-top: 8px;">
          <div class="ha-tooltip-comment" style="font-size: 13px; line-height: 1.4; color: #fff;">
            <strong style="color: ${scColorHex}; font-size: 13px;">${authorClean}:</strong> ${sc.text || '<i>(No message)</i>'}
          </div>
        </div>
      `;

      tooltipEl.innerHTML = sanitizeHTML(html);

      // Position tooltip above the caret
      const tooltipWidth = 320;
      const tooltipHeight = tooltipEl.offsetHeight || 100;
      const containerWidth = container.clientWidth;

      let left = state.hoveredCaretX - (tooltipWidth / 2);
      left = Math.max(0, Math.min(containerWidth - tooltipWidth, left));

      const top = state.hoveredCaretY - tooltipHeight - 12;

      tooltipEl.style.left = left + 'px';
      tooltipEl.style.top = top + 'px';
      tooltipEl.style.opacity = 1;
      return;
    }

    if (state.hoveredBinIndex === null) {
      tooltipEl.style.opacity = 0;
      return;
    }

    const index = state.hoveredBinIndex;
    const bin = state.binnedData[index];
    const filteredBin = state.filteredBinnedData[index];
    if (!bin) {
      tooltipEl.style.opacity = 0;
      return;
    }

    const timeStr = formatTime(bin.x);
    const overallY = bin.rawY ?? bin.y;
    const filteredY = filteredBin ? (filteredBin.rawY ?? filteredBin.y) : 0;
    const totalMsgs = bin.messages ? bin.messages.length : 0;
    const offset = state.tooltipMessageOffset || 0;

    // Slice 4 messages starting at current rotation offset
    const sample = [];
    if (bin.messages && totalMsgs > 0) {
      for (let i = 0; i < Math.min(4, totalMsgs); i++) {
        const msgIndex = (offset + i) % totalMsgs;
        sample.push(bin.messages[msgIndex]);
      }
    }

    // Pad sample to exactly 4 items to keep fixed height
    while (sample.length < 4) {
      sample.push({ author: '', text: '' });
    }

    let html = `
      <div class="ha-tooltip-title">
        <span>⏱ Time: ${timeStr}</span>
      </div>
      <div class="ha-tooltip-metrics">
        <div class="ha-tooltip-metric">
          <span class="ha-tooltip-metric-label">Overall Chat Activity:</span>
          <span class="ha-tooltip-metric-value">${overallY} msgs/min</span>
        </div>
    `;

    if (state.filterQuery) {
      html += `
        <div class="ha-tooltip-metric">
          <span class="ha-tooltip-metric-label">Filtered ("${state.filterQuery}"):</span>
          <span class="ha-tooltip-metric-value">${filteredY} msgs/min</span>
        </div>
      `;
    }

    if (state.sentimentEnabled) {
      const sentimentVal = state.sentimentBinnedData[index] ? state.sentimentBinnedData[index].y : 0;
      let sentimentLabel = 'Neutral';
      let sentimentColor = '#aaa';
      if (sentimentVal > 0.3) {
        sentimentLabel = 'Positive Hype';
        sentimentColor = '#00ffcc';
      } else if (sentimentVal > 0.1) {
        sentimentLabel = 'Mild Positive';
        sentimentColor = '#77ffdd';
      } else if (sentimentVal < -0.3) {
        sentimentLabel = 'Negative (Fail/Rage)';
        sentimentColor = '#ff4e4e';
      } else if (sentimentVal < -0.1) {
        sentimentLabel = 'Mild Negative';
        sentimentColor = '#ff9999';
      }

      html += `
        <div class="ha-tooltip-metric">
          <span class="ha-tooltip-metric-label">Average Sentiment:</span>
          <span class="ha-tooltip-metric-value" style="color: ${sentimentColor}">${sentimentVal > 0 ? '+' : ''}${sentimentVal} (${sentimentLabel})</span>
        </div>
      `;
    }

    html += `</div>`; // Close metrics

    if (totalMsgs > 0) {
      html += `
        <div class="ha-tooltip-comments">
          <div class="ha-tooltip-metric-label" style="margin-bottom: 2px; font-weight: 600;">
            Sample Messages (${offset + 1}-${Math.min(offset + 4, totalMsgs)} of ${totalMsgs}):
          </div>
      `;
      sample.forEach(m => {
        if (!m.author && !m.text) {
          html += `<div class="ha-tooltip-comment">&nbsp;</div>`;
        } else {
          const authorClean = m.author.startsWith('@') ? m.author.slice(1) : m.author;
          html += `
            <div class="ha-tooltip-comment">
              <span class="ha-tooltip-author">${authorClean}:</span>${m.text}
            </div>
          `;
        }
      });
      html += `</div>`;
    } else {
      html += `
        <div class="ha-tooltip-comments">
          <div class="ha-tooltip-metric-label" style="margin-bottom: 2px; font-weight: 600;">No messages in this interval</div>
          <div class="ha-tooltip-comment">&nbsp;</div>
          <div class="ha-tooltip-comment">&nbsp;</div>
          <div class="ha-tooltip-comment">&nbsp;</div>
          <div class="ha-tooltip-comment">&nbsp;</div>
        </div>
      `;
    }

    tooltipEl.innerHTML = sanitizeHTML(html);

    // Position tooltip above the peak
    const tooltipWidth = 380;
    const tooltipHeight = tooltipEl.offsetHeight || (state.sentimentEnabled ? 190 : 170);
    const containerWidth = container.clientWidth;

    let left = state.hoveredCaretX - (tooltipWidth / 2);
    // Constrain within container boundaries
    left = Math.max(0, Math.min(containerWidth - tooltipWidth, left));

    const top = state.hoveredCaretY - tooltipHeight - 12;

    tooltipEl.style.left = left + 'px';
    tooltipEl.style.top = top + 'px';
    tooltipEl.style.opacity = 1;
  }

  // Chart.js external tooltip handler
  function externalTooltipHandler(context) {
    const {chart, tooltip} = context;
    if (tooltip.opacity === 0 || !tooltip.dataPoints || tooltip.dataPoints.length === 0) {
      const container = chart.canvas.parentNode;
      const tooltipEl = container.querySelector('#ha-chart-tooltip');
      if (tooltipEl) tooltipEl.style.opacity = 0;
      state.hoveredBinIndex = null;
      state.hoveredSuperChatPoint = null;
      stopTooltipRotation();
      return;
    }

    const dataPoint = tooltip.dataPoints[0];
    const datasetIndex = dataPoint.datasetIndex;

    if (datasetIndex === 3) {
      state.hoveredBinIndex = null;
      state.hoveredSuperChatPoint = dataPoint.raw;
      stopTooltipRotation();
    } else {
      const index = dataPoint.dataIndex;
      if (state.hoveredBinIndex !== index) {
        state.hoveredBinIndex = index;
        state.tooltipMessageOffset = 0;
      }
      state.hoveredSuperChatPoint = null;
    }
    state.hoveredCaretX = tooltip.caretX;
    state.hoveredCaretY = tooltip.caretY;

    renderHTMLTooltip(chart);
    if (datasetIndex !== 3) {
      startTooltipRotation();
    }
  }

  // Playback Indicator Line Custom Chart.js Plugin
  const playbackLinePlugin = {
    id: 'playbackLine',
    afterDatasetsDraw(chart) {
      const video = document.querySelector('ytd-player video') || document.querySelector('video');
      if (!video || isNaN(video.currentTime) || isNaN(video.duration) || video.duration <= 0) return;

      const ctx = chart.ctx;
      const xAxis = chart.scales.x;
      const yAxis = chart.scales.y;

      const xPos = xAxis.getPixelForValue(video.currentTime);
      const topY = yAxis.top;
      const bottomY = yAxis.bottom;

      ctx.save();

      // Draw vertical playback line
      ctx.beginPath();
      ctx.strokeStyle = '#ffa500'; // Contrast color (orange)
      ctx.lineWidth = 2;
      ctx.moveTo(xPos, topY);
      ctx.lineTo(xPos, bottomY);
      ctx.stroke();

      // Draw a tiny downward-pointing triangle indicator at the top of the line
      ctx.fillStyle = '#ffa500';
      ctx.beginPath();
      ctx.moveTo(xPos - 5, topY);
      ctx.lineTo(xPos + 5, topY);
      ctx.lineTo(xPos, topY + 6);
      ctx.closePath();
      ctx.fill();

      ctx.restore();
    }
  };

  let lastVideoEl = null;

  function updatePlaybackIndicator() {
    if (state.chart && !state.isCollapsed) {
      state.chart.draw();
    }
  }

  function setupPlaybackIndicatorListener() {
    const video = document.querySelector('ytd-player video') || document.querySelector('video');
    if (video && video !== lastVideoEl) {
      if (lastVideoEl) {
        lastVideoEl.removeEventListener('timeupdate', updatePlaybackIndicator);
      }
      video.addEventListener('timeupdate', updatePlaybackIndicator);
      lastVideoEl = video;
      console.log('Highlight Analyzer: Attached playback indicator listener to video element');
    }
  }

  // Draw or refresh Chart.js instance
  function renderChart() {
    const canvas = document.getElementById('ha-chart-canvas');
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    if (state.chart) {
      state.chart.data.datasets[0].data = state.binnedData;
      state.chart.data.datasets[0].tension = 0.3;
      state.chart.data.datasets[1].data = state.filteredBinnedData;
      state.chart.data.datasets[1].tension = 0.3;
      state.chart.data.datasets[1].hidden = !state.filterQuery;
      if (state.chart.data.datasets[2]) {
        state.chart.data.datasets[2].data = state.sentimentBinnedData;
        state.chart.data.datasets[2].hidden = !state.sentimentEnabled;
      }
      if (state.chart.data.datasets[3]) {
        state.chart.data.datasets[3].data = state.superChatPoints || [];
        state.chart.data.datasets[3].hidden = !state.showSuperchatsOnGraph;
      }
      state.chart.options.scales.x.max = state.duration || 3600;
      state.chart.options.scales.y.max = state.peakRate > 0 ? state.peakRate : undefined;
      state.chart.options.scales.ySentiment.display = state.sentimentEnabled;
      state.chart.update('none'); // Update without animation for performance
      return;
    }

    // Destroy any existing Chart.js instance associated with this canvas to prevent reuse errors
    if (typeof Chart !== 'undefined') {
      try {
        const existingChart = Chart.getChart(canvas);
        if (existingChart) {
          existingChart.destroy();
        }
      } catch (e) {}
    }

    // Create overall linear gradient fill
    const gradient = ctx.createLinearGradient(0, 0, 0, 200);
    gradient.addColorStop(0, 'rgba(255, 0, 0, 0.4)');
    gradient.addColorStop(1, 'rgba(255, 0, 0, 0.0)');

    // Create filtered linear gradient fill
    const filteredGradient = ctx.createLinearGradient(0, 0, 0, 200);
    filteredGradient.addColorStop(0, 'rgba(255, 165, 0, 0.3)');
    filteredGradient.addColorStop(1, 'rgba(255, 165, 0, 0.0)');

    state.chart = new Chart(ctx, {
      type: 'line',
      plugins: [playbackLinePlugin],
      data: {
        datasets: [
          {
            label: 'Overall Chat Activity',
            data: state.binnedData,
            borderColor: '#ff0000',
            borderWidth: 2,
            backgroundColor: gradient,
            fill: true,
            pointBackgroundColor: '#ff0000',
            pointBorderColor: '#ffffff',
            pointHoverRadius: 6,
            pointRadius: 0, // Hide dots by default, show on hover
            pointHitRadius: 10,
            tension: 0.3 // Locked curve curvature
          },
          {
            label: 'Filtered Keyword Activity',
            data: state.filteredBinnedData,
            borderColor: '#ffa500',
            borderWidth: 2,
            backgroundColor: filteredGradient,
            fill: true,
            pointBackgroundColor: '#ffa500',
            pointBorderColor: '#ffffff',
            pointHoverRadius: 6,
            pointRadius: 0,
            pointHitRadius: 10,
            tension: 0.3, // Locked curve curvature
            hidden: !state.filterQuery
          },
          {
            label: 'Sentiment Trajectory',
            data: state.sentimentBinnedData,
            borderColor: '#00ffcc',
            borderWidth: 2,
            backgroundColor: 'rgba(0, 255, 204, 0.05)',
            fill: false,
            pointBackgroundColor: '#00ffcc',
            pointBorderColor: '#ffffff',
            pointHoverRadius: 6,
            pointRadius: 0,
            pointHitRadius: 10,
            tension: 0.3,
            yAxisID: 'ySentiment',
            hidden: !state.sentimentEnabled
          },
          {
            label: 'Super Chats',
            data: state.superChatPoints || [],
            type: 'scatter',
            borderColor: 'transparent',
            backgroundColor: 'transparent',
            pointBackgroundColor: (context) => {
              const dataPoint = context.dataset.data[context.dataIndex];
              return dataPoint ? (dataPoint.colorHex || '#ffa500') : '#ffa500';
            },
            pointBorderColor: '#ffffff',
            pointBorderWidth: 1.5,
            pointRadius: 6,
            pointHoverRadius: 8,
            pointHitRadius: 10,
            yAxisID: 'y',
            hidden: !state.showSuperchatsOnGraph
          }
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        layout: {
          padding: {
            top: 10 // Reduced top padding since custom tooltip floats above the canvas
          }
        },
        plugins: {
          legend: { display: false },
          tooltip: {
            enabled: false, // Disable default tooltip rendering
            external: externalTooltipHandler // Route to our custom HTML tooltip
          }
        },
        scales: {
          x: {
            type: 'linear',
            min: 0,
            max: state.duration || 3600,
            ticks: {
              color: 'rgba(255, 255, 255, 0.6)',
              callback: (val) => formatTime(val),
              font: { size: 10 }
            },
            grid: { display: false }
          },
          y: {
            min: 0,
            max: state.peakRate > 0 ? state.peakRate : undefined,
            ticks: {
              color: 'rgba(255, 255, 255, 0.6)',
              font: { size: 10 }
            },
            grid: { color: 'rgba(255, 255, 255, 0.05)' }
          },
          ySentiment: {
            position: 'right',
            min: -1.2 / (state.sentimentGain || 1.0),
            max: 1.2 / (state.sentimentGain || 1.0),
            ticks: {
              color: 'rgba(0, 255, 204, 0.6)',
              font: { size: 10 },
              callback: (val) => val > 0 ? `+${val.toFixed(2)}` : val.toFixed(2)
            },
            grid: { drawOnChartArea: false }, // Only show grid lines for the main Y axis
            display: state.sentimentEnabled
          }
        },
        onClick: (event, elements) => {
          if (elements && elements.length > 0) {
            const index = elements[0].index;
            const datasetIndex = elements[0].datasetIndex;
            if (datasetIndex === 3) {
              const dataPoint = state.superChatPoints[index];
              if (dataPoint) {
                seekTo(dataPoint.offset);
              }
            } else {
              const dataPoint = state.binnedData[index];
              if (dataPoint) {
                seekTo(dataPoint.x);
              }
            }
          } else {
            // Fallback: click anywhere on chart calculates target time from scale
            const chartArea = state.chart.chartArea;
            const xVal = state.chart.scales.x.getValueForPixel(event.x);
            if (xVal >= 0 && xVal <= state.duration && event.x >= chartArea.left && event.x <= chartArea.right) {
              seekTo(xVal);
            }
          }
        }
      }
    });
  }

  // Export processed chat to CSV file
  function exportChat() {
    if (state.messages.length === 0) {
      alert('No messages loaded to export. Load chat first.');
      return;
    }

    const videoId = new URLSearchParams(window.location.search).get('v') || 'VOD';
    const csvLines = ["Timestamp,Offset (Seconds),Author,Message"];

    state.messages.forEach(m => {
      const escapedAuthor = m.author.replace(/"/g, '""');
      const escapedText = m.text.replace(/"/g, '""');
      csvLines.push(`"${formatTime(m.offset)}",${m.offset},"${escapedAuthor}","${escapedText}"`);
    });

    const blob = new Blob([csvLines.join("\n")], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);

    const link = document.createElement("a");
    link.href = url;
    link.download = `chat_log_${videoId}.csv`;
    document.body.appendChild(link);
    link.click();

    // Cleanup
    document.body.removeChild(link);
    URL.revokeObjectURL(url);
  }

  // Scan live chat history page by page
  async function startScan() {
    if (state.isScanning) return;

    // Automatically expand panel when starting a scan
    if (state.isCollapsed) {
      const panel = document.getElementById('highlight-analyzer-panel');
      if (panel) {
        const body = panel.querySelector('#ha-panel-body');
        const btn = panel.querySelector('#ha-btn-toggle-collapse');
        if (body && btn) {
          body.style.display = 'block';
          btn.textContent = '▲ Hide';
          state.isCollapsed = false;
        }
      }
    }

    extractMetadata();

    const errorEl = document.getElementById('ha-error-message');
    if (errorEl) errorEl.style.display = 'none';

    // Reset search UI & state
    state.searchQuery = "";
    const searchInput = document.getElementById('ha-search-input');
    if (searchInput) searchInput.value = '';
    const searchResults = document.getElementById('ha-search-results');
    if (searchResults) {
      searchResults.innerHTML = sanitizeHTML(`<div style="color: var(--yt-spec-text-secondary, #aaa); padding: 4px;">Type something to search...</div>`);
    }

    state.isScanning = true;
    state.isPaused = false;
    state.progress = 0;
    state.messages = [];
    state.binnedData = [];
    state.filteredBinnedData = [];
    state.spikes = [];
    state.emoteCounts = {};
    state.detectedEmoteFilters = [];
    state.isCachedLoad = false;
    state.abortController = new AbortController();

    updateBinsAndSpikes();
    updateUI();

    let pagesLoaded = 0;

    try {
      if (isYouTube) {
        let currentToken = state.initialToken;

        // Await background pre-fetched 0-second token if page has timestamp offset
        if (zeroTokenPromise) {
          try {
            const zeroToken = await zeroTokenPromise;
            if (zeroToken) {
              currentToken = zeroToken;
              state.initialToken = zeroToken;
            }
          } catch (e) {
            console.warn('Highlight Analyzer: Error awaiting zero-second token:', e);
          }
        }

        if (!currentToken) {
          throw new Error('Could not find chat replay token. Make sure "Live Chat Replay" is enabled and active.');
        }

        let lastOffset = 0;
        while (currentToken && state.isScanning) {
          // Pause check
          while (state.isPaused) {
            if (state.abortController.signal.aborted) throw new Error('Aborted');
            await new Promise(r => setTimeout(r, 100));
          }

          if (state.abortController.signal.aborted) throw new Error('Aborted');

          const url = `https://www.youtube.com/youtubei/v1/live_chat/get_live_chat_replay?key=${state.apiKey}&prettyPrint=false`;
          const response = await fetch(url, {
            method: 'POST',
            signal: state.abortController.signal,
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({
              context: state.context,
              continuation: currentToken
            })
          });

          if (!response.ok) {
            throw new Error(`HTTP error ${response.status}`);
          }

          const data = await response.json();
          const actions = data.continuationContents?.liveChatContinuation?.actions || [];

          actions.forEach(action => {
            const replayAction = action.replayChatItemAction;
            if (replayAction) {
              const offsetMs = parseInt(replayAction.videoOffsetTimeMsec || 0, 10);
              const offsetSec = offsetMs / 1000;
              lastOffset = offsetSec;

              const subActions = replayAction.actions || [];
              subActions.forEach(sub => {
                const item = sub.addChatItemAction?.item;
                if (item) {
                  let text = '';
                  let author = '';
                  let messageRuns = [];
                  let isSuperChat = false;
                  let superChatAmount = '';
                  let superChatColor = '';

                  if (item.liveChatTextMessageRenderer) {
                    const renderer = item.liveChatTextMessageRenderer;
                    author = renderer.authorName?.simpleText || 'Anonymous';
                    messageRuns = renderer.message?.runs || [];
                  } else if (item.liveChatPaidMessageRenderer) {
                    const renderer = item.liveChatPaidMessageRenderer;
                    author = renderer.authorName?.simpleText || 'SuperChat';
                    messageRuns = renderer.message?.runs || [];
                    isSuperChat = true;
                    superChatAmount = renderer.purchaseAmountText?.simpleText || '';
                    superChatColor = getSuperChatColorCategory(renderer.headerBackgroundColor);
                  } else if (item.liveChatMembershipItemRenderer) {
                    const renderer = item.liveChatMembershipItemRenderer;
                    author = renderer.authorName?.simpleText || 'Member';
                    messageRuns = renderer.headerPrimaryText?.runs || [];
                  }

                  const parsedTexts = [];
                  messageRuns.forEach(run => {
                    if (run.text) {
                      parsedTexts.push(run.text);
                      // Match and count standard emojis in plain text runs
                      const emojis = run.text.match(/\p{Extended_Pictographic}/gu);
                      if (emojis) {
                        emojis.forEach(emoji => {
                          state.emoteCounts[emoji] = (state.emoteCounts[emoji] || 0) + 1;
                        });
                      }
                    } else if (run.emoji) {
                      const isCustom = run.emoji.isCustomEmoji ||
                                       (run.emoji.shortcuts && run.emoji.shortcuts.some(s => s.startsWith(':') && s.endsWith(':')));
                      if (isCustom) {
                        const shortcut = run.emoji.shortcuts?.[0] || run.emoji.accessibility?.accessibilityData?.label || '';
                        if (shortcut) {
                          parsedTexts.push(shortcut);
                          state.emoteCounts[shortcut] = (state.emoteCounts[shortcut] || 0) + 1;
                        }
                      } else {
                        const emojiText = run.emoji.emojiId || run.emoji.shortcuts?.[0] || '';
                        if (emojiText) {
                          parsedTexts.push(emojiText);
                          state.emoteCounts[emojiText] = (state.emoteCounts[emojiText] || 0) + 1;
                        }
                      }
                    }
                  });

                  text = parsedTexts.join(' ');

                  if (text || author) {
                    const msgObj = {
                      offset: offsetSec,
                      text: text,
                      author: author
                    };
                    if (isSuperChat) {
                      msgObj.isSuperChat = true;
                      msgObj.superChatAmount = superChatAmount;
                      msgObj.superChatColor = superChatColor;
                    }
                    state.messages.push(msgObj);
                  }
                }
              });
            }
          });

          pagesLoaded++;

          if (state.duration > 0) {
            state.progress = Math.min(100, Math.round((lastOffset / state.duration) * 100));
          } else {
            state.progress = 0;
          }

          updateBinsAndSpikes();
          updateUI();

          const nextContData = data.continuationContents?.liveChatContinuation?.continuations?.[0];
          currentToken = nextContData?.liveChatReplayContinuationData?.continuation ||
                         nextContData?.reloadContinuationData?.continuation ||
                         nextContData?.timedContinuationData?.continuation;

          if (!currentToken) break;

          await new Promise(r => setTimeout(r, FETCH_DELAY_MS));
        }
      } else if (isTwitch) {
        const videoId = getTwitchVideoId();
        if (!videoId) {
          throw new Error('Could not parse Twitch Video ID.');
        }

        // Fetch device ID
        let deviceId = localStorage.getItem('local_copy_unique_id');
        if (deviceId) {
          deviceId = deviceId.replace(/^"|"$/g, '');
        } else {
          deviceId = "1f4785b0f64241aaa2ecd75589de0044"; // fallback
        }

        // Fetch integrity token
        let integrityToken = null;
        try {
          const integrityRes = await fetch("https://gql.twitch.tv/integrity", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              "Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko",
              "X-Device-Id": deviceId
            }
          });
          if (integrityRes.ok) {
            const integrityData = await integrityRes.json();
            integrityToken = integrityData.token;
          }
        } catch (e) {
          console.warn('Highlight Analyzer: Failed to fetch integrity token', e);
        }

        let currentToken = null; // cursor
        let contentOffsetSeconds = 0;
        let hasNextPage = true;
        let lastOffset = 0;

        while (hasNextPage && state.isScanning) {
          // Pause check
          while (state.isPaused) {
            if (state.abortController.signal.aborted) throw new Error('Aborted');
            await new Promise(r => setTimeout(r, 100));
          }

          if (state.abortController.signal.aborted) throw new Error('Aborted');

          const comments = await fetchTwitchCommentsPage(videoId, contentOffsetSeconds, currentToken, integrityToken, deviceId, state.abortController.signal);
          if (!comments) break;

          const edges = comments.edges || [];
          edges.forEach(edge => {
            const node = edge.node;
            if (node) {
              const offsetSec = node.contentOffsetSeconds || 0;
              lastOffset = offsetSec;

              let author = node.commenter?.displayName || node.commenter?.login || 'Anonymous';
              let text = '';

              const fragments = node.message?.fragments || [];
              const parsedTexts = [];
              fragments.forEach(frag => {
                if (frag.text) {
                  parsedTexts.push(frag.text);
                  if (frag.emote) {
                    const emoteName = frag.text;
                    state.emoteCounts[emoteName] = (state.emoteCounts[emoteName] || 0) + 1;
                  } else {
                    // Match and count standard emojis in plain text fragments
                    const emojis = frag.text.match(/\p{Extended_Pictographic}/gu);
                    if (emojis) {
                      emojis.forEach(emoji => {
                        state.emoteCounts[emoji] = (state.emoteCounts[emoji] || 0) + 1;
                      });
                    }
                  }
                }
              });
              text = parsedTexts.join('');

              if (text || author) {
                state.messages.push({
                  offset: offsetSec,
                  text: text,
                  author: author
                });
              }
            }
          });

          pagesLoaded++;

          if (state.duration > 0) {
            state.progress = Math.min(100, Math.round((lastOffset / state.duration) * 100));
          } else {
            state.progress = 0;
          }

          updateBinsAndSpikes();
          updateUI();

          hasNextPage = comments.pageInfo?.hasNextPage || false;
          currentToken = comments.pageInfo?.nextCursor || (edges.length > 0 ? edges[edges.length - 1].cursor : null);

          if (!currentToken) break;

          await new Promise(r => setTimeout(r, FETCH_DELAY_MS));
        }
      } else if (isKick) {
        const videoUuid = getKickVideoId();
        const channelSlug = getKickChannelSlug();
        if (!videoUuid || !channelSlug) {
          throw new Error('Could not parse Kick Channel Slug or Video UUID.');
        }

        // Fetch channel and video details in parallel
        if (errorEl) {
          errorEl.textContent = '⏳ Resolving channel and video metadata...';
          errorEl.style.display = 'block';
          errorEl.style.color = 'var(--yt-spec-text-secondary, #aaa)';
        }

        const [channelInfo, videoInfo] = await Promise.all([
          fetchKickChannelInfo(channelSlug),
          fetchKickVideoInfo(videoUuid)
        ]);

        if (errorEl) {
          errorEl.style.display = 'none';
          errorEl.style.color = ''; // reset to default error styling
        }

        const channelId = channelInfo.id;
        const startTimeStr = videoInfo.created_at;
        const startTime = new Date(startTimeStr);
        const duration = videoInfo.video?.duration || state.duration || 3600;
        state.duration = duration;

        const endTime = new Date(startTime.getTime() + (duration * 1000));
        let currentToken = null; // cursor
        const seenMessages = new Set();

        // Start scanning from the end of the VOD and paginate backwards
        let currentFetchTime = new Date(endTime.getTime());
        let initialFetch = true;
        let retryCount = 0;

        while (state.isScanning) {
          // Pause check
          while (state.isPaused) {
            if (state.abortController.signal.aborted) throw new Error('Aborted');
            await new Promise(r => setTimeout(r, 100));
          }

          if (state.abortController.signal.aborted) throw new Error('Aborted');

          let chatData;
          if (initialFetch) {
            chatData = await fetchKickCommentsPage(channelId, currentFetchTime.toISOString(), null, state.abortController.signal);
          } else {
            if (!currentToken) {
              console.log('Highlight Analyzer: No more cursors returned. Scanning finished.');
              break;
            }
            chatData = await fetchKickCommentsPage(channelId, null, currentToken, state.abortController.signal);
          }

          const messages = chatData?.data?.messages || [];
          const nextCursor = chatData?.data?.cursor || null;

          if (initialFetch && !nextCursor && retryCount < 10) {
            // We are in the active live block (empty cursor).
            // Process the messages from this block, then step back by 1 minute to retry
            if (messages.length > 0) {
              messages.forEach(msg => {
                if (seenMessages.has(msg.id)) return;
                seenMessages.add(msg.id);

                const msgTime = new Date(msg.created_at);
                const offsetSec = (msgTime.getTime() - startTime.getTime()) / 1000;

                if (offsetSec >= 0 && offsetSec <= duration) {
                  let author = msg.sender?.username || 'Anonymous';
                  let text = msg.content || '';

                  // Extract custom emotes
                  const emoteRegex = /\[emote:\d+:([a-zA-Z0-9_-]+)\]/g;
                  let emoteMatch;
                  while ((emoteMatch = emoteRegex.exec(text)) !== null) {
                    const emoteName = emoteMatch[1];
                    state.emoteCounts[emoteName] = (state.emoteCounts[emoteName] || 0) + 1;
                  }

                  // Match and count standard emojis in plain text
                  const emojis = text.match(/\p{Extended_Pictographic}/gu);
                  if (emojis) {
                    emojis.forEach(emoji => {
                      state.emoteCounts[emoji] = (state.emoteCounts[emoji] || 0) + 1;
                    });
                  }

                  const cleanText = text.replace(/\[emote:\d+:([a-zA-Z0-9_-]+)\]/g, '$1');

                  state.messages.push({
                    offset: offsetSec,
                    text: cleanText,
                    author: author
                });
                }
              });
              updateBinsAndSpikes();
              updateUI();
            }

            currentFetchTime = new Date(currentFetchTime.getTime() - 60000);
            retryCount++;
            console.log(`Highlight Analyzer: Live block detected. Stepping back 1 min to: ${currentFetchTime.toISOString()} (Retry ${retryCount}/10)`);
            await new Promise(r => setTimeout(r, FETCH_DELAY_MS));
            continue;
          }

          // If we got here, we either have a valid cursor or we exhausted retries
          initialFetch = false;

          if (messages.length > 0) {
            let reachedStart = false;

            messages.forEach(msg => {
              if (seenMessages.has(msg.id)) return;
              seenMessages.add(msg.id);

              const msgTime = new Date(msg.created_at);
              const offsetSec = (msgTime.getTime() - startTime.getTime()) / 1000;

              // Check if we reached before the start of the stream
              if (offsetSec < 0) {
                reachedStart = true;
                return;
              }

              // Only keep messages within the VOD duration
              if (offsetSec <= duration) {
                let author = msg.sender?.username || 'Anonymous';
                let text = msg.content || '';

                // Extract custom emotes: [emote:ID:Name]
                const emoteRegex = /\[emote:\d+:([a-zA-Z0-9_-]+)\]/g;
                let emoteMatch;
                while ((emoteMatch = emoteRegex.exec(text)) !== null) {
                  const emoteName = emoteMatch[1];
                  state.emoteCounts[emoteName] = (state.emoteCounts[emoteName] || 0) + 1;
                }

                // Match and count standard emojis
                const emojis = text.match(/\p{Extended_Pictographic}/gu);
                if (emojis) {
                  emojis.forEach(emoji => {
                    state.emoteCounts[emoji] = (state.emoteCounts[emoji] || 0) + 1;
                  });
                }

                const cleanText = text.replace(/\[emote:\d+:([a-zA-Z0-9_-]+)\]/g, '$1');

                state.messages.push({
                  offset: offsetSec,
                  text: cleanText,
                  author: author
                });
              }
            });

            // Get the oldest message on this page to calculate progress
            const oldestMsgOnPage = new Date(messages[messages.length - 1].created_at);
            const oldestOffset = (oldestMsgOnPage.getTime() - startTime.getTime()) / 1000;

            if (duration > 0) {
              const progressPct = Math.max(0, Math.min(100, Math.round((1 - (oldestOffset / duration)) * 100)));
              state.progress = progressPct;
            } else {
              state.progress = 0;
            }

            updateBinsAndSpikes();
            updateUI();

            if (reachedStart) {
              console.log('Highlight Analyzer: Reached messages before VOD start time. Finishing scan.');
              break;
            }
          } else {
            if (!nextCursor) {
              break;
            }
          }

          currentToken = nextCursor;
          pagesLoaded++;

          await new Promise(r => setTimeout(r, FETCH_DELAY_MS));
        }

        // Sort messages chronologically at the end since we fetched backwards
        state.messages.sort((a, b) => a.offset - b.offset);
        state.progress = 100;
        updateBinsAndSpikes();
        updateUI();
      }
    } catch (err) {
      if (err.message !== 'Aborted') {
        console.error('Highlight Analyzer scan error:', err);
        if (errorEl) {
          errorEl.textContent = `❌ Scan failed: ${err.message}`;
          errorEl.style.display = 'block';
        }
      }
    } finally {
      state.isScanning = false;
      state.isPaused = false;
      state.progress = 0;
      state.abortController = null;
      updateBinsAndSpikes();
      updateUI();

      // Cache scanned VOD chat data
      const currentVideoId = isYouTube ?
        new URLSearchParams(window.location.search).get('v') :
        (isTwitch ? getTwitchVideoId() : getKickVideoId());

      if (currentVideoId && state.messages.length > 0) {
        saveCachedVod(currentVideoId, {
          messages: state.messages,
          duration: state.duration,
          emoteCounts: state.emoteCounts,
          totalMessages: state.totalMessages,
          peakRate: state.peakRate,
          averageRate: state.averageRate,
          dynamicEmoteSentiment: state.dynamicEmoteSentiment,
          sentimentBinnedData: state.sentimentBinnedData
        });
      }
    }
  }

  function pauseScan() {
    if (!state.isScanning) return;
    state.isPaused = !state.isPaused;
    updateUI();
  }

  // Abort scan
  function stopScan() {
    if (state.abortController) {
      state.abortController.abort();
    }
    state.isScanning = false;
    state.isPaused = false;
    updateUI();
  }

  // Insert panel element into page DOM
  function insertPanel() {
    if (document.getElementById('highlight-analyzer-panel')) return;

    let target = null;
    if (isYouTube) {
      target = document.querySelector('ytd-watch-metadata') || document.querySelector('#meta');
    } else if (isTwitch) {
                  target = document.querySelector('#live-channel-stream-information') ||
               document.querySelector('.channel-info-content') ||
               document.querySelector('.channel-info-bar') ||
               document.querySelector('[data-a-target="channel-header-properties"]') ||
               document.querySelector('.about-section') ||
               document.querySelector('main');
    } else if (isKick) {
      const playerContainer = document.getElementById('injected-channel-player') ||
                              document.getElementById('video-player');
      if (playerContainer) {
        let ancestor = playerContainer;
        while (ancestor && ancestor.parentNode && ancestor.parentNode.tagName !== 'MAIN') {
          ancestor = ancestor.parentNode;
        }
        target = ancestor || playerContainer;
      } else {
        target = document.querySelector('.video-player-container') ||
                 document.querySelector('[data-testid="video-player"]') ||
                 document.querySelector('main');
      }
    }
    if (!target) return;

    injectStyles();
    loadFilters();
    loadSettings();
    loadRemovedSpikes();

    const panel = document.createElement('div');
    panel.id = 'highlight-analyzer-panel';
    panel.innerHTML = sanitizeHTML(`
      <div class="ha-header">
        <div class="ha-title-row">
          <h3 class="ha-title">📊 Highlight Analyzer</h3>
          <span class="ha-badge idle">Ready</span>
        </div>
        <div class="ha-controls">
          <button class="ha-btn" id="ha-btn-toggle-collapse">▼ Show</button>
          <button class="ha-btn ha-btn-primary" id="ha-btn-scan">🚀 Load Chat</button>
          <button class="ha-btn" id="ha-btn-pause" style="display: none;">⏸ Pause</button>
          <button class="ha-btn" id="ha-btn-stop" style="display: none;">🛑 Stop</button>
        </div>
      </div>

      <div id="ha-panel-body" style="display: none; margin-top: 12px;">
        <div class="ha-error" id="ha-error-message"></div>

        <div class="ha-progress-track">
          <div class="ha-progress-bar"></div>
        </div>

        <div class="ha-chart-container">
          <canvas id="ha-chart-canvas"></canvas>
        </div>

        <!-- Graph Peak Amplification Settings Slider Panel -->
        <details class="ha-settings-panel">
          <summary class="ha-settings-title">⚙️ Graph Settings & Peak Amplification</summary>
          <div class="ha-settings-content">
            <div class="ha-setting-row">
              <span class="ha-setting-label" title="Multiplies the chat activity spike heights on the chart, making peaks stand out more clearly.">Main Peak Gain:</span>
              <input type="range" class="ha-setting-slider" id="ha-slider-main-gain" min="0.5" max="5" step="0.1" value="${state.mainGain}">
              <span class="ha-setting-value" id="ha-val-main-gain">${formatAmplificationLabel(state.mainGain)}</span>
            </div>
            <div class="ha-setting-row">
              <span class="ha-setting-label" title="Multiplies the peak heights for custom search/keyword filters on the chart.">Filter Peak Gain:</span>
              <input type="range" class="ha-setting-slider" id="ha-slider-filter-gain" min="0.5" max="5" step="0.1" value="${state.filterGain}">
              <span class="ha-setting-value" id="ha-val-filter-gain">${formatAmplificationLabel(state.filterGain)}</span>
            </div>
            <div class="ha-setting-row">
              <span class="ha-setting-label" title="Choose 'Manual' to use a constant offset, or 'Auto' to dynamically find the beginning of a chat reaction.">Seek Mode:</span>
              <div style="flex: 1; display: flex; align-items: center; gap: 12px;">
                <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none;">
                  <input type="radio" name="ha-seek-mode" value="manual" ${state.seekMode === 'manual' ? 'checked' : ''} style="margin: 0; cursor: pointer;"> Manual
                </label>
                <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none;">
                  <input type="radio" name="ha-seek-mode" value="auto" ${state.seekMode === 'auto' ? 'checked' : ''} style="margin: 0; cursor: pointer;"> Auto (Valley-seek)
                </label>
              </div>
            </div>
            <div class="ha-setting-row" id="ha-row-seek-offset" style="display: ${state.seekMode === 'manual' ? 'flex' : 'none'};">
              <span class="ha-setting-label" title="The number of seconds to jump back before the peak timestamp to give context (Manual mode only).">Seek Lead-in Buffer:</span>
              <input type="number" id="ha-input-seek-offset" min="0" max="300" step="1" value="${state.seekOffset}" style="flex: 0 0 70px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.15); color: #fff; border-radius: 4px; padding: 2px 6px; font-family: monospace; text-align: center; height: auto;">
              <span class="ha-setting-value" style="text-align: left; width: auto; flex: 1;">seconds</span>
            </div>
            <!-- Blacklist Settings -->
            <div class="ha-setting-row" style="align-items: flex-start;">
              <span class="ha-setting-label" style="margin-top: 4px;" title="Ignores chat messages containing specified blacklisted words/phrases to filter out spam.">Enable Blacklist:</span>
              <div style="flex: 1; display: flex; flex-direction: column; gap: 6px;">
                <div style="display: flex; align-items: center; gap: 8px;">
                  <input type="checkbox" id="ha-checkbox-blacklist-enabled" ${state.blacklistEnabled ? 'checked' : ''}>
                  <label for="ha-checkbox-blacklist-enabled" style="user-select: none;">Enabled</label>
                  <input type="checkbox" id="ha-checkbox-blacklist-case" ${state.blacklistCaseSensitive ? 'checked' : ''} style="margin-left: 10px;">
                  <label for="ha-checkbox-blacklist-case" style="user-select: none;">Case Sensitive</label>
                </div>
                <input type="text" id="ha-input-blacklist-query" class="ha-filter-input" placeholder="Blacklist words... (comma-separated)" value="${state.blacklistQuery}" style="padding: 4px 8px; font-size: 11px;">
              </div>
            </div>
            <!-- Cache Settings -->
            <div class="ha-setting-row" style="justify-content: space-between;">
              <span id="ha-cache-stats" style="color: var(--yt-spec-text-secondary, #aaa); font-size: 11px;">Loading cache stats...</span>
              <button class="ha-btn" id="ha-btn-clear-cache" style="padding: 2px 8px; font-size: 11px; height: auto; line-height: 1.5; margin: 0;" title="Delete all cached VOD chat scans from your local browser storage.">🗑️ Clear Cache</button>
            </div>
            <!-- Sentiment settings -->
            <div class="ha-setting-row" style="flex-direction: column; align-items: flex-start; gap: 6px;">
              <div style="display: flex; align-items: center; gap: 8px;">
                <input type="checkbox" id="ha-checkbox-sentiment-enabled" ${state.sentimentEnabled ? 'checked' : ''}>
                <label for="ha-checkbox-sentiment-enabled" style="user-select: none; font-size: 12px; color: var(--yt-spec-text-secondary, #aaa);">Enable Sentiment Trajectory Line</label>
              </div>
              <div id="ha-sentiment-gain-container" style="display: ${state.sentimentEnabled ? 'flex' : 'none'}; align-items: center; width: 100%; gap: 8px; padding-left: 20px; box-sizing: border-box;">
                <span class="ha-setting-label" style="width: auto; min-width: 110px; font-size: 11px; color: var(--yt-spec-text-secondary, #aaa);" title="Adjusts the visual scale of the sentiment line on the chart.">Sentiment Gain:</span>
                <input type="range" class="ha-setting-slider" id="ha-slider-sentiment-gain" min="0.2" max="5" step="0.1" value="${state.sentimentGain || 1.0}" style="flex: 1;">
                <span class="ha-setting-value" id="ha-val-sentiment-gain" style="font-size: 11px; min-width: 35px;">${formatAmplificationLabel(state.sentimentGain || 1.0)}</span>
              </div>
            </div>
            <!-- Super Chats settings -->
            <div class="ha-setting-row" style="flex-direction: column; align-items: flex-start; gap: 6px;">
              <div style="display: flex; align-items: center; gap: 8px;">
                <input type="checkbox" id="ha-checkbox-superchats-enabled" ${state.showSuperchatsOnGraph ? 'checked' : ''}>
                <label for="ha-checkbox-superchats-enabled" style="user-select: none; font-size: 12px; color: var(--yt-spec-text-secondary, #aaa);">Show Super Chats on Graph</label>
              </div>
              <div id="ha-superchats-subtoggles-container" style="display: ${state.showSuperchatsOnGraph ? 'flex' : 'none'}; flex-wrap: wrap; gap: 10px; padding-left: 20px; font-size: 11px; color: var(--yt-spec-text-secondary, #aaa); box-sizing: border-box;">
                <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none;">
                  <input type="checkbox" id="ha-checkbox-sc-teal" ${state.showSuperchatTeal ? 'checked' : ''} style="margin: 0; cursor: pointer;">
                  <span style="color: #00bfa5; font-weight: bold;">Teal</span>
                </label>
                <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none;">
                  <input type="checkbox" id="ha-checkbox-sc-yellow" ${state.showSuperchatYellow ? 'checked' : ''} style="margin: 0; cursor: pointer;">
                  <span style="color: #ffca28; font-weight: bold;">Yellow</span>
                </label>
                <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none;">
                  <input type="checkbox" id="ha-checkbox-sc-pink" ${state.showSuperchatPink ? 'checked' : ''} style="margin: 0; cursor: pointer;">
                  <span style="color: #e91e63; font-weight: bold;">Pink</span>
                </label>
                <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none;">
                  <input type="checkbox" id="ha-checkbox-sc-red" ${state.showSuperchatRed ? 'checked' : ''} style="margin: 0; cursor: pointer;">
                  <span style="color: #ff0000; font-weight: bold;">Red</span>
                </label>
              </div>
            </div>
            <!-- Auto-filter settings -->
            <div class="ha-setting-row">
              <span class="ha-setting-label" title="Includes standard pictorial emojis (e.g. 😂, 👍, 🔥) in the automatically detected emote list.">Include Emojis:</span>
              <div style="flex: 1; display: flex; align-items: center; gap: 8px;">
                <input type="checkbox" id="ha-checkbox-auto-emojis-enabled" ${state.includeStandardEmojisInAutoFilters ? 'checked' : ''}>
                <label for="ha-checkbox-auto-emojis-enabled" style="user-select: none; font-size: 12px; color: var(--yt-spec-text-secondary, #aaa);">Include standard emojis in auto-filters</label>
              </div>
            </div>
            <div class="ha-setting-row">
              <span class="ha-setting-label" title="Limits the maximum number of automatically detected emote filter buttons shown.">Max Auto-Filters:</span>
              <input type="number" id="ha-input-max-auto-filters" min="1" max="50" step="1" value="${state.maxAutoFilters}" style="flex: 0 0 70px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.15); color: #fff; border-radius: 4px; padding: 2px 6px; font-family: monospace; text-align: center; height: auto;">
              <span class="ha-setting-value" style="text-align: left; width: auto; flex: 1;">filters shown</span>
            </div>
            <div class="ha-setting-row">
              <span class="ha-setting-label" title="Limits the maximum number of automatically detected top highlight spikes shown.">Max Highlight Spikes:</span>
              <input type="number" id="ha-input-max-spikes" min="1" max="20" step="1" value="${state.maxSpikes || 5}" style="flex: 0 0 70px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.15); color: #fff; border-radius: 4px; padding: 2px 6px; font-family: monospace; text-align: center; height: auto;">
              <span class="ha-setting-value" style="text-align: left; width: auto; flex: 1;">spikes shown</span>
            </div>
          </div>
        </details>

        <!-- Keyword Filters Section -->
        <div class="ha-filter-section">
          <div class="ha-filter-row">
            <input type="text" class="ha-filter-input" id="ha-filter-input" placeholder="Search keywords... e.g. lol, omg (comma-separated)">
            <button class="ha-btn" id="ha-btn-export" title="Export full parsed chat to CSV">💾 Export Chat</button>
          </div>

          <div class="ha-tags-container">
            <button class="ha-add-tag-btn" id="ha-btn-add-tag" title="Add Custom Filter Pill">+</button>
          </div>

          <div class="ha-filter-form" id="ha-filter-form" style="display: none;">
            <div class="ha-form-row">
              <input type="text" class="ha-form-input" id="ha-form-name" placeholder="Tag Name (e.g. Pippa)" style="flex: 1;">
              <input type="text" class="ha-form-input" id="ha-form-keywords" placeholder="Keywords (comma-separated: e.g. pippa,rabbit,bunny)" style="flex: 2;">
            </div>
            <div class="ha-form-buttons">
              <button class="ha-form-btn ha-form-btn-cancel" id="ha-form-cancel">Cancel</button>
              <button class="ha-form-btn ha-form-btn-save" id="ha-form-save">Save Tag</button>
            </div>
          </div>
        </div>

        <!-- Search logs panel -->
        <details class="ha-search-panel" id="ha-search-panel">
          <summary class="ha-settings-title">🔍 Search Message Logs</summary>
          <div style="margin-top: 10px; display: flex; flex-direction: column; gap: 8px;">
            <div style="display: flex; align-items: center; gap: 8px;">
              <input type="text" id="ha-search-input" class="ha-filter-input" placeholder="Search message text or author..." style="flex: 1; margin-bottom: 0;">
              <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; font-size: 11px; color: var(--yt-spec-text-secondary, #aaa); white-space: nowrap;">
                <input type="checkbox" id="ha-checkbox-superchats-only" ${state.filterSuperchatsOnly ? 'checked' : ''} style="margin: 0; cursor: pointer;"> Super Chats Only
              </label>
            </div>
            <div class="ha-search-results-container" id="ha-search-results">
              <div style="color: var(--yt-spec-text-secondary, #aaa); padding: 4px;">Type something to search...</div>
            </div>
          </div>
        </details>

        <div class="ha-stats-row">
          <div class="ha-stat-card">
            <div class="ha-stat-label">Total Messages</div>
            <div class="ha-stat-value" id="ha-stat-total">0</div>
          </div>
          <div class="ha-stat-card">
            <div class="ha-stat-label">Average Activity</div>
            <div class="ha-stat-value" id="ha-stat-average">0/min</div>
          </div>
          <div class="ha-stat-card">
            <div class="ha-stat-label">Peak Activity</div>
            <div class="ha-stat-value" id="ha-stat-peak">0/min</div>
          </div>
        </div>

        <div class="ha-highlights-panel">
          <h4 class="ha-highlights-title">🔥 Top Highlight Spikes</h4>
          <ul class="ha-spikes-list">
            <li style="font-size:12px; color:var(--yt-spec-text-secondary, #aaa);">No highlights detected yet. Start scanning.</li>
          </ul>
        </div>
      </div>
    `);

    target.parentNode.insertBefore(panel, target.nextSibling);
    updateCacheStatsUI();

    panel.querySelector('#ha-btn-scan').addEventListener('click', startScan);
    panel.querySelector('#ha-btn-pause').addEventListener('click', pauseScan);
    panel.querySelector('#ha-btn-stop').addEventListener('click', stopScan);
    panel.querySelector('#ha-btn-export').addEventListener('click', exportChat);
    panel.querySelector('#ha-btn-add-tag').addEventListener('click', () => openFilterForm(null));
    panel.querySelector('#ha-form-cancel').addEventListener('click', closeFilterForm);
    panel.querySelector('#ha-form-save').addEventListener('click', saveFilterForm);

    const filterInput = panel.querySelector('#ha-filter-input');
    filterInput.addEventListener('input', (e) => {
      state.filterQuery = e.target.value.toLowerCase();
      updateBinsAndSpikes();
      updateUI();
    });

    const sliderMain = panel.querySelector('#ha-slider-main-gain');
    const valMain = panel.querySelector('#ha-val-main-gain');
    sliderMain.addEventListener('input', (e) => {
      const val = parseFloat(e.target.value);
      state.mainGain = val;
      valMain.textContent = formatAmplificationLabel(val);
      if (state.chart) {
        state.binnedData = transformData(state.binnedData, val);
        state.chart.data.datasets[0].data = state.binnedData;
        state.chart.update('none');
      }
    });

    const sliderFilter = panel.querySelector('#ha-slider-filter-gain');
    const valFilter = panel.querySelector('#ha-val-filter-gain');
    sliderFilter.addEventListener('input', (e) => {
      const val = parseFloat(e.target.value);
      state.filterGain = val;
      valFilter.textContent = formatAmplificationLabel(val);
      if (state.chart) {
        state.filteredBinnedData = transformData(state.filteredBinnedData, val);
        state.chart.data.datasets[1].data = state.filteredBinnedData;
        state.chart.update('none');
      }
    });

    const inputSeekOffset = panel.querySelector('#ha-input-seek-offset');
    inputSeekOffset.addEventListener('input', (e) => {
      let val = parseInt(e.target.value, 10);
      if (isNaN(val) || val < 0) val = 0;
      state.seekOffset = val;
      saveSeekOffset();
    });

    const radioSeekModes = panel.querySelectorAll('input[name="ha-seek-mode"]');
    const rowSeekOffset = panel.querySelector('#ha-row-seek-offset');
    radioSeekModes.forEach(radio => {
      radio.addEventListener('change', (e) => {
        if (e.target.checked) {
          state.seekMode = e.target.value;
          saveSeekOffset();
          if (state.seekMode === 'manual') {
            rowSeekOffset.style.display = 'flex';
          } else {
            rowSeekOffset.style.display = 'none';
          }
        }
      });
    });

    panel.querySelector('#ha-checkbox-blacklist-enabled').addEventListener('change', (e) => {
      state.blacklistEnabled = e.target.checked;
      saveSettings();
      updateBinsAndSpikes();
      updateUI();
    });

    panel.querySelector('#ha-checkbox-blacklist-case').addEventListener('change', (e) => {
      state.blacklistCaseSensitive = e.target.checked;
      saveSettings();
      updateBinsAndSpikes();
      updateUI();
    });

    panel.querySelector('#ha-input-blacklist-query').addEventListener('input', (e) => {
      state.blacklistQuery = e.target.value;
      saveSettings();
      updateBinsAndSpikes();
      updateUI();
    });

    panel.querySelector('#ha-btn-clear-cache').addEventListener('click', async () => {
      if (confirm('Are you sure you want to clear all cached VOD chats? This action cannot be undone.')) {
        await clearVodCache();
        await updateCacheStatsUI();
      }
    });

    panel.querySelector('#ha-checkbox-sentiment-enabled').addEventListener('change', (e) => {
      state.sentimentEnabled = e.target.checked;
      saveSettings();
      const gainContainer = panel.querySelector('#ha-sentiment-gain-container');
      if (gainContainer) {
        gainContainer.style.display = state.sentimentEnabled ? 'flex' : 'none';
      }
      if (state.chart) {
        if (state.chart.data.datasets[2]) {
          state.chart.data.datasets[2].hidden = !state.sentimentEnabled;
        }
        state.chart.options.scales.ySentiment.display = state.sentimentEnabled;
        state.chart.update('none');
      }
    });

    panel.querySelector('#ha-slider-sentiment-gain').addEventListener('input', (e) => {
      const val = parseFloat(e.target.value);
      state.sentimentGain = val;
      const valEl = panel.querySelector('#ha-val-sentiment-gain');
      if (valEl) {
        valEl.textContent = formatAmplificationLabel(val);
      }
      saveSettings();
      if (state.chart) {
        state.chart.options.scales.ySentiment.min = -1.2 / val;
        state.chart.options.scales.ySentiment.max = 1.2 / val;
        state.chart.update('none');
      }
    });

    panel.querySelector('#ha-checkbox-superchats-enabled').addEventListener('change', (e) => {
      state.showSuperchatsOnGraph = e.target.checked;
      saveSettings();
      const subtogglesContainer = panel.querySelector('#ha-superchats-subtoggles-container');
      if (subtogglesContainer) {
        subtogglesContainer.style.display = state.showSuperchatsOnGraph ? 'flex' : 'none';
      }
      if (state.chart) {
        if (state.chart.data.datasets[3]) {
          state.chart.data.datasets[3].hidden = !state.showSuperchatsOnGraph;
        }
        state.chart.update('none');
      }
    });

    const bindScSubtoggle = (colorId, stateProp) => {
      const el = panel.querySelector('#ha-checkbox-sc-' + colorId);
      if (el) {
        el.addEventListener('change', (e) => {
          state[stateProp] = e.target.checked;
          saveSettings();
          updateBinsAndSpikes();
          if (state.chart && state.chart.data.datasets[3]) {
            state.chart.data.datasets[3].data = state.superChatPoints || [];
            state.chart.update('none');
          }
        });
      }
    };
    bindScSubtoggle('teal', 'showSuperchatTeal');
    bindScSubtoggle('yellow', 'showSuperchatYellow');
    bindScSubtoggle('pink', 'showSuperchatPink');
    bindScSubtoggle('red', 'showSuperchatRed');

    panel.querySelector('#ha-checkbox-superchats-only').addEventListener('change', (e) => {
      state.filterSuperchatsOnly = e.target.checked;
      performSearch(state.searchQuery);
    });

    panel.querySelector('#ha-checkbox-auto-emojis-enabled').addEventListener('change', (e) => {
      state.includeStandardEmojisInAutoFilters = e.target.checked;
      saveSettings();
      generateEmoteFilters();
      updateUI();
    });

    panel.querySelector('#ha-input-max-auto-filters').addEventListener('input', (e) => {
      let val = parseInt(e.target.value, 10);
      if (isNaN(val) || val < 1) val = 1;
      state.maxAutoFilters = val;
      saveSettings();
      generateEmoteFilters();
      updateUI();
    });

    panel.querySelector('#ha-input-max-spikes').addEventListener('input', (e) => {
      let val = parseInt(e.target.value, 10);
      if (isNaN(val) || val < 1) val = 1;
      state.maxSpikes = val;
      saveSettings();
      updateBinsAndSpikes();
      updateUI();
    });

    panel.querySelector('#ha-search-input').addEventListener('input', debounce((e) => {
      state.searchQuery = e.target.value;
      performSearch(state.searchQuery);
    }, 200));

    panel.querySelector('#ha-btn-toggle-collapse').addEventListener('click', () => {
      const body = panel.querySelector('#ha-panel-body');
      const btn = panel.querySelector('#ha-btn-toggle-collapse');
      if (state.isCollapsed) {
        body.style.display = 'block';
        btn.textContent = '▲ Hide';
        state.isCollapsed = false;

        // Trigger Chart.js resize & update if chart exists
        if (state.chart) {
          state.chart.resize();
          state.chart.update('none');
        } else {
          renderChart();
        }
      } else {
        // Collapse
        body.style.display = 'none';
        btn.textContent = '▼ Show';
        state.isCollapsed = true;
      }
    });

    // Initial draw
    updateBinsAndSpikes();
    updateUI();
    setupPlaybackIndicatorListener();

    // Check cache and load it automatically if it exists for this VOD
    const videoId = isYouTube ?
      new URLSearchParams(window.location.search).get('v') :
      getTwitchVideoId();

    if (videoId) {
      getCachedVod(videoId).then(cached => {
        if (cached && cached.messages && cached.messages.length > 0) {
          state.messages = cached.messages;
          state.duration = cached.duration || getActiveVideoDuration();
          state.emoteCounts = cached.emoteCounts || {};
          state.totalMessages = cached.totalMessages || cached.messages.length;
          state.peakRate = cached.peakRate || 0;
          state.averageRate = cached.averageRate || 0;
          state.dynamicEmoteSentiment = cached.dynamicEmoteSentiment || {};
          state.sentimentBinnedData = cached.sentimentBinnedData || [];
          state.isCachedLoad = true;

          // Rebuild binned data, spikes list, and UI
          updateBinsAndSpikes();
          updateUI();
          console.log(`Highlight Analyzer: Loaded VOD ${videoId} data from cache.`);
        }
      }).catch(err => {
        console.warn('Highlight Analyzer: Failed to load cached VOD:', err);
      });
    }
  }

  // Handle page resets and cleanup
  function handlePageChange() {
    // Destroy existing chart if it exists
    if (state.chart) {
      state.chart.destroy();
      state.chart = null;
    }

    // Stop ongoing scan
    if (state.abortController) {
      state.abortController.abort();
    }

    stopTooltipRotation();

    // Reset state
    state = {
      isScanning: false,
      isPaused: false,
      progress: 0,
      totalMessages: 0,
      peakRate: 0,
      averageRate: 0,
      messages: [],
      binnedData: [],
      filteredBinnedData: [],
      spikes: [],
      duration: 0,
      apiKey: null,
      context: null,
      initialToken: null,
      chart: null,
      abortController: null,
      filterQuery: "",
      customFilters: [],
      emoteCounts: {},
      detectedEmoteFilters: [],
      mainGain: 1.0,
      filterGain: 1.0,
      seekOffset: 10,
      seekMode: 'manual',
      isCachedLoad: false,
      tooltipMessageOffset: 0,
      hoveredBinIndex: null,
      hoveredCaretX: null,
      hoveredCaretY: null,
      isCollapsed: true,
      blacklistEnabled: false,
      blacklistQuery: "",
      blacklistCaseSensitive: false,
      searchQuery: "",
      sentimentEnabled: true,
      sentimentGain: 1.0,
      dynamicEmoteSentiment: {},
      sentimentBinnedData: [],
      customEmoteCurves: {},
      anchorPositiveCurve: [],
      anchorLaughterCurve: [],
      anchorNegativeCurve: [],
      showSuperchatsOnGraph: true,
      showSuperchatTeal: true,
      showSuperchatYellow: true,
      showSuperchatPink: true,
      showSuperchatRed: true,
      filterSuperchatsOnly: false,
      superChatPoints: [],
      hoveredSuperChatPoint: null,
      maxSpikes: 5,
      removedSpikes: []
    };
    window.HighlightAnalyzerState = state;
    state.update = () => { updateBinsAndSpikes(); updateUI(); };
    loadSeekOffset();
    loadSettings();
    loadRemovedSpikes();

    triggerZeroTokenFetch();

    // Remove old panel
    const oldPanel = document.getElementById('highlight-analyzer-panel');
    if (oldPanel) oldPanel.remove();

    // Check if new page has chat replay and insert panel
    setTimeout(() => {
      extractMetadata();
      if (state.initialToken) {
        insertPanel();
      }
    }, 1500); // Small delay to let SPA load player elements
  }

  // Watch URL changes and maintain panel presence
  let currentUrl = location.href;
  setInterval(() => {
    if (location.href !== currentUrl) {
      currentUrl = location.href;
      handlePageChange();
    } else {
      const isWatchPage = (isYouTube && location.href.includes('watch')) ||
                          (isTwitch && (location.href.includes('/videos/') || location.href.includes('/video/'))) ||
                          (isKick && (location.href.includes('/videos/') || location.href.includes('/video/')));
      if (isWatchPage && !document.getElementById('highlight-analyzer-panel')) {
        extractMetadata();
        if (state.initialToken) {
          insertPanel();
        } else if (isYouTube && zeroTokenPromise) {
          zeroTokenPromise.then(zeroToken => {
            if (zeroToken && !document.getElementById('highlight-analyzer-panel')) {
              state.initialToken = zeroToken;
              insertPanel();
            }
          }).catch(() => {});
        }
      }
    }
    setupPlaybackIndicatorListener();
  }, 1000);

  // Initialize script
  async function init() {
    showChangelogIfNeeded();
    loadSeekOffset();
    loadSettings();
    loadRemovedSpikes();
    triggerZeroTokenFetch();
    extractMetadata();
    if (state.initialToken) {
      insertPanel();
    } else if (isYouTube && zeroTokenPromise) {
      try {
        const zeroToken = await zeroTokenPromise;
        if (zeroToken) {
          state.initialToken = zeroToken;
          insertPanel();
        }
      } catch (e) {
        console.warn('Highlight Analyzer: failed to get zero token for panel insert', e);
      }
    }
  }

  // Execute immediately if DOM is already fully loaded (e.g. dynamic CDP injection)
  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    setTimeout(init, 500);
  } else {
    window.addEventListener('load', () => {
      setTimeout(init, 1500);
    });
  }

  // Backup listener for SPA page navigation
  document.addEventListener('yt-navigate-finish', () => {
    handlePageChange();
  });

})();