Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c7d9fa9
refactor: split build script into separate commands for js, linux, ma…
SammyBytes Nov 1, 2025
7cd67c3
feat: add CertHelper for TLS certificate paths and implement Spotify …
SammyBytes Nov 2, 2025
69966b6
feat: implement Spotify authentication with token management and HTTP…
SammyBytes Nov 2, 2025
866040f
feat: implement token management with database integration and update…
SammyBytes Nov 2, 2025
597dbdf
feat: create spotify_tokens table and update token management functions
SammyBytes Nov 2, 2025
87ca78d
feat: refactor token management and add result handling for Spotify a…
SammyBytes Nov 2, 2025
5e00124
feat: refactor Spotify API initialization and token management logic
SammyBytes Nov 2, 2025
ccb6dfc
feat: refactor Spotify configuration and database integration
SammyBytes Nov 2, 2025
defbeed
feat: enhance Spotify integration with improved token management and …
SammyBytes Nov 2, 2025
15215bf
feat: restructure Spotify module and implement token management with …
SammyBytes Nov 2, 2025
fbdc7aa
Merge pull request #4 from SammyBytes/feat/spotify-retry-inactive
SammyBytes Nov 2, 2025
511ad7f
feat: add device active check before playback controls
SammyBytes Nov 2, 2025
68e4ed2
feat: add logging for token management and initialization in Spotify …
SammyBytes Nov 2, 2025
38c1012
feat: remove logging for token saving in database service
SammyBytes Nov 2, 2025
72d09d4
feat: update build configuration and restructure database handling
SammyBytes Nov 2, 2025
8f96c01
Merge pull request #5 from SammyBytes:fix/spotify-inactive
SammyBytes Nov 2, 2025
8a86e08
feat: update environment configuration and add postbuild script for f…
SammyBytes Nov 2, 2025
1b92c57
Merge pull request #6 from SammyBytes:feat/postbuild
SammyBytes Nov 2, 2025
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
SPOTIFY_CLIENT_ID=your_spotify_client_id
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
SPOTIFY_REDIRECT_URI=https://localhost:8888/callback
HONO_PORT=1234
SPOTIFY_REDIRECT_URI=https://127.0.0.1:1234/api/v1/spotify/callback
NODE_ENV=production
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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun && cp hotkeys.json dist/hotkeys.json"
"build:js": "bun build src/index.ts --outdir dist --target bun && cp hotkeys.json dist/hotkeys.json",
"build:linux": "bun build --compile --target=bun-linux-x64-baseline src/index.ts --outfile dist/linux/KeySpotic-linux",
"build:win": "bun build --compile --target=bun-windows-x64 src/index.ts --outfile dist/win/KeySpotic-win.exe",
"postbuild": "bun run scripts/postbuild.ts",
"build": "bun run build:linux && bun run build:win && bun run postbuild"
},
"keywords": [
"spotify",
Expand Down
22 changes: 22 additions & 0 deletions scripts/postbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { mkdirSync, cpSync } from "fs";
import { join } from "path";

const targets = ["linux", "mac", "win"];

for (const target of targets) {
const base = join("dist", target);

// Create directories if they don't exist
mkdirSync(base, { recursive: true });
mkdirSync(join(base, "certs"), { recursive: true });

// Copy common files
cpSync(".env.example", join(base, ".env.example"));
cpSync("hotkeys.json", join(base, "hotkeys.json"));

// Copy certificates
cpSync("certs/cert.pem", join(base, "certs/cert.pem"));
cpSync("certs/key.pem", join(base, "certs/key.pem"));

console.log(`Files copied for ${target}`);
}
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());
});
}
};
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.

20 changes: 10 additions & 10 deletions src/modules/spotify/commands/main.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { playPause, nextTrack, previousTrack } from "./player";
import path from "path";
import { playOrPause, nextTrack, previousTrack } from "./player";
import hotkeysConfig from "../../../../hotkeys.json" with { type: "json" };

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 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.playPause, action: playOrPause },
{ hotkey: hotkeysConfig.spotify.nextTrack, action: nextTrack },
{ hotkey: hotkeysConfig.spotify.previousTrack, action: previousTrack },
];
Loading