Twitch Channel Preview

Replaces channel icons in the sidebar with live preview images.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
}