Twitch Channel Preview

Replaces channel icons in the sidebar with live preview images.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Twitch Channel Preview
// @namespace    dev.263
// @version      1.0.0
// @description  Replaces channel icons in the sidebar with live preview images.
// @author       Bastian Bräu
// @match        https://www.twitch.tv/*
// @grant        none
// @license      ISC
// @homepageURL  https://github.com/b263/user-scripts
// @supportURL   https://github.com/b263/user-scripts/issues
// ==/UserScript==

function applyStyles(element, styles) {
  Object.entries(styles).forEach(([property, value]) => {
    element.style[property] = value;
  });
}

class Debug {
  static enabled = false;

  static _output(method, message, data = null) {
    if (!Debug.enabled) return;
    const timestamp = new Date().toLocaleTimeString();
    const args = [`[TwitchPreview ${timestamp}] ${message}`];
    if (data !== null) args.push(data);
    console[method](...args);
  }

  static log(message, data = null) {
    this._output('log', message, data);
  }

  static warn(message, data = null) {
    this._output('warn', message, data);
  }

  static error(message, data = null) {
    this._output('error', message, data);
  }
}

class SidebarCard {
  constructor(card) {
    this.card = card;
  }

  getChannelNameFromUrl(url) {
    return url.split('/').pop();
  }

  getDisplayName() {
    const nameElement = this.card.querySelector('p');
    return nameElement ? nameElement.textContent.trim() : null;
  }

  getChannelLink() {
    return this.card.querySelector('a[href^="/"]');
  }

  getHref() {
    const link = this.getChannelLink();
    return link?.getAttribute('href');
  }

  getChannelName() {
    const href = this.getHref();
    return href ? this.getChannelNameFromUrl(href) : null;
  }

  isLive() {
    const cardText = this.card.textContent.toLowerCase();
    const isLive = !cardText.includes('offline');
    Debug.log(
      `Channel live check - Display: ${this.getDisplayName()}, Text: "${cardText.substring(0, 100)}...", IsLive: ${isLive}`
    );
    return isLive;
  }

  isValid() {
    const href = this.getHref();
    const channelName = this.getChannelName();
    const displayName = this.getDisplayName();

    const checks = {
      hasChannelLink: !!this.getChannelLink(),
      hasValidHref: !(
        !href ||
        href === '/' ||
        href.includes('/directory') ||
        href.includes('/settings')
      ),
      hasChannelName: !(!channelName || channelName.length === 0),
      hasDisplayName: !!displayName,
    };

    const isValid = Object.values(checks).every(check => check);

    if (!isValid) {
      Debug.warn(
        `Invalid channel data - Display: "${displayName || 'NULL'}", Href: ${href}, Checks:`,
        checks
      );
    }

    return isValid;
  }
}

class CardState {
  static isProcessed(card) {
    return card.dataset.previewProcessed === 'true';
  }

  static markAsProcessed(card) {
    card.dataset.previewProcessed = 'true';
  }

  static unmarkAsProcessed(card) {
    card.removeAttribute('data-preview-processed');
  }

  static storeOriginalContent(card, href) {
    card.dataset.originalContent = card.innerHTML;
    card.dataset.channelLink = href;
  }

  static hasPreviewImage(card) {
    return !!card.querySelector('img[data-channel-name]');
  }
}

class ImageLoader {
  static waitForImageLoad(img) {
    return new Promise(resolve => {
      if (img.complete) {
        resolve();
      } else {
        const onLoad = () => {
          img.removeEventListener('load', onLoad);
          img.removeEventListener('error', onLoad);
          resolve();
        };
        img.addEventListener('load', onLoad);
        img.addEventListener('error', onLoad);
      }
    });
  }

  static handleImageLoad() {
    if (this.naturalWidth && this.naturalHeight) {
      if (this.naturalWidth < 100 || this.naturalHeight < 100) {
        this.style.opacity = '0.3';
        this.style.filter = 'grayscale(100%)';
      }
    }
  }

  static handleImageError() {
    this.style.opacity = '0.3';
    this.style.filter = 'grayscale(100%)';
  }
}

class UIBuilder {
  static createPreviewImage(channelName) {
    const img = document.createElement('img');
    img.dataset.channelName = channelName;
    img.src = `https://static-cdn.jtvnw.net/previews-ttv/live_user_${channelName}-320x180.jpg?t=${Date.now()}`;

    applyStyles(img, {
      width: '100%',
      height: '100%',
      objectFit: 'cover',
      borderRadius: '4px',
      transition: 'opacity 0.3s ease',
    });

    img.onload = ImageLoader.handleImageLoad.bind(img);
    img.onerror = ImageLoader.handleImageError.bind(img);

    return img;
  }

  static createChannelNameDisplay(displayName) {
    const nameDiv = document.createElement('div');
    nameDiv.textContent = displayName;

    applyStyles(nameDiv, {
      fontWeight: 'bold',
      padding: '4px 8px',
      textAlign: 'center',
    });

    return nameDiv;
  }

  static createLinkWrapper(href) {
    const linkWrapper = document.createElement('a');
    linkWrapper.href = href;

    applyStyles(linkWrapper, {
      display: 'block',
      width: '100%',
      height: '100%',
      textDecoration: 'none',
    });

    return linkWrapper;
  }

  static createContainer() {
    const container = document.createElement('div');

    applyStyles(container, {
      display: 'flex',
      flexDirection: 'column',
      height: '100%',
    });

    return container;
  }

  static createImageContainer(previewImg) {
    const imageContainer = document.createElement('div');

    applyStyles(imageContainer, {
      flex: '1',
      overflow: 'hidden',
    });

    imageContainer.appendChild(previewImg);
    return imageContainer;
  }

  static buildPreviewCard(channelName, displayName, href) {
    const previewImg = this.createPreviewImage(channelName);
    const channelNameDiv = this.createChannelNameDisplay(displayName);
    const linkWrapper = this.createLinkWrapper(href);
    const container = this.createContainer();
    const imageContainer = this.createImageContainer(previewImg);

    container.appendChild(channelNameDiv);
    container.appendChild(imageContainer);
    linkWrapper.appendChild(container);

    return { linkWrapper, previewImg };
  }
}

class SidebarManager {
  static getSidebar() {
    return document.querySelector('.side-bar-contents');
  }

  static getSideNavCards() {
    const sidebar = this.getSidebar();
    const cards = sidebar
      ? Array.from(sidebar.querySelectorAll('.side-nav-card'))
      : [];

    Debug.log(`Found ${cards.length} sidebar cards`);
    return cards;
  }

  static getUnprocessedCards() {
    return document.querySelectorAll(
      '.side-nav-card:not([data-preview-processed])'
    );
  }

  static getProcessedCards() {
    return document.querySelectorAll('.side-nav-card[data-preview-processed]');
  }

  static getAllPreviewImages() {
    return document.querySelectorAll('img[data-channel-name]');
  }
}

class ChannelProcessor {
  static shouldSkipCard(channelData) {
    const checks = {
      isProcessed: CardState.isProcessed(channelData.card),
      isValid: channelData.isValid(),
      isLive: channelData.isLive(),
    };

    const shouldSkip = checks.isProcessed || !checks.isValid || !checks.isLive;

    if (shouldSkip) {
      const reason = checks.isProcessed
        ? 'already processed'
        : !checks.isValid
          ? 'invalid data'
          : !checks.isLive
            ? 'offline'
            : 'unknown';
      Debug.log(
        `Skipping card - Display: ${channelData.getDisplayName()}, Reason: ${reason}`
      );
    }

    return shouldSkip;
  }

  static async processCard(card) {
    const channelData = new SidebarCard(card);
    Debug.log(`Processing card - Display: ${channelData.getDisplayName()}`);

    if (this.shouldSkipCard(channelData)) {
      if (!channelData.isLive()) {
        CardState.markAsProcessed(card);
        Debug.log(
          `Marked offline card as processed - Display: ${channelData.getDisplayName()}`
        );
      }
      return;
    }

    Debug.log(
      `Building preview for channel - Display: ${channelData.getDisplayName()}, URL: ${channelData.getChannelName()}`
    );

    CardState.storeOriginalContent(card, channelData.getHref());

    const { linkWrapper, previewImg } = UIBuilder.buildPreviewCard(
      channelData.getChannelName(),
      channelData.getDisplayName(),
      channelData.getHref()
    );

    const originalChildren = Array.from(card.children);
    originalChildren.forEach(child => {
      child.style.display = 'none';
      child.style.visibility = 'hidden';
      child.style.position = 'absolute';
      child.style.left = '-9999px';
    });

    card.appendChild(linkWrapper);

    CardState.markAsProcessed(card);
    await ImageLoader.waitForImageLoad(previewImg);

    Debug.log(
      `Successfully processed card - Display: ${channelData.getDisplayName()}`
    );
  }

  static async processAllCards() {
    const cards = SidebarManager.getSideNavCards();
    Debug.log(`Starting to process ${cards.length} cards`);

    for (const card of cards) {
      await this.processCard(card);
    }

    Debug.log(`Finished processing all cards`);
  }

  static async updateExistingPreviews() {
    const previewImages = SidebarManager.getAllPreviewImages();

    for (const img of previewImages) {
      const channelName = img.dataset.channelName;
      if (channelName) {
        img.src = `https://static-cdn.jtvnw.net/previews-ttv/live_user_${channelName}-320x180.jpg?t=${Date.now()}`;
        await ImageLoader.waitForImageLoad(img);
      }
    }
  }

  static checkForOfflineToOnlineTransition() {
    const processedCards = SidebarManager.getProcessedCards();

    processedCards.forEach(card => {
      const channelData = new SidebarCard(card);
      if (channelData.isLive() && !CardState.hasPreviewImage(card)) {
        CardState.unmarkAsProcessed(card);
      }
    });
  }
}

class ChangeObserver {
  constructor() {
    this.debounceTimeout = 500;
    this.pollInterval = 5000;
    this.updateInterval = 30000;
    this.loadDelay = null;
    this.observer = null;
  }

  shouldProcessMutations(mutations) {
    return mutations.some(mutation => {
      if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
        return Array.from(mutation.addedNodes).some(node => {
          return (
            node.nodeType === 1 &&
            node.querySelector &&
            (node.querySelector('.side-nav-card') ||
              node.classList.contains('side-nav-card') ||
              node.closest('.side-bar-contents'))
          );
        });
      }

      return (
        mutation.type === 'characterData' ||
        (mutation.type === 'childList' &&
          mutation.target.matches &&
          mutation.target.matches('.side-nav-card, .side-nav-card *'))
      );
    });
  }

  handleMutations(mutations) {
    if (!this.shouldProcessMutations(mutations)) return;

    Debug.log('DOM mutations detected, scheduling card processing');

    clearTimeout(this.loadDelay);
    this.loadDelay = setTimeout(() => {
      const unprocessedCards = SidebarManager.getUnprocessedCards();
      Debug.log(
        `Found ${unprocessedCards.length} unprocessed cards after mutation`
      );

      ChannelProcessor.checkForOfflineToOnlineTransition();

      if (unprocessedCards.length > 0) {
        ChannelProcessor.processAllCards();
      }
    }, this.debounceTimeout);
  }

  checkForUnprocessedCards() {
    const cards = SidebarManager.getUnprocessedCards();
    if (cards.length > 0) {
      Debug.log(`Periodic check found ${cards.length} unprocessed cards`);
      ChannelProcessor.processAllCards();
    }
  }

  setupMutationObserver() {
    this.observer = new MutationObserver(this.handleMutations.bind(this));
    this.observer.observe(document.body, {
      childList: true,
      subtree: true,
      characterData: true,
    });
  }

  setupIntervals() {
    setInterval(
      () => ChannelProcessor.updateExistingPreviews(),
      this.updateInterval
    );
    setInterval(() => this.checkForUnprocessedCards(), this.pollInterval);
  }

  init() {
    ChannelProcessor.processAllCards();
    this.setupIntervals();
    this.setupMutationObserver();
  }
}

const changeObserver = new ChangeObserver();

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', () => changeObserver.init());
} else {
  changeObserver.init();
}