以分頁或新視窗同時播放多個影片,並可將任意影片放大置頂。 // Play multiple videos simultaneously in tabs or windows, and pin any video to the top.
// ==UserScript==
// @name YouTube 多重播放器 YouTube Multi-Player
// @name:zh-TW YouTube 多重播放器
// @name:en YouTube Multi-Player
// @namespace http://tampermonkey.net/
// @version 6.6
// @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=*
// @match https://www.youtube.com/channel/*
// @exclude https://studio.youtube.com/*
// @exclude https://accounts.youtube.com/*
// @exclude https://www.youtube.com/watch
// @grant GM_info
// @license MIT
// @description 以分頁或新視窗同時播放多個影片,並可將任意影片放大置頂。 // Play multiple videos simultaneously in tabs or windows, and pin any video to the top.
// @description:zh-TW 以新分頁或新視窗同時播放多個影片,並可將任意影片放大置頂。
// @description:en Play multiple videos simultaneously in tabs or windows, and pin any video to the top.
// ==/UserScript==
(function(){
'use strict';
// --- 腳本參數 / Script Parameters ---
const MAX_PINNED = 2; // 最大置頂影片數量 / Maximum number of pinned videos
const LIST_COUNT = 2; // 設定清單數量 / Number of lists
// --- 語言檢測 / Language Detection ---
// 檢測瀏覽器語言以決定顯示中文或英文 / Detect browser language to decide display language
const isChinese = navigator.language.startsWith('zh') || (typeof GM_info !== 'undefined' && GM_info.script.locale === 'zh-TW');
// --- 文字資源 / Text Resources ---
// 定義按鈕和提示文字 / Define button and prompt texts
const TEXTS = {
play: isChinese ? '▶ 播放' : '▶ Play',
modeTab: isChinese ? '分頁' : 'Tab',
modeWindow: isChinese ? '視窗' : 'Pop-up', // Changed from 'Window' to 'Pop-up' as per request
list: isChinese ? '清單' : 'List', // Base text for list button
noVideos: isChinese ? '當前清單無影片' : 'No videos in current list',
// Note: The List button text itself will be dynamically generated as "List1 (N)" etc.
// 注意:清單按鈕的文字本身會動態生成為 "List1 (N)" 等。
};
// --- 網址驗證 / URL Validation ---
// 驗證當前網址是否符合腳本執行條件 / Validate if the current URL matches the script execution conditions
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=.*/,
/^https:\/\/www\.youtube\.com\/channel\/.*/
];
return patterns.some(p => p.test(window.location.href));
};
// 移除面板如果網址無效 / Remove panel if URL becomes invalid
let checkInterval = setInterval(() => {
if(!validateURL()){
const panel = document.getElementById('ytMulti_panel');
if(panel) panel.remove();
clearInterval(checkInterval);
}
}, 60000);
// --- 儲存鍵名 / Storage Keys ---
// 定義用於 localStorage 的鍵名 / Define keys used for localStorage
const STORAGE_POS = 'ytMulti_btnPos'; // 按鈕位置 / Button position
const STORAGE_MODE = 'ytMulti_openMode'; // 開啟模式 (分頁/視窗) / Open mode (tab/window)
const STORAGE_CURRENT = 'ytMulti_currentList'; // 目前清單 / Current active list
// --- 動態生成清單儲存鍵 / Dynamically Generate List Storage Keys ---
// 根據 LIST_COUNT 參數生成對應的清單鍵名 / Generate corresponding list key names based on the LIST_COUNT parameter
const generateStorageKeys = () => {
const keys = {};
for (let i = 1; i <= LIST_COUNT; i++) {
keys[`list${i}`] = `ytMulti_videoList${i}`;
}
return keys;
};
const STORAGE_LISTS = generateStorageKeys();
let currentList = localStorage.getItem(STORAGE_CURRENT) || 'list1';
if (!STORAGE_LISTS[currentList]) {
currentList = Object.keys(STORAGE_LISTS)[0];
localStorage.setItem(STORAGE_CURRENT, currentList);
}
// --- 創建控制面板 / Create Control Panel ---
// 創建並設定控制面板的樣式 / Create and style the control panel
const panel = document.createElement('div');
panel.id = 'ytMulti_panel';
panel.style.cssText = `
position: fixed;
background: rgba(0,0,0,0.8);
color: #fff;
padding: 6px 8px;
border-radius: 8px;
z-index: 9999;
display: flex;
align-items: center;
cursor: move;
gap: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
font-family: Arial, sans-serif;
backdrop-filter: blur(4px);
`;
document.body.appendChild(panel);
// --- 還原面板位置 / Restore Panel Position ---
// 從 localStorage 恢復面板位置 / Restore panel position from localStorage
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';
}
// --- 使面板可拖曳 / Make Panel Draggable ---
// 實現面板的拖曳功能 / Implement panel drag functionality
panel.addEventListener('mousedown', e => {
e.preventDefault();
let startX = e.clientX, startY = e.clientY;
const rect = panel.getBoundingClientRect();
let hasMoved = false;
function onMove(ev){
panel.style.top = rect.top + ev.clientY - startY + 'px';
panel.style.left = rect.left + ev.clientX - startX + 'px';
hasMoved = true;
}
function onUp(){
if (hasMoved) {
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);
});
// --- 創建樣式化按鈕 / Create Styled Button ---
// 生成具有統一樣式的按鈕 / Generate a button with a consistent style
function createStyledButton(text){
const btn = document.createElement('button');
btn.textContent = text;
btn.style.cssText = `
padding: 6px 12px;
height: 36px;
border: none;
border-radius: 6px;
background: #ff0000;
color: white;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
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;
}
// --- 初始化按鈕 / Initialize Buttons ---
// 創建並添加控制按鈕到面板 / Create and add control buttons to the panel
const playBtn = createStyledButton(TEXTS.play); // 播放按鈕 / Play button
const modeBtn = createStyledButton(localStorage.getItem(STORAGE_MODE) === 'tab' ? TEXTS.modeTab : TEXTS.modeWindow); // 模式按鈕 / Mode button
const listBtn = createStyledButton(`${TEXTS.list}1`); // 清單按鈕 (初始為 List1) / List button (initially List1)
panel.append(playBtn, modeBtn, listBtn);
// --- 拖曳處理 / Drag and Drop Handling ---
// 處理拖曳影片網址到面板的功能 / Handle dragging video URLs onto the panel
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 = STORAGE_LISTS[currentList];
let ids = JSON.parse(localStorage.getItem(storageKey) || '[]');
if(!ids.includes(vid)){
ids.push(vid);
localStorage.setItem(storageKey, JSON.stringify(ids));
updateListButtonCount();
}
});
// --- 模式切換 / Mode Toggle ---
// 切換開啟模式 (分頁/視窗) / Toggle the open mode (tab/window)
modeBtn.addEventListener('click', () => {
const mode = localStorage.getItem(STORAGE_MODE) === 'tab' ? 'window' : 'tab';
localStorage.setItem(STORAGE_MODE, mode);
modeBtn.textContent = mode === 'tab' ? TEXTS.modeTab : TEXTS.modeWindow;
});
// --- 清單切換 / List Toggle ---
// 在不同清單之間切換 / Toggle between different lists
listBtn.addEventListener('click', () => {
const listNames = Object.keys(STORAGE_LISTS);
const currentIndex = listNames.indexOf(currentList);
const nextIndex = (currentIndex + 1) % listNames.length;
currentList = listNames[nextIndex];
localStorage.setItem(STORAGE_CURRENT, currentList);
updateListButtonCount();
});
// --- 更新清單按鈕計數 / Update List Button Count ---
// 更新清單按鈕上的影片數量顯示 / Update the video count display on the list button
const updateListButtonCount = () => {
const storageKey = STORAGE_LISTS[currentList];
const count = JSON.parse(localStorage.getItem(storageKey) || '[]').length;
const listNum = currentList.replace('list', '');
listBtn.textContent = `${TEXTS.list}${listNum} (${count})`;
};
// --- 播放功能 / Play Function ---
// 生成播放頁面並開啟 / Generate the play page and open it
playBtn.addEventListener('click', () => {
const storageKey = STORAGE_LISTS[currentList];
const ids = JSON.parse(localStorage.getItem(storageKey) || '[]');
if(!ids.length) return alert(TEXTS.noVideos);
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 {
// Changed from 'window' to 'popup' for the window features string as per request
window.open(blobUrl, '_blank', 'width=800,height=600,scrollbars=no,resizable=yes');
}
});
// --- 解析 YouTube ID / Parse YouTube ID ---
// 從網址中提取 YouTube 影片 ID / Extract the YouTube video ID from a URL
function parseYouTubeID(url){
const m = url.match(/(?:v=|youtu\.be\/)([A-Za-z0-9_-]{11})/);
return m ? m[1] : null;
}
// --- 生成播放頁面 HTML / Generate Play Page HTML ---
// 生成包含影片播放邏輯的 HTML 頁面 / Generate the HTML page containing the video playback logic
function makeBlobPage(ids, listKey){
const listJson = JSON.stringify(ids);
const storageListsJson = JSON.stringify(STORAGE_LISTS);
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Multi-Player</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, .up-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;}
.up-btn{top:54px;right:6px;background:#ffaa44;}
.remove-btn::after{content:'×';color:white;font-size:16px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;line-height:20px;}
.pin-btn::after{content:'📌';color:white;font-size:14px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;line-height:20px;}
.up-btn::after{content:'↑';color:white;font-size:16px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;line-height:20px;}
.video-wrapper:hover .remove-btn, .video-wrapper:hover .pin-btn, .video-wrapper:hover .up-btn{display:block;}
</style></head><body><div class="container"></div><script>
const MAX_PINNED = ${MAX_PINNED};
const ASPECT_RATIO = 16/9;
let ids = ${listJson};
const listKey = ${JSON.stringify(listKey)};
const STORAGE_LISTS = ${storageListsJson};
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';
});
}
// 更新影片順序,僅移動 DOM 元素而不重新載入 / Update video order by moving DOM elements only, without reloading
function updateOrderInList(movedId, targetId) {
const movedIndex = ids.indexOf(movedId);
const targetIndex = ids.indexOf(targetId);
if (movedIndex === -1 || targetIndex === -1 || movedIndex === targetIndex) return;
const [movedVideo] = ids.splice(movedIndex, 1);
const newIndex = targetIndex > movedIndex ? targetIndex : targetIndex;
ids.splice(newIndex, 0, movedVideo);
const storageKey = STORAGE_LISTS[listKey];
localStorage.setItem(storageKey, JSON.stringify(ids));
const movedElement = document.querySelector(\`[data-id="\${movedId}"]\`);
const targetElement = document.querySelector(\`[data-id="\${targetId}"]\`);
if (movedElement && targetElement) {
if (newIndex > movedIndex) {
// 向下移動時,插入到目標元素之後 / When moving down, insert after the target element
container.insertBefore(movedElement, targetElement.nextSibling);
} else {
// 向上移動時,插入到目標元素之前 / When moving up, insert before the target element
container.insertBefore(movedElement, targetElement);
}
}
requestAnimationFrame(() => {
updateLayout();
});
}
function createVideo(id){
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) {
console.error("Invalid YouTube ID:", id);
return null;
}
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';
ifr.onload = function() {
setTimeout(() => {
try {
ifr.contentWindow.postMessage({
event: 'command',
func: 'pauseVideo'
}, '*');
} catch (e) {
}
}, 1000);
};
const delBtn = document.createElement('div');
delBtn.className = 'remove-btn';
delBtn.onclick = (e) => {
e.stopPropagation();
const storageKey = STORAGE_LISTS[listKey];
const stored = JSON.parse(localStorage.getItem(storageKey) || '[]');
const index = stored.indexOf(id);
if (index > -1) {
stored.splice(index, 1);
localStorage.setItem(storageKey, JSON.stringify(stored));
}
// 刪除影片後重新計算佈局 / Recalculate layout after deleting a video
ids = stored; // 更新 ids 陣列 / Update the ids array
wrap.remove();
updateLayout();
};
const pinBtn = document.createElement('div');
pinBtn.className = 'pin-btn';
pinBtn.onclick = (e) => {
e.stopPropagation();
const index = pinnedIds.indexOf(id);
if(index !== -1){
pinnedIds.splice(index, 1);
}else{
if(pinnedIds.length >= MAX_PINNED) pinnedIds.shift();
pinnedIds.push(id);
}
updateLayout();
};
const upBtn = document.createElement('div');
upBtn.className = 'up-btn';
upBtn.onclick = (e) => {
e.stopPropagation();
const currentIndex = ids.indexOf(id);
if (currentIndex > 0) {
const prevId = ids[currentIndex - 1];
updateOrderInList(id, prevId);
}
};
wrap.append(ifr, delBtn, pinBtn, upBtn);
return wrap;
}
ids.forEach(id => {
const videoElement = createVideo(id);
if (videoElement) {
container.appendChild(videoElement);
}
});
updateLayout();
window.addEventListener('resize', updateLayout);
<\/script></body></html>`;
}
// --- 初始化清單計數 / Initialize List Count ---
// 頁面載入時初始化清單按鈕的計數顯示 / Initialize the count display on the list button when the page loads
const initListCount = () => {
updateListButtonCount(); // 直接調用更新函數 / Call the update function directly
};
initListCount();
})();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址