Greasy Fork is available in English.

VOD Highlight Analyzer

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         VOD Highlight Analyzer
// @namespace    http://tampermonkey.net/
// @version      2.0
// @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';

  const CURRENT_VERSION = '2.0';

  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
  };
  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);
    }
  }

  // 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;
    } 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());
    } 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.0 update! We've added several highly requested features to make analyzing VODs 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: "🟢 Kick VOD Support",
          desc: "Full support for scanning chat logs and displaying highlight activity charts on Kick streams and VODs."
        },
        {
          title: "📈 Sentiment Trajectory Line",
          desc: "Tracks the positive/negative mood trends of the chat. Toggle it on the graph and use the new **Sentiment Gain** slider to scale the amplitude of the curves."
        },
        {
          title: "💬 YouTube Super Chats Logging",
          desc: "Super Chats are now parsed and plotted as color-coded dots (Teal, Yellow, Pink, Red) on the chart. Hover for donor details, click to seek, toggle colors, or search them specifically in message logs."
        }
      ];

      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
    const filteredSpikes = [];
    for (const peak of localPeaks) {
      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 >= 5) 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));
        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();

    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>
        </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-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
    };
    window.HighlightAnalyzerState = state;
    state.update = () => { updateBinsAndSpikes(); updateUI(); };
    loadSeekOffset();
    loadSettings();

    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();
    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();
  });

})();