Play multiple videos or chatroom simultaneously in new tabs or windows, and pin any video to the top. Add Bookmarklet start, resolution spoofing for specific quality, copy buttons, and a Home button.
// ==UserScript==
// @name YouTube Multi-Player
// @name:zh-TW YouTube 多重播放器
// @namespace http://tampermonkey.net/
// @author Dxzy
// @version 8.4.9
// @match https://www.youtube.com/*
// @exclude https://studio.youtube.com/*
// @exclude https://accounts.youtube.com/*
// @exclude https://www.youtube.com/live_chat*
// @exclude https://www.youtube.com/embed/*
// @exclude https://www.yout-ube.com/*
// @grant GM_info
// @grant GM_registerMenuCommand
// @license MIT
// @description:zh-TW 以新分頁或新視窗同時播放多個影片或聊天室,並可將任意項目放大置頂。新增書籤啟動、偽造解析度指定畫質、複製按鈕、首頁鈕。
// @description Play multiple videos or chatroom simultaneously in new tabs or windows, and pin any video to the top. Add Bookmarklet start, resolution spoofing for specific quality, copy buttons, and a Home button.
// ==/UserScript==
(function () {
'use strict';
// = CONFIG 區塊 - 核心參數設定 / Core Configuration =
const CONFIG = {
MAX_PINNED: 3,
PORTRAIT_HEIGHT_THRESHOLD: 1.1,
LANDSCAPE_ASPECT_RATIO_THRESHOLD: 1.7,
LANDSCAPE_COLUMN_CONFIG: [2, 5, 10, 17, 26, 37, 50, 65],
PORTRAIT_MAX_COLUMNS: 4,
LIST_COUNT: 4,
SCREEN_WIDTH_DEFAULT: 1080,
RESOLUTION_PRESETS: [3, 4, 5, 5, 6],
RESOLUTION_LEVELS: {
1: [3840, 2160], 2: [2560, 1440], 3: [1920, 1080], 4: [1280, 720],
5: [854, 480], 6: [640, 360], 7: [432, 240], 8: [256, 144]
},
SPOOF_RESOLUTION_ENABLED: true,
HOME_PINNED_BY_DEFAULT: true,
ADD_BUTTON_ENABLED_STORAGE_KEY: 'ytMulti_addButtonEnabled',
SYNC_EVENT_KEY: 'ytMulti_syncEvent',
ASPECT_RATIO_STANDARD: 16/9,
CHAT_SUFFIX: '_chat',
VIDEO_SUFFIX: '_video',
DOMAIN_MODE_STORAGE_KEY: 'ytMulti_domainMode',
DOMAIN_MODES: [
{ key: 'YT', domain: 'www.youtube.com', label: 'YT' },
{ key: 'YU', domain: 'www.yout-ube.com', label: 'YU' }
],
POLLING_INTERVAL: 2000,
RESIZE_DEBOUNCE: 100,
HOVER_TIMEOUT: 5000
};
// = 多語言設定 / Multi-language =
const LANG = {
zh: {
play: '▶ 播放', playIcon: '▶', modeCurrentTab: '分頁', modeNewTab: '分頁+', modeNewWindow: '視窗',
list: '清單', noVideos: '當前清單無影片', addButton: '添加', addIcon: '+',
settingsTitle: '🔧 修改設定值', settingsPrompt: '請輸入要修改的設定項目編號(輸入 0 退出):\n\n',
settingsValuePrompt: '\n當前值: {current}\n預設值: {default}\n說明: {desc}\n\n請輸入新值(輸入 0 取消): ',
settingsSaved: '✅ 設定已儲存', settingsCancelled: '❌ 已取消',
settingsInvalid: '⚠️ 輸入無效', settingsExit: '👋 已退出',
descMaxPinned: '最多可同時置頂的影片數量',
descListCount: '影片清單總數量',
descPortraitMaxCols: '縱向螢幕最大欄數',
descLandscapeConfig: '橫向欄數閾值(逗號分隔)',
descPortraitThreshold: '縱向高度倍數閾值',
descLandscapeAR: '橫向寬高比閾值',
descScreenWidth: '偽造解析度時使用的螢幕寬度基準值',
descResolutionPresets: '解析度檔位 [1.2160 2.1440 3.1080 4.720 5.480 6.360 7.240 8.144],預設 3,4,5,5,6',
descSpoofResolution: '偽造解析度開關:1=開啟 2=關閉',
descHomePinnedDefault: '新增首頁時預設置頂:1=開啟 2=關閉',
spoofOn: '開啟', spoofOff: '關閉'
},
en: {
play: '▶ Play', playIcon: '▶', modeCurrentTab: 'Tab', modeNewTab: 'Tab+', modeNewWindow: 'Window',
list: 'List', noVideos: 'No videos in current list', addButton: 'Add', addIcon: '+',
settingsTitle: '🔧 Modify Settings', settingsPrompt: 'Enter setting number (0 to exit):\n\n',
settingsValuePrompt: '\nCurrent: {current}\nDefault: {default}\nDesc: {desc}\n\nNew value (0 to cancel): ',
settingsSaved: '✅ Settings saved', settingsCancelled: '❌ Cancelled',
settingsInvalid: '⚠️ Invalid input', settingsExit: '👋 Exited',
descMaxPinned: 'Max pinned videos',
descListCount: 'Total list count',
descPortraitMaxCols: 'Max columns in portrait',
descLandscapeConfig: 'Landscape column thresholds (comma-separated)',
descPortraitThreshold: 'Portrait height multiplier threshold',
descLandscapeAR: 'Landscape AR threshold',
descScreenWidth: 'Screen width baseline for resolution spoofing',
descResolutionPresets: 'Resolution levels [1.2160 2.1440 3.1080 4.720 5.480 6.360 7.240 8.144], default 3,4,5,5,6',
descSpoofResolution: 'Spoof resolution toggle: 1=Enable 2=Disable',
descHomePinnedDefault: 'Pin new homepages by default: 1=Enable 2=Disable',
spoofOn: 'ON', spoofOff: 'OFF'
}
};
const LANG_CODE = navigator.language.startsWith('zh') ? 'zh' : 'en';
const t = (key, params = {}) => {
let str = LANG[LANG_CODE][key] || key;
for (const [k, v] of Object.entries(params)) str = str.replace(`{${k}}`, v);
return str;
};
// = 設定項目定義 / Settings Definitions =
const CONFIG_ITEMS = {
MAX_PINNED: { default: CONFIG.MAX_PINNED, type: 'int', min: 1, max: 10, descZh: LANG.zh.descMaxPinned, descEn: LANG.en.descMaxPinned },
LIST_COUNT: { default: CONFIG.LIST_COUNT, type: 'int', min: 1, max: 10, descZh: LANG.zh.descListCount, descEn: LANG.en.descListCount },
PORTRAIT_MAX_COLUMNS: { default: CONFIG.PORTRAIT_MAX_COLUMNS, type: 'int', min: 1, max: 6, descZh: LANG.zh.descPortraitMaxCols, descEn: LANG.en.descPortraitMaxCols },
LANDSCAPE_COLUMN_CONFIG: { default: [...CONFIG.LANDSCAPE_COLUMN_CONFIG], type: 'array', descZh: LANG.zh.descLandscapeConfig, descEn: LANG.en.descLandscapeConfig },
PORTRAIT_HEIGHT_THRESHOLD: { default: CONFIG.PORTRAIT_HEIGHT_THRESHOLD, type: 'float', min: 1.0, max: 2.0, step: 0.1, descZh: LANG.zh.descPortraitThreshold, descEn: LANG.en.descPortraitThreshold },
LANDSCAPE_ASPECT_RATIO_THRESHOLD: { default: CONFIG.LANDSCAPE_ASPECT_RATIO_THRESHOLD, type: 'float', min: 1.0, max: 3.0, step: 0.1, descZh: LANG.zh.descLandscapeAR, descEn: LANG.en.descLandscapeAR },
SCREEN_WIDTH_DEFAULT: { default: CONFIG.SCREEN_WIDTH_DEFAULT, type: 'int', min: 144, max: 3840, descZh: LANG.zh.descScreenWidth, descEn: LANG.en.descScreenWidth },
RESOLUTION_PRESETS: { default: [...CONFIG.RESOLUTION_PRESETS], type: 'preset', descZh: LANG.zh.descResolutionPresets, descEn: LANG.en.descResolutionPresets },
SPOOF_RESOLUTION_ENABLED: { default: CONFIG.SPOOF_RESOLUTION_ENABLED, type: 'toggle12', descZh: LANG.zh.descSpoofResolution, descEn: LANG.en.descSpoofResolution },
HOME_PINNED_BY_DEFAULT: { default: CONFIG.HOME_PINNED_BY_DEFAULT, type: 'toggle12', descZh: LANG.zh.descHomePinnedDefault, descEn: LANG.en.descHomePinnedDefault }
};
const SETTINGS_PREFIX = 'ytMulti_setting_';
// = 設定載入與儲存 / Settings Load & Save =
const loadSettings = () => {
for (const [key, cfg] of Object.entries(CONFIG_ITEMS)) {
const stored = localStorage.getItem(SETTINGS_PREFIX + key);
if (stored !== null) {
try {
CONFIG[key] = cfg.type === 'array' || cfg.type === 'preset' ? JSON.parse(stored) :
cfg.type === 'bool' || cfg.type === 'toggle12' ? stored === 'true' :
cfg.type === 'float' ? parseFloat(stored) : parseInt(stored, 10);
} catch (e) { CONFIG[key] = cfg.default; }
}
}
};
const generateStorageKeys = () => {
const k = {};
for (let i = 1; i <= CONFIG.LIST_COUNT; i++) k[`list${i}`] = `ytMulti_videoList${i}`;
return k;
};
loadSettings();
let STORAGE_LISTS = generateStorageKeys();
const saveSetting = (key, val) => {
const type = CONFIG_ITEMS[key].type;
localStorage.setItem(SETTINGS_PREFIX + key, (type === 'array' || type === 'preset') ? JSON.stringify(val) : String(val));
CONFIG[key] = val;
if (key === 'LIST_COUNT') {
STORAGE_LISTS = generateStorageKeys();
if (!STORAGE_LISTS[currentList]) {
currentList = Object.keys(STORAGE_LISTS)[0];
localStorage.setItem(STORAGE_CURRENT, currentList);
}
updateListButtonCount();
}
};
// = 輔助函數 / Helper Functions =
const isChatId = id => id.endsWith(CONFIG.CHAT_SUFFIX);
const isVideoId = id => id.endsWith(CONFIG.VIDEO_SUFFIX);
const isHomepage = id => id.startsWith('homepage');
const getBaseId = id => {
if (isChatId(id)) return id.slice(0, -CONFIG.CHAT_SUFFIX.length);
if (isVideoId(id)) return id.slice(0, -CONFIG.VIDEO_SUFFIX.length);
return id;
};
const getChatId = id => {
if (isChatId(id)) return id;
if (isVideoId(id)) return id.slice(0, -CONFIG.VIDEO_SUFFIX.length) + CONFIG.CHAT_SUFFIX;
return id + CONFIG.CHAT_SUFFIX;
};
const getVideoId = id => {
if (isChatId(id)) return id.slice(0, -CONFIG.CHAT_SUFFIX.length);
if (isVideoId(id)) return id.slice(0, -CONFIG.VIDEO_SUFFIX.length);
return id;
};
const isFullUrl = id => id.startsWith('http');
const getVideoIdFromUrl = url => {
const m1 = url.match(/[?&]v=([A-Za-z0-9_-]{11})/);
if (m1) return m1[1];
const m2 = url.match(/youtu\.be\/([A-Za-z0-9_-]{11})/);
return m2 ? m2[1] : null;
};
const getListIdFromUrl = url => {
const m = url.match(/[?&]list=([^&]+)/);
return m ? m[1] : null;
};
const isWatchPage = () => /\/watch[?\/]/.test(location.href) || /\/live[?\/]/.test(location.href);
const getPresetLabel = level => {
const r = CONFIG.RESOLUTION_LEVELS[level];
return r ? `${r[0]}P` : '?';
};
const formatPresets = arr => arr.map((l, i) => i === 0 ? CONFIG.SCREEN_WIDTH_DEFAULT : getPresetLabel(l)).join(',');
// = 儲存格式轉換 / Storage Format Migration =
const migrateStorageFormat = () => {
for (const key of Object.values(STORAGE_LISTS)) {
const raw = localStorage.getItem(key);
if (!raw) continue;
try {
const arr = JSON.parse(raw);
let changed = false;
const migrated = arr.map(id => {
if (id === 'homepage') { changed = true; return 'homepage_1'; }
if (isFullUrl(id)) {
changed = true;
const vid = getVideoIdFromUrl(id);
if (!vid) return id;
return id.includes('_chat') || id.includes('live_chat') ? vid + CONFIG.CHAT_SUFFIX : vid + CONFIG.VIDEO_SUFFIX;
}
if (!isVideoId(id) && !isChatId(id) && !isHomepage(id) && /^[A-Za-z0-9_-]{11}$/.test(id)) {
changed = true;
return id + CONFIG.VIDEO_SUFFIX;
}
return id;
});
if (changed) localStorage.setItem(key, JSON.stringify(migrated));
} catch (e) {}
}
};
migrateStorageFormat();
// = 其他儲存鍵值 / Other Storage Keys =
const STORAGE_POS = 'ytMulti_btnPos';
const STORAGE_MODE = 'ytMulti_openMode';
const STORAGE_CURRENT = 'ytMulti_currentList';
const STORAGE_PINNED_PREFIX = 'ytMulti_pinned_';
let currentList = localStorage.getItem(STORAGE_CURRENT) || 'list1';
if (!STORAGE_LISTS[currentList]) {
currentList = Object.keys(STORAGE_LISTS)[0];
localStorage.setItem(STORAGE_CURRENT, currentList);
}
const getCurrentDomainMode = () => CONFIG.DOMAIN_MODES.find(m => m.key === localStorage.getItem(CONFIG.DOMAIN_MODE_STORAGE_KEY)) || CONFIG.DOMAIN_MODES[0];
// = 設定選單 / Settings Menu =
if (typeof GM_registerMenuCommand !== 'undefined') GM_registerMenuCommand(t('settingsTitle'), openSettingsMenu);
function openSettingsMenu() {
loadSettings();
const items = Object.entries(CONFIG_ITEMS);
while (true) {
let menu = t('settingsPrompt');
items.forEach(([k, c], i) => {
const cur = CONFIG[k];
let curStr, defStr;
if (c.type === 'array' || c.type === 'preset') {
curStr = c.type === 'preset' ? formatPresets(cur) : JSON.stringify(cur);
defStr = c.type === 'preset' ? formatPresets(c.default) : JSON.stringify(c.default);
} else if (c.type === 'toggle12') {
curStr = cur ? `1 (${t('spoofOn')})` : `2 (${t('spoofOff')})`;
defStr = c.default ? `1 (${t('spoofOn')})` : `2 (${t('spoofOff')})`;
} else {
curStr = cur; defStr = c.default;
}
menu += `${i + 1}. ${k}\n ${LANG_CODE === 'zh' ? c.descZh : c.descEn}\n 當前/Current: ${curStr}\n 預設/Default: ${defStr}\n\n`;
});
const choice = prompt(menu + '0 = Exit', '0');
if (!choice || choice === '0') return;
const idx = parseInt(choice, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= items.length) { alert(t('settingsInvalid')); continue; }
const [key, cfg] = items[idx];
const cur = CONFIG[key];
let curStr, defStr;
if (cfg.type === 'array' || cfg.type === 'preset') {
curStr = cfg.type === 'preset' ? formatPresets(cur) : JSON.stringify(cur);
defStr = cfg.type === 'preset' ? formatPresets(cfg.default) : JSON.stringify(cfg.default);
} else if (cfg.type === 'toggle12') {
curStr = cur ? `1 (${t('spoofOn')})` : `2 (${t('spoofOff')})`;
defStr = cfg.default ? `1 (${t('spoofOn')})` : `2 (${t('spoofOff')})`;
} else {
curStr = cur; defStr = cfg.default;
}
const desc = LANG_CODE === 'zh' ? cfg.descZh : cfg.descEn;
const input = prompt(t('settingsValuePrompt', { current: curStr, default: defStr, desc }), '0');
if (!input || input === '0') continue;
try {
let val;
if (cfg.type === 'array') {
val = input.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
if (!val.length) throw 'E';
} else if (cfg.type === 'preset') {
val = input.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n >= 1 && n <= 8);
if (!val.length || val.length !== 5) throw 'E';
} else if (cfg.type === 'toggle12') {
val = input === '1';
} else if (cfg.type === 'float') {
val = parseFloat(input); if (isNaN(val) || val < cfg.min || val > cfg.max) throw 'E';
} else {
val = parseInt(input, 10); if (isNaN(val) || val < cfg.min || val > cfg.max) throw 'E';
}
saveSetting(key, val); alert(t('settingsSaved')); updateListButtonCount();
} catch (e) { alert(t('settingsInvalid')); }
}
}
// = 懸浮面板 / Floating 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);overflow:hidden;white-space:nowrap;transition:opacity 0.2s;pointer-events:auto;`;
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'; }
let isDragging = false;
panel.addEventListener('mousedown', e => {
e.preventDefault(); isDragging = true;
const startX = e.clientX, startY = e.clientY, rect = panel.getBoundingClientRect();
let hasMoved = false;
const onMove = ev => { panel.style.top = rect.top + ev.clientY - startY + 'px'; panel.style.left = rect.left + ev.clientX - startX + 'px'; hasMoved = true; };
const onUp = () => { isDragging = false; 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, { passive: true }); window.addEventListener('mouseup', onUp, { passive: true });
}, { passive: true });
// = 按鈕樣式 / Button Styles =
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);display:flex;align-items:center;justify-content:center;`;
btn.addEventListener('mouseover', () => { if (btn.style.background !== '#00aa00' && btn.style.background !== 'rgb(0, 170, 0)') btn.style.background = '#cc0000'; });
btn.addEventListener('mouseout', () => { if (btn.style.background !== '#00aa00' && btn.style.background !== 'rgb(0, 170, 0)') btn.style.background = '#ff0000'; });
return btn;
}
function createStateButton(text, isEnabled) {
const btn = document.createElement('button'); btn.textContent = text;
const base = isEnabled ? '#00aa00' : '#ff0000';
btn.style.cssText = `padding:6px 12px;height:36px;border:none;border-radius:6px;background:${base};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);display:flex;align-items:center;justify-content:center;`;
btn.addEventListener('mouseover', () => { const c = btn.style.background; btn.style.background = (c !== '#00aa00' && c !== 'rgb(0, 170, 0)') ? '#cc0000' : '#008800'; });
btn.addEventListener('mouseout', () => { const c = btn.style.background; btn.style.background = (c === '#008800' || c === 'rgb(0, 136, 0)') ? '#00aa00' : ((c !== '#00aa00' && c !== 'rgb(0, 170, 0)') ? '#ff0000' : c); });
return btn;
}
// = 面板按鈕初始化 / Panel Buttons Init =
const playBtn = createStyledButton(t('playIcon'));
const modeBtn = createStyledButton(t('modeCurrentTab'));
const listBtn = createStyledButton(`${t('list')}1`);
const domainBtn = createStyledButton(getCurrentDomainMode().label);
let isAddButtonEnabled = localStorage.getItem(CONFIG.ADD_BUTTON_ENABLED_STORAGE_KEY) === 'true';
const addButtonToggle = createStateButton(t('addButton'), isAddButtonEnabled);
const otherButtons = [modeBtn, listBtn, domainBtn, addButtonToggle];
panel.append(playBtn, modeBtn, listBtn, domainBtn, addButtonToggle);
const toggleAddButton = () => { isAddButtonEnabled = !isAddButtonEnabled; localStorage.setItem(CONFIG.ADD_BUTTON_ENABLED_STORAGE_KEY, String(isAddButtonEnabled)); updateAddButtonState(); isAddButtonEnabled ? startObservingVideos() : stopObservingVideos(); };
const updateAddButtonState = () => { const base = isAddButtonEnabled ? '#00aa00' : '#ff0000'; addButtonToggle.style.background = base; addButtonToggle.onmouseover = () => { addButtonToggle.style.background = isAddButtonEnabled ? '#008800' : '#cc0000'; }; addButtonToggle.onmouseout = () => { addButtonToggle.style.background = base; }; };
addButtonToggle.addEventListener('click', e => { e.stopPropagation(); if (isCurrentWatchPage) { addToCurrentList(location.href); triggerSync(location.href); } else toggleAddButton(); });
domainBtn.addEventListener('click', () => { const cur = getCurrentDomainMode(), idx = CONFIG.DOMAIN_MODES.findIndex(m => m.key === cur.key); const next = CONFIG.DOMAIN_MODES[(idx + 1) % CONFIG.DOMAIN_MODES.length]; localStorage.setItem(CONFIG.DOMAIN_MODE_STORAGE_KEY, next.key); domainBtn.textContent = next.label; });
// = 面板折疊邏輯 / Panel Collapse Logic =
let keepPanelExpanded = false, panelHovered = false;
const collapsePanel = () => { if (isDragging || keepPanelExpanded) return; playBtn.textContent = t('playIcon'); otherButtons.forEach(b => b.style.display = 'none'); };
const expandPanel = () => { if (isDragging) return; playBtn.textContent = t('play'); otherButtons.forEach(b => b.style.display = 'flex'); updateListButtonCount(); };
const resetPanelExpandState = () => { keepPanelExpanded = false; if (!panelHovered && !isCurrentWatchPage) collapsePanel(); };
collapsePanel();
const updateListButtonCount = () => { const k = STORAGE_LISTS[currentList]; if (!k) return; const c = JSON.parse(localStorage.getItem(k) || '[]').length; listBtn.textContent = `${t('list')}${currentList.replace('list', '')} (${c})`; };
// = 頁面狀態偵測 / Page State Detection =
let isCurrentWatchPage = isWatchPage();
let urlPollingId = null, wprObserver = null, wprDetected = false;
const stopUrlPolling = () => { if (urlPollingId) { clearInterval(urlPollingId); urlPollingId = null; } };
const setupNavigationObserver = () => {
wprObserver = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'youtube-wpr') {
const v = document.documentElement.getAttribute('youtube-wpr');
const isW = v !== null && v !== '';
if (isW !== isCurrentWatchPage) { isCurrentWatchPage = isW; isW ? setupWatchPageMode() : setupHomePageMode(); }
if (!wprDetected) { wprDetected = true; stopUrlPolling(); }
}
}
});
wprObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['youtube-wpr'] });
let lastHref = location.href;
urlPollingId = setInterval(() => { if (wprDetected) { stopUrlPolling(); return; } if (location.href !== lastHref) { lastHref = location.href; updatePageContext(); } }, CONFIG.POLLING_INTERVAL);
};
const updatePageContext = () => { const was = isCurrentWatchPage; isCurrentWatchPage = isWatchPage(); if (was !== isCurrentWatchPage) isCurrentWatchPage ? setupWatchPageMode() : setupHomePageMode(); if (isCurrentWatchPage) { panel.style.opacity = '0'; if (addButtonToggle.textContent !== t('addIcon')) { addButtonToggle.textContent = t('addIcon'); addButtonToggle.style.fontSize = '18px'; } } };
const setupWatchPageMode = () => { panel.style.opacity = '0'; addButtonToggle.textContent = t('addIcon'); addButtonToggle.style.fontSize = '18px'; addButtonToggle.style.background = '#00aa00'; addButtonToggle.onmouseover = () => { addButtonToggle.style.background = '#008800'; }; addButtonToggle.onmouseout = () => { addButtonToggle.style.background = '#00aa00'; }; updateListButtonCount(); collapsePanel(); stopObservingVideos(); };
const setupHomePageMode = () => { panel.style.opacity = '1'; addButtonToggle.textContent = t('addButton'); addButtonToggle.style.fontSize = '13px'; updateAddButtonState(); updateListButtonCount(); collapsePanel(); if (isAddButtonEnabled) startObservingVideos(); };
panel.addEventListener('mouseenter', () => { panelHovered = true; panel.style.opacity = '1'; expandPanel(); }, { passive: true });
panel.addEventListener('mouseleave', () => { panelHovered = false; if (!panel.matches(':hover')) { panel.style.opacity = isCurrentWatchPage ? '0' : '1'; isCurrentWatchPage ? collapsePanel() : resetPanelExpandState(); } }, { passive: true });
modeBtn.addEventListener('click', () => { const cur = localStorage.getItem(STORAGE_MODE) || 'current_tab'; const nxt = cur === 'current_tab' ? 'new_tab' : cur === 'new_tab' ? 'new_window' : 'current_tab'; localStorage.setItem(STORAGE_MODE, nxt); modeBtn.textContent = nxt === 'current_tab' ? t('modeCurrentTab') : nxt === 'new_tab' ? t('modeNewTab') : t('modeNewWindow'); });
listBtn.addEventListener('click', () => {
const names = Object.keys(STORAGE_LISTS);
const idx = names.indexOf(currentList);
currentList = names[(idx + 1) % names.length];
localStorage.setItem(STORAGE_CURRENT, currentList);
updateListButtonCount();
});
// = 跨頁同步 / Cross-tab Sync =
const setupSyncListener = () => { window.addEventListener('storage', e => { if (e.key === CONFIG.SYNC_EVENT_KEY) { try { const d = JSON.parse(e.newValue); if (d.listKey === currentList && d.type === 'videoAdded') { const k = STORAGE_LISTS[currentList], ids = JSON.parse(localStorage.getItem(k) || '[]'); if (d.videoId && !ids.includes(d.videoId)) { ids.push(d.videoId); localStorage.setItem(k, JSON.stringify(ids)); updateListButtonCount(); } } } catch (err) {} } }); };
const triggerSync = id => { try { localStorage.setItem(CONFIG.SYNC_EVENT_KEY, JSON.stringify({ type: 'videoAdded', listKey: currentList, videoId: id, timestamp: Date.now() })); } catch (e) {} };
// = 啟動多播放器 / Launch Multi-Player =
const openMultiPlayer = () => {
const k = STORAGE_LISTS[currentList];
let ids = JSON.parse(localStorage.getItem(k) || '[]');
const pinnedK = STORAGE_PINNED_PREFIX + currentList;
let pinned = JSON.parse(localStorage.getItem(pinnedK) || '[]');
if (!ids || ids.length === 0) {
ids = ['homepage_1'];
localStorage.setItem(k, JSON.stringify(ids));
updateListButtonCount();
if (CONFIG.HOME_PINNED_BY_DEFAULT && !pinned.includes('homepage_1')) {
pinned.push('homepage_1');
}
}
const url = makeBlobPage(ids, currentList, pinned, getCurrentDomainMode().domain);
const mode = localStorage.getItem(STORAGE_MODE) || 'current_tab';
const cleanupBlob = () => { try { URL.revokeObjectURL(url); } catch(e) {} };
if (mode === 'current_tab') { location.href = url; setTimeout(cleanupBlob, 3000); }
else if (mode === 'new_window') { window.open(url, '_blank', 'width=800,height=600,scrollbars=no,resizable=yes'); setTimeout(cleanupBlob, 2000); }
else { window.open(url, '_blank'); setTimeout(cleanupBlob, 2000); }
};
playBtn.addEventListener('click', openMultiPlayer);
// = 自動啟動檢查 / Auto-launch Check =
const checkAutoLaunch = () => {
const params = new URLSearchParams(window.location.search);
const autoKey = params.get('ytMulti_auto');
if (autoKey && STORAGE_LISTS[autoKey]) {
currentList = autoKey;
localStorage.setItem(STORAGE_CURRENT, currentList);
updateListButtonCount();
history.replaceState(null, '', window.location.pathname);
setTimeout(openMultiPlayer, 400);
}
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', checkAutoLaunch);
else checkAutoLaunch();
// = 添加到清單 / Add to List =
const addToCurrentList = id => {
const k = STORAGE_LISTS[currentList], ids = JSON.parse(localStorage.getItem(k) || '[]');
let normalizedId = id;
if (isFullUrl(id)) {
const vid = getVideoIdFromUrl(id);
if (vid) normalizedId = id.includes('_chat') || id.includes('live_chat') ? vid + CONFIG.CHAT_SUFFIX : vid + CONFIG.VIDEO_SUFFIX;
} else if (!isVideoId(id) && !isChatId(id) && !isHomepage(id) && /^[A-Za-z0-9_-]{11}$/.test(id)) {
normalizedId = id + CONFIG.VIDEO_SUFFIX;
}
if (normalizedId && !ids.includes(normalizedId)) {
ids.push(normalizedId);
localStorage.setItem(k, JSON.stringify(ids));
updateListButtonCount();
triggerSync(normalizedId);
if (!isCurrentWatchPage) { keepPanelExpanded = true; expandPanel(); }
return true;
}
return false;
};
// = 影片觀察器 / Video Observer =
let videoObserver = null, isObserving = false, styleTag = null, processedElements = new Set();
const getVideoContainerSelector = () => location.href.includes('/playlist?list=') ? 'ytd-playlist-video-renderer ytd-thumbnail' : 'ytd-thumbnail, ytd-playlist-thumbnail, ytd-grid-video-renderer, ytd-video-renderer, ytd-compact-video-renderer, ytd-rich-item-renderer, ytd-rich-grid-media, ytd-playlist-video-renderer';
const startObservingVideos = () => { if (isObserving || !isAddButtonEnabled || isCurrentWatchPage) return; isObserving = true; if (!styleTag) { styleTag = document.createElement('style'); styleTag.textContent = `.ytMulti-add-btn{position:absolute;top:8px;left:8px;width:42px;height:42px;background:rgba(0,0,0,0.8);color:white;border:none;border-radius:50%;cursor:pointer;font-size:31px;display:none;z-index:10000;box-shadow:0 2px 6px rgba(0,0,0,0.4);align-items:center;justify-content:center}.ytMulti-video-hover .ytMulti-add-btn{display:flex}`; document.head.appendChild(styleTag); } videoObserver = new MutationObserver(mutations => { const nodes = []; for (const m of mutations) if (m.type === 'childList') for (const n of m.addedNodes) if (n.nodeType === Node.ELEMENT_NODE) nodes.push(n); if (nodes.length) requestAnimationFrame(() => nodes.forEach(processNode)); }); videoObserver.observe(document.body, { childList: true, subtree: true }); processNode(document.body); setTimeout(() => processNode(document.body), 500); };
const processNode = node => { if (!node || !node.querySelectorAll) return; const selector = getVideoContainerSelector(); const targets = node.matches && node.matches(selector) ? [node] : []; targets.push(...node.querySelectorAll(selector)); for (const el of targets) { const id = el.querySelector('a[href*="/watch?"]')?.href; if (id && !processedElements.has(id)) { processedElements.add(id); addButtonsToContainer(el); } } };
const stopObservingVideos = () => { isObserving = false; document.querySelectorAll('.ytMulti-add-btn').forEach(b => b.remove()); document.querySelectorAll('[data-ytMultiProcessed]').forEach(el => { delete el.dataset.ytMultiProcessed; processedElements.delete(el.dataset.videoUrl); }); if (styleTag) { styleTag.remove(); styleTag = null; } if (videoObserver) { videoObserver.disconnect(); videoObserver = null; } };
const addButtonsToContainer = el => { if (el.querySelector('.ytMulti-add-btn')) return; const btn = document.createElement('button'); btn.className = 'ytMulti-add-btn'; btn.textContent = '+'; btn.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); let vid = el.querySelector('a[href*="/watch?"]')?.href; if (!vid) { const ep = el.querySelector('[data-endpoint]'); if (ep) try { const d = JSON.parse(ep.getAttribute('data-endpoint')); if (d?.videoId) vid = 'https://www.youtube.com/watch?v=' + d.videoId; } catch (e) {} } if (vid) addToCurrentList(vid); }, { passive: true }); el.style.position = 'relative'; el.appendChild(btn); el.addEventListener('mouseenter', () => el.classList.add('ytMulti-video-hover'), { passive: true }); el.addEventListener('mouseleave', () => el.classList.remove('ytMulti-video-hover'), { passive: true }); };
// = 生成 Blob 頁面 / Generate Blob Page =
const makeBlobPage = (ids, listKey, initialPinnedIds = [], domainMode = 'www.youtube.com') => {
const nonce = document.querySelector('script[nonce]')?.nonce || '';
const idWithOrder = ids.map((id, i) => ({ id, order: (i * 2) + 1 }));
const ytParams = 'enablejsapi=1&autoplay=1&rel=0&fs=1&playsinline=1&iv_load_policy=3&cc_load_policy=0&controls=1&vq=hd1080';
const blobParams = {
MAX_PINNED: CONFIG.MAX_PINNED,
PORTRAIT_HEIGHT_THRESHOLD: CONFIG.PORTRAIT_HEIGHT_THRESHOLD,
LANDSCAPE_ASPECT_RATIO_THRESHOLD: CONFIG.LANDSCAPE_ASPECT_RATIO_THRESHOLD,
LANDSCAPE_COLUMN_CONFIG: CONFIG.LANDSCAPE_COLUMN_CONFIG.join(','),
PORTRAIT_MAX_COLUMNS: CONFIG.PORTRAIT_MAX_COLUMNS,
ASPECT_RATIO_STANDARD: CONFIG.ASPECT_RATIO_STANDARD,
CHAT_SUFFIX: CONFIG.CHAT_SUFFIX,
VIDEO_SUFFIX: CONFIG.VIDEO_SUFFIX,
DOMAIN_MODE: domainMode,
SYNC_EVENT_KEY: CONFIG.SYNC_EVENT_KEY,
DEBOUNCE_MS: CONFIG.RESIZE_DEBOUNCE,
RESOLUTION_LEVELS: JSON.stringify(CONFIG.RESOLUTION_LEVELS),
RESOLUTION_PRESETS: JSON.stringify(CONFIG.RESOLUTION_PRESETS),
SCREEN_WIDTH: CONFIG.SCREEN_WIDTH_DEFAULT,
SPOOF_ENABLED: CONFIG.SPOOF_RESOLUTION_ENABLED,
LIST_COUNT: CONFIG.LIST_COUNT,
HOME_PINNED_BY_DEFAULT: CONFIG.HOME_PINNED_BY_DEFAULT,
YT_PARAMS: ytParams,
HOVER_TIMEOUT: CONFIG.HOVER_TIMEOUT,
idWithOrder: JSON.stringify(idWithOrder),
listKey: listKey,
STORAGE_LISTS: JSON.stringify(STORAGE_LISTS),
INITIAL_PINNED_IDS: JSON.stringify(initialPinnedIds),
PINNED_STORAGE_KEY: STORAGE_PINNED_PREFIX + listKey
};
const jsCode = `
(function(){
'use strict';
// = 常數宣告 / Constants =
const MAX_PINNED = ${blobParams.MAX_PINNED};
const PORTRAIT_HEIGHT_THRESHOLD = ${blobParams.PORTRAIT_HEIGHT_THRESHOLD};
const LANDSCAPE_ASPECT_RATIO_THRESHOLD = ${blobParams.LANDSCAPE_ASPECT_RATIO_THRESHOLD};
const LANDSCAPE_COLUMN_CONFIG = [${blobParams.LANDSCAPE_COLUMN_CONFIG}];
const PORTRAIT_MAX_COLUMNS = ${blobParams.PORTRAIT_MAX_COLUMNS};
const ASPECT_RATIO_STANDARD = ${blobParams.ASPECT_RATIO_STANDARD};
const CHAT_SUFFIX = '${blobParams.CHAT_SUFFIX}';
const VIDEO_SUFFIX = '${blobParams.VIDEO_SUFFIX}';
const DOMAIN_MODE = '${blobParams.DOMAIN_MODE}';
const SYNC_EVENT_KEY = '${blobParams.SYNC_EVENT_KEY}';
const DEBOUNCE_MS = ${blobParams.DEBOUNCE_MS};
const RESOLUTION_LEVELS = ${blobParams.RESOLUTION_LEVELS};
const RESOLUTION_PRESETS = ${blobParams.RESOLUTION_PRESETS};
const SCREEN_WIDTH = ${blobParams.SCREEN_WIDTH};
const SPOOF_ENABLED = ${blobParams.SPOOF_ENABLED};
const LIST_COUNT = ${blobParams.LIST_COUNT};
const HOME_PINNED_BY_DEFAULT = ${blobParams.HOME_PINNED_BY_DEFAULT};
const YT_PARAMS = '${blobParams.YT_PARAMS}';
const HOVER_TIMEOUT = ${blobParams.HOVER_TIMEOUT};
// = 首頁框架強制使用主域名,確保穩定載入 / Homepage always uses main domain =
const HOME_DOMAIN = 'www.youtube.com';
// = 資料結構 / Data Structures =
let idOrderMap = new Map(${blobParams.idWithOrder}.map(i => [i.id, i.order]));
const listKey = '${blobParams.listKey}';
const STORAGE_LISTS = ${blobParams.STORAGE_LISTS};
const INITIAL_PINNED_IDS = ${blobParams.INITIAL_PINNED_IDS};
const PINNED_STORAGE_KEY = '${blobParams.PINNED_STORAGE_KEY}';
const container = document.querySelector('.container');
let pinnedIds = INITIAL_PINNED_IDS;
let elementCache = new Map();
let layoutDirty = true;
let resizeTimer;
const hoverTimers = new Map();
// = 輔助函數 / Helper Functions =
const isChatId = id => id.endsWith(CHAT_SUFFIX);
const isVideoId = id => id.endsWith(VIDEO_SUFFIX);
const isHomepage = id => id.startsWith('homepage');
const getBaseId = id => {
if (isChatId(id)) return id.slice(0, -CHAT_SUFFIX.length);
if (isVideoId(id)) return id.slice(0, -VIDEO_SUFFIX.length);
return id;
};
const getChatId = id => {
if (isChatId(id)) return id;
if (isVideoId(id)) return id.slice(0, -VIDEO_SUFFIX.length) + CHAT_SUFFIX;
return id + CHAT_SUFFIX;
};
const getVideoId = id => {
if (isChatId(id)) return id.slice(0, -CHAT_SUFFIX.length);
if (isVideoId(id)) return id.slice(0, -VIDEO_SUFFIX.length);
return id;
};
const isFullUrl = id => id.startsWith('http');
const getVideoIdFromUrl = url => {
const m1 = url.match(/[?&]v=([A-Za-z0-9_-]{11})/);
if (m1) return m1[1];
const m2 = url.match(/youtu\\.be\\/([A-Za-z0-9_-]{11})/);
return m2 ? m2[1] : null;
};
const getListIdFromUrl = url => {
const m = url.match(/[?&]list=([^&]+)/);
return m ? m[1] : null;
};
// = 按鈕顯示控制 / Button Visibility Control =
const showButtons = wrapper => {
wrapper.querySelectorAll('.remove-btn,.pin-btn,.chat-toggle-btn,.add-chat-btn,.top-btn,.up-btn,.down-btn,.bottom-btn,.copy-btn,.copy-placeholder').forEach(b => b.style.display = 'flex');
};
const hideButtons = wrapper => {
wrapper.querySelectorAll('.remove-btn,.pin-btn,.chat-toggle-btn,.add-chat-btn,.top-btn,.up-btn,.down-btn,.bottom-btn,.copy-btn,.copy-placeholder').forEach(b => b.style.display = 'none');
};
const setupHoverLogic = wrapper => {
wrapper.addEventListener('mouseenter', e => {
clearTimeout(hoverTimers.get(wrapper));
showButtons(wrapper);
hoverTimers.set(wrapper, setTimeout(() => hideButtons(wrapper), HOVER_TIMEOUT));
});
wrapper.addEventListener('mouseleave', () => {
clearTimeout(hoverTimers.get(wrapper));
hoverTimers.delete(wrapper);
hideButtons(wrapper);
});
};
// = 佈局計算 / Layout Calculation =
const isLandscape = () => container.offsetWidth > container.offsetHeight * LANDSCAPE_ASPECT_RATIO_THRESHOLD;
const findBestPortraitColumns = (itemCount, availableW, availableH, threshold) => {
let best = 1, minO = Infinity;
for (let c = 1; c <= PORTRAIT_MAX_COLUMNS; c++) {
const r = Math.ceil(itemCount / c);
const h = (availableW / c) / ASPECT_RATIO_STANDARD;
const o = (r * h) / availableH;
if (o <= threshold) return c;
if (o < minO) { minO = o; best = c; }
}
return best;
};
const calculateLayout = () => {
if (!layoutDirty) return { cells: [] };
const W = container.offsetWidth, H = container.offsetHeight;
const all = Array.from(idOrderMap.entries()).sort((a, b) => a[1] - b[1]);
const pin = all.filter(([id]) => pinnedIds.includes(id));
const vis = all.filter(([id]) => !pinnedIds.includes(id));
const cells = [];
let y = 0;
pin.forEach(([id]) => {
const h = W / ASPECT_RATIO_STANDARD;
cells.push({ id, x: 0, y, w: W, h, isPinned: true });
y += h;
});
if (vis.length > 0) {
const aH = H - y;
if (aH > 0) {
const cc = isLandscape() ? (() => {
let c = 1;
for (let i = 0; i < LANDSCAPE_COLUMN_CONFIG.length; i++) {
if (vis.length >= LANDSCAPE_COLUMN_CONFIG[i]) c++;
else break;
}
return c;
})() : findBestPortraitColumns(vis.length, W, aH, PORTRAIT_HEIGHT_THRESHOLD);
const uW = W / cc, h = uW / ASPECT_RATIO_STANDARD;
for (let i = 0; i < vis.length; i++) {
const r = Math.floor(i / cc), col = i % cc;
cells.push({ id: vis[i][0], x: col * uW, y: y + r * h, w: uW, h: h });
}
}
}
layoutDirty = false;
return { cells };
};
const updateLayout = () => {
const { cells } = calculateLayout();
cells.forEach(c => {
const w = elementCache.get(c.id);
if (w) {
w.style.transform = 'translate(' + Math.round(c.x) + 'px,' + Math.round(c.y) + 'px)';
w.style.width = Math.round(c.w) + 'px';
w.style.height = Math.round(c.h) + 'px';
w.style.zIndex = c.isPinned ? '100' : '1';
const scaler = w.querySelector('.video-scaler');
const ifr = w.querySelector('iframe');
if (scaler && ifr) {
if (isHomepage(c.id)) {
scaler.style.width = c.w + 'px';
scaler.style.height = c.h + 'px';
ifr.style.width = '100%';
ifr.style.height = '100%';
scaler.style.transform = 'scale(1)';
} else if (SPOOF_ENABLED && !isChatId(c.id)) {
let levelIdx = 0;
for (let i = 0; i < RESOLUTION_PRESETS.length; i++) {
if (c.w >= SCREEN_WIDTH / (i + 1)) { levelIdx = i; break; }
}
const lvl = RESOLUTION_PRESETS[levelIdx] || 5;
const [tw, th] = RESOLUTION_LEVELS[lvl];
scaler.style.width = tw + 'px';
scaler.style.height = th + 'px';
ifr.style.width = tw + 'px';
ifr.style.height = th + 'px';
const sx = c.w / tw, sy = c.h / th;
scaler.style.transform = 'scale(' + sx + ',' + sy + ')';
} else {
scaler.style.width = c.w + 'px';
scaler.style.height = c.h + 'px';
ifr.style.width = '100%';
ifr.style.height = '100%';
scaler.style.transform = 'scale(1)';
}
scaler.style.transformOrigin = 'top left';
}
}
});
};
const scheduleLayout = () => {
layoutDirty = true;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(updateLayout, DEBOUNCE_MS);
};
// = 順序調整 / Order Adjustment =
const swapOrder = (a, b) => {
const o1 = idOrderMap.get(a), o2 = idOrderMap.get(b);
if (o1 !== undefined && o2 !== undefined) {
idOrderMap.set(a, o2);
idOrderMap.set(b, o1);
save();
scheduleLayout();
}
};
const moveTop = id => {
const c = idOrderMap.get(id);
if (c === undefined || c === 0) return;
const mn = Math.min(...Array.from(idOrderMap.values()));
for (let [i, o] of idOrderMap) if (o >= mn && o < c) idOrderMap.set(i, o + 1);
idOrderMap.set(id, mn - 1);
save();
scheduleLayout();
};
const moveBottom = id => {
const c = idOrderMap.get(id);
if (c === undefined) return;
const mx = Math.max(...Array.from(idOrderMap.values()));
if (c === mx) return;
for (let [i, o] of idOrderMap) if (o > c && o <= mx) idOrderMap.set(i, o - 1);
idOrderMap.set(id, mx + 1);
save();
scheduleLayout();
};
const moveDown = id => {
const c = idOrderMap.get(id);
if (c === undefined) return;
let nId = null, nO = Infinity;
for (let [i, o] of idOrderMap) if (o > c && o < nO && !pinnedIds.includes(i)) { nO = o; nId = i; }
if (nId) swapOrder(id, nId);
};
// = 按鈕建立 / Button Creation =
const mkBtn = (cls, fn) => {
const d = document.createElement('div');
d.className = cls;
d.onclick = e => { e.stopPropagation(); fn(e); };
return d;
};
// = 影片/首頁框架建立 / Frame Creation =
const createVideo = (id, order) => {
const isHome = isHomepage(id);
const vid = isHome ? null : (isFullUrl(id) ? getVideoIdFromUrl(id) : getVideoId(id));
if (!isHome && (!vid || !/^[A-Za-z0-9_-]{11}$/.test(vid))) return null;
const w = document.createElement('div');
w.className = 'video-wrapper' + (isChatId(id) ? ' is-chat' : '');
w.dataset.id = id;
w.style.willChange = 'transform,opacity';
setupHoverLogic(w);
const isC = isChatId(id);
// = 首頁與聊天室框架使用主域名,一般影片跟隨 DOMAIN_MODE =
const frameDomain = (isHome || isC) ? HOME_DOMAIN : DOMAIN_MODE;
let src = isHome ? 'https://' + frameDomain + '/' : (isC ? 'https://' + frameDomain + '/live_chat?v=' + vid : 'https://' + frameDomain + '/embed/' + vid + '?' + YT_PARAMS + (isFullUrl(id) && getListIdFromUrl(id) ? '&list=' + getListIdFromUrl(id) : ''));
const scaler = document.createElement('div');
scaler.className = 'video-scaler';
scaler.style.cssText = 'will-change:transform;transform-origin:top left;background:#000;backface-visibility:hidden;-webkit-backface-visibility:hidden;image-rendering:crisp-edges;image-rendering:pixelated;';
const ifr = document.createElement('iframe');
ifr.style.cssText = 'display:block;border:0;outline:0;margin:0;padding:0;background:#000;transform:translateZ(0);-webkit-transform:translateZ(0);image-rendering:crisp-edges;image-rendering:pixelated;';
ifr.src = src;
ifr.allow = 'autoplay; encrypted-media; fullscreen';
// = 採用現代標準 flag,僅允許使用者手勢觸發頂層導航 =
ifr.sandbox = 'allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-pointer-lock allow-presentation allow-top-navigation-by-user-activation';
const del = mkBtn('remove-btn', () => {
ifr.src = 'about:blank';
w.remove();
elementCache.delete(id);
idOrderMap.delete(id);
const p = pinnedIds.indexOf(id);
if (p !== -1) pinnedIds.splice(p, 1);
save();
scheduleLayout();
});
const pin = mkBtn('pin-btn', () => {
const i = pinnedIds.indexOf(id);
if (i !== -1) pinnedIds.splice(i, 1);
else {
if (pinnedIds.length >= MAX_PINNED) pinnedIds.shift();
pinnedIds.push(id);
}
save();
scheduleLayout();
});
const chatT = mkBtn('chat-toggle-btn', () => {
if (isHome) return;
const p = isC ? (isFullUrl(id) ? id.replace(CHAT_SUFFIX, '') : getBaseId(id)) : (isFullUrl(id) ? id + CHAT_SUFFIX : getChatId(id));
if (idOrderMap.has(p)) return;
idOrderMap.delete(id);
idOrderMap.set(p, isC ? order - 1 : order + 1);
ifr.src = 'about:blank';
w.remove();
elementCache.delete(id);
const nw = createVideo(p, isC ? order - 1 : order + 1);
if (nw) { container.appendChild(nw); elementCache.set(p, nw); }
scheduleLayout();
});
const chatA = mkBtn('add-chat-btn', () => {
if (isHome) return;
const p = isC ? (isFullUrl(id) ? id.replace(CHAT_SUFFIX, '') : getBaseId(id)) : (isFullUrl(id) ? id + CHAT_SUFFIX : getChatId(id));
if (idOrderMap.has(p)) return;
idOrderMap.set(p, isC ? order - 1 : order + 1);
save();
const nw = createVideo(p, isC ? order - 1 : order + 1);
if (nw) { container.appendChild(nw); elementCache.set(p, nw); }
scheduleLayout();
});
const top = mkBtn('top-btn', () => { moveTop(id); });
const up = mkBtn('up-btn', () => {
const c = idOrderMap.get(id);
if (c === undefined) return;
let nId = null, nO = -Infinity;
for (let [oid, o] of idOrderMap) if (o < c && o > nO && !pinnedIds.includes(oid)) { nO = o; nId = oid; }
if (nId) swapOrder(id, nId);
});
const dn = mkBtn('down-btn', () => { moveDown(id); });
const bot = mkBtn('bottom-btn', () => { moveBottom(id); });
const copyCol = document.createElement('div');
copyCol.className = 'copy-col';
copyCol.style.cssText = 'position:absolute;top:6px;left:32px;display:flex;flex-direction:column;gap:4px;z-index:9999;';
for (let i = 1; i <= LIST_COUNT; i++) {
const targetKey = 'list' + i;
if (targetKey === listKey) {
const ph = document.createElement('div');
ph.className = 'copy-placeholder';
copyCol.appendChild(ph);
} else {
const cb = mkBtn('copy-btn', () => {
const tk = STORAGE_LISTS[targetKey];
const ti = JSON.parse(localStorage.getItem(tk) || '[]');
if (!ti.includes(id)) {
ti.push(id);
localStorage.setItem(tk, JSON.stringify(ti));
try {
localStorage.setItem(SYNC_EVENT_KEY, JSON.stringify({ type: 'videoAdded', listKey: targetKey, videoId: id, timestamp: Date.now() }));
} catch (e) {}
}
});
cb.dataset.num = i;
cb.style.cssText = 'position:relative;width:20px;height:20px;border-radius:3px;background:#4488ff;color:white;border:none;cursor:pointer;';
copyCol.appendChild(cb);
}
}
if (copyCol.children.length > 0) w.appendChild(copyCol);
scaler.appendChild(ifr);
w.append(scaler, del, pin, chatT, chatA, top, up, dn, bot);
elementCache.set(id, w);
return w;
};
// = 初始渲染 / Initial Render =
const frag = document.createDocumentFragment();
Array.from(idOrderMap.entries()).sort((a, b) => a[1] - b[1]).forEach(([id, order]) => {
const el = createVideo(id, order);
if (el) frag.appendChild(el);
});
container.appendChild(frag);
updateLayout();
// = 首頁按鈕邏輯 / Home Button Logic =
const homeBtn = document.createElement('div');
homeBtn.className = 'home-add-btn';
homeBtn.textContent = '+';
homeBtn.style.cssText = 'position:fixed;bottom:20px;right:20px;width:64px;height:64px;border-radius:50%;background:rgba(20,20,20,0.85);color:#fff;border:2px solid #555;font-size:36px;cursor:pointer;z-index:99999;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(0,0,0,0.5);transition:opacity 0.2s, transform 0.2s;opacity:0;pointer-events:none;transform:scale(0.8);user-select:none;';
const homeTrigger = document.createElement('div');
homeTrigger.style.cssText = 'position:fixed;bottom:0;right:0;width:180px;height:180px;z-index:99998;cursor:default;';
homeTrigger.appendChild(homeBtn);
document.body.appendChild(homeTrigger);
const toggleHomeBtn = (show) => {
homeBtn.style.opacity = show ? '1' : '0';
homeBtn.style.pointerEvents = show ? 'auto' : 'none';
homeBtn.style.transform = show ? 'scale(1)' : 'scale(0.8)';
};
homeTrigger.addEventListener('mouseenter', () => toggleHomeBtn(true));
homeTrigger.addEventListener('mouseleave', () => toggleHomeBtn(false));
homeBtn.addEventListener('click', () => {
const hId = 'homepage_' + Date.now();
const newOrder = Math.max(...Array.from(idOrderMap.values()), 0) + 2;
idOrderMap.set(hId, newOrder);
if (HOME_PINNED_BY_DEFAULT) {
if (pinnedIds.length >= MAX_PINNED) pinnedIds.shift();
pinnedIds.push(hId);
}
const w = createVideo(hId, newOrder);
if (w) {
container.appendChild(w);
elementCache.set(hId, w);
}
save();
scheduleLayout();
});
// = 事件監聽 / Event Listeners =
window.addEventListener('resize', scheduleLayout, { passive: true });
window.addEventListener('storage', e => {
if (e.key === SYNC_EVENT_KEY || e.key.includes(listKey)) {
try {
if (e.key === SYNC_EVENT_KEY) {
const d = JSON.parse(e.newValue);
if (d.listKey !== listKey || d.type !== 'videoAdded') return;
}
const ids = JSON.parse(localStorage.getItem(STORAGE_LISTS[listKey]) || '[]');
if (Array.from(idOrderMap.keys()).join(',') === ids.join(',')) return;
const ex = new Set(Array.from(container.querySelectorAll('.video-wrapper')).map(el => el.dataset.id));
const nm = new Map();
let od = 1;
const f = document.createDocumentFragment();
ids.forEach(id => {
if (!isChatId(id) && !isHomepage(id)) {
nm.set(id, od); od += 2;
if (!ex.has(id)) { const el = createVideo(id, nm.get(id)); if (el) f.appendChild(el); }
const ch = isFullUrl(id) ? id + CHAT_SUFFIX : getChatId(id);
if (ex.has(ch)) nm.set(ch, nm.get(id) + 1);
} else if (isChatId(id)) {
nm.set(id, od); od += 2;
if (!ex.has(id)) { const el = createVideo(id, nm.get(id)); if (el) f.appendChild(el); }
} else if (isHomepage(id)) {
nm.set(id, od); od += 2;
if (!ex.has(id)) { const el = createVideo(id, nm.get(id)); if (el) f.appendChild(el); }
}
});
Array.from(container.querySelectorAll('.video-wrapper')).forEach(el => {
if (!nm.has(el.dataset.id)) {
const tmp = elementCache.get(el.dataset.id);
if (tmp) { const i = tmp.querySelector('iframe'); if (i) i.src = 'about:blank'; }
elementCache.delete(el.dataset.id);
el.remove();
}
});
if (f.hasChildNodes()) container.appendChild(f);
idOrderMap = nm;
scheduleLayout();
} catch (err) {}
}
});
// = 儲存函數 / Save Function =
const save = () => {
const cleanArr = [...idOrderMap.entries()].sort((a, b) => a[1] - b[1]).map(e => e[0]);
localStorage.setItem(STORAGE_LISTS[listKey], JSON.stringify(cleanArr));
localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify(pinnedIds));
};
// = 清理函數 / Cleanup =
window.addEventListener('beforeunload', () => {
clearTimeout(resizeTimer);
elementCache.forEach(w => {
const i = w.querySelector('iframe');
if (i) i.src = 'about:blank';
});
elementCache.clear();
});
})();`;
const css = `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;background:#000;transition:transform 0.2s ease,opacity 0.2s ease;will-change:transform,opacity;backface-visibility:hidden;transform:translate3d(0,0,0);-webkit-transform:translate3d(0,0,0)}.video-scaler{will-change:transform;transform-origin:top left}.video-wrapper iframe{display:block;border:0;outline:0;margin:0;padding:0;background:#000;transform:translateZ(0);-webkit-transform:translateZ(0)}.remove-btn,.pin-btn,.chat-toggle-btn,.add-chat-btn,.top-btn,.up-btn,.down-btn,.bottom-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;left:6px;background:#ff4444}.pin-btn{top:30px;left:6px;background:#44aaff}.chat-toggle-btn{top:54px;left:6px;background:#888888}.add-chat-btn{top:78px;left:6px;background:#44aa44}.top-btn{top:102px;left:6px;background:#ffaa44}.up-btn{top:126px;left:6px;background:#88cc44}.down-btn{top:150px;left:6px;background:#44cc88}.bottom-btn{top:174px;left:6px;background:#aa44ff}.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}.chat-toggle-btn::after{content:'🔄';color:white;font-size:14px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;line-height:20px}.add-chat-btn::after{content:'+';color:white;font-size:16px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;line-height:20px}.top-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}.down-btn::after{content:'↓';color:white;font-size:16px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;line-height:20px}.bottom-btn::after{content:'⤓';color:white;font-size:14px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;line-height:20px}.copy-col{position:absolute;top:6px;left:32px;display:flex;flex-direction:column;gap:4px;z-index:9999}.copy-btn,.copy-placeholder{width:20px;height:20px;border-radius:3px;display:none}.copy-btn{position:relative;background:#4488ff;color:white;border:none;cursor:pointer}.copy-btn::after{content:attr(data-num);color:white;font-size:14px;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;line-height:20px}.copy-placeholder{background:transparent;cursor:default}`;
const nonceAttr = nonce ? ` nonce="${nonce}"` : '';
const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Multi-Player</title><style>${css}</style></head><body><div class="container"></div><script${nonceAttr}>${jsCode}<\/script></body></html>`;
return URL.createObjectURL(new Blob([html], { type: 'text/html' }));
};
// = 初始化 / Initialization =
updatePageContext();
setupSyncListener();
setupNavigationObserver();
if (!isCurrentWatchPage && isAddButtonEnabled) startObservingVideos();
window.addEventListener('beforeunload', () => { stopUrlPolling(); wprObserver?.disconnect(); stopObservingVideos(); });
})();