-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat: Desktop workflow enhancements (audio-only, playback recovery, virtual desktop, tray hover mini-player) #4428
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
Open
EmreSoyak
wants to merge
2
commits into
pear-devs:master
Choose a base branch
from
EmreSoyak:emre/custom-features
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| # Feature Pack: Desktop Workflow Enhancements | ||
|
|
||
| A set of four opt-in features for power users who run YouTube Music as a background audio companion on desktop — particularly those who use virtual desktops, minimize to tray, and want quick playback control without opening the full window. | ||
|
|
||
| Every feature defaults to **off** and is toggled from the existing settings/plugin menu. No existing behavior is changed unless the user explicitly enables a feature. | ||
|
|
||
| --- | ||
|
|
||
| ## 1. Audio-Only Mode | ||
|
|
||
| **Plugin** | Settings > Plugins > Audio Only | Requires restart | ||
|
|
||
| ### The problem | ||
|
|
||
| YouTube Music streams video even when the window is minimized or hidden in the tray. On a desktop app used purely for music, this wastes ~300 MB of RAM on video decoding and buffering that nobody is watching. | ||
|
|
||
| ### The solution | ||
|
|
||
| A renderer plugin that forces YouTube Music into its audio-only playback path — the same mode the mobile app uses on audio-only plans. Video decoding stops entirely, album art is shown instead, and memory usage drops significantly. | ||
|
|
||
| ### How it works | ||
|
|
||
| - Sets `playback-mode="ATV_PREFERRED"` on the player element, telling YouTube's player this is an audio-only surface | ||
| - Calls `setPlaybackQuality('tiny')` via the internal player API to prevent video stream selection | ||
| - Hides the `<video>` element and shows album art (`#song-image`) instead | ||
| - A `MutationObserver` locks the `playback-mode` attribute — YouTube periodically tries to flip it back to video mode; this prevents that | ||
| - Re-applies on every song change via the `videodatachange` event | ||
|
|
||
| ### Files | ||
|
|
||
| | File | Role | | ||
| |------|------| | ||
| | `src/plugins/audio-only/index.ts` | Full plugin (renderer-side) | | ||
|
|
||
| --- | ||
|
|
||
| ## 2. Playback Recovery | ||
|
|
||
| **Plugin** | Settings > Plugins > Playback Recovery | ||
|
|
||
| ### The problem | ||
|
|
||
| YouTube Music's web player occasionally enters a stuck state — the progress bar stops, audio cuts out, but the UI still shows "playing." This happens more frequently during long listening sessions, on flaky connections, or after the system wakes from sleep. The only fix is to manually skip the track or reload the app. | ||
|
|
||
| ### The solution | ||
|
|
||
| A watchdog plugin that monitors the `<video>` element's health every 3 seconds and applies progressive recovery strategies when playback stalls. It handles dead playback, frozen progress, buffer exhaustion, media errors, and stream stalls — all without user intervention. | ||
|
|
||
| ### How it works | ||
|
|
||
| **Detection (watchdog runs every 3 seconds):** | ||
|
|
||
| | Condition | Meaning | | ||
| |-----------|---------| | ||
| | `readyState === 0` while player state is "playing" | Completely dead — no media data loaded | | ||
| | `currentTime` not advancing while not paused | Frozen — player thinks it's playing but nothing moves | | ||
| | Buffer end <= current time while `readyState < 3` | Buffer exhausted — nothing left to play | | ||
|
|
||
| **Recovery strategies (progressive):** | ||
|
|
||
| | Attempt | Strategy | What it does | | ||
| |---------|----------|-------------| | ||
| | 1-2 | Seek to current position | Forces the player to re-request the current buffer segment | | ||
| | 3-4 | Seek forward 1 second | Jumps past a potentially corrupt segment | | ||
| | 5+ | Skip to next track | Gives up on the current song and moves on | | ||
|
|
||
| **Event hooks:** Also listens for `error`, `stalled`, and `waiting` events for immediate detection. A `MutationObserver` watches for the `<video>` element being destroyed and recreated by YouTube's player, automatically re-attaching hooks to the new element. | ||
|
|
||
| ### Config | ||
|
|
||
| | Option | Default | Description | | ||
| |--------|---------|-------------| | ||
| | `stallTimeoutMs` | `8000` | How long to wait before a stall triggers recovery | | ||
| | `maxRetries` | `5` | Max recovery attempts before skipping to next track | | ||
| | `logToConsole` | `true` | Log recovery events to DevTools console for debugging | | ||
|
|
||
| ### Files | ||
|
|
||
| | File | Role | | ||
| |------|------| | ||
| | `src/plugins/playback-recovery/index.ts` | Full plugin (renderer-side, watchdog + event hooks) | | ||
|
|
||
| --- | ||
|
|
||
| ## 3. Virtual Desktop Awareness | ||
|
|
||
| **Core setting** | Options > Tray > "Move to current virtual desktop on show" | ||
|
|
||
| ### The problem | ||
|
|
||
| On Windows 10/11 (and macOS/Linux with workspaces), if YouTube Music is open on Desktop 1 and you're working on Desktop 3, clicking the tray icon or launching a second instance **yanks you back to Desktop 1** instead of bringing the window to you. This breaks the flow for anyone who uses virtual desktops to organize their work. | ||
|
|
||
| ### The solution | ||
|
|
||
| When this setting is enabled, showing the YouTube Music window — whether by tray click, the "Show" context menu, or launching a second instance — **moves the window to your current desktop** instead of switching desktops. | ||
|
|
||
| ### How it works | ||
|
|
||
| Uses Electron's `setVisibleOnAllWorkspaces` API with a pin/unpin technique: | ||
|
|
||
| ``` | ||
| win.setVisibleOnAllWorkspaces(true) // Pin to all desktops (appears on current) | ||
| win.show() // Show and focus | ||
| win.setVisibleOnAllWorkspaces(false) // Unpin (stays on current desktop) | ||
| ``` | ||
|
|
||
| Applied at all 3 places where the window is shown: | ||
| 1. Tray icon click (show window) | ||
| 2. Tray right-click > "Show" menu item | ||
| 3. Second-instance handler (launching the app while it's already running) | ||
|
|
||
| Cross-platform: works on Windows virtual desktops, macOS Spaces, and Linux workspaces. | ||
|
|
||
| ### Files | ||
|
|
||
| | File | Role | | ||
| |------|------| | ||
| | `src/window-utils.ts` | `showOnCurrentDesktop()` helper (new file) | | ||
| | `src/tray.ts` | Uses helper in click + "Show" menu handlers | | ||
| | `src/index.ts` | Uses helper in second-instance handler | | ||
| | `src/config/defaults.ts` | `trayMoveToCurrentDesktop` option | | ||
| | `src/menu.ts` | Toggle in Options > Tray submenu | | ||
|
|
||
| --- | ||
|
|
||
| ## 4. Tray Hover Mini-Player | ||
|
|
||
| **Notification plugin extension** | Interactive Settings > "Show mini-player on tray hover" | ||
|
|
||
| ### The problem | ||
|
|
||
| The existing interactive toast notification shows song info and controls when a song changes, but it auto-dismisses after 5 seconds. If you miss it or want to skip a track 30 seconds later, your only options are: | ||
|
|
||
| - **Double-click the tray icon** — opens the full window (overkill for just pressing "next") | ||
| - **Right-click the tray** — opens a basic text menu (functional but no album art, no visual feedback) | ||
|
|
||
| There's no quick, on-demand way to see what's playing and control it without opening the full app. | ||
|
|
||
| ### The solution | ||
|
|
||
| Hovering over the tray icon shows a compact floating mini-player with album art, song title, artist, and previous/play-pause/next buttons. It stays visible as long as your mouse is on the tray icon or the popup, and fades out when you move away. | ||
|
|
||
| This gives users three tiers of tray interaction: | ||
| 1. **Hover** — Quick glance + controls via the mini-player | ||
| 2. **Single click** — Toggle the toast notification (existing behavior) | ||
| 3. **Double click** — Open the full window (existing behavior) | ||
|
|
||
| ### How it works | ||
|
|
||
| **Popup window:** | ||
| - Frameless, transparent, always-on-top `BrowserWindow` positioned above the tray icon | ||
| - Dark theme (#282828) matching YouTube Music's aesthetic | ||
| - Shows album art (56x56), song title, artist, and SVG icon buttons | ||
|
|
||
| **Hover tracking (main process cursor polling):** | ||
| - `tray.on('mouse-move')` triggers the popup to appear | ||
| - A 150ms `setInterval` polls `screen.getCursorScreenPoint()` and checks if the cursor is over the popup bounds or the tray icon bounds | ||
| - If the cursor is on neither for a full cycle, the popup fades out | ||
| - This approach is more reliable on Windows than HTML-based mouseenter/mouseleave events | ||
|
|
||
| **Button clicks (`document.title` IPC):** | ||
| - Buttons use `onmousedown` (fires before window activation) and set `document.title` to signal the action | ||
| - Main process listens via `BrowserWindow.on('page-title-updated')` — reliable regardless of window focus state | ||
| - A counter is appended to ensure repeated clicks on the same button always trigger | ||
|
|
||
| **Toast suppression:** | ||
| - When the hover popup is visible, the interactive toast notification is suppressed to prevent both from appearing simultaneously | ||
| - The popup exports `isHoverPopupVisible()` which `interactive.ts` checks before showing a toast | ||
|
|
||
| ### Infrastructure fix: Deferred tray event handlers | ||
|
|
||
| Plugins load (`loadAllMainPlugins`) before the tray is created (`setUpTray`). Any `setTrayOnClick`, `setTrayOnDoubleClick`, or `setTrayOnMouseMove` calls from plugins were silently dropped because the tray didn't exist yet. | ||
|
|
||
| Fixed by queuing handlers registered before the tray exists and applying them at the end of `setUpTray`. This fix also benefits the existing notification plugin's `trayControls` feature, which had the same latent timing bug. | ||
|
|
||
| ### Files | ||
|
|
||
| | File | Role | | ||
| |------|------| | ||
| | `src/plugins/notifications/hover-popup.ts` | Popup window management, cursor tracking, IPC (new file) | | ||
| | `assets/hover-popup.html` | Mini-player UI: HTML, CSS, button handlers (new file) | | ||
| | `src/plugins/notifications/index.ts` | `hoverControls` config option | | ||
| | `src/plugins/notifications/main.ts` | Wires up `setupHoverPopup()` | | ||
| | `src/plugins/notifications/menu.ts` | Menu toggle | | ||
| | `src/plugins/notifications/interactive.ts` | Toast suppression check | | ||
| | `src/tray.ts` | `setTrayOnMouseMove()`, `getTrayBounds()`, deferred handler queue | | ||
|
|
||
| --- | ||
|
|
||
| ## Summary | ||
|
|
||
| | Feature | Type | Toggle | Default | Platform | | ||
| |---------|------|--------|---------|----------| | ||
| | Audio-Only Mode | Plugin | Plugin settings | Off | All | | ||
| | Playback Recovery | Plugin | Plugin settings | Off | All | | ||
| | Virtual Desktop Awareness | Core setting | Options > Tray | Off | Windows, macOS, Linux | | ||
| | Tray Hover Mini-Player | Plugin extension | Notifications > Interactive Settings | Off | Windows, macOS | | ||
|
|
||
| All features are: | ||
| - **Opt-in** — disabled by default, no impact on existing users | ||
| - **Independent** — can be enabled in any combination | ||
| - **Consistent** — follow existing plugin/config/menu/i18n patterns | ||
| - **Reversible** — toggle off and restart to fully revert | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| <!doctype html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <style> | ||
| *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } | ||
| html, body { | ||
| background: transparent !important; | ||
| overflow: hidden; | ||
| width: 100%; | ||
| height: 100%; | ||
| } | ||
| body { | ||
| padding: 8px; | ||
| user-select: none; | ||
| font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; | ||
| } | ||
| #popup { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 12px; | ||
| padding: 10px 14px; | ||
| background: #282828; | ||
| border-radius: 10px; | ||
| box-shadow: 0 8px 30px rgba(0, 0, 0, 0.55); | ||
| border: 1px solid rgba(255, 255, 255, 0.06); | ||
| height: calc(100% - 0px); | ||
| opacity: 0; | ||
| transform: translateY(6px); | ||
| transition: opacity 0.2s ease, transform 0.2s ease; | ||
| } | ||
| #popup.visible { | ||
| opacity: 1; | ||
| transform: translateY(0); | ||
| } | ||
| #albumArt { | ||
| width: 56px; | ||
| height: 56px; | ||
| border-radius: 6px; | ||
| object-fit: cover; | ||
| flex-shrink: 0; | ||
| background: #333; | ||
| } | ||
| #info { | ||
| flex: 1; | ||
| min-width: 0; | ||
| display: flex; | ||
| flex-direction: column; | ||
| justify-content: center; | ||
| gap: 3px; | ||
| } | ||
| #title { | ||
| color: #fff; | ||
| font-size: 13px; | ||
| font-weight: 600; | ||
| line-height: 1.3; | ||
| white-space: nowrap; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| } | ||
| #artist { | ||
| color: #aaa; | ||
| font-size: 11px; | ||
| line-height: 1.3; | ||
| white-space: nowrap; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| } | ||
| #controls { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 2px; | ||
| flex-shrink: 0; | ||
| } | ||
| .btn { | ||
| width: 36px; | ||
| height: 36px; | ||
| border: none; | ||
| background: transparent; | ||
| cursor: pointer; | ||
| border-radius: 50%; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| transition: background 0.15s; | ||
| padding: 0; | ||
| } | ||
| .btn:hover { background: rgba(255, 255, 255, 0.12); } | ||
| .btn:active { background: rgba(255, 255, 255, 0.20); } | ||
| .btn svg { width: 18px; height: 18px; fill: #fff; } | ||
| .btn.pp svg { width: 22px; height: 22px; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="popup"> | ||
| <img id="albumArt" /> | ||
| <div id="info"> | ||
| <div id="title">No song playing</div> | ||
| <div id="artist"></div> | ||
| </div> | ||
| <div id="controls"> | ||
| <button class="btn" id="prev" title="Previous"> | ||
| <svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zM18 6v12l-8.5-6z"/></svg> | ||
| </button> | ||
| <button class="btn pp" id="pp" title="Play/Pause"> | ||
| <svg viewBox="0 0 24 24" id="ppSvg"><path d="M8 5v14l11-7z"/></svg> | ||
| </button> | ||
| <button class="btn" id="next" title="Next"> | ||
| <svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg> | ||
|
Comment on lines
+98
to
+109
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. Move the popup copy into i18n.
🤖 Prompt for AI Agents |
||
| </button> | ||
| </div> | ||
| </div> | ||
| <script> | ||
| (function() { | ||
| var popup = document.getElementById('popup'); | ||
|
|
||
| var n = 0; | ||
| document.getElementById('prev').onmousedown = function(e) { e.preventDefault(); document.title = 'act:previous:' + (++n); }; | ||
| document.getElementById('next').onmousedown = function(e) { e.preventDefault(); document.title = 'act:next:' + (++n); }; | ||
| document.getElementById('pp').onmousedown = function(e) { e.preventDefault(); document.title = 'act:playPause:' + (++n); }; | ||
|
|
||
| window.updateSongInfo = function(data) { | ||
| document.getElementById('title').textContent = data.title || 'No song playing'; | ||
| document.getElementById('artist').textContent = data.artist || ''; | ||
| var art = document.getElementById('albumArt'); | ||
| if (data.imageSrc) { art.src = data.imageSrc; art.style.display = ''; } | ||
| else { art.style.display = 'none'; } | ||
| document.getElementById('ppSvg').innerHTML = data.isPaused | ||
| ? '<path d="M8 5v14l11-7z"/>' | ||
| : '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>'; | ||
| }; | ||
|
|
||
| window.showPopup = function() { popup.classList.add('visible'); }; | ||
| window.hidePopup = function() { popup.classList.remove('visible'); }; | ||
| })(); | ||
| </script> | ||
| </body> | ||
| </html> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Add a language to the fenced code block.
This block is currently tripping
markdownlint(MD040).Suggested fix
Verify each finding against the current code and only fix it if needed.
In
@EMRE-FEATURES.mdaround lines 101 - 105, The fenced code block containingthe win.setVisibleOnAllWorkspaces(...) and win.show() example lacks a language
tag and triggers markdownlint MD040; update the triple-backtick fence to include
a language (e.g., "ts") so it reads
ts before the first line and keep the closingat the end, ensuring the block exactly surrounds thewin.setVisibleOnAllWorkspaces(true), win.show(),
win.setVisibleOnAllWorkspaces(false) lines.