您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
以新分頁或新視窗同時播放多個影片,並可將任意影片放大置頂。
// ==UserScript== // @name YouTube 多重播放器 // @namespace http://tampermonkey.net/ // @version 4.9 // @match https://www.youtube.com/ // @match https://www.youtube.com/feed/* // @match https://www.youtube.com/playlist?list=* // @match https://www.youtube.com/@* // @match https://www.youtube.com/gaming // @match https://www.youtube.com/results?search_query=* // @exclude https://studio.youtube.com/* // @exclude https://accounts.youtube.com/* // @exclude https://www.youtube.com/watch* // @grant none // @license MIT // @description 以新分頁或新視窗同時播放多個影片,並可將任意影片放大置頂。 // ==/UserScript== (function(){ const MAX_PINNED = 2; const validateURL = () => { const patterns = [ /^https:\/\/www\.youtube\.com\/$/, /^https:\/\/www\.youtube\.com\/feed\/.*/, /^https:\/\/www\.youtube\.com\/playlist\?list=.*/, /^https:\/\/www\.youtube\.com\/@.*/, /^https:\/\/www\.youtube\.com\/gaming$/, /^https:\/\/www\.youtube\.com\/results\?search_query=.*/ ]; return patterns.some(p => p.test(window.location.href)); }; setTimeout(() => { if(!validateURL()){ const panel = document.getElementById('ytMulti_panel'); if(panel) panel.remove(); return; } }, 60000); const STORAGE_POS = 'ytMulti_btnPos'; const STORAGE_LIST1 = 'ytMulti_videoList1'; const STORAGE_LIST2 = 'ytMulti_videoList2'; const STORAGE_MODE = 'ytMulti_openMode'; const STORAGE_CURRENT = 'ytMulti_currentList'; let currentList = localStorage.getItem(STORAGE_CURRENT) || 'list1'; const panel = document.createElement('div'); panel.id = 'ytMulti_panel'; panel.style.cssText = ` position: fixed; background: rgba(0,0,0,0.8); color: #fff; padding: 8px 12px; border-radius: 8px; z-index: 9999; display: flex; align-items: center; cursor: move; gap: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); font-family: Arial, sans-serif; backdrop-filter: blur(4px); `; document.body.appendChild(panel); const savedPos = JSON.parse(localStorage.getItem(STORAGE_POS) || 'null'); if(savedPos){ panel.style.top = savedPos.top; panel.style.left = savedPos.left; panel.style.right = 'auto'; } panel.addEventListener('mousedown', e => { e.preventDefault(); let startX = e.clientX, startY = e.clientY; const rect = panel.getBoundingClientRect(); function onMove(ev){ panel.style.top = rect.top + ev.clientY - startY + 'px'; panel.style.left = rect.left + ev.clientX - startX + 'px'; } function onUp(){ localStorage.setItem(STORAGE_POS, JSON.stringify({top: panel.style.top, left: panel.style.left})); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }); function createStyledButton(text){ const btn = document.createElement('button'); btn.textContent = text; btn.style.cssText = ` padding: 8px 16px; border: none; border-radius: 6px; background: #ff0000; color: white; cursor: pointer; transition: all 0.2s; font-size: 14px; font-weight: 500; text-shadow: 0 1px 2px rgba(0,0,0,0.2); box-shadow: 0 2px 4px rgba(0,0,0,0.2); `; btn.addEventListener('mouseover', () => btn.style.background = '#cc0000'); btn.addEventListener('mouseout', () => btn.style.background = '#ff0000'); return btn; } const playBtn = createStyledButton('▶ 播放'); const modeBtn = createStyledButton(localStorage.getItem(STORAGE_MODE) === 'tab' ? '分頁' : '視窗'); const listBtn = createStyledButton(currentList === 'list1' ? 'List1' : 'List2'); panel.append(playBtn, modeBtn, listBtn); panel.addEventListener('dragover', e => e.preventDefault()); panel.addEventListener('drop', e => { e.preventDefault(); const data = e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('text/plain'); const vid = parseYouTubeID(data); if(!vid) return; const storageKey = currentList === 'list1' ? STORAGE_LIST1 : STORAGE_LIST2; let ids = JSON.parse(localStorage.getItem(storageKey) || '[]'); if(!ids.includes(vid)){ ids.push(vid); localStorage.setItem(storageKey, JSON.stringify(ids)); listBtn.textContent = currentList === 'list1' ? `List1 (${ids.length})` : `List2 (${ids.length})`; } }); modeBtn.addEventListener('click', () => { const mode = localStorage.getItem(STORAGE_MODE) === 'tab' ? 'window' : 'tab'; localStorage.setItem(STORAGE_MODE, mode); modeBtn.textContent = mode === 'tab' ? '分頁' : '視窗'; }); listBtn.addEventListener('click', () => { currentList = currentList === 'list1' ? 'list2' : 'list1'; localStorage.setItem(STORAGE_CURRENT, currentList); const storageKey = currentList === 'list1' ? STORAGE_LIST1 : STORAGE_LIST2; const count = JSON.parse(localStorage.getItem(storageKey) || '[]').length; listBtn.textContent = currentList === 'list1' ? `List1 (${count})` : `List2 (${count})`; }); playBtn.addEventListener('click', () => { const storageKey = currentList === 'list1' ? STORAGE_LIST1 : STORAGE_LIST2; const ids = JSON.parse(localStorage.getItem(storageKey) || '[]'); if(!ids.length) return alert('當前清單無影片'); const html = makeBlobPage(ids, currentList); const blobUrl = URL.createObjectURL(new Blob([html], {type: 'text/html'})); const mode = localStorage.getItem(STORAGE_MODE); if(mode === 'tab'){ location.href = blobUrl; } else { window.open(blobUrl, '_blank', 'width=800,height=600'); } }); function parseYouTubeID(url){ const m = url.match(/(?:v=|youtu\.be\/)([A-Za-z0-9_-]{11})/); return m ? m[1] : null; } function makeBlobPage(ids, listKey){ const listJson = JSON.stringify(ids); return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>多重播放</title><style> body{margin:0;padding:0;background:#000;overflow:hidden;} .container{position:absolute;top:0;left:0;width:100vw;height:100vh;display:flex;flex-wrap:wrap;align-content:flex-start;} .video-wrapper{position:absolute;overflow:hidden;transition:all 0.3s ease;} .video-wrapper iframe{width:100%;height:100%;border:none;} .remove-btn, .pin-btn{ position:absolute; width:20px;height:20px; border-radius:3px; display:none; cursor:pointer; z-index:9999; box-shadow:0 0 3px rgba(0,0,0,0.3); } .remove-btn{top:6px;right:6px;background:#ff4444;} .pin-btn{top:30px;right:6px;background:#44aaff;} .remove-btn::after{content:'×';color:white;font-size:16px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);} .pin-btn::after{content:'📌';color:white;font-size:14px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);} .video-wrapper:hover .remove-btn, .video-wrapper:hover .pin-btn{display:block;} </style></head><body><div class="container"></div><script> const MAX_PINNED = ${MAX_PINNED}; const ASPECT_RATIO = 16/9; const ids = ${listJson}; const listKey = ${JSON.stringify(listKey)}; const container = document.querySelector('.container'); let pinnedIds = []; function calculateLayout(){ const W = container.offsetWidth; const H = container.offsetHeight; const visibleIds = ids.filter(id => !pinnedIds.includes(id)); const n = visibleIds.length; if(n === 0) return {cols:0, rows:0, itemWidth:0, itemHeight:0}; let bestCols = 1; let bestRows = 1; let bestItemWidth = 0; let bestItemHeight = 0; let bestScore = 0; const pinnedHeight = pinnedIds.length * (W / ASPECT_RATIO); const availableH = H - pinnedHeight; for(let cols=1; cols<=Math.min(n,12); cols++){ const rows = Math.ceil(n/cols); let itemWidth = W/cols; let itemHeight = itemWidth/ASPECT_RATIO; if(rows*itemHeight > availableH){ itemHeight = availableH/rows; itemWidth = itemHeight*ASPECT_RATIO; } const usedWidth = cols*itemWidth; const usedHeight = rows*itemHeight; const areaScore = usedWidth*usedHeight; const penalty = (W-usedWidth)*0.1 + (availableH-usedHeight)*0.2; const totalScore = areaScore - penalty; if(totalScore > bestScore){ bestScore = totalScore; bestCols = cols; bestRows = rows; bestItemWidth = itemWidth; bestItemHeight = itemHeight; } } return {cols:bestCols, rows:bestRows, itemWidth:bestItemWidth, itemHeight:bestItemHeight, availableH, pinnedHeight}; } function updateLayout(){ const {cols, rows, itemWidth, itemHeight, availableH, pinnedHeight} = calculateLayout(); pinnedIds.forEach((id, index) => { const pinnedVideo = document.querySelector('[data-id="'+id+'"]'); if(pinnedVideo){ pinnedVideo.style.top = (index * (window.innerWidth / ASPECT_RATIO)) + 'px'; pinnedVideo.style.left = '0px'; pinnedVideo.style.width = '100vw'; pinnedVideo.style.height = (window.innerWidth / ASPECT_RATIO) + 'px'; pinnedVideo.style.zIndex = '100'; } }); const visibleVideos = Array.from(container.children).filter(v => !pinnedIds.includes(v.dataset.id)); visibleVideos.forEach((wrap, index) => { const col = index % cols; const row = Math.floor(index / cols); wrap.style.width = itemWidth + 'px'; wrap.style.height = itemHeight + 'px'; wrap.style.left = (col * itemWidth) + 'px'; wrap.style.top = pinnedHeight + (row * itemHeight) + 'px'; wrap.style.zIndex = '1'; }); } function createVideo(id){ const wrap = document.createElement('div'); wrap.className = 'video-wrapper'; wrap.dataset.id = id; const ifr = document.createElement('iframe'); ifr.src = 'https://www.youtube.com/embed/'+id+'?autoplay=1&playsinline=1&rel=0&modestbranding=1&origin='+encodeURIComponent(window.location.origin); ifr.allow = 'autoplay; encrypted-media; fullscreen'; const delBtn = document.createElement('div'); delBtn.className = 'remove-btn'; delBtn.onclick = () => { const storageKey = listKey === 'list1' ? 'ytMulti_videoList1' : 'ytMulti_videoList2'; const stored = JSON.parse(localStorage.getItem(storageKey) || '[]'); stored.splice(stored.indexOf(id), 1); localStorage.setItem(storageKey, JSON.stringify(stored)); wrap.remove(); updateLayout(); }; const pinBtn = document.createElement('div'); pinBtn.className = 'pin-btn'; pinBtn.onclick = () => { const index = pinnedIds.indexOf(id); if(index !== -1){ pinnedIds.splice(index, 1); }else{ if(pinnedIds.length >= MAX_PINNED) pinnedIds.shift(); pinnedIds.push(id); } updateLayout(); }; wrap.append(ifr, delBtn, pinBtn); return wrap; } ids.forEach(id => container.appendChild(createVideo(id))); updateLayout(); window.addEventListener('resize', updateLayout); setInterval(updateLayout, 500); <\/script></body></html>`; } const initListCount = () => { const count1 = JSON.parse(localStorage.getItem(STORAGE_LIST1) || '[]').length; const count2 = JSON.parse(localStorage.getItem(STORAGE_LIST2) || '[]').length; listBtn.textContent = currentList === 'list1' ? `List1 (${count1})` : `List2 (${count2})`; }; initListCount(); })();