diff --git a/electron/main.ts b/electron/main.ts index 4d6a1db7d..0bbdb3010 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -50,7 +50,8 @@ import { } from "./netdiag.js"; import { TouchBarManager } from "./touchbar.js"; import { registerAuthSessionIpc } from "./auth-session-ipc.js"; -import { ThumbarController } from "./thumbar.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"; @@ -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 = () => { @@ -473,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(); @@ -485,6 +493,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 +524,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)); }); @@ -551,18 +563,23 @@ if (!gotTheLock) { touchBarManager.registerShortcut(); const updateSleepBlocker = createSleepBlocker(powerSaveBlocker); - thumbar = new ThumbarController( - () => mainWindow, - (type) => { - if (mainWindow && !mainWindow.isDestroyed()) - mainWindow.webContents.send("overlay:action", { type }); - }, - ); + 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: sendPlayerAction, + quit: () => shutdown.quit(), + }); 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..df510d8d7 --- /dev/null +++ b/electron/tray.test.ts @@ -0,0 +1,131 @@ +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 { createTray } 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 = createTray({ + iconPath: "icon.png", + showWindow, + send: (action) => sent.push(action), + quit, + platform, + }); + 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("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?.(); + 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, item } = setup("darwin"); + expect(tray.listeners.has("click")).toBe(false); + expect(item("Show Liner")).toBeDefined(); + }); + + 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..a38dadb88 --- /dev/null +++ b/electron/tray.ts @@ -0,0 +1,112 @@ +import { + Menu, + Tray, + nativeImage, + type MenuItemConstructorOptions, +} from "electron"; +import { + PLAYING_STATUSES, + type ThumbarAction, + type ThumbarPlayerState, +} from "./thumbar.js"; + +const MACOS_ICON_SIZE = 16; +const ICON_SIZE = 32; + +export interface TrayDeps { + iconPath: string; + 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; + const opensMenuOnClick = platform === "darwin"; + try { + const size = opensMenuOnClick ? MACOS_ICON_SIZE : ICON_SIZE; + 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"); + if (!opensMenuOnClick) { + 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; + } +} + +export function createTray(deps: TrayDeps): TrayController { + const tray = new TrayController(deps); + tray.create(); + return tray; +}