-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(plugins): add Slowed playback plugin #4400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 3 commits
6823dd0
96e6470
8cd6297
874d992
c3ef41b
39fcec6
38f634b
2326edd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do not use |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 1. Remove qualquer resquício de execução anterior (Segurança máxima) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| document.getElementById('sr-panel')?.remove(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the purpose of this statement?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 2. Cria o Painel | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const panel = document.createElement('div'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| panel.id = 'sr-panel'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| document.body.appendChild(panel); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+29
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tear down the previous plugin instance before mounting a new one. Line 38 only removes the old panel node. If Suggested guard at the top of
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n src/plugins/slowed/index.tsx | head -100Repository: pear-devs/pear-desktop
Length of output: 4262
🏁 Script executed:
cat -n src/plugins/slowed/index.tsx | tail -20Repository: 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i18n
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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).
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Soften the The implementation still polls and mutates shared 🤖 Prompt for AI Agents |
||
Uh oh!
There was an error while loading. Please reload this page.