This commit is contained in:
kmmiio99o 2025-09-13 22:10:01 +02:00
parent f4be7b2743
commit a6156a928e
6 changed files with 677 additions and 435 deletions

View file

@ -1,15 +1,32 @@
const Constants = {
DEFAULT_APP_NAME: "Music",
DEFAULT_TIME_INTERVAL: 5,
/** The application ID of the Discord's developer app */
APPLICATION_ID: "1054951789318909972",
/** Last.fm api key */
LFM_API_KEY: "014ffe8a614370f000d85d95ec30e1be",
/** These are the default album covers that are used by Last.fm */
LFM_DEFAULT_COVER_HASHES: [
"2a96cbd8b46e442fc41c2b86b821562f",
"c6f59c1e5e7240a4c0d427abd71f3dbb",
]
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: "https://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,
},
} as const;
export default Constants;

View file

@ -1,121 +1,106 @@
import { plugin } from "@vendetta";
import { FluxDispatcher, React } from "@vendetta/metro/common";
import { FluxDispatcher } from "@vendetta/metro/common";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { showToast } from "@vendetta/ui/toasts";
import { lazy, Suspense } from "react";
import { Activity, LFMSettings } from "../../defs";
import { flush, initialize } from "./manager";
import { lazy } from "react";
import { LFMSettings } from "../../defs";
import Constants from "./constants";
import { initialize, stop } from "./manager";
import { UserStore } from "./modules";
import { Forms } from "@vendetta/ui/components";
import { ScrollView } from "react-native";
const { FormText } = Forms;
export const pluginState = {
pluginStopped: false,
lastActivity: undefined,
updateInterval: undefined,
lastTrackUrl: undefined,
} as {
pluginStopped: boolean;
lastActivity?: any;
updateInterval?: NodeJS.Timer;
lastTrackUrl?: string;
};
// Error boundary component for settings
function SettingsErrorBoundary({ children }: { children: React.ReactNode }) {
const [hasError, setHasError] = React.useState(false);
React.useEffect(() => {
const errorHandler = (error: any) => {
console.error("Settings component error:", error);
setHasError(true);
};
return () => {
setHasError(false);
};
}, []);
if (hasError) {
return (
<ScrollView style={{ padding: 12 }}>
<FormText style={{ color: "#FF0000" }}>
Failed to load Last.fm settings. Please try reloading the plugin.
</FormText>
</ScrollView>
);
}
return (
<Suspense fallback={
<ScrollView style={{ padding: 12 }}>
<FormText>Loading Last.fm settings...</FormText>
</ScrollView>
}>
{children}
</Suspense>
);
// Initialize default settings
const defaultSettings: LFMSettings = Constants.DEFAULT_SETTINGS;
for (const key in defaultSettings) {
plugin.storage[key] =
plugin.storage[key] ?? defaultSettings[key as keyof typeof defaultSettings];
}
// Lazy-loaded Settings component with error handling
const LazySettings = lazy(() => {
try {
return import("./ui/pages/Settings").catch((error) => {
console.error("Failed to load Settings component:", error);
// Return a fallback component
return {
default: () => (
<ScrollView style={{ padding: 12 }}>
<FormText style={{ color: "#FF0000" }}>
Error: Could not load Last.fm settings component.
{"\n"}Please check console for details.
</FormText>
</ScrollView>
)
};
});
} catch (error) {
console.error("Critical error in lazy loading:", error);
// Return fallback component for critical errors
return Promise.resolve({
default: () => (
<ScrollView style={{ padding: 12 }}>
<FormText style={{ color: "#FF0000" }}>
Critical error loading settings. Please restart the app.
</FormText>
</ScrollView>
)
});
}
export const currentSettings = new Proxy(plugin.storage, {
get(target, prop) {
return target[prop];
},
set(target, prop, value) {
target[prop] = value;
return true;
},
});
// Wrapped settings component
function WrappedSettings() {
return (
<SettingsErrorBoundary>
<LazySettings />
</SettingsErrorBoundary>
);
const LazySettings = lazy(() => import("./ui/pages/Settings"));
// Connection status tracking
let connectionAttempts = 0;
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++;
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 const pluginState = {} as {
pluginStopped?: boolean,
lastActivity?: Activity,
updateInterval?: NodeJS.Timer,
lastTrackUrl?: string,
};
plugin.storage.ignoreSpotify ??= true;
export const currentSettings = { ...plugin.storage } as LFMSettings;
export default {
settings: WrappedSettings,
onLoad() {
pluginState.pluginStopped = false;
settings: LazySettings,
onLoad() {
pluginState.pluginStopped = false;
if (UserStore.getCurrentUser()) {
initialize().catch(console.error);
} else {
const callback = () => {
initialize().catch(console.error);
FluxDispatcher.unsubscribe(callback);
};
FluxDispatcher.subscribe("CONNECTION_OPEN", callback);
}
},
onUnload() {
pluginState.pluginStopped = true;
flush();
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);
}
};
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();
}
},
};

View file

@ -7,157 +7,217 @@ import Constants from "./constants";
import { SelfPresenceStore } from "./modules";
import { clearActivity, fetchAsset, sendRequest } from "./utils/activity";
import { setDebugInfo } from "./utils/debug";
import { fetchLatestScrobble } from "./utils/lastfm";
import { lastfmClient } from "./utils/lastfm";
enum ActivityType {
PLAYING = 0,
STREAMING = 1,
LISTENING = 2,
COMPETING = 5
PLAYING = 0,
STREAMING = 1,
LISTENING = 2,
COMPETING = 5,
}
const verboseLog = (...message: any) => currentSettings.verboseLogging && console.log(...message);
const verboseLog = (...message: any[]) =>
currentSettings.verboseLogging && console.log("[Last.fm]", ...message);
/**
* Fetches the current scrobble and sets the activity.
* This function is actively called by the updateInterval.
*/
async function update() {
class PluginManager {
private static instance: PluginManager;
private updateTimer?: NodeJS.Timer;
private reconnectTimer?: NodeJS.Timer;
private consecutiveFailures: number = 0;
private isReconnecting: boolean = false;
private constructor() {}
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 unloaded, aborting update()...");
flush();
return;
verboseLog("Plugin is stopped, skipping update");
this.stopUpdates();
return;
}
verboseLog("--> Fetching last track...");
if (!currentSettings.username) {
showToast("Last.fm username is not set!", getAssetIDByName("Small"));
flush(); // Flush as we always need reinitialization
throw new Error("Username is not set");
}
if (currentSettings.ignoreSpotify) {
const spotifyActivity = SelfPresenceStore.findActivity(act => act.sync_id);
if (spotifyActivity) {
verboseLog("--> Spotify is currently playing, aborting...");
setDebugInfo("isSpotifyIgnored", true);
clearActivity();
return;
} else setDebugInfo("isSpotifyIgnored", false);
} else {
setDebugInfo("isSpotifyIgnored", undefined);
}
const lastTrack = await fetchLatestScrobble().catch(async (err) => {
verboseLog("--> An error occurred while fetching the last track, aborting...");
clearActivity();
throw err;
});
setDebugInfo("lastTrack", lastTrack);
if (!lastTrack.nowPlaying) {
verboseLog("--> Last track is not currently playing, aborting...");
clearActivity();
return;
}
verboseLog("--> Track fetched!");
if (pluginState.lastTrackUrl === lastTrack.url) {
verboseLog("--> Last track is the same as the previous one, aborting...");
return;
}
const activity = {
name: currentSettings.appName || Constants.DEFAULT_APP_NAME,
flags: 0,
type: currentSettings.listeningTo ? ActivityType.LISTENING : ActivityType.PLAYING,
details: lastTrack.name,
state: `${lastTrack.artist}`,
status_display_type: 1,
application_id: Constants.APPLICATION_ID,
} as Activity;
pluginState.lastTrackUrl = lastTrack.url;
if (activity.name.includes("{{")) {
for (const key in lastTrack) {
activity.name = activity.name.replace(`{{${key}}}`, lastTrack[key]);
}
}
// Set timestamps
if (currentSettings.showTimestamp) {
activity.timestamps = {
start: Date.now()| 0
};
}
if (lastTrack.album) {
const asset = await fetchAsset([lastTrack.albumArt]);
activity.assets = {
large_image: asset[0],
large_text: `${lastTrack.album}`
};
}
verboseLog("--> Setting activity...");
setDebugInfo("lastActivity", activity);
verboseLog(activity);
verboseLog("Fetching last track...");
try {
sendRequest(activity);
} catch (err) {
verboseLog("--> An error occurred while setting the activity");
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();
throw err;
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: 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
if (currentSettings.showTimestamp) {
activity.timestamps = {
start: Date.now() | 0,
};
}
// Handle album art
if (lastTrack.album) {
const asset = await fetchAsset([lastTrack.albumArt]);
if (asset[0]) {
activity.assets = {
large_image: asset[0],
large_text: lastTrack.album,
};
}
}
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);
}
}
verboseLog("--> Successfully set activity!");
}
private handleError(error: Error) {
this.consecutiveFailures++;
setDebugInfo("lastError", error);
/** Stops and reset everything, can be started again with `initialize()` */
export function flush(isClearActivity = false) {
pluginState.lastActivity = null;
pluginState.lastTrackUrl = null;
if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) {
verboseLog(
`Failed ${this.consecutiveFailures} times, initiating reconnection...`,
);
this.startReconnection();
}
}
pluginState.updateInterval && clearInterval(pluginState.updateInterval);
!isClearActivity && clearActivity();
}
private startReconnection() {
if (this.isReconnecting) return;
/** Initializes the plugin */
export async function initialize() {
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;
}
this.isReconnecting = false;
this.consecutiveFailures = 0;
}
/**
* Stops all update and reconnection timers
*/
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 already stopped!");
throw new Error("Plugin is stopped");
}
flush();
this.stopUpdates();
await this.updateActivity();
let tries = 0;
await update().catch((err) => {
console.error(err);
tries++;
});
// Periodically fetches the current scrobble and sets the activity
pluginState.updateInterval = setInterval(
() => update()
.then(() => {
tries = 0;
})
.catch(err => {
console.error(err);
if (++tries > 3) {
console.error("Failed to fetch/set activity 3 times, aborting...");
flush();
}
}),
(Number(currentSettings.timeInterval) || Constants.DEFAULT_TIME_INTERVAL) * 1000
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();
}
}
// Export singleton instance methods
const manager = PluginManager.getInstance();
export const initialize = () => manager.initialize();
export const stop = () => manager.stop();

View file

@ -1,162 +1,192 @@
import { NavigationNative, React } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { plugin } from "@vendetta";
import { Forms } from "@vendetta/ui/components";
import { useProxy } from "@vendetta/storage";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import { showToast } from "@vendetta/ui/toasts";
import { React } from "@vendetta/metro/common";
import { ScrollView } from "react-native";
import { lazy, useEffect } from "react";
import { ScrollView, Text, TouchableOpacity } from "react-native";
import { currentSettings } from "../..";
import { LFMSettings } from "../../../../defs";
import { currentSettings, pluginState } from "../..";
import Constants from "../../constants";
import { initialize } from "../../manager";
import {
logComponentMount,
logComponentError,
logNavigationError,
incrementSettingsLoad,
} from "../../utils/debug";
import { lastfmClient } from "../../utils/lastfm";
const { FormRow, FormInput, FormDivider, FormSwitchRow, FormText, FormIcon } =
const { FormInput, FormRow, FormSwitch, FormSection, FormDivider, FormText } =
Forms;
function UpdateButton() {
async function onPressCallback() {
try {
incrementSettingsLoad();
for (const key in storage) {
if (storage[key] != null) {
currentSettings[key] = storage[key];
}
}
export default function Settings() {
useProxy(currentSettings);
const [loading, setLoading] = React.useState(false);
const saveSettings = (key: string, value: any) => {
currentSettings[key] = value;
plugin.storage[key] = value;
};
const testConnection = async () => {
if (!currentSettings.username || !currentSettings.apiKey) {
showToast(
"Please enter both username and API key",
getAssetIDByName("Small"),
);
return;
}
setLoading(true);
try {
await lastfmClient.fetchLatestScrobble();
showToast("Connection successful!", getAssetIDByName("Check"));
// Reinitialize the plugin with new settings
await initialize();
showToast("Settings updated!", getAssetIDByName("Check"));
} catch (error) {
logComponentError("UpdateButton", error);
showToast("Failed to update settings", getAssetIDByName("Small"));
showToast(
`Connection failed: ${error.message}`,
getAssetIDByName("Small"),
);
} finally {
setLoading(false);
}
}
};
return (
<TouchableOpacity onPress={onPressCallback}>
<FormText style={{ marginRight: 12 }}>UPDATE</FormText>
</TouchableOpacity>
);
}
<ScrollView style={{ flex: 1 }}>
<FormSection title="Account Settings">
<FormInput
title="Last.fm Username"
placeholder="Enter your Last.fm username"
value={currentSettings.username}
onChange={(value: string) => saveSettings("username", value)}
/>
<FormDivider />
<FormInput
title="API Key"
placeholder="Enter your Last.fm API key"
value={currentSettings.apiKey}
onChange={(value: string) => saveSettings("apiKey", value)}
secureTextEntry={true}
/>
<FormText style={{ padding: 8, opacity: 0.5 }}>
Get your API key at: https://www.last.fm/api/account/create
</FormText>
<FormRow
label="Test Connection"
leading={<FormRow.Icon source={getAssetIDByName("ic_connection")} />}
trailing={loading ? <FormRow.Arrow loading /> : <FormRow.Arrow />}
onPress={testConnection}
disabled={loading}
/>
</FormSection>
export default React.memo(function Settings() {
const settings = useProxy(storage) as LFMSettings;
const navigation = NavigationNative.useNavigation();
<FormSection title="Display Settings">
<FormInput
title="App Name"
placeholder={Constants.DEFAULT_SETTINGS.appName}
value={currentSettings.appName}
onChange={(value: string) => saveSettings("appName", value)}
/>
<FormDivider />
<FormInput
title="Update Interval (seconds)"
placeholder={String(Constants.DEFAULT_SETTINGS.timeInterval)}
value={String(currentSettings.timeInterval)}
onChange={(value: string) => {
const interval = Number(value);
if (interval >= Constants.MIN_UPDATE_INTERVAL) {
saveSettings("timeInterval", interval);
} else {
showToast(
`Minimum interval is ${Constants.MIN_UPDATE_INTERVAL} seconds`,
getAssetIDByName("Small"),
);
}
}}
keyboardType="numeric"
/>
</FormSection>
// Log component mount
useEffect(() => {
logComponentMount("Settings");
}, []);
// Safety check to ensure settings is properly initialized
if (!settings) {
logComponentError("Settings", "Settings proxy is null or undefined");
return (
<ScrollView>
<FormText>Loading...</FormText>
</ScrollView>
);
}
useEffect(() => {
try {
navigation.setOptions({
title: "Last.fm Configuration",
headerRight: () => <UpdateButton />,
});
} catch (error) {
logComponentError("Settings-Navigation", error);
}
}, []);
return (
<ScrollView>
<FormInput
value={settings.appName || undefined}
onChangeText={(value: string) => (settings.appName = value.trim())}
title="Discord Application Name"
placeholder={Constants.DEFAULT_APP_NAME}
returnKeyType="done"
/>
<FormDivider />
<FormInput
required
value={settings.username || undefined}
onChangeText={(value: string) => (settings.username = value.trim())}
title="Last.fm username"
helpText={
!settings.username && (
<Text style={{ color: "#FF0000" }}>
{"This field is required!"}
</Text>
)
}
placeholder="wumpus123"
returnKeyType="done"
/>
<FormDivider />
<FormInput
value={settings.timeInterval}
onChangeText={(value: string) => (settings.timeInterval = value)}
title="Update interval (in seconds)"
placeholder={Constants.DEFAULT_TIME_INTERVAL.toString()}
keyboardType="numeric"
returnKeyType="done"
/>
<FormDivider />
<FormSwitchRow
label="Show time elapsed"
subLabel="Show the time elapsed since the song started playing"
leading={<FormIcon source={getAssetIDByName("clock")} />}
value={settings.showTimestamp}
onValueChange={(value: boolean) => (settings.showTimestamp = value)}
/>
<FormDivider />
<FormSwitchRow
label="Set status as listening"
subLabel='Set your status as "Listening to" instead of "Playing"'
leading={<FormIcon source={getAssetIDByName("ic_headset_neutral")} />}
value={settings.listeningTo}
onValueChange={(value: boolean) => (settings.listeningTo = value)}
/>
<FormDivider />
<FormSwitchRow
label="Hide when Spotify is running"
subLabel="Hide the status when a Spotify activity is detected"
leading={
<FormIcon
source={getAssetIDByName("img_account_sync_spotify_light_and_dark")}
/>
}
value={settings.ignoreSpotify}
onValueChange={(value: boolean) => (settings.ignoreSpotify = value)}
/>
<FormDivider />
<FormRow
label="Debug"
subLabel="View debug information"
leading={<FormIcon source={getAssetIDByName("debug")} />}
trailing={FormRow.Arrow}
onPress={() => {
try {
navigation.push("VendettaCustomPage", {
title: "Debug",
render: lazy(() => import("./Debug")),
});
} catch (error) {
logNavigationError(error);
console.error("Failed to navigate to Debug page:", error);
showToast("Failed to open debug page", getAssetIDByName("Small"));
<FormSection title="Options">
<FormRow
label="Show 'Listening to' instead of 'Playing'"
leading={<FormRow.Icon source={getAssetIDByName("ic_music")} />}
trailing={
<FormSwitch
value={
currentSettings.listeningTo ??
Constants.DEFAULT_SETTINGS.listeningTo
}
onValueChange={(value: boolean) =>
saveSettings("listeningTo", value)
}
/>
}
}}
/>
/>
<FormDivider />
<FormRow
label="Show Timestamp"
leading={<FormRow.Icon source={getAssetIDByName("ic_timeline")} />}
trailing={
<FormSwitch
value={
currentSettings.showTimestamp ??
Constants.DEFAULT_SETTINGS.showTimestamp
}
onValueChange={(value: boolean) =>
saveSettings("showTimestamp", value)
}
/>
}
/>
<FormDivider />
<FormRow
label="Ignore when Spotify is playing"
leading={
<FormRow.Icon source={getAssetIDByName("ic_spotify_white_24px")} />
}
trailing={
<FormSwitch
value={
currentSettings.ignoreSpotify ??
Constants.DEFAULT_SETTINGS.ignoreSpotify
}
onValueChange={(value: boolean) =>
saveSettings("ignoreSpotify", value)
}
/>
}
/>
<FormDivider />
<FormRow
label="Verbose Logging"
leading={
<FormRow.Icon source={getAssetIDByName("ic_message_copy")} />
}
trailing={
<FormSwitch
value={
currentSettings.verboseLogging ??
Constants.DEFAULT_SETTINGS.verboseLogging
}
onValueChange={(value: boolean) =>
saveSettings("verboseLogging", value)
}
/>
}
/>
</FormSection>
<FormSection title="Plugin Status">
<FormText style={{ padding: 8 }}>
Status: {pluginState.pluginStopped ? "Stopped" : "Running"}
{"\n"}
Last Update: {pluginState.lastTrackUrl ? "Success" : "No track data"}
</FormText>
</FormSection>
<FormText style={{ padding: 16, opacity: 0.5, textAlign: "center" }}>
Version 2.0.0
</FormText>
</ScrollView>
);
});
}

View file

@ -3,34 +3,42 @@ import { FluxDispatcher } from "@vendetta/metro/common";
import { pluginState } from "..";
import { Activity } from "../../../defs";
import Constants from "../constants";
import { flush } from "../manager";
import { stop } from "../manager";
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) {
if (pluginState.pluginStopped) {
flush(true);
activity = null;
}
export function sendRequest(activity: 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): Promise<string[]> {
if (!asset) return [];
export async function fetchAsset(
asset: string[],
appId: string = Constants.APPLICATION_ID,
): Promise<string[]> {
if (!asset?.length) return [];
try {
return await AssetManager.fetchAssetIds(appId, asset);
} catch (error) {
console.error("[Last.fm] Failed to fetch assets:", error);
return [];
}
}

View file

@ -2,48 +2,190 @@ 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";
/** Fetches the latest user's scrobble */
export async function fetchLatestScrobble(): Promise<Track> {
const params = new URLSearchParams({
"method": "user.getrecenttracks",
"user": currentSettings.username,
"api_key": Constants.LFM_API_KEY,
"format": "json",
"limit": "1",
"extended": "1"
interface LastFMError {
error: number;
message: string;
}
interface LastFMResponse {
recenttracks?: {
track: LastFMTrack[];
};
error?: number;
message?: string;
}
interface LastFMTrack {
name: string;
artist: {
name: string;
};
album: {
"#text": string;
};
image?: {
size: string;
"#text": string;
}[];
url: string;
date?: {
"#text": string;
};
"@attr"?: {
nowplaying: boolean;
};
loved: string;
}
class LastFMClient {
private static instance: LastFMClient;
private retryCount: number = 0;
private lastError: number = 0;
private constructor() {}
public static getInstance(): LastFMClient {
if (!LastFMClient.instance) {
LastFMClient.instance = new LastFMClient();
}
return LastFMClient.instance;
}
private validateSettings(): void {
if (!currentSettings.username) {
throw new Error("Last.fm username is not set");
}
if (!currentSettings.apiKey) {
throw new Error("Last.fm API key is not set");
}
}
private async handleError(error: LastFMError): Promise<never> {
this.lastError = error.error;
const errorMessages: { [key: number]: string } = {
2: "Invalid API key",
6: "User not found",
8: "Operation failed - Backend error",
11: "Service temporarily unavailable",
16: "Service temporarily unavailable",
29: "Rate limit exceeded",
};
const message =
errorMessages[error.error] || error.message || "Unknown error";
showToast(`Last.fm Error: ${message}`, getAssetIDByName("Small"));
throw new Error(`Last.fm API Error ${error.error}: ${message}`);
}
private async makeRequest(
params: Record<string, string>,
): Promise<LastFMResponse> {
const queryParams = new URLSearchParams({
...params,
api_key: currentSettings.apiKey,
format: "json",
}).toString();
const result = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`);
if (!result.ok) throw new Error(`Failed to fetch the latest scrobble: ${result.statusText}`);
const response = await fetch(
`${Constants.LFM_API_BASE_URL}?${queryParams}`,
);
const data: LastFMResponse = await response.json();
const info = await result.json();
if (!response.ok || data.error) {
await this.handleError(data as LastFMError);
}
const lastTrack = info?.recenttracks?.track?.[0];
setDebugInfo("lastAPIResponse", lastTrack);
return data;
}
if (!lastTrack) throw info;
public async fetchLatestScrobble(): Promise<Track> {
try {
this.validateSettings();
return {
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;
return {
name: lastTrack.name,
artist: lastTrack.artist.name,
album: lastTrack.album["#text"],
albumArt: await handleAlbumCover(lastTrack.image?.find((x: any) => x.size === "large")?.["#text"]),
albumArt: await this.handleAlbumCover(
lastTrack.image?.find((x) => x.size === "large")?.["#text"],
),
url: lastTrack.url,
date: lastTrack.date?.["#text"] ?? "now",
nowPlaying: Boolean(lastTrack["@attr"]?.nowplaying),
loved: lastTrack.loved === "1",
} as Track;
} as Track;
} catch (error) {
// Increment retry count and throw if we've exceeded max retries
this.retryCount++;
if (this.retryCount > Constants.MAX_RETRY_ATTEMPTS) {
this.retryCount = 0;
throw error;
}
// Wait before retrying
await new Promise((resolve) =>
setTimeout(resolve, Constants.RETRY_DELAY),
);
return this.fetchLatestScrobble();
}
}
private async handleAlbumCover(cover?: string): Promise<string | null> {
if (!cover) return null;
// If the cover is a default one, return null
if (
Constants.LFM_DEFAULT_COVER_HASHES.some((hash) => cover.includes(hash))
) {
return null;
}
return cover;
}
public getLastError(): number {
return this.lastError;
}
public resetRetryCount(): void {
this.retryCount = 0;
}
}
/**
* Currently ditches the default album covers
* @param cover The album cover given by Last.fm
*/
export async function handleAlbumCover(cover: string): Promise<string> {
// If the cover is a default one, return null (remove the cover)
if (Constants.LFM_DEFAULT_COVER_HASHES.some(x => cover.includes(x))) {
return null;
}
return cover;
// Export a singleton instance
export const lastfmClient = LastFMClient.getInstance();
/** Fetches the latest user's scrobble */
export async function fetchLatestScrobble(): Promise<Track> {
return lastfmClient.fetchLatestScrobble();
}
/**
* Handles album cover processing
* @param cover The album cover given by Last.fm
*/
export async function handleAlbumCover(cover: string): Promise<string> {
return lastfmClient.handleAlbumCover(cover);
}