Greasy Fork is available in English.
Analyzes chat activity of YouTube, Twitch and Kick VODs and displays a clickable density chart to jump to highlights.
// ==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"> </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"> </div> <div class="ha-tooltip-comment"> </div> <div class="ha-tooltip-comment"> </div> <div class="ha-tooltip-comment"> </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(); }); })();