自動偵測 SHOWROOM M3U8 串流
// ==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();
})();