SHOWROOM player

自動偵測 SHOWROOM M3U8 串流

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         SHOWROOM player
// @namespace    showroom-stream-interceptor
// @version      1.2.2
// @description  自動偵測 SHOWROOM M3U8 串流
// @author       Copilot
// @match        *://*.showroom-live.com/*
// @match        *://showroom-live.com/*
// @run-at       document-start
// @grant        unsafeWindow
// @require      https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js
// @license      MIT
// ==/UserScript==

/* global Hls */
(function () {
  'use strict';

  const win = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

  // ─── State ────────────────────────────────────────────────────────────────
  const capturedStreams = new Map(); // quality key → url
  let hlsInstance  = null;
  let panelEl      = null;
  let videoEl      = null;
  let isDragging   = false;
  let dragOffX = 0, dragOffY = 0;
  let currentUrl   = '';
  let panelReady   = false;
  let styleReady   = false;
  let headerObserverReady = false;

  // ─── Panel position/size persistence ──
  const PANEL_POS_KEY = 'srip-panel-pos';
  function savePanelPos() {
    if (!panelEl) return;
    const rect = panelEl.getBoundingClientRect();
    const style = window.getComputedStyle(panelEl);
    const data = {
      left: panelEl.style.left || '',
      top: panelEl.style.top || '',
      right: panelEl.style.right || '',
      bottom: panelEl.style.bottom || '',
      width: style.width,
      height: style.height,
    };
    try {
      localStorage.setItem(PANEL_POS_KEY, JSON.stringify(data));
    } catch (_) {}
  }
  function restorePanelPos() {
    if (!panelEl) return;
    let data = null;
    try {
      data = JSON.parse(localStorage.getItem(PANEL_POS_KEY) || 'null');
    } catch (_) {}
    if (data && (data.left || data.top || data.width || data.height)) {
      if (data.left)   panelEl.style.left   = data.left;
      if (data.top)    panelEl.style.top    = data.top;
      if (data.right)  panelEl.style.right  = data.right;
      if (data.bottom) panelEl.style.bottom = data.bottom;
      if (data.width)  panelEl.style.width  = data.width;
      if (data.height) panelEl.style.height = data.height;
    }
  }

  // ─── Quality helpers ──────────────────────────────────────────────────────
  const Q_ORDER  = ['ss', 'abr', 'mm', 'll'];
  const HEADER_ICON = {
    // 子母畫面/跳出視窗 icon
    play: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="13" y="13" width="8" height="8" rx="2" fill="currentColor"/></svg>',
    pot: '<img src="https://i.namu.wiki/i/zOIbq8uWX_j6Q_2o2fIrlM3M6WtHdagol9NRp6WTI5PLcTkfMGxol_ns7jF2UdE93CkB3pQ5QGASLmJZ1fAhkw.svg" alt="" referrerpolicy="no-referrer">',
    copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2zm-3 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1h-2V5H5v9h1v2z" fill="currentColor"></path></svg>',
  };

  function isRoomPage() {
    return /^\/r\/[^/]+/.test(location.pathname);
  }

  function getBestStreamUrl() {
    return capturedStreams.get('ss') || capturedStreams.get('abr') || capturedStreams.get('mm') || capturedStreams.get('ll') || '';
  }

  function classifyUrl(url) {
    if (!url || !url.includes('.m3u8')) return null;
    if (!/main_(?:abr|ss|mm|ll)[^/]*\.m3u8(?:$|\?)/.test(url)) return null;
    if (url.includes('_abr')) return 'abr';
    if (url.includes('_ss'))  return 'ss';
    if (url.includes('_mm'))  return 'mm';
    if (url.includes('_ll'))  return 'll';
    return null; // 忽略其他 m3u8(例如 segment requests)
  }

  function isStreamApiUrl(url) {
    return typeof url === 'string' && /\/api\/live\/streaming_url/.test(url);
  }

  function visitPayload(value, onString) {
    if (!value) return;
    if (typeof value === 'string') {
      onString(value);
      return;
    }
    if (Array.isArray(value)) {
      value.forEach(item => visitPayload(item, onString));
      return;
    }
    if (typeof value === 'object') {
      Object.keys(value).forEach(key => {
        visitPayload(value[key], onString);
      });
    }
  }

  console.log('[SRIP] script loaded', location.href);

  // ─── Parse SHOWROOM stream API payloads only ─────────────────────────────
  function scanStreamApiResponse(sourceUrl, text) {
    if (!isStreamApiUrl(sourceUrl) || !text || typeof text !== 'string') return;

    try {
      const payload = JSON.parse(text);
      let found = 0;
      visitPayload(payload, value => {
        if (typeof value !== 'string' || !value.includes('.m3u8')) return;
        if (!/^https?:\/\//.test(value)) return;
        found += 1;
        handleDetected(value);
      });
      if (found > 0) {
        console.log('[SRIP] parsed stream API', sourceUrl, 'count=', found);
      }
    } catch (_) {
      // Fallback: if the endpoint returns plain text for some reason, scan only this API response.
      const re = /https?:\/\/[^\s"'\\]+\.m3u8(?:[^\s"'\\]*)/g;
      let m;
      while ((m = re.exec(text)) !== null) {
        handleDetected(m[0]);
      }
    }
  }

  // ─── Network Hooks ────────────────────────────────────────────────────────
  // Hook XMLHttpRequest — intercept both request URL and response body
  const _xhrOpen = win.XMLHttpRequest.prototype.open;
  win.XMLHttpRequest.prototype.open = function (method, url) {
    if (typeof url === 'string') handleDetected(url);
    this.addEventListener('load', function () {
      try {
        if (!this.responseType || this.responseType === 'text') {
          scanStreamApiResponse(this.responseURL || url, this.responseText);
        }
      } catch (_) {}
    });
    return _xhrOpen.apply(this, arguments);
  };

  // Hook fetch — intercept both request URL and response body
  const _fetch = win.fetch;
  if (typeof _fetch === 'function') {
    win.fetch = function (input, init) {
      const url = (typeof input === 'string') ? input : (input && input.url) || '';
      handleDetected(url);
      let p;
      try { p = _fetch.apply(win, arguments); } catch (e) { throw e; }
      p.then(res => {
        try {
          const clone = res.clone();
          clone.text().then(t => scanStreamApiResponse(res.url || url, t)).catch(() => {});
        } catch (_) {}
      }).catch(() => {});
      return p;
    };
  }

  function handleDetected(url) {
    const q = classifyUrl(url);
    if (!q) return;
    const prev = capturedStreams.get(q);
    capturedStreams.set(q, url);
    if (prev !== url) {
      console.log(`[抓到了 M3U8 ${q.toUpperCase()}]`, url);
      scheduleRefresh();
    }
  }

  // ─── DOM Video Observer ───────────────────────────────────────────────────
  // Catch M3U8 URLs assigned directly to <video src> or <source src>
  function observeVideos() {
    const check = (node) => {
      if (!node || node.nodeType !== 1) return;
      const tag = node.tagName;
      if (tag === 'VIDEO' || tag === 'SOURCE') {
        const s = node.getAttribute('src') || '';
        if (s) handleDetected(s);
      }
      // Also walk children
      node.querySelectorAll && node.querySelectorAll('video,source').forEach(el => {
        const s = el.getAttribute('src') || '';
        if (s) handleDetected(s);
      });
    };

    // Intercept setAttribute on the prototype so dynamic src changes are caught
    const _setSrc = Object.getOwnPropertyDescriptor(win.HTMLMediaElement.prototype, 'src');
    if (_setSrc && _setSrc.set) {
      Object.defineProperty(win.HTMLMediaElement.prototype, 'src', {
        get: _setSrc.get,
        set: function (v) {
          handleDetected(v);
          return _setSrc.set.call(this, v);
        },
        configurable: true,
      });
    }

    // MutationObserver fallback for innerHTML / setAttribute
    const obs = new MutationObserver(muts => {
      muts.forEach(m => {
        if (m.type === 'attributes' && m.attributeName === 'src') {
          handleDetected(m.target.getAttribute('src') || '');
        }
        m.addedNodes.forEach(check);
      });
    });

    const doObserve = () => {
      obs.observe(document.documentElement, {
        subtree: true, childList: true,
        attributes: true, attributeFilter: ['src'],
      });
    };
    if (document.documentElement) doObserve();
    else document.addEventListener('DOMContentLoaded', doObserve, { once: true });
  }
  observeVideos();

  // ─── Panel Bootstrap ──────────────────────────────────────────────────────
  function ensurePanelOnDOMReady() {
    if (panelReady) return;
    if (document.body) {
      buildPanel();
    } else {
      document.addEventListener('DOMContentLoaded', buildPanel, { once: true });
    }
  }

  // ─── CSS ──────────────────────────────────────────────────────────────────
  const CSS = `
  #srip {
    position: fixed; bottom: 22px; right: 22px;
    width: 430px;
    height: 280px;
    min-width: 280px;
    min-height: 160px;
    max-width: 92vw;
    max-height: 90vh;
    background: #0e0e16;
    border: 1px solid #7c3aed55;
    border-radius: 14px;
    box-shadow: 0 10px 40px rgba(0,0,0,.75);
    z-index: 2147483647;
    font-family: 'Segoe UI', 'Noto Sans TC', sans-serif;
    font-size: 13px;
    color: #e2e8f0;
    overflow: hidden;
    resize: both;
  }

  /* ── titlebar ── */
  #srip-bar {
    display: flex; align-items: center; justify-content: space-between;
    padding: 0 10px 0 12px; height: 34px;
    background: linear-gradient(90deg,#1c1030,#180d2e);
    cursor: grab; border-bottom: 1px solid #7c3aed33;
  }
  #srip-bar:active { cursor: grabbing; }
  #srip-bar-title { font-weight: 800; font-size: 12px; color: #a78bfa; letter-spacing: .3px; }
  #srip-bar-right { display: flex; align-items: center; gap: 6px; }
  #srip-count { font-size: 11px; color: #6b7280; }
  .srip-bar-btn {
    background: none; border: none; color: #94a3b8;
    font-size: 15px; cursor: pointer; padding: 0 3px; line-height: 1;
  }
  .srip-bar-btn:hover { color: #c4b5fd; }
  #srip-close-btn:hover { color: #f87171; }

  /* ── body ── */
  #srip-body { padding: 8px; height: calc(100% - 34px); }

  /* ── video ── */
  #srip-video {
    width: 100%;
    height: 100%;
    border-radius: 9px;
    background: #000;
    display: block;
  }

  .srip-header-item {
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    width: 54px;
    height: 100%;
  }
  .srip-header-button {
    appearance: none;
    border: 0;
    background: transparent;
    color: inherit;
    cursor: pointer;
    font: inherit;
    padding: 0;
    width: 100%;
    height: 100%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .srip-header-button[disabled] {
    opacity: .45;
    cursor: default;
  }
  .srip-header-button:not([disabled]):hover {
    color: #a78bfa;
  }
  .srip-header-icon {
    width: 25px;
    height: 25px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .srip-header-icon svg {
    width: 25px;
    height: 25px;
    display: block;
  }
  .srip-header-icon img {
    width: 25px;
    height: 25px;
    display: block;
    object-fit: contain;
  }
  html.srip-player-active #avatarContainer {
    display: none !important;
    visibility: hidden !important;
    pointer-events: none !important;
  }
  html.srip-player-active body {
    background-image: none !important;
    background-color: #181028 !important;
  }
  html.srip-player-active .st-mute__ballon,
  html.srip-player-active .st-banners,
  html.srip-player-active .room-telop,
  html.srip-player-active .room-info-view,
  html.srip-player-active .st-video__setting,
  html.srip-player-active .st-activate__list {
    display: none !important;
    visibility: hidden !important;
    pointer-events: none !important;
  }
  /* 強制覆蓋背景圖(針對 inline style) */
  html.srip-player-active [style*="room_background/default.png"],
  html.srip-player-active [style*="static.showroom-live.com/image/room_background/"] {
    background-image: none !important;
  }
  `;

  function ensureStyle() {
    if (styleReady) return;
    if (!document.head) {
      document.addEventListener('DOMContentLoaded', ensureStyle, { once: true });
      return;
    }
    styleReady = true;
    const styleEl = document.createElement('style');
    styleEl.textContent = CSS;
    document.head.appendChild(styleEl);
  }

  function ensureHeaderActions() {
    if (!isRoomPage()) return;
    ensureStyle();

    // 找到 <header class="room-header"> 下的 <p.st-official>
    const header = document.querySelector('header.room-header');
    if (!header) return;
    const official = header.querySelector('p.st-official');
    if (!official) return;

    // 檢查是否已經插入過
    if (header.querySelector('.srip-header-actions')) {
      updateHeaderActions();
      return;
    }

    // 建立 actions 容器
    const actionsWrap = document.createElement('span');
    actionsWrap.className = 'srip-header-actions';
    actionsWrap.style.display = 'inline-flex';
    actionsWrap.style.alignItems = 'center';
    actionsWrap.style.marginLeft = '12px';

    const actions = [
      { key: 'play', title: '用最佳畫質開啟懸浮播放器', onClick: handlePlayBest },
      { key: 'pot', title: '用 PotPlayer 開啟最佳串流', onClick: handlePotPlayer },
      { key: 'copy', title: '複製最佳串流網址', onClick: handleCopyBest },
    ];

    actions.forEach(action => {
      // 外層容器,模仿 .srip-header-item
      const item = document.createElement('span');
      item.className = 'srip-header-item';
      item.style.width = '54px';
      item.style.height = '100%';
      item.style.display = 'flex';
      item.style.alignItems = 'center';
      item.style.justifyContent = 'center';
      item.style.position = 'relative';

      const button = document.createElement('button');
      button.type = 'button';
      button.className = 'srip-header-button';
      button.dataset.sripAction = action.key;
      button.title = action.title;
      button.setAttribute('aria-label', action.title);
      button.innerHTML = `<span class="srip-header-icon">${HEADER_ICON[action.key] || ''}</span>`;
      button.addEventListener('click', action.onClick);

      item.appendChild(button);
      actionsWrap.appendChild(item);
    });

    // 插入到 <p.st-official> 之後
    if (official.nextSibling) {
      header.insertBefore(actionsWrap, official.nextSibling);
    } else {
      header.appendChild(actionsWrap);
    }

    updateHeaderActions();
  }

  function observeHeaderMenu() {
    if (headerObserverReady) return;
    headerObserverReady = true;

    const run = () => {
      ensureHeaderActions();
      const observer = new MutationObserver(() => {
        ensureHeaderActions();
      });
      observer.observe(document.documentElement, {
        subtree: true,
        childList: true,
      });
    };

    if (document.documentElement) run();
    else document.addEventListener('DOMContentLoaded', run, { once: true });
  }

  function updateHeaderActions() {
    const bestUrl = getBestStreamUrl();
    const disabled = !bestUrl;
    document.querySelectorAll('.srip-header-button').forEach(button => {
      button.disabled = disabled;
    });
  }

  function setPageChromeHidden(hidden) {
    document.documentElement.classList.toggle('srip-player-active', hidden);

    const avatarContainer = document.querySelector('#avatarContainer');
    if (avatarContainer) {
      avatarContainer.style.display = hidden ? 'none' : '';
      avatarContainer.style.visibility = hidden ? 'hidden' : '';
      avatarContainer.style.pointerEvents = hidden ? 'none' : '';
    }
  }

  // ─── Build Panel ──────────────────────────────────────────────────────────
  function buildPanel() {
    if (panelReady) return;
    panelReady = true;
    ensureStyle();

    panelEl = document.createElement('div');
    panelEl.id = 'srip';
    panelEl.innerHTML = `
      <div id="srip-bar">
        <span id="srip-bar-title">▶ SR Stream Player</span>
        <div id="srip-bar-right">
          <span id="srip-count">偵測中…</span>
          <button class="srip-bar-btn" id="srip-close-btn" title="關閉">✕</button>
        </div>
      </div>
      <div id="srip-body">
        <video id="srip-video" controls preload="none"></video>
      </div>
    `;
    document.body.appendChild(panelEl);
    videoEl = panelEl.querySelector('#srip-video');

    // ── 還原位置與大小 ──
    restorePanelPos();

    // ── Drag ──
    const bar = panelEl.querySelector('#srip-bar');
    bar.addEventListener('mousedown', e => {
      if (e.target.tagName === 'BUTTON') return;
      isDragging = true;
      const r = panelEl.getBoundingClientRect();
      dragOffX = e.clientX - r.left;
      dragOffY = e.clientY - r.top;
      e.preventDefault();
    });
    document.addEventListener('mousemove', e => {
      if (!isDragging) return;
      panelEl.style.left   = (e.clientX - dragOffX) + 'px';
      panelEl.style.top    = (e.clientY - dragOffY) + 'px';
      panelEl.style.right  = 'auto';
      panelEl.style.bottom = 'auto';
      savePanelPos();
    });
    document.addEventListener('mouseup', () => { isDragging = false; });

    // ── Resize ──
    panelEl.addEventListener('mouseup', savePanelPos);
    panelEl.addEventListener('mouseleave', savePanelPos);
    panelEl.addEventListener('touchend', savePanelPos);
    panelEl.addEventListener('touchcancel', savePanelPos);
    panelEl.addEventListener('transitionend', savePanelPos);
    // 監聽 resize 事件(resize: both)
    let resizeObserver = null;
    if (window.ResizeObserver) {
      resizeObserver = new ResizeObserver(savePanelPos);
      resizeObserver.observe(panelEl);
    }

    // ── Close ──
    panelEl.querySelector('#srip-close-btn').addEventListener('click', () => {
      destroyPlayer();
      if (resizeObserver) resizeObserver.disconnect();
      panelEl.remove();
      panelEl   = null;
      videoEl   = null;
      panelReady = false;
    });

    renderUiState();
  }

  // ─── Stop Original Streams ────────────────────────────────────────────────
  function stopOriginalStreams() {
    document.querySelectorAll('video').forEach(v => {
      if (v === videoEl) return;
      try {
        v.pause();
        v.srcObject = null;
        const src = v.src;
        v.removeAttribute('src');
        v.load();
        // If there's an HLS source on the page's player, try to null src
        if (src) v.src = '';
      } catch (_) { /* ignore cross-origin */ }
    });
  }

  // ─── HLS Playback ─────────────────────────────────────────────────────────
  function destroyPlayer() {
    if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
    if (videoEl) { videoEl.pause(); videoEl.src = ''; }
    currentUrl = '';
    setPageChromeHidden(false);
  }

  function playStream(url) {
    if (!videoEl || !url) return;

    destroyPlayer();
    currentUrl = url;

    if (typeof Hls !== 'undefined' && Hls.isSupported()) {
      hlsInstance = new Hls({
        enableWorker: true,
        lowLatencyMode: true,
        maxBufferLength: 30,
      });
      hlsInstance.loadSource(url);
      hlsInstance.attachMedia(videoEl);
      hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
        videoEl.play().catch(() => {});
      });
      hlsInstance.on(Hls.Events.ERROR, (_evt, data) => {
        if (data.fatal) console.warn('[SRIP] HLS fatal error', data);
      });
    } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
      // Safari native HLS
      videoEl.src = url;
      videoEl.play().catch(() => {});
    } else {
      alert('[SR Stream Player] 您的瀏覽器不支援 HLS.js,請安裝 Tampermonkey 並允許 @require 載入 hls.js。');
      return;
    }

    setPageChromeHidden(true);
    renderUiState();
  }

  async function copyText(text) {
    if (!text) return false;
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (_) {
      const input = document.createElement('textarea');
      input.value = text;
      input.setAttribute('readonly', 'readonly');
      input.style.position = 'fixed';
      input.style.left = '-9999px';
      document.body.appendChild(input);
      input.select();
      const ok = document.execCommand('copy');
      input.remove();
      return ok;
    }
  }

  function handlePlayBest() {
    const url = getBestStreamUrl();
    if (!url) {
      alert('[SR Stream Player] 尚未抓到可播放的串流。');
      return;
    }
    stopOriginalStreams();
    ensurePanelOnDOMReady();
    playStream(url);
  }

  function handlePotPlayer() {
    const url = getBestStreamUrl();
    if (!url) {
      alert('[SR Stream Player] 尚未抓到可播放的串流。');
      return;
    }
    location.href = 'potplayer://' + url;
  }

  async function handleCopyBest() {
    const url = getBestStreamUrl();
    if (!url) {
      alert('[SR Stream Player] 尚未抓到可播放的串流。');
      return;
    }
    const ok = await copyText(url);
    if (!ok) {
      alert('[SR Stream Player] 複製失敗。');
    }
  }

  // ─── Render UI State ──────────────────────────────────────────────────────
  let refreshPending = false;
  function scheduleRefresh() {
    if (refreshPending) return;
    refreshPending = true;
    // microtask-safe delay
    Promise.resolve().then(() => { refreshPending = false; renderUiState(); });
  }

  function renderUiState() {
    updateHeaderActions();
    if (!panelEl) return;

    const count = panelEl.querySelector('#srip-count');
    if (!count) return;

    const bestUrl = getBestStreamUrl();
    count.textContent = capturedStreams.size > 0 ? `已抓到 ${capturedStreams.size} 個流` : '偵測中…';
    if (!bestUrl && currentUrl) {
      currentUrl = '';
    }
  }

  observeHeaderMenu();

})();