fastlnker

Detect and play video streams (HLS, DASH, MP4, WebM) with improved performance and features

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         fastlnker
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Detect and play video streams (HLS, DASH, MP4, WebM) with improved performance and features
// @author       tae
// @match        *://*/*
// @grant        GM_setClipboard
// @grant        unsafeWindow
// ==/UserScript==

(function () {
  'use strict';

  /* =========================
     CONSTANTS & CONFIG
  ========================= */
  const CONFIG = {
    UI_DEBOUNCE: 500,
    UPDATE_THROTTLE: 250,
    URL_DEDUP_WINDOW: 5000,
    MIN_URL_LENGTH: 10,
    VARIANT_CACHE_SIZE: 50,
  };

  /* =========================
     UTILITIES
  ========================= */
  function debounce(func, delay) {
    let timeout;
    return function (...args) {
      const context = this;
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(context, args), delay);
    };
  }

  function throttle(func, limit) {
    let inThrottle;
    let lastResult;
    return function (...args) {
      const context = this;
      if (!inThrottle) {
        inThrottle = true;
        setTimeout(() => (inThrottle = false), limit);
        lastResult = func.apply(context, args);
      }
      return lastResult;
    };
  }

  /* =========================
     M3U8 PARSER
  ========================= */
  class M3U8Parser {
    static async fetch(url) {
      try {
        const response = await fetch(url);
        return await response.text();
      } catch (e) {
        console.error('FastStream: Failed to fetch m3u8:', e);
        return null;
      }
    }

    static parse(content) {
      const variants = [];
      const lines = content.split('\n');

      for (let i = 0; i < lines.length; i++) {
        const line = lines[i].trim();

        if (line.startsWith('#EXT-X-STREAM-INF')) {
          const resolution = this.extractResolution(line);
          const bandwidth = this.extractBandwidth(line);
          const nextLine = lines[i + 1]?.trim();

          if (nextLine && !nextLine.startsWith('#')) {
            variants.push({
              resolution,
              bandwidth,
              url: nextLine,
              label: this.createLabel(resolution, bandwidth),
            });
          }
        }
      }
      return variants;
    }

    static extractResolution(line) {
      const match = line.match(/RESOLUTION=(\d+)x(\d+)/);
      return match ? { width: parseInt(match[1]), height: parseInt(match[2]) } : null;
    }

    static extractBandwidth(line) {
      const match = line.match(/BANDWIDTH=(\d+)/);
      return match ? parseInt(match[1]) : null;
    }

    static createLabel(resolution, bandwidth) {
      if (resolution) {
        const height = resolution.height;
        const label = `${height}p`;
        return bandwidth ? `${label} (${(bandwidth / 1000).toFixed(0)} kbps)` : label;
      }
      return 'Unknown';
    }
  }

  /* =========================
     URL ANALYZER
  ========================= */
  class URLAnalyzer {
    static getType(url) {
      const cleanUrl = url.split('?')[0].toLowerCase();

      if (cleanUrl.includes('m3u8')) return 'HLS';
      if (cleanUrl.includes('mpd')) return 'DASH';
      if (cleanUrl.endsWith('.mp4')) return 'MP4';
      if (cleanUrl.endsWith('.webm')) return 'WebM';
      if (cleanUrl.endsWith('.ts')) return 'TS';
      if (cleanUrl.endsWith('.m3u')) return 'M3U';
      if (cleanUrl.includes('playlist')) return 'Playlist';

      return 'Stream';
    }

    static getQuality(url) {
      // Extract quality from URL patterns
      const patterns = [
        /(\d{3,4})p/i,
        /quality[=_]([^&]+)/i,
        /res[=_]([^&]+)/i,
        /bitrate[=_](\d+)/i,
      ];

      for (const pattern of patterns) {
        const match = url.match(pattern);
        if (match) return match[1];
      }
      return null;
    }

    static isDirectPlayable(url) {
      const type = this.getType(url);
      // MP4, WebM, HLS, and DASH can be played in most video players
      return ['MP4', 'WebM', 'HLS', 'DASH', 'M3U'].includes(type);
    }

    static isLikelyMasterPlaylist(url) {
      // Master playlists typically have these characteristics
      const urlLower = url.toLowerCase();
      return (
        (urlLower.includes('m3u8') || urlLower.includes('master')) &&
        (urlLower.includes('master') ||
          urlLower.includes('variant') ||
          urlLower.includes('playlist'))
      );
    }

    static isLikelySegment(url) {
      // Segments are usually .ts files or have numeric identifiers
      const urlLower = url.toLowerCase();
      return (
        urlLower.endsWith('.ts') ||
        /segment\d+/i.test(urlLower) ||
        /-\d+\.m3u8/i.test(urlLower)
      );
    }

    static getRank(type, url) {
      // Rank URLs by preference for external players
      const urlLower = url.toLowerCase();

      // Segments get lowest priority
      if (this.isLikelySegment(url)) return 10;

      switch (type) {
        case 'MP4':
        case 'WebM':
          return 1; // Direct playable files - highest priority
        case 'HLS':
        case 'DASH':
          // Master playlists ranked higher than variants
          return this.isLikelyMasterPlaylist(url) ? 2 : 3;
        case 'M3U':
          return 2;
        case 'Playlist':
          return 4;
        case 'TS':
          return 8; // Very low priority
        default:
          return 6;
      }
    }
  }

  /* =========================
     DETECTOR
  ========================= */
  class Detector {
    constructor() {
      this.videos = new Map(); // Map of video elements to their data
      this.detectedUrls = new Map(); // Map of URL -> detection data
      this.playingSources = new Set();
      this.variantCache = new Map();
      this.networkUrls = new Map(); // Track network-detected URLs separately
      this.uiUpdateDebounced = debounce(this.updateUI.bind(this), CONFIG.UI_DEBOUNCE);
      this.init();
    }

    init() {
      this.interceptNetwork();
      this.setupMutationObserver();
      this.initialScan();
    }

    interceptNetwork() {
      const origFetch = unsafeWindow.fetch;
      unsafeWindow.fetch = async (...args) => {
        try {
          const url = args[0];
          if (url && typeof url === 'string') {
            this.checkNetworkUrl(url);
          }
        } catch (e) {
          console.warn('FastStream: Error in fetch interception:', e);
        }
        return origFetch.apply(this, args);
      };

      const origOpen = unsafeWindow.XMLHttpRequest.prototype.open;
      unsafeWindow.XMLHttpRequest.prototype.open = function (method, url) {
        try {
          if (url && typeof url === 'string') {
            this.checkNetworkUrl(url);
          }
        } catch (e) {
          console.warn('FastStream: Error in XMLHttpRequest interception:', e);
        }
        return origOpen.apply(this, arguments);
      }.bind(this);
    }

    setupMutationObserver() {
      const videoObserver = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          if (mutation.type === 'childList') {
            mutation.addedNodes.forEach((node) => {
              if (node.nodeName === 'VIDEO') {
                this.attach(node);
              } else if (node.querySelectorAll) {
                node.querySelectorAll('video').forEach((video) => this.attach(video));
              }
            });
            mutation.removedNodes.forEach((node) => {
              if (node.nodeName === 'VIDEO') {
                this.detach(node);
              } else if (node.querySelectorAll) {
                node.querySelectorAll('video').forEach((video) => this.detach(video));
              }
            });
          }
        });
        this.uiUpdateDebounced();
      });

      videoObserver.observe(document.body, { childList: true, subtree: true });
    }

    initialScan() {
      document.querySelectorAll('video').forEach((v) => this.attach(v));
      this.uiUpdateDebounced();
    }

    checkNetworkUrl(url) {
      // Filter for media URLs
      if (!/m3u8|mpd|\.mp4|\.webm|\.ts|manifest|playlist|\.m3u/i.test(url)) return;

      // Skip blob URLs and data URLs
      if (url.startsWith('blob:') || url.startsWith('data:')) return;

      // Avoid duplicates
      if (
        this.networkUrls.has(url) &&
        Date.now() - this.networkUrls.get(url).timestamp < CONFIG.URL_DEDUP_WINDOW
      ) {
        return;
      }

      const type = URLAnalyzer.getType(url);
      const isPlayable = URLAnalyzer.isDirectPlayable(url);
      const rank = URLAnalyzer.getRank(type, url);

      this.networkUrls.set(url, {
        url,
        type,
        isPlaying: false,
        timestamp: Date.now(),
        fromVideo: false,
        fromNetwork: true,
        isPlayable,
        rank,
      });

      this.detectedUrls.set(url, this.networkUrls.get(url));
      this.uiUpdateDebounced();
    }

    attach(video) {
      if (this.videos.has(video)) return;

      const update = () => {
        this.extractSources(video);
        this.updatePlayingStatus(video);
        this.uiUpdateDebounced();
      };

      const throttledUpdate = throttle(update, CONFIG.UPDATE_THROTTLE);

      const eventListeners = {
        play: update,
        loadedmetadata: update,
        loadstart: update,
        playing: update,
        pause: update,
        ended: update,
        timeupdate: throttledUpdate,
        srcchange: update,
      };

      for (const event in eventListeners) {
        video.addEventListener(event, eventListeners[event], true);
      }

      this.videos.set(video, { el: video, listeners: eventListeners });

      // Override src setter
      const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
      if (descriptor) {
        const originalSet = descriptor.set;
        const originalGet = descriptor.get;

        Object.defineProperty(video, 'src', {
          set(value) {
            if (originalSet) originalSet.call(this, value);
            this.dispatchEvent(new Event('srcchange'));
          },
          get() {
            return originalGet ? originalGet.call(this) : '';
          },
          configurable: true,
        });
      }

      update();
    }

    detach(video) {
      const videoData = this.videos.get(video);
      if (videoData) {
        for (const event in videoData.listeners) {
          video.removeEventListener(event, videoData.listeners[event], true);
        }
        this.videos.delete(video);

        // Remove video-specific sources
        this.detectedUrls.forEach((data, url) => {
          if (data.fromVideo && data.videoElement === video) {
            this.detectedUrls.delete(url);
          }
        });

        this.uiUpdateDebounced();
      }
    }

    extractSources(video) {
      if (video.currentSrc) {
        this.addVideoSource(video.currentSrc, video);
      } else if (video.src) {
        this.addVideoSource(video.src, video);
      }

      video.querySelectorAll('source').forEach((s) => {
        if (s.src) {
          this.addVideoSource(s.src, video);
        }
      });
    }

    addVideoSource(url, videoElement) {
      if (url.startsWith('blob:') || url.startsWith('data:')) return;

      const type = URLAnalyzer.getType(url);
      const isPlayable = URLAnalyzer.isDirectPlayable(url);
      const rank = URLAnalyzer.getRank(type, url);

      const sourceData = {
        url,
        type,
        isPlaying: false,
        timestamp: Date.now(),
        fromVideo: true,
        videoElement,
        isPlayable,
        rank,
      };

      this.detectedUrls.set(url, sourceData);
    }

    updatePlayingStatus(video) {
      const currentlyPlaying = new Set();

      if (!video.paused && video.currentTime > 0 && !video.ended) {
        if (video.currentSrc) currentlyPlaying.add(video.currentSrc);
        else if (video.src) currentlyPlaying.add(video.src);

        video.querySelectorAll('source').forEach((s) => {
          if (s.src) currentlyPlaying.add(s.src);
        });
      }

      this.detectedUrls.forEach((data, url) => {
        data.isPlaying = currentlyPlaying.has(url);
      });
    }

    getSortedUniqueSources() {
      const uniqueSources = Array.from(this.detectedUrls.values());

      // Filter low-value URLs
      const filteredSources = uniqueSources.filter(
        (s) => s.url.length > CONFIG.MIN_URL_LENGTH && !s.url.startsWith('blob:')
      );

      // Sort by rank, then playing status, then timestamp
      filteredSources.sort((a, b) => {
        if (a.rank !== b.rank) return a.rank - b.rank;
        if (a.isPlaying !== b.isPlaying) return b.isPlaying - a.isPlaying;
        return b.timestamp - a.timestamp;
      });

      // Deduplicate URLs
      const seenUrls = new Set();
      const finalSources = [];

      for (const source of filteredSources) {
        let simplifiedUrl = source.url;

        if (source.type === 'HLS' || source.type === 'DASH' || source.type === 'M3U') {
          simplifiedUrl = source.url.split('?')[0];
        }

        if (!seenUrls.has(simplifiedUrl)) {
          seenUrls.add(simplifiedUrl);
          finalSources.push(source);
        }
      }

      return finalSources;
    }

    async setQualityVariants(url) {
      if (url.includes('m3u8') && !this.variantCache.has(url)) {
        const content = await M3U8Parser.fetch(url);
        if (content) {
          const variants = M3U8Parser.parse(content);
          this.variantCache.set(url, variants);

          // Limit cache size
          if (this.variantCache.size > CONFIG.VARIANT_CACHE_SIZE) {
            const firstKey = this.variantCache.keys().next().value;
            this.variantCache.delete(firstKey);
          }

          return variants;
        }
      }
      return this.variantCache.get(url) || [];
    }

    getQualityVariants(url) {
      return this.variantCache.get(url) || [];
    }

    updateUI() {
      window.fastUI?.update();
    }
  }

  /* =========================
     UI + PLAYER
  ========================= */
  class UI {
    constructor(detector) {
      this.d = detector;
      this.hls = null;
      this.dash = null;
      this.playerExpanded = false;
      this.currentZoom = 1;
      this.shadowRoot = null;
      this.build();
    }

    build() {
      // Create a shadow DOM host to isolate UI from page styles
      this.host = document.createElement('div');
      this.host.id = 'faststream-host-' + Math.random().toString(36).substr(2, 9);
      this.host.style.cssText = `
        position: fixed;
        bottom: 20px;
        right: 20px;
        z-index: 2147483647;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        all: initial;
      `;

      // Attach to document root, not body (safer from ad injections)
      document.documentElement.appendChild(this.host);

      // Create shadow DOM for style isolation
      this.shadowRoot = this.host.attachShadow({ mode: 'open' });

      // Add global styles to shadow DOM
      const style = document.createElement('style');
      style.textContent = `
        * {
          box-sizing: border-box;
          margin: 0;
          padding: 0;
        }

        button {
          font-family: inherit;
        }

        #faststream-btn {
          width: 55px;
          height: 55px;
          border-radius: 50%;
          border: none;
          background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
          color: #fff;
          font-size: 24px;
          cursor: pointer;
          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
          transition: transform 0.2s, box-shadow 0.2s;
          z-index: 2147483647;
          position: relative;
        }

        #faststream-btn:hover {
          transform: scale(1.1);
          box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
        }

        #faststream-btn:active {
          transform: scale(0.95);
        }

        #faststream-panel {
          display: none;
          position: fixed;
          bottom: 85px;
          right: 20px;
          width: 550px;
          max-height: 650px;
          background: #fff;
          border-radius: 12px;
          overflow: hidden;
          box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
          flex-direction: column;
          z-index: 2147483646;
        }

        #faststream-panel.open {
          display: flex;
        }

        #faststream-player-container {
          display: none;
          background: #000;
          border-bottom: 2px solid #eee;
          position: relative;
        }

        #faststream-player-container.active {
          display: block;
        }

        #faststream-list-container {
          max-height: 400px;
          overflow-y: auto;
          flex: 1;
          background: #fafafa;
        }

        #faststream-list-container::-webkit-scrollbar {
          width: 8px;
        }

        #faststream-list-container::-webkit-scrollbar-track {
          background: #f1f1f1;
        }

        #faststream-list-container::-webkit-scrollbar-thumb {
          background: #888;
          border-radius: 4px;
        }

        #faststream-list-container::-webkit-scrollbar-thumb:hover {
          background: #555;
        }

        .faststream-source-row {
          padding: 14px;
          border-bottom: 1px solid #eee;
          display: flex;
          justify-content: space-between;
          align-items: center;
          background: #fff;
          transition: background 0.2s;
          cursor: pointer;
        }

        .faststream-source-row:hover {
          background: #f5f5f5;
        }

        .faststream-source-row.playing {
          background: #e8f4fd;
        }

        .faststream-source-row.playing:hover {
          background: #d4e9f7;
        }

        .faststream-source-info {
          flex: 1;
          min-width: 0;
        }

        .faststream-source-header {
          font-weight: 600;
          font-size: 13px;
          color: #333;
          display: flex;
          align-items: center;
          gap: 8px;
        }

        .faststream-source-number {
          color: #667eea;
          font-weight: bold;
        }

        .faststream-source-type {
          background: #667eea;
          color: #fff;
          padding: 2px 6px;
          border-radius: 3px;
          font-size: 11px;
          font-weight: bold;
        }

        .faststream-playable-badge {
          background: #28a745;
          color: #fff;
          padding: 2px 6px;
          border-radius: 3px;
          font-size: 10px;
          font-weight: bold;
        }

        .faststream-not-playable-badge {
          background: #dc3545;
          color: #fff;
          padding: 2px 6px;
          border-radius: 3px;
          font-size: 10px;
          font-weight: bold;
        }

        .faststream-source-url {
          font-size: 11px;
          color: #666;
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          margin-top: 6px;
          word-break: break-all;
        }

        .faststream-source-status {
          font-size: 10px;
          color: #4CAF50;
          font-weight: bold;
          margin-top: 4px;
        }

        .faststream-source-buttons {
          display: flex;
          gap: 6px;
          margin-left: 10px;
        }

        .faststream-btn-copy, .faststream-btn-play {
          padding: 6px 12px;
          border-radius: 4px;
          border: none;
          cursor: pointer;
          font-size: 12px;
          font-weight: 500;
          transition: all 0.2s;
          white-space: nowrap;
        }

        .faststream-btn-copy {
          background: #f0f0f0;
          color: #333;
          border: 1px solid #ddd;
        }

        .faststream-btn-copy:hover {
          background: #e0e0e0;
        }

        .faststream-btn-play {
          background: #667eea;
          color: #fff;
        }

        .faststream-btn-play:hover {
          background: #5568d3;
        }

        #faststream-empty {
          padding: 30px 20px;
          text-align: center;
          color: #999;
          font-size: 14px;
        }

        .faststream-video-wrapper {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          transform-origin: center;
          transition: transform 0.2s;
          display: flex;
          align-items: center;
          justify-content: center;
        }

        #faststream-video {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          max-width: 100%;
          max-height: 100%;
        }

        .faststream-player-controls {
          position: absolute;
          bottom: 50px;
          right: 10px;
          display: flex;
          gap: 8px;
          z-index: 10;
          flex-wrap: wrap;
          align-items: center;
          justify-content: flex-end;
        }

        .faststream-quality-select {
          padding: 8px 12px;
          border-radius: 4px;
          border: 1px solid #999;
          background: #333;
          color: #fff;
          font-size: 12px;
          cursor: pointer;
          font-weight: 500;
        }

        .faststream-zoom-container {
          display: flex;
          gap: 4px;
          background: #333;
          border-radius: 4px;
          padding: 4px;
        }

        .faststream-zoom-btn {
          padding: 6px 10px;
          border: 1px solid #999;
          background: #333;
          color: #fff;
          cursor: pointer;
          font-size: 12px;
          border-radius: 3px;
          transition: all 0.2s;
        }

        .faststream-zoom-btn:hover {
          background: #444;
        }

        .faststream-zoom-btn.reset {
          min-width: 38px;
          font-weight: bold;
          font-size: 11px;
        }

        .faststream-control-btn {
          padding: 8px 12px;
          border-radius: 4px;
          border: 1px solid #999;
          background: #333;
          color: #fff;
          cursor: pointer;
          font-size: 14px;
          transition: all 0.2s;
        }

        .faststream-control-btn:hover {
          background: #444;
        }

        #faststream-player-wrapper {
          position: relative;
          width: 100%;
          padding-bottom: 56.25%;
          overflow: hidden;
        }
      `;
      this.shadowRoot.appendChild(style);

      // Create main container
      const container = document.createElement('div');
      container.style.cssText = `
        position: fixed;
        bottom: 20px;
        right: 20px;
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        gap: 10px;
        z-index: 2147483647;
      `;

      // Create button
      this.btn = document.createElement('button');
      this.btn.id = 'faststream-btn';
      this.btn.textContent = '▶';
      this.btn.title = 'FastStream Video Detector';

      this.btn.addEventListener('click', () => {
        this.panel.classList.toggle('open');
        if (this.panel.classList.contains('open')) {
          this.update();
        }
      });

      // Create panel
      this.panel = document.createElement('div');
      this.panel.id = 'faststream-panel';

      this.playerContainer = document.createElement('div');
      this.playerContainer.id = 'faststream-player-container';

      this.listContainer = document.createElement('div');
      this.listContainer.id = 'faststream-list-container';

      this.panel.appendChild(this.playerContainer);
      this.panel.appendChild(this.listContainer);

      container.appendChild(this.btn);
      container.appendChild(this.panel);

      this.shadowRoot.appendChild(container);

      this.createPlayer();
    }

    createPlayer() {
      const wrap = document.createElement('div');
      wrap.id = 'faststream-player-wrapper';

      this.videoWrapper = document.createElement('div');
      this.videoWrapper.className = 'faststream-video-wrapper';

      this.video = document.createElement('video');
      this.video.id = 'faststream-video';
      this.video.controls = true;

      this.videoWrapper.appendChild(this.video);

      const controls = document.createElement('div');
      controls.className = 'faststream-player-controls';

      // Quality selector
      this.q = document.createElement('select');
      this.q.className = 'faststream-quality-select';

      // Zoom controls
      const zoomContainer = document.createElement('div');
      zoomContainer.className = 'faststream-zoom-container';

      const zoomOut = document.createElement('button');
      zoomOut.textContent = '−';
      zoomOut.title = 'Zoom Out';
      zoomOut.className = 'faststream-zoom-btn';
      zoomOut.addEventListener('click', () => this.setZoom(this.currentZoom - 0.2));

      const zoomReset = document.createElement('button');
      zoomReset.textContent = '100%';
      zoomReset.title = 'Reset Zoom';
      zoomReset.className = 'faststream-zoom-btn reset';
      zoomReset.addEventListener('click', () => this.setZoom(1));

      const zoomIn = document.createElement('button');
      zoomIn.textContent = '+';
      zoomIn.title = 'Zoom In';
      zoomIn.className = 'faststream-zoom-btn';
      zoomIn.addEventListener('click', () => this.setZoom(this.currentZoom + 0.2));

      zoomContainer.appendChild(zoomOut);
      zoomContainer.appendChild(zoomReset);
      zoomContainer.appendChild(zoomIn);

      // Fullscreen button
      const fs = document.createElement('button');
      fs.textContent = '⛶';
      fs.title = 'Toggle Fullscreen';
      fs.className = 'faststream-control-btn';
      fs.addEventListener('click', () => {
        if (!document.fullscreenElement) {
          wrap.requestFullscreen().catch((err) => {
            console.warn('Fullscreen not available:', err);
          });
        } else {
          document.exitFullscreen();
        }
      });

      // Close button
      const close = document.createElement('button');
      close.textContent = '✕';
      close.title = 'Close Player';
      close.className = 'faststream-control-btn';
      close.addEventListener('click', () => {
        this.playerContainer.classList.remove('active');
        this.playerExpanded = false;
        this.video.pause();
        this.video.src = '';
        if (this.hls) {
          this.hls.destroy();
          this.hls = null;
        }
        if (this.dash) {
          this.dash.reset();
          this.dash = null;
        }
        this.currentZoom = 1;
        this.updateZoom();
      });

      this.q.addEventListener('change', () => {
        if (this.hls && this.q.value !== 'auto') {
          this.hls.currentLevel = +this.q.value;
        } else if (this.hls) {
          this.hls.currentLevel = -1;
        }
      });

      controls.appendChild(this.q);
      controls.appendChild(zoomContainer);
      controls.appendChild(fs);
      controls.appendChild(close);

      wrap.appendChild(this.videoWrapper);
      wrap.appendChild(controls);
      this.playerContainer.appendChild(wrap);
    }

    setZoom(value) {
      this.currentZoom = Math.max(0.5, Math.min(3, value));
      this.updateZoom();
    }

    updateZoom() {
      this.videoWrapper.style.transform = `scale(${this.currentZoom})`;
    }

    async play(url) {
      this.playerExpanded = true;
      this.playerContainer.classList.add('active');
      this.q.innerHTML = '';

      if (this.hls) {
        this.hls.destroy();
        this.hls = null;
      }
      if (this.dash) {
        this.dash.reset();
        this.dash = null;
      }

      this.currentZoom = 1;
      this.updateZoom();

      this.video.src = '';
      this.video.load();

      if (url.includes('m3u8') && typeof Hls !== 'undefined') {
        const variants = await this.d.setQualityVariants(url);

        this.hls = new Hls();
        this.hls.loadSource(url);
        this.hls.attachMedia(this.video);

        this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
          this.q.innerHTML = '<option value="auto">Auto</option>';

          if (variants.length > 0) {
            variants.forEach((variant, index) => {
              const o = document.createElement('option');
              o.value = index;
              o.text = variant.label || 'Unknown';
              this.q.appendChild(o);
            });
          } else {
            this.hls.levels.forEach((l, i) => {
              const o = document.createElement('option');
              o.value = i;
              o.text = l.height ? `${l.height}p` : 'Unknown';
              this.q.appendChild(o);
            });
          }
          this.video.play().catch((err) => console.warn('Play failed:', err));
        });

        this.hls.on(Hls.Events.ERROR, (event, data) => {
          if (data.fatal) {
            console.error('FastStream: HLS error:', data);
            if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
              this.hls.startLoad();
            } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
              this.hls.recoverMediaError();
            } else {
              this.video.src = url;
              this.video.play().catch((err) => console.warn('Fallback play failed:', err));
            }
          }
        });
      } else if (url.includes('mpd') && typeof dashjs !== 'undefined') {
        this.dash = dashjs.MediaPlayer().create();
        this.dash.initialize(this.video, url, true);
        this.q.innerHTML = '<option>Auto</option>';
        this.video.play().catch((err) => console.warn('Play failed:', err));
      } else {
        this.video.src = url;
        this.q.innerHTML = '<option>Auto</option>';
        this.video.play().catch((err) => console.warn('Play failed:', err));
      }
    }

    copyToClipboard(text) {
      GM_setClipboard(text);
      alert('URL copied to clipboard!');
    }

    update() {
      if (!this.panel.classList.contains('open')) return;

      this.listContainer.innerHTML = '';
      const sources = this.d.getSortedUniqueSources();

      if (sources.length === 0) {
        const empty = document.createElement('div');
        empty.id = 'faststream-empty';
        empty.textContent = 'No video streams detected yet. Open a video page and it will appear here.';
        this.listContainer.appendChild(empty);
        return;
      }

      sources.forEach((s, index) => {
        const row = document.createElement('div');
        row.className = 'faststream-source-row' + (s.isPlaying ? ' playing' : '');

        const info = document.createElement('div');
        info.className = 'faststream-source-info';

        const header = document.createElement('div');
        header.className = 'faststream-source-header';

        const numSpan = document.createElement('span');
        numSpan.className = 'faststream-source-number';
        numSpan.textContent = `#${index + 1}`;

        const typeSpan = document.createElement('span');
        typeSpan.className = 'faststream-source-type';
        typeSpan.textContent = s.type;

        const badge = document.createElement('span');
        badge.className = s.isPlayable
          ? 'faststream-playable-badge'
          : 'faststream-not-playable-badge';
        badge.textContent = s.isPlayable ? '✓ External' : '⚠ Browser Only';

        header.appendChild(numSpan);
        header.appendChild(typeSpan);
        header.appendChild(badge);

        const urlDiv = document.createElement('div');
        urlDiv.className = 'faststream-source-url';
        urlDiv.textContent = s.url.substring(0, 70) + (s.url.length > 70 ? '...' : '');
        urlDiv.title = s.url;

        const statusDiv = document.createElement('div');
        if (s.isPlaying) {
          statusDiv.className = 'faststream-source-status';
          statusDiv.textContent = '● PLAYING';
        }

        info.appendChild(header);
        info.appendChild(urlDiv);
        if (s.isPlaying) info.appendChild(statusDiv);

        const buttons = document.createElement('div');
        buttons.className = 'faststream-source-buttons';

        const copy = document.createElement('button');
        copy.textContent = '📋 Copy';
        copy.className = 'faststream-btn-copy';
        copy.addEventListener('click', () => this.copyToClipboard(s.url));

        const play = document.createElement('button');
        play.textContent = '▶ Play';
        play.className = 'faststream-btn-play';
        play.addEventListener('click', () => this.play(s.url));

        buttons.appendChild(copy);
        buttons.appendChild(play);

        row.appendChild(info);
        row.appendChild(buttons);
        this.listContainer.appendChild(row);
      });
    }
  }

  /* =========================
     INIT
  ========================= */
  function init() {
    const d = new Detector();
    const ui = new UI(d);
    window.fastUI = ui;
    console.log('FastStream v2.0 initialized');
  }

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