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
49 changes: 33 additions & 16 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand All @@ -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));
});

Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
);

Expand Down
2 changes: 1 addition & 1 deletion electron/thumbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThumbarGlyph, NativeImage> | null = null;
Expand Down
131 changes: 131 additions & 0 deletions electron/tray.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof FakeTray>[] = [];

class FakeTray {
destroyed = false;
listeners = new Map<string, () => 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);
});
});
112 changes: 112 additions & 0 deletions electron/tray.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
send: (action: ThumbarAction) => void;
quit: () => void;
platform?: NodeJS.Platform;
}

export function trayMenuTemplate(
state: { hasTrack: boolean; playing: boolean },
deps: Pick<TrayDeps, "showWindow" | "send" | "quit">,
): 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;
}