Skip to content
Merged
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
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config
.DS_Store
src/spotify.db
certs/cert.pem
certs/key.pem
src/certs/*
certs/*
spotify.db
src/keyspotic.db
keyspotic.db
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { startListener } from "./listeners/hotkeys";
import { setupSpotifyAuth } from "./modules/auth/setupSpotifyAuth";
import { initSpotifyAuth } from "./modules/spotify/startup";
import { spotifyCommands } from "./modules/spotify/commands/main";

const allCommands = [...spotifyCommands];
Expand All @@ -8,4 +8,4 @@ console.log("Hotkeys listener init...");
startListener(allCommands);
console.log("Hotkeys listener started.");

setupSpotifyAuth();
initSpotifyAuth();
76 changes: 23 additions & 53 deletions src/listeners/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,32 @@ import {
GlobalKeyboardListener,
IGlobalKeyEvent,
} from "node-global-key-listener";
import { Command } from "../modules/spotify/commands/main";

function normalizeModifier(key: string) {
if (!key) return "";
if (key.includes("CTRL")) return "CTRL";
if (key.includes("ALT")) return "ALT";
if (key.includes("SHIFT")) return "SHIFT";
if (key.includes("META")) return "META";
return key.toUpperCase();
}

function buildCombo(heldModifiers: Set<string>, key: string) {
// Order fixed for consistency: CTRL + ALT + SHIFT + META + key
const order = ["CTRL", "ALT", "SHIFT", "META"];
const mods = order.filter((m) => heldModifiers.has(m));
return mods.length
? [...mods, key.toUpperCase()].join(" + ")
: key.toUpperCase();
}

export function startListener(commands: Command[]) {
import { Command } from "../modules/hotkey/core/engine";
import { initialState, updateState } from "../modules/hotkey/core/state";
import { executeCommands } from "../modules/hotkey/core/engine";

const IGNORED_EVENTS = ["MOUSE"];

/**
* Starts the global hotkey listener and processes key events.
* @param commands The list of registered commands to execute on hotkey presses.
* @returns A function to stop the listener.
*/
export const startListener = (commands: Command[]) => {
const keyboard = new GlobalKeyboardListener();
const heldModifiers = new Set<string>();
const loggedCombos = new Set<string>();

const MODIFIERS = new Set([
"LEFT CTRL",
"RIGHT CTRL",
"LEFT ALT",
"RIGHT ALT",
"LEFT SHIFT",
"RIGHT SHIFT",
]);

keyboard.addListener((e: IGlobalKeyEvent) => {
if (!e.name || e.name.includes("MOUSE")) return;
let internalState = initialState();

const key = e.name.toUpperCase();
keyboard.addListener((event: IGlobalKeyEvent) => {
if (!event.name || IGNORED_EVENTS.includes(event.name)) return;

if (e.state === "DOWN") {
if (MODIFIERS.has(key)) {
heldModifiers.add(normalizeModifier(key));
} else {
const combo = buildCombo(heldModifiers, key);
const data = { name: event.name, state: event.state };
// Transform the state immutably
internalState = updateState(data, internalState);

if (!loggedCombos.has(combo)) {
loggedCombos.add(combo);
// Evaluate the current state and execute commands
const actions = executeCommands(internalState, data, commands);

const cmd = commands.find((c) => c.hotkey === combo);
if (cmd) cmd.action();
}
}
} else if (e.state === "UP") {
const normalizedKey = normalizeModifier(key);
if (MODIFIERS.has(key)) heldModifiers.delete(normalizedKey);
else loggedCombos.clear();
}
// Execute all matched actions
actions.forEach((act) => act());
});
}
};
6 changes: 6 additions & 0 deletions src/migrations/001_create_spotify_tokens_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS spotify_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
access_token TEXT,
refresh_token TEXT,
expires_at INTEGER
);
14 changes: 14 additions & 0 deletions src/modules/auth/certHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { binDir } from "../../shared/shared";
import { join } from "path";

/**
* Paths to the TLS certificate and key files.
* These are used for setting up HTTPS servers.
*
*/
const retrieveCertPaths = {
certFile: join(binDir, "certs", "cert.pem"),
keyFile: join(binDir, "certs", "key.pem"),
};

export { retrieveCertPaths };
35 changes: 0 additions & 35 deletions src/modules/auth/setupSpotifyAuth.ts

This file was deleted.

37 changes: 37 additions & 0 deletions src/modules/hotkey/core/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ListenerState } from "./state";
/**
* Represents a command with its associated hotkey and action.
*/
export interface Command {
hotkey: string;
action: () => void;
}

/**
* Represents an engine event for key actions.
* @property name The name of the key.
* @property state The state of the key, either "UP" or "DOWN".
*/
export interface EngineEvent {
name: string;
state: "UP" | "DOWN";
}
/**
* Executes the commands associated with the currently pressed hotkeys.
* @param state The current listener state.
* @param event The key event to process.
* @param commands The list of registered commands.
* @returns An array of functions to execute the matched commands.
*/
export const executeCommands = (
state: ListenerState,
event: EngineEvent,
commands: Command[]
): (() => void)[] => {
if (!event.name) return [];

const pressedCombos = Array.from(state.combos);
return commands
.filter((cmd) => pressedCombos.includes(cmd.hotkey))
.map((cmd) => cmd.action);
};
53 changes: 53 additions & 0 deletions src/modules/hotkey/core/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { buildCombo, isModifierKey, normalizeModifier } from "./transform";
/**
* State of the hotkey listener, tracking held keys and active combos.
*
*/
export interface ListenerState {
held: Set<string>;
combos: Set<string>;
}
/**
* Initializes the listener state with empty held keys and combos.
* @returns The initial listener state.
*/
export const initialState = (): ListenerState => ({
held: new Set(),
combos: new Set(),
});
/**
* Updates the listener state based on the incoming key event.
* @param event The key event to process.
* @param prev The previous listener state.
* @returns The updated listener state.
*/
export const updateState = (
event: { name: string; state: "UP" | "DOWN" },
prev: ListenerState
): ListenerState => {
const next = {
held: new Set(prev.held),
combos: new Set(prev.combos),
};

const key = event.name.toUpperCase();
const isMod = isModifierKey(key);

if (event.state === "DOWN") {
if (isMod) {
next.held.add(normalizeModifier(key));
return next;
}

const combo = buildCombo(next.held, key);
next.combos.add(combo);
return next;
}

if (event.state === "UP") {
if (isMod) next.held.delete(normalizeModifier(key));
else next.combos.clear();
}

return next;
};
40 changes: 40 additions & 0 deletions src/modules/hotkey/core/transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Normalizes various modifier key names to standard forms.
* @param key The key name to normalize.
* @returns The normalized key name.
*/
export const normalizeModifier = (key: string): string => {
if (!key) return "";
const map = {
CTRL: ["LEFT CTRL", "RIGHT CTRL", "CTRL"],
ALT: ["LEFT ALT", "RIGHT ALT", "ALT"],
SHIFT: ["LEFT SHIFT", "RIGHT SHIFT", "SHIFT"],
META: ["LEFT META", "RIGHT META", "META", "SUPER", "WINDOWS"],
} as const;

for (const [normalized, variants] of Object.entries(map)) {
if (variants.some((v) => key.includes(v))) return normalized;
}

return key.toUpperCase();
};

/**
* Checks if the given key is a modifier key.
* @param key The key name to check.
* @returns True if the key is a modifier key, false otherwise.
*/
export const isModifierKey = (key: string): boolean =>
["CTRL", "ALT", "SHIFT", "META"].some((m) => key.includes(m));

/**
* Builds a combo string from held modifier keys and the main key.
* @param held The set of currently held modifier keys.
* @param key The main key to include in the combo.
* @returns The constructed combo string.
*/
export const buildCombo = (held: Set<string>, key: string): string => {
const order = ["CTRL", "ALT", "SHIFT", "META"];
const mods = order.filter((m) => held.has(m));
return [...mods, key.toUpperCase()].join(" + ");
};
24 changes: 0 additions & 24 deletions src/modules/routes/main.ts

This file was deleted.

21 changes: 12 additions & 9 deletions src/modules/spotify/commands/main.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { playPause, nextTrack, previousTrack } from "./player";
import path from "path";
import { binDir } from "../../../shared/shared";
import { playOrPause, nextTrack, previousTrack } from "./player";
import { join } from "path";

export interface Command {
export type Command = {
hotkey: string;
action: () => void;
}
const root = process.cwd();
const hotkeysPath = Bun.file(path.join(root, "hotkeys.json"));
const hotkeysText = await hotkeysPath.text();
};
const hotkeysText = await Bun.file(join(binDir, "hotkeys.json")).text();
const hotkeysConfig = JSON.parse(hotkeysText);

//TODO: Use ZOD or similar for validation
/**
* Array of Spotify commands with their associated hotkeys and actions.
* Each command consists of a hotkey string and a corresponding action function.
* This array is used to map user inputs to Spotify playback controls.
*/
export const spotifyCommands: Command[] = [
{ hotkey: hotkeysConfig.spotify.playPause, action: playPause },
{ hotkey: hotkeysConfig.spotify.playOrPause, action: playOrPause },
{ hotkey: hotkeysConfig.spotify.nextTrack, action: nextTrack },
{ hotkey: hotkeysConfig.spotify.previousTrack, action: previousTrack },
];
Loading