- // ==UserScript==
- // @name YouTube Speed and Loop
- // @name:zh-TW YouTube 播放速度與循環
- // @namespace https://github.com/Hank8933
- // @version 1.0
- // @description Enhances YouTube with playback speeds beyond 2x and repeat functionality
- // @description:zh-TW 為 YouTube 提供超過 2 倍的播放速度控制和重複播放功能
- // @author Hank8933
- // @homepage https://github.com/Hank8933/YouTube-Speed-and-Loop
- // @match https://www.youtube.com/*
- // @grant none
- // @license MIT
- // ==/UserScript==
-
- (function() {
- 'use strict';
-
- // Define CSS with variables
- const panelCSS = `
- :root {
- --primary-bg: #212121;
- --hover-bg: #333;
- --active-bg: #f00;
- --panel-bg: rgba(33, 33, 33, 0.9);
- --text-color: #fff;
- --shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
- }
- .yt-custom-control-panel {
- position: relative;
- top: 0;
- left: 0;
- z-index: 99999;
- font-family: Roboto, Arial, sans-serif;
- align-self: center;
- }
- .yt-custom-control-toggle {
- background-color: var(--primary-bg);
- color: var(--text-color);
- padding: 8px 16px;
- border-radius: 20px;
- border: none;
- font-weight: bold;
- cursor: pointer;
- transition: background-color 0.3s;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .yt-custom-control-toggle:hover {
- background-color: var(--hover-bg);
- }
- .yt-custom-control-content {
- position: absolute;
- top: calc(100% + 5px);
- left: 50%;
- transform: translateX(-50%);
- background-color: var(--panel-bg);
- color: var(--text-color);
- padding: 10px;
- border-radius: 8px;
- box-shadow: var(--shadow);
- display: none;
- flex-direction: column;
- gap: 5px;
- min-width: 300px;
- white-space: nowrap;
- }
- .yt-custom-control-panel.expanded .yt-custom-control-content {
- display: flex;
- }
- .yt-custom-control-title {
- font-weight: bold;
- margin-bottom: 5px;
- }
- .yt-custom-control-section {
- margin-bottom: 5px;
- }
- .yt-custom-btn {
- background-color: #444;
- border: none;
- color: var(--text-color);
- padding: 5px 10px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 12px;
- white-space: nowrap;
- text-align: center;
- flex: 1;
- margin-right: 5px;
- }
- .yt-custom-btn:last-child {
- margin-right: 0;
- }
- .yt-custom-btn:hover {
- background-color: #555;
- }
- .yt-custom-btn.active {
- background-color: var(--active-bg);
- }
- .yt-speed-controls {
- display: flex;
- flex-direction: column;
- gap: 5px;
- white-space: nowrap;
- }
- .yt-slider-row {
- display: flex;
- align-items: center;
- width: 100%;
- }
- .yt-custom-slider {
- flex-grow: 1;
- min-width: 100px;
- }
- .yt-preset-speeds {
- display: flex;
- gap: 5px;
- width: 100%;
- }
- .yt-custom-slider-value {
- min-width: 40px;
- text-align: right;
- }
- #end {
- display: flex;
- align-items: center;
- }
- #buttons {
- margin-left: 10px;
- }
- `;
-
- // Add CSS to document head
- const styleEl = document.createElement('style');
- styleEl.textContent = panelCSS;
- document.head.appendChild(styleEl);
-
- // Utility function to create DOM elements
- function createElement(tag, className, textContent) {
- const el = document.createElement(tag);
- if (className) el.className = className;
- if (textContent) el.textContent = textContent;
- return el;
- }
-
- // Create control panel DOM structure
- function createControlPanel() {
- const panel = createElement('div', 'yt-custom-control-panel');
- const toggleBtn = createElement('button', 'yt-custom-control-toggle', '≡');
- toggleBtn.id = 'yt-toggle-panel';
- const contentDiv = createElement('div', 'yt-custom-control-content');
-
- const titleDiv = createElement('div', 'yt-custom-control-title');
- titleDiv.appendChild(createElement('span', '', 'YouTube Enhanced Controls'));
-
- const speedSection = createElement('div', 'yt-custom-control-section');
- const speedText = createElement('div', '');
- speedText.textContent = 'Playback Speed: ';
- const speedValue = createElement('span', '', '1.0');
- speedValue.id = 'yt-speed-value';
- speedText.appendChild(speedValue);
- speedText.append('x');
- const speedControls = createElement('div', 'yt-speed-controls');
- const sliderRow = createElement('div', 'yt-slider-row');
- const speedSlider = createElement('input', 'yt-custom-slider');
- speedSlider.type = 'range';
- speedSlider.id = 'yt-speed-slider';
- speedSlider.min = '0.25';
- speedSlider.max = '5';
- speedSlider.step = '0.25';
- speedSlider.value = '1';
- sliderRow.appendChild(speedSlider);
- speedControls.appendChild(sliderRow);
- const presetSpeeds = createElement('div', 'yt-preset-speeds');
- [1, 1.5, 2, 3, 4, 5].forEach(speed => {
- const btn = createElement('button', 'yt-custom-btn yt-speed-preset', `${speed}x`);
- btn.dataset.speed = speed;
- presetSpeeds.appendChild(btn);
- });
- speedControls.appendChild(presetSpeeds);
- speedSection.appendChild(speedText);
- speedSection.appendChild(speedControls);
-
- const loopSection = createElement('div', 'yt-custom-control-section');
- loopSection.appendChild(createElement('div', '', 'Loop Playback'));
- const loopToggle = createElement('button', 'yt-custom-btn', 'Off');
- loopToggle.id = 'yt-loop-toggle';
- loopSection.appendChild(loopToggle);
-
- const loopRangeSection = createElement('div', 'yt-custom-control-section');
- loopRangeSection.appendChild(createElement('div', '', 'Loop Range'));
- const rangeButtons = createElement('div', '');
- const loopStartBtn = createElement('button', 'yt-custom-btn', 'Set Start');
- loopStartBtn.id = 'yt-loop-start-btn';
- const loopEndBtn = createElement('button', 'yt-custom-btn', 'Set End');
- loopEndBtn.id = 'yt-loop-end-btn';
- const loopClearBtn = createElement('button', 'yt-custom-btn', 'Clear');
- loopClearBtn.id = 'yt-loop-clear-btn';
- rangeButtons.append(loopStartBtn, loopEndBtn, loopClearBtn);
- const loopInfo = createElement('div', '', 'No loop range set');
- loopInfo.id = 'yt-loop-info';
- loopRangeSection.append(rangeButtons, loopInfo);
-
- contentDiv.append(titleDiv, speedSection, loopSection, loopRangeSection);
- panel.append(toggleBtn, contentDiv);
-
- const endDiv = document.querySelector('#end');
- if (endDiv) {
- endDiv.insertBefore(panel, endDiv.querySelector('#buttons'));
- } else {
- document.body.appendChild(panel);
- }
- return panel;
- }
-
- // Wait for video element
- function waitForVideo() {
- return new Promise(resolve => {
- const checkVideo = () => {
- const video = document.querySelector('video');
- if (video) resolve(video);
- else setTimeout(checkVideo, 200);
- };
- checkVideo();
- });
- }
-
- // Observe native playback rate changes
- function observePlaybackRate(video) {
- let lastRate = video.playbackRate;
- const interval = setInterval(() => {
- const newRate = video.playbackRate;
- if (newRate !== lastRate) {
- SpeedController.updatePlaybackRate(newRate);
- lastRate = newRate;
- }
- }, 500);
- return { disconnect: () => clearInterval(interval) };
- }
-
- // Speed Controller Module
- const SpeedController = {
- updatePlaybackRate(rate) {
- const video = document.querySelector('video');
- if (!video) return;
- const speedValue = document.getElementById('yt-speed-value');
- const speedSlider = document.getElementById('yt-speed-slider');
- const speedPresets = document.querySelectorAll('.yt-speed-preset');
- if (speedValue) speedValue.textContent = rate.toFixed(2);
- if (speedSlider) speedSlider.value = rate;
- speedPresets.forEach(btn => {
- btn.classList.toggle('active', parseFloat(btn.dataset.speed) === rate);
- });
- },
- init(video, slider, presetSpeeds) {
- slider.addEventListener('input', () => {
- const rate = parseFloat(slider.value);
- video.playbackRate = rate;
- this.updatePlaybackRate(rate);
- });
- presetSpeeds.addEventListener('click', (e) => {
- const btn = e.target.closest('.yt-speed-preset');
- if (btn) {
- const rate = parseFloat(btn.dataset.speed);
- video.playbackRate = rate;
- this.updatePlaybackRate(rate);
- }
- });
- }
- };
-
- // Loop Controller Module
- const LoopController = {
- init(video, toggle, startBtn, endBtn, clearBtn, info) {
- let isLooping = false;
- let loopStart = null;
- let loopEnd = null;
-
- toggle.addEventListener('click', () => {
- isLooping = !isLooping;
- video.loop = isLooping;
- toggle.textContent = isLooping ? 'On' : 'Off';
- toggle.classList.toggle('active', isLooping);
- });
-
- startBtn.addEventListener('click', () => {
- loopStart = video.currentTime;
- this.updateLoopInfo(loopStart, loopEnd, info);
- });
-
- endBtn.addEventListener('click', () => {
- loopEnd = video.currentTime;
- this.updateLoopInfo(loopStart, loopEnd, info);
- });
-
- clearBtn.addEventListener('click', () => {
- loopStart = null;
- loopEnd = null;
- this.updateLoopInfo(loopStart, loopEnd, info);
- });
-
- video.addEventListener('timeupdate', () => {
- if (isLooping && loopStart !== null && loopEnd !== null && loopStart < loopEnd) {
- if (video.currentTime >= loopEnd) {
- video.currentTime = loopStart;
- }
- }
- });
- },
- updateLoopInfo(start, end, info) {
- if (start !== null && end !== null) {
- info.textContent = `From ${this.formatTime(start)} to ${this.formatTime(end)}`;
- } else if (start !== null) {
- info.textContent = `Start: ${this.formatTime(start)}, End: Not set`;
- } else if (end !== null) {
- info.textContent = `Start: Not set, End: ${this.formatTime(end)}`;
- } else {
- info.textContent = 'No loop range set';
- }
- },
- formatTime(seconds) {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${mins}:${secs.toString().padStart(2, '0')}`;
- }
- };
-
- // Main initialization
- async function init() {
- const video = await waitForVideo();
- const panel = createControlPanel();
-
- setTimeout(() => {
- const toggleBtn = document.getElementById('yt-toggle-panel');
- const speedSlider = document.getElementById('yt-speed-slider');
- const presetSpeeds = document.querySelector('.yt-preset-speeds');
- const loopToggle = document.getElementById('yt-loop-toggle');
- const loopStartBtn = document.getElementById('yt-loop-start-btn');
- const loopEndBtn = document.getElementById('yt-loop-end-btn');
- const loopClearBtn = document.getElementById('yt-loop-clear-btn');
- const loopInfo = document.getElementById('yt-loop-info');
-
- toggleBtn.addEventListener('click', () => {
- panel.classList.toggle('expanded');
- toggleBtn.textContent = panel.classList.contains('expanded') ? '_' : '≡';
- });
-
- SpeedController.init(video, speedSlider, presetSpeeds);
- LoopController.init(video, loopToggle, loopStartBtn, loopEndBtn, loopClearBtn, loopInfo);
- const playbackRateObserver = observePlaybackRate(video);
- SpeedController.updatePlaybackRate(video.playbackRate || 1);
- }, 2000);
- }
-
- // Ensure DOM is loaded
- document.addEventListener('DOMContentLoaded', () => {
- if (!document.body) return;
- setTimeout(init, 1000);
- });
-
- // Detect page navigation
- let lastUrl = location.href;
- const observer = new MutationObserver(() => {
- if (lastUrl !== location.href) {
- lastUrl = location.href;
- const oldPanel = document.querySelector('.yt-custom-control-panel');
- if (oldPanel) oldPanel.remove();
- setTimeout(init, 1000);
- }
- });
- observer.observe(document, { subtree: true, childList: true });
- })();