test
This commit is contained in:
parent
f4be7b2743
commit
a6156a928e
6 changed files with 677 additions and 435 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue