Skip to content
140 changes: 140 additions & 0 deletions src/plugins/slowed/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { createSignal, createEffect, onCleanup, Show } from 'solid-js';
import { render } from 'solid-js/web';

import style from './style.css?inline';
import { createPlugin } from '@/utils';

export interface SlowedConfig {
enabled: boolean;
speed: number;
keepPitch: boolean;
}

const DEFAULT_CONFIG: SlowedConfig = {
enabled: false,
speed: 1.0,
keepPitch: false,
};

export default createPlugin({
name: () => 'Slowed',
authors: ['The-Kryz'],
restartNeeded: false,
config: DEFAULT_CONFIG,
stylesheets: [style],

renderer: {
cleanup: null as (() => void) | null,

start({ config, setConfig }) {
const safeConfig = config || DEFAULT_CONFIG;

const [speed, setSpeed] = createSignal(safeConfig.speed ?? 1.0);
const [keepPitch, setKeepPitch] = createSignal(safeConfig.keepPitch ?? false);
const [collapsed, setCollapsed] = createSignal(false);

const getVideo = () => document.querySelector<HTMLVideoElement>('video');

document.getElementById('sr-panel')?.remove();

const panel = document.createElement('div');
panel.id = 'sr-panel';
document.body.appendChild(panel);

const dispose = render(() => (
<div class="sr-container">
<div class="sr-header" onClick={() => setCollapsed(!collapsed())} style="cursor: pointer;">
<span class="sr-logo">◈</span>
<span class="sr-title"> SLOWED</span>
</div>

<Show when={!collapsed()}>
<div class="sr-body">
<div class="sr-presets">
<button class="sr-btn" onClick={() => { setSpeed(0.75); setKeepPitch(false); }}>Slowed</button>
<button class="sr-btn" onClick={() => { setSpeed(1.25); setKeepPitch(true); }}>Nightcore</button>
<button class="sr-btn sr-btn--danger" onClick={() => { setSpeed(1.0); setKeepPitch(false); }}>Reset</button>
</div>

<div class="sr-row">
<div class="sr-label-row">
<span class="sr-label">Speed</span>
<span class="sr-val">{speed().toFixed(2)}x</span>
</div>
<input
class="sr-slider"
type="range" min="0.5" max="1.5" step="0.01"
value={speed()}
onInput={(e) => setSpeed(parseFloat(e.currentTarget.value))}
style={{ '--fill': `${((speed() - 0.5) / (1.5 - 0.5)) * 100}%` }}
/>
</div>

<div class="sr-row sr-pitch-row">
<span class="sr-label">Keep pitch</span>
<label class="sr-switch">
<input type="checkbox" checked={keepPitch()} onChange={(e) => setKeepPitch(e.currentTarget.checked)} />
<span class="sr-thumb"></span>
</label>
</div>
</div>
</Show>
</div>
), panel);

const interval = setInterval(() => {
const video = getVideo();
if (!video) return;

if (Math.abs(video.playbackRate - speed()) > 0.01) {
video.playbackRate = speed();
}

if (video.preservesPitch !== keepPitch()) {
video.preservesPitch = keepPitch();
// @ts-ignore - Prefixos para garantir funcionamento no Electron/Chromium
video.webkitPreservesPitch = keepPitch();
}
}, 500);

const doCleanup = () => {
clearInterval(interval);
dispose();
panel.remove();
const video = getVideo();
if (video) {
video.playbackRate = 1.0;
video.preservesPitch = true;
// @ts-ignore
video.webkitPreservesPitch = true;
}
};

this.cleanup = doCleanup;
onCleanup(doCleanup);

createEffect(() => {
const video = getVideo();
if (video) {
const s = speed();
const p = keepPitch();
video.playbackRate = s;
video.preservesPitch = p;
// @ts-ignore
video.webkitPreservesPitch = p;
}
});

createEffect(() => {
setConfig({ speed: speed(), keepPitch: keepPitch() });
});
},

stop() {
if (this.cleanup) {
this.cleanup();
this.cleanup = null;
}
}
},
});
12 changes: 12 additions & 0 deletions src/plugins/slowed/readme.md
Original file line number Diff line number Diff line change
@@ -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 `<video>` element properties (`playbackRate` and `preservesPitch`). It explicitly avoids the Web Audio API to guarantee 100% compatibility with other audio-hijacking plugins (like native Equalizers or Crossfade).
242 changes: 242 additions & 0 deletions src/plugins/slowed/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/* =========================================================
SLOWED Plugin — Panel Styles
========================================================= */

#sr-panel {
position: fixed;
bottom: 72px;
right: 16px;
z-index: 9999;
width: 260px;
background: #1a0a2e;
border: 1px solid #6b21a8;
border-radius: 12px;
box-shadow:
0 0 0 1px #4c1d95,
0 8px 32px rgba(109, 40, 217, 0.35),
0 2px 8px rgba(0, 0, 0, 0.6);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 13px;
color: #e9d5ff;
user-select: none;
overflow: hidden;
}

#sr-panel:hover {
box-shadow:
0 0 0 1px #6d28d9,
0 8px 40px rgba(109, 40, 217, 0.5),
0 2px 8px rgba(0, 0, 0, 0.6);
}

.sr-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: linear-gradient(135deg, #2e1065 0%, #1e0d4a 100%);
border-bottom: 1px solid #4c1d95;
cursor: pointer; /* Barra inteira clicável para minimizar */
}

.sr-logo {
font-size: 16px;
color: #a855f7;
filter: drop-shadow(0 0 6px #a855f7);
line-height: 1;
}

.sr-title {
flex: 1;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #d8b4fe;
}

.sr-body {
padding: 12px 14px 14px;
display: flex;
flex-direction: column;
gap: 14px;
}

.sr-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}

.sr-btn {
flex: 1 1 auto;
min-width: 0;
padding: 5px 8px;
background: #2d1b69;
border: 1px solid #5b21b6;
border-radius: 6px;
color: #c4b5fd;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
white-space: nowrap;
text-align: center;
}

.sr-btn:hover {
background: #4c1d95;
border-color: #7c3aed;
color: #ede9fe;
box-shadow: 0 0 8px rgba(124, 58, 237, 0.4);
}

.sr-btn:active {
background: #5b21b6;
}

.sr-btn--danger {
background: #1f0a0a;
border-color: #7f1d1d;
color: #fca5a5;
}

.sr-btn--danger:hover {
background: #450a0a;
border-color: #b91c1c;
color: #fecaca;
box-shadow: 0 0 8px rgba(185, 28, 28, 0.35);
}

.sr-row {
display: flex;
flex-direction: column;
gap: 6px;
}

.sr-label-row {
display: flex;
justify-content: space-between;
align-items: baseline;
}

.sr-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #a78bfa;
}

.sr-val {
font-size: 12px;
font-weight: 700;
color: #d8b4fe;
min-width: 38px;
text-align: right;
font-variant-numeric: tabular-nums;
}

.sr-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: linear-gradient(
to right,
#7c3aed var(--fill, 30%),
#3b1a6e var(--fill, 30%)
);
outline: none;
cursor: pointer;
transition: background 0.1s;
}

.sr-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #c084fc, #7c3aed);
border: 2px solid #a855f7;
box-shadow: 0 0 6px rgba(168, 85, 247, 0.7);
cursor: pointer;
transition: box-shadow 0.15s, transform 0.1s;
}

.sr-slider:hover::-webkit-slider-thumb {
box-shadow: 0 0 12px rgba(168, 85, 247, 0.9);
transform: scale(1.15);
}

.sr-slider:active::-webkit-slider-thumb {
transform: scale(0.95);
}

.sr-pitch-row {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding-top: 4px;
}

.sr-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
}

.sr-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}

.sr-thumb {
position: absolute;
inset: 0;
border-radius: 20px;
background: #2d1b69;
border: 1px solid #5b21b6;
transition: background 0.2s, border-color 0.2s;
}

.sr-thumb::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #6d28d9;
box-shadow: 0 0 4px rgba(109, 40, 217, 0.6);
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s, box-shadow 0.2s;
}

.sr-switch input:checked + .sr-thumb {
background: #4c1d95;
border-color: #7c3aed;
}

.sr-switch input:checked + .sr-thumb::after {
transform: translateX(16px);
background: #a855f7;
box-shadow: 0 0 8px rgba(168, 85, 247, 0.8);
}

/* Assinatura no rodapé */
.sr-footer {
text-align: center;
font-size: 10px;
color: #8b5cf6;
opacity: 0.8;
margin-top: 6px;
font-weight: 500;
letter-spacing: 0.05em;
}