Quick speed buttons and custom playback rate input to Freesound players
Size
13.2 KB
Version
0.0.1
Created
Oct 22, 2025
Updated
1 day ago
1// ==UserScript==
2// @name Freesound Speed Control
3// @namespace https://freesound.org/
4// @version 0.0.1
5// @description Quick speed buttons and custom playback rate input to Freesound players
6// @author Who me
7// @match *://*.freesound.org/*
8// @icon https://freesound.org/static/img/favicon.ico
9// @grant GM_addStyle
10// ==/UserScript==
11(function () {
12 'use strict';
13 const SPEEDS = [.25, .5, 1, 2, 3, 4, 8, 12, 16];
14 const STORE_KEY = 'fs_speed';
15 const MIN_RATE = 0.1;
16 const MAX_RATE = 16; // more might not work
17
18 GM_addStyle(`
19.tm-speedbar.top-left {
20 position: absolute;
21 top: 4px;
22 left: 4px;
23 margin: 0;
24 z-index: 1000;
25 pointer-events: none;
26}
27.tm-speedbar.top-left * { pointer-events: auto; }
28.tm-speedbar--fallback {
29 position: relative;
30 z-index: 1;
31 display: inline-flex;
32 margin: .4rem 0 .2rem;
33}
34.tm-speed-btn, .tm-speed-input {
35 font: inherit;
36 line-height: 1;
37 padding: .05rem .05rem;
38 border-radius: .2rem;
39 border: 1px solid rgba(0,128,0,.3);
40 background: rgba(144,238,144,.75);
41 cursor: pointer;
42 color: #006400;
43}
44.tm-speed-btn:hover { background: rgba(144,238,144,.95); }
45.tm-speed-btn.tm-active { border-color: #00aa00; background: rgba(50,205,50,.85); }
46.tm-speed-input { width: 3.5rem; text-align: center; }
47.tm-speed-label { opacity: .9; font-size: 0.8em; margin-right: .1rem; user-select: none; }
48 `);
49
50 const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
51 const saveRate = (rate) => localStorage.setItem(STORE_KEY, String(rate));
52 const loadRate = () => {
53 const v = parseFloat(localStorage.getItem(STORE_KEY) || '1');
54 return Number.isFinite(v) ? clamp(v, MIN_RATE, MAX_RATE) : 1;
55 };
56
57 // Session-global override set ONLY by "Apply-to-all"
58 let GLOBAL_RATE = null;
59
60 function findAudio(root) {
61 let audio = root.querySelector('audio');
62 if (!audio) {
63 const maybe = root.nextElementSibling;
64 if (maybe && maybe.tagName === 'AUDIO') audio = maybe;
65 }
66 return audio;
67 }
68
69 // NOTE: makeButton no longer tries to touch any input directly (prevents cross-player bleed).
70 function makeButton(rate, apply) {
71 const btn = document.createElement('button');
72 btn.type = 'button';
73 btn.className = 'tm-speed-btn';
74 btn.textContent = `${rate}`;
75 btn.dataset.rate = String(rate);
76 btn.addEventListener('click', () => apply(rate));
77 return btn;
78 }
79
80 // Only the first enhanced player should auto-load the saved/default rate
81 let appliedInitialOnce = false;
82
83 function enhancePlayer(player) {
84 if (!player || player.__tmEnhanced) return;
85 const audio = findAudio(player);
86 if (!audio) return;
87
88 player.__tmEnhanced = true;
89
90 const controlsBar =
91 player.querySelector('.bw-player__controls') ||
92 player.querySelector('.bw-player__controls--big');
93
94 const bar = document.createElement('div');
95 bar.className = 'tm-speedbar top-left';
96 if (!controlsBar) bar.classList.add('tm-speedbar--fallback');
97
98 player.style.position = 'relative';
99 player.prepend(bar);
100
101 const label = document.createElement('span');
102 label.className = 'tm-speed-label';
103 label.textContent = '';
104 bar.appendChild(label);
105
106 // Create custom input first so inner closures can reference it safely
107 const input = document.createElement('input');
108 input.className = 'tm-speed-input';
109 input.type = 'number';
110 input.step = '0.1';
111 input.min = String(MIN_RATE);
112 input.max = String(MAX_RATE);
113 input.placeholder = 'custom×';
114 input.title = `Enter custom speed (${MIN_RATE}–${MAX_RATE})`;
115 bar.appendChild(input);
116
117 // Buttons (they call setRate; setRate/applyRate/syncUI update input & highlight)
118 const rateBtns = SPEEDS.map((r) => makeButton(r, setRate));
119 rateBtns.forEach((b) => bar.appendChild(b));
120
121 function handleInputEvent() {
122 const val = clamp(parseFloat(input.value || 'NaN'), MIN_RATE, MAX_RATE);
123 if (Number.isFinite(val)) setRate(val);
124 }
125 input.addEventListener('input', handleInputEvent);
126 input.addEventListener('change', handleInputEvent);
127 input.addEventListener('keydown', (e) => { if (e.key === 'Enter') input.blur(); });
128
129 // --- Reverse playback toggle (unchanged logic) ---
130 const reverseBtn = document.createElement('button');
131 reverseBtn.type = 'button';
132 reverseBtn.className = 'tm-speed-btn';
133 reverseBtn.textContent = '⇋';
134 reverseBtn.title = 'Toggle reverse playback';
135 bar.appendChild(reverseBtn);
136
137 let reverseCtx = null;
138 let reverseSource = null;
139 let reverseRAF = null;
140 let reverseStartOffset = 0;
141 let reverseDuration = 0;
142 let reverseStartCtxTime = 0;
143
144 const indicator = player.querySelector('.bw-player__progress-indicator');
145 const indicatorContainer = player.querySelector('.bw-player__progress-indicator-container');
146
147 function stopReverse(commitTime = false) {
148 if (reverseSource) {
149 try { reverseSource.stop(); } catch (_) {}
150 try { reverseSource.disconnect(); } catch (_) {}
151 reverseSource = null;
152 }
153 if (reverseRAF) {
154 cancelAnimationFrame(reverseRAF);
155 reverseRAF = null;
156 }
157 if (indicator) indicator.style.transform = '';
158
159 if (commitTime && reverseDuration > 0 && reverseCtx) {
160 const elapsed = (reverseCtx.currentTime - reverseStartCtxTime) * (audio.playbackRate || 1);
161 const T = Math.max(0, reverseStartOffset - elapsed);
162 audio.currentTime = T;
163 }
164
165 reverseBtn.classList.remove('tm-active');
166 }
167
168 reverseBtn.addEventListener('click', async () => {
169 if (reverseSource) {
170 stopReverse(true);
171 audio.play();
172 return;
173 }
174 audio.pause();
175 stopReverse();
176
177 if (!reverseCtx) reverseCtx = new (window.AudioContext || window.webkitAudioContext)();
178 try {
179 if (!player.__tmReverseBuffer) {
180 const resp = await fetch(audio.currentSrc);
181 const ab = await resp.arrayBuffer();
182 const decoded = await reverseCtx.decodeAudioData(ab);
183 for (let ch = 0; ch < decoded.numberOfChannels; ch++) {
184 decoded.getChannelData(ch).reverse();
185 }
186 player.__tmReverseBuffer = decoded;
187 }
188
189 const decoded = player.__tmReverseBuffer;
190 reverseDuration = decoded.duration;
191 reverseStartOffset = audio.currentTime > 0 ? audio.currentTime : reverseDuration;
192 const reversedOffset = reverseDuration - reverseStartOffset;
193
194 reverseSource = reverseCtx.createBufferSource();
195 reverseSource.buffer = decoded;
196 reverseSource.playbackRate.value = audio.playbackRate || 1;
197 reverseSource.loop = audio.loop;
198 reverseSource.connect(reverseCtx.destination);
199 reverseSource.start(0, reversedOffset);
200
201 reverseStartCtxTime = reverseCtx.currentTime;
202 reverseBtn.classList.add('tm-active');
203
204 if (indicator && indicatorContainer) {
205 const width = indicatorContainer.offsetWidth || 0;
206 const step = () => {
207 if (!reverseSource) return;
208 const rate = audio.playbackRate || 1;
209 const elapsed = (reverseCtx.currentTime - reverseStartCtxTime) * rate;
210
211 let T;
212 if (reverseSource.loop) {
213 const cycleTime = (reverseStartOffset - elapsed) % reverseDuration;
214 T = (cycleTime <= 0 ? cycleTime + reverseDuration : cycleTime);
215 } else {
216 T = Math.max(0, reverseStartOffset - elapsed);
217 }
218
219 const percent = T / reverseDuration;
220 const x = -((1 - percent) * width);
221 indicator.style.transform = `translateX(${x}px)`;
222
223 if (!reverseSource.loop && T <= 0) {
224 stopReverse(true);
225 return;
226 }
227 reverseRAF = requestAnimationFrame(step);
228 };
229 reverseRAF = requestAnimationFrame(step);
230 }
231
232 reverseSource.onended = reverseSource.loop ? null : () => stopReverse(true);
233 } catch (err) {
234 console.error('[FS Speed] Reverse playback failed:', err);
235 stopReverse();
236 }
237 });
238
239 audio.addEventListener('play', () => stopReverse());
240 const stopBtn = player.querySelector('.bw-icon-stop');
241 if (stopBtn) stopBtn.closest('button')?.addEventListener('click', () => stopReverse());
242
243 audio.addEventListener('ratechange', () => {
244 if (reverseSource) reverseSource.playbackRate.value = audio.playbackRate || 1;
245 });
246
247 // Local stop
248 const stopLocalBtn = document.createElement('button');
249 stopLocalBtn.type = 'button';
250 stopLocalBtn.className = 'tm-speed-btn';
251 stopLocalBtn.textContent = '⏹';
252 stopLocalBtn.title = 'Stop playback for this player';
253 bar.appendChild(stopLocalBtn);
254 stopLocalBtn.addEventListener('click', () => {
255 stopReverse();
256 try { audio.pause(); } catch(_) {}
257 audio.currentTime = 0;
258 });
259
260 // Apply-to-all
261 const applyBtn = document.createElement('button');
262 applyBtn.type = 'button';
263 applyBtn.className = 'tm-speed-btn';
264 applyBtn.textContent = '⇉';
265 applyBtn.title = 'Apply current speed to all players on this page';
266 bar.appendChild(applyBtn);
267
268 applyBtn.addEventListener('click', () => {
269 const rate = audio.playbackRate || 1;
270 GLOBAL_RATE = rate; // session override
271 saveRate(rate); // persist for reloads
272
273 // Update every player on the page now
274 document.querySelectorAll('.bw-player').forEach((pl) => {
275 if (pl === player) return;
276 if (pl.__tmApplyRate) {
277 pl.__tmApplyRate(rate); // will sync buttons + inputs
278 } else {
279 const a = findAudio(pl);
280 if (a) {
281 try { a.playbackRate = rate; } catch(_) {}
282 }
283 }
284 });
285 });
286
287 // --- Core rule: PLAY uses what's in THIS player's input field ---
288 audio.addEventListener('play', () => {
289 const val = clamp(parseFloat(input.value || 'NaN'), MIN_RATE, MAX_RATE);
290 if (Number.isFinite(val)) {
291 applyRate(val);
292 } else {
293 // if input is empty/invalid, fall back to session-global (if set) or current audio rate
294 const eff = GLOBAL_RATE !== null ? GLOBAL_RATE : (audio.playbackRate || 1);
295 applyRate(eff);
296 }
297 });
298
299 // Keep UI in sync if something else changes the playbackRate
300 audio.addEventListener('ratechange', syncUI);
301
302 // Optional: improve pitch handling
303 try {
304 audio.preservesPitch = false;
305 audio.mozPreservesPitch = false;
306 audio.webkitPreservesPitch = false;
307 } catch (_) {}
308
309 function setRate(v) {
310 const rate = clamp(v, MIN_RATE, MAX_RATE);
311 applyRate(rate);
312 saveRate(rate); // for next page load
313 }
314
315 function applyRate(v) {
316 player.__tmApplyRate = applyRate;
317 const rate = clamp(v, MIN_RATE, MAX_RATE);
318 try { audio.playbackRate = rate; } catch (e) {
319 console.warn('[FS Speed] Failed to set rate', e);
320 }
321 syncUI();
322 }
323
324 function syncUI() {
325 const r = Math.round((audio.playbackRate || 1) * 100) / 100;
326 rateBtns.forEach((b) => {
327 const same = Math.abs(parseFloat(b.dataset.rate) - r) < 0.001;
328 b.classList.toggle('tm-active', same);
329 });
330 input.value = String(r);
331 }
332
333 // --- Initial per-player setup ---
334 const initRate = (GLOBAL_RATE !== null ? GLOBAL_RATE : loadRate());
335 applyRate(initRate);
336 }
337
338 // Enhance existing players
339 document.querySelectorAll('.bw-player').forEach(enhancePlayer);
340
341 // Enhance dynamically added players
342 const mo = new MutationObserver((muts) => {
343 for (const m of muts) {
344 m.addedNodes.forEach((n) => {
345 if (!(n instanceof HTMLElement)) return;
346 if (n.matches && n.matches('.bw-player')) enhancePlayer(n);
347 n.querySelectorAll && n.querySelectorAll('.bw-player').forEach(enhancePlayer);
348 });
349 }
350 });
351 mo.observe(document.documentElement, { childList: true, subtree: true });
352
353})();