From 6823dd0378f1310d28c7ddac5c07e5efdd86e685 Mon Sep 17 00:00:00 2001 From: Paulo Everton Nunes Novais Date: Wed, 1 Apr 2026 10:57:35 -0300 Subject: [PATCH 1/7] feat(plugins): add Slowed playback plugin --- src/plugins/slowed/index.ts | 340 +++++++++++++++++++++++++++++++++++ src/plugins/slowed/readme.md | 12 ++ src/plugins/slowed/style.css | 248 +++++++++++++++++++++++++ 3 files changed, 600 insertions(+) create mode 100644 src/plugins/slowed/index.ts create mode 100644 src/plugins/slowed/readme.md create mode 100644 src/plugins/slowed/style.css diff --git a/src/plugins/slowed/index.ts b/src/plugins/slowed/index.ts new file mode 100644 index 0000000000..9c3d54611b --- /dev/null +++ b/src/plugins/slowed/index.ts @@ -0,0 +1,340 @@ +import style from './style.css?inline'; +import { createPlugin } from '@/utils'; + +// ─── Config ──────────────────────────────────────────────────────────────── + +export interface SlowedReverbConfig { + enabled: boolean; + speed: number; + reverbAmount: number; + bassBoost: number; + keepPitch: boolean; +} + +const DEFAULT_CONFIG: SlowedReverbConfig = { + enabled: false, + speed: 1.0, + reverbAmount: 0, + bassBoost: 0, + keepPitch: false, +}; + +// Variável global inviolável para guardar a config atual +let currentConfig = { ...DEFAULT_CONFIG }; + +// ─── Audio Engine ────────────────────────────────────────────────────────── + +let audioCtx: AudioContext | null = null; +let sourceNode: MediaElementAudioSourceNode | null = null; +let convolverNode: ConvolverNode | null = null; +let bassFilter: BiquadFilterNode | null = null; +let dryGain: GainNode | null = null; +let wetGain: GainNode | null = null; +let masterGain: GainNode | null = null; +let engineReady = false; +let connectedVideo: HTMLVideoElement | null = null; + +function makeImpulse(ctx: AudioContext, duration = 3, decay = 2): AudioBuffer { + const len = ctx.sampleRate * duration; + const buf = ctx.createBuffer(2, len, ctx.sampleRate); + for (let ch = 0; ch < 2; ch++) { + const d = buf.getChannelData(ch); + for (let i = 0; i < len; i++) { + d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); + } + } + return buf; +} + +function buildEngine(video: HTMLVideoElement) { + if (!audioCtx) { + const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; + audioCtx = new AudioContextClass(); + + bassFilter = audioCtx.createBiquadFilter(); + convolverNode = audioCtx.createConvolver(); + dryGain = audioCtx.createGain(); + wetGain = audioCtx.createGain(); + masterGain = audioCtx.createGain(); + + bassFilter.type = 'lowshelf'; + bassFilter.frequency.value = 150; + bassFilter.gain.value = 0; + + convolverNode.buffer = makeImpulse(audioCtx); + + bassFilter.connect(dryGain); + bassFilter.connect(convolverNode); + convolverNode.connect(wetGain); + dryGain.connect(masterGain); + wetGain.connect(masterGain); + masterGain.connect(audioCtx.destination); + } + + // Garante que o áudio seja reconectado se o YT Music recriar o vídeo + if (connectedVideo !== video) { + if (sourceNode) { + sourceNode.disconnect(); + } + try { + sourceNode = audioCtx.createMediaElementSource(video); + sourceNode.connect(bassFilter); + connectedVideo = video; + engineReady = true; + } catch (e) { + console.warn('[Slowed&Reverb] Aviso ao conectar nó de áudio:', e); + } + } +} + +function applyConfig(cfg: SlowedReverbConfig, video: HTMLVideoElement | null) { + if (!video) return; + + currentConfig = cfg; + + video.playbackRate = cfg.speed; + video.preservesPitch = cfg.keepPitch; + + buildEngine(video); + if (!audioCtx || !dryGain || !wetGain || !bassFilter) return; + + const t0 = audioCtx.currentTime; + + const wet = cfg.reverbAmount / 100; + dryGain.gain.setTargetAtTime(1 - wet * 0.7, t0, 0.05); + wetGain.gain.setTargetAtTime(wet * 2.5, t0, 0.05); + + bassFilter.gain.setTargetAtTime((cfg.bassBoost / 100) * 20, t0, 0.05); + + if (audioCtx.state === 'suspended') { + audioCtx.resume().catch(() => {}); + } +} + +function disableEngine(video: HTMLVideoElement | null) { + if (video) { + video.playbackRate = 1.0; + video.preservesPitch = true; + } + if (!audioCtx || !dryGain || !wetGain || !bassFilter) return; + + const t0 = audioCtx.currentTime; + dryGain.gain.setTargetAtTime(1, t0, 0.05); + wetGain.gain.setTargetAtTime(0, t0, 0.05); + bassFilter.gain.setTargetAtTime(0, t0, 0.05); +} + +function getVideo(): HTMLVideoElement | null { + return document.querySelector('video'); +} + +// ─── UI Panel Builder (Intacto, 100% o seu design) ───────────────────────── + +const PRESETS: Record> = { + slowed: { speed: 0.75, reverbAmount: 0, bassBoost: 0 }, + reverb: { speed: 1.0, reverbAmount: 60, bassBoost: 0 }, + 'slowed+reverb': { speed: 0.8, reverbAmount: 40, bassBoost: 10 }, + nightcore: { speed: 1.25, reverbAmount: 0, bassBoost: 0 }, + reset: { speed: 1.0, reverbAmount: 0, bassBoost: 0, keepPitch: false }, +}; + +function buildPanel( + cfg: SlowedReverbConfig, + persist: (patch: Partial) => void, +): HTMLElement { + const panel = document.createElement('div'); + panel.id = 'sr-panel'; + panel.innerHTML = ` +
+ + Slowed & Reverb + +
+
+
+ + + + + +
+
+
+ Speed + ${cfg.speed.toFixed(2)}x +
+ +
+
+
+ Reverb + ${cfg.reverbAmount}% +
+ +
+
+
+ Bass Boost + ${cfg.bassBoost}% +
+ +
+
+ Keep pitch + +
+
+ `; + + const $ = (sel: string) => panel.querySelector(sel)!; + const speed = $('#sr-speed'); + const reverb = $('#sr-reverb'); + const bass = $('#sr-bass'); + const pitch = $('#sr-pitch'); + const body = $('#sr-body'); + + function updateSliderFill(input: HTMLInputElement) { + const min = parseFloat(input.min); + const max = parseFloat(input.max); + const val = parseFloat(input.value); + const pct = ((val - min) / (max - min)) * 100; + input.style.setProperty('--fill', `${pct}%`); + } + + function syncSlider(input: HTMLInputElement, valEl: HTMLElement, format: (n: number) => string, key: keyof SlowedReverbConfig, parse: (s: string) => number) { + input.addEventListener('input', () => { + const v = parse(input.value); + valEl.textContent = format(v); + (cfg as any)[key] = v; + currentConfig = cfg; + persist({ [key]: v } as any); + applyConfig(cfg, getVideo()); + updateSliderFill(input); + }); + updateSliderFill(input); + } + + syncSlider(speed, $('#sr-speed-val'), (v) => v.toFixed(2) + 'x', 'speed', parseFloat); + syncSlider(reverb, $('#sr-reverb-val'), (v) => v + '%', 'reverbAmount', parseInt); + syncSlider(bass, $('#sr-bass-val'), (v) => v + '%', 'bassBoost', parseInt); + + pitch.addEventListener('change', () => { + cfg.keepPitch = pitch.checked; + currentConfig = cfg; + persist({ keepPitch: pitch.checked }); + applyConfig(cfg, getVideo()); + }); + + panel.querySelectorAll('[data-preset]').forEach((btn) => { + btn.addEventListener('click', () => { + const patch = PRESETS[btn.dataset.preset!]; + if (!patch) return; + Object.assign(cfg, patch); + currentConfig = cfg; + persist(patch); + if (patch.speed !== undefined) { speed.value = String(patch.speed); $('#sr-speed-val').textContent = patch.speed.toFixed(2) + 'x'; updateSliderFill(speed); } + if (patch.reverbAmount !== undefined) { reverb.value = String(patch.reverbAmount); $('#sr-reverb-val').textContent = patch.reverbAmount + '%'; updateSliderFill(reverb); } + if (patch.bassBoost !== undefined) { bass.value = String(patch.bassBoost); $('#sr-bass-val').textContent = patch.bassBoost + '%'; updateSliderFill(bass); } + if (patch.keepPitch !== undefined) { pitch.checked = patch.keepPitch; } + applyConfig(cfg, getVideo()); + }); + }); + + let collapsed = false; + $('#sr-collapse').addEventListener('click', () => { + collapsed = !collapsed; + body.style.display = collapsed ? 'none' : ''; + $('#sr-collapse').textContent = collapsed ? '⌄' : '⌃'; + }); + + return panel; +} + +// ─── Plugin Definition ───────────────────────────────────────────────────── + +let watchInterval: any = null; +let lastVideoSrc = ""; + +export default createPlugin({ + name: () => 'Slowed & Reverb', + restartNeeded: false, + config: DEFAULT_CONFIG as any, + + menu: async () => { return []; }, + + renderer: { + start({ config, setConfig }) { + const styleSheet = document.createElement('style'); + styleSheet.textContent = style; + document.head.appendChild(styleSheet); + + currentConfig = { ...DEFAULT_CONFIG, ...(config as any) }; + + // O Vigia Implacável: Roda a cada 300ms e garante que o YT Music não desfaça nada + watchInterval = setInterval(() => { + const video = getVideo(); + if (!video) return; + + // Injeta o painel se ele sumir por conta do YT recarregar a página + if (!document.getElementById('sr-panel')) { + const panel = buildPanel(currentConfig, (patch) => setConfig(patch as any)); + document.body.appendChild(panel); + } + + // Se a música trocar (o endereço do vídeo mudou) + if (video.src !== lastVideoSrc) { + lastVideoSrc = video.src; + applyConfig(currentConfig, video); // Reaplica os filtros + } + + // O BLOQUEIO DE VELOCIDADE: Se o YT Music tentar resetar pra 1.0, forçamos a config atual + if (video.playbackRate !== currentConfig.speed) { + video.playbackRate = currentConfig.speed; + } + + if (video.preservesPitch !== currentConfig.keepPitch) { + video.preservesPitch = currentConfig.keepPitch; + } + }, 300); + }, + + onConfigChange(newConfig) { + currentConfig = newConfig as SlowedReverbConfig; + applyConfig(currentConfig, getVideo()); + + const panel = document.getElementById('sr-panel'); + if (!panel) return; + + const setVal = (id: string, val: string) => { + const el = panel.querySelector(id); + if (el) { + el.value = val; + el.style.setProperty('--fill', `${((parseFloat(val) - parseFloat(el.min)) / (parseFloat(el.max) - parseFloat(el.min))) * 100}%`); + } + }; + + setVal('#sr-speed', String((newConfig as any).speed)); + setVal('#sr-reverb', String((newConfig as any).reverbAmount)); + setVal('#sr-bass', String((newConfig as any).bassBoost)); + + const pitch = panel.querySelector('#sr-pitch'); + if (pitch) pitch.checked = (newConfig as any).keepPitch; + + const sv = panel.querySelector('#sr-speed-val'); + const rv = panel.querySelector('#sr-reverb-val'); + const bv = panel.querySelector('#sr-bass-val'); + if (sv) sv.textContent = (newConfig as any).speed.toFixed(2) + 'x'; + if (rv) rv.textContent = (newConfig as any).reverbAmount + '%'; + if (bv) bv.textContent = (newConfig as any).bassBoost + '%'; + }, + + stop() { + if (watchInterval) clearInterval(watchInterval); + disableEngine(getVideo()); + document.getElementById('sr-panel')?.remove(); + } + } +}); \ No newline at end of file diff --git a/src/plugins/slowed/readme.md b/src/plugins/slowed/readme.md new file mode 100644 index 0000000000..e74dde566f --- /dev/null +++ b/src/plugins/slowed/readme.md @@ -0,0 +1,12 @@ +# Slowed & Nightcore Plugin + +Adds an interactive floating panel to easily apply "Slowed" or "Nightcore" effects to tracks. + +## Features +* **Playback Speed Control:** Precision slider from 0.5x to 1.5x. +* **Pitch Preservation:** Toggle to keep the original pitch or let it deepen/raise with the speed (true slowed/nightcore effect). +* **Presets:** Quick buttons for standard Slowed, Nightcore, and Reset. +* **Persistent Settings:** Preserves your chosen speed and pitch settings across track changes seamlessly. + +## Compatibility Note +This plugin operates strictly via the HTML5 `