// ==UserScript==
// @name YouTube Loop Clip
// @name:zh-CN YouTube 循环片段
// @name:zh-TW YouTube 循環片段
// @name:en YouTube Loop Clip
// @name:ja YouTubeループクリップ
// @name:ko 유튜브 루프 클립
// @namespace https://github.com/ooking/youtube-loop-clip
// @version 1.0.3
// @description 在YouTube视频时间轴上选择片段并循环播放,支持无限循环和刷新页面后设置不丢失
// @description:zh-CN 在YouTube视频时间轴上选择片段并循环播放,支持无限循环和刷新页面后设置不丢失
// @description:zh-TW 在YouTube影片時間軸上選擇片段並循環播放,支援無限循環且刷新頁面後設定不丟失
// @description:en Select a clip on the YouTube timeline and loop playback, supports infinite loop and persistent settings after refresh
// @description:ja YouTubeのタイムラインでクリップを選択してループ再生、無限ループとリフレッシュ後も設定保持
// @description:ko 유튜브 타임라인에서 클립을 선택해 반복 재생, 무한 반복 및 새로고침 후에도 설정 유지
// @author King Chan ([email protected])
// @include *://*.youtube.com/**
// @exclude *://accounts.youtube.com/*
// @exclude *://www.youtube.com/live_chat_replay*
// @exclude *://www.youtube.com/persist_identity*
// @grant GM_setValue
// @grant GM_getValue
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABm0lEQVR4AdSWAZaCIBRFaTY2tbJsZTUra979iqECYqLWHJ4IfP67cMLhxx38NwF4Onfu1KiurfN4vQMAGd4V4HXVe23dOw+lbksP0A1MCNuwqk92mEVaUgOQOcbIOnd4AGF+BiBDa6jevXiA3Y1laIs+EkAMzn0FwEOoN+kS6uTcaSznnI8h3kvd6TK3AzeZXKRGeoSKpQzGiTcpDihV8ZIDwLCJTyvvBUrR7KKqackB/E3DXz36dqyGI1sOgPGoZM4Ruqp+SiUgycW8BTCiAqQEYjStbdYAIBMQ7AY7Q7tYtQAwTP7QGEypFoA/rosh1gJgaN+J1Arn+nMAvzOTl6w6mSsHkPTn4yK9/csPE+cA+ktDOGHpe/edSJ6OHABe3OHCi2kSSkY2FtTM4+rFvZJcUc0BMIkEXiQEijM/kAJtLKiZk1y54qyUAFjgVo+PAeA8b7XIbN4jd8AWbQA60zRQlrbiIJcd8zMAEguCq5N10t5QmONlFj0ArQ6CQX+hrFmTl/8b1NiZBgD0CAJCu1DqfXUd5CDvZIf/AQAA//8BTt4CAAAABklEQVQDALNfokFDr0z6AAAAAElFTkSuQmCC
// @license MPL-2.0
// ==/UserScript==
(function () {
'use strict';
// 工具函数:格式化时间为 hh:mm:ss
function formatTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [h, m, s]
.map((v) => v < 10 ? '0' + v : v)
.join(':');
}
// 工具函数:解析 hh:mm:ss 为秒
function parseTime(str) {
const parts = str.split(':').map(Number);
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
return parts[0] * 60 + parts[1];
} else if (parts.length === 1) {
return parts[0];
}
return 0;
}
// 获取当前视频ID
function getVideoId() {
const url = new URL(window.location.href);
return url.searchParams.get('v');
}
// 获取播放器元素
function getPlayer() {
return document.querySelector('video');
}
// 持久化设置
function saveSettings(settings) {
const videoId = getVideoId();
if (videoId) {
GM_setValue('yt_loop_' + videoId, JSON.stringify(settings));
}
}
function loadSettings() {
const videoId = getVideoId();
if (videoId) {
const data = GM_getValue('yt_loop_' + videoId, null);
return data ? JSON.parse(data) : null;
}
return null;
}
// 创建控制按钮
function createButton(text, onClick) {
const btn = document.createElement('button');
btn.textContent = text;
btn.style.margin = '0 4px';
btn.style.padding = '4px 8px';
btn.style.zIndex = '9999';
btn.style.background = '#ff0';
btn.style.border = '1px solid #888';
btn.style.borderRadius = '4px';
btn.style.cursor = 'pointer';
btn.onclick = onClick;
return btn;
}
// 创建循环设置按钮(小巧美观)
function createLoopButton(onClick) {
const btn = document.createElement('button');
btn.textContent = 'LoopClip';
btn.style.margin = '10px 4px';
btn.style.padding = '2px 10px';
btn.style.fontSize = '13px';
btn.style.zIndex = '9999';
btn.style.background = '#ffe066';
btn.style.border = '1px solid #bbb';
btn.style.borderRadius = '20px';
btn.style.cursor = 'pointer';
btn.style.boxShadow = '0 1px 4px rgba(0,0,0,0.08)';
btn.className = 'yt-loop-btn';
btn.onclick = onClick;
return btn;
}
// 创建设置弹窗(安全版,避免 innerHTML)
function showDialog(settings, onSave) {
// 创建遮罩
const mask = document.createElement('div');
mask.style.position = 'fixed';
mask.style.top = '0';
mask.style.left = '0';
mask.style.width = '100vw';
mask.style.height = '100vh';
mask.style.background = 'rgba(0,0,0,0.3)';
mask.style.zIndex = '99999';
// 弹窗内容
const dialog = document.createElement('div');
dialog.style.position = 'fixed';
dialog.style.top = '50%';
dialog.style.left = '50%';
dialog.style.transform = 'translate(-50%, -50%)';
dialog.style.background = '#fff';
dialog.style.padding = '24px';
dialog.style.borderRadius = '8px';
dialog.style.boxShadow = '0 2px 12px rgba(0,0,0,0.2)';
dialog.style.minWidth = '320px';
// 标题
const title = document.createElement('h3');
title.style.marginTop = '0';
title.textContent = '循环片段设置';
dialog.appendChild(title);
// 开始时间
const labelStart = document.createElement('label');
labelStart.textContent = '开始时间 ';
const inputStart = document.createElement('input');
inputStart.id = 'yt-loop-start';
inputStart.type = 'text';
inputStart.value = formatTime(settings.start);
inputStart.style.width = '80px';
labelStart.appendChild(inputStart);
dialog.appendChild(labelStart);
dialog.appendChild(document.createElement('br'));
dialog.appendChild(document.createElement('br'));
// 结束时间
const labelEnd = document.createElement('label');
labelEnd.textContent = '结束时间 ';
const inputEnd = document.createElement('input');
inputEnd.id = 'yt-loop-end';
inputEnd.type = 'text';
inputEnd.value = formatTime(settings.end);
inputEnd.style.width = '80px';
labelEnd.appendChild(inputEnd);
dialog.appendChild(labelEnd);
dialog.appendChild(document.createElement('br'));
dialog.appendChild(document.createElement('br'));
// 循环次数和无限循环
const labelCount = document.createElement('label');
labelCount.textContent = '循环次数 ';
const inputCount = document.createElement('input');
inputCount.id = 'yt-loop-count';
inputCount.type = 'number';
inputCount.min = '1';
inputCount.value = settings.count || '';
inputCount.style.width = '60px';
labelCount.appendChild(inputCount);
const spanInfinite = document.createElement('span');
const inputInfinite = document.createElement('input');
inputInfinite.id = 'yt-loop-infinite';
inputInfinite.type = 'checkbox';
inputInfinite.checked = !!settings.infinite;
spanInfinite.appendChild(inputInfinite);
spanInfinite.appendChild(document.createTextNode(' 无限循环'));
labelCount.appendChild(spanInfinite);
dialog.appendChild(labelCount);
dialog.appendChild(document.createElement('br'));
dialog.appendChild(document.createElement('br'));
// 保存按钮
const btnSave = document.createElement('button');
btnSave.id = 'yt-loop-save';
btnSave.textContent = '保存';
btnSave.style.marginRight = '8px';
dialog.appendChild(btnSave);
// 取消按钮
const btnCancel = document.createElement('button');
btnCancel.id = 'yt-loop-cancel';
btnCancel.textContent = '取消';
dialog.appendChild(btnCancel);
mask.appendChild(dialog);
document.body.appendChild(mask);
// 事件绑定
btnSave.onclick = () => {
const start = parseTime(inputStart.value);
const end = parseTime(inputEnd.value);
const infinite = inputInfinite.checked;
const count = infinite ? 0 : parseInt(inputCount.value, 10) || 1;
onSave({ start, end, count, infinite });
document.body.removeChild(mask);
};
btnCancel.onclick = () => {
document.body.removeChild(mask);
};
inputInfinite.onchange = (e) => {
inputCount.disabled = e.target.checked;
};
}
// 创建循环设置弹窗(美化,英文,按钮上方弹出,任意空白处可拖动)
function showLoopDialog(settings, player, onSave) {
if (document.getElementById('yt-loop-dialog')) return;
if (player) player.pause();
// 获取按钮位置
const btn = document.querySelector('.yt-loop-btn');
let top = 80, left = window.innerWidth / 2;
if (btn) {
const rect = btn.getBoundingClientRect();
top = rect.top - 16 - 240; // 240为弹窗高度预估,16为间距
if (top < 10) top = 10;
left = rect.left + rect.width / 2;
}
const dialog = document.createElement('div');
dialog.id = 'yt-loop-dialog';
dialog.style.position = 'fixed';
dialog.style.top = top + 'px';
dialog.style.left = left + 'px';
dialog.style.transform = 'translateX(-50%)';
dialog.style.background = 'linear-gradient(135deg, #fffbe6 0%, #f7f7fa 10%)';
dialog.style.padding = '8px 28px 20px 28px';
dialog.style.borderRadius = '16px';
dialog.style.boxShadow = '0 4px 24px rgba(0,0,0,0.13)';
dialog.style.minWidth = '340px';
dialog.style.zIndex = '99999';
dialog.style.fontFamily = 'Segoe UI, Arial, sans-serif';
dialog.style.color = '#222';
dialog.style.cursor = 'move';
// 标题栏
const titleBar = document.createElement('div');
titleBar.style.fontWeight = 'bold';
titleBar.style.marginBottom = '18px';
titleBar.style.fontSize = '20px';
titleBar.style.letterSpacing = '0.5px';
titleBar.style.textAlign = 'center';
titleBar.textContent = 'Loop Clip Settings';
dialog.appendChild(titleBar);
// label样式
const labelStyle = 'display:inline-block;min-width:110px;font-size:16px;margin-bottom:8px;';
const inputStyle = 'font-size:15px;padding:2px 8px;border-radius:6px;border:1px solid #ccc;margin-right:8px;width:90px;background:#fff;';
const btnStyle = 'font-size:14px;padding:2px 10px;border-radius:8px;border:1px solid #bbb;background:#ffe066;cursor:pointer;margin-left:8px;';
// 开始时间
const labelStart = document.createElement('label');
labelStart.setAttribute('style', labelStyle);
labelStart.textContent = 'Start Time:';
const inputStart = document.createElement('input');
inputStart.id = 'yt-loop-start';
inputStart.type = 'text';
inputStart.value = formatTime(settings.start);
inputStart.setAttribute('style', inputStyle);
labelStart.appendChild(inputStart);
const btnGetStart = document.createElement('button');
btnGetStart.textContent = 'Get Current';
btnGetStart.setAttribute('style', btnStyle);
btnGetStart.onclick = () => {
inputStart.value = formatTime(Math.floor(player.currentTime));
};
labelStart.appendChild(btnGetStart);
dialog.appendChild(labelStart);
dialog.appendChild(document.createElement('br'));
dialog.appendChild(document.createElement('br'));
// 结束时间
const labelEnd = document.createElement('label');
labelEnd.setAttribute('style', labelStyle);
labelEnd.textContent = 'End Time:';
const inputEnd = document.createElement('input');
inputEnd.id = 'yt-loop-end';
inputEnd.type = 'text';
inputEnd.value = formatTime(settings.end);
inputEnd.setAttribute('style', inputStyle);
labelEnd.appendChild(inputEnd);
const btnGetEnd = document.createElement('button');
btnGetEnd.textContent = 'Get Current';
btnGetEnd.setAttribute('style', btnStyle);
btnGetEnd.onclick = () => {
inputEnd.value = formatTime(Math.floor(player.currentTime));
};
labelEnd.appendChild(btnGetEnd);
dialog.appendChild(labelEnd);
dialog.appendChild(document.createElement('br'));
dialog.appendChild(document.createElement('br'));
// 循环次数和无限循环
const labelCount = document.createElement('label');
labelCount.setAttribute('style', labelStyle);
labelCount.textContent = 'Loop Count:';
const inputCount = document.createElement('input');
inputCount.id = 'yt-loop-count';
inputCount.type = 'number';
inputCount.min = '1';
inputCount.value = settings.count || '';
inputCount.setAttribute('style', inputStyle);
labelCount.appendChild(inputCount);
const spanInfinite = document.createElement('span');
spanInfinite.setAttribute('style', 'margin-left:12px;font-size:15px;');
const inputInfinite = document.createElement('input');
inputInfinite.id = 'yt-loop-infinite';
inputInfinite.type = 'checkbox';
inputInfinite.checked = !!settings.infinite;
spanInfinite.appendChild(inputInfinite);
spanInfinite.appendChild(document.createTextNode(' Infinite'));
labelCount.appendChild(spanInfinite);
dialog.appendChild(labelCount);
dialog.appendChild(document.createElement('br'));
dialog.appendChild(document.createElement('br'));
// 循环播放/暂停按钮
let isLoopPlaying = false;
const btnLoopPlay = document.createElement('button');
btnLoopPlay.textContent = 'Play Loop';
btnLoopPlay.setAttribute('style', btnStyle + 'margin-right:8px;background:#7ed957;border:1px solid #6bbf4e;');
const btnLoopPause = document.createElement('button');
btnLoopPause.textContent = 'Stop';
btnLoopPause.setAttribute('style', btnStyle + 'margin-right:8px;background:#ffb4b4;border:1px solid #e88c8c;');
btnLoopPause.disabled = true;
dialog.appendChild(btnLoopPlay);
dialog.appendChild(btnLoopPause);
// 保存按钮
const btnSave = document.createElement('button');
btnSave.id = 'yt-loop-save';
btnSave.textContent = 'Save';
btnSave.setAttribute('style', btnStyle + 'margin-right:8px;background:#e0e0e0;border:1px solid #bbb;');
dialog.appendChild(btnSave);
// 取消按钮
const btnCancel = document.createElement('button');
btnCancel.id = 'yt-loop-cancel';
btnCancel.textContent = 'Close';
btnCancel.setAttribute('style', btnStyle + 'background:#e0e0e0;border:1px solid #bbb;');
dialog.appendChild(btnCancel);
document.body.appendChild(dialog);
// 拖动逻辑:点击弹窗任意空白处都可拖动
let isDragging = false, offsetX = 0, offsetY = 0;
dialog.addEventListener('mousedown', function(e) {
// 只响应左键且不响应按钮、输入框等控件
if (e.button !== 0) return;
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.tagName === 'LABEL') return;
isDragging = true;
offsetX = e.clientX - dialog.getBoundingClientRect().left;
offsetY = e.clientY - dialog.getBoundingClientRect().top;
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
document.body.style.userSelect = 'none';
});
function moveHandler(e) {
if (isDragging) {
dialog.style.left = e.clientX - offsetX + 'px';
dialog.style.top = e.clientY - offsetY + 'px';
dialog.style.transform = '';
}
}
function upHandler() {
isDragging = false;
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
document.body.style.userSelect = '';
}
// 事件绑定
btnSave.onclick = () => {
const start = parseTime(inputStart.value);
const end = parseTime(inputEnd.value);
const infinite = inputInfinite.checked;
const count = infinite ? 0 : parseInt(inputCount.value, 10) || 1;
onSave({ start, end, count, infinite });
};
btnCancel.onclick = () => {
document.body.removeChild(dialog);
stopLoopPlay();
};
inputInfinite.onchange = (e) => {
inputCount.disabled = e.target.checked;
};
// 循环播放逻辑
let loopCount = 0;
let loopHandler = null;
function startLoopPlay() {
if (isLoopPlaying) return;
isLoopPlaying = true;
btnLoopPlay.disabled = true;
btnLoopPause.disabled = false;
loopCount = 0;
player.currentTime = parseTime(inputStart.value);
player.play();
loopHandler = function() {
const start = parseTime(inputStart.value);
const end = parseTime(inputEnd.value);
const infinite = inputInfinite.checked;
const count = infinite ? 0 : parseInt(inputCount.value, 10) || 1;
if (start < end && player.currentTime >= end) {
if (infinite || loopCount < count - 1) {
player.currentTime = start;
player.play();
loopCount++;
} else {
loopCount = 0;
player.pause();
stopLoopPlay();
}
}
if (player.currentTime < start || player.currentTime > end) {
loopCount = 0;
}
};
player.addEventListener('timeupdate', loopHandler);
}
function stopLoopPlay() {
if (!isLoopPlaying) return;
isLoopPlaying = false;
btnLoopPlay.disabled = false;
btnLoopPause.disabled = true;
if (loopHandler) player.removeEventListener('timeupdate', loopHandler);
loopHandler = null;
player.pause();
}
btnLoopPlay.onclick = startLoopPlay;
btnLoopPause.onclick = stopLoopPlay;
}
// 主逻辑
function main() {
// 等待播放器加载
let lastUrl = '';
setInterval(() => {
if (window.location.href !== lastUrl) {
lastUrl = window.location.href;
setTimeout(init, 1000);
}
}, 1000);
function init() {
// 移除旧按钮
document.querySelectorAll('.yt-loop-btn').forEach((el) => el.remove());
const player = getPlayer();
if (!player) return;
// 按钮容器
const controls = document.querySelector('.ytp-left-controls');
if (!controls) return;
// 加载设置
let settings = loadSettings() || { start: 0, end: Math.floor(player.duration), count: 1, infinite: false };
// 创建循环设置按钮
const btnLoop = createLoopButton(() => {
showLoopDialog(settings, player, (newSettings) => {
settings = { ...settings, ...newSettings };
saveSettings(settings);
});
});
controls.appendChild(btnLoop);
// 原生播放器按钮正常播放
// 循环逻辑已移到弹窗按钮
}
// 首次初始化
setTimeout(init, 1000);
}
// 启动脚本
main();
})();