diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index e8028f8..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "editor.formatOnPaste": true, - "editor.formatOnSave": true, - "files.insertFinalNewline": true, - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - }, -} diff --git a/build.mjs b/build.mjs index 8fb409a..e27b872 100644 --- a/build.mjs +++ b/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; + } }); + }); } diff --git a/plugins/Last.fm/manifest.json b/plugins/Last.fm/manifest.json index 9361d91..0de6420 100644 --- a/plugins/Last.fm/manifest.json +++ b/plugins/Last.fm/manifest.json @@ -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" diff --git a/plugins/Last.fm/src/changelog.ts b/plugins/Last.fm/src/changelog.ts index ad14750..a83d620 100644 --- a/plugins/Last.fm/src/changelog.ts +++ b/plugins/Last.fm/src/changelog.ts @@ -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"; diff --git a/plugins/Last.fm/src/constants.ts b/plugins/Last.fm/src/constants.ts index e96e18d..f4e7372 100644 --- a/plugins/Last.fm/src/constants.ts +++ b/plugins/Last.fm/src/constants.ts @@ -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; diff --git a/plugins/Last.fm/src/index.tsx b/plugins/Last.fm/src/index.tsx index 499d8ac..5f751ea 100644 --- a/plugins/Last.fm/src/index.tsx +++ b/plugins/Last.fm/src/index.tsx @@ -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, }; diff --git a/plugins/Last.fm/src/manager.ts b/plugins/Last.fm/src/manager.ts index 4ed74f9..3ca14d3 100644 --- a/plugins/Last.fm/src/manager.ts +++ b/plugins/Last.fm/src/manager.ts @@ -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 diff --git a/plugins/Last.fm/src/ui/ChangelogView.tsx b/plugins/Last.fm/src/ui/ChangelogView.tsx index e8422cd..c3c3344 100644 --- a/plugins/Last.fm/src/ui/ChangelogView.tsx +++ b/plugins/Last.fm/src/ui/ChangelogView.tsx @@ -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 ( - - - - { - clipboard.setString(currentVersion); - showToast( - "Version copied to clipboard", - getAssetIDByName("toast_copy_link"), - ); - }} - > - + return ( + + + + { + clipboard.setString(currentVersion); + showToast( + "Version copied to clipboard", + getAssetIDByName("toast_copy_link"), + ); + }} + > + Copy - - - } - /> - + + + } + /> + - - {changelog.map((entry, index) => ( - - - + + {changelog.map((entry, index) => ( + + + Version {entry.version} - - - {entry.date} - - - {entry.changes.map((change, changeIndex) => ( - + + + {entry.date} + + + {entry.changes.map((change, changeIndex) => ( + • {change} - - ))} + + ))} + + ))} + - ))} - - - - ); + + ); } diff --git a/plugins/Last.fm/src/ui/pages/Debug.tsx b/plugins/Last.fm/src/ui/pages/Debug.tsx index 43aa31f..2e65635 100644 --- a/plugins/Last.fm/src/ui/pages/Debug.tsx +++ b/plugins/Last.fm/src/ui/pages/Debug.tsx @@ -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 ( - - + // Safety check for debugInfo + if (!debugInfo) { + logComponentError("Debug", "useDebugInfo returned null or undefined"); + return ( + + No debug information available - - - ); - } + + + ); + } - return ( - - - {debugInfo} - - - ); - } catch (error) { - logComponentError("Debug", error); - console.error("Debug component error:", error); - return ( - - + return ( + + + {debugInfo} + + + ); + } catch (error) { + logComponentError("Debug", error); + console.error("Debug component error:", error); + return ( + + Error loading debug information: {String(error)} - - - ); - } + + + ); + } }); diff --git a/plugins/Last.fm/src/ui/pages/Settings.tsx b/plugins/Last.fm/src/ui/pages/Settings.tsx index c1fb4de..bb6c9c3 100644 --- a/plugins/Last.fm/src/ui/pages/Settings.tsx +++ b/plugins/Last.fm/src/ui/pages/Settings.tsx @@ -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 ( - - - - - { - set("username", v); - update(); - }} - isClearable - /> - { - set("apiKey", v); - update(); - }} - secureTextEntry={true} - isClearable - /> - Linking.openURL("https://www.last.fm/api/")} - /> - - : } - onPress={testConnection} - disabled={loading} - /> - + return ( + + + + + { + set("username", v); + update(); + }} + isClearable + /> + { + set("apiKey", v); + update(); + }} + secureTextEntry={true} + isClearable + /> + Linking.openURL("https://www.last.fm/api/")} + /> + + : } + onPress={testConnection} + disabled={loading} + /> + - - - { - set("appName", v); - update(); - }} - isClearable - /> - { - 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 - /> - - + + + { + set("appName", v); + update(); + }} + isClearable + /> + { + 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 + /> + + - - { - set("listeningTo", value); - update(); - }} - /> - { - set("showTimestamp", value); - update(); - }} - /> - { - set("ignoreSpotify", value); - update(); - }} - /> - { - set("verboseLogging", value); - update(); - }} - /> - + + { + set("listeningTo", value); + update(); + }} + /> + { + set("showTimestamp", value); + update(); + }} + /> + { + set("ignoreSpotify", value); + update(); + }} + /> + { + set("verboseLogging", value); + update(); + }} + /> + - - - - + + + + - - - } - onPress={() => { - Linking.openURL(Constants.GITHUB_COMMITS_URL); - }} - /> - + + + } + onPress={() => { + Linking.openURL(Constants.GITHUB_COMMITS_URL); + }} + /> + - {/* Changelog Section */} - - {changelog.map((entry, index) => ( - - ))} - - - - ); + {/* Changelog Section */} + + {changelog.map((entry, index) => ( + + ))} + + + + ); } diff --git a/plugins/Last.fm/src/utils/activity.ts b/plugins/Last.fm/src/utils/activity.ts index 96ed16d..f0f7964 100644 --- a/plugins/Last.fm/src/utils/activity.ts +++ b/plugins/Last.fm/src/utils/activity.ts @@ -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 { - 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 []; + } } diff --git a/plugins/Last.fm/src/utils/debug.ts b/plugins/Last.fm/src/utils/debug.ts index 9646054..d64fab0 100644 --- a/plugins/Last.fm/src/utils/debug.ts +++ b/plugins/Last.fm/src/utils/debug.ts @@ -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, + ); + } } diff --git a/plugins/Last.fm/src/utils/lastfm.ts b/plugins/Last.fm/src/utils/lastfm.ts index 5403e66..fb7d621 100644 --- a/plugins/Last.fm/src/utils/lastfm.ts +++ b/plugins/Last.fm/src/utils/lastfm.ts @@ -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 { - 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, - ): Promise { - 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 { + 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, + ): Promise { + 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 { + 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 { - 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 { - return lastfmClient.handleAlbumCover(cover); + return lastfmClient.handleAlbumCover(cover); } diff --git a/plugins/defs.d.ts b/plugins/defs.d.ts index 1e93627..c475fcf 100644 --- a/plugins/defs.d.ts +++ b/plugins/defs.d.ts @@ -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; + validateCredentials(): Promise; + getServiceName(): string; +} + +export interface ServiceConfig { + name: string; + baseUrl: string; + requiresApiKey: boolean; + requiresToken: boolean; } diff --git a/serve.mjs b/serve.mjs index 493dd9c..52f0854 100755 --- a/serve.mjs +++ b/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); });