Vimeo Player Speed Slider

Add Speed Slider to Vimeo Player Settings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Vimeo Player Speed Slider
// @namespace    lukaszmical.pl
// @version      1.1.3
// @description  Add Speed Slider to Vimeo Player Settings
// @author       Łukasz Micał
// @include      https://*.vimeo.com/*
// @include      https://vimeo.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=vimeo.com
// @grant        none
// ==/UserScript==


// apps/vimeo-speed-slider/src/components/Elements.ts
var Elements = class _Elements {
  static ref(selector) {
    return document.querySelector(selector);
  }
  static menu() {
    return _Elements.ref(
      '[data-menu="prefs"] [class^=Menu_module_menuPanel]'
    );
  }
  static menuItem() {
    return _Elements.ref(
      '[data-menu="prefs"] [class^=Menu_module_menuPanel] [class^=MenuOption_module_option]'
    );
  }
  static menuItemLabel() {
    return _Elements.menuItem()?.querySelector("span");
  }
  static menuItemWithLabel(labels) {
    const optionItems = [
      ...document.querySelectorAll(
        '[data-menu="prefs"] [class^=MenuOption_module_option]'
      )
    ];
    return optionItems.find(
      (e) => e.id !== MenuItem.ID && labels.some((text) => e.innerText.includes(text))
    );
  }
  static menuSpeedItem() {
    return _Elements.menuItemWithLabel([
      "Speed",
      "Velocidad",
      "Geschwindigkeit",
      "Vitesse",
      "Velocidade",
      "\u30B9\u30D4\u30FC\u30C9",
      "\uC18D\uB3C4"
    ]);
  }
  static menuQualityItem() {
    return _Elements.menuItemWithLabel([
      "Quality",
      "Calidad",
      "Qualit\xE4t",
      "Qualit\xE9",
      "Qualidade",
      "\u753B\u8CEA",
      "\uACE0\uD654\uC9C8"
    ]);
  }
  static menuSpeedLabel() {
    return _Elements.menuSpeedItem()?.querySelector("span");
  }
  static video() {
    return _Elements.ref(".vp-video video");
  }
};

// libs/share/src/ui/Dom.ts
var Dom = class _Dom {
  static appendChildren(element, children) {
    if (typeof children === "string") {
      element.innerHTML = children;
    } else if (children) {
      element.append(
        ..._Dom.array(children).map((item) => {
          if (item instanceof HTMLElement || item instanceof SVGElement) {
            return item;
          }
          if (item instanceof Component) {
            return item.getElement();
          }
          if (_Dom.isSvgItem(item)) {
            return _Dom.createSvg(item);
          }
          return _Dom.create(item);
        })
      );
    }
  }
  static create(data) {
    const element = document.createElement(data.tag);
    _Dom.appendChildren(element, data.children);
    _Dom.applyClass(element, data.classes);
    _Dom.applyAttrs(element, data.attrs);
    _Dom.applyEvents(element, data.events);
    _Dom.applyStyles(element, data.styles);
    return element;
  }
  static element(tag, classes, children) {
    return _Dom.create({ tag, classes, children });
  }
  static createSvg(data) {
    const element = document.createElementNS(
      "http://www.w3.org/2000/svg",
      data.tag
    );
    _Dom.appendChildren(element, data.children);
    _Dom.applyClass(element, data.classes);
    _Dom.applyAttrs(element, data.attrs);
    _Dom.applyEvents(element, data.events);
    _Dom.applyStyles(element, data.styles);
    return element;
  }
  static array(element) {
    return Array.isArray(element) ? element : [element];
  }
  static elementSvg(tag, classes, children) {
    return _Dom.createSvg({ tag, classes, children });
  }
  static applyAttrs(element, attrs) {
    if (attrs) {
      Object.entries(attrs).forEach(([key, value]) => {
        if (value === void 0 || value === false) {
          element.removeAttribute(key);
        } else {
          element.setAttribute(key, `${value}`);
        }
      });
    }
  }
  static applyStyles(element, styles) {
    if (styles) {
      Object.entries(styles).forEach(([key, value]) => {
        const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
        element.style.setProperty(name, value);
      });
    }
  }
  static applyEvents(element, events) {
    if (events) {
      Object.entries(events).forEach(([name, callback]) => {
        element.addEventListener(name, callback);
      });
    }
  }
  static applyClass(element, classes) {
    if (classes) {
      element.setAttribute("class", classes);
    }
  }
  static isSvgItem(item) {
    try {
      const element = document.createElementNS(
        "http://www.w3.org/2000/svg",
        item.tag
      );
      return element.namespaceURI === "http://www.w3.org/2000/svg";
    } catch (error) {
      return false;
    }
  }
};

// libs/share/src/ui/Component.ts
var Component = class {
  constructor(tag, props = {}) {
    this.element = Dom.create({ tag, ...props });
  }
  addClassName(...className) {
    this.element.classList.add(...className);
  }
  event(event, callback) {
    this.element.addEventListener(event, callback);
  }
  getElement() {
    return this.element;
  }
  mount(parent) {
    parent.appendChild(this.element);
  }
};

// libs/share/src/ui/GlobalStyle.ts
var GlobalStyle = class {
  static addStyle(key, styles) {
    const style = document.getElementById(key) || function() {
      const style2 = document.createElement("style");
      style2.id = key;
      document.head.appendChild(style2);
      return style2;
    }();
    style.textContent = styles;
  }
};

// apps/vimeo-speed-slider/src/components/Slider.ts
var Slider = class _Slider extends Component {
  static {
    this.MIN_VALUE = 0.5;
  }
  static {
    this.MAX_VALUE = 4;
  }
  constructor() {
    super("input", {
      classes: "vis-slider",
      attrs: {
        type: "range",
        min: _Slider.MIN_VALUE,
        max: _Slider.MAX_VALUE,
        step: 0.05
      },
      styles: {
        background: "#ffffff66",
        width: "calc(100% - 30px)",
        height: "6px",
        outline: "none",
        margin: "0 10px",
        padding: "0",
        borderRadius: "3px",
        minWidth: "150px"
      }
    });
    GlobalStyle.addStyle(
      "vis-slider",
      `input[type='range'].vis-slider {
            -webkit-appearance: none;
          }

          input[type='range'].vis-slider::-moz-range-thumb ,
          input[type='range'].vis-slider::-webkit-slider-thumb {
            -webkit-appearance: none;
            appearance: none;
            width: 12px;
            height: 12px;
            border-radius: 6px;
            background: #fff;
            cursor: pointer;
            margin-top: -2px;
          }`
    );
  }
  initEvents(onChange) {
    this.event("change", () => onChange(this.getSpeed()));
    this.event("input", () => onChange(this.getSpeed()));
    this.event("wheel", (event) => {
      event.stopPropagation();
      event.preventDefault();
      const diff = event.deltaY > 0 ? -0.05 : 0.05;
      onChange(this.getSpeed() + diff);
      return false;
    });
  }
  setSpeed(speed) {
    this.updateBg(speed);
    this.element.value = speed.toString();
  }
  getSpeed() {
    return parseFloat(this.element.value);
  }
  updateBg(value) {
    const progress = (value - _Slider.MIN_VALUE) / (_Slider.MAX_VALUE - _Slider.MIN_VALUE) * 100;
    this.element.style.background = "linear-gradient(to right, COLOR1 0%, COLOR1 STEP%, COLOR2 STEP%, COLOR2 100%)".replaceAll("COLOR1", "var(--color-two)").replaceAll("COLOR2", "#ffffff66").replaceAll("STEP", progress.toFixed(1));
  }
};

// apps/vimeo-speed-slider/src/components/Checkbox.ts
var Checkbox = class extends Component {
  constructor(checked) {
    super("input", {
      styles: {
        accentColor: "var(--color-two)",
        appearance: "auto",
        width: "16px",
        height: "16px",
        margin: "0",
        padding: "0"
      },
      attrs: {
        type: "checkbox",
        title: "Remember speed",
        checked
      }
    });
  }
  initEvents(onChange) {
    this.event("change", () => onChange(this.element.checked));
  }
  setValue(checked) {
    this.element.checked = checked;
  }
};

// apps/vimeo-speed-slider/src/components/Label.ts
var Label = class extends Component {
  constructor() {
    super("span");
    this.label = "Speed";
    this.speed = "1.0";
  }
  init() {
    const originalItemLabel = Elements.menuSpeedLabel();
    if (originalItemLabel) {
      this.label = originalItemLabel.innerText;
      this.element.className = originalItemLabel.className;
    }
    const itemLabel = Elements.menuItemLabel();
    if (itemLabel) {
      this.element.className = itemLabel.className;
    }
    this.render();
  }
  setSpeed(speed) {
    this.speed = speed.toFixed(1);
    this.render();
  }
  render() {
    this.element.innerText = `${this.label}: ${this.speed}`;
  }
};

// apps/vimeo-speed-slider/src/components/MenuItem.ts
var MenuItem = class _MenuItem extends Component {
  constructor(setSpeed, setRemember) {
    super("div", { attrs: { id: _MenuItem.ID } });
    this.checkbox = new Checkbox(false);
    this.slider = new Slider();
    this.label = new Label();
    this.wrapper = Dom.create({
      tag: "div",
      styles: {
        display: "flex",
        alignItems: "center"
      }
    });
    this.wrapper.append(this.checkbox.getElement(), this.slider.getElement());
    this.element.append(this.label.getElement(), this.wrapper);
    this.slider.initEvents(setSpeed);
    this.checkbox.initEvents(setRemember);
  }
  static {
    this.ID = "vis-menu-speed-item";
  }
  setSpeed(speed) {
    this.slider.setSpeed(speed);
    this.label.setSpeed(speed);
  }
  setRemember(state) {
    this.checkbox.setValue(state);
  }
  mountItem() {
    const originalSpeedItem = Elements.menuSpeedItem();
    const originalQualityItem = Elements.menuQualityItem();
    if (!originalSpeedItem && !originalQualityItem) {
      this.element.parentNode?.removeChild(this.element);
      return;
    }
    originalSpeedItem?.style.setProperty("display", "none");
    if (!this.element.parentNode) {
      if (originalSpeedItem) {
        originalSpeedItem.after(this.element);
      } else if (originalQualityItem) {
        originalQualityItem.after(this.element);
      }
      this.label.init();
      this.element.className = Elements.menuItem()?.className || this.element.className;
    }
  }
};

// apps/vimeo-speed-slider/src/components/Player.ts
var Player = class _Player {
  constructor() {
    this.player = null;
    this.speed = 1;
  }
  static {
    this.READY_FLAG = "vis-listener";
  }
  getPlayer() {
    if (!this.player) {
      this.player = Elements.video();
      if (this.player) {
        this.initEvent(this.player);
      }
    }
    return this.player;
  }
  initEvent(player) {
    if (!player.getAttribute(_Player.READY_FLAG)) {
      player.addEventListener("ratechange", this.checkPlayerSpeed.bind(this));
      player.setAttribute(_Player.READY_FLAG, "ready");
    }
  }
  checkPlayerSpeed() {
    const player = this.getPlayer();
    if (player && Math.abs(player.playbackRate - this.speed) > 0.01) {
      player.playbackRate = this.speed;
      setTimeout(this.checkPlayerSpeed.bind(this), 200);
    }
  }
  setSpeed(speed) {
    this.speed = speed;
    const player = this.getPlayer();
    if (player !== null) {
      player.playbackRate = speed;
    }
  }
};

// libs/share/src/store/Store.ts
var Store = class {
  constructor(key) {
    this.key = key;
  }
  encode(val) {
    return JSON.stringify(val);
  }
  decode(val) {
    return JSON.parse(val);
  }
  set(value) {
    try {
      localStorage.setItem(this.key, this.encode(value));
    } catch (e) {
      return;
    }
  }
  get(defaultValue = void 0) {
    try {
      const data = localStorage.getItem(this.key);
      if (data) {
        return this.decode(data);
      }
      return defaultValue;
    } catch (e) {
      return defaultValue;
    }
  }
  remove() {
    localStorage.removeItem(this.key);
  }
};

// libs/share/src/ui/Observer.ts
var Observer = class {
  stop() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
  start(element, callback) {
    this.stop();
    this.observer = new MutationObserver(callback);
    this.observer.observe(element, {
      childList: true,
      subtree: true,
      attributes: true,
      characterData: true,
      attributeOldValue: true,
      characterDataOldValue: true
    });
  }
};

// apps/vimeo-speed-slider/src/controllers/AppController.ts
var AppController = class {
  constructor() {
    this.player = new Player();
    this.videoObserver = new Observer();
    this.menuObserver = new Observer();
    this.rememberSpeed = new Store("vis-remember-speed");
    this.speed = new Store("vis-speed");
    this.item = new MenuItem(
      this.setSpeed.bind(this),
      this.setRemember.bind(this)
    );
    this.setSpeed(this.getSpeed());
    this.setRemember(this.rememberSpeed.get(false));
  }
  setSpeed(speed) {
    this.speed.set(speed);
    this.player.setSpeed(speed);
    this.item.setSpeed(speed);
  }
  setRemember(state) {
    this.rememberSpeed.set(state);
    this.item.setRemember(state);
  }
  getSpeed() {
    return this.rememberSpeed.get(false) ? this.speed.get(1) : 1;
  }
  mount() {
    this.item.mountItem();
  }
  init() {
    const video = Elements.video();
    const menu = Elements.menu();
    if (video && menu) {
      this.videoObserver.start(video, this.mount.bind(this));
      this.menuObserver.start(menu, this.mount.bind(this));
      this.mount();
      this.setSpeed(this.getSpeed());
      return true;
    }
    return false;
  }
};

// apps/vimeo-speed-slider/src/main.ts
var app = new AppController();
var attempt = 0;
function init() {
  if (attempt <= 4 && !app.init()) {
    attempt++;
    window.setTimeout(init, 2e3);
  }
}
init();