Detect and play video streams (HLS, DASH, MP4, WebM) with improved performance and features
// ==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();
}
})();