From 2cd17647f7124aee3c9af1813a19b29e35e2a54a Mon Sep 17 00:00:00 2001 From: Arylite <158570181+Arylite@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:15:27 +0400 Subject: [PATCH 1/2] feat(electron): system tray with playback controls --- electron/main.ts | 38 ++++++++++--- electron/thumbar.ts | 2 +- electron/tray.test.ts | 122 ++++++++++++++++++++++++++++++++++++++++++ electron/tray.ts | 105 ++++++++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 electron/tray.test.ts create mode 100644 electron/tray.ts diff --git a/electron/main.ts b/electron/main.ts index 4d6a1db7d..c232de00a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -51,6 +51,7 @@ import { import { TouchBarManager } from "./touchbar.js"; import { registerAuthSessionIpc } from "./auth-session-ipc.js"; import { ThumbarController } from "./thumbar.js"; +import { TrayController } from "./tray.js"; import { OverlayManager } from "./overlay.js"; import { detectCompositor } from "./compositor/index.js"; import { BundleManager } from "./bundle-manager.js"; @@ -125,6 +126,7 @@ let mainWindow: BrowserWindow | null = null; let touchBarManager: TouchBarManager | null = null; let overlayManager: OverlayManager | null = null; let thumbar: ThumbarController | null = null; +let tray: TrayController | null = null; let bundleManager: BundleManager | null = null; let discordRpcManager: DiscordRpcManager | null = null; let poTokenService: PoTokenService | null = null; @@ -180,6 +182,10 @@ function unmaximizeWindow() { const shutdown = createShutdown({ cleanup: [ () => touchBarManager?.unregisterShortcut(), + () => { + tray?.destroy(); + tray = null; + }, () => overlayManager?.dispose(), () => { // drop the discord ipc socket so presence clears and nothing keeps the process alive @@ -274,7 +280,8 @@ function createWindow() { }); // the custom window controls replace the native traffic lights - if (process.platform === "darwin") mainWindow.setWindowButtonVisibility(false); + if (process.platform === "darwin") + mainWindow.setWindowButtonVisibility(false); // sync window state changes to renderer const broadcastWindowState = () => { @@ -485,6 +492,16 @@ function createWindow() { mainWindow.on("restore", () => thumbar?.refresh()); } +function revealMainWindow() { + if (!mainWindow || mainWindow.isDestroyed()) { + createWindow(); + } else { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } +} + // Single instance lock const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { @@ -506,13 +523,7 @@ if (!gotTheLock) { app.exit(0); return; } - if (!mainWindow || mainWindow.isDestroyed()) { - createWindow(); - } else { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - } + revealMainWindow(); if (raw) deliverDeeplink(parseDeeplink(raw)); }); @@ -558,11 +569,22 @@ if (!gotTheLock) { mainWindow.webContents.send("overlay:action", { type }); }, ); + tray = new TrayController({ + iconPath: path.join(process.env.VITE_PUBLIC!, "icon.png"), + showWindow: revealMainWindow, + send: (type) => { + if (mainWindow && !mainWindow.isDestroyed()) + mainWindow.webContents.send("overlay:action", { type }); + }, + quit: () => shutdown.quit(), + }); + tray.create(); ipcMain.on( "touchbar:update-state", (_event, state: { status: string; track?: unknown }) => { updateSleepBlocker(state.status); thumbar?.update(state); + tray?.update(state); }, ); diff --git a/electron/thumbar.ts b/electron/thumbar.ts index 742050a6e..3159d1d03 100644 --- a/electron/thumbar.ts +++ b/electron/thumbar.ts @@ -102,7 +102,7 @@ function glyphImage(glyph: ThumbarGlyph): NativeImage { export type ThumbarPlayerState = { status?: string; track?: unknown } | null | undefined; -const PLAYING_STATUSES = new Set(["playing", "loading", "buffering"]); +export const PLAYING_STATUSES = new Set(["playing", "loading", "buffering"]); export class ThumbarController { private icons: Record | null = null; diff --git a/electron/tray.test.ts b/electron/tray.test.ts new file mode 100644 index 000000000..8bba9d09a --- /dev/null +++ b/electron/tray.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type FakeItem = { + label?: string; + type?: string; + enabled?: boolean; + click?: () => void; +}; + +const { trays, FakeTray } = vi.hoisted(() => { + const trays: InstanceType[] = []; + + class FakeTray { + destroyed = false; + listeners = new Map void>(); + setToolTip = vi.fn(); + setContextMenu = vi.fn<(menu: FakeItem[]) => void>(); + constructor() { + trays.push(this); + } + on(event: string, listener: () => void) { + this.listeners.set(event, listener); + } + isDestroyed() { + return this.destroyed; + } + destroy() { + this.destroyed = true; + } + } + + return { trays, FakeTray }; +}); + +vi.mock("electron", () => ({ + Tray: FakeTray, + Menu: { buildFromTemplate: (template: FakeItem[]) => template }, + nativeImage: { + createFromPath: vi.fn(() => ({ resize: vi.fn(() => ({})) })), + }, +})); + +import { TrayController } from "./tray"; +import type { ThumbarAction } from "./thumbar"; + +describe("TrayController", () => { + beforeEach(() => { + trays.length = 0; + }); + + function setup(platform: NodeJS.Platform = "win32") { + const sent: ThumbarAction[] = []; + const showWindow = vi.fn(); + const quit = vi.fn(); + const controller = new TrayController({ + iconPath: "icon.png", + showWindow, + send: (action) => sent.push(action), + quit, + platform, + }); + controller.create(); + const tray = trays[0]!; + const lastMenu = () => tray.setContextMenu.mock.calls.at(-1)?.[0] ?? []; + const item = (label: string) => lastMenu().find((i) => i.label === label); + return { sent, showWindow, quit, controller, tray, lastMenu, item }; + } + + it("starts with playback disabled until a track is loaded", () => { + const { item } = setup(); + expect(item("Show Liner")?.enabled).toBeUndefined(); + expect(item("Previous")?.enabled).toBe(false); + expect(item("Play")?.enabled).toBe(false); + expect(item("Next")?.enabled).toBe(false); + }); + + it("routes playback items to the player while a track plays", () => { + const { sent, controller, item } = setup(); + controller.update({ status: "playing", track: { id: "a" } }); + item("Previous")?.click?.(); + item("Pause")?.click?.(); + item("Next")?.click?.(); + expect(sent).toEqual(["prev", "togglePlay", "next"]); + }); + + it("only rebuilds the menu when the play state changes", () => { + const { tray, controller, item } = setup(); + controller.update({ status: "playing", track: { id: "a" } }); + controller.update({ status: "buffering", track: { id: "b" } }); + expect(tray.setContextMenu).toHaveBeenCalledTimes(2); + controller.update({ status: "paused", track: { id: "b" } }); + expect(item("Play")?.enabled).toBe(true); + expect(tray.setContextMenu).toHaveBeenCalledTimes(3); + }); + + it("shows the window and quits from the menu", () => { + const { showWindow, quit, item } = setup(); + item("Show Liner")?.click?.(); + item("Quit Liner")?.click?.(); + expect(showWindow).toHaveBeenCalledOnce(); + expect(quit).toHaveBeenCalledOnce(); + }); + + it("brings the window back on click outside macos", () => { + const { tray, showWindow } = setup(); + tray.listeners.get("click")?.(); + expect(showWindow).toHaveBeenCalledOnce(); + }); + + it("leaves the click to the menu on macos", () => { + const { tray } = setup("darwin"); + expect(tray.listeners.has("click")).toBe(false); + }); + + it("stops touching the tray once destroyed", () => { + const { tray, controller } = setup(); + controller.destroy(); + expect(tray.destroyed).toBe(true); + controller.update({ status: "playing", track: { id: "a" } }); + expect(tray.setContextMenu).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/tray.ts b/electron/tray.ts new file mode 100644 index 000000000..a4132b78d --- /dev/null +++ b/electron/tray.ts @@ -0,0 +1,105 @@ +import { + Menu, + Tray, + nativeImage, + type MenuItemConstructorOptions, +} from "electron"; +import { + PLAYING_STATUSES, + type ThumbarAction, + type ThumbarPlayerState, +} from "./thumbar.js"; + +export interface TrayDeps { + iconPath: string; + /** bring the main window back, creating it if it was closed */ + showWindow: () => void; + send: (action: ThumbarAction) => void; + quit: () => void; + platform?: NodeJS.Platform; +} + +export function trayMenuTemplate( + state: { hasTrack: boolean; playing: boolean }, + deps: Pick, +): MenuItemConstructorOptions[] { + return [ + { label: "Show Liner", click: () => deps.showWindow() }, + { type: "separator" }, + { + label: "Previous", + enabled: state.hasTrack, + click: () => deps.send("prev"), + }, + { + label: state.playing ? "Pause" : "Play", + enabled: state.hasTrack, + click: () => deps.send("togglePlay"), + }, + { + label: "Next", + enabled: state.hasTrack, + click: () => deps.send("next"), + }, + { type: "separator" }, + { label: "Quit Liner", click: () => deps.quit() }, + ]; +} + +export class TrayController { + private tray: Tray | null = null; + private hasTrack = false; + private playing = false; + private appliedKey: string | null = null; + + constructor(private readonly deps: TrayDeps) {} + + create() { + if (this.tray) return; + const platform = this.deps.platform ?? process.platform; + try { + // the macos menu bar fits a 16pt glyph; windows and linux scale down from 32 + const size = platform === "darwin" ? 16 : 32; + const icon = nativeImage + .createFromPath(this.deps.iconPath) + .resize({ width: size, height: size }); + this.tray = new Tray(icon); + } catch (err) { + console.warn("[tray] could not create tray icon:", err); + return; + } + this.tray.setToolTip("Liner"); + // on macos a click opens the context menu, elsewhere it brings the window back + if (platform !== "darwin") { + this.tray.on("click", () => this.deps.showWindow()); + } + this.apply(true); + } + + update(state: ThumbarPlayerState) { + this.hasTrack = Boolean(state?.track); + this.playing = PLAYING_STATUSES.has(state?.status ?? ""); + this.apply(false); + } + + destroy() { + if (this.tray && !this.tray.isDestroyed()) this.tray.destroy(); + this.tray = null; + this.appliedKey = null; + } + + private apply(force: boolean) { + if (!this.tray || this.tray.isDestroyed()) return; + const key = this.hasTrack ? (this.playing ? "playing" : "paused") : "none"; + if (!force && key === this.appliedKey) return; + this.tray.setContextMenu( + Menu.buildFromTemplate( + trayMenuTemplate( + { hasTrack: this.hasTrack, playing: this.playing }, + this.deps, + ), + ), + ); + this.appliedKey = key; + } +} From 4446c72ecb5f78c439594a0dceb5bb22ee922d10 Mon Sep 17 00:00:00 2001 From: Arylite <158570181+Arylite@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:30:36 +0400 Subject: [PATCH 2/2] fix(electron): tray resets on macos window close, wiring moved into tray module --- electron/main.ts | 25 ++++++++++--------------- electron/tray.test.ts | 17 +++++++++++++---- electron/tray.ts | 17 ++++++++++++----- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index c232de00a..0bbdb3010 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -50,8 +50,8 @@ import { } from "./netdiag.js"; import { TouchBarManager } from "./touchbar.js"; import { registerAuthSessionIpc } from "./auth-session-ipc.js"; -import { ThumbarController } from "./thumbar.js"; -import { TrayController } from "./tray.js"; +import { ThumbarController, type ThumbarAction } from "./thumbar.js"; +import { createTray, type TrayController } from "./tray.js"; import { OverlayManager } from "./overlay.js"; import { detectCompositor } from "./compositor/index.js"; import { BundleManager } from "./bundle-manager.js"; @@ -480,6 +480,7 @@ function createWindow() { if (process.platform === "darwin") { // macOS keeps the app alive without windows; the overlay can be rebuilt on activate overlayManager?.destroy(); + tray?.update(null); } else { // closing the main window (title bar button, taskbar, alt+f4) ends the whole app shutdown.quit(); @@ -562,23 +563,17 @@ if (!gotTheLock) { touchBarManager.registerShortcut(); const updateSleepBlocker = createSleepBlocker(powerSaveBlocker); - thumbar = new ThumbarController( - () => mainWindow, - (type) => { - if (mainWindow && !mainWindow.isDestroyed()) - mainWindow.webContents.send("overlay:action", { type }); - }, - ); - tray = new TrayController({ + const sendPlayerAction = (type: ThumbarAction) => { + if (mainWindow && !mainWindow.isDestroyed()) + mainWindow.webContents.send("overlay:action", { type }); + }; + thumbar = new ThumbarController(() => mainWindow, sendPlayerAction); + tray = createTray({ iconPath: path.join(process.env.VITE_PUBLIC!, "icon.png"), showWindow: revealMainWindow, - send: (type) => { - if (mainWindow && !mainWindow.isDestroyed()) - mainWindow.webContents.send("overlay:action", { type }); - }, + send: sendPlayerAction, quit: () => shutdown.quit(), }); - tray.create(); ipcMain.on( "touchbar:update-state", (_event, state: { status: string; track?: unknown }) => { diff --git a/electron/tray.test.ts b/electron/tray.test.ts index 8bba9d09a..df510d8d7 100644 --- a/electron/tray.test.ts +++ b/electron/tray.test.ts @@ -40,7 +40,7 @@ vi.mock("electron", () => ({ }, })); -import { TrayController } from "./tray"; +import { createTray } from "./tray"; import type { ThumbarAction } from "./thumbar"; describe("TrayController", () => { @@ -52,14 +52,13 @@ describe("TrayController", () => { const sent: ThumbarAction[] = []; const showWindow = vi.fn(); const quit = vi.fn(); - const controller = new TrayController({ + const controller = createTray({ iconPath: "icon.png", showWindow, send: (action) => sent.push(action), quit, platform, }); - controller.create(); const tray = trays[0]!; const lastMenu = () => tray.setContextMenu.mock.calls.at(-1)?.[0] ?? []; const item = (label: string) => lastMenu().find((i) => i.label === label); @@ -93,6 +92,15 @@ describe("TrayController", () => { expect(tray.setContextMenu).toHaveBeenCalledTimes(3); }); + it("disables playback items when the track is removed", () => { + const { controller, item } = setup(); + controller.update({ status: "playing", track: { id: "a" } }); + controller.update({ status: "idle", track: null }); + expect(item("Previous")?.enabled).toBe(false); + expect(item("Play")?.enabled).toBe(false); + expect(item("Next")?.enabled).toBe(false); + }); + it("shows the window and quits from the menu", () => { const { showWindow, quit, item } = setup(); item("Show Liner")?.click?.(); @@ -108,8 +116,9 @@ describe("TrayController", () => { }); it("leaves the click to the menu on macos", () => { - const { tray } = setup("darwin"); + const { tray, item } = setup("darwin"); expect(tray.listeners.has("click")).toBe(false); + expect(item("Show Liner")).toBeDefined(); }); it("stops touching the tray once destroyed", () => { diff --git a/electron/tray.ts b/electron/tray.ts index a4132b78d..a38dadb88 100644 --- a/electron/tray.ts +++ b/electron/tray.ts @@ -10,9 +10,11 @@ import { type ThumbarPlayerState, } from "./thumbar.js"; +const MACOS_ICON_SIZE = 16; +const ICON_SIZE = 32; + export interface TrayDeps { iconPath: string; - /** bring the main window back, creating it if it was closed */ showWindow: () => void; send: (action: ThumbarAction) => void; quit: () => void; @@ -57,9 +59,9 @@ export class TrayController { create() { if (this.tray) return; const platform = this.deps.platform ?? process.platform; + const opensMenuOnClick = platform === "darwin"; try { - // the macos menu bar fits a 16pt glyph; windows and linux scale down from 32 - const size = platform === "darwin" ? 16 : 32; + const size = opensMenuOnClick ? MACOS_ICON_SIZE : ICON_SIZE; const icon = nativeImage .createFromPath(this.deps.iconPath) .resize({ width: size, height: size }); @@ -69,8 +71,7 @@ export class TrayController { return; } this.tray.setToolTip("Liner"); - // on macos a click opens the context menu, elsewhere it brings the window back - if (platform !== "darwin") { + if (!opensMenuOnClick) { this.tray.on("click", () => this.deps.showWindow()); } this.apply(true); @@ -103,3 +104,9 @@ export class TrayController { this.appliedKey = key; } } + +export function createTray(deps: TrayDeps): TrayController { + const tray = new TrayController(deps); + tray.create(); + return tray; +}