Adds a speed control, as well as forward and back skips to the song player controls
// ==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.
*
************************************************************************/
})();