Play multiple videos or chatrooms simultaneously in new tabs or windows, with the ability to pin and enlarge any item on top. Features include bookmark launch, spoofed resolution (fine-tune auto quality), homepage frame (custom links), and more instant switching. Supports list switching within the blob page. Supports playlist URLs.
// ==UserScript==
// @name YouTube Multi-Player
// @name:zh-TW YouTube 多重播放器
// @namespace http://tampermonkey.net/
// @author Dxzy
// @version 8.15.0
// @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.youtube-nocookie.com/*
// @grant GM_info
// @grant GM_registerMenuCommand
// @license MIT
// @description:zh-TW 以新分頁或新視窗同時播放多個影片或聊天室,並可將任意項目放大置頂。書籤啟動、偽造解析度(微調自動畫質)、首頁框架(自訂連結)、更多即時切換。支援 Blob 頁內自由切換清單。支援播放清單連結。
// @description Play multiple videos or chatrooms simultaneously in new tabs or windows, with the ability to pin and enlarge any item on top. Features include bookmark launch, spoofed resolution (fine-tune auto quality), homepage frame (custom links), and more instant switching. Supports list switching within the blob page. Supports playlist URLs.
// ==/UserScript==
(function () {
'use strict';
if (
window.top !== window &&
/^(\/watch|\/embed|\/live_chat|\/accounts|\/studio)/.test(location.pathname)
)
return;
const IS_SUB_FRAME = window.top !== window;
let isAddButtonEnabled =
IS_SUB_FRAME || localStorage.getItem('ytMulti_addButtonEnabled') === 'true';
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: 1920,
RESOLUTION_PRESETS: [3, 4, 5, 6, 7],
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: false,
VIDEO_PINNED_BY_DEFAULT: false,
BTN_SIZE_SCALE: 100,
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',
VIDEO_DOMAIN_MODE_PREFIX: 'ytMulti_vidDomain_',
DEFAULT_DOMAIN_MODE: 'YT',
DOMAIN_MODES: [
{ key: 'YT', domain: 'www.youtube.com', label: 'YT' },
{ key: 'YU', domain: 'www.youtube-nocookie.com', label: 'YU' },
],
POLLING_INTERVAL: 2000,
RESIZE_DEBOUNCE: 100,
HOVER_TIMEOUT: 5000,
HOME_TARGET_URL: '',
BTN_LAYOUT: 3,
BTN_HIDE_TIMEOUT: 5000,
LIST_NAMES: '',
BUTTON_INJECTION_STATS: false,
};
const LANG = {
zh: {
play: '▶ 播放',
playIcon: '▶',
modeCurrentTab: '分頁',
modeNewTab: '分頁+',
modeNewWindow: '視窗',
list: '清單',
noVideos: '當前清單無影片',
addButton: '添加',
addIcon: '+',
settingsTitle: '🔧 修改設定值',
settingsPrompt: '請輸入要修改的設定項目編號(輸入 0 退出):\n',
settingsValuePrompt:
'\n當前值: {current}\n預設值: {default}\n說明: {desc}\n請輸入新值(留空即使用空白值,輸入 0 取消): ',
settingsSaved: '✅ 設定已儲存',
settingsCancelled: '❌ 已取消',
settingsInvalid: '⚠️ 輸入無效',
settingsExit: '👋 已退出',
descMaxPinned: '最多可同時置頂的影片數量',
descListCount: '影片清單總數量',
descPortraitMaxCols: '縱向螢幕最大欄數',
descLandscapeConfig: '橫向欄數閾值(逗號分隔)',
descPortraitThreshold: '縱向高度倍數閾值',
descLandscapeAR: '橫向寬高比閾值',
descScreenWidth: '偽造解析度時使用的螢幕寬度基準值',
descResolutionPresets:
'解析度檔位 [1.3840x2160 2.2560x1440 3.1920x1080 4.1280x720 5.854x480 6.640x360 7.432x240 8.256x144]',
descSpoofResolution: '偽造解析度開關:1=開啟 2=關閉',
descHomePinnedDefault: '新增首頁時預設置頂:1=開啟 2=關閉',
descVideoPinnedDefault: '新增影片時預設置頂:1=開啟 2=關閉',
descBtnSizeScale: '懸浮按鈕大小縮放比例(百分比,預設100,輸入200為兩倍大)',
descHomeTargetUrl: '首頁框架目標 YouTube 連結(留空則恢復預設首頁)',
descBtnLayout: '懸浮按鈕排列方式:1=直列左側 2=直列右側 3=橫行',
descBtnHideTimeout: '懸浮按鈕無操作後自動隱藏的時間(毫秒)',
descListNames: '自訂清單名稱(逗號分隔,如:主清單,音樂,遊戲)',
descBtnStats: '統計按鈕成功注入選擇器的次數(僅記憶體,重啟腳本清零):1=開啟 2=關閉',
descDefaultDomainMode: '新影片預設嵌入模式:1=YT(原生) 2=YU(nocookie)',
spoofOn: '開啟',
spoofOff: '關閉',
layout1: '直列左',
layout2: '直列右',
layout3: '橫行',
emptyVal: '(空白)',
btnToggleDomain: 'T',
btnToggleFrame: 'F',
exportAll: '匯入全部清單',
},
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',
settingsValuePrompt:
'\nCurrent: {current}\nDefault: {default}\nDesc: {desc}\nNew value (empty for blank, 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.3840x2160 2.2560x1440 3.1920x1080 4.1280x720 5.854x480 6.640x360 7.432x240 8.256x144]',
descSpoofResolution: 'Spoof resolution toggle: 1=Enable 2=Disable',
descHomePinnedDefault: 'Pin new homepages by default: 1=Enable 2=Disable',
descVideoPinnedDefault: 'Pin new videos by default: 1=Enable 2=Disable',
descBtnSizeScale: 'Floating button size scale (percent, default 100, 200 for 2x)',
descHomeTargetUrl: 'Target YouTube URL for home frame (empty restores default)',
descBtnLayout: 'Floating button layout: 1=Vertical-Left 2=Vertical-Right 3=Horizontal',
descBtnHideTimeout: 'Auto-hide timeout for floating buttons after inactivity (ms)',
descListNames: 'Custom list names (comma-separated, e.g., Main,Music,Gaming)',
descBtnStats: 'Track successful button injections per selector (memory only): 1=Enable 2=Disable',
descDefaultDomainMode: 'Default embed mode for new videos: 1=YT(native) 2=YU(nocookie)',
spoofOn: 'ON',
spoofOff: 'OFF',
layout1: 'V-Left',
layout2: 'V-Right',
layout3: 'Horizontal',
emptyVal: '(empty)',
btnToggleDomain: 'T',
btnToggleFrame: 'F',
exportAll: 'Import All Lists',
},
};
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;
};
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,
},
VIDEO_PINNED_BY_DEFAULT: {
default: CONFIG.VIDEO_PINNED_BY_DEFAULT,
type: 'toggle12',
descZh: LANG.zh.descVideoPinnedDefault,
descEn: LANG.en.descVideoPinnedDefault,
},
BTN_SIZE_SCALE: {
default: CONFIG.BTN_SIZE_SCALE,
type: 'int',
min: 50,
max: 300,
step: 10,
descZh: LANG.zh.descBtnSizeScale,
descEn: LANG.en.descBtnSizeScale,
},
HOME_TARGET_URL: {
default: '',
type: 'string',
descZh: LANG.zh.descHomeTargetUrl,
descEn: LANG.en.descHomeTargetUrl,
},
BTN_LAYOUT: {
default: 3,
type: 'int',
min: 1,
max: 3,
descZh: LANG.zh.descBtnLayout,
descEn: LANG.en.descBtnLayout,
},
BTN_HIDE_TIMEOUT: {
default: 5000,
type: 'int',
min: 1000,
max: 30000,
step: 500,
descZh: LANG.zh.descBtnHideTimeout,
descEn: LANG.en.descBtnHideTimeout,
},
LIST_NAMES: {
default: CONFIG.LIST_NAMES,
type: 'string',
descZh: LANG.zh.descListNames,
descEn: LANG.en.descListNames,
},
BUTTON_INJECTION_STATS: {
default: CONFIG.BUTTON_INJECTION_STATS,
type: 'toggle12',
descZh: LANG.zh.descBtnStats,
descEn: LANG.en.descBtnStats,
},
DEFAULT_DOMAIN_MODE: {
default: 'YT',
type: 'toggleYT',
descZh: LANG.zh.descDefaultDomainMode,
descEn: LANG.en.descDefaultDomainMode,
},
};
const SETTINGS_PREFIX = 'ytMulti_setting_';
const parseSettingValue = (stored, cfg) => {
try {
if (cfg.type === 'array' || cfg.type === 'preset') {
const val = JSON.parse(stored);
if (!Array.isArray(val)) return cfg.default;
if (cfg.type === 'preset')
return val.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8).slice(0, 5);
return val.filter((n) => Number.isInteger(n));
} else if (cfg.type === 'bool' || cfg.type === 'toggle12') {
return stored === 'true';
} else if (cfg.type === 'toggleYT') {
return stored === 'YU' ? 'YU' : 'YT';
} else if (cfg.type === 'float') {
const val = parseFloat(stored);
if (isNaN(val) || val < cfg.min || val > cfg.max) return cfg.default;
return val;
} else if (cfg.type === 'string') {
return stored;
} else {
const val = parseInt(stored, 10);
if (isNaN(val) || val < cfg.min || val > cfg.max) return cfg.default;
return val;
}
} catch (e) {
return cfg.default;
}
};
const loadSettings = () => {
for (const [key, cfg] of Object.entries(CONFIG_ITEMS)) {
const stored = localStorage.getItem(SETTINGS_PREFIX + key);
if (stored !== null) CONFIG[key] = parseSettingValue(stored, cfg);
}
};
const safeJSONParse = (str, fallback) => {
if (str === null || str === undefined || str === '') return fallback;
try {
return JSON.parse(str);
} catch (e) {
return fallback;
}
};
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 initializeStorage = () => {
for (let i = 1; i <= CONFIG.LIST_COUNT; i++) {
const listKey = `ytMulti_videoList${i}`;
if (localStorage.getItem(listKey) === null) localStorage.setItem(listKey, '[]');
const pinnedKey = `ytMulti_pinned_list${i}`;
if (localStorage.getItem(pinnedKey) === null) localStorage.setItem(pinnedKey, '[]');
}
};
initializeStorage();
const cleanupOrphanDomainModes = () => {
const prefix = CONFIG.VIDEO_DOMAIN_MODE_PREFIX;
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) {
const baseId = key.slice(prefix.length);
const mode = localStorage.getItem(key);
if (mode === 'YT') {
keysToRemove.push(key);
continue;
}
if (mode === 'YU') {
let exists = false;
for (let j = 1; j <= CONFIG.LIST_COUNT; j++) {
const listKey = 'ytMulti_videoList' + j;
const ids = safeJSONParse(localStorage.getItem(listKey), []);
if (ids.some((id) => getBaseId(id) === baseId)) {
exists = true;
break;
}
}
if (!exists) keysToRemove.push(key);
}
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
};
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('ytMulti_currentList', currentList);
}
if (typeof updateListButtonCount === 'function') updateListButtonCount();
}
};
const isChatId = (id) => id?.includes(CONFIG.CHAT_SUFFIX);
const isVideoId = (id) => id?.endsWith(CONFIG.VIDEO_SUFFIX);
const isHomepage = (id) => id?.startsWith('homepage');
const isFrameMode = (id) => id?.startsWith('frame:');
const getFrameVideoId = (id) => (isFrameMode(id) ? id.slice(6) : null);
const getBaseId = (id) => {
if (isChatId(id)) return id.split(CONFIG.CHAT_SUFFIX)[0];
if (isVideoId(id)) return id.slice(0, -CONFIG.VIDEO_SUFFIX.length);
if (isFrameMode(id)) return getFrameVideoId(id);
return id;
};
cleanupOrphanDomainModes();
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 getBaseId(id);
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 formatPresets = (arr) => {
const heights = arr.map((l) => CONFIG.RESOLUTION_LEVELS[l]?.[1] || '?');
const levels = arr.join(',');
return `${heights.join(',')} (${levels})`;
};
const normalizeVideoId = (id) => {
if (!id) return null;
if (
id.includes(CONFIG.CHAT_SUFFIX) ||
id.endsWith(CONFIG.VIDEO_SUFFIX) ||
id.startsWith('homepage') ||
isFrameMode(id)
)
return id;
if (id.startsWith('http')) {
const vid = getVideoIdFromUrl(id);
const listId = getListIdFromUrl(id);
if (vid && /^[A-Za-z0-9_-]{11}$/.test(vid)) {
const listPart = listId ? `_list_${listId}` : '';
return id.includes('_chat') || id.includes('live_chat')
? vid + listPart + CONFIG.CHAT_SUFFIX
: vid + listPart + CONFIG.VIDEO_SUFFIX;
}
return null;
}
if (/^[A-Za-z0-9_-]{11}$/.test(id)) return id + CONFIG.VIDEO_SUFFIX;
return null;
};
const formatSettingDisplay = (cfg, val, key = '') => {
if (cfg.type === 'array' || cfg.type === 'preset')
return cfg.type === 'preset' ? formatPresets(val) : JSON.stringify(val);
if (cfg.type === 'toggle12') return val ? `1 (${t('spoofOn')})` : `2 (${t('spoofOff')})`;
if (cfg.type === 'toggleYT') return val === 'YU' ? `2 (YU)` : `1 (YT)`;
if (key === 'BTN_LAYOUT' && cfg.type === 'int') {
const map = { 1: t('layout1'), 2: t('layout2'), 3: t('layout3') };
return `${val} (${map[val] || val})`;
}
if (cfg.type === 'string' && (val === '' || val === null || val === undefined))
return t('emptyVal');
return String(val);
};
const getListNamesArray = () => {
const raw = CONFIG.LIST_NAMES || '';
const names = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s);
const res = [];
for (let i = 0; i < CONFIG.LIST_COUNT; i++)
res.push(names[i] || `${LANG_CODE === 'zh' ? '清單' : 'List'}${i + 1}`);
return res;
};
const getListDisplayName = () => {
const idx = parseInt(currentList.replace('list', ''), 10) - 1;
return getListNamesArray()[idx] || currentList;
};
const getVideoDomainMode = (videoId) => {
const baseId = getBaseId(videoId);
const stored = localStorage.getItem(CONFIG.VIDEO_DOMAIN_MODE_PREFIX + baseId);
return stored || CONFIG.DEFAULT_DOMAIN_MODE;
};
const setVideoDomainMode = (videoId, mode) => {
const baseId = getBaseId(videoId);
if (mode === 'YT' || mode === 'YU')
localStorage.setItem(CONFIG.VIDEO_DOMAIN_MODE_PREFIX + baseId, mode);
};
const cleanupVideoDomainMode = (videoId, listCount = CONFIG.LIST_COUNT) => {
const baseId = getBaseId(videoId);
let exists = false;
for (let i = 1; i <= listCount; i++) {
const key = 'ytMulti_videoList' + i;
const ids = safeJSONParse(localStorage.getItem(key), []);
if (ids.some((id) => getBaseId(id) === baseId)) {
exists = true;
break;
}
}
if (!exists) localStorage.removeItem(CONFIG.VIDEO_DOMAIN_MODE_PREFIX + baseId);
};
const STORAGE_POS = 'ytMulti_btnPos',
STORAGE_MODE = 'ytMulti_openMode',
STORAGE_CURRENT = 'ytMulti_currentList',
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];
const addToCurrentList = (id) => {
const k = STORAGE_LISTS[currentList],
normalizedId = normalizeVideoId(id);
if (!normalizedId) return false;
const ids = safeJSONParse(localStorage.getItem(k), []);
if (ids.includes(normalizedId)) return false;
ids.push(normalizedId);
localStorage.setItem(k, JSON.stringify(ids));
if (CONFIG.VIDEO_PINNED_BY_DEFAULT && !isHomepage(normalizedId) && !isFrameMode(normalizedId)) {
const pinnedK = STORAGE_PINNED_PREFIX + currentList;
const pinned = safeJSONParse(localStorage.getItem(pinnedK), []);
if (!pinned.includes(normalizedId)) {
if (pinned.length >= CONFIG.MAX_PINNED) pinned.pop();
pinned.push(normalizedId);
localStorage.setItem(pinnedK, JSON.stringify(pinned));
}
}
if (typeof updateListButtonCount === 'function') updateListButtonCount();
try {
localStorage.setItem(
CONFIG.SYNC_EVENT_KEY,
JSON.stringify({
type: 'videoAdded',
listKey: currentList,
videoId: normalizedId,
timestamp: Date.now(),
}),
);
} catch (e) {}
return true;
};
let videoObserver = null,
isObserving = false,
styleTag = null,
pollTimer = null;
const PROCESSED_ATTR = 'data-ytmulti-processed';
const TARGET_SELECTORS = [
'ytd-rich-item-renderer',
'ytd-thumbnail',
'ytd-playlist-thumbnail',
'ytd-playlist-video-renderer',
'yt-lockup-view-model',
];
const getVideoContainerSelector = () =>
location.href.includes('/playlist?list=')
? 'ytd-playlist-video-renderer ytd-thumbnail'
: TARGET_SELECTORS.join(', ');
const injectionStats = {};
TARGET_SELECTORS.forEach((s) => (injectionStats[s] = 0));
const startObservingVideos = () => {
if (!IS_SUB_FRAME && !isAddButtonEnabled) return;
if (!IS_SUB_FRAME && 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);
}
if (!videoObserver) {
videoObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList')
for (const n of m.addedNodes) if (n.nodeType === Node.ELEMENT_NODE) processNode(n);
}
});
videoObserver.observe(document.body, { childList: true, subtree: true });
}
if (pollTimer) clearInterval(pollTimer);
const checkAndScan = () => {
if (document.querySelector('ytd-browse, ytd-rich-grid-renderer, ytd-playlist-sidebar-renderer')) {
processNode(document.body);
clearInterval(pollTimer);
pollTimer = null;
}
};
pollTimer = setInterval(checkAndScan, 300);
checkAndScan();
};
const processNode = (node) => {
if (!node || !node.querySelectorAll) return;
const selector = getVideoContainerSelector();
if (node.matches && node.matches(selector)) tryAddButton(node);
node.querySelectorAll(selector).forEach(tryAddButton);
};
const tryAddButton = (el) => {
if (el.hasAttribute(PROCESSED_ATTR)) return;
const vidLink = el.querySelector('a[href*="/watch?"]');
let hasVideo = false;
if (vidLink?.href) hasVideo = true;
else {
const ep = el.querySelector('[data-endpoint]');
if (ep) {
try {
const d = JSON.parse(ep.getAttribute('data-endpoint'));
if (d?.videoId) hasVideo = true;
} catch (e) {}
}
}
if (!hasVideo) return;
el.setAttribute(PROCESSED_ATTR, '1');
addButtonsToContainer(el);
if (CONFIG.BUTTON_INJECTION_STATS)
for (const sel of TARGET_SELECTORS) if (el.matches(sel)) injectionStats[sel]++;
if (!IS_SUB_FRAME && typeof window.onTargetFound === 'function') window.onTargetFound();
};
const stopObservingVideos = () => {
isObserving = false;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
document.querySelectorAll('.ytMulti-add-btn').forEach((b) => b.remove());
document.querySelectorAll(`[${PROCESSED_ATTR}]`).forEach((el) => el.removeAttribute(PROCESSED_ATTR));
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);
});
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 });
};
let isCurrentWatchPage = isWatchPage();
if (!IS_SUB_FRAME) {
let hasFoundTargets = false,
keepPanelExpanded = false,
panelHovered = false;
let urlPollingId = null,
wprObserver = null,
wprDetected = false;
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:none;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;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 },
);
const BTN_BASE_STYLE =
'padding:6px 12px;height:36px;border:none;border-radius:6px;color:white;cursor:pointer;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;';
const createPanelButton = (text, options = {}) => {
const {
isActive = false,
activeBg = '#00aa00',
activeHover = '#008800',
baseBg = '#ff0000',
baseHover = '#cc0000',
} = options;
const btn = document.createElement('button');
btn.textContent = text;
btn.dataset.state = isActive ? 'active' : 'inactive';
btn.style.cssText = `${BTN_BASE_STYLE}background:${isActive ? activeBg : baseBg};`;
const updateStyle = () => {
btn.style.background = btn.dataset.state === 'active' ? activeBg : baseBg;
};
btn.addEventListener('mouseover', () => {
btn.style.background = btn.dataset.state === 'active' ? activeHover : baseHover;
});
btn.addEventListener('mouseout', updateStyle);
return btn;
};
const playBtn = createPanelButton(t('playIcon'));
const modeBtn = createPanelButton(t('modeCurrentTab'));
const listBtn = createPanelButton(`${getListDisplayName()} (0)`);
const addButtonToggle = createPanelButton(t('addButton'), { isActive: isAddButtonEnabled });
const otherButtons = [modeBtn, listBtn, addButtonToggle];
panel.append(playBtn, modeBtn, listBtn, addButtonToggle);
const updateListButtonCount = () => {
const k = STORAGE_LISTS[currentList];
if (!k) return;
const c = safeJSONParse(localStorage.getItem(k), []).length;
listBtn.textContent = `${getListDisplayName()} (${c})`;
};
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 updatePanelVisibility = () => {
if (isCurrentWatchPage) {
panel.style.display = 'none';
collapsePanel();
} else {
panel.style.display = 'flex';
if (!panelHovered && !keepPanelExpanded) collapsePanel();
}
};
window.onTargetFound = () => {
if (!hasFoundTargets) hasFoundTargets = true;
};
panel.addEventListener(
'mouseenter',
() => {
panelHovered = true;
panel.style.display = 'flex';
expandPanel();
},
{ passive: true },
);
panel.addEventListener(
'mouseleave',
() => {
panelHovered = false;
updatePanelVisibility();
},
{ passive: true },
);
const toggleAddButton = () => {
isAddButtonEnabled = !isAddButtonEnabled;
localStorage.setItem(CONFIG.ADD_BUTTON_ENABLED_STORAGE_KEY, String(isAddButtonEnabled));
addButtonToggle.dataset.state = isAddButtonEnabled ? 'active' : 'inactive';
addButtonToggle.dispatchEvent(new MouseEvent('mouseout'));
isAddButtonEnabled ? startObservingVideos() : stopObservingVideos();
};
addButtonToggle.addEventListener('click', (e) => {
e.stopPropagation();
if (isCurrentWatchPage) addToCurrentList(location.href);
else toggleAddButton();
});
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();
});
const stopUrlPolling = () => {
if (urlPollingId) {
clearInterval(urlPollingId);
urlPollingId = null;
}
};
const applyPanelMode = (isWatch) => {
isCurrentWatchPage = isWatch;
if (isWatch) {
hasFoundTargets = false;
panel.style.display = 'none';
collapsePanel();
addButtonToggle.textContent = t('addIcon');
addButtonToggle.style.fontSize = '18px';
addButtonToggle.dataset.state = 'active';
stopObservingVideos();
} else {
addButtonToggle.textContent = t('addButton');
addButtonToggle.style.fontSize = '13px';
addButtonToggle.dataset.state = isAddButtonEnabled ? 'active' : 'inactive';
if (isAddButtonEnabled) startObservingVideos();
updatePanelVisibility();
}
addButtonToggle.dispatchEvent(new MouseEvent('mouseout'));
updateListButtonCount();
};
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) applyPanelMode(isW);
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;
applyPanelMode(isWatchPage());
}
}, CONFIG.POLLING_INTERVAL);
};
const setupSyncListener = () => {
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_CURRENT) {
currentList = e.newValue || 'list1';
if (typeof updateListButtonCount === 'function') updateListButtonCount();
}
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 = safeJSONParse(localStorage.getItem(k), []);
if (d.videoId && !ids.includes(d.videoId)) {
ids.push(d.videoId);
localStorage.setItem(k, JSON.stringify(ids));
updateListButtonCount();
}
}
} catch (err) {}
}
});
};
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) => {
menu += `${i + 1}. ${k}\n${LANG_CODE === 'zh' ? c.descZh : c.descEn}\n當前/Current: ${formatSettingDisplay(
c,
CONFIG[k],
k,
)}\n預設/Default: ${formatSettingDisplay(c, c.default, k)}\n`;
});
if (CONFIG.BUTTON_INJECTION_STATS) {
menu += '📊 按鈕注入統計 (本會話):\n';
for (const [sel, count] of Object.entries(injectionStats)) {
const shortSel = sel.length > 45 ? sel.slice(0, 42) + '...' : sel;
menu += ` ${shortSel}: ${count}\n`;
}
menu += '\n';
}
menu += `${items.length + 1}. 📤 ${LANG_CODE === 'zh' ? '匯出清單' : 'Export Lists'}\n${
items.length + 2
}. 📥 ${LANG_CODE === 'zh' ? '匯入清單' : 'Import Lists'}\n${items.length + 3}. 📥 ${t(
'exportAll',
)}\n0 = Exit`;
const choice = prompt(menu, '0');
if (!choice || choice === '0') return;
const idx = parseInt(choice, 10) - 1;
if (idx === items.length) {
let output = '';
const names = getListNamesArray();
const parts = [];
for (let i = 1; i <= CONFIG.LIST_COUNT; i++) {
const key = STORAGE_LISTS[`list${i}`];
const ids = safeJSONParse(localStorage.getItem(key), []);
if (ids.length > 0) {
parts.push(`${names[i - 1]}:${ids.join(',')}`);
}
}
output = parts.join(';');
prompt(
LANG_CODE === 'zh' ? '✅ 清單資料已生成,請複製以下內容:' : '✅ List data generated, please copy:',
output.trim(),
);
continue;
}
if (idx === items.length + 1) {
let listOptions = '';
for (let i = 1; i <= CONFIG.LIST_COUNT; i++)
listOptions += `${i}. ${getListNamesArray()[i - 1]}\n`;
const targetChoice = prompt(
`${LANG_CODE === 'zh' ? '請選擇要覆蓋的清單編號:' : 'Enter list number to overwrite:'}\n${listOptions}`,
'1',
);
const tIdx = parseInt(targetChoice, 10);
if (isNaN(tIdx) || tIdx < 1 || tIdx > CONFIG.LIST_COUNT) {
alert(t('settingsInvalid'));
continue;
}
const dataInput = prompt(
LANG_CODE === 'zh'
? '請貼上清單資料(格式:清單名:ID1,ID2 或直接貼上 ID 逗號分隔):'
: 'Paste list data (format: ListName:ID1,ID2 or just comma-separated IDs):',
'',
);
if (!dataInput || dataInput === '0') continue;
try {
let ids;
if (dataInput.includes(':')) {
ids = dataInput.split(':')[1].trim();
} else {
ids = dataInput.trim();
}
const parsedIds = ids
.split(',')
.map((s) => s.trim())
.filter((s) => s);
const key = STORAGE_LISTS[`list${tIdx}`];
localStorage.setItem(key, JSON.stringify(parsedIds));
if (currentList === `list${tIdx}`) updateListButtonCount();
alert(t('settingsSaved'));
} catch (e) {
alert(t('settingsInvalid'));
}
continue;
}
if (idx === items.length + 2) {
const dataInput = prompt(
LANG_CODE === 'zh'
? '請貼上全部清單資料(格式:清單名A:ID1,ID2;清單名B:ID3,ID4):'
: 'Paste all list data (format: ListA:ID1,ID2;ListB:ID3,ID4):',
'',
);
if (!dataInput || dataInput === '0') continue;
try {
const listParts = dataInput.split(';').filter((s) => s.trim());
const names = getListNamesArray();
let importCount = 0;
for (const part of listParts) {
const colonIdx = part.indexOf(':');
if (colonIdx === -1) continue;
const listName = part.slice(0, colonIdx).trim();
const idsStr = part.slice(colonIdx + 1).trim();
const parsedIds = idsStr
.split(',')
.map((s) => s.trim())
.filter((s) => s);
let targetIdx = names.findIndex((n) => n === listName);
if (targetIdx === -1) {
for (let i = 0; i < CONFIG.LIST_COUNT; i++) {
const key = STORAGE_LISTS[`list${i + 1}`];
const existingIds = safeJSONParse(localStorage.getItem(key), []);
if (existingIds.length === 0) {
targetIdx = i;
const newNames = [...names];
newNames[i] = listName;
saveSetting('LIST_NAMES', newNames.join(','));
break;
}
}
}
if (targetIdx !== -1) {
const key = STORAGE_LISTS[`list${targetIdx + 1}`];
localStorage.setItem(key, JSON.stringify(parsedIds));
if (currentList === `list${targetIdx + 1}`) updateListButtonCount();
importCount++;
}
}
alert(
LANG_CODE === 'zh'
? `✅ 成功匯入 ${importCount} 個清單`
: `✅ Successfully imported ${importCount} lists`,
);
} catch (e) {
alert(t('settingsInvalid'));
}
continue;
}
if (isNaN(idx) || idx < 0 || idx >= items.length) {
alert(t('settingsInvalid'));
continue;
}
const [key, cfg] = items[idx];
let defaultDisplay = cfg.default;
if (cfg.type === 'toggle12') defaultDisplay = cfg.default ? '1' : '2';
else if (cfg.type === 'toggleYT') defaultDisplay = cfg.default === 'YU' ? '2' : '1';
const input = prompt(
t('settingsValuePrompt', {
current: formatSettingDisplay(cfg, CONFIG[key], key),
default: formatSettingDisplay(cfg, cfg.default, key),
desc: LANG_CODE === 'zh' ? cfg.descZh : cfg.descEn,
}),
defaultDisplay,
);
if (input === null || 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 === 'toggleYT') {
val = input === '2' ? 'YU' : 'YT';
} else if (cfg.type === 'string') {
val = input.trim();
} 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'));
}
}
}
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';
const sharedHelpers = `
const isChatId = (id) => id && id.includes(CHAT_SUFFIX);
const isVideoId = (id) => id && id.endsWith(VIDEO_SUFFIX);
const isHomepage = (id) => id && id.startsWith('homepage');
const isFrameMode = (id) => id && id.startsWith('frame:');
const getFrameVideoId = (id) => (isFrameMode(id) ? id.slice(6) : null);
const getBaseId = (id) => {
if (isChatId(id)) return id.split(CHAT_SUFFIX)[0];
if (isVideoId(id)) return id.slice(0, -VIDEO_SUFFIX.length);
if (isFrameMode(id)) return getFrameVideoId(id);
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) => {
const base = getBaseId(id);
return base.split('_list_')[0];
};
const getPlaylistId = (id) => {
const base = getBaseId(id);
const idx = base.indexOf('_list_');
return idx !== -1 ? base.slice(idx + 6) : null;
};
const isFullUrl = (id) => id && id.startsWith('http');
const getVideoIdFromUrl = (url) => {
const m1 = url && url.match(/[?&]v=([A-Za-z0-9_-]{11})/);
if (m1) return m1[1];
const m2 = url && url.match(/youtu[.]be\\/([A-Za-z0-9_-]{11})/);
return m2 ? m2[1] : null;
};
const getListIdFromUrl = (url) => {
const m = url && url.match(/[?&]list=([^&]+)/);
return m ? m[1] : null;
};
`.trim();
const safeHomeUrl = CONFIG.HOME_TARGET_URL.replace(/'/g, "\\'");
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,
VIDEO_DOMAIN_MODE_PREFIX: CONFIG.VIDEO_DOMAIN_MODE_PREFIX,
DEFAULT_DOMAIN_MODE: CONFIG.DEFAULT_DOMAIN_MODE,
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,
VIDEO_PINNED_BY_DEFAULT: CONFIG.VIDEO_PINNED_BY_DEFAULT,
BTN_SIZE_SCALE: CONFIG.BTN_SIZE_SCALE,
YT_PARAMS: ytParams,
HOVER_TIMEOUT: CONFIG.HOVER_TIMEOUT,
BTN_LAYOUT: CONFIG.BTN_LAYOUT,
BTN_HIDE_TIMEOUT: CONFIG.BTN_HIDE_TIMEOUT,
HOME_TARGET_URL: safeHomeUrl,
idWithOrder: JSON.stringify(idWithOrder),
listKey: listKey,
STORAGE_LISTS: JSON.stringify(STORAGE_LISTS),
INITIAL_PINNED_IDS: JSON.stringify(initialPinnedIds),
PINNED_PREFIX: STORAGE_PINNED_PREFIX,
STORAGE_CURRENT: STORAGE_CURRENT,
SHARED_HELPERS: sharedHelpers,
};
const jsCode = `
(function() {
'use strict';
const CHAT_SUFFIX = '${blobParams.CHAT_SUFFIX}';
const VIDEO_SUFFIX = '${blobParams.VIDEO_SUFFIX}';
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 DOMAIN_MODE = '${blobParams.DOMAIN_MODE}';
const VIDEO_DOMAIN_MODE_PREFIX = '${blobParams.VIDEO_DOMAIN_MODE_PREFIX}';
const DEFAULT_DOMAIN_MODE = '${blobParams.DEFAULT_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 VIDEO_PINNED_BY_DEFAULT = ${blobParams.VIDEO_PINNED_BY_DEFAULT};
const BTN_SIZE_SCALE = ${blobParams.BTN_SIZE_SCALE};
const YT_PARAMS = '${blobParams.YT_PARAMS}';
const HOVER_TIMEOUT = ${blobParams.HOVER_TIMEOUT};
const BTN_LAYOUT = ${blobParams.BTN_LAYOUT};
const BTN_HIDE_TIMEOUT = ${blobParams.BTN_HIDE_TIMEOUT};
const HOME_TARGET_URL = '${blobParams.HOME_TARGET_URL}';
const HOME_DOMAIN = 'www.youtube.com';
const STORAGE_CURRENT = '${blobParams.STORAGE_CURRENT}';
${blobParams.SHARED_HELPERS}
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_PREFIX = '${blobParams.PINNED_PREFIX}';
const container = document.querySelector('.container');
let pinnedIds = INITIAL_PINNED_IDS;
let elementCache = new Map();
let hoverTimers = new Map();
let layoutDirty = true;
let resizeTimer;
let storageSyncTimer = null;
let lastSyncTime = 0;
let blobCurrentList = listKey;
const getSortedEntries = () => Array.from(idOrderMap.entries()).sort((a, b) => a[1] - b[1]);
const applyEntries = (entries) => {
idOrderMap.clear();
entries.forEach(([eid], i) => idOrderMap.set(eid, (i + 1) * 2));
};
const safeJSONParse = (str, fallback) => {
if (str === null || str === undefined || str === '') return fallback;
try { return JSON.parse(str); } catch (e) { return fallback; }
};
const clearHoverTimer = (id) => {
if (hoverTimers.has(id)) { clearTimeout(hoverTimers.get(id)); hoverTimers.delete(id); }
};
const clearElementCache = (id) => {
const w = elementCache.get(id);
if (w) {
const ifr = w.querySelector('iframe');
if (ifr) { ifr.src = 'about:blank'; ifr.removeAttribute('src'); }
w.remove();
}
elementCache.delete(id);
clearHoverTimer(id);
};
const clearAllTimers = () => { hoverTimers.forEach(clearTimeout); hoverTimers.clear(); };
const clearAllElements = () => {
elementCache.forEach((w) => {
const ifr = w.querySelector('iframe');
if (ifr) { ifr.src = 'about:blank'; ifr.removeAttribute('src'); }
w.remove();
});
elementCache.clear();
hoverTimers.clear();
};
const setupEnterShowHide = (triggerEl, targetEl, timeout) => {
let timer = null, visible = false;
const show = () => {
targetEl.style.opacity = '1';
targetEl.style.pointerEvents = 'auto';
};
const hide = () => {
targetEl.style.opacity = '0';
targetEl.style.pointerEvents = 'none';
};
triggerEl.addEventListener('mouseenter', () => {
if (!visible) { show(); visible = true; clearTimeout(timer); timer = setTimeout(() => { hide(); visible = false; }, timeout); }
});
triggerEl.addEventListener('mouseleave', () => { clearTimeout(timer); if (visible) { hide(); visible = false; } });
hide();
};
const showButtons = (wrapper) => {
wrapper.querySelectorAll('.remove-btn,.pin-btn,.domain-toggle-btn,.frame-toggle-btn,.chat-toggle-btn,.add-chat-btn,.top-btn,.up-btn,.down-btn,.bottom-btn,.copy-btn,.copy-placeholder,.list-switch-btn').forEach((b) => b.style.display = 'flex');
};
const hideButtons = (wrapper) => {
wrapper.querySelectorAll('.remove-btn,.pin-btn,.domain-toggle-btn,.frame-toggle-btn,.chat-toggle-btn,.add-chat-btn,.top-btn,.up-btn,.down-btn,.bottom-btn,.copy-btn,.copy-placeholder,.list-switch-btn').forEach((b) => b.style.display = 'none');
};
const setupHoverLogic = (wrapper, id) => {
wrapper.addEventListener('mouseenter', () => {
clearHoverTimer(id);
showButtons(wrapper);
hoverTimers.set(id, setTimeout(() => hideButtons(wrapper), BTN_HIDE_TIMEOUT));
});
wrapper.addEventListener('mouseleave', () => { clearHoverTimer(id); hideButtons(wrapper); });
};
const isLandscape = () => (container.offsetWidth || window.innerWidth) > (container.offsetHeight || window.innerHeight) * LANDSCAPE_ASPECT_RATIO_THRESHOLD;
const getColsForArea = (itemCount, areaWidth, fullW) => {
let c = 1;
for (let i = 0; i < LANDSCAPE_COLUMN_CONFIG.length; i++) {
if (itemCount >= LANDSCAPE_COLUMN_CONFIG[i]) c++;
else break;
}
return c;
};
const findBestPortraitColumns = (itemCount, availableW, availableH, threshold) => {
if (availableH <= 0) return 1;
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 || window.innerWidth;
const H = container.offsetHeight || window.innerHeight;
const isLand = W > H * LANDSCAPE_ASPECT_RATIO_THRESHOLD;
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 = [];
const getCols = (cnt) => {
let c = 1;
for (let i = 0; i < LANDSCAPE_COLUMN_CONFIG.length; i++) {
if (cnt >= LANDSCAPE_COLUMN_CONFIG[i]) c++;
else break;
}
return c;
};
if (isLand) {
const pCount = pin.length;
if (pCount === 0) {
if (vis.length > 0) {
const cc = getCols(vis.length);
const uW = W / cc;
const uH = 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: r * uH, w: uW, h: uH });
}
}
} else if (pCount === 1) {
const pH = W / ASPECT_RATIO_STANDARD;
cells.push({ id: pin[0][0], x: 0, y: 0, w: W, h: pH, isPinned: true });
const aH = H - pH;
if (aH > 0 && vis.length > 0) {
const cc = getCols(vis.length);
const uW = W / cc;
for (let i = 0; i < vis.length; i++) {
const r = Math.floor(i / cc), col = i % cc;
const cw = Math.min(uW, W);
const ch = cw / ASPECT_RATIO_STANDARD;
cells.push({ id: vis[i][0], x: col * uW, y: pH + r * ch, w: cw, h: ch });
}
}
} else if (pCount === 2) {
const pW = W / 2, pH = pW / ASPECT_RATIO_STANDARD;
cells.push({ id: pin[0][0], x: 0, y: 0, w: pW, h: pH, isPinned: true });
cells.push({ id: pin[1][0], x: pW, y: 0, w: pW, h: pH, isPinned: true });
const aH = H - pH;
if (aH > 0 && vis.length > 0) {
const cc = Math.min(vis.length, 4);
const uW = W / cc;
for (let i = 0; i < vis.length; i++) {
const r = Math.floor(i / cc), col = i % cc;
const cw = Math.min(uW, pW);
const ch = cw / ASPECT_RATIO_STANDARD;
cells.push({ id: vis[i][0], x: col * uW, y: pH + r * ch, w: cw, h: ch });
}
}
} else if (pCount >= 3) {
const pW = W / 2, pH = pW / ASPECT_RATIO_STANDARD;
for (let i = 0; i < Math.min(3, pCount); i++) {
cells.push({ id: pin[i][0], x: (i % 2) * pW, y: Math.floor(i / 2) * pH, w: pW, h: pH, isPinned: true });
}
if (vis.length > 0) {
const availW = pW;
const cc = getColsForArea(vis.length, availW, W);
const uW = availW / cc;
for (let i = 0; i < vis.length; i++) {
const r = Math.floor(i / cc), col = i % cc;
const cw = uW;
const ch = cw / ASPECT_RATIO_STANDARD;
cells.push({ id: vis[i][0], x: pW + col * uW, y: pH + r * ch, w: cw, h: ch });
}
}
}
} else {
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 = 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 = () => {
if (container.offsetWidth === 0 || container.offsetHeight === 0) { requestAnimationFrame(updateLayout); return; }
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'), ifr = w.querySelector('iframe');
if (scaler && ifr) {
if (isHomepage(c.id) || isFrameMode(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 res = RESOLUTION_LEVELS[lvl];
const tw = res ? res[0] : 854, th = res ? res[1] : 480;
scaler.style.width = tw + 'px';
scaler.style.height = th + 'px';
ifr.style.width = tw + 'px';
ifr.style.height = th + 'px';
scaler.style.transform = 'scale(' + (c.w / tw) + ',' + (c.h / th) + ')';
} 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); };
const mkBtn = (cls, fn) => {
const d = document.createElement('div');
d.className = cls;
d.onclick = (e) => { e.stopPropagation(); fn(e); };
return d;
};
const getVideoDomainMode = (vid) => {
const base = getBaseId(vid);
const stored = localStorage.getItem(VIDEO_DOMAIN_MODE_PREFIX + base);
return stored || DEFAULT_DOMAIN_MODE;
};
const setVideoDomainMode = (vid, mode) => {
const base = getBaseId(vid);
if (mode === 'YT' || mode === 'YU') localStorage.setItem(VIDEO_DOMAIN_MODE_PREFIX + base, mode);
};
const cleanupVideoDomainMode = (videoId) => {
const baseId = getBaseId(videoId);
let exists = false;
for (let i = 1; i <= LIST_COUNT; i++) {
const key = 'ytMulti_videoList' + i;
const ids = safeJSONParse(localStorage.getItem(key), []);
if (ids.some((id) => getBaseId(id) === baseId)) { exists = true; break; }
}
if (!exists) localStorage.removeItem(VIDEO_DOMAIN_MODE_PREFIX + baseId);
};
const safeSave = () => {
try {
const entries = getSortedEntries();
idOrderMap.clear();
entries.forEach(([eid], i) => idOrderMap.set(eid, (i + 1) * 2));
const cleanArr = entries.map((e) => e[0]);
localStorage.setItem(STORAGE_LISTS[blobCurrentList], JSON.stringify(cleanArr));
localStorage.setItem(PINNED_PREFIX + blobCurrentList, JSON.stringify(pinnedIds));
} catch (e) { console.warn('[Blob] SafeSave error:', e); }
};
const btnScale = BTN_SIZE_SCALE / 100;
const btnW = Math.round(20 * btnScale);
const btnH = Math.round(20 * btnScale);
const btnFs = Math.round(14 * btnScale);
const btnGap = 0;
const baseOffset = 6;
const emptySwitcher = document.createElement('div');
emptySwitcher.id = 'empty-list-switcher';
emptySwitcher.style.cssText = 'position:fixed;top:10px;left:10px;display:none;flex-direction:row;gap:8px;z-index:99999;';
document.body.appendChild(emptySwitcher);
for (let i = 1; i <= LIST_COUNT; i++) {
const btn = document.createElement('button');
btn.textContent = i;
btn.dataset.num = i;
btn.style.cssText = 'width:40px;height:40px;border-radius:5px;background:#ff4444;color:white;border:none;cursor:pointer;font-size:18px;font-weight:bold;box-shadow:0 2px 5px rgba(0,0,0,0.3);';
btn.addEventListener('click', () => { switchList('list' + i); });
emptySwitcher.appendChild(btn);
}
const updateEmptySwitcher = () => {
if (idOrderMap.size === 0) {
emptySwitcher.style.display = 'flex';
emptySwitcher.querySelectorAll('button').forEach((b) => {
b.style.background = ('list' + b.dataset.num === blobCurrentList) ? '#aa0000' : '#ff4444';
});
} else {
emptySwitcher.style.display = 'none';
}
};
const switchList = (targetKey) => {
if (targetKey === blobCurrentList) return;
blobCurrentList = targetKey;
localStorage.setItem(STORAGE_CURRENT, targetKey);
const newKey = STORAGE_LISTS[targetKey];
const nIds = safeJSONParse(localStorage.getItem(newKey), []);
const nPin = safeJSONParse(localStorage.getItem(PINNED_PREFIX + targetKey), []);
pinnedIds = nPin || [];
clearAllElements();
idOrderMap.clear();
let o = 0;
nIds.forEach((id) => {
o += 2;
idOrderMap.set(id, o);
const el = createVideo(id, o);
if (el) container.appendChild(el);
});
scheduleLayout();
document.querySelectorAll('.list-switch-btn').forEach((b) => {
b.style.background = (('list' + b.dataset.num) === blobCurrentList) ? '#aa0000' : '#ff4444';
});
updateEmptySwitcher();
};
const createVideo = (id, order) => {
const isHome = isHomepage(id);
const isFrame = isFrameMode(id);
const vid = isHome || isFrame ? null : isFullUrl(id) ? getVideoIdFromUrl(id) : getVideoId(id);
if (!isHome && !isFrame && (!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;
setupHoverLogic(w, id);
const isC = isChatId(id);
const playlistId = getPlaylistId(id);
const frameDomain =
isHome || isC || isFrame
? HOME_DOMAIN
: getVideoDomainMode(id) === 'YU'
? 'www.youtube-nocookie.com'
: DOMAIN_MODE;
let src;
if (isHome) {
src = HOME_TARGET_URL || 'https://' + frameDomain + '/';
} else if (isFrame) {
const fVid = getFrameVideoId(id);
src = 'https://' + frameDomain + '/watch?v=' + fVid;
} else if (isC) {
src = 'https://' + frameDomain + '/live_chat?v=' + vid;
} else {
if (frameDomain === 'www.youtube-nocookie.com') {
let params = 'autoplay=1&iv_load_policy=3&loop=1';
if (playlistId) {
params += '&list=' + playlistId;
} else {
params += '&playlist=' + vid;
}
src = 'https://www.youtube-nocookie.com/embed/' + vid + '?' + params;
} else {
let params = YT_PARAMS;
if (playlistId) {
params += '&list=' + playlistId;
}
src = 'https://' + frameDomain + '/embed/' + vid + '?' + params;
}
}
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;fullscreen';
ifr.sandbox = 'allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals';
const del = mkBtn('remove-btn', () => {
clearElementCache(id);
idOrderMap.delete(id);
const p = pinnedIds.indexOf(id);
if (p !== -1) pinnedIds.splice(p, 1);
cleanupVideoDomainMode(id);
safeSave();
scheduleLayout();
updateEmptySwitcher();
});
const pin = mkBtn('pin-btn', () => {
const i = pinnedIds.indexOf(id);
if (i !== -1) pinnedIds.splice(i, 1);
else {
if (pinnedIds.length >= MAX_PINNED) pinnedIds.pop();
pinnedIds.push(id);
}
safeSave();
scheduleLayout();
});
const domainToggle = mkBtn('domain-toggle-btn', () => {
if (isHome || isFrame) return;
const cur = getVideoDomainMode(id);
const nxt = cur === 'YT' ? 'YU' : 'YT';
setVideoDomainMode(id, nxt);
clearElementCache(id);
const newOrder = idOrderMap.get(id);
const nw = createVideo(id, newOrder);
if (nw) { container.appendChild(nw); elementCache.set(id, nw); }
scheduleLayout();
});
domainToggle.textContent = '';
const frameToggle = mkBtn('frame-toggle-btn', () => {
let vId = null, newId = null;
if (isHome || isFrame) {
try { vId = ifr.contentDocument?.querySelector('[video-id]')?.getAttribute('video-id'); } catch (e) {}
if (!vId) vId = getVideoIdFromUrl(ifr.src) || getFrameVideoId(id);
if (!vId || !/^[A-Za-z0-9_-]{11}$/.test(vId)) return;
newId = vId + VIDEO_SUFFIX;
} else {
const baseVid = isFullUrl(id) ? getVideoIdFromUrl(id) : getVideoId(id);
if (!baseVid) return;
newId = 'frame:' + baseVid;
}
if (idOrderMap.has(newId)) return;
const entries = getSortedEntries();
const idx = entries.findIndex((e) => e[0] === id);
if (idx !== -1) {
entries[idx][0] = newId;
}
applyEntries(entries);
const pIdx = pinnedIds.indexOf(id);
if (pIdx !== -1) pinnedIds[pIdx] = newId;
clearElementCache(id);
const newOrder = idOrderMap.get(newId);
const nw = createVideo(newId, newOrder);
if (nw) { container.appendChild(nw); elementCache.set(newId, nw); }
safeSave();
scheduleLayout();
});
frameToggle.textContent = '';
const chatT = mkBtn('chat-toggle-btn', () => {
if (isHome || isFrame) return;
const base = getBaseId(id);
const p = isC ? base + VIDEO_SUFFIX : base + CHAT_SUFFIX;
if (idOrderMap.has(p)) return;
const entries = getSortedEntries();
const idx = entries.findIndex((e) => e[0] === id);
if (idx !== -1) {
if (isC && idx > 0) {
const temp = entries[idx - 1];
entries[idx - 1] = entries[idx];
entries[idx] = temp;
} else if (!isC && idx < entries.length - 1) {
const temp = entries[idx + 1];
entries[idx + 1] = entries[idx];
entries[idx] = temp;
}
entries[idx][0] = p;
}
applyEntries(entries);
clearElementCache(id);
const newOrder = idOrderMap.get(p);
const nw = createVideo(p, newOrder);
if (nw) { container.appendChild(nw); elementCache.set(p, nw); }
safeSave();
scheduleLayout();
});
const chatA = mkBtn('add-chat-btn', () => {
if (isHome) return;
if (isFrame) {
const fVid = getFrameVideoId(id);
if (!fVid || !/^[A-Za-z0-9_-]{11}$/.test(fVid)) return;
const newId = fVid + VIDEO_SUFFIX;
if (idOrderMap.has(newId)) return;
const entries = getSortedEntries();
const idx = entries.findIndex((e) => e[0] === id);
if (idx !== -1) {
entries.splice(idx + 1, 0, [newId, 0]);
} else {
entries.push([newId, 0]);
}
applyEntries(entries);
const newOrder = idOrderMap.get(newId);
const nw = createVideo(newId, newOrder);
if (nw) { container.appendChild(nw); elementCache.set(newId, nw); }
safeSave();
scheduleLayout();
return;
}
const base = isFullUrl(id) ? getVideoIdFromUrl(id) : getBaseId(id);
if (!base || !/^[A-Za-z0-9_-]{11}$/.test(base.split('_list_')[0])) return;
let cnt = 0;
for (const k of idOrderMap.keys()) if (k.includes('_chat') && k.startsWith(base)) cnt++;
const newId = base + CHAT_SUFFIX + '_' + (cnt + 1);
const entries = getSortedEntries();
const idx = entries.findIndex((e) => e[0] === id);
if (idx !== -1) {
entries.splice(idx + 1, 0, [newId, 0]);
} else {
entries.push([newId, 0]);
}
applyEntries(entries);
const newOrder = idOrderMap.get(newId);
const nw = createVideo(newId, newOrder);
if (nw) { container.appendChild(nw); elementCache.set(newId, nw); }
safeSave();
scheduleLayout();
});
const top = mkBtn('top-btn', () => {
const entries = getSortedEntries();
const idx = entries.findIndex((e) => e[0] === id);
if (idx > 0) {
const item = entries.splice(idx, 1)[0];
entries.unshift(item);
applyEntries(entries);
safeSave();
scheduleLayout();
}
});
const up = mkBtn('up-btn', () => {
const entries = getSortedEntries();
const idx = entries.findIndex((e) => e[0] === id);
if (idx > 0) {
const temp = entries[idx - 1];
entries[idx - 1] = entries[idx];
entries[idx] = temp;
applyEntries(entries);
safeSave();
scheduleLayout();
}
});
const dn = mkBtn('down-btn', () => {
const entries = getSortedEntries();
const idx = entries.findIndex((e) => e[0] === id);
if (idx !== -1 && idx < entries.length - 1) {
const temp = entries[idx + 1];
entries[idx + 1] = entries[idx];
entries[idx] = temp;
applyEntries(entries);
safeSave();
scheduleLayout();
}
});
const bot = mkBtn('bottom-btn', () => {
const entries = getSortedEntries();
const idx = entries.findIndex((e) => e[0] === id);
if (idx !== -1 && idx < entries.length - 1) {
const item = entries.splice(idx, 1)[0];
entries.push(item);
applyEntries(entries);
safeSave();
scheduleLayout();
}
});
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 === blobCurrentList) {
const ph = document.createElement('div');
ph.className = 'copy-placeholder';
ph.style.width = btnW + 'px';
ph.style.height = btnH + 'px';
copyCol.appendChild(ph);
} else {
const cb = mkBtn('copy-btn', () => {
const tk = STORAGE_LISTS[targetKey];
const ti = safeJSONParse(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) { console.warn('[YT-Multi-Blob] Copy sync error:', e); }
}
});
cb.dataset.num = i;
cb.style.cssText = 'position:relative;width:' + btnW + 'px;height:' + btnH + 'px;border-radius:3px;background:#4488ff;color:white;border:none;cursor:pointer;font-size:' + btnFs + 'px;';
copyCol.appendChild(cb);
}
}
for (let i = 1; i <= LIST_COUNT; i++) {
const targetKey = 'list' + i;
const sb = mkBtn('list-switch-btn', () => switchList(targetKey));
sb.dataset.num = i;
sb.style.cssText = 'position:relative;width:' + btnW + 'px;height:' + btnH + 'px;border-radius:3px;background:' + ((targetKey === blobCurrentList) ? '#aa0000' : '#ff4444') + ';color:white;border:none;cursor:pointer;font-size:' + btnFs + 'px;';
copyCol.appendChild(sb);
}
if (copyCol.children.length > 0) w.appendChild(copyCol);
scaler.appendChild(ifr);
w.append(scaler, del, pin, domainToggle, frameToggle, chatT, chatA, top, up, dn, bot);
const btnList = [
{ el: del, key: 'del' }, { el: pin, key: 'pin' }, { el: domainToggle, key: 'domainToggle' },
{ el: frameToggle, key: 'frameToggle' }, { el: chatT, key: 'chatT' }, { el: chatA, key: 'chatA' },
{ el: top, key: 'top' }, { el: up, key: 'up' }, { el: dn, key: 'dn' }, { el: bot, key: 'bot' }
];
if (BTN_LAYOUT === 1 || BTN_LAYOUT === 2) {
const isRight = BTN_LAYOUT === 2;
btnList.forEach((btn, idx) => {
const pos = isRight ? { top: baseOffset + idx * (btnH + btnGap), right: baseOffset } : { top: baseOffset + idx * (btnH + btnGap), left: baseOffset };
Object.entries(pos).forEach(([p, v]) => btn.el.style[p] = v + 'px');
btn.el.style.width = btnW + 'px';
btn.el.style.height = btnH + 'px';
btn.el.style.fontSize = btnFs + 'px';
});
const copyOffset = baseOffset + btnList.length * (btnH + btnGap);
copyCol.style.cssText = 'position:absolute;top:' + baseOffset + 'px;' + (isRight ? 'right:' : 'left:') + (baseOffset + btnW) + 'px;display:flex;flex-direction:column;gap:' + btnGap + 'px;z-index:9999;';
copyCol.querySelectorAll('.copy-btn,.copy-placeholder,.list-switch-btn').forEach((c) => {
c.style.width = btnW + 'px';
c.style.height = btnH + 'px';
});
} else {
btnList.forEach((btn, idx) => {
const pos = { top: baseOffset, left: baseOffset + idx * (btnW + btnGap) };
Object.entries(pos).forEach(([p, v]) => btn.el.style[p] = v + 'px');
btn.el.style.width = btnW + 'px';
btn.el.style.height = btnH + 'px';
btn.el.style.fontSize = btnFs + 'px;';
});
const copyStart = baseOffset + btnList.length * (btnW + btnGap);
copyCol.style.cssText = 'position:absolute;top:' + baseOffset + 'px;left:' + copyStart + 'px;display:flex;flex-direction:row;gap:' + btnGap + 'px;z-index:9999;';
copyCol.querySelectorAll('.copy-btn,.copy-placeholder,.list-switch-btn').forEach((c) => {
c.style.width = btnW + 'px';
c.style.height = btnH + 'px';
});
}
elementCache.set(id, w);
return w;
};
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();
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:80px;height:80px;z-index:100000;cursor:default;';
homeTrigger.appendChild(homeBtn);
document.body.appendChild(homeTrigger);
setupEnterShowHide(homeTrigger, homeBtn, HOVER_TIMEOUT);
homeBtn.addEventListener('click', () => {
const hId = 'homepage_' + Date.now();
const entries = getSortedEntries();
entries.push([hId, 0]);
applyEntries(entries);
if (HOME_PINNED_BY_DEFAULT) {
if (pinnedIds.length >= MAX_PINNED) pinnedIds.pop();
pinnedIds.push(hId);
}
const newOrder = idOrderMap.get(hId);
const w = createVideo(hId, newOrder);
if (w) { container.appendChild(w); elementCache.set(hId, w); }
safeSave();
scheduleLayout();
updateEmptySwitcher();
});
window.addEventListener('resize', scheduleLayout, { passive: true });
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_LISTS[blobCurrentList]) {
const now = Date.now();
if (now - lastSyncTime < 200) return;
lastSyncTime = now;
if (storageSyncTimer) clearTimeout(storageSyncTimer);
storageSyncTimer = setTimeout(() => {
storageSyncTimer = null;
try {
const newIds = safeJSONParse(localStorage.getItem(STORAGE_LISTS[blobCurrentList]), []);
const newPinned = safeJSONParse(localStorage.getItem(PINNED_PREFIX + blobCurrentList), []);
pinnedIds = newPinned;
const newSet = new Set(newIds);
for (const id of [...idOrderMap.keys()]) if (!newSet.has(id)) clearElementCache(id);
let currentMax = idOrderMap.size > 0 ? Math.max(...idOrderMap.values()) : 0;
newIds.forEach((id) => {
if (!idOrderMap.has(id)) {
currentMax += 2;
idOrderMap.set(id, currentMax);
if (!container.querySelector('.video-wrapper[data-id="' + id + '"]')) {
const el = createVideo(id, currentMax);
if (el) container.appendChild(el);
}
}
});
scheduleLayout();
updateEmptySwitcher();
} catch (err) { console.warn('[YT-Multi-Blob] Sync error:', err); }
}, 100);
}
});
window.addEventListener('beforeunload', () => { clearTimeout(resizeTimer); clearAllElements(); clearAllTimers(); });
updateEmptySwitcher();
})();
`.trim();
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;
backface-visibility: hidden;
transform: translate3d(0, 0, 0); -webkit-transform: translate3d(0, 0, 0);
}
.video-scaler { 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);
image-rendering: crisp-edges; image-rendering: pixelated;
}
.remove-btn, .pin-btn, .domain-toggle-btn, .frame-toggle-btn,
.chat-toggle-btn, .add-chat-btn, .top-btn, .up-btn, .down-btn, .bottom-btn {
position: absolute; border-radius: 3px; display: none; cursor: pointer;
z-index: 9999; box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
}
.remove-btn { background: #ff4444; }
.pin-btn { background: #44aaff; }
.domain-toggle-btn { background: #aa44aa; }
.frame-toggle-btn { background: #44aaaa; }
.chat-toggle-btn { background: #888888; }
.add-chat-btn { background: #44aa44; }
.top-btn { background: #ffaa44; }
.up-btn { background: #88cc44; }
.down-btn { background: #44cc88; }
.bottom-btn { 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;
}
.domain-toggle-btn::after {
content: 'T'; color: white; font-size: 14px; position: absolute;
top: 50%; left: 50%; transform: translate(-50%, -50%);
line-height: 20px; pointer-events: none;
}
.frame-toggle-btn::after {
content: 'F'; color: white; font-size: 14px; position: absolute;
top: 50%; left: 50%; transform: translate(-50%, -50%);
line-height: 20px; pointer-events: none;
}
.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; display: flex; gap: 4px; z-index: 9999; }
.copy-btn, .copy-placeholder, .list-switch-btn { 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;
}
.list-switch-btn { position: relative; background: #ff4444; color: white; border: none; cursor: pointer; }
.list-switch-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; }
.home-add-btn {
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; display: flex; align-items: center;
justify-content: center; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
user-select: none;
}
`.trim();
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' }));
};
const openMultiPlayer = () => {
const k = STORAGE_LISTS[currentList];
let ids = safeJSONParse(localStorage.getItem(k), []);
const pinnedK = STORAGE_PINNED_PREFIX + currentList;
let pinned = safeJSONParse(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);
}
window.addEventListener('pagehide', cleanupBlob, { once: true });
};
playBtn.addEventListener('click', openMultiPlayer);
const checkAutoLaunch = () => {
const params = new URLSearchParams(window.location.search);
const autoKey = params.get('ytMulti_auto');
if (autoKey && STORAGE_LISTS[autoKey]) {
currentList = autoKey;
localStorage.setItem('ytMulti_currentList', currentList);
updateListButtonCount();
history.replaceState(null, '', window.location.pathname);
setTimeout(openMultiPlayer, 400);
}
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', checkAutoLaunch);
else checkAutoLaunch();
applyPanelMode(isWatchPage());
setupSyncListener();
setupNavigationObserver();
window.addEventListener('beforeunload', () => {
stopUrlPolling();
wprObserver?.disconnect();
stopObservingVideos();
});
} else {
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', () => setTimeout(startObservingVideos, 1000));
else setTimeout(startObservingVideos, 1000);
window.addEventListener('beforeunload', stopObservingVideos);
}
})();