Left-Handed Video Shortcuts

A set of shortcuts designed for the Left-Hand style while watching videos

目前為 2025-11-19 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Left-Handed Video Shortcuts
// @namespace    https://github.com/dzist
// @version      0.0.20
// @description  A set of shortcuts designed for the Left-Hand style while watching videos
// @description:zh-CN  专为左手人士设计的视频快捷键
// @author       Dylan Zhang
// @license      MIT
// @include      *://*.youtube.com/*
// @include      *://*.bilibili.com/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @run-at       document-end
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    /* utilities */
    const utils = {
        ensureCondition(condition, maxAttempts = 600 /* 10s */, failureMessage) {
            return new Promise((resolve, reject) => {
                let attempts = 0
                const detect = () => {
                    const result = condition()
                    if (result) {
                        resolve(result)
                    } else if (attempts < maxAttempts) {
                        attempts++
                        requestAnimationFrame(detect)
                    } else {
                        reject(new Error(failureMessage))
                    }
                }
                requestAnimationFrame(detect)
            })
        },
        ensureElement(selector, maxAttempts = 600) {
            return utils.ensureCondition(
                () => document.querySelector(selector),
                maxAttempts,
                `Could not detect ${selector} after ${maxAttempts} attempts`
            )
        }
    }

    class Indicator {
        constructor() {
            this.el = null
            this.hasInited = false
            this.timer = null
            this.duration = 0.5
            this.id = 'wasd-indicator'
            this.activeClass = 'wasd-indicator-active'
        }
        initialize() {
            this.injectStyle()
            this.injectElement()
        }
        injectStyle() {
            const { id, activeClass, duration } = this
            const style = document.createElement('style')
            style.id = `${id}-style`
            style.textContent = `
            #${id} {
              box-sizing: border-box;
              display: flex;
              justify-content: center;
              align-items: center;
              min-width: 50px;
              height: 50px;
              padding: 0 10px;
              background: #000;
              font-size: 18px;
              font-weight: bold;
              color: #fff;
              border-radius: 10px;
              opacity: 0;
              transition: opacity ${duration}s ease;
              position: fixed;
              left: 10px;
              bottom: 10px;
              z-index: -1;
            }
            #${id}.${activeClass} {
              opacity: 1;
              z-index: 99;
            }
            `
            document.body.appendChild(style)
        }
        injectElement() {
            const el = document.createElement('div')
            el.id = this.id
            document.body.appendChild(this.el = el)
        }
        show(text) {
            if (!this.hasInited) {
                this.hasInited = true
                this.initialize()
                // Force to excute a reflow to
                // ensure that the animation can be run at the first time
                void this.el.offsetWidth
            }

            const { el, activeClass } = this
            el.textContent = text
            el.classList.add(activeClass)

            if (this.timer) clearTimeout(this.timer)
            this.timer = setTimeout(() => {
                el.classList.remove(activeClass)
                this.timer = null
            }, 800)
        }
    }

    class Shortcuts {
        constructor(meida, indicator) {
            this.media = meida
            this.indicator = indicator

            this.isVisible = false
            this.seekStep = 5
            this.volume = 1
            this.volumeStep = 0.1
            this.playbackRate = 1
            this.playbackRateStep = 0.25
            this.minPlaybackRate = 0.5

            this.allowedKeysList = {
                w: () => {
                    const volume = this.currentVolume
                    let text = '⬆'
                    if (volume === 1) text += 'Max'
                    return text
                },
                s: () => {
                    const volume = this.currentVolume
                    let text = '⬇︎'
                    if (volume === 0) text += 'Min'
                    return text
                },
                a: '⬅︎',
                d: '➡︎',
                1: 'x1',
                2: 'x2',
                3: 'x3',
                4: 'x4',
                5: 'x5',
                r: () => `x${this.playbackRate}`,
                x: ['Off', 'On']
            }

            this.watcher = null

            this.bindEvents()
            this.watch()
        }
        bindEvents() {
            window.addEventListener('keydown', this.handleKeyDown, { capture: true })
            window.addEventListener('beforeunload', this.handleBeforeUnload)
        }
        handleKeyDown = (event) => {
            if (this.isTyping) return

            // the key is uppercase while pressing with the shift key
            const key = event.key.toLowerCase()
            let text = this.allowedKeysList[key]
            // not in the allowed keys or with ctrl/command key
            if (!text || event.metaKey || event.ctrlKey) return

            event.stopImmediatePropagation()
            switch(key) {
                case 'w': // increase volume
                    this.increaseVolume()
                    break
                case 's': // decrease volume
                    this.decreaseVolume()
                    break
                case 'a': // rewind
                    this.seek(this.currentTime - this.seekStep)
                    break
                case 'd': // fast forward
                    this.seek(this.currentTime + this.seekStep)
                    break
                case '1':
                case '2':
                case '3':
                case '4':
                case '5':
                    this.setPlaybackRate(parseInt(event.key))
                    break
                case 'r':
                    event.shiftKey
                        ? this.decreasePlaybackRate()
                        : this.increasePlaybackRate()
                    break
            }

            if (this.isVisible) {
                if (key === 'x') {
                    this.isVisible = false
                    text = text[0]
                }
                if (typeof text === 'function') {
                    text = text()
                }
                this.indicator.show(text)
            } else {
                if (key === 'x') {
                    this.isVisible = true
                    this.indicator.show(text[1])
                }
            }
        }
        handleBeforeUnload = () => {
            window.removeEventListener('keydown', this.handleKeydown)
            window.removeEventListener('beforeunload', this.handleBeforeUnload)

            this.watcher.stop()
            this.media = null
        }

        watch() {
            this.watcher = this.createWatcher(this.media, () => {
                this.setPlaybackRate(this.playbackRate)
                this.setVolume(this.volume)
            })
        }
        createWatcher(media, callback) {
            const observer = new MutationObserver((mutationList) => {
                for (const mutation of mutationList) {
                    if (mutation.attributeName === 'src') {
                        callback && callback()
                        break
                    }
                }
            })
            observer.observe(media, {
                attributes: true
            })

            const stop = () => observer.disconnct()
            return {
                stop
            }
        }

        seek(time) {
            this.media.currentTime = time
        }
        get currentTime() {
            return this.media.currentTime
        }

        get currentVolume() {
            return this.media.volume
        }
        setVolume(volume) {
            this.volume = this.media.volume = volume
        }
        increaseVolume() {
            const volume = Math.min(this.media.volume + this.volumeStep, 1)
            this.setVolume(volume)
        }
        decreaseVolume() {
            const volume = Math.max(this.media.volume - this.volumeStep, 0)
            this.setVolume(volume)
        }

        setPlaybackRate(playbackRate) {
            this.playbackRate = this.media.playbackRate = playbackRate
        }
        increasePlaybackRate() {
            const rate = this.playbackRate + this.playbackRateStep
            this.setPlaybackRate(rate)
        }
        decreasePlaybackRate() {
            const rate = Math.max(this.playbackRate - this.playbackRateStep, this.minPlaybackRate)
            this.setPlaybackRate(rate)
        }

        get isTyping() {
            const activeElement = document.activeElement
            return activeElement instanceof HTMLInputElement ||
                activeElement instanceof HTMLTextAreaElement ||
                activeElement.isContentEditable === true
        }
    }

    utils.ensureElement('video').then(video => {
        new Shortcuts(video, new Indicator())
    })
})();