fastlnker

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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