Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
203 changes: 203 additions & 0 deletions EMRE-FEATURES.md
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)
```
Comment on lines +101 to +105
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

Add a language to the fenced code block.

This block is currently tripping markdownlint (MD040).

Suggested fix
-```
+```ts
 win.setVisibleOnAllWorkspaces(true)   // Pin to all desktops (appears on current)
 win.show()                             // Show and focus
 win.setVisibleOnAllWorkspaces(false)  // Unpin (stays on current desktop)
</details>

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.22.0)</summary>

[warning] 101-101: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @EMRE-FEATURES.md around lines 101 - 105, The fenced code block containing
the 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 closing at the end, ensuring the block exactly surrounds the
win.setVisibleOnAllWorkspaces(true), win.show(),
win.setVisibleOnAllWorkspaces(false) lines.


</details>

<!-- fingerprinting:phantom:medusa:grasshopper:65f23a06-22db-4af4-b272-5df95138973f -->

<!-- This is an auto-generated comment by CodeRabbit -->


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
138 changes: 138 additions & 0 deletions assets/hover-popup.html
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
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

Move the popup copy into i18n.

No song playing plus the three button tooltips are hardcoded here, so this mini-player will stay English even when the rest of the app is translated.

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

In `@assets/hover-popup.html` around lines 98 - 109, Replace hardcoded UI copy in
the hover popup by wiring it to the app i18n system: remove the literal "No song
playing" in the element with id="title" and the literal title attributes on
buttons with ids "prev", "pp", and "next", and instead set their text/title via
your i18n lookup (e.g., using the same i18n keys pattern used elsewhere) when
the popup is created/updated; ensure you add unique i18n keys like
"player.noSong", "player.prevTooltip", "player.playPauseTooltip", and
"player.nextTooltip" to the locale files and update the popup initialization
code to populate `#title.textContent` and the buttons' title attributes from those
keys.

</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>
2 changes: 2 additions & 0 deletions src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface DefaultConfig {
removeUpgradeButton: boolean;
restartOnConfigChanges: boolean;
trayClickPlayPause: boolean;
trayMoveToCurrentDesktop: boolean;
autoResetAppCache: boolean;
resumeOnStart: boolean;
likeButtons: string;
Expand Down Expand Up @@ -64,6 +65,7 @@ export const defaultConfig: DefaultConfig = {
removeUpgradeButton: false,
restartOnConfigChanges: false,
trayClickPlayPause: false,
trayMoveToCurrentDesktop: false,
autoResetAppCache: false,
resumeOnStart: true,
likeButtons: '',
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/resources/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"disabled": "Disabled",
"enabled-and-hide-app": "Enabled and hide app",
"enabled-and-show-app": "Enabled and show app",
"move-to-current-desktop": "Move to current virtual desktop on show",
"play-pause-on-click": "Play/Pause on click"
}
},
Expand Down Expand Up @@ -243,6 +244,10 @@
},
"name": "Album Color Theme"
},
"audio-only": {
"description": "Forces audio-only mode — blocks all video streams at network and player level to save RAM and bandwidth",
"name": "Audio Only"
},
"ambient-mode": {
"description": "Applies a lighting effect by casting gentle colors from the video, into your screen’s background",
"menu": {
Expand Down Expand Up @@ -684,6 +689,7 @@
"label": "Interactive Settings",
"submenu": {
"hide-button-text": "Hide button text",
"hover-controls": "Show mini-player on tray hover",
"refresh-on-play-pause": "Refresh on Play/Pause",
"tray-controls": "Open/Close on tray click"
}
Expand All @@ -698,6 +704,13 @@
"description": "Improve performance by enabling experimental scripts",
"name": "Performance improvement [Beta]"
},
"playback-recovery": {
"description": "Automatically recovers from stuck, stalled, or dead playback states",
"menu": {
"log-to-console": "Log recovery events to console"
},
"name": "Playback Recovery"
},
"picture-in-picture": {
"description": "Allows to switch the app to picture-in-picture mode",
"menu": {
Expand Down
Loading