Suno Speed Control

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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.
   *
   ************************************************************************/
})();