update showing errors as toast notifications

ofc zed thought why not use formatting again <3
This commit is contained in:
kmmiio99o 2026-01-02 19:26:46 +01:00
parent a5686e815c
commit dbcc2a4338
No known key found for this signature in database
GPG key ID: 163FE39F7772D2EC
3 changed files with 382 additions and 382 deletions

View file

@ -1,9 +1,5 @@
import { plugin } from "@vendetta";
import { FluxDispatcher } from "@vendetta/metro/common";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { showToast } from "@vendetta/ui/toasts";
import { React } from "@vendetta/metro/common";
import { View } from "react-native";
import { Forms } from "@vendetta/ui/components";
import { LFMSettings } from "../../defs";
@ -17,10 +13,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 +27,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 +47,66 @@ 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;
console.log("[Last.fm] Successfully connected to Last.fm");
} 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) {
console.log(
`[Last.fm] Retrying connection... (attempt ${connectionAttempts})`,
);
setTimeout(tryInitialize, RECONNECT_DELAY);
} else {
console.error(
"[Last.fm] Failed to connect to Last.fm after multiple attempts",
);
}
}
}
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 (!currentSettings.username || !currentSettings.apiKey) {
console.log(
"[Last.fm] Please configure Last.fm username and API key in settings",
);
return;
}
if (UserStore.getCurrentUser()) {
tryInitialize();
} else {
const waitForUser = () => {
if (UserStore.getCurrentUser()) {
tryInitialize();
} else {
const waitForUser = () => {
if (UserStore.getCurrentUser()) {
tryInitialize();
FluxDispatcher.unsubscribe("CONNECTION_OPEN", waitForUser);
}
};
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,
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,
};

View file

@ -1,6 +1,3 @@
import { getAssetIDByName } from "@vendetta/ui/assets";
import { showToast } from "@vendetta/ui/toasts";
import { currentSettings, pluginState } from ".";
import { Activity } from "../../defs";
import Constants from "./constants";
@ -9,9 +6,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 +20,211 @@ 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 = 0;
private isReconnecting = 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();
}
return PluginManager.instance;
public static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
}
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;
}
setDebugInfo("isSpotifyIgnored", false);
}
const lastTrack = await lastfmClient.fetchLatestScrobble();
setDebugInfo("lastTrack", 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);
}
private async updateActivity() {
if (pluginState.pluginStopped) {
verboseLog("Plugin is stopped, skipping update");
this.stopUpdates();
return;
}
private handleError(error: Error) {
this.consecutiveFailures++;
setDebugInfo("lastError", error);
verboseLog("Fetching last track...");
if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) {
verboseLog(
`Failed ${this.consecutiveFailures} times, initiating reconnection...`,
);
this.startReconnection();
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;
}
}
setDebugInfo("isSpotifyIgnored", false);
}
private startReconnection() {
if (this.isReconnecting) return;
const lastTrack = await lastfmClient.fetchLatestScrobble();
setDebugInfo("lastTrack", lastTrack);
this.isReconnecting = true;
this.stopUpdates();
if (!lastTrack.nowPlaying) {
verboseLog("Last track is not currently playing");
clearActivity();
return;
}
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);
}
if (pluginState.lastTrackUrl === lastTrack.url) {
verboseLog("Track hasn't changed");
return;
}
private stopReconnection() {
if (this.reconnectTimer) {
clearInterval(this.reconnectTimer);
this.reconnectTimer = undefined;
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] || "",
);
}
this.isReconnecting = false;
this.consecutiveFailures = 0;
}
}
/**
// 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);
}
}
private handleError(error: Error) {
this.consecutiveFailures++;
setDebugInfo("lastError", error);
if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) {
console.log(
`[Last.fm] Failed ${this.consecutiveFailures} times, initiating reconnection...`,
);
this.startReconnection();
}
}
private startReconnection() {
if (this.isReconnecting) return;
this.isReconnecting = true;
this.stopUpdates();
console.log("[Last.fm] Starting reconnection process");
this.reconnectTimer = setInterval(() => {
console.log("[Last.fm] Attempting to reconnect...");
this.initialize()
.then(() => {
console.log("[Last.fm] Reconnection successful");
this.stopReconnection();
})
.catch((error) => {
console.log("[Last.fm] Reconnection failed:", error);
});
}, Constants.RETRY_DELAY);
}
private stopReconnection() {
if (this.reconnectTimer) {
clearInterval(this.reconnectTimer);
this.reconnectTimer = undefined;
}
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");
}
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`);
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);
console.log(`[Last.fm] 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();
console.log("[Last.fm] Plugin stopped");
}
}
// Export singleton instance methods

View file

@ -2,8 +2,6 @@ import { currentSettings } from "..";
import { Track } from "../../../defs";
import Constants from "../constants";
import { setDebugInfo } from "./debug";
import { showToast } from "@vendetta/ui/toasts";
import { getAssetIDByName } from "@vendetta/ui/assets";
interface LastFMError {
error: number;
@ -44,161 +42,165 @@ interface LastFMTrack {
}
class LastFMClient {
private static instance: LastFMClient;
private retryCount = 0;
private lastError = 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;
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 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;
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 errorMessages: { [key: number]: string } = {
2: "Invalid API key",
11: "Service temporarily unavailable",
16: "Service temporarily unavailable",
29: "Rate limit exceeded",
};
const message =
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}`);
// Log instead of showing toast
console.log(`[Last.fm] API Error ${error.error}: ${message}`);
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);
}
private async makeRequest(
params: Record<string, string>,
): Promise<LastFMResponse> {
const queryParams = new URLSearchParams({
...params,
api_key: currentSettings.apiKey,
format: "json",
}).toString();
return data;
}
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<
public async fetchLatestScrobble(): Promise<
Track & { from: number; to: number | null }
> {
try {
this.validateSettings();
> {
try {
this.validateSettings();
const data = await this.makeRequest({
method: "user.getrecenttracks",
user: currentSettings.username,
limit: "1",
extended: "1",
});
const data = await this.makeRequest({
method: "user.getrecenttracks",
user: currentSettings.username,
limit: "1",
extended: "1",
});
const lastTrack = data?.recenttracks?.track?.[0];
setDebugInfo("lastAPIResponse", lastTrack);
const lastTrack = data?.recenttracks?.track?.[0];
setDebugInfo("lastAPIResponse", lastTrack);
if (!lastTrack) {
throw new Error("No tracks found");
}
if (!lastTrack) {
console.log("[Last.fm] No tracks found for user");
throw new Error("No tracks found");
}
// Reset retry count on successful request
this.retryCount = 0;
// 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);
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,
});
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();
const duration = parseInt(trackInfo?.track?.duration);
if (duration > 0) {
to = from + Math.floor(duration / 1000);
}
}
} catch (err) {
console.warn("[Last.fm] Failed to fetch track duration", err);
}
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 {
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;
console.error("[Last.fm] Max retry attempts reached");
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 +209,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 +218,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);
}