Greasy Fork is available in English.
油管的视频旋转插件.
// ==UserScript== // @author zhzLuke96 // @name 油管视频旋转 // @name:en youtube player rotate // @version 2.10 // @description 油管的视频旋转插件. // @description:en rotate youtube player. // @namespace https://github.com/zhzLuke96/ytp-rotate // @match https://www.youtube.com/* // @grant none // @license MIT // @supportURL https://github.com/zhzLuke96/ytp-rotate/issues // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // ==/UserScript== (async function () { "use strict"; // assets const assets = { locals: { zh: { click_rotate: "点击顺时针旋转视频90°", toggle_plugin: "开/关 ytp-rotate", rotate90: "旋转90°", cover_screen: "填充屏幕", flip_horizontal: "水平翻转", flip_vertical: "垂直翻转", PIP: "画中画", click_cover_screen: "点击 开/关 填充屏幕", }, en: { click_rotate: "click to rotate video 90°", toggle_plugin: "on/off ytp-rotate", rotate90: "rotate 90°", cover_screen: "cover screen", flip_horizontal: "flip horizontal", flip_vertical: "flip vertical", PIP: "picture in picture", click_cover_screen: "click to on/off screen", }, }, icons: { rotate: `<svg style="transform: rotateX(180deg);" width="24px" height="24px" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> <rect width="48" height="48" fill="white" fill-opacity="0.01"/> <path d="M4 24C4 35.0457 12.9543 44 24 44L19 39" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> <path d="M44 24C44 12.9543 35.0457 4 24 4L29 9" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> <path d="M30 41L7 18L18 7L41 30L30 41Z" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> </svg>`, fullscreen: `<svg width="24px" height="24px" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg" class="si-glyph si-glyph-fullscreen"><g fill="currentColor" fill-rule="evenodd"><path class="si-glyph-fill" d="M3 5h12v8H3zM3.918 14.938H1v-2.876h1v1.98h1.918v.896ZM17 14.938h-2.938v-.896H16v-1.984h1v2.88ZM17 5.917h-1v-1.95h-1.943v-.946H17v2.896ZM2 5.938H1V3h2.938v.938H2v2Z"/></g></svg>`, flip_horizontal: `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M2 18.114V5.886c0-1.702 0-2.553.542-2.832.543-.28 1.235.216 2.62 1.205l1.582 1.13c.616.44.924.66 1.09.982C8 6.694 8 7.073 8 7.83v8.34c0 .757 0 1.136-.166 1.459-.166.323-.474.543-1.09.983l-1.582 1.13c-1.385.988-2.077 1.483-2.62 1.204C2 20.666 2 19.816 2 18.114ZM22 18.114V5.886c0-1.702 0-2.553-.542-2.832-.543-.28-1.235.216-2.62 1.205l-1.582 1.13c-.616.44-.924.66-1.09.982C16 6.694 16 7.073 16 7.83v8.34c0 .757 0 1.136.166 1.459.166.323.474.543 1.09.983l1.581 1.13c1.386.988 2.078 1.483 2.62 1.204.543-.28.543-1.13.543-2.832Z"/><path fill="currentColor" fill-rule="evenodd" d="M12 1.25a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0V2a.75.75 0 0 1 .75-.75Zm0 8a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Zm0 8a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd"/></svg>`, flip_vertical: `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M18.114 22H5.886c-1.702 0-2.553 0-2.832-.542-.28-.543.216-1.235 1.205-2.62l1.13-1.582c.44-.616.66-.924.982-1.09C6.694 16 7.073 16 7.83 16h8.34c.757 0 1.136 0 1.459.166.323.166.543.474.983 1.09l1.13 1.581c.988 1.386 1.483 2.078 1.204 2.62-.28.543-1.13.543-2.832.543ZM18.114 2H5.886c-1.702 0-2.553 0-2.832.542-.28.543.216 1.235 1.205 2.62l1.13 1.582c.44.616.66.924.982 1.09C6.694 8 7.073 8 7.83 8h8.34c.757 0 1.136 0 1.459-.166.323-.166.543-.474.983-1.09l1.13-1.582c.988-1.385 1.483-2.077 1.204-2.62C20.666 2 19.816 2 18.114 2Z"/><path fill="currentColor" fill-rule="evenodd" d="M1.25 12a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5H2a.75.75 0 0 1-.75-.75Zm8 0a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1-.75-.75Zm8 0a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd"/></svg>`, pip: `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path fill="currentColor" d="M21 3a1 1 0 0 1 1 1v7h-2V5H4v14h6v2H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h18zm0 10a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h8zm-1 2h-6v4h6v-4z"/></svg>`, }, }; const constants = { version: GM_info.script.version, user_lang: ( navigator.language || navigator.browserLanguage || navigator.systemLanguage ).toLowerCase() || "", style_rule_name: "ytp_player_rotate_user_js", }; const $ = (q) => document.querySelector(q); const i18n = (x) => assets.locals[constants.user_lang.includes("zh") ? "zh" : "en"][x] || x; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function $css(style_obj, important = true) { return ( Object.entries(style_obj || {}) // transform用array储存属性 .map(([k, v]) => [k, Array.isArray(v) ? v.join(" ") : v]) .map(([k, v]) => `${k}:${v} ${important ? "!important" : ""};`) .join("\n") ); } // NOTE: 为了解决 `This document requires 'TrustedHTML' assignment.` 问题 // 首先,检查 trustedTypes 是否可用 let trusted_policy = null; if (window.trustedTypes) { // 如果默认策略不存在,创建一个新的默认策略 if (!window.trustedTypes.defaultPolicy) { try { window.trustedTypes.createPolicy("default", { createHTML: (string, sink) => { return string; }, }); } catch (error) { console.error("Failed to create default Trusted Types policy:", error); } } // 如果默认策略存在但没有 createHTML 方法,尝试添加这个方法 if ( window.trustedTypes.defaultPolicy && !window.trustedTypes.defaultPolicy.createHTML ) { try { Object.defineProperty(window.trustedTypes.defaultPolicy, "createHTML", { value: function (string, sink) { return string; }, writable: false, enumerable: true, configurable: false, }); } catch (error) { console.error( "Failed to add createHTML to default Trusted Types policy:", error ); } } try { trusted_policy = window.trustedTypes.createPolicy("safe", { createHTML: (string, sink) => { return string; }, }); } catch (error) { console.error("Failed to create default Trusted Types policy:", error); } } /** * @param {string} string * @returns {string | TrustedHTML} */ function trusted_html(string) { if (window.trustedTypes?.defaultPolicy?.createHTML) { try { return window.trustedTypes.defaultPolicy.createHTML(string); } catch (error) { // console.error("Failed to create trusted HTML:", error); } try { if (trusted_policy) { return trusted_policy.createHTML(string); } } catch (error) { // console.error("Failed to create trusted HTML:", error); } } return string; } /** * debounce * * @param {Function} func - The function to debounce. * @param {number} wait - The number of milliseconds to delay. * @param {boolean} [immediate=false] - Specifies whether the function should be invoked on the leading edge (`true`) or the trailing edge (`false`) of the `wait` timeout. Default is `false`. * @return {Function} - The debounced function. */ function debounce(func, wait, immediate = false) { let timeout; return function () { const context = this, args = arguments; const later = function () { timeout = null; if (!immediate) func.apply(context, args); }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; } async function wait_for_element(selector) { let retry_count = 60; while (retry_count > 0) { const element = $(selector); if (element && element instanceof HTMLElement) { return element; } else { retry_count--; await delay(1000); } } throw new Error( `[ytp-rotate]TIMEOUT, setup failed, can't find [${selector}]` ); } class YtdApp { static EVENT_onReady = "onReady"; static EVENT_innertubeCommand = "innertubeCommand"; static EVENT_onOrchestrationBecameLeader = "onOrchestrationBecameLeader"; static EVENT_onOrchestrationLostLeader = "onOrchestrationLostLeader"; static EVENT_onOfflineOperationFailure = "onOfflineOperationFailure"; static EVENT_SIZE_CLICKED = "SIZE_CLICKED"; static EVENT_onFullerscreenEduClicked = "onFullerscreenEduClicked"; static EVENT_onStateChange = "onStateChange"; static EVENT_onPlayVideo = "onPlayVideo"; static EVENT_onAutonavChangeRequest = "onAutonavChangeRequest"; static EVENT_onVideoDataChange = "onVideoDataChange"; static EVENT_onCollapseMiniplayer = "onCollapseMiniplayer"; static EVENT_cinematicSettingsToggleChange = "cinematicSettingsToggleChange"; static EVENT_onFeedbackStartRequest = "onFeedbackStartRequest"; static EVENT_onFeedbackArticleRequest = "onFeedbackArticleRequest"; static EVENT_onYpcContentRequest = "onYpcContentRequest"; static EVENT_onAutonavPauseRequest = "onAutonavPauseRequest"; static EVENT_onAdStateChange = "onAdStateChange"; static EVENT_CONNECTION_ISSUE = "CONNECTION_ISSUE"; static EVENT_SUBSCRIBE = "SUBSCRIBE"; static EVENT_UNSUBSCRIBE = "UNSUBSCRIBE"; static EVENT_onYtShowToast = "onYtShowToast"; static EVENT_onFullscreenChange = "onFullscreenChange"; static EVENT_onAbnormalityDetected = "onAbnormalityDetected"; static EVENT_onAutonavCoundownStarted = "onAutonavCoundownStarted"; static EVENT_updateEngagementPanelAction = "updateEngagementPanelAction"; static EVENT_changeEngagementPanelVisibility = "changeEngagementPanelVisibility"; static EVENT_onVideoProgress = "onVideoProgress"; static PlayerStates = { [2]: "paused", [3]: "playing", [5]: "cued", }; // 这个组件是全局单例,页面不关闭都存在 $root = wait_for_element("ytd-app"); // inner ytd-player instance /** * @type {YtdInstance} */ _ytd_player_ = null; $player_root = null; $right_controls = this.wait_for_element(".ytp-right-controls"); $left_controls = this.wait_for_element(".ytp-left-controls"); $settings_button = this.wait_for_element(".ytp-settings-button"); ready = new Promise(async (resolve, reject) => { const query_player = async () => { const root = await this.$root; this.$player_root = root.querySelector(".html5-video-player"); if (this.$player_root) { resolve(); return this.$player_root; } }; const instance = await this.ytd_player_instance(); if (!instance) { reject(new Error("can't find ytd-player instance")); return; } instance.addEventListener(YtdApp.EVENT_onReady, query_player); instance.addEventListener(YtdApp.EVENT_onPlayVideo, query_player); instance.addEventListener(YtdApp.EVENT_onVideoDataChange, query_player); instance.addEventListener(YtdApp.EVENT_onVideoProgress, query_player); // 不需要... // setTimeout(() => { // reject(new Error("timeout")); // }, 1000 * 60); }); async ytd_player_instance() { while (!this._ytd_player_) { const $player = await wait_for_element("ytd-player"); this._ytd_player_ = $player.player_; await delay(1000); } return this._ytd_player_; } /** * * @returns {Promise<Number>} */ async get_player_state() { const instance = await this.ytd_player_instance(); return instance.getPlayerState(); } /** * * @param {String} event */ async wait_for_player_event(event) { const instance = await this.ytd_player_instance(); return new Promise((resolve) => { instance.addEventListener(event, resolve); }); } /** * * @param {String} selector * @returns {Promise<HTMLElement>} */ async wait_for_element(selector) { await this.ready; const $player = await this.get_player_root(); let element = $player.querySelector(selector); while (!element) { await delay(500); element = $player.querySelector(selector); } return element; } /** * * @returns {Promise<HTMLDivElement>} */ async get_player_root() { if (this.$player_root) { return this.$player_root; } await this.ready; this.$player_root = document.querySelector(".html5-video-player"); if (this.$player_root) { return this.$player_root; } throw new Error("can't find player element"); } } const ytd_app = new YtdApp(); class YtpPlayer { ui = new YtpPlayerUI(); rotate_transform = new RotateTransform(); $player = ytd_app.get_player_root(); $video = null; // 从$player中获取 enabled = true; constructor() { this.ready = this.setup(); this.ready.then(() => { console.log("[ytp-rotate:player] ready"); this.setup_observer(); }); } async setup_observer() { // ready 之后监听player元素变化 this.observe_player_rerender(); this.observe_player_resize(); const debounce_update = debounce(() => this.update(), 300); // FIXME 没有gc window.addEventListener("resize", debounce_update); window.addEventListener("popstate", debounce_update); const instance = await ytd_app.ytd_player_instance(); if (!instance) { console.warn("[ytp-rotate] can't find ytd-player instance"); return; } instance.addEventListener( YtdApp.EVENT_onVideoDataChange, debounce_update ); } async setup() { await this.$player; await this.mount_rotate_component(); await this.mount_ui_component(); this.enable(); } // 监听player元素变化 // NOTE: 加这个是因为油管的广告也是用player播放,并且播放完之后video元素不会复用...直接就删了...所以需要监听player的子元素变化 // NOTE2: 其实理论上说css写在player上就行了,但是计算缩放需要针对特定的视频分辨率,所以还是写在video上比较好 async observe_player_rerender() { if (window.MutationObserver === undefined) { // 有可能没有 console.warn( `[ytp-rotate] MutationObserver not supported, can't observe player` ); return; } const observer = new MutationObserver((mutationsList, observer) => { for (const mutation of mutationsList) { if (mutation.type === "childList") { const video_elem = mutation.target.querySelector(".html5-main-video"); if (!video_elem) { continue; } if (video_elem !== this.$video) { this.$video = video_elem; this.reset_rotate_component().catch((e) => console.error("[ytp-rotate] reset_rotate_component failed", e) ); // FIXME 这里最好ui也reset一下,但是现在暂时不用 // this.reset_ui_component(); } } } }); observer.observe(await this.$player, { childList: true }); } async observe_player_resize() { if (window.ResizeObserver === undefined) { console.warn( `[ytp-rotate] ResizeObserver not supported, can't observe player` ); return; } const $player = await this.$player; const observer = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.target === $player) { this.update(); } } }); observer.observe($player); } async mount_ui_component() { const $player = await this.$player; const $video = $player.querySelector( ".html5-video-container .html5-main-video" ); if (!$video) { throw new Error("can't find video element"); } this.$video = $video; this.ui.mount($video, $player); } async mount_rotate_component() { const $player = await this.$player; const $video = $player.querySelector( ".html5-video-container .html5-main-video" ); if (!$video) { throw new Error("can't find video element"); } this.$video = $video; this.rotate_transform.mount($video, $player); } // 重置旋转组件 // NOTE 现在只有video元素变化才会调用 async reset_rotate_component() { console.warn( `[ytp-rotate] video element changed, reset rotate component...` ); this.rotate_transform.unmount(); this.rotate_transform = new RotateTransform(); await this.mount_rotate_component(); } async reset_ui_component() { console.warn(`[ytp-rotate] video element changed, reset ui component...`); this.ui.unmount(); this.ui = new YtpPlayerUI(); await this.mount_ui_component(); } enable() { this.enabled = true; this.ui.enable(); this.rotate_transform.enable(); this.update(); } disable() { this.enabled = false; this.ui.disable(); this.rotate_transform.disable(); this.rotate_transform.reset(); this.update(); } async is_visible() { return (await this.$player).getBoundingClientRect().width > 0; } async update() { if (!this.enabled || !(await this.is_visible())) { return; } await this.ready; this.rotate_transform.update(); this.ui.update(); // debug // console.log("[ytp-rotate] update", Date.now()); } } class YtpPlayerUI { key2dom = {}; enabled = true; elements = []; buttons = []; menuitems = []; constructor() { // pass } mount($video, $player) { if (!($video instanceof HTMLVideoElement)) { throw new Error("$video must be a HTMLVideoElement"); } if (!($player instanceof HTMLElement)) { throw new Error("$player must be a HTMLElement"); } this.$video = $video; this.$player = $player; } unmount() { this.disable(); for (const element of this.elements) { element.remove(); } this.elements = []; this.buttons = []; this.menuitems = []; this.key2dom = {}; } enable() { this.enabled = true; // for (const dom of Object.values(this.key2dom)) { // dom.hidden = false; // } } disable() { this.enabled = false; // NOTE 因为隐藏之后menu container不会resize所以算了不隐藏了... // for (const [key, dom] of Object.entries(this.key2dom)) { // if (key === "menu_toggle_plugin") continue; // dom.hidden = true; // } } update() { for (const item of Object.values(this.menuitems)) { item.on_update?.(); } } async add_button({ html = "", class_name = "ytp-button", on_click, css_text = "", id, key = "", title = "", to_right = true, } = {}) { const $right_controls = await ytd_app.$right_controls; const $left_controls = await ytd_app.$left_controls; const $settings_button = await ytd_app.$settings_button; const $button = $settings_button.cloneNode(true); this.elements.push($button); $button.innerHTML = trusted_html(html); $button.classList.add(class_name); if (css_text) $button.style.cssText = css_text; if (id) $button.id = id; if (key) this.key2dom[key] = $button; if (title) { $button.title = title; $button.setAttribute("aria-label", title); } if (on_click) $button.addEventListener("click", async (ev) => { try { await on_click(ev); } catch (error) { console.error(error); } }); if (to_right) { $right_controls.insertBefore( $button, $right_controls.firstElementChild ); } else { $left_controls.appendChild($button); } this.buttons.push({ $button, on_click, key, id, }); this.button_normalize($button); return $button; } button_normalize($btn) { if (!($btn instanceof HTMLButtonElement)) { return; } // 移除quality-badge相关class for (const cls of $btn.classList) { if (cls.endsWith("quality-badge")) { $btn.classList.remove(cls); } } [ "aria-controls", "aria-haspopup", "aria-expanded", "aria-pressed", ].forEach((attr) => { $btn.removeAttribute(attr); }); ["tooltipText", "tooltipTargetId"].forEach((attr) => { delete $btn.dataset[attr]; }); } query_cache = {}; // menu的query需要等待contextmenu事件再开始检测 wait_for_menu_element(selector) { if (this.query_cache[selector]) { return this.query_cache[selector]; } const dom = document.querySelector(selector); if (dom) { this.query_cache[selector] = Promise.resolve(dom); return Promise.resolve(dom); } return new Promise((resolve) => { // 因为video元素随时会销毁,所以需要监听parent上 const target = this.$video.parentElement; const handler = (ev) => { // 如果不是右键 if (ev.button !== 2) return; const domP = wait_for_element(selector); this.query_cache[selector] = domP; resolve(domP); target.removeEventListener("mousedown", handler); }; target.addEventListener("mousedown", handler); }); } async add_menu({ label = "", content = '<div class="ytp-menuitem-toggle-checkbox"></div>', href = "", icon, on_click, key, on_update, } = {}) { const [$panel_menu, $panel_menu_link_tpl, $panel_menu_div_tpl] = await Promise.all([ this.wait_for_menu_element( ".ytp-contextmenu>.ytp-panel>.ytp-panel-menu" ), this.wait_for_menu_element( ".ytp-contextmenu>.ytp-panel>.ytp-panel-menu>a.ytp-menuitem" ), this.wait_for_menu_element( ".ytp-contextmenu>.ytp-panel>.ytp-panel-menu>div.ytp-menuitem" ), ]); let $element = null; if (href) { $element = $panel_menu_link_tpl.cloneNode(true); $element.href = href; } else { $element = $panel_menu_div_tpl.cloneNode(true); } this.elements.push($element); const $label = $element.querySelector(".ytp-menuitem-label"); const $content = $element.querySelector(".ytp-menuitem-content"); const $icon = $element.querySelector(".ytp-menuitem-icon"); const __on_update = (ev) => on_update && on_update({ $element, $label, $content, $icon, ev }); if (key) this.key2dom[key] = $element; if (label) $label.innerHTML = trusted_html(label); if (content) $content.innerHTML = trusted_html(content); if (on_click) $element.addEventListener("click", async (ev) => { try { await on_click?.(ev); await __on_update(ev); } catch (error) { console.error(error); } }); if (icon) $icon.innerHTML = trusted_html(icon); $panel_menu.appendChild($element); this.menuitems.push({ $element, on_click, key, on_update: __on_update, }); return $element; } } class RotateTransform { status = { rotate: 0, // 0 1 2 3 => 0 90 180 270 horizontal: false, vertical: false, // 类似于background-image的cover,但是会居中 cover_screen: false, }; styles = { transform: [], }; $style = document.createElement("style"); constructor() { this.enable(); } isNoneEffect() { const { rotate, horizontal, vertical, cover_screen } = this.status; return ( rotate === 0 && horizontal === false && vertical === false && cover_screen === false ); } mount($video, $player) { if (!($video instanceof HTMLVideoElement)) { throw new Error("$video must be a HTMLVideoElement"); } if (!($player instanceof HTMLElement)) { throw new Error("$player must be a HTMLElement"); } this.$video = $video; this.$player = $player; $video.classList.add(constants.style_rule_name); } unmount() { this.disable(); this.$video.classList.remove(constants.style_rule_name); this.$video = null; this.$player = null; } updateRule(overwrite_str) { const cssText = overwrite_str === undefined ? $css(this.styles) : overwrite_str; this.$style.innerHTML = trusted_html( `.${constants.style_rule_name}{${cssText}}` ); } /** * 计算缩放值K */ calcScaleK() { const { $player, $video } = this; if (!$player || !$video) { throw new Error("can't find player or video element"); } const [pw, ph] = [$player.clientWidth, $player.clientHeight]; let [w, h] = [$video.clientWidth, $video.clientHeight]; // 这里替换是因为旋转之后等于 wh 对调 if (this.status.rotate % 2 == 1) { [w, h] = [h, w]; } if (this.status.cover_screen) { // 适配w的面积 const fit_w_size = pw * (pw / w) * h; // 适配h的面积 const fit_h_size = ph * (ph / h) * w; if (fit_h_size > fit_w_size) { return ph / h; } else { return pw / w; } } // NOTE: 下面这个写的有点懵逼,忘记怎么算的了不改了,能用... // pw === w if (~~((pw * h) / w) <= h) { // 💥💥💥 return pw / w; } // ph === h return ph / h; } update() { const { $player, $video } = this; if (!$player || !$video) { throw new Error("can't find player or video element"); } if (this.isNoneEffect()) { // 清空副作用 this.updateRule(""); return; } const scaleK = this.calcScaleK(); const transform_arr = [ `rotate(${this.status.rotate * 90}deg)`, `scale(${scaleK})`, ]; const append_transform = (text) => transform_arr.push(text); if (this.status.horizontal) { if (this.status.rotate % 2 == 1) append_transform("rotateX(180deg)"); else append_transform("rotateY(180deg)"); } if (this.status.vertical) { if (this.status.rotate % 2 == 1) append_transform("rotateY(180deg)"); else append_transform("rotateX(180deg)"); } this.styles.transform = transform_arr; this.updateRule(); } enabled = true; enable() { this.enabled = true; document.getElementsByTagName("head")[0].appendChild(this.$style); } disable() { this.enabled = false; this.$style.remove(); } rotate() { if (!this.enabled) return; this.status.rotate = (this.status.rotate + 1) % 4; this.update(); return this.status.rotate; } toggle_horizontal() { if (!this.enabled) return; this.status.horizontal = !this.status.horizontal; this.update(); return this.status.horizontal; } toggle_vertical() { if (!this.enabled) return; this.status.vertical = !this.status.vertical; this.update(); return this.status.vertical; } toggle_cover_screen() { if (!this.enabled) return; this.status.cover_screen = !this.status.cover_screen; this.update(); return this.status.cover_screen; } reset() { this.status.rotate = 0; this.status.horizontal = false; this.status.vertical = false; this.update(); return this.status; } } async function main() { const player = new YtpPlayer(); await player.ready; window.addEventListener("contextmenu", () => { player.ui.update(); }); // setup buttons await player.ui.add_button({ html: assets.icons.rotate, on_click: () => player.rotate_transform.rotate(), css_text: $css( { display: "inline-flex", "align-items": "center", "justify-content": "center", width: "48px", height: "48px", color: "#fff", fill: "#fff", "vertical-align": "top", }, false ), id: "rotate-btn", title: i18n("click_rotate"), key: "btn_rotate", }); await player.ui.add_button({ html: assets.icons.fullscreen, on_click: () => player.rotate_transform.toggle_cover_screen(), css_text: $css( { display: "inline-flex", "align-items": "center", "justify-content": "center", width: "48px", height: "48px", color: "#fff", fill: "#fff", "vertical-align": "top", }, false ), id: "cover-screen-btn", title: i18n("click_cover_screen"), key: "btn_cover_screen", }); // setup contextmenu await player.ui.add_menu({ key: "menu_toggle_plugin", label: i18n("toggle_plugin"), icon: '<div style="text-align: center;font-size: 24px">🎠</div>', on_click: (ev) => { if (player.enabled) { player.disable(); } else { player.enable(); player.update(); } }, on_update: ({ $element }) => { $element.setAttribute("aria-checked", player.enabled.toString()); }, }); // rotate menuitem await player.ui.add_menu({ key: "menu_rotate", on_click: (ev) => { player.rotate_transform.rotate(); }, on_update: ({ $content }) => { $content.innerHTML = trusted_html( player.rotate_transform.status.rotate * 90 + "°" ); }, label: i18n("rotate90"), content: "0°", icon: assets.icons.rotate, }); // cover_screen menuitem await player.ui.add_menu({ key: "menu_cover_screen", on_click: (ev) => { player.rotate_transform.toggle_cover_screen(); }, on_update: ({ $element }) => { $element.setAttribute( "aria-checked", player.rotate_transform.status.cover_screen.toString() ); }, label: i18n("cover_screen"), icon: assets.icons.fullscreen, }); // flip horizontal await player.ui.add_menu({ key: "menu_horizontal", on_click(ev) { player.rotate_transform.toggle_horizontal(); }, on_update: ({ $element }) => { $element.setAttribute( "aria-checked", player.rotate_transform.status.horizontal.toString() ); }, label: i18n("flip_horizontal"), icon: assets.icons.flip_horizontal, }); // flip vertical await player.ui.add_menu({ key: "menu_vertical", on_click(ev) { player.rotate_transform.toggle_vertical(); }, on_update: ({ $element }) => { $element.setAttribute( "aria-checked", player.rotate_transform.status.vertical.toString() ); }, label: i18n("flip_vertical"), icon: assets.icons.flip_vertical, }); // picture in picture await player.ui.add_menu({ key: "menu_pip", on_click(ev) { if (document.pictureInPictureElement) { return document.exitPictureInPicture(); } else { return $("video").requestPictureInPicture(); } }, on_update: ({ $element }) => { $element.setAttribute( "aria-checked", Boolean(document.pictureInPictureElement).toString() ); }, label: i18n("PIP"), icon: assets.icons.pip, }); return player; } console.log(`[ytp-rotate] ${constants.version} (${constants.user_lang})`); main() .then(() => console.log(`[ytp-rotate] ready`)) .catch((err) => { console.error("[ytp-rotate]", err); }); })();