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 = { const Constants = {
DEFAULT_APP_NAME: "Music", DEFAULT_APP_NAME: "Music",
DEFAULT_TIME_INTERVAL: 5, DEFAULT_TIME_INTERVAL: 5,
/** The application ID of the Discord's developer app */ /** The application ID of the Discord's developer app */
APPLICATION_ID: "1054951789318909972", APPLICATION_ID: "1054951789318909972",
/** Last.fm api key */ /** The minimum update interval in seconds to prevent rate limiting */
LFM_API_KEY: "014ffe8a614370f000d85d95ec30e1be", MIN_UPDATE_INTERVAL: 3,
/** These are the default album covers that are used by Last.fm */ /** Maximum retry attempts for API calls */
LFM_DEFAULT_COVER_HASHES: [ MAX_RETRY_ATTEMPTS: 3,
"2a96cbd8b46e442fc41c2b86b821562f", /** Retry delay in milliseconds */
"c6f59c1e5e7240a4c0d427abd71f3dbb", 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; } as const;
export default Constants; export default Constants;

View file

@ -1,121 +1,106 @@
import { plugin } from "@vendetta"; 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 { lazy } from "react";
import { Activity, LFMSettings } from "../../defs"; import { LFMSettings } from "../../defs";
import { flush, initialize } from "./manager"; import Constants from "./constants";
import { initialize, stop } from "./manager";
import { UserStore } from "./modules"; 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 // Initialize default settings
function SettingsErrorBoundary({ children }: { children: React.ReactNode }) { const defaultSettings: LFMSettings = Constants.DEFAULT_SETTINGS;
const [hasError, setHasError] = React.useState(false); for (const key in defaultSettings) {
plugin.storage[key] =
React.useEffect(() => { plugin.storage[key] ?? defaultSettings[key as keyof typeof defaultSettings];
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>
);
} }
// Lazy-loaded Settings component with error handling export const currentSettings = new Proxy(plugin.storage, {
const LazySettings = lazy(() => { get(target, prop) {
try { return target[prop];
return import("./ui/pages/Settings").catch((error) => { },
console.error("Failed to load Settings component:", error); set(target, prop, value) {
// Return a fallback component target[prop] = value;
return { return true;
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>
)
});
}
}); });
// Wrapped settings component const LazySettings = lazy(() => import("./ui/pages/Settings"));
function WrappedSettings() {
return ( // Connection status tracking
<SettingsErrorBoundary> let connectionAttempts = 0;
<LazySettings /> const MAX_CONNECTION_ATTEMPTS = 3;
</SettingsErrorBoundary> 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 { export default {
settings: WrappedSettings, settings: LazySettings,
onLoad() { onLoad() {
pluginState.pluginStopped = false; pluginState.pluginStopped = false;
if (UserStore.getCurrentUser()) { if (!currentSettings.username || !currentSettings.apiKey) {
initialize().catch(console.error); showToast(
} else { "Please configure Last.fm username and API key in settings",
const callback = () => { getAssetIDByName("ic_warning"),
initialize().catch(console.error); );
FluxDispatcher.unsubscribe(callback); return;
};
FluxDispatcher.subscribe("CONNECTION_OPEN", callback);
}
},
onUnload() {
pluginState.pluginStopped = true;
flush();
} }
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 { SelfPresenceStore } from "./modules";
import { clearActivity, fetchAsset, sendRequest } from "./utils/activity"; import { clearActivity, fetchAsset, sendRequest } from "./utils/activity";
import { setDebugInfo } from "./utils/debug"; import { setDebugInfo } from "./utils/debug";
import { fetchLatestScrobble } from "./utils/lastfm"; import { lastfmClient } from "./utils/lastfm";
enum ActivityType { enum ActivityType {
PLAYING = 0, PLAYING = 0,
STREAMING = 1, STREAMING = 1,
LISTENING = 2, LISTENING = 2,
COMPETING = 5 COMPETING = 5,
} }
const verboseLog = (...message: any) => currentSettings.verboseLogging && console.log(...message); const verboseLog = (...message: any[]) =>
currentSettings.verboseLogging && console.log("[Last.fm]", ...message);
/** class PluginManager {
* Fetches the current scrobble and sets the activity. private static instance: PluginManager;
* This function is actively called by the updateInterval. private updateTimer?: NodeJS.Timer;
*/ private reconnectTimer?: NodeJS.Timer;
async function update() { 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) { if (pluginState.pluginStopped) {
verboseLog("--> Plugin is unloaded, aborting update()..."); verboseLog("Plugin is stopped, skipping update");
flush(); this.stopUpdates();
return; return;
} }
verboseLog("--> Fetching last track..."); 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);
try { try {
sendRequest(activity); if (!currentSettings.username || !currentSettings.apiKey) {
} catch (err) { throw new Error("Username or API key not set");
verboseLog("--> An error occurred while setting the activity"); }
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(); 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()` */ if (this.consecutiveFailures >= Constants.MAX_RETRY_ATTEMPTS) {
export function flush(isClearActivity = false) { verboseLog(
pluginState.lastActivity = null; `Failed ${this.consecutiveFailures} times, initiating reconnection...`,
pluginState.lastTrackUrl = null; );
this.startReconnection();
}
}
pluginState.updateInterval && clearInterval(pluginState.updateInterval); private startReconnection() {
!isClearActivity && clearActivity(); if (this.isReconnecting) return;
}
/** Initializes the plugin */ this.isReconnecting = true;
export async function initialize() { 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) { 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; const interval = Math.max(
(Number(currentSettings.timeInterval) ||
await update().catch((err) => { Constants.DEFAULT_SETTINGS.timeInterval) * 1000,
console.error(err); Constants.MIN_UPDATE_INTERVAL * 1000,
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
); );
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 { plugin } from "@vendetta";
import { storage } from "@vendetta/plugin"; import { Forms } from "@vendetta/ui/components";
import { useProxy } from "@vendetta/storage"; import { useProxy } from "@vendetta/storage";
import { getAssetIDByName } from "@vendetta/ui/assets"; import { getAssetIDByName } from "@vendetta/ui/assets";
import { Forms } from "@vendetta/ui/components";
import { showToast } from "@vendetta/ui/toasts"; import { showToast } from "@vendetta/ui/toasts";
import { React } from "@vendetta/metro/common";
import { ScrollView } from "react-native";
import { lazy, useEffect } from "react"; import { currentSettings, pluginState } from "../..";
import { ScrollView, Text, TouchableOpacity } from "react-native";
import { currentSettings } from "../..";
import { LFMSettings } from "../../../../defs";
import Constants from "../../constants"; import Constants from "../../constants";
import { initialize } from "../../manager"; import { initialize } from "../../manager";
import { import { lastfmClient } from "../../utils/lastfm";
logComponentMount,
logComponentError,
logNavigationError,
incrementSettingsLoad,
} from "../../utils/debug";
const { FormRow, FormInput, FormDivider, FormSwitchRow, FormText, FormIcon } = const { FormInput, FormRow, FormSwitch, FormSection, FormDivider, FormText } =
Forms; Forms;
function UpdateButton() { export default function Settings() {
async function onPressCallback() { useProxy(currentSettings);
try {
incrementSettingsLoad();
for (const key in storage) {
if (storage[key] != null) {
currentSettings[key] = storage[key];
}
}
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(); await initialize();
showToast("Settings updated!", getAssetIDByName("Check"));
} catch (error) { } catch (error) {
logComponentError("UpdateButton", error); showToast(
showToast("Failed to update settings", getAssetIDByName("Small")); `Connection failed: ${error.message}`,
getAssetIDByName("Small"),
);
} finally {
setLoading(false);
} }
} };
return ( return (
<TouchableOpacity onPress={onPressCallback}> <ScrollView style={{ flex: 1 }}>
<FormText style={{ marginRight: 12 }}>UPDATE</FormText> <FormSection title="Account Settings">
</TouchableOpacity> <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() { <FormSection title="Display Settings">
const settings = useProxy(storage) as LFMSettings; <FormInput
const navigation = NavigationNative.useNavigation(); 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 <FormSection title="Options">
useEffect(() => { <FormRow
logComponentMount("Settings"); label="Show 'Listening to' instead of 'Playing'"
}, []); leading={<FormRow.Icon source={getAssetIDByName("ic_music")} />}
trailing={
// Safety check to ensure settings is properly initialized <FormSwitch
if (!settings) { value={
logComponentError("Settings", "Settings proxy is null or undefined"); currentSettings.listeningTo ??
return ( Constants.DEFAULT_SETTINGS.listeningTo
<ScrollView> }
<FormText>Loading...</FormText> onValueChange={(value: boolean) =>
</ScrollView> saveSettings("listeningTo", value)
); }
} />
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"));
} }
}} />
/> <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> </ScrollView>
); );
}); }

View file

@ -3,34 +3,42 @@ import { FluxDispatcher } from "@vendetta/metro/common";
import { pluginState } from ".."; import { pluginState } from "..";
import { Activity } from "../../../defs"; import { Activity } from "../../../defs";
import Constants from "../constants"; import Constants from "../constants";
import { flush } from "../manager"; import { stop } from "../manager";
import { AssetManager } from "../modules"; import { AssetManager } from "../modules";
/** Clears the user's activity */ /** Clears the user's activity */
export function clearActivity() { export function clearActivity() {
return sendRequest(null); return sendRequest(null);
} }
/** Sends the activity details to Discord */ /** Sends the activity details to Discord */
export function sendRequest(activity: Activity) { export function sendRequest(activity: Activity | null) {
if (pluginState.pluginStopped) { if (pluginState.pluginStopped) {
flush(true); stop();
activity = null; activity = null;
} }
pluginState.lastActivity = activity; pluginState.lastActivity = activity;
FluxDispatcher.dispatch({ FluxDispatcher.dispatch({
type: "LOCAL_ACTIVITY_UPDATE", type: "LOCAL_ACTIVITY_UPDATE",
activity: activity, activity: activity,
pid: 2312, pid: 2312,
socketId: "Last.fm@Vendetta" socketId: "Last.fm@Vendetta",
}); });
} }
/** Fetches a Discord application's asset */ /** Fetches a Discord application's asset */
export async function fetchAsset(asset: string[], appId: string = Constants.APPLICATION_ID): Promise<string[]> { export async function fetchAsset(
if (!asset) return []; asset: string[],
appId: string = Constants.APPLICATION_ID,
): Promise<string[]> {
if (!asset?.length) return [];
try {
return await AssetManager.fetchAssetIds(appId, asset); 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 { Track } from "../../../defs";
import Constants from "../constants"; import Constants from "../constants";
import { setDebugInfo } from "./debug"; import { setDebugInfo } from "./debug";
import { showToast } from "@vendetta/ui/toasts";
import { getAssetIDByName } from "@vendetta/ui/assets";
/** Fetches the latest user's scrobble */ interface LastFMError {
export async function fetchLatestScrobble(): Promise<Track> { error: number;
const params = new URLSearchParams({ message: string;
"method": "user.getrecenttracks", }
"user": currentSettings.username,
"api_key": Constants.LFM_API_KEY, interface LastFMResponse {
"format": "json", recenttracks?: {
"limit": "1", track: LastFMTrack[];
"extended": "1" };
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(); }).toString();
const result = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`); const response = await fetch(
if (!result.ok) throw new Error(`Failed to fetch the latest scrobble: ${result.statusText}`); `${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]; return data;
setDebugInfo("lastAPIResponse", lastTrack); }
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, name: lastTrack.name,
artist: lastTrack.artist.name, artist: lastTrack.artist.name,
album: lastTrack.album["#text"], 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, url: lastTrack.url,
date: lastTrack.date?.["#text"] ?? "now", date: lastTrack.date?.["#text"] ?? "now",
nowPlaying: Boolean(lastTrack["@attr"]?.nowplaying), nowPlaying: Boolean(lastTrack["@attr"]?.nowplaying),
loved: lastTrack.loved === "1", 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;
}
} }
/** // Export a singleton instance
* Currently ditches the default album covers export const lastfmClient = LastFMClient.getInstance();
* @param cover The album cover given by Last.fm
*/ /** Fetches the latest user's scrobble */
export async function handleAlbumCover(cover: string): Promise<string> { export async function fetchLatestScrobble(): Promise<Track> {
// If the cover is a default one, return null (remove the cover) return lastfmClient.fetchLatestScrobble();
if (Constants.LFM_DEFAULT_COVER_HASHES.some(x => cover.includes(x))) { }
return null;
} /**
return cover; * 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);
} }