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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config is always non-null.


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');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not use document.querySelector<HTMLVideoElement>('video') in start


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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this statement?

@ArjixWasTaken ArjixWasTaken Apr 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll rework the UI either way, so it's pointless to review it.
ig this should be marked as a draft


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

Comment on lines +29 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tear down the previous plugin instance before mounting a new one.

Line 38 only removes the old panel node. If start() runs again without stop(), the previous interval and Solid root keep running and continue mutating the player in the background.

Suggested guard at the top of start()
     start({ config, setConfig }) {
+      this.cleanup?.();
+      this.cleanup = null;
+
       const safeConfig = config || DEFAULT_CONFIG;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
start({ config, setConfig }) {
this.cleanup?.();
this.cleanup = null;
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);
🧰 Tools
🪛 ESLint

[error] 31-31: Delete ······

(prettier/prettier)


[error] 33-33: Replace safeConfig.keepPitch·??·false with ⏎········safeConfig.keepPitch·??·false,⏎······

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/slowed/index.tsx` around lines 29 - 43, The start() function
currently only removes the old DOM node ('sr-panel') but leaves the previous
Solid root, intervals and mutations running; modify start() to first detect and
tear down any existing plugin instance by invoking the plugin's stop() (or
calling the existing teardown function) before proceeding: ensure any running
interval timers are cleared, any Solid root or reactive roots are disposed, and
any event listeners or mutations set up by start()/getVideo are removed; then
create the new panel and initialize signals as before so multiple start() calls
don't leak background work.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/plugins/slowed/index.tsx | head -100

Repository: pear-devs/pear-desktop

Length of output: 4262


🏁 Script executed:

cat -n src/plugins/slowed/index.tsx | tail -20

Repository: pear-devs/pear-desktop

Length of output: 585


Use a real button for the collapse toggle with proper ARIA attributes.

The header is currently a non-semantic <div> with an onClick handler, which is not keyboard-operable and doesn't expose the expanded/collapsed state to assistive technologies.

Replace it with a <button> element and add aria-expanded={!collapsed()} to expose the state. However, the suggested aria-controls="sr-panel-body" references an ID that doesn't exist—the panel body (<div class="sr-body">) has no ID. Either add id="sr-panel-body" to the body element or remove the aria-controls attribute (it's optional and aria-expanded is the critical attribute for keyboard accessibility).

🧰 Tools
🪛 ESLint

[error] 46-46: Replace <div·class="sr-header"·onClick={()·=>·setCollapsed(!collapsed())}·style="cursor:·pointer;" with ··<div⏎··············class="sr-header"⏎··············onClick={()·=>·setCollapsed(!collapsed())}⏎··············style="cursor:·pointer;"⏎············

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/slowed/index.tsx` at line 46, Replace the non-semantic <div
class="sr-header"> click target with a real <button> that calls
setCollapsed(!collapsed()) (same toggle logic), add aria-expanded={!collapsed()}
to expose state to AT, and either add id="sr-panel-body" to the <div
class="sr-body"> or remove aria-controls from the button (aria-expanded is
required, aria-controls is optional) so the toggle is keyboard-accessible and
references a real ID if used.

<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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i18n

<label class="sr-switch">
<input type="checkbox" checked={keepPitch()} onChange={(e) => setKeepPitch(e.currentTarget.checked)} />
Comment on lines +59 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Associate the visible labels with the slider and checkbox.

Right now assistive tech will see an unlabeled range input and an unlabeled checkbox, because the visible text lives in separate <span> elements.

Suggested association via aria-labelledby
-                  <span class="sr-label">Speed</span>
+                  <span id="sr-speed-label" class="sr-label">Speed</span>
@@
-                <input 
+                <input
+                  aria-labelledby="sr-speed-label"
                   class="sr-slider" 
                   type="range" min="0.5" max="1.5" step="0.01" 
                   value={speed()} 
@@
-                <span class="sr-label">Keep pitch</span>
+                <span id="sr-pitch-label" class="sr-label">Keep pitch</span>
                 <label class="sr-switch">
-                  <input type="checkbox" checked={keepPitch()} onChange={(e) => setKeepPitch(e.currentTarget.checked)} />
+                  <input
+                    type="checkbox"
+                    aria-labelledby="sr-pitch-label"
+                    checked={keepPitch()}
+                    onChange={(e) => setKeepPitch(e.currentTarget.checked)}
+                  />
🧰 Tools
🪛 ESLint

[error] 59-59: Insert ⏎··

(prettier/prettier)


[error] 60-60: Insert ··

(prettier/prettier)


[error] 61-61: Insert ··

(prettier/prettier)


[error] 62-62: Insert ··

(prettier/prettier)


[error] 63-63: Replace ················ with ··················

(prettier/prettier)


[error] 64-68: Replace <input·⏎··················class="sr-slider"·⏎··················type="range"·min="0.5"·max="1.5"·step="0.01"·⏎··················value={speed()}·⏎ with ··<input⏎····················class="sr-slider"⏎····················type="range"⏎····················min="0.5"⏎····················max="1.5"⏎····················step="0.01"⏎····················value={speed()}⏎··

(prettier/prettier)


[error] 66-66: Props should be sorted alphabetically

(stylistic/jsx-sort-props)


[error] 66-66: Props should be sorted alphabetically

(stylistic/jsx-sort-props)


[error] 66-66: Props should be sorted alphabetically

(stylistic/jsx-sort-props)


[error] 68-68: Props should be sorted alphabetically

(stylistic/jsx-sort-props)


[error] 69-69: Replace style={{·'--fill':·${((speed()·-·0.5)·/·(1.5·-·0.5))··100}%·}} with ··style={{⏎······················'--fill':·${((speed()·-·0.5)·/·(1.5·-·0.5))··100}%,

(prettier/prettier)


[error] 69-69: Props should be sorted alphabetically

(stylistic/jsx-sort-props)


[error] 70-70: Replace /> with ····}}

(prettier/prettier)


[error] 71-71: Replace </div with ····/

(prettier/prettier)


[error] 72-72: Insert ··</div>

(prettier/prettier)


[error] 73-73: Insert ⏎··

(prettier/prettier)


[error] 74-74: Insert ··

(prettier/prettier)


[error] 75-75: Insert ··

(prettier/prettier)


[error] 76-76: Replace <input·type="checkbox"·checked={keepPitch()}·onChange={(e)·=>·setKeepPitch(e.currentTarget.checked)} with ··<input⏎······················type="checkbox"⏎······················checked={keepPitch()}⏎······················onChange={(e)·=>·setKeepPitch(e.currentTarget.checked)}⏎···················

(prettier/prettier)


[error] 76-76: Props should be sorted alphabetically

(stylistic/jsx-sort-props)


[error] 76-76: Props should be sorted alphabetically

(stylistic/jsx-sort-props)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/slowed/index.tsx` around lines 59 - 76, The range and checkbox
inputs lack accessible associations with their visible text; add explicit labels
by giving the slider and checkbox unique ids (e.g., "speed-slider",
"keep-pitch-checkbox") and reference the corresponding visible text spans using
aria-labelledby (pointing to the ids of the "Speed" sr-label span and the "Keep
pitch" sr-label span), or alternatively wrap each input in a <label> that
contains the visible text; update the JSX where speed(), setSpeed, keepPitch(),
and setKeepPitch() are used so the inputs include the new id and aria-labelledby
attributes to link them to the existing sr-label elements.

<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();
Comment on lines +95 to +96

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This statement is unnecessary.

Comment on lines +93 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the file structure and read the relevant file
wc -l src/plugins/slowed/index.tsx

Repository: pear-devs/pear-desktop

Length of output: 98


🏁 Script executed:

# Read the file to see the code at the mentioned lines
cat -n src/plugins/slowed/index.tsx | sed -n '85,130p'

Repository: pear-devs/pear-desktop

Length of output: 1654


🏁 Script executed:

# Check the TypeScript configuration to understand lib setup
cat -n tsconfig.json | head -30

Repository: pear-devs/pear-desktop

Length of output: 1076


🏁 Script executed:

# Also check what TypeScript version is being used
cat package.json | grep -A 5 -B 5 typescript

Repository: pear-devs/pear-desktop

Length of output: 1148


🏁 Script executed:

# Check TypeScript's DOM lib definitions for HTMLVideoElement
node -e "
const ts = require('typescript');
const sourceFile = ts.createSourceFile('lib.dom.d.ts', '', ts.ScriptTarget.Latest, true);
// Try to find HTMLVideoElement definition
const libPath = require.resolve('typescript');
const libDir = require('path').dirname(libPath);
const domLib = require('path').join(libDir, '../lib/lib.dom.d.ts');
const fs = require('fs');
const content = fs.readFileSync(domLib, 'utf-8');
// Extract HTMLVideoElement interface
const match = content.match(/interface HTMLVideoElement[^}]*preservesPitch[^}]*}/s);
if (match) console.log(match[0].substring(0, 2000));
" 2>/dev/null || echo "Need different approach"

Repository: pear-devs/pear-desktop

Length of output: 48


🏁 Script executed:

# Alternative: search for HTMLVideoElement typing in node_modules
find node_modules/typescript -name "lib.dom.d.ts" 2>/dev/null | head -1

Repository: pear-devs/pear-desktop

Length of output: 48


🏁 Script executed:

# Look at TypeScript definitions
grep -A 20 "interface HTMLVideoElement" node_modules/typescript/lib/lib.dom.d.ts 2>/dev/null | head -40

Repository: pear-devs/pear-desktop

Length of output: 1855


🏁 Script executed:

# Search for preservesPitch in DOM lib
grep -n "preservesPitch" node_modules/typescript/lib/lib.dom.d.ts 2>/dev/null || echo "Not found in standard DOM lib"

Repository: pear-devs/pear-desktop

Length of output: 438


🏁 Script executed:

# Search for webkitPreservesPitch in DOM lib
grep -n "webkitPreservesPitch" node_modules/typescript/lib/lib.dom.d.ts 2>/dev/null || echo "Not found in standard DOM lib"

Repository: pear-devs/pear-desktop

Length of output: 95


🏁 Script executed:

# Check ESLint configuration
cat -n .eslintrc.json 2>/dev/null || cat -n .eslintrc.js 2>/dev/null || echo "No explicit eslintrc found"

Repository: pear-devs/pear-desktop

Length of output: 92


🏁 Script executed:

# Look for ESLint config in package.json
grep -A 10 '"eslintConfig"' package.json

Repository: pear-devs/pear-desktop

Length of output: 48


🏁 Script executed:

# Check for TypeScript ESLint rules about `@ts-ignore`
grep -r "@ts-ignore\|ts-ignore" .eslintrc* 2>/dev/null || echo "No explicit rules found"

Repository: pear-devs/pear-desktop

Length of output: 89


🏁 Script executed:

# Check typescript-eslint version and common rules
npm ls typescript-eslint 2>/dev/null | grep typescript-eslint | head -5

Repository: pear-devs/pear-desktop

Length of output: 1620


🏁 Script executed:

# Check for eslint.config.js (flat config)
ls -la eslint.config.* 2>/dev/null || echo "No flat config found"

Repository: pear-devs/pear-desktop

Length of output: 132


🏁 Script executed:

# Look in src or root for any eslint config
find . -maxdepth 2 -name "eslint*" -type f 2>/dev/null

Repository: pear-devs/pear-desktop

Length of output: 85


🏁 Script executed:

# Check the ESLint flat config file
cat -n eslint.config.mjs

Repository: pear-devs/pear-desktop

Length of output: 4803


Replace @ts-ignore comments with a typed helper for webkitPreservesPitch.

The preservesPitch property is already typed on HTMLMediaElement, but webkitPreservesPitch is not in the standard DOM lib and requires a vendor type extension. Create a small typed helper and reuse it across lines 93–96, 108–109, and 123–124 to avoid suppressing type checking:

Suggested typed helper
+type PitchAwareVideo = HTMLVideoElement & {
+  webkitPreservesPitch?: boolean;
+};
+
+const setPitchPreservation = (video: PitchAwareVideo, enabled: boolean) => {
+  video.preservesPitch = enabled;
+  if ('webkitPreservesPitch' in video) {
+    video.webkitPreservesPitch = enabled;
+  }
+};
@@
-        if (video.preservesPitch !== keepPitch()) {
-          video.preservesPitch = keepPitch();
-          // `@ts-ignore` - Prefixos para garantir funcionamento no Electron/Chromium
-          video.webkitPreservesPitch = keepPitch();
-        }
+        if (video.preservesPitch !== keepPitch()) {
+          setPitchPreservation(video as PitchAwareVideo, keepPitch());
+        }
🧰 Tools
🪛 ESLint

[error] 95-95: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free.

(@typescript-eslint/ban-ts-comment)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/slowed/index.tsx` around lines 93 - 96, Replace the three usages
of "// `@ts-ignore`" by adding a small typed helper that exposes
webkitPreservesPitch on media elements (e.g., declare an interface
HTMLMediaElementWithWebkit { webkitPreservesPitch?: boolean } or a helper
function setWebkitPreservesPitch(el: HTMLMediaElement, val: boolean)) and use
that helper where you currently set video.webkitPreservesPitch in
src/plugins/slowed/index.tsx (the blocks that check keepPitch() and the later
occurrences around lines 108–109 and 123–124); ensure you cast the
HTMLMediaElement to the typed interface or call the helper so the assignment is
type-safe and remove the ts-ignore comments.

}
}, 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;
Comment on lines +108 to +109

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ditto]

}
Comment on lines +100 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't hard-reset the media element to 1.0/true on cleanup.

Stopping the plugin currently overwrites any pre-existing playback settings, including user-selected speed or another plugin's values. Capture the previous state for the video you modify and restore that instead of forcing defaults.

🧰 Tools
🪛 ESLint

[error] 108-108: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free.

(@typescript-eslint/ban-ts-comment)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/slowed/index.tsx` around lines 100 - 110, doCleanup is
force-resetting video playback properties to hardcoded defaults; instead capture
the video's original state when you first modify it (e.g., originalPlaybackRate,
originalPreservesPitch, originalWebkitPreservesPitch stored on the video element
or in a Map keyed by the element) and in doCleanup (where you call getVideo(),
clearInterval(interval), dispose(), panel.remove()) restore those saved values
only if they exist; ensure you only touch the same properties you changed and
safely handle missing/removed video elements.

};

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;
Comment on lines +123 to +124

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

}
});

createEffect(() => {
setConfig({ speed: speed(), keepPitch: keepPitch() });
});
Comment on lines +128 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't persist every slider tick via raw setConfig().

createEffect runs for every speed() change, and setConfig can be async. Dragging the slider will queue a burst of writes and can silently drop rejections.

Suggested debounce and Promise handling
+      let persistTimer: number | undefined;
+
       createEffect(() => {
-        setConfig({ speed: speed(), keepPitch: keepPitch() });
+        clearTimeout(persistTimer);
+        const next = { speed: speed(), keepPitch: keepPitch() };
+        persistTimer = window.setTimeout(() => {
+          void Promise.resolve(setConfig(next)).catch(console.error);
+        }, 150);
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
createEffect(() => {
setConfig({ speed: speed(), keepPitch: keepPitch() });
});
let persistTimer: number | undefined;
createEffect(() => {
clearTimeout(persistTimer);
const next = { speed: speed(), keepPitch: keepPitch() };
persistTimer = window.setTimeout(() => {
void Promise.resolve(setConfig(next)).catch(console.error);
}, 150);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/slowed/index.tsx` around lines 128 - 130, The effect currently
calls setConfig on every speed() change which can queue many async writes during
slider drags; debounce updates and handle the returned Promise: replace the
immediate createEffect -> setConfig call with a debounced updater that waits
e.g. 100–250ms after the last speed()/keepPitch() change before calling
setConfig({ speed: speed(), keepPitch: keepPitch() }), await the Promise and
catch/log any rejection, and ensure rapid intermediate ticks cancel the pending
debounce so only the final value is persisted (refer to createEffect, speed,
keepPitch, and setConfig to locate the code).

},

stop() {
if (this.cleanup) {
this.cleanup();
this.cleanup = null;
}
}
},
});
12 changes: 12 additions & 0 deletions src/plugins/slowed/readme.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this file?

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).
Comment on lines +11 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Soften the 100% compatibility claim.

The implementation still polls and mutates shared video state, so this reads stronger than the code can guarantee. I'd describe it as "avoids Web Audio API conflicts" rather than promising full compatibility with every plugin.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/slowed/readme.md` around lines 11 - 12, Replace the absolute
claim about "100% compatibility" in the README sentence that references HTML5
<video> element properties (`playbackRate` and `preservesPitch`) with a softer
statement: state that the plugin "avoids using the Web Audio API and is designed
to minimize conflicts with other audio-hijacking plugins" or similar, and remove
or reword the phrase promising guaranteed compatibility so it no longer asserts
complete compatibility with every plugin.

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;
Comment on lines +140 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore visible focus styles for the slider and switch.

.sr-slider removes the browser outline, and the hidden checkbox never gets a replacement focus ring. That makes keyboard navigation hard to track on the two main controls.

Suggested CSS fix
 .sr-slider {
   -webkit-appearance: none;
   appearance: none;
   width: 100%;
   height: 4px;
   border-radius: 2px;
@@
-  outline: none;
   cursor: pointer;
   transition: background 0.1s;
 }
+
+.sr-slider:focus-visible {
+  outline: 2px solid `#c084fc`;
+  outline-offset: 4px;
+}
@@
 .sr-switch input {
   opacity: 0;
   width: 0;
   height: 0;
   position: absolute;
 }
+
+.sr-switch input:focus-visible + .sr-thumb {
+  box-shadow:
+    0 0 0 2px `#1a0a2e`,
+    0 0 0 4px `#c084fc`;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/plugins/slowed/style.css` around lines 140 - 206, The slider removes the
native outline and the hidden checkbox for the switch has no visible
replacement, breaking keyboard focus visibility; restore accessible focus
indicators by adding focus styles (use :focus-visible) for .sr-slider to show an
outline or glow (e.g., box-shadow or outline) and add focus-visible/on-focus
rules that target the switch’s visual thumb (e.g., .sr-switch
input:focus-visible + .sr-thumb or .sr-switch:focus-within .sr-thumb) to draw a
clear focus ring/border when the invisible input receives keyboard focus; ensure
the focus styles are subtle, high-contrast, and animate consistently with
existing transitions.

}

.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;
}