Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "\u0079\u006f\u0075\u0074\u0075\u0062\u0065\u002d\u006d\u0075\u0073\u0069\u0063",
"desktopName": "com.github.th_ch.\u0079\u006f\u0075\u0074\u0075\u0062\u0065\u005f\u006d\u0075\u0073\u0069\u0063",
"productName": "\u0059\u006f\u0075\u0054\u0075\u0062\u0065\u0020\u004d\u0075\u0073\u0069\u0063",
"name": "youtube-music",
"desktopName": "com.github.th_ch.youtube_music",
"productName": "YouTube Music",
"version": "3.11.0",
"description": "\u0059\u006f\u0075\u0054\u0075\u0062\u0065\u0020\u004d\u0075\u0073\u0069\u0063 Desktop App - including custom plugins",
"description": "YouTube Music Desktop App - including custom plugins",
"main": "./dist/main/index.js",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -176,6 +176,7 @@
"@types/html-to-text": "9.0.4",
"@types/semver": "7.7.1",
"@types/trusted-types": "2.0.7",
"ajv-formats": "^3.0.1",
Comment thread
ArjixWasTaken marked this conversation as resolved.
Outdated
"bufferutil": "4.1.0",
"builtin-modules": "5.0.0",
"cross-env": "10.1.0",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

131 changes: 131 additions & 0 deletions src/plugins/slowed/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { createSignal, createEffect, onCleanup } 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',
restartNeeded: false,
config: DEFAULT_CONFIG,
stylesheets: [style],

renderer: {
// Definimos variáveis fora do start para podermos limpar no stop
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


// 1. Remove qualquer resquício de execução anterior (Segurança máxima)
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


// 2. Cria o Painel
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>

{!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 class="sr-footer">Made by Kryz &lt;3</div>
Comment thread
ArjixWasTaken marked this conversation as resolved.
Outdated
</div>
)}
</div>
Comment thread
ArjixWasTaken marked this conversation as resolved.
Outdated
), panel);

const interval = setInterval(() => {
const video = getVideo();
if (video && Math.abs(video.playbackRate - speed()) > 0.01) {
video.playbackRate = speed();
video.preservesPitch = keepPitch();
}
}, 500);

// Criamos a função de limpeza que será chamada tanto pelo onCleanup quanto pelo stop
const doCleanup = () => {
clearInterval(interval);
dispose();
panel.remove();
const video = getVideo();
if (video) {
video.playbackRate = 1.0;
video.preservesPitch = true;
}
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);

// Efeitos de áudio e config
createEffect(() => {
const video = getVideo();
if (video) {
video.playbackRate = speed();
video.preservesPitch = keepPitch();
}
});

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

},

// O segredo está aqui: o método stop() é o que o YTMusic chama
// quando você desmarca o plugin no menu.
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.

Loading