Suno Speed Control

Adds a speed control, as well as forward and back skips to the song player controls

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Suno Speed Control
// @match        https://suno.com/*
// @version      1.4
// @namespace    https://greasyfork.org/en/users/922168-mark-zinzow
// @author       Mark Zinzow
// @description  Adds a speed control, as well as forward and back skips to the song player controls
// @grant        none
// @license MIT
// ==/UserScript==
/* eslint-disable spaced-comment */

(function () {
  'use strict';

  /************************************************************************
   * CONFIGURATION — tweak these values
   ************************************************************************/
  const TARGET_SELECTOR = '.mx-auto.flex.flex-col.items-center.justify-center.gap-1';
  const STORAGE_KEY = 'suno_playback_rate';
  const DEFAULT_RATE = 1.0;

  // Speed range and step (0.25 increments by default)
  const SPEED_MIN = 0.5;
  const SPEED_MAX = 2.5;
  const SPEED_STEP = 0.25;

  // Skip controls: default seconds to skip when pressing the buttons
  const SKIP_SECONDS_DEFAULT = 7;

  // Force the dropdown to open upward (Spotify-like behavior)
  // Set to true by default per your request
  const ALWAYS_OPEN_UP = true;

  // IDs for created elements (used to avoid duplicates)
  const WRAPPER_ID = 'suno-speed-skip-wrapper';
  const DROPDOWN_ID = 'suno-speed-dropdown';
  const BACK_BTN_ID = 'suno-skip-back';
  const FORWARD_BTN_ID = 'suno-skip-forward';

  /************************************************************************
   * UTILITIES
   ************************************************************************/
  function applyRateToAll(rate) {
    document.querySelectorAll('audio').forEach(a => {
      try { a.playbackRate = rate; } catch (e) { /* ignore */ }
    });
  }

  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }

  function skipAll(deltaSeconds) {
    document.querySelectorAll('audio').forEach(a => {
      try {
        const newTime = clamp(a.currentTime + deltaSeconds, 0, a.duration || Number.MAX_SAFE_INTEGER);
        a.currentTime = newTime;
      } catch (e) {}
    });
  }

  /************************************************************************
   * CREATE CONTROLS
   ************************************************************************/
  function createDropdown() {
    const select = document.createElement('select');
    select.id = DROPDOWN_ID;
    select.title = 'Playback speed';

    // Compact centered select for inline player controls
    select.style.padding = '3px 6px';
    select.style.borderRadius = '3px';
    select.style.background = 'var(--color-background-glass, #000)';
    select.style.color = 'var(--color-foreground-primary, #fff)';
    select.style.border = '1px solid rgba(255,255,255,0.06)';
    select.style.fontSize = '12px';
    select.style.cursor = 'pointer';
    select.style.minWidth = '36px';
    select.style.boxSizing = 'border-box';
    select.style.position = 'relative';
    select.style.zIndex = 999999;
    select.style.display = 'inline-flex';     // use flex to center contents
    select.style.alignItems = 'center';        // vertical centering
    select.style.justifyContent = 'center';    // horizontal centering for single-line text
    select.style.height = '34px';              // match Suno button height
    select.style.lineHeight = '34px';          // fallback centering for older browsers
    select.style.flex = '0 0 auto';            // prevent stretching/wrapping in flex rows
    select.style.paddingRight = '18px';        // leave room for native dropdown arrow

    // Spotify-like upward opening (Chromium trick)
    if (ALWAYS_OPEN_UP) {
      // direction: rtl flips the dropdown list in many Chromium browsers
      select.style.direction = 'rtl';
      // keep the text left-aligned for readability
      select.style.textAlign = 'left';
      // adjust padding so text and arrow don't collide
      select.style.paddingLeft = '8px';
      select.style.paddingRight = '18px';
    }

    // Populate options
    for (let s = SPEED_MIN; s <= SPEED_MAX + 0.0001; s = +(s + SPEED_STEP).toFixed(2)) {
      const opt = document.createElement('option');
      opt.value = s.toFixed(2);
      opt.textContent = `${s.toFixed(2)}x`;
      select.appendChild(opt);
    }

    // Restore saved rate
    const saved = parseFloat(localStorage.getItem(STORAGE_KEY));
    select.value = (!isNaN(saved) ? saved : DEFAULT_RATE).toFixed(2);

    // Change handler
    select.addEventListener('change', () => {
      const rate = parseFloat(select.value);
      localStorage.setItem(STORAGE_KEY, rate);
      applyRateToAll(rate);
    });

    // Fallback flip logic for non-Chromium browsers or if ALWAYS_OPEN_UP is false
    if (!ALWAYS_OPEN_UP) {
      select.addEventListener('mousedown', () => {
        const rect = select.getBoundingClientRect();
        const spaceBelow = window.innerHeight - rect.bottom;
        const spaceAbove = rect.top;
        if (spaceAbove > spaceBelow) {
          select.style.transform = 'translateY(-100%)';
        } else {
          select.style.transform = 'translateY(0)';
        }
      });
    }

    return select;
  }

  function createSkipButton(id, label, ariaLabel, onClick) {
    const btn = document.createElement('button');
    btn.id = id;
    btn.type = 'button';
    btn.setAttribute('aria-label', ariaLabel);
    btn.title = ariaLabel;
    btn.textContent = label;
    // Compact circular style to match Suno buttons
    btn.style.display = 'inline-flex';
    btn.style.alignItems = 'center';
    btn.style.justifyContent = 'center';
    btn.style.width = '34px';
    btn.style.height = '34px';
    btn.style.borderRadius = '50%';
    btn.style.border = 'none';
    btn.style.background = 'transparent';
    btn.style.color = 'var(--color-foreground-primary, #fff)';
    btn.style.cursor = 'pointer';
    btn.style.fontSize = '16px';
    btn.style.padding = '0';
    btn.style.flex = '0 0 auto';
    btn.style.lineHeight = '1';
    btn.addEventListener('click', onClick);
    return btn;
  }

  /************************************************************************
   * INJECTION LOGIC — insert as last child of the button row (inline)
   ************************************************************************/
  function injectIntoPlayer(target) {
    if (!target) return;

    // Avoid duplicates
    if (document.getElementById(WRAPPER_ID)) return;

    // Find the button row (first row with controls)
    const buttonRow = target.querySelector('div.flex.flex-row.items-center.gap-2') || target.querySelector('div.flex.flex-row.items-center');
    const insertBase = buttonRow || target;

    // Create wrapper (inline-flex so it sits on the same row)
    const wrapper = document.createElement('div');
    wrapper.id = WRAPPER_ID;
    wrapper.style.display = 'inline-flex';      // inline so it doesn't force a new block line
    wrapper.style.alignItems = 'center';
    wrapper.style.gap = '6px';
    wrapper.style.margin = '0 0 0 8px';         // only left margin
    wrapper.style.userSelect = 'none';
    wrapper.style.flex = '0 0 auto';            // prevent it from stretching or wrapping
    wrapper.style.alignSelf = 'center';
    wrapper.style.zIndex = 999999;

    // Optional small label (can remove if you prefer icon-only)
    const label = document.createElement('span');
    label.textContent = 'Speed';
    label.style.fontSize = '12px';
    label.style.color = 'var(--color-foreground-tertiary, rgba(255,255,255,0.75))';
    label.style.userSelect = 'none';
    label.style.marginRight = '4px';
    label.style.flex = '0 0 auto';

    // Create controls
    const backBtn = createSkipButton(BACK_BTN_ID, '⟲', `Skip back ${SKIP_SECONDS_DEFAULT}s`, () => skipAll(-SKIP_SECONDS_DEFAULT));
    const forwardBtn = createSkipButton(FORWARD_BTN_ID, '↻', `Skip forward ${SKIP_SECONDS_DEFAULT}s`, () => skipAll(SKIP_SECONDS_DEFAULT));
    const dropdown = createDropdown();

    // Append in desired order (back, forward, label, dropdown)
    wrapper.appendChild(backBtn);
    wrapper.appendChild(forwardBtn);
    wrapper.appendChild(label);
    wrapper.appendChild(dropdown);

    // Insert as the last child of the button row so it sits inline to the right
    try {
      insertBase.appendChild(wrapper);
    } catch (e) {
      // Fallback: if append fails, insert before the first child to keep it visible
      if (insertBase.firstElementChild) {
        insertBase.firstElementChild.insertAdjacentElement('beforebegin', wrapper);
      } else {
        insertBase.appendChild(wrapper);
      }
    }

    // Apply saved rate immediately
    applyRateToAll(parseFloat(dropdown.value));
  }

  /************************************************************************
   * OBSERVERS — keep controls present and keep playbackRate applied
   ************************************************************************/
  const rootObserver = new MutationObserver(() => {
    const target = document.querySelector(TARGET_SELECTOR);
    if (target) injectIntoPlayer(target);
  });

  rootObserver.observe(document.body, { childList: true, subtree: true });

  // Ensure playbackRate is applied whenever audio elements are added/changed
  const audioObserver = new MutationObserver(() => {
    const saved = parseFloat(localStorage.getItem(STORAGE_KEY)) || DEFAULT_RATE;
    applyRateToAll(saved);
  });

  audioObserver.observe(document.body, { childList: true, subtree: true });

  // Initial injection attempt
  const initialTarget = document.querySelector(TARGET_SELECTOR);
  if (initialTarget) injectIntoPlayer(initialTarget);

  // Apply saved rate on load
  const savedRate = parseFloat(localStorage.getItem(STORAGE_KEY)) || DEFAULT_RATE;
  applyRateToAll(savedRate);

  /************************************************************************
   * TROUBLESHOOTING NOTES
   *
   * - If the controls wrap to a new line, Suno's button row may allow wrapping.
   *   You can force no-wrap by uncommenting the lines below (may cause horizontal overflow):
   *
   *     const row = document.querySelector(TARGET_SELECTOR + ' div.flex.flex-row.items-center.gap-2');
   *     if (row) row.style.flexWrap = 'nowrap';
   *
   * - If the select text is not vertically centered, adjust select.style.height
   *   and select.style.lineHeight to match the Suno button height (default 34px here).
   *
   * - To remove the label and save horizontal space:
   *     wrapper.removeChild(label);
   *
   * - To change skip seconds without editing the script, I can add a tiny settings UI.
   *
   * - If Suno changes markup, update TARGET_SELECTOR to a stable container.
   *
   ************************************************************************/
})();