Add advanced CLI UI and logging to build and serve scripts
- Enhance `build.mjs` and `serve.mjs` with colorful banners, progress bars, tables, and section headers for a professional CLI experience - Add detailed build summaries, error reporting, and plugin statistics to `build.mjs` - Improve argument parsing and verbose logging in `build.mjs` - Add plugin authors to Last.fm manifest - Refactor Last.fm plugin code for consistent formatting and improved settings typing - Add plugin and file statistics, network interface table, and graceful shutdown to `serve.mjs` - Remove `.vscode/settings.json` from repo
This commit is contained in:
parent
c35789b14e
commit
19168b1238
15 changed files with 1999 additions and 995 deletions
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnSave": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
}
|
||||
720
build.mjs
720
build.mjs
|
|
@ -1,151 +1,651 @@
|
|||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import nodeResolve from "@rollup/plugin-node-resolve";
|
||||
import { createHash } from "crypto";
|
||||
import { readFile, readdir, writeFile } from "fs/promises";
|
||||
import { readFile, readdir, writeFile, stat } from "fs/promises";
|
||||
import { argv } from "process";
|
||||
import { rollup, watch } from "rollup";
|
||||
|
||||
import swc from "@swc/core";
|
||||
import esbuild from "rollup-plugin-esbuild";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🎨 STYLING & ANIMATIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
italic: "\x1b[3m",
|
||||
underline: "\x1b[4m",
|
||||
|
||||
// Standard colors
|
||||
black: "\x1b[30m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
|
||||
// Bright colors
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightMagenta: "\x1b[95m",
|
||||
brightCyan: "\x1b[96m",
|
||||
brightWhite: "\x1b[97m",
|
||||
|
||||
// Backgrounds
|
||||
bgRed: "\x1b[41m",
|
||||
bgGreen: "\x1b[42m",
|
||||
bgYellow: "\x1b[43m",
|
||||
bgBlue: "\x1b[44m",
|
||||
bgMagenta: "\x1b[45m",
|
||||
bgCyan: "\x1b[46m",
|
||||
};
|
||||
|
||||
const symbols = {
|
||||
// Build status
|
||||
success: "✓",
|
||||
error: "✗",
|
||||
warning: "⚠",
|
||||
info: "ℹ",
|
||||
|
||||
// Activities
|
||||
building: "🔨",
|
||||
watching: "👁",
|
||||
rocket: "🚀",
|
||||
gear: "⚙",
|
||||
lightning: "⚡",
|
||||
fire: "🔥",
|
||||
sparkles: "✨",
|
||||
|
||||
// UI elements
|
||||
arrow: "→",
|
||||
bullet: "•",
|
||||
star: "★",
|
||||
diamond: "◆",
|
||||
|
||||
// Box drawing
|
||||
boxLight: "─",
|
||||
boxHeavy: "━",
|
||||
boxDouble: "═",
|
||||
cornerTL: "┌",
|
||||
cornerTR: "┐",
|
||||
cornerBL: "└",
|
||||
cornerBR: "┘",
|
||||
cross: "┼",
|
||||
teeLeft: "├",
|
||||
teeRight: "┤",
|
||||
vertical: "│",
|
||||
};
|
||||
|
||||
const spinners = {
|
||||
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
||||
line: ["│", "╱", "─", "╲"],
|
||||
bounce: ["⠁", "⠂", "⠄", "⠂"],
|
||||
pulse: ["●", "◐", "◑", "◒", "◓", "◔", "◕", "◖", "◗"],
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🛠 UTILITIES
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function formatTime(ms) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function createGradient(text, startColor, endColor) {
|
||||
// Simple gradient effect by alternating colors
|
||||
return text
|
||||
.split("")
|
||||
.map((char, i) => {
|
||||
const color = i % 2 === 0 ? startColor : endColor;
|
||||
return `${color}${char}${colors.reset}`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function createBox(content, title = "", style = "light") {
|
||||
const lines = content.split("\n");
|
||||
const maxLength = Math.max(
|
||||
...lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").length),
|
||||
title.length,
|
||||
);
|
||||
const width = maxLength + 4;
|
||||
|
||||
const horizontal =
|
||||
style === "heavy"
|
||||
? symbols.boxHeavy
|
||||
: style === "double"
|
||||
? symbols.boxDouble
|
||||
: symbols.boxLight;
|
||||
|
||||
let result = "";
|
||||
|
||||
// Top border
|
||||
result += `${symbols.cornerTL}${horizontal.repeat(width - 2)}${symbols.cornerTR}\n`;
|
||||
|
||||
// Title
|
||||
if (title) {
|
||||
const padding = Math.floor((width - title.length - 4) / 2);
|
||||
result += `${symbols.vertical} ${" ".repeat(padding)}${colors.bold}${title}${colors.reset}${" ".repeat(padding)} ${symbols.vertical}\n`;
|
||||
result += `${symbols.teeLeft}${horizontal.repeat(width - 2)}${symbols.teeRight}\n`;
|
||||
}
|
||||
|
||||
// Content
|
||||
lines.forEach((line) => {
|
||||
const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const padding = width - cleanLine.length - 4;
|
||||
result += `${symbols.vertical} ${line}${" ".repeat(padding)} ${symbols.vertical}\n`;
|
||||
});
|
||||
|
||||
// Bottom border
|
||||
result += `${symbols.cornerBL}${horizontal.repeat(width - 2)}${symbols.cornerBR}`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class Spinner {
|
||||
constructor(text = "", spinnerType = "dots") {
|
||||
this.text = text;
|
||||
this.frames = spinners[spinnerType];
|
||||
this.current = 0;
|
||||
this.isSpinning = false;
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.isSpinning) return;
|
||||
this.isSpinning = true;
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
process.stdout.write(
|
||||
`\r${colors.cyan}${this.frames[this.current]}${colors.reset} ${this.text}`,
|
||||
);
|
||||
this.current = (this.current + 1) % this.frames.length;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
succeed(text) {
|
||||
this.stop();
|
||||
console.log(
|
||||
`${colors.green}${symbols.success}${colors.reset} ${text || this.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
fail(text) {
|
||||
this.stop();
|
||||
console.log(
|
||||
`${colors.red}${symbols.error}${colors.reset} ${text || this.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.isSpinning) return;
|
||||
this.isSpinning = false;
|
||||
clearInterval(this.interval);
|
||||
process.stdout.write("\r\x1b[K"); // Clear line
|
||||
}
|
||||
}
|
||||
|
||||
function createProgressBar(current, total, width = 40, showPercentage = true) {
|
||||
const percentage = Math.round((current / total) * 100);
|
||||
const filled = Math.round((current / total) * width);
|
||||
const empty = width - filled;
|
||||
|
||||
const filledBar = `${colors.brightCyan}${"█".repeat(filled)}${colors.reset}`;
|
||||
const emptyBar = `${colors.dim}${"░".repeat(empty)}${colors.reset}`;
|
||||
|
||||
let result = `${filledBar}${emptyBar}`;
|
||||
|
||||
if (showPercentage) {
|
||||
result += ` ${colors.bold}${percentage.toString().padStart(3)}%${colors.reset}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🎭 LOGGER
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const logger = {
|
||||
banner() {
|
||||
const title = createGradient(
|
||||
"VENDETTA PLUGIN BUILDER",
|
||||
colors.brightCyan,
|
||||
colors.brightMagenta,
|
||||
);
|
||||
const subtitle = `${colors.dim}Professional-grade TypeScript plugin compilation${colors.reset}`;
|
||||
|
||||
console.log();
|
||||
console.log(` ${symbols.rocket} ${title}`);
|
||||
console.log(` ${subtitle}`);
|
||||
console.log();
|
||||
},
|
||||
|
||||
section(title) {
|
||||
const line = symbols.boxHeavy.repeat(60);
|
||||
console.log(`\n${colors.brightBlue}${line}${colors.reset}`);
|
||||
console.log(`${colors.bold}${colors.brightBlue} ${title}${colors.reset}`);
|
||||
console.log(`${colors.brightBlue}${line}${colors.reset}\n`);
|
||||
},
|
||||
|
||||
info(message, icon = symbols.info) {
|
||||
console.log(`${colors.brightBlue}${icon}${colors.reset} ${message}`);
|
||||
},
|
||||
|
||||
success(message, icon = symbols.success) {
|
||||
console.log(`${colors.brightGreen}${icon}${colors.reset} ${message}`);
|
||||
},
|
||||
|
||||
warning(message, icon = symbols.warning) {
|
||||
console.log(`${colors.brightYellow}${icon}${colors.reset} ${message}`);
|
||||
},
|
||||
|
||||
error(message, icon = symbols.error) {
|
||||
console.log(`${colors.brightRed}${icon}${colors.reset} ${message}`);
|
||||
},
|
||||
|
||||
plugin: {
|
||||
building(name) {
|
||||
return new Spinner(
|
||||
`${colors.bold}${name}${colors.reset} ${colors.dim}Building...${colors.reset}`,
|
||||
"pulse",
|
||||
);
|
||||
},
|
||||
|
||||
success(name, time, size) {
|
||||
const timeStr = `${colors.dim}${time}${colors.reset}`;
|
||||
const sizeStr = size ? `${colors.dim}${size}${colors.reset}` : "";
|
||||
console.log(
|
||||
`${colors.brightGreen}${symbols.success}${colors.reset} ${colors.bold}${name}${colors.reset} ${timeStr} ${sizeStr}`,
|
||||
);
|
||||
},
|
||||
|
||||
error(name, error) {
|
||||
console.log(
|
||||
`${colors.brightRed}${symbols.error}${colors.reset} ${colors.bold}${name}${colors.reset}`,
|
||||
);
|
||||
if (error) {
|
||||
console.log(`${colors.red}${colors.dim} ${error}${colors.reset}`);
|
||||
}
|
||||
},
|
||||
|
||||
watching(name, time) {
|
||||
const timeStr = `${colors.dim}${time}${colors.reset}`;
|
||||
console.log(
|
||||
`${colors.brightCyan}${symbols.lightning}${colors.reset} ${colors.bold}${name}${colors.reset} ${colors.dim}rebuilt${colors.reset} ${timeStr}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
table(headers, rows) {
|
||||
const colWidths = headers.map((header, i) =>
|
||||
Math.max(
|
||||
header.length,
|
||||
...rows.map(
|
||||
(row) =>
|
||||
(row[i] || "").toString().replace(/\x1b\[[0-9;]*m/g, "").length,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Header
|
||||
const headerRow = headers
|
||||
.map(
|
||||
(header, i) =>
|
||||
`${colors.bold}${header.padEnd(colWidths[i])}${colors.reset}`,
|
||||
)
|
||||
.join(` ${colors.dim}│${colors.reset} `);
|
||||
|
||||
console.log(` ${headerRow}`);
|
||||
|
||||
// Separator
|
||||
const separator = colWidths
|
||||
.map((width) => symbols.boxLight.repeat(width))
|
||||
.join(`${colors.dim}─┼─${colors.reset}`);
|
||||
console.log(` ${colors.dim}${separator}${colors.reset}`);
|
||||
|
||||
// Rows
|
||||
rows.forEach((row) => {
|
||||
const formattedRow = row
|
||||
.map((cell, i) => {
|
||||
const cleanCell = (cell || "")
|
||||
.toString()
|
||||
.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const padding = colWidths[i] - cleanCell.length;
|
||||
return cell + " ".repeat(padding);
|
||||
})
|
||||
.join(` ${colors.dim}│${colors.reset} `);
|
||||
|
||||
console.log(` ${formattedRow}`);
|
||||
});
|
||||
},
|
||||
|
||||
summary(stats) {
|
||||
const { built, failed, total, totalTime, watchMode } = stats;
|
||||
|
||||
const rows = [
|
||||
["Total Plugins", total.toString()],
|
||||
["Built Successfully", `${colors.brightGreen}${built}${colors.reset}`],
|
||||
[
|
||||
"Failed",
|
||||
failed > 0
|
||||
? `${colors.brightRed}${failed}${colors.reset}`
|
||||
: `${colors.dim}0${colors.reset}`,
|
||||
],
|
||||
["Total Time", formatTime(totalTime)],
|
||||
[
|
||||
"Mode",
|
||||
watchMode
|
||||
? `${colors.brightCyan}Watch${colors.reset}`
|
||||
: `${colors.dim}Build${colors.reset}`,
|
||||
],
|
||||
];
|
||||
|
||||
this.table(["Metric", "Value"], rows);
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🚀 MAIN EXECUTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const args = argv.slice(2);
|
||||
console.clear();
|
||||
|
||||
const inputPlugins = args.filter(x => !x.startsWith("-"));
|
||||
const flags = args.map(x => x.toLowerCase()).filter(x => x.startsWith("-"));
|
||||
// Parse arguments
|
||||
const inputPlugins = args.filter((x) => !x.startsWith("-"));
|
||||
const flags = args.map((x) => x.toLowerCase()).filter((x) => x.startsWith("-"));
|
||||
|
||||
const isWatch = flags.includes("--watch") || flags.includes("-w");
|
||||
const isVerbose = flags.includes("--verbose") || flags.includes("-v");
|
||||
const toBuild = inputPlugins.length ? inputPlugins : await readdir("./plugins");
|
||||
|
||||
console.log(`Building ${toBuild.length} plugin(s)...`);
|
||||
// Display banner
|
||||
logger.banner();
|
||||
|
||||
let failed = 0;
|
||||
// Build configuration
|
||||
const config = {
|
||||
plugins: toBuild.length,
|
||||
mode: isWatch ? "Watch" : "Build",
|
||||
verbose: isVerbose,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
for (const plugin of toBuild) {
|
||||
try {
|
||||
await buildPlugin(plugin);
|
||||
} catch (e) {
|
||||
console.error(e.stack || e);
|
||||
failed++;
|
||||
}
|
||||
logger.info(
|
||||
`${colors.dim}Found ${colors.reset}${colors.bold}${config.plugins}${colors.reset}${colors.dim} plugin(s) to ${config.mode.toLowerCase()}${colors.reset}`,
|
||||
);
|
||||
if (isWatch) {
|
||||
logger.info(
|
||||
`${colors.dim}Running in watch mode - changes will trigger rebuilds${colors.reset}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n" + (
|
||||
failed
|
||||
? "\x1b[31m" + `Failed to build ${failed} plugin(s)` + "\x1b[0m"
|
||||
: "\x1b[32m" + "All plugin(s) built successfully!" + "\x1b[0m"
|
||||
));
|
||||
// Build statistics
|
||||
let built = 0;
|
||||
let failed = 0;
|
||||
const buildResults = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
isWatch && console.log("\nWatching for changes...");
|
||||
// Build all plugins
|
||||
logger.section("Building Plugins");
|
||||
|
||||
for (let i = 0; i < toBuild.length; i++) {
|
||||
const plugin = toBuild[i];
|
||||
|
||||
if (!isWatch) {
|
||||
console.log(
|
||||
`\n ${createProgressBar(i, toBuild.length)} ${colors.dim}(${i}/${toBuild.length})${colors.reset}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pluginStartTime = Date.now();
|
||||
let spinner;
|
||||
|
||||
try {
|
||||
if (!plugin.endsWith(".ts")) {
|
||||
spinner = logger.plugin.building(plugin);
|
||||
spinner.start();
|
||||
|
||||
const result = await buildPlugin(plugin);
|
||||
const buildTime = Date.now() - pluginStartTime;
|
||||
|
||||
spinner.stop();
|
||||
logger.plugin.success(
|
||||
plugin,
|
||||
formatTime(buildTime),
|
||||
result.size ? formatSize(result.size) : "",
|
||||
);
|
||||
|
||||
buildResults.push({
|
||||
name: plugin,
|
||||
status: "success",
|
||||
time: buildTime,
|
||||
size: result.size || 0,
|
||||
});
|
||||
|
||||
built++;
|
||||
}
|
||||
} catch (e) {
|
||||
if (spinner) spinner.stop();
|
||||
|
||||
logger.plugin.error(plugin, isVerbose ? e.stack : e.message);
|
||||
|
||||
buildResults.push({
|
||||
name: plugin,
|
||||
status: "error",
|
||||
time: Date.now() - pluginStartTime,
|
||||
error: e.message,
|
||||
});
|
||||
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress
|
||||
if (!isWatch) {
|
||||
console.log(
|
||||
`\n ${createProgressBar(toBuild.length, toBuild.length)} ${colors.dim}(${toBuild.length}/${toBuild.length})${colors.reset}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Summary
|
||||
logger.section("Build Summary");
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
logger.summary({
|
||||
built,
|
||||
failed,
|
||||
total: toBuild.length,
|
||||
totalTime,
|
||||
watchMode: isWatch,
|
||||
});
|
||||
|
||||
// Results table
|
||||
if (buildResults.length > 0 && !isWatch) {
|
||||
console.log();
|
||||
const tableRows = buildResults.map((result) => [
|
||||
result.name,
|
||||
result.status === "success"
|
||||
? `${colors.brightGreen}${symbols.success} Success${colors.reset}`
|
||||
: `${colors.brightRed}${symbols.error} Failed${colors.reset}`,
|
||||
formatTime(result.time),
|
||||
result.size ? formatSize(result.size) : `${colors.dim}N/A${colors.reset}`,
|
||||
]);
|
||||
|
||||
logger.table(["Plugin", "Status", "Time", "Size"], tableRows);
|
||||
}
|
||||
|
||||
// Final status
|
||||
console.log();
|
||||
if (failed > 0) {
|
||||
logger.error(
|
||||
`Build completed with ${failed} error(s) ${symbols.bullet} ${built} succeeded`,
|
||||
symbols.warning,
|
||||
);
|
||||
} else {
|
||||
logger.success(
|
||||
`All plugins built successfully! ${symbols.sparkles}`,
|
||||
symbols.fire,
|
||||
);
|
||||
}
|
||||
|
||||
// Watch mode notification
|
||||
if (isWatch) {
|
||||
console.log();
|
||||
logger.info(
|
||||
`Watching for changes... ${colors.dim}Press ${colors.reset}${colors.bold}Ctrl+C${colors.reset}${colors.dim} to exit${colors.reset}`,
|
||||
symbols.watching,
|
||||
);
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🔧 BUILD FUNCTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function buildPlugin(plugin) {
|
||||
if (plugin.endsWith(".ts")) return;
|
||||
if (plugin.endsWith(".ts")) return {};
|
||||
|
||||
const manifest = Object.assign(
|
||||
JSON.parse(await readFile("./base_manifest.json")),
|
||||
JSON.parse(await readFile(`./plugins/${plugin}/manifest.json`))
|
||||
);
|
||||
const manifest = Object.assign(
|
||||
JSON.parse(await readFile("./base_manifest.json")),
|
||||
JSON.parse(await readFile(`./plugins/${plugin}/manifest.json`)),
|
||||
);
|
||||
|
||||
const entry = "index.js";
|
||||
const outPath = `./dist/${plugin}/${entry}`;
|
||||
const entry = "index.js";
|
||||
const outPath = `./dist/${plugin}/${entry}`;
|
||||
|
||||
/** @type {import("rollup").RollupOptions} */
|
||||
const options = {
|
||||
input: `./plugins/${plugin}/${manifest.main}`,
|
||||
output: {
|
||||
file: outPath,
|
||||
globals(id) {
|
||||
if (id.startsWith("@vendetta"))
|
||||
return id.substring(1).replace(/\//g, ".");
|
||||
/** @type {import("rollup").RollupOptions} */
|
||||
const options = {
|
||||
input: `./plugins/${plugin}/${manifest.main}`,
|
||||
output: {
|
||||
file: outPath,
|
||||
globals(id) {
|
||||
if (id.startsWith("@vendetta"))
|
||||
return id.substring(1).replace(/\//g, ".");
|
||||
|
||||
const map = {
|
||||
"react": "window.React",
|
||||
"react-native": "vendetta.metro.common.ReactNative"
|
||||
};
|
||||
const map = {
|
||||
react: "window.React",
|
||||
"react-native": "vendetta.metro.common.ReactNative",
|
||||
};
|
||||
|
||||
return map[id] || null;
|
||||
return map[id] || null;
|
||||
},
|
||||
format: "iife",
|
||||
compact: true,
|
||||
exports: "named",
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
onwarn: (warning) => {
|
||||
if (
|
||||
![
|
||||
"UNRESOLVED_IMPORT",
|
||||
"MISSING_NAME_OPTION_FOR_IIFE_EXPORT",
|
||||
"CIRCULAR_DEPENDENCY",
|
||||
].includes(warning.code) &&
|
||||
isVerbose
|
||||
) {
|
||||
logger.warning(`${plugin}: ${warning.message}`);
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
resolveOnly: (id) => !["react", "react-native"].includes(id),
|
||||
}),
|
||||
commonjs(),
|
||||
{
|
||||
name: "swc",
|
||||
transform(code, id) {
|
||||
return swc.transform(code, {
|
||||
filename: id,
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: "typescript",
|
||||
tsx: true,
|
||||
},
|
||||
externalHelpers: true,
|
||||
},
|
||||
format: "iife",
|
||||
compact: true,
|
||||
exports: "named",
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
onwarn: (warning) => {
|
||||
![
|
||||
"UNRESOLVED_IMPORT",
|
||||
"MISSING_NAME_OPTION_FOR_IIFE_EXPORT",
|
||||
"CIRCULAR_DEPENDENCY"
|
||||
].includes(warning.code) && console.warn(warning);
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
resolveOnly: (id) => !["react", "react-native"].includes(id)
|
||||
}),
|
||||
commonjs(),
|
||||
{
|
||||
name: "swc",
|
||||
transform(code, id) {
|
||||
return swc.transform(code, {
|
||||
filename: id,
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: "typescript",
|
||||
tsx: true,
|
||||
},
|
||||
externalHelpers: true,
|
||||
},
|
||||
env: {
|
||||
targets: "defaults",
|
||||
include: [
|
||||
"transform-classes",
|
||||
"transform-arrow-functions",
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
env: {
|
||||
targets: "defaults",
|
||||
include: ["transform-classes", "transform-arrow-functions"],
|
||||
},
|
||||
esbuild({ minify: true }),
|
||||
]
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
esbuild({ minify: true }),
|
||||
],
|
||||
};
|
||||
|
||||
const applyHash = async () => Object.assign(manifest, {
|
||||
hash: createHash("sha256").update(await readFile(outPath)).digest("hex"),
|
||||
main: entry
|
||||
const applyHash = async () => {
|
||||
const content = await readFile(outPath);
|
||||
return Object.assign(manifest, {
|
||||
hash: createHash("sha256").update(content).digest("hex"),
|
||||
main: entry,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isWatch) {
|
||||
const bundle = await rollup(options);
|
||||
await bundle.write(options.output);
|
||||
await bundle.close();
|
||||
if (!isWatch) {
|
||||
const bundle = await rollup(options);
|
||||
await bundle.write(options.output);
|
||||
await bundle.close();
|
||||
|
||||
await applyHash();
|
||||
await writeFile(`./dist/${plugin}/manifest.json`, JSON.stringify(manifest));
|
||||
await applyHash();
|
||||
await writeFile(`./dist/${plugin}/manifest.json`, JSON.stringify(manifest));
|
||||
|
||||
console.log(`${plugin}: ` + "\x1b[32m" + "Build succeed!" + "\x1b[0m");
|
||||
return;
|
||||
}
|
||||
// Get file size
|
||||
const stats = await stat(outPath);
|
||||
return { size: stats.size };
|
||||
}
|
||||
|
||||
const watcher = watch(options);
|
||||
const watcher = watch(options);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
watcher.on("event", async (event) => {
|
||||
switch (event.code) {
|
||||
case "BUNDLE_END": {
|
||||
event.result.close();
|
||||
return await new Promise((resolve, reject) => {
|
||||
watcher.on("event", async (event) => {
|
||||
switch (event.code) {
|
||||
case "BUNDLE_START":
|
||||
// Silent in watch mode, spinner handles this
|
||||
break;
|
||||
|
||||
await applyHash();
|
||||
await writeFile(`./dist/${plugin}/manifest.json`, JSON.stringify(manifest));
|
||||
case "BUNDLE_END": {
|
||||
event.result.close();
|
||||
|
||||
console.log(`${plugin}: ` + "\x1b[32m" + `Build succeed! (${event.duration}ms)` + "\x1b[0m");
|
||||
resolve();
|
||||
break;
|
||||
}
|
||||
case "ERROR":
|
||||
case "FATAL":
|
||||
console.error(`${plugin}: ` + "\x1b[31m", "Failed! :(", "\x1b[0m");
|
||||
reject(event.error);
|
||||
break;
|
||||
}
|
||||
});
|
||||
await applyHash();
|
||||
await writeFile(
|
||||
`./dist/${plugin}/manifest.json`,
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
|
||||
const buildTime = formatTime(event.duration || 0);
|
||||
logger.plugin.watching(plugin, buildTime);
|
||||
resolve({ size: 0 });
|
||||
break;
|
||||
}
|
||||
case "ERROR":
|
||||
case "FATAL":
|
||||
logger.plugin.error(
|
||||
plugin,
|
||||
isVerbose ? event.error.stack : event.error.message,
|
||||
);
|
||||
reject(event.error);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
{
|
||||
"name": "Last.fm",
|
||||
"description": "Show off your last.fm status on your Discord profile.",
|
||||
"authors": [
|
||||
{
|
||||
"name": "pylix",
|
||||
"id": "492949202121261067"
|
||||
},
|
||||
{
|
||||
"name": "kmmiio99o",
|
||||
"id": "879393496627306587"
|
||||
}
|
||||
],
|
||||
"main": "src/index.tsx",
|
||||
"vendetta": {
|
||||
"icon": "ic_stage_music"
|
||||
|
|
|
|||
|
|
@ -5,38 +5,38 @@ interface ChangelogEntry {
|
|||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: "2.3.0",
|
||||
date: "2025-09-19",
|
||||
changes: [
|
||||
"1. I TRIED SO HARD 2. remove backend error 3. factor settings tab 4. factor V2 5. factor V3 6. factor V4 7. factor V5 8. factor V6 9. Full settings refactor :3",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2.2.0",
|
||||
date: "2025-09-13",
|
||||
changes: ["Remember kids! Do not sniff crack!"],
|
||||
},
|
||||
{
|
||||
version: "2.0.0",
|
||||
date: "2025-09-13",
|
||||
changes: ["Fix settings scrolling"],
|
||||
},
|
||||
{
|
||||
version: "1.3.0",
|
||||
date: "2025-09-13",
|
||||
changes: ["fix reanimated"],
|
||||
},
|
||||
{
|
||||
version: "1.2.0",
|
||||
date: "2025-09-13",
|
||||
changes: ["test"],
|
||||
},
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2025-09-12",
|
||||
changes: ["fixed reanimated issue??"],
|
||||
},
|
||||
{
|
||||
version: "2.3.0",
|
||||
date: "2025-09-19",
|
||||
changes: [
|
||||
"1. I TRIED SO HARD 2. remove backend error 3. factor settings tab 4. factor V2 5. factor V3 6. factor V4 7. factor V5 8. factor V6 9. Full settings refactor :3",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "2.2.0",
|
||||
date: "2025-09-13",
|
||||
changes: ["Remember kids! Do not sniff crack!"],
|
||||
},
|
||||
{
|
||||
version: "2.0.0",
|
||||
date: "2025-09-13",
|
||||
changes: ["Fix settings scrolling"],
|
||||
},
|
||||
{
|
||||
version: "1.3.0",
|
||||
date: "2025-09-13",
|
||||
changes: ["fix reanimated"],
|
||||
},
|
||||
{
|
||||
version: "1.2.0",
|
||||
date: "2025-09-13",
|
||||
changes: ["test"],
|
||||
},
|
||||
{
|
||||
version: "1.1.0",
|
||||
date: "2025-09-12",
|
||||
changes: ["fixed reanimated issue??"],
|
||||
},
|
||||
];
|
||||
|
||||
export const currentVersion = "2.3.0";
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
const Constants = {
|
||||
DEFAULT_APP_NAME: "Music",
|
||||
DEFAULT_TIME_INTERVAL: 5,
|
||||
/** The application ID of the Discord's developer app */
|
||||
APPLICATION_ID: "1054951789318909972",
|
||||
/** The minimum update interval in seconds to prevent rate limiting */
|
||||
MIN_UPDATE_INTERVAL: 3,
|
||||
/** Maximum retry attempts for API calls */
|
||||
MAX_RETRY_ATTEMPTS: 3,
|
||||
/** Retry delay in milliseconds */
|
||||
RETRY_DELAY: 5000,
|
||||
/** Base URL for Last.fm API */
|
||||
LFM_API_BASE_URL: "http://ws.audioscrobbler.com/2.0",
|
||||
/** These are the default album covers that are used by Last.fm */
|
||||
LFM_DEFAULT_COVER_HASHES: [
|
||||
"2a96cbd8b46e442fc41c2b86b821562f",
|
||||
"c6f59c1e5e7240a4c0d427abd71f3dbb",
|
||||
],
|
||||
/** Default settings */
|
||||
DEFAULT_SETTINGS: {
|
||||
username: "",
|
||||
apiKey: "",
|
||||
appName: "Music",
|
||||
timeInterval: 5,
|
||||
showTimestamp: true,
|
||||
listeningTo: true,
|
||||
ignoreSpotify: true,
|
||||
verboseLogging: false,
|
||||
} /** GitHub repository URL */,
|
||||
GITHUB_URL: "https://github.com/kmmiio99o/letup",
|
||||
/** GitHub commits URL */
|
||||
GITHUB_COMMITS_URL: "https://github.com/kmmiio99o/letup/commits/main/",
|
||||
DEFAULT_APP_NAME: "Music",
|
||||
DEFAULT_TIME_INTERVAL: 5,
|
||||
/** The application ID of the Discord's developer app */
|
||||
APPLICATION_ID: "1054951789318909972",
|
||||
/** The minimum update interval in seconds to prevent rate limiting */
|
||||
MIN_UPDATE_INTERVAL: 3,
|
||||
/** Maximum retry attempts for API calls */
|
||||
MAX_RETRY_ATTEMPTS: 3,
|
||||
/** Retry delay in milliseconds */
|
||||
RETRY_DELAY: 5000,
|
||||
/** Base URL for Last.fm API */
|
||||
LFM_API_BASE_URL: "http://ws.audioscrobbler.com/2.0",
|
||||
/** These are the default album covers that are used by Last.fm */
|
||||
LFM_DEFAULT_COVER_HASHES: [
|
||||
"2a96cbd8b46e442fc41c2b86b821562f",
|
||||
"c6f59c1e5e7240a4c0d427abd71f3dbb",
|
||||
],
|
||||
/** Default settings */
|
||||
DEFAULT_SETTINGS: {
|
||||
username: "",
|
||||
apiKey: "",
|
||||
appName: "Music",
|
||||
timeInterval: 5,
|
||||
showTimestamp: true,
|
||||
listeningTo: true,
|
||||
ignoreSpotify: true,
|
||||
verboseLogging: false,
|
||||
} /** GitHub repository URL */,
|
||||
GITHUB_URL: "https://github.com/kmmiio99o/vd-plugins",
|
||||
/** GitHub commits URL */
|
||||
GITHUB_COMMITS_URL: "https://github.com/kmmiio99o/vd-plugins/commits/main/",
|
||||
} as const;
|
||||
|
||||
export default Constants;
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ import Settings from "./ui/pages/Settings";
|
|||
const { FormText } = Forms;
|
||||
|
||||
export const pluginState = {
|
||||
pluginStopped: false,
|
||||
lastActivity: undefined,
|
||||
updateInterval: undefined,
|
||||
lastTrackUrl: undefined,
|
||||
pluginStopped: false,
|
||||
lastActivity: undefined,
|
||||
updateInterval: undefined,
|
||||
lastTrackUrl: undefined,
|
||||
} as {
|
||||
pluginStopped: boolean;
|
||||
lastActivity?: any;
|
||||
|
|
@ -31,18 +31,18 @@ export const pluginState = {
|
|||
// Initialize default settings
|
||||
const defaultSettings: LFMSettings = Constants.DEFAULT_SETTINGS;
|
||||
for (const key in defaultSettings) {
|
||||
plugin.storage[key] =
|
||||
plugin.storage[key] =
|
||||
plugin.storage[key] ?? defaultSettings[key as keyof typeof defaultSettings];
|
||||
}
|
||||
|
||||
export const currentSettings = new Proxy(plugin.storage, {
|
||||
get(target, prop) {
|
||||
return target[prop];
|
||||
},
|
||||
set(target, prop, value) {
|
||||
target[prop] = value;
|
||||
return true;
|
||||
},
|
||||
get(target, prop) {
|
||||
return target[prop];
|
||||
},
|
||||
set(target, prop, value) {
|
||||
target[prop] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Connection status tracking
|
||||
|
|
@ -51,62 +51,62 @@ const MAX_CONNECTION_ATTEMPTS = 3;
|
|||
const RECONNECT_DELAY = 5000;
|
||||
|
||||
async function tryInitialize() {
|
||||
try {
|
||||
await initialize();
|
||||
connectionAttempts = 0;
|
||||
} catch (error) {
|
||||
console.error("[Last.fm] Initialization error:", error);
|
||||
connectionAttempts++;
|
||||
try {
|
||||
await initialize();
|
||||
connectionAttempts = 0;
|
||||
} catch (error) {
|
||||
console.error("[Last.fm] Initialization error:", error);
|
||||
connectionAttempts++;
|
||||
|
||||
if (connectionAttempts < MAX_CONNECTION_ATTEMPTS) {
|
||||
showToast("Retrying connection...", getAssetIDByName("ic_clock"));
|
||||
setTimeout(tryInitialize, RECONNECT_DELAY);
|
||||
} else {
|
||||
showToast("Failed to connect to Last.fm", getAssetIDByName("Small"));
|
||||
if (connectionAttempts < MAX_CONNECTION_ATTEMPTS) {
|
||||
showToast("Retrying connection...", getAssetIDByName("ic_clock"));
|
||||
setTimeout(tryInitialize, RECONNECT_DELAY);
|
||||
} else {
|
||||
showToast("Failed to connect to Last.fm", getAssetIDByName("Small"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
onLoad() {
|
||||
pluginState.pluginStopped = false;
|
||||
onLoad() {
|
||||
pluginState.pluginStopped = false;
|
||||
|
||||
if (!currentSettings.username || !currentSettings.apiKey) {
|
||||
showToast(
|
||||
"Please configure Last.fm username and API key in settings",
|
||||
getAssetIDByName("ic_warning"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (UserStore.getCurrentUser()) {
|
||||
tryInitialize();
|
||||
} else {
|
||||
const waitForUser = () => {
|
||||
if (UserStore.getCurrentUser()) {
|
||||
tryInitialize();
|
||||
FluxDispatcher.unsubscribe("CONNECTION_OPEN", waitForUser);
|
||||
if (!currentSettings.username || !currentSettings.apiKey) {
|
||||
showToast(
|
||||
"Please configure Last.fm username and API key in settings",
|
||||
getAssetIDByName("ic_warning"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
FluxDispatcher.subscribe("CONNECTION_OPEN", waitForUser);
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
pluginState.pluginStopped = true;
|
||||
stop();
|
||||
},
|
||||
// Handle settings updates
|
||||
onSettingsUpdate(newSettings: any) {
|
||||
Object.assign(currentSettings, newSettings);
|
||||
tryInitialize();
|
||||
},
|
||||
// Handle Discord reconnection
|
||||
onDiscordReconnect() {
|
||||
if (!pluginState.pluginStopped) {
|
||||
tryInitialize();
|
||||
}
|
||||
},
|
||||
// Settings component
|
||||
settings: Settings,
|
||||
if (UserStore.getCurrentUser()) {
|
||||
tryInitialize();
|
||||
} else {
|
||||
const waitForUser = () => {
|
||||
if (UserStore.getCurrentUser()) {
|
||||
tryInitialize();
|
||||
FluxDispatcher.unsubscribe("CONNECTION_OPEN", waitForUser);
|
||||
}
|
||||
};
|
||||
|
||||
FluxDispatcher.subscribe("CONNECTION_OPEN", waitForUser);
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
pluginState.pluginStopped = true;
|
||||
stop();
|
||||
},
|
||||
// Handle settings updates
|
||||
onSettingsUpdate(newSettings: any) {
|
||||
Object.assign(currentSettings, newSettings);
|
||||
tryInitialize();
|
||||
},
|
||||
// Handle Discord reconnection
|
||||
onDiscordReconnect() {
|
||||
if (!pluginState.pluginStopped) {
|
||||
tryInitialize();
|
||||
}
|
||||
},
|
||||
// Settings component
|
||||
settings: Settings,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import { clearActivity, fetchAsset, sendRequest } from "./utils/activity";
|
|||
import { setDebugInfo } from "./utils/debug";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
import { lastfmClient } from "./utils/lastfm";
|
||||
|
||||
|
|
@ -23,210 +23,210 @@ enum ActivityType {
|
|||
}
|
||||
|
||||
const verboseLog = (...message: any[]) =>
|
||||
currentSettings.verboseLogging && console.log("[Last.fm]", ...message);
|
||||
currentSettings.verboseLogging && console.log("[Last.fm]", ...message);
|
||||
|
||||
class PluginManager {
|
||||
private static instance: PluginManager;
|
||||
private updateTimer?: NodeJS.Timer;
|
||||
private reconnectTimer?: NodeJS.Timer;
|
||||
private consecutiveFailures: number = 0;
|
||||
private isReconnecting: boolean = false;
|
||||
private static instance: PluginManager;
|
||||
private updateTimer?: NodeJS.Timer;
|
||||
private reconnectTimer?: NodeJS.Timer;
|
||||
private consecutiveFailures = 0;
|
||||
private isReconnecting = false;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): PluginManager {
|
||||
if (!PluginManager.instance) {
|
||||
PluginManager.instance = new PluginManager();
|
||||
public static getInstance(): PluginManager {
|
||||
if (!PluginManager.instance) {
|
||||
PluginManager.instance = new PluginManager();
|
||||
}
|
||||
return PluginManager.instance;
|
||||
}
|
||||
return PluginManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Updates the Discord activity with the current Last.fm track
|
||||
*/
|
||||
private async updateActivity() {
|
||||
if (pluginState.pluginStopped) {
|
||||
verboseLog("Plugin is stopped, skipping update");
|
||||
this.stopUpdates();
|
||||
return;
|
||||
}
|
||||
|
||||
verboseLog("Fetching last track...");
|
||||
|
||||
try {
|
||||
if (!currentSettings.username || !currentSettings.apiKey) {
|
||||
throw new Error("Username or API key not set");
|
||||
}
|
||||
|
||||
if (currentSettings.ignoreSpotify) {
|
||||
const spotifyActivity = SelfPresenceStore.findActivity(
|
||||
(act) => act.sync_id,
|
||||
);
|
||||
if (spotifyActivity) {
|
||||
verboseLog("Spotify is currently playing, clearing activity");
|
||||
setDebugInfo("isSpotifyIgnored", true);
|
||||
clearActivity();
|
||||
return;
|
||||
private async updateActivity() {
|
||||
if (pluginState.pluginStopped) {
|
||||
verboseLog("Plugin is stopped, skipping update");
|
||||
this.stopUpdates();
|
||||
return;
|
||||
}
|
||||
setDebugInfo("isSpotifyIgnored", false);
|
||||
}
|
||||
|
||||
const lastTrack = await lastfmClient.fetchLatestScrobble();
|
||||
setDebugInfo("lastTrack", lastTrack);
|
||||
verboseLog("Fetching last track...");
|
||||
|
||||
if (!lastTrack.nowPlaying) {
|
||||
verboseLog("Last track is not currently playing");
|
||||
clearActivity();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!currentSettings.username || !currentSettings.apiKey) {
|
||||
throw new Error("Username or API key not set");
|
||||
}
|
||||
|
||||
if (pluginState.lastTrackUrl === lastTrack.url) {
|
||||
verboseLog("Track hasn't changed");
|
||||
return;
|
||||
}
|
||||
if (currentSettings.ignoreSpotify) {
|
||||
const spotifyActivity = SelfPresenceStore.findActivity(
|
||||
(act) => act.sync_id,
|
||||
);
|
||||
if (spotifyActivity) {
|
||||
verboseLog("Spotify is currently playing, clearing activity");
|
||||
setDebugInfo("isSpotifyIgnored", true);
|
||||
clearActivity();
|
||||
return;
|
||||
}
|
||||
setDebugInfo("isSpotifyIgnored", false);
|
||||
}
|
||||
|
||||
const activity: Activity = {
|
||||
name: currentSettings.appName || Constants.DEFAULT_APP_NAME,
|
||||
flags: 0,
|
||||
type: currentSettings.listeningTo
|
||||
? ActivityType.LISTENING
|
||||
: ActivityType.PLAYING,
|
||||
details: lastTrack.name,
|
||||
state: `by ${lastTrack.artist}`,
|
||||
status_display_type: 1,
|
||||
application_id: Constants.APPLICATION_ID,
|
||||
};
|
||||
const lastTrack = await lastfmClient.fetchLatestScrobble();
|
||||
setDebugInfo("lastTrack", lastTrack);
|
||||
|
||||
// Handle dynamic app name
|
||||
if (activity.name.includes("{{")) {
|
||||
for (const key in lastTrack) {
|
||||
activity.name = activity.name.replace(
|
||||
`{{${key}}}`,
|
||||
lastTrack[key as keyof typeof lastTrack] || "",
|
||||
);
|
||||
if (!lastTrack.nowPlaying) {
|
||||
verboseLog("Last track is not currently playing");
|
||||
clearActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluginState.lastTrackUrl === lastTrack.url) {
|
||||
verboseLog("Track hasn't changed");
|
||||
return;
|
||||
}
|
||||
|
||||
const activity: Activity = {
|
||||
name: currentSettings.appName || Constants.DEFAULT_APP_NAME,
|
||||
flags: 0,
|
||||
type: currentSettings.listeningTo
|
||||
? ActivityType.LISTENING
|
||||
: ActivityType.PLAYING,
|
||||
details: lastTrack.name,
|
||||
state: `by ${lastTrack.artist}`,
|
||||
status_display_type: 1,
|
||||
application_id: Constants.APPLICATION_ID,
|
||||
};
|
||||
|
||||
// Handle dynamic app name
|
||||
if (activity.name.includes("{{")) {
|
||||
for (const key in lastTrack) {
|
||||
activity.name = activity.name.replace(
|
||||
`{{${key}}}`,
|
||||
lastTrack[key as keyof typeof lastTrack] || "",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set timestamps if enabled
|
||||
// Use `from` and `to` from track.getInfo
|
||||
if (currentSettings.showTimestamp && typeof lastTrack.from === "number") {
|
||||
activity.timestamps = {
|
||||
start: lastTrack.from * 1000,
|
||||
...(typeof lastTrack.to === "number" && { end: lastTrack.to * 1000 }),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle album art
|
||||
if (lastTrack.album) {
|
||||
const asset = await fetchAsset([lastTrack.albumArt]);
|
||||
if (asset[0]) {
|
||||
activity.assets = {
|
||||
large_image: asset[0],
|
||||
large_text: `on ${lastTrack.album}${
|
||||
currentSettings.showTimestamp && lastTrack.to
|
||||
? ` • ${formatTime((lastTrack.to - lastTrack.from) / 1000)}`
|
||||
: ""
|
||||
}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
verboseLog("Setting activity:", activity);
|
||||
setDebugInfo("lastActivity", activity);
|
||||
|
||||
await sendRequest(activity);
|
||||
pluginState.lastTrackUrl = lastTrack.url;
|
||||
pluginState.lastActivity = activity;
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
verboseLog("Activity set successfully!");
|
||||
} catch (error) {
|
||||
console.error("[Last.fm] Update error:", error);
|
||||
this.handleError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set timestamps if enabled
|
||||
// Use `from` and `to` from track.getInfo
|
||||
if (currentSettings.showTimestamp && typeof lastTrack.from === "number") {
|
||||
activity.timestamps = {
|
||||
start: lastTrack.from * 1000,
|
||||
...(typeof lastTrack.to === "number" && { end: lastTrack.to * 1000 }),
|
||||
};
|
||||
}
|
||||
private handleError(error: Error) {
|
||||
this.consecutiveFailures++;
|
||||
setDebugInfo("lastError", error);
|
||||
|
||||
// Handle album art
|
||||
if (lastTrack.album) {
|
||||
const asset = await fetchAsset([lastTrack.albumArt]);
|
||||
if (asset[0]) {
|
||||
activity.assets = {
|
||||
large_image: asset[0],
|
||||
large_text: `on ${lastTrack.album}${
|
||||
currentSettings.showTimestamp && lastTrack.to
|
||||
? ` • ${formatTime((lastTrack.to - lastTrack.from) / 1000)}`
|
||||
: ""
|
||||
}`,
|
||||
};
|
||||
if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) {
|
||||
verboseLog(
|
||||
`Failed ${this.consecutiveFailures} times, initiating reconnection...`,
|
||||
);
|
||||
this.startReconnection();
|
||||
}
|
||||
}
|
||||
|
||||
verboseLog("Setting activity:", activity);
|
||||
setDebugInfo("lastActivity", activity);
|
||||
|
||||
await sendRequest(activity);
|
||||
pluginState.lastTrackUrl = lastTrack.url;
|
||||
pluginState.lastActivity = activity;
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
verboseLog("Activity set successfully!");
|
||||
} catch (error) {
|
||||
console.error("[Last.fm] Update error:", error);
|
||||
this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: Error) {
|
||||
this.consecutiveFailures++;
|
||||
setDebugInfo("lastError", error);
|
||||
private startReconnection() {
|
||||
if (this.isReconnecting) return;
|
||||
|
||||
if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) {
|
||||
verboseLog(
|
||||
`Failed ${this.consecutiveFailures} times, initiating reconnection...`,
|
||||
);
|
||||
this.startReconnection();
|
||||
this.isReconnecting = true;
|
||||
this.stopUpdates();
|
||||
|
||||
verboseLog("Starting reconnection process");
|
||||
this.reconnectTimer = setInterval(() => {
|
||||
verboseLog("Attempting to reconnect...");
|
||||
this.initialize()
|
||||
.then(() => {
|
||||
verboseLog("Reconnection successful");
|
||||
this.stopReconnection();
|
||||
})
|
||||
.catch((error) => {
|
||||
verboseLog("Reconnection failed:", error);
|
||||
});
|
||||
}, Constants.RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
private startReconnection() {
|
||||
if (this.isReconnecting) return;
|
||||
|
||||
this.isReconnecting = true;
|
||||
this.stopUpdates();
|
||||
|
||||
verboseLog("Starting reconnection process");
|
||||
this.reconnectTimer = setInterval(() => {
|
||||
verboseLog("Attempting to reconnect...");
|
||||
this.initialize()
|
||||
.then(() => {
|
||||
verboseLog("Reconnection successful");
|
||||
this.stopReconnection();
|
||||
})
|
||||
.catch((error) => {
|
||||
verboseLog("Reconnection failed:", error);
|
||||
});
|
||||
}, Constants.RETRY_DELAY);
|
||||
}
|
||||
|
||||
private stopReconnection() {
|
||||
if (this.reconnectTimer) {
|
||||
clearInterval(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
private stopReconnection() {
|
||||
if (this.reconnectTimer) {
|
||||
clearInterval(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
this.isReconnecting = false;
|
||||
this.consecutiveFailures = 0;
|
||||
}
|
||||
this.isReconnecting = false;
|
||||
this.consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Stops all update and reconnection timers
|
||||
*/
|
||||
private stopUpdates() {
|
||||
if (this.updateTimer) {
|
||||
clearInterval(this.updateTimer);
|
||||
this.updateTimer = undefined;
|
||||
private stopUpdates() {
|
||||
if (this.updateTimer) {
|
||||
clearInterval(this.updateTimer);
|
||||
this.updateTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Initializes the plugin and starts the update loop
|
||||
*/
|
||||
public async initialize() {
|
||||
if (pluginState.pluginStopped) {
|
||||
throw new Error("Plugin is stopped");
|
||||
public async initialize() {
|
||||
if (pluginState.pluginStopped) {
|
||||
throw new Error("Plugin is stopped");
|
||||
}
|
||||
|
||||
this.stopUpdates();
|
||||
await this.updateActivity();
|
||||
|
||||
const interval = Math.max(
|
||||
(Number(currentSettings.timeInterval) ||
|
||||
Constants.DEFAULT_SETTINGS.timeInterval) * 1000,
|
||||
Constants.MIN_UPDATE_INTERVAL * 1000,
|
||||
);
|
||||
|
||||
this.updateTimer = setInterval(() => this.updateActivity(), interval);
|
||||
verboseLog(`Update timer started with interval: ${interval}ms`);
|
||||
}
|
||||
|
||||
this.stopUpdates();
|
||||
await this.updateActivity();
|
||||
|
||||
const interval = Math.max(
|
||||
(Number(currentSettings.timeInterval) ||
|
||||
Constants.DEFAULT_SETTINGS.timeInterval) * 1000,
|
||||
Constants.MIN_UPDATE_INTERVAL * 1000,
|
||||
);
|
||||
|
||||
this.updateTimer = setInterval(() => this.updateActivity(), interval);
|
||||
verboseLog(`Update timer started with interval: ${interval}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Stops the plugin and cleans up
|
||||
*/
|
||||
public stop() {
|
||||
pluginState.pluginStopped = true;
|
||||
this.stopUpdates();
|
||||
this.stopReconnection();
|
||||
clearActivity();
|
||||
}
|
||||
public stop() {
|
||||
pluginState.pluginStopped = true;
|
||||
this.stopUpdates();
|
||||
this.stopReconnection();
|
||||
clearActivity();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance methods
|
||||
|
|
|
|||
|
|
@ -7,74 +7,74 @@ import { React } from "@vendetta/metro/common";
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
const { TableRowGroup, TableRow, Stack } = findByProps(
|
||||
"TableSwitchRow",
|
||||
"TableRowGroup",
|
||||
"Stack",
|
||||
"TableRow",
|
||||
"TableSwitchRow",
|
||||
"TableRowGroup",
|
||||
"Stack",
|
||||
"TableRow",
|
||||
);
|
||||
const { ScrollView } = General;
|
||||
|
||||
export default function ChangelogView() {
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||
<Stack spacing={8}>
|
||||
<TableRowGroup title="CURRENT VERSION">
|
||||
<TableRow
|
||||
label={currentVersion}
|
||||
trailing={
|
||||
<General.TouchableOpacity
|
||||
onPress={() => {
|
||||
clipboard.setString(currentVersion);
|
||||
showToast(
|
||||
"Version copied to clipboard",
|
||||
getAssetIDByName("toast_copy_link"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<General.Text style={{ color: "#72767d", fontSize: 16 }}>
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||
<Stack spacing={8}>
|
||||
<TableRowGroup title="CURRENT VERSION">
|
||||
<TableRow
|
||||
label={currentVersion}
|
||||
trailing={
|
||||
<General.TouchableOpacity
|
||||
onPress={() => {
|
||||
clipboard.setString(currentVersion);
|
||||
showToast(
|
||||
"Version copied to clipboard",
|
||||
getAssetIDByName("toast_copy_link"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<General.Text style={{ color: "#72767d", fontSize: 16 }}>
|
||||
Copy
|
||||
</General.Text>
|
||||
</General.TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
</General.Text>
|
||||
</General.TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="CHANGELOG">
|
||||
{changelog.map((entry, index) => (
|
||||
<Stack
|
||||
key={index}
|
||||
spacing={4}
|
||||
style={{
|
||||
padding: 12,
|
||||
borderBottomWidth: index === changelog.length - 1 ? 0 : 0.5,
|
||||
borderBottomColor: "#292929",
|
||||
}}
|
||||
>
|
||||
<General.View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<General.Text style={{ color: "#fff", fontWeight: "bold" }}>
|
||||
<TableRowGroup title="CHANGELOG">
|
||||
{changelog.map((entry, index) => (
|
||||
<Stack
|
||||
key={index}
|
||||
spacing={4}
|
||||
style={{
|
||||
padding: 12,
|
||||
borderBottomWidth: index === changelog.length - 1 ? 0 : 0.5,
|
||||
borderBottomColor: "#292929",
|
||||
}}
|
||||
>
|
||||
<General.View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<General.Text style={{ color: "#fff", fontWeight: "bold" }}>
|
||||
Version {entry.version}
|
||||
</General.Text>
|
||||
<General.Text style={{ color: "#72767d" }}>
|
||||
{entry.date}
|
||||
</General.Text>
|
||||
</General.View>
|
||||
{entry.changes.map((change, changeIndex) => (
|
||||
<General.Text
|
||||
key={changeIndex}
|
||||
style={{ color: "#dcddde", marginLeft: 8, marginTop: 2 }}
|
||||
>
|
||||
</General.Text>
|
||||
<General.Text style={{ color: "#72767d" }}>
|
||||
{entry.date}
|
||||
</General.Text>
|
||||
</General.View>
|
||||
{entry.changes.map((change, changeIndex) => (
|
||||
<General.Text
|
||||
key={changeIndex}
|
||||
style={{ color: "#dcddde", marginLeft: 8, marginTop: 2 }}
|
||||
>
|
||||
• {change}
|
||||
</General.Text>
|
||||
))}
|
||||
</General.Text>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</TableRowGroup>
|
||||
</Stack>
|
||||
))}
|
||||
</TableRowGroup>
|
||||
</Stack>
|
||||
</ScrollView>
|
||||
);
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,50 +3,50 @@ import { Codeblock, Forms } from "@vendetta/ui/components";
|
|||
import { ScrollView, Text } from "react-native";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
useDebugInfo,
|
||||
logComponentMount,
|
||||
logComponentError,
|
||||
useDebugInfo,
|
||||
logComponentMount,
|
||||
logComponentError,
|
||||
} from "../../utils/debug";
|
||||
|
||||
const { FormText } = Forms;
|
||||
|
||||
export default React.memo(function Debug() {
|
||||
// Log component mount
|
||||
useEffect(() => {
|
||||
logComponentMount("Debug");
|
||||
}, []);
|
||||
// Log component mount
|
||||
useEffect(() => {
|
||||
logComponentMount("Debug");
|
||||
}, []);
|
||||
|
||||
try {
|
||||
const debugInfo = useDebugInfo();
|
||||
try {
|
||||
const debugInfo = useDebugInfo();
|
||||
|
||||
// Safety check for debugInfo
|
||||
if (!debugInfo) {
|
||||
logComponentError("Debug", "useDebugInfo returned null or undefined");
|
||||
return (
|
||||
<ScrollView>
|
||||
<FormText style={{ margin: 12 }}>
|
||||
// Safety check for debugInfo
|
||||
if (!debugInfo) {
|
||||
logComponentError("Debug", "useDebugInfo returned null or undefined");
|
||||
return (
|
||||
<ScrollView>
|
||||
<FormText style={{ margin: 12 }}>
|
||||
No debug information available
|
||||
</FormText>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
</FormText>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
<Codeblock selectable style={{ margin: 12 }}>
|
||||
{debugInfo}
|
||||
</Codeblock>
|
||||
</ScrollView>
|
||||
);
|
||||
} catch (error) {
|
||||
logComponentError("Debug", error);
|
||||
console.error("Debug component error:", error);
|
||||
return (
|
||||
<ScrollView>
|
||||
<FormText style={{ margin: 12, color: "#FF0000" }}>
|
||||
return (
|
||||
<ScrollView>
|
||||
<Codeblock selectable style={{ margin: 12 }}>
|
||||
{debugInfo}
|
||||
</Codeblock>
|
||||
</ScrollView>
|
||||
);
|
||||
} catch (error) {
|
||||
logComponentError("Debug", error);
|
||||
console.error("Debug component error:", error);
|
||||
return (
|
||||
<ScrollView>
|
||||
<FormText style={{ margin: 12, color: "#FF0000" }}>
|
||||
Error loading debug information: {String(error)}
|
||||
</FormText>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
</FormText>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import { findByProps } from "@vendetta/metro";
|
|||
|
||||
const { ScrollView } = findByProps("ScrollView");
|
||||
const { TableRowGroup, TableSwitchRow, Stack, TableRow } = findByProps(
|
||||
"TableSwitchRow",
|
||||
"TableRowGroup",
|
||||
"Stack",
|
||||
"TableRow",
|
||||
"TableSwitchRow",
|
||||
"TableRowGroup",
|
||||
"Stack",
|
||||
"TableRow",
|
||||
);
|
||||
const { TextInput } = findByProps("TextInput");
|
||||
const { FormText } = findByProps("FormText");
|
||||
|
|
@ -26,187 +26,187 @@ const set = (k: string, v: any) => (plugin.storage[k] = v);
|
|||
|
||||
// Define the Settings component
|
||||
export default function Settings() {
|
||||
const [_, forceUpdate] = React.useReducer((x) => ~x, 0);
|
||||
const update = () => forceUpdate();
|
||||
const [_, forceUpdate] = React.useReducer((x) => ~x, 0);
|
||||
const update = () => forceUpdate();
|
||||
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const testConnection = async () => {
|
||||
const username = get("username");
|
||||
const apiKey = get("apiKey");
|
||||
const testConnection = async () => {
|
||||
const username = get("username");
|
||||
const apiKey = get("apiKey");
|
||||
|
||||
if (!username || !apiKey) {
|
||||
showToast(
|
||||
"Please enter both username and API key",
|
||||
getAssetIDByName("Small"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!username || !apiKey) {
|
||||
showToast(
|
||||
"Please enter both username and API key",
|
||||
getAssetIDByName("Small"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await lastfmClient.fetchLatestScrobble();
|
||||
showToast("Connection successful!", getAssetIDByName("Check"));
|
||||
await initialize();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`Connection failed: ${error.message}`,
|
||||
getAssetIDByName("Small"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
setLoading(true);
|
||||
try {
|
||||
await lastfmClient.fetchLatestScrobble();
|
||||
showToast("Connection successful!", getAssetIDByName("Check"));
|
||||
await initialize();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`Connection failed: ${error.message}`,
|
||||
getAssetIDByName("Small"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||
<Stack spacing={8}>
|
||||
<TableRowGroup title="Account Settings">
|
||||
<Stack spacing={4}>
|
||||
<TextInput
|
||||
placeholder="Last.fm Username"
|
||||
value={get("username")}
|
||||
onChange={(v: string) => {
|
||||
set("username", v);
|
||||
update();
|
||||
}}
|
||||
isClearable
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="API Key"
|
||||
value={get("apiKey")}
|
||||
onChange={(v: string) => {
|
||||
set("apiKey", v);
|
||||
update();
|
||||
}}
|
||||
secureTextEntry={true}
|
||||
isClearable
|
||||
/>
|
||||
<TableRow
|
||||
label="Get API Key"
|
||||
subLabel="https://www.last.fm/api/"
|
||||
onPress={() => Linking.openURL("https://www.last.fm/api/")}
|
||||
/>
|
||||
</Stack>
|
||||
<TableRow
|
||||
label="Test Connection"
|
||||
trailing={loading ? <TableRow.Arrow loading /> : <TableRow.Arrow />}
|
||||
onPress={testConnection}
|
||||
disabled={loading}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
|
||||
<Stack spacing={8}>
|
||||
<TableRowGroup title="Account Settings">
|
||||
<Stack spacing={4}>
|
||||
<TextInput
|
||||
placeholder="Last.fm Username"
|
||||
value={get("username")}
|
||||
onChange={(v: string) => {
|
||||
set("username", v);
|
||||
update();
|
||||
}}
|
||||
isClearable
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="API Key"
|
||||
value={get("apiKey")}
|
||||
onChange={(v: string) => {
|
||||
set("apiKey", v);
|
||||
update();
|
||||
}}
|
||||
secureTextEntry={true}
|
||||
isClearable
|
||||
/>
|
||||
<TableRow
|
||||
label="Get API Key"
|
||||
subLabel="https://www.last.fm/api/"
|
||||
onPress={() => Linking.openURL("https://www.last.fm/api/")}
|
||||
/>
|
||||
</Stack>
|
||||
<TableRow
|
||||
label="Test Connection"
|
||||
trailing={loading ? <TableRow.Arrow loading /> : <TableRow.Arrow />}
|
||||
onPress={testConnection}
|
||||
disabled={loading}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="Display Settings">
|
||||
<Stack spacing={4}>
|
||||
<TextInput
|
||||
placeholder={`App Name (Default: ${Constants.DEFAULT_SETTINGS.appName})`}
|
||||
value={get("appName", Constants.DEFAULT_SETTINGS.appName)}
|
||||
onChange={(v: string) => {
|
||||
set("appName", v);
|
||||
update();
|
||||
}}
|
||||
isClearable
|
||||
/>
|
||||
<TextInput
|
||||
placeholder={`Update Interval (Default: ${Constants.DEFAULT_SETTINGS.timeInterval}s)`}
|
||||
value={String(
|
||||
get("timeInterval", Constants.DEFAULT_SETTINGS.timeInterval),
|
||||
)}
|
||||
onChange={(v: string) => {
|
||||
const interval = Number(v);
|
||||
if (interval >= Constants.MIN_UPDATE_INTERVAL) {
|
||||
set("timeInterval", interval);
|
||||
update();
|
||||
} else {
|
||||
showToast(
|
||||
`Minimum interval is ${Constants.MIN_UPDATE_INTERVAL} seconds`,
|
||||
getAssetIDByName("Small"),
|
||||
);
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
isClearable
|
||||
/>
|
||||
</Stack>
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="Display Settings">
|
||||
<Stack spacing={4}>
|
||||
<TextInput
|
||||
placeholder={`App Name (Default: ${Constants.DEFAULT_SETTINGS.appName})`}
|
||||
value={get("appName", Constants.DEFAULT_SETTINGS.appName)}
|
||||
onChange={(v: string) => {
|
||||
set("appName", v);
|
||||
update();
|
||||
}}
|
||||
isClearable
|
||||
/>
|
||||
<TextInput
|
||||
placeholder={`Update Interval (Default: ${Constants.DEFAULT_SETTINGS.timeInterval}s)`}
|
||||
value={String(
|
||||
get("timeInterval", Constants.DEFAULT_SETTINGS.timeInterval),
|
||||
)}
|
||||
onChange={(v: string) => {
|
||||
const interval = Number(v);
|
||||
if (interval >= Constants.MIN_UPDATE_INTERVAL) {
|
||||
set("timeInterval", interval);
|
||||
update();
|
||||
} else {
|
||||
showToast(
|
||||
`Minimum interval is ${Constants.MIN_UPDATE_INTERVAL} seconds`,
|
||||
getAssetIDByName("Small"),
|
||||
);
|
||||
}
|
||||
}}
|
||||
keyboardType="numeric"
|
||||
isClearable
|
||||
/>
|
||||
</Stack>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="Options">
|
||||
<TableSwitchRow
|
||||
label="Show 'Listening to' instead of 'Playing'"
|
||||
value={get("listeningTo", Constants.DEFAULT_SETTINGS.listeningTo)}
|
||||
onValueChange={(value: boolean) => {
|
||||
set("listeningTo", value);
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Show Timestamp"
|
||||
value={get(
|
||||
"showTimestamp",
|
||||
Constants.DEFAULT_SETTINGS.showTimestamp,
|
||||
)}
|
||||
onValueChange={(value: boolean) => {
|
||||
set("showTimestamp", value);
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Ignore when Spotify is playing"
|
||||
value={get(
|
||||
"ignoreSpotify",
|
||||
Constants.DEFAULT_SETTINGS.ignoreSpotify,
|
||||
)}
|
||||
onValueChange={(value: boolean) => {
|
||||
set("ignoreSpotify", value);
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Verbose Logging"
|
||||
value={get(
|
||||
"verboseLogging",
|
||||
Constants.DEFAULT_SETTINGS.verboseLogging,
|
||||
)}
|
||||
onValueChange={(value: boolean) => {
|
||||
set("verboseLogging", value);
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="Options">
|
||||
<TableSwitchRow
|
||||
label="Show 'Listening to' instead of 'Playing'"
|
||||
value={get("listeningTo", Constants.DEFAULT_SETTINGS.listeningTo)}
|
||||
onValueChange={(value: boolean) => {
|
||||
set("listeningTo", value);
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Show Timestamp"
|
||||
value={get(
|
||||
"showTimestamp",
|
||||
Constants.DEFAULT_SETTINGS.showTimestamp,
|
||||
)}
|
||||
onValueChange={(value: boolean) => {
|
||||
set("showTimestamp", value);
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Ignore when Spotify is playing"
|
||||
value={get(
|
||||
"ignoreSpotify",
|
||||
Constants.DEFAULT_SETTINGS.ignoreSpotify,
|
||||
)}
|
||||
onValueChange={(value: boolean) => {
|
||||
set("ignoreSpotify", value);
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Verbose Logging"
|
||||
value={get(
|
||||
"verboseLogging",
|
||||
Constants.DEFAULT_SETTINGS.verboseLogging,
|
||||
)}
|
||||
onValueChange={(value: boolean) => {
|
||||
set("verboseLogging", value);
|
||||
update();
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="Plugin Status">
|
||||
<TableRow
|
||||
label="Status"
|
||||
subLabel={pluginState.pluginStopped ? "Stopped" : "Running"}
|
||||
/>
|
||||
<TableRow
|
||||
label="Last Update"
|
||||
subLabel={pluginState.lastTrackUrl ? "Success" : "No track data"}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="Plugin Status">
|
||||
<TableRow
|
||||
label="Status"
|
||||
subLabel={pluginState.pluginStopped ? "Stopped" : "Running"}
|
||||
/>
|
||||
<TableRow
|
||||
label="Last Update"
|
||||
subLabel={pluginState.lastTrackUrl ? "Success" : "No track data"}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="About">
|
||||
<TableRow label="Version" subLabel={currentVersion} />
|
||||
<TableRow
|
||||
label="View Changelog"
|
||||
trailing={<TableRow.Arrow />}
|
||||
onPress={() => {
|
||||
Linking.openURL(Constants.GITHUB_COMMITS_URL);
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="About">
|
||||
<TableRow label="Version" subLabel={currentVersion} />
|
||||
<TableRow
|
||||
label="View Changelog"
|
||||
trailing={<TableRow.Arrow />}
|
||||
onPress={() => {
|
||||
Linking.openURL(Constants.GITHUB_COMMITS_URL);
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
{/* Changelog Section */}
|
||||
<TableRowGroup title="Changelog">
|
||||
{changelog.map((entry, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
label={`v${entry.version} (${entry.date})`}
|
||||
subLabel={entry.changes.join(", ")}
|
||||
/>
|
||||
))}
|
||||
</TableRowGroup>
|
||||
</Stack>
|
||||
</ScrollView>
|
||||
);
|
||||
{/* Changelog Section */}
|
||||
<TableRowGroup title="Changelog">
|
||||
{changelog.map((entry, index) => (
|
||||
<TableRow
|
||||
key={index}
|
||||
label={`v${entry.version} (${entry.date})`}
|
||||
subLabel={entry.changes.join(", ")}
|
||||
/>
|
||||
))}
|
||||
</TableRowGroup>
|
||||
</Stack>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,37 +8,37 @@ import { AssetManager } from "../modules";
|
|||
|
||||
/** Clears the user's activity */
|
||||
export function clearActivity() {
|
||||
return sendRequest(null);
|
||||
return sendRequest(null);
|
||||
}
|
||||
|
||||
/** Sends the activity details to Discord */
|
||||
export function sendRequest(activity: Activity | null) {
|
||||
if (pluginState.pluginStopped) {
|
||||
stop();
|
||||
activity = null;
|
||||
}
|
||||
if (pluginState.pluginStopped) {
|
||||
stop();
|
||||
activity = null;
|
||||
}
|
||||
|
||||
pluginState.lastActivity = activity;
|
||||
pluginState.lastActivity = activity;
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "LOCAL_ACTIVITY_UPDATE",
|
||||
activity: activity,
|
||||
pid: 2312,
|
||||
socketId: "Last.fm@Vendetta",
|
||||
});
|
||||
FluxDispatcher.dispatch({
|
||||
type: "LOCAL_ACTIVITY_UPDATE",
|
||||
activity: activity,
|
||||
pid: 2312,
|
||||
socketId: "Last.fm@Vendetta",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetches a Discord application's asset */
|
||||
export async function fetchAsset(
|
||||
asset: string[],
|
||||
appId: string = Constants.APPLICATION_ID,
|
||||
asset: string[],
|
||||
appId: string = Constants.APPLICATION_ID,
|
||||
): Promise<string[]> {
|
||||
if (!asset?.length) return [];
|
||||
if (!asset?.length) return [];
|
||||
|
||||
try {
|
||||
return await AssetManager.fetchAssetIds(appId, asset);
|
||||
} catch (error) {
|
||||
console.error("[Last.fm] Failed to fetch assets:", error);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await AssetManager.fetchAssetIds(appId, asset);
|
||||
} catch (error) {
|
||||
console.error("[Last.fm] Failed to fetch assets:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,70 +21,70 @@ debugInfo.componentMountCount = 0;
|
|||
debugInfo.settingsLoadAttempts = 0;
|
||||
|
||||
export function setDebugInfo(
|
||||
key: keyof typeof debugInfo,
|
||||
value: (typeof debugInfo)[typeof key],
|
||||
key: keyof typeof debugInfo,
|
||||
value: (typeof debugInfo)[typeof key],
|
||||
) {
|
||||
debugInfo[key] = value;
|
||||
__forceUpdate?.();
|
||||
debugInfo[key] = value;
|
||||
__forceUpdate?.();
|
||||
}
|
||||
|
||||
export function logComponentMount(componentName: string) {
|
||||
debugInfo.componentMountCount = (debugInfo.componentMountCount || 0) + 1;
|
||||
console.log(
|
||||
`[Last.fm Debug] Component mounted: ${componentName} (count: ${debugInfo.componentMountCount})`,
|
||||
);
|
||||
debugInfo.componentMountCount = (debugInfo.componentMountCount || 0) + 1;
|
||||
console.log(
|
||||
`[Last.fm Debug] Component mounted: ${componentName} (count: ${debugInfo.componentMountCount})`,
|
||||
);
|
||||
}
|
||||
|
||||
export function logComponentError(componentName: string, error: any) {
|
||||
const errorMessage = `${componentName}: ${String(error)}`;
|
||||
debugInfo.componentMountErrors = debugInfo.componentMountErrors || [];
|
||||
debugInfo.componentMountErrors.push(errorMessage);
|
||||
const errorMessage = `${componentName}: ${String(error)}`;
|
||||
debugInfo.componentMountErrors = debugInfo.componentMountErrors || [];
|
||||
debugInfo.componentMountErrors.push(errorMessage);
|
||||
|
||||
if (String(error).includes("Reanimated")) {
|
||||
debugInfo.lastReanimatedError = errorMessage;
|
||||
}
|
||||
if (String(error).includes("Reanimated")) {
|
||||
debugInfo.lastReanimatedError = errorMessage;
|
||||
}
|
||||
|
||||
console.error(`[Last.fm Debug] Component error in ${componentName}:`, error);
|
||||
__forceUpdate?.();
|
||||
console.error(`[Last.fm Debug] Component error in ${componentName}:`, error);
|
||||
__forceUpdate?.();
|
||||
}
|
||||
|
||||
export function logNavigationError(error: any) {
|
||||
debugInfo.lastNavigationError = String(error);
|
||||
console.error(`[Last.fm Debug] Navigation error:`, error);
|
||||
__forceUpdate?.();
|
||||
debugInfo.lastNavigationError = String(error);
|
||||
console.error("[Last.fm Debug] Navigation error:", error);
|
||||
__forceUpdate?.();
|
||||
}
|
||||
|
||||
export function incrementSettingsLoad() {
|
||||
debugInfo.settingsLoadAttempts = (debugInfo.settingsLoadAttempts || 0) + 1;
|
||||
console.log(
|
||||
`[Last.fm Debug] Settings load attempt: ${debugInfo.settingsLoadAttempts}`,
|
||||
);
|
||||
debugInfo.settingsLoadAttempts = (debugInfo.settingsLoadAttempts || 0) + 1;
|
||||
console.log(
|
||||
`[Last.fm Debug] Settings load attempt: ${debugInfo.settingsLoadAttempts}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function useDebugInfo(): string {
|
||||
try {
|
||||
[, __forceUpdate] = useReducer((x) => ~x, 0);
|
||||
try {
|
||||
[, __forceUpdate] = useReducer((x) => ~x, 0);
|
||||
|
||||
// Add runtime information
|
||||
const runtimeInfo = {
|
||||
...debugInfo,
|
||||
timestamp: new Date().toISOString(),
|
||||
reactVersion: require("react").version,
|
||||
hasReanimatedError: !!debugInfo.lastReanimatedError,
|
||||
totalErrors: debugInfo.componentMountErrors?.length || 0,
|
||||
};
|
||||
// Add runtime information
|
||||
const runtimeInfo = {
|
||||
...debugInfo,
|
||||
timestamp: new Date().toISOString(),
|
||||
reactVersion: require("react").version,
|
||||
hasReanimatedError: !!debugInfo.lastReanimatedError,
|
||||
totalErrors: debugInfo.componentMountErrors?.length || 0,
|
||||
};
|
||||
|
||||
return JSON.stringify(runtimeInfo, null, 4);
|
||||
} catch (error) {
|
||||
console.error("[Last.fm Debug] Error in useDebugInfo:", error);
|
||||
return JSON.stringify(
|
||||
{
|
||||
error: "Failed to generate debug info",
|
||||
errorMessage: String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
}
|
||||
return JSON.stringify(runtimeInfo, null, 4);
|
||||
} catch (error) {
|
||||
console.error("[Last.fm Debug] Error in useDebugInfo:", error);
|
||||
return JSON.stringify(
|
||||
{
|
||||
error: "Failed to generate debug info",
|
||||
errorMessage: String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
4,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,161 +44,161 @@ interface LastFMTrack {
|
|||
}
|
||||
|
||||
class LastFMClient {
|
||||
private static instance: LastFMClient;
|
||||
private retryCount: number = 0;
|
||||
private lastError: number = 0;
|
||||
private static instance: LastFMClient;
|
||||
private retryCount = 0;
|
||||
private lastError = 0;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LastFMClient {
|
||||
if (!LastFMClient.instance) {
|
||||
LastFMClient.instance = new LastFMClient();
|
||||
}
|
||||
return LastFMClient.instance;
|
||||
}
|
||||
|
||||
private validateSettings(): void {
|
||||
if (!currentSettings.username) {
|
||||
throw new Error("Last.fm username is not set");
|
||||
}
|
||||
if (!currentSettings.apiKey) {
|
||||
throw new Error("Last.fm API key is not set");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleError(error: LastFMError): Promise<never> {
|
||||
this.lastError = error.error;
|
||||
|
||||
const errorMessages: { [key: number]: string } = {
|
||||
2: "Invalid API key",
|
||||
11: "Service temporarily unavailable",
|
||||
16: "Service temporarily unavailable",
|
||||
29: "Rate limit exceeded",
|
||||
};
|
||||
|
||||
const message =
|
||||
errorMessages[error.error] || error.message || "Unknown error";
|
||||
showToast(`Last.fm Error: ${message}`, getAssetIDByName("Small"));
|
||||
|
||||
throw new Error(`Last.fm API Error ${error.error}: ${message}`);
|
||||
}
|
||||
|
||||
private async makeRequest(
|
||||
params: Record<string, string>,
|
||||
): Promise<LastFMResponse> {
|
||||
const queryParams = new URLSearchParams({
|
||||
...params,
|
||||
api_key: currentSettings.apiKey,
|
||||
format: "json",
|
||||
}).toString();
|
||||
|
||||
const response = await fetch(
|
||||
`${Constants.LFM_API_BASE_URL}?${queryParams}`,
|
||||
);
|
||||
const data: LastFMResponse = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
await this.handleError(data as LastFMError);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public async fetchLatestScrobble(): Promise<
|
||||
Track & { from: number; to: number | null }
|
||||
> {
|
||||
try {
|
||||
this.validateSettings();
|
||||
|
||||
const data = await this.makeRequest({
|
||||
method: "user.getrecenttracks",
|
||||
user: currentSettings.username,
|
||||
limit: "1",
|
||||
extended: "1",
|
||||
});
|
||||
|
||||
const lastTrack = data?.recenttracks?.track?.[0];
|
||||
setDebugInfo("lastAPIResponse", lastTrack);
|
||||
|
||||
if (!lastTrack) {
|
||||
throw new Error("No tracks found");
|
||||
}
|
||||
|
||||
// Reset retry count on successful request
|
||||
this.retryCount = 0;
|
||||
|
||||
const isNowPlaying = Boolean(lastTrack["@attr"]?.nowplaying);
|
||||
const from = isNowPlaying
|
||||
? Math.floor(Date.now() / 1000)
|
||||
: parseInt(lastTrack?.date?.uts);
|
||||
|
||||
let to: number | null = null;
|
||||
try {
|
||||
const trackInfo = await this.makeRequest({
|
||||
method: "track.getInfo",
|
||||
track: lastTrack.name,
|
||||
artist: lastTrack.artist.name,
|
||||
username: currentSettings.username,
|
||||
});
|
||||
|
||||
const duration = parseInt(trackInfo?.track?.duration);
|
||||
if (duration > 0) {
|
||||
to = from + Math.floor(duration / 1000);
|
||||
public static getInstance(): LastFMClient {
|
||||
if (!LastFMClient.instance) {
|
||||
LastFMClient.instance = new LastFMClient();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to fetch track duration", err);
|
||||
}
|
||||
return LastFMClient.instance;
|
||||
}
|
||||
|
||||
return {
|
||||
name: lastTrack.name,
|
||||
artist: lastTrack.artist.name,
|
||||
album: lastTrack.album["#text"],
|
||||
albumArt: await this.handleAlbumCover(
|
||||
lastTrack.image?.find((x) => x.size === "large")?.["#text"],
|
||||
),
|
||||
url: lastTrack.url,
|
||||
date: lastTrack.date?.["#text"] ?? "now",
|
||||
nowPlaying: isNowPlaying,
|
||||
loved: lastTrack.loved === "1",
|
||||
from,
|
||||
to,
|
||||
};
|
||||
} catch (error) {
|
||||
// Increment retry count and throw if we've exceeded max retries
|
||||
this.retryCount++;
|
||||
if (this.retryCount > Constants.MAX_RETRY_ATTEMPTS) {
|
||||
private validateSettings(): void {
|
||||
if (!currentSettings.username) {
|
||||
throw new Error("Last.fm username is not set");
|
||||
}
|
||||
if (!currentSettings.apiKey) {
|
||||
throw new Error("Last.fm API key is not set");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleError(error: LastFMError): Promise<never> {
|
||||
this.lastError = error.error;
|
||||
|
||||
const errorMessages: { [key: number]: string } = {
|
||||
2: "Invalid API key",
|
||||
11: "Service temporarily unavailable",
|
||||
16: "Service temporarily unavailable",
|
||||
29: "Rate limit exceeded",
|
||||
};
|
||||
|
||||
const message =
|
||||
errorMessages[error.error] || error.message || "Unknown error";
|
||||
showToast(`Last.fm Error: ${message}`, getAssetIDByName("Small"));
|
||||
|
||||
throw new Error(`Last.fm API Error ${error.error}: ${message}`);
|
||||
}
|
||||
|
||||
private async makeRequest(
|
||||
params: Record<string, string>,
|
||||
): Promise<LastFMResponse> {
|
||||
const queryParams = new URLSearchParams({
|
||||
...params,
|
||||
api_key: currentSettings.apiKey,
|
||||
format: "json",
|
||||
}).toString();
|
||||
|
||||
const response = await fetch(
|
||||
`${Constants.LFM_API_BASE_URL}?${queryParams}`,
|
||||
);
|
||||
const data: LastFMResponse = await response.json();
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
await this.handleError(data as LastFMError);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public async fetchLatestScrobble(): Promise<
|
||||
Track & { from: number; to: number | null }
|
||||
> {
|
||||
try {
|
||||
this.validateSettings();
|
||||
|
||||
const data = await this.makeRequest({
|
||||
method: "user.getrecenttracks",
|
||||
user: currentSettings.username,
|
||||
limit: "1",
|
||||
extended: "1",
|
||||
});
|
||||
|
||||
const lastTrack = data?.recenttracks?.track?.[0];
|
||||
setDebugInfo("lastAPIResponse", lastTrack);
|
||||
|
||||
if (!lastTrack) {
|
||||
throw new Error("No tracks found");
|
||||
}
|
||||
|
||||
// Reset retry count on successful request
|
||||
this.retryCount = 0;
|
||||
|
||||
const isNowPlaying = Boolean(lastTrack["@attr"]?.nowplaying);
|
||||
const from = isNowPlaying
|
||||
? Math.floor(Date.now() / 1000)
|
||||
: parseInt(lastTrack?.date?.uts);
|
||||
|
||||
let to: number | null = null;
|
||||
try {
|
||||
const trackInfo = await this.makeRequest({
|
||||
method: "track.getInfo",
|
||||
track: lastTrack.name,
|
||||
artist: lastTrack.artist.name,
|
||||
username: currentSettings.username,
|
||||
});
|
||||
|
||||
const duration = parseInt(trackInfo?.track?.duration);
|
||||
if (duration > 0) {
|
||||
to = from + Math.floor(duration / 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to fetch track duration", err);
|
||||
}
|
||||
|
||||
return {
|
||||
name: lastTrack.name,
|
||||
artist: lastTrack.artist.name,
|
||||
album: lastTrack.album["#text"],
|
||||
albumArt: await this.handleAlbumCover(
|
||||
lastTrack.image?.find((x) => x.size === "large")?.["#text"],
|
||||
),
|
||||
url: lastTrack.url,
|
||||
date: lastTrack.date?.["#text"] ?? "now",
|
||||
nowPlaying: isNowPlaying,
|
||||
loved: lastTrack.loved === "1",
|
||||
from,
|
||||
to,
|
||||
};
|
||||
} catch (error) {
|
||||
// Increment retry count and throw if we've exceeded max retries
|
||||
this.retryCount++;
|
||||
if (this.retryCount > Constants.MAX_RETRY_ATTEMPTS) {
|
||||
this.retryCount = 0;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Constants.RETRY_DELAY),
|
||||
);
|
||||
return this.fetchLatestScrobble();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAlbumCover(cover?: string): Promise<string | null> {
|
||||
if (!cover) return null;
|
||||
|
||||
// If the cover is a default one, return null
|
||||
if (
|
||||
Constants.LFM_DEFAULT_COVER_HASHES.some((hash) => cover.includes(hash))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cover;
|
||||
}
|
||||
|
||||
public getLastError(): number {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
public resetRetryCount(): void {
|
||||
this.retryCount = 0;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Constants.RETRY_DELAY),
|
||||
);
|
||||
return this.fetchLatestScrobble();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAlbumCover(cover?: string): Promise<string | null> {
|
||||
if (!cover) return null;
|
||||
|
||||
// If the cover is a default one, return null
|
||||
if (
|
||||
Constants.LFM_DEFAULT_COVER_HASHES.some((hash) => cover.includes(hash))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cover;
|
||||
}
|
||||
|
||||
public getLastError(): number {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
public resetRetryCount(): void {
|
||||
this.retryCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
|
|
@ -207,8 +207,8 @@ export const lastfmClient = LastFMClient.getInstance();
|
|||
/** Fetches the latest user's scrobble */
|
||||
export async function fetchLatestScrobble(): Promise<
|
||||
Track & { from: number; to: number | null }
|
||||
> {
|
||||
return lastfmClient.fetchLatestScrobble();
|
||||
> {
|
||||
return lastfmClient.fetchLatestScrobble();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,5 +216,5 @@ export async function fetchLatestScrobble(): Promise<
|
|||
* @param cover The album cover given by Last.fm
|
||||
*/
|
||||
export async function handleAlbumCover(cover: string): Promise<string> {
|
||||
return lastfmClient.handleAlbumCover(cover);
|
||||
return lastfmClient.handleAlbumCover(cover);
|
||||
}
|
||||
|
|
|
|||
94
plugins/defs.d.ts
vendored
94
plugins/defs.d.ts
vendored
|
|
@ -1,48 +1,74 @@
|
|||
export type Activity = {
|
||||
name: string;
|
||||
application_id: string;
|
||||
flags: number;
|
||||
type: number;
|
||||
details?: string;
|
||||
state?: string;
|
||||
timestamps?: {
|
||||
_enabled?: boolean;
|
||||
start: number | string;
|
||||
end?: number | string;
|
||||
};
|
||||
assets: ActivityAssets;
|
||||
buttons: ActivityButton[];
|
||||
name: string;
|
||||
application_id: string;
|
||||
flags: number;
|
||||
type: number;
|
||||
details?: string;
|
||||
state?: string;
|
||||
timestamps?: {
|
||||
_enabled?: boolean;
|
||||
start: number | string;
|
||||
end?: number | string;
|
||||
};
|
||||
assets?: ActivityAssets;
|
||||
buttons?: ActivityButton[];
|
||||
status_display_type?: number;
|
||||
};
|
||||
|
||||
export type ActivityButton = {
|
||||
label: string;
|
||||
url: string;
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ActivityAssets = {
|
||||
large_image?: string;
|
||||
large_text?: string;
|
||||
small_image?: string;
|
||||
small_text?: string;
|
||||
large_image?: string;
|
||||
large_text?: string;
|
||||
small_image?: string;
|
||||
small_text?: string;
|
||||
};
|
||||
|
||||
export type ServiceType = "lastfm" | "librefm" | "listenbrainz";
|
||||
|
||||
export type LFMSettings = {
|
||||
appName: string;
|
||||
username: string;
|
||||
showTimestamp: boolean;
|
||||
timeInterval: number | string;
|
||||
listeningTo: boolean;
|
||||
ignoreSpotify: boolean;
|
||||
verboseLogging: boolean;
|
||||
appName: string;
|
||||
username: string;
|
||||
apiKey: string;
|
||||
showTimestamp: boolean;
|
||||
timeInterval: number | string;
|
||||
listeningTo: boolean;
|
||||
ignoreSpotify: boolean;
|
||||
verboseLogging: boolean;
|
||||
service: ServiceType;
|
||||
// Service-specific settings
|
||||
librefmUsername?: string;
|
||||
librefmApiKey?: string;
|
||||
listenbrainzUsername?: string;
|
||||
listenbrainzToken?: string;
|
||||
};
|
||||
|
||||
export type Track = {
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumArt: string;
|
||||
url: string;
|
||||
date: string;
|
||||
nowPlaying: boolean;
|
||||
loved: boolean;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumArt: string | null;
|
||||
url: string;
|
||||
date: string;
|
||||
nowPlaying: boolean;
|
||||
loved: boolean;
|
||||
from: number;
|
||||
to: number | null;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export interface ServiceClient {
|
||||
fetchLatestScrobble(): Promise<Track>;
|
||||
validateCredentials(): Promise<boolean>;
|
||||
getServiceName(): string;
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
requiresApiKey: boolean;
|
||||
requiresToken: boolean;
|
||||
}
|
||||
|
|
|
|||
582
serve.mjs
582
serve.mjs
|
|
@ -1,81 +1,557 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { exec } from "child_process";
|
||||
import { exec, spawn } from "child_process";
|
||||
import { networkInterfaces } from "os";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { readdir, stat, access } from "fs/promises";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🎨 STYLING & ANIMATIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Get local IP address
|
||||
function getLocalIP() {
|
||||
const colors = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
italic: "\x1b[3m",
|
||||
underline: "\x1b[4m",
|
||||
|
||||
// Standard colors
|
||||
black: "\x1b[30m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
|
||||
// Bright colors
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightMagenta: "\x1b[95m",
|
||||
brightCyan: "\x1b[96m",
|
||||
brightWhite: "\x1b[97m",
|
||||
|
||||
// Backgrounds
|
||||
bgRed: "\x1b[41m",
|
||||
bgGreen: "\x1b[42m",
|
||||
bgYellow: "\x1b[43m",
|
||||
bgBlue: "\x1b[44m",
|
||||
bgMagenta: "\x1b[45m",
|
||||
bgCyan: "\x1b[46m",
|
||||
};
|
||||
|
||||
const symbols = {
|
||||
// Status indicators
|
||||
success: "✓",
|
||||
error: "✗",
|
||||
warning: "⚠",
|
||||
info: "ℹ",
|
||||
|
||||
// Activities
|
||||
server: "🚀",
|
||||
folder: "📁",
|
||||
network: "🌐",
|
||||
local: "🏠",
|
||||
external: "📡",
|
||||
|
||||
lightning: "⚡",
|
||||
fire: "🔥",
|
||||
sparkles: "✨",
|
||||
eyes: "👀",
|
||||
stop: "🛑",
|
||||
gear: "⚙",
|
||||
globe: "🌍",
|
||||
|
||||
// UI elements
|
||||
arrow: "→",
|
||||
bullet: "•",
|
||||
star: "★",
|
||||
diamond: "◆",
|
||||
|
||||
// Box drawing
|
||||
boxLight: "─",
|
||||
boxHeavy: "━",
|
||||
boxDouble: "═",
|
||||
cornerTL: "┌",
|
||||
cornerTR: "┐",
|
||||
cornerBL: "└",
|
||||
cornerBR: "┘",
|
||||
cross: "┼",
|
||||
teeLeft: "├",
|
||||
teeRight: "┤",
|
||||
vertical: "│",
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🛠 UTILITIES
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createGradient(text, startColor, endColor) {
|
||||
return text
|
||||
.split("")
|
||||
.map((char, i) => {
|
||||
const color = i % 2 === 0 ? startColor : endColor;
|
||||
return `${color}${char}${colors.reset}`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function createBox(content, title = "", style = "light") {
|
||||
const lines = content.split("\n");
|
||||
const maxLength = Math.max(
|
||||
...lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").length),
|
||||
title.length,
|
||||
);
|
||||
const width = maxLength + 4;
|
||||
|
||||
const horizontal =
|
||||
style === "heavy"
|
||||
? symbols.boxHeavy
|
||||
: style === "double"
|
||||
? symbols.boxDouble
|
||||
: symbols.boxLight;
|
||||
|
||||
let result = "";
|
||||
|
||||
// Top border
|
||||
result += `${symbols.cornerTL}${horizontal.repeat(width - 2)}${symbols.cornerTR}\n`;
|
||||
|
||||
// Title
|
||||
if (title) {
|
||||
const padding = Math.floor((width - title.length - 4) / 2);
|
||||
result += `${symbols.vertical} ${" ".repeat(padding)}${colors.bold}${title}${colors.reset}${" ".repeat(padding)} ${symbols.vertical}\n`;
|
||||
result += `${symbols.teeLeft}${horizontal.repeat(width - 2)}${symbols.teeRight}\n`;
|
||||
}
|
||||
|
||||
// Content
|
||||
lines.forEach((line) => {
|
||||
const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const padding = width - cleanLine.length - 4;
|
||||
result += `${symbols.vertical} ${line}${" ".repeat(padding)} ${symbols.vertical}\n`;
|
||||
});
|
||||
|
||||
// Bottom border
|
||||
result += `${symbols.cornerBL}${horizontal.repeat(width - 2)}${symbols.cornerBR}`;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🌐 NETWORK UTILITIES
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getAllNetworkInterfaces() {
|
||||
const nets = networkInterfaces();
|
||||
const results = {};
|
||||
const results = [];
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name]) {
|
||||
// Skip over non-IPv4 and internal addresses
|
||||
if (net.family === "IPv4" && !net.internal) {
|
||||
if (!results[name]) {
|
||||
results[name] = [];
|
||||
}
|
||||
results[name].push(net.address);
|
||||
results.push({
|
||||
interface: name,
|
||||
address: net.address,
|
||||
netmask: net.netmask,
|
||||
mac: net.mac,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first available IP
|
||||
const interfaces = Object.keys(results);
|
||||
if (interfaces.length > 0) {
|
||||
return results[interfaces[0]][0];
|
||||
}
|
||||
return "localhost";
|
||||
return results;
|
||||
}
|
||||
|
||||
const distPath = path.join(__dirname, "dist");
|
||||
const port = process.env.PORT || 3000;
|
||||
const host = "0.0.0.0"; // Bind to all interfaces
|
||||
const localIP = getLocalIP();
|
||||
function getPrimaryIP() {
|
||||
const interfaces = getAllNetworkInterfaces();
|
||||
|
||||
console.log("🚀 Starting server...");
|
||||
console.log(`📁 Serving files from: ${distPath}`);
|
||||
console.log(`🌐 Server will be available at:`);
|
||||
console.log(` - Local: http://localhost:${port}`);
|
||||
console.log(` - Network: http://${localIP}:${port}`);
|
||||
console.log(
|
||||
` - Public: http://0.0.0.0:${port} (accessible from any device on your network)`,
|
||||
);
|
||||
console.log("\n💡 Press Ctrl+C to stop the server\n");
|
||||
// Prefer WiFi/Ethernet interfaces
|
||||
const preferred = interfaces.find(
|
||||
(iface) =>
|
||||
iface.interface.toLowerCase().includes("wlan") ||
|
||||
iface.interface.toLowerCase().includes("wifi") ||
|
||||
iface.interface.toLowerCase().includes("eth") ||
|
||||
iface.interface.toLowerCase().includes("en"),
|
||||
);
|
||||
|
||||
// Use the serve package to start the server
|
||||
const serveCommand = `npx serve -s "${distPath}" -l ${port}`;
|
||||
return preferred ? preferred.address : interfaces[0]?.address || "localhost";
|
||||
}
|
||||
|
||||
const serverProcess = exec(serveCommand, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`❌ Error starting server: ${error.message}`);
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 📊 FILE SYSTEM UTILITIES
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function analyzeDistDirectory(distPath) {
|
||||
try {
|
||||
await access(distPath);
|
||||
const entries = await readdir(distPath);
|
||||
|
||||
const plugins = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const entryPath = path.join(distPath, entry);
|
||||
const entryStats = await stat(entryPath);
|
||||
|
||||
if (entryStats.isDirectory()) {
|
||||
// Analyze plugin directory
|
||||
const pluginFiles = await readdir(entryPath);
|
||||
let pluginSize = 0;
|
||||
|
||||
for (const file of pluginFiles) {
|
||||
const filePath = path.join(entryPath, file);
|
||||
const fileStats = await stat(filePath);
|
||||
if (fileStats.isFile()) {
|
||||
pluginSize += fileStats.size;
|
||||
}
|
||||
}
|
||||
|
||||
plugins.push({
|
||||
name: entry,
|
||||
size: pluginSize,
|
||||
files: pluginFiles.length,
|
||||
});
|
||||
|
||||
totalSize += pluginSize;
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip entries that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plugins,
|
||||
totalSize,
|
||||
totalPlugins: plugins.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
plugins: [],
|
||||
totalSize: 0,
|
||||
totalPlugins: 0,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🎭 LOGGER
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const logger = {
|
||||
banner() {
|
||||
const title = createGradient(
|
||||
"VENDETTA DEV SERVER",
|
||||
colors.brightCyan,
|
||||
colors.brightMagenta,
|
||||
);
|
||||
const subtitle = `${colors.dim}Professional development server for Vendetta plugins${colors.reset}`;
|
||||
|
||||
console.log();
|
||||
console.log(` ${symbols.server} ${title}`);
|
||||
console.log(` ${subtitle}`);
|
||||
console.log();
|
||||
},
|
||||
|
||||
section(title) {
|
||||
const line = symbols.boxHeavy.repeat(60);
|
||||
console.log(`\n${colors.brightBlue}${line}${colors.reset}`);
|
||||
console.log(`${colors.bold}${colors.brightBlue} ${title}${colors.reset}`);
|
||||
console.log(`${colors.brightBlue}${line}${colors.reset}\n`);
|
||||
},
|
||||
|
||||
info(message, icon = symbols.info) {
|
||||
console.log(`${colors.brightBlue}${icon}${colors.reset} ${message}`);
|
||||
},
|
||||
|
||||
success(message, icon = symbols.success) {
|
||||
console.log(`${colors.brightGreen}${icon}${colors.reset} ${message}`);
|
||||
},
|
||||
|
||||
warning(message, icon = symbols.warning) {
|
||||
console.log(`${colors.brightYellow}${icon}${colors.reset} ${message}`);
|
||||
},
|
||||
|
||||
error(message, icon = symbols.error) {
|
||||
console.log(`${colors.brightRed}${icon}${colors.reset} ${message}`);
|
||||
},
|
||||
|
||||
table(headers, rows) {
|
||||
const colWidths = headers.map((header, i) =>
|
||||
Math.max(
|
||||
header.length,
|
||||
...rows.map(
|
||||
(row) =>
|
||||
(row[i] || "").toString().replace(/\x1b\[[0-9;]*m/g, "").length,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Header
|
||||
const headerRow = headers
|
||||
.map(
|
||||
(header, i) =>
|
||||
`${colors.bold}${header.padEnd(colWidths[i])}${colors.reset}`,
|
||||
)
|
||||
.join(` ${colors.dim}│${colors.reset} `);
|
||||
|
||||
console.log(` ${headerRow}`);
|
||||
|
||||
// Separator
|
||||
const separator = colWidths
|
||||
.map((width) => symbols.boxLight.repeat(width))
|
||||
.join(`${colors.dim}─┼─${colors.reset}`);
|
||||
console.log(` ${colors.dim}${separator}${colors.reset}`);
|
||||
|
||||
// Rows
|
||||
rows.forEach((row) => {
|
||||
const formattedRow = row
|
||||
.map((cell, i) => {
|
||||
const cleanCell = (cell || "")
|
||||
.toString()
|
||||
.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const padding = colWidths[i] - cleanCell.length;
|
||||
return cell + " ".repeat(padding);
|
||||
})
|
||||
.join(` ${colors.dim}│${colors.reset} `);
|
||||
|
||||
console.log(` ${formattedRow}`);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 🚀 MAIN EXECUTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function main() {
|
||||
console.clear();
|
||||
|
||||
// Configuration
|
||||
const distPath = path.join(__dirname, "dist");
|
||||
const port = process.env.PORT || 3000;
|
||||
const host = "0.0.0.0";
|
||||
const primaryIP = getPrimaryIP();
|
||||
const allInterfaces = getAllNetworkInterfaces();
|
||||
|
||||
// Display banner
|
||||
logger.banner();
|
||||
|
||||
// Analyze distribution directory
|
||||
logger.section("Analyzing Distribution");
|
||||
|
||||
const analysis = await analyzeDistDirectory(distPath);
|
||||
|
||||
if (analysis.error) {
|
||||
logger.error(`Cannot access dist directory: ${analysis.error}`);
|
||||
logger.info(
|
||||
`Make sure to run ${colors.bold}npm run build${colors.reset} first`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward output from serve command
|
||||
serverProcess.stdout.on("data", (data) => {
|
||||
console.log(data.toString());
|
||||
});
|
||||
logger.info(
|
||||
`${colors.dim}Serving from${colors.reset} ${colors.bold}${distPath}${colors.reset}`,
|
||||
);
|
||||
logger.success(
|
||||
`Found ${colors.bold}${analysis.totalPlugins}${colors.reset} plugin(s) ${colors.dim}(${formatFileSize(analysis.totalSize)} total)${colors.reset}`,
|
||||
);
|
||||
|
||||
serverProcess.stderr.on("data", (data) => {
|
||||
console.error(data.toString());
|
||||
});
|
||||
// Plugin table
|
||||
if (analysis.plugins.length > 0) {
|
||||
console.log();
|
||||
const pluginRows = analysis.plugins.map((plugin) => [
|
||||
plugin.name,
|
||||
plugin.files.toString(),
|
||||
formatFileSize(plugin.size),
|
||||
]);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
console.log("\n🛑 Shutting down server...");
|
||||
serverProcess.kill("SIGTERM");
|
||||
process.exit(0);
|
||||
});
|
||||
logger.table(["Plugin", "Files", "Size"], pluginRows);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("\n🛑 Shutting down server...");
|
||||
serverProcess.kill("SIGTERM");
|
||||
process.exit(0);
|
||||
// Network information
|
||||
logger.section("Network Configuration");
|
||||
|
||||
const localUrl = `http://localhost:${port}`;
|
||||
const networkUrl = `http://${primaryIP}:${port}`;
|
||||
|
||||
logger.info(
|
||||
`${colors.dim}Binding to${colors.reset} ${colors.bold}${host}:${port}${colors.reset}`,
|
||||
);
|
||||
|
||||
// Network interfaces table
|
||||
if (allInterfaces.length > 0) {
|
||||
console.log();
|
||||
const networkRows = allInterfaces.map((iface) => [
|
||||
iface.interface,
|
||||
iface.address,
|
||||
`http://${iface.address}:${port}`,
|
||||
]);
|
||||
|
||||
logger.table(["Interface", "IP Address", "URL"], networkRows);
|
||||
}
|
||||
|
||||
// Access URLs
|
||||
logger.section("Access Information");
|
||||
|
||||
logger.success(
|
||||
`${symbols.local} Local: ${colors.bold}${colors.underline}${localUrl}${colors.reset}`,
|
||||
);
|
||||
logger.success(
|
||||
`${symbols.network} Network: ${colors.bold}${colors.underline}${networkUrl}${colors.reset}`,
|
||||
);
|
||||
logger.info(
|
||||
`${symbols.globe} External: ${colors.dim}Accessible from any device on your network${colors.reset}`,
|
||||
);
|
||||
|
||||
// Server startup
|
||||
logger.section("Starting Server");
|
||||
|
||||
logger.info(`${symbols.lightning} Initializing development server...`);
|
||||
|
||||
// Check if serve is available
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
exec("npx serve --version", (error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warning("Installing serve package...");
|
||||
}
|
||||
|
||||
const serverProcess = spawn(
|
||||
"npx",
|
||||
["serve", "-s", distPath, "-l", port.toString()],
|
||||
{
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
},
|
||||
);
|
||||
|
||||
// Handle server output
|
||||
let serverStarted = false;
|
||||
|
||||
serverProcess.stdout.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
|
||||
if (
|
||||
!serverStarted &&
|
||||
(output.includes("Accepting connections") || output.includes("Local:"))
|
||||
) {
|
||||
serverStarted = true;
|
||||
|
||||
logger.success(`${symbols.fire} Server is running!`);
|
||||
console.log();
|
||||
|
||||
// Instructions
|
||||
logger.info(
|
||||
`${symbols.eyes} ${colors.dim}Watching for file changes...${colors.reset}`,
|
||||
);
|
||||
logger.info(
|
||||
`${symbols.stop} ${colors.dim}Press ${colors.reset}${colors.bold}Ctrl+C${colors.reset}${colors.dim} to stop the server${colors.reset}`,
|
||||
);
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
`${colors.brightGreen}${symbols.sparkles} Ready to develop! ${symbols.sparkles}${colors.reset}`,
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Filter out serve's own output to avoid duplication
|
||||
if (
|
||||
!output.includes("Local:") &&
|
||||
!output.includes("On Your Network:") &&
|
||||
!output.includes("Copied local address") &&
|
||||
!output.includes("Accepting connections") &&
|
||||
output.trim()
|
||||
) {
|
||||
console.log(`${colors.dim}[serve] ${output.trim()}${colors.reset}`);
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.stderr.on("data", (data) => {
|
||||
const error = data.toString().trim();
|
||||
if (error && !error.includes("WARNING") && !error.includes("deprecated")) {
|
||||
logger.error(`Server error: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on("error", (error) => {
|
||||
logger.error(`Failed to start server: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
logger.error(`Server exited with code ${code}`);
|
||||
process.exit(code);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = (signal) => {
|
||||
console.log(
|
||||
`\n${colors.brightYellow}${symbols.stop} Received ${signal}, shutting down gracefully...${colors.reset}`,
|
||||
);
|
||||
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
serverProcess.kill("SIGTERM");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!serverProcess.killed) {
|
||||
logger.warning("Force killing server process...");
|
||||
serverProcess.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
logger.success("Server stopped successfully");
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error(`Uncaught exception: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
shutdown("UNCAUGHT_EXCEPTION");
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
logger.error(`Unhandled rejection at: ${promise}, reason: ${reason}`);
|
||||
shutdown("UNHANDLED_REJECTION");
|
||||
});
|
||||
}
|
||||
|
||||
// Start the server
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
`${colors.brightRed}${symbols.error} Fatal error:${colors.reset}`,
|
||||
error.message,
|
||||
);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue