feat: Implement RPC customization settings

- Added RPC preview component for live activity preview. - Added
settings for "Listening to" status, show tooltip text, album name and
duration in the tooltip. - Refactored settings structure for better
organization. - Fixed an issue where the sidebar settings weren't
showing. - Updated the minified build.
This commit is contained in:
kmmiio99o 2025-11-16 15:39:56 +01:00
parent fc3ff41838
commit 1928b45207
6 changed files with 554 additions and 97 deletions

View file

@ -3,7 +3,7 @@
"build": "node scripts/build.mjs", "build": "node scripts/build.mjs",
"watch": "node scripts/build.mjs -w", "watch": "node scripts/build.mjs -w",
"lint": "eslint plugins", "lint": "eslint plugins",
"make": "node make.mjs", "make": "node scripts/make.mjs",
"serve": "node scripts/serve.mjs" "serve": "node scripts/serve.mjs"
}, },
"devDependencies": { "devDependencies": {

View file

@ -4,7 +4,7 @@ const Constants = {
DEFAULT_APP_NAME: "Music", DEFAULT_APP_NAME: "Music",
DEFAULT_TIME_INTERVAL: 5, DEFAULT_TIME_INTERVAL: 5,
// Discord application ID // Discord application ID
APPLICATION_ID: "1054951789318909972", APPLICATION_ID: "1368513179272871956",
// Don't check more than once every 3 seconds to avoid getting rate limited // Don't check more than once every 3 seconds to avoid getting rate limited
MIN_UPDATE_INTERVAL: 3, MIN_UPDATE_INTERVAL: 3,
// How many times to retry failed API calls // How many times to retry failed API calls

View file

@ -18,7 +18,7 @@ export const pluginState = {
} as { } as {
pluginStopped: boolean; pluginStopped: boolean;
lastActivity?: any; lastActivity?: any;
updateInterval?: NodeJS.Timer; updateInterval?: NodeJS.Timeout;
lastTrackUrl?: string; lastTrackUrl?: string;
}; };

View file

@ -189,37 +189,53 @@ class PluginManager {
}); });
} }
// set up album art and tooltip text // set up album art and tooltip text - SIMPLIFIED VERSION (no fallback image)
if (lastTrack.album || lastTrack.albumArt) { if (lastTrack.album || lastTrack.albumArt) {
const assetUrls = lastTrack.albumArt ? [lastTrack.albumArt] : []; const assetUrls = lastTrack.albumArt ? [lastTrack.albumArt] : [];
const assets = await fetchAsset(assetUrls); const assets = await fetchAsset(assetUrls);
if (assets[0]) { // Only use album art if available
let largeImageAsset = assets[0];
if (largeImageAsset) {
activity.assets = {
large_image: largeImageAsset,
};
// Build tooltip text based on settings
if (currentSettings.showLargeText) { if (currentSettings.showLargeText) {
const largeText = lastTrack.album let largeText = "";
? `on ${lastTrack.album}`
: `${lastTrack.artist} - ${lastTrack.name}`;
const durationText = if (currentSettings.showAlbumInTooltip && lastTrack.album) {
currentSettings.showTimestamp && lastTrack.duration largeText += `on ${lastTrack.album}`;
? ` • ${formatDuration(lastTrack.duration)}` }
: "";
activity.assets = { if (currentSettings.showDurationInTooltip && lastTrack.duration) {
large_image: assets[0], const durationText = formatDuration(lastTrack.duration);
large_text: largeText + durationText, if (largeText) {
}; largeText += ` • ${durationText}`;
} else { } else {
activity.assets = { largeText = durationText;
large_image: assets[0], }
}; }
if (largeText) {
activity.assets.large_text = largeText;
}
} }
logVerbose("Album art set:", assets[0]); logVerbose("Album art set:", largeImageAsset);
} else if (lastTrack.album) { if (activity.assets.large_text) {
activity.assets = currentSettings.showLargeText logVerbose("Tooltip text set:", activity.assets.large_text);
? { large_text: `on ${lastTrack.album}` } }
: {}; } else if (
lastTrack.album &&
currentSettings.showLargeText &&
currentSettings.showAlbumInTooltip
) {
activity.assets = {
large_text: `on ${lastTrack.album}`,
};
} }
} }

View file

@ -21,6 +21,7 @@ const tabsNavigationRef = bunny?.metro?.findByPropsLazy("getRootNavigationRef");
const settingConstants = bunny?.metro?.findByPropsLazy( const settingConstants = bunny?.metro?.findByPropsLazy(
"SETTING_RENDERER_CONFIG", "SETTING_RENDERER_CONFIG",
); );
const createListModule = bunny?.metro?.findByPropsLazy("createList");
const SettingsOverviewScreen = bunny?.metro?.findByNameLazy( const SettingsOverviewScreen = bunny?.metro?.findByNameLazy(
"SettingsOverviewScreen", "SettingsOverviewScreen",
false, false,
@ -96,37 +97,32 @@ function patchPanelUI(tabs, patches) {
} }
function patchTabsUI(tabs, patches) { function patchTabsUI(tabs, patches) {
if (!settingConstants || !SettingsOverviewScreen || !tabsNavigationRef) { if (!settingConstants || !tabsNavigationRef) {
console.warn("[LastFm] Missing required constants for tabs UI patch"); console.warn("[LastFm] Missing required constants for tabs UI patch");
return; return;
} }
const row = {}; const row = {
row[tabs.key] = { [tabs.key]: {
type: "pressable", type: "pressable",
title: tabs.title, title: tabs.title,
icon: tabs.icon, icon: tabs.icon,
IconComponent: IconComponent:
tabs.icon && tabs.icon &&
(() => { (() => React.createElement(TableRowIcon, { source: tabs.icon })),
const actualIconSource = usePredicate: tabs.predicate,
typeof tabs.icon === "object" && tabs.icon.uri !== undefined useTrailing: tabs.trailing,
? tabs.icon.uri onPress: () => {
: tabs.icon; const navigation = tabsNavigationRef.getRootNavigationRef();
return React.createElement(TableRowIcon, { source: actualIconSource }); const Component = tabs.page;
}),
usePredicate: tabs.predicate,
useTrailing: tabs.trailing,
onPress: () => {
const navigation = tabsNavigationRef.getRootNavigationRef();
const Component = tabs.page;
navigation.navigate("VendettaCustomPage", { navigation.navigate("VendettaCustomPage", {
title: tabs.title(), title: tabs.title(),
render: () => React.createElement(Component), render: () => React.createElement(Component),
}); });
},
withArrow: true,
}, },
withArrow: true,
}; };
let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG; let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG;
@ -141,30 +137,54 @@ function patchTabsUI(tabs, patches) {
set: (v) => (rendererConfigValue = v), set: (v) => (rendererConfigValue = v),
}); });
const firstRender = Symbol("LastFm-pinToSettings"); const firstRender = Symbol("pinToSettings meow meow");
patches.push( try {
after("default", SettingsOverviewScreen, (args, ret) => { if (!createListModule) return;
if (!(args[0] as { [key: symbol]: boolean })[firstRender]) { patches.push(
(args[0] as { [key: symbol]: boolean })[firstRender] = true; after("createList", createListModule, function (args, ret) {
if (!args[0][firstRender]) {
args[0][firstRender] = true;
const { sections } = findInReactTree( const [config] = args;
ret, const sections = config.sections;
(i) => i.props?.sections,
).props;
const section = sections?.find((x) => const section = sections?.find((x: any) =>
["Bunny", "Revenge", "Kettu", "Vencore", "ShiggyCord"].some( ["Bunny", "Revenge", "Kettu", "Vencore", "ShiggyCord"].some(
(mod) => x.label === mod && x.title === mod, (mod) => x.label === mod && x.title === mod,
), ),
); );
if (section?.settings) { if (section?.settings) {
section.settings = [...section.settings, tabs.key]; section.settings = [...section.settings, tabs.key];
}
} }
} }),
}), );
); } catch {
if (!SettingsOverviewScreen) return;
patches.push(
after("default", SettingsOverviewScreen, (args, ret) => {
if (!args[0][firstRender]) {
args[0][firstRender] = true;
const { sections } = findInReactTree(
ret,
(i) => i.props?.sections,
).props;
const section = sections?.find((x: any) =>
["Bunny", "Revenge", "Kettu", "Vencore", "ShiggyCord"].some(
(mod) => x.label === mod && x.title === mod,
),
);
if (section?.settings) {
section.settings = [...section.settings, tabs.key];
}
}
}),
);
}
} }
function patchSettingsPin(tabs) { function patchSettingsPin(tabs) {

View file

@ -33,13 +33,397 @@ plugin.storage.librefmUsername ??= "";
plugin.storage.librefmApiKey ??= ""; plugin.storage.librefmApiKey ??= "";
plugin.storage.listenbrainzUsername ??= ""; plugin.storage.listenbrainzUsername ??= "";
plugin.storage.listenbrainzToken ??= ""; plugin.storage.listenbrainzToken ??= "";
plugin.storage.addToSidebar ??= false; plugin.storage.addToSidebar ??= true;
plugin.storage.showLargeText ??= true; plugin.storage.showLargeText ??= true;
plugin.storage.ignoreList ??= []; plugin.storage.ignoreList ??= [];
plugin.storage.showAlbumInTooltip ??= true;
plugin.storage.showDurationInTooltip ??= true;
// Removed: plugin.storage.showFallbackImage ??= false;
const get = (k: string, fallback?: any) => plugin.storage[k] ?? fallback; const get = (k: string, fallback?: any) => plugin.storage[k] ?? fallback;
const set = (k: string, v: any) => (plugin.storage[k] = v); const set = (k: string, v: any) => (plugin.storage[k] = v);
// RPC Preview Component
function RPCPreview() {
useProxy(plugin.storage);
const [previewTrack, setPreviewTrack] = React.useState<any>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [currentProgress, setCurrentProgress] = React.useState(0);
React.useEffect(() => {
const fetchPreviewData = async () => {
if (!get("username") || !get("apiKey")) {
setIsLoading(false);
return;
}
try {
setIsLoading(true);
// Try to get recent tracks from Last.fm API
const response = await fetch(
`https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${get("username")}&api_key=${get("apiKey")}&format=json&limit=1`,
);
const data = await response.json();
if (
data.recenttracks &&
data.recenttracks.track &&
data.recenttracks.track.length > 0
) {
const track = data.recenttracks.track[0];
// Try to get track info for duration
let duration = 180; // Default 3 minutes
try {
const trackInfoResponse = await fetch(
`https://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key=${get("apiKey")}&artist=${encodeURIComponent(track.artist["#text"])}&track=${encodeURIComponent(track.name)}&format=json&username=${get("username")}`,
);
const trackInfo = await trackInfoResponse.json();
if (trackInfo.track && trackInfo.track.duration) {
duration = Math.floor(trackInfo.track.duration / 1000); // Convert ms to seconds
}
} catch (error) {
console.log("Could not fetch track duration, using default");
}
setPreviewTrack({
name: track.name || "Unknown Track",
artist: track.artist?.["#text"] || "Unknown Artist",
album: track.album?.["#text"] || "Unknown Album",
image:
track.image?.[2]?.["#text"] ||
track.image?.[1]?.["#text"] ||
null,
nowPlaying: track["@attr"]?.nowplaying === "true",
duration: duration,
startTime:
track["@attr"]?.nowplaying === "true"
? Math.floor(Date.now() / 1000) - 60
: null, // Simulate 1 minute progress for now playing
});
}
} catch (error) {
console.error("Failed to fetch preview data:", error);
} finally {
setIsLoading(false);
}
};
fetchPreviewData();
}, [get("username"), get("apiKey")]);
// Update progress for now playing tracks
React.useEffect(() => {
if (!previewTrack?.nowPlaying || !get("showTimestamp")) {
return;
}
const interval = setInterval(() => {
if (previewTrack.startTime && previewTrack.duration) {
const now = Math.floor(Date.now() / 1000);
const elapsed = now - previewTrack.startTime;
const progress = Math.min(elapsed / previewTrack.duration, 1);
setCurrentProgress(progress);
}
}, 1000);
return () => clearInterval(interval);
}, [previewTrack, get("showTimestamp")]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
};
const getPreviewText = () => {
let text = "";
if (get("showAlbumInTooltip") && previewTrack?.album) {
text += `on ${previewTrack.album}`;
}
if (get("showDurationInTooltip") && previewTrack?.duration) {
const durationText = ` • ${formatTime(previewTrack.duration)}`;
if (text) {
text += durationText;
} else {
text = formatTime(previewTrack.duration);
}
}
return text || "No tooltip text";
};
const getCurrentProgressData = () => {
if (!previewTrack?.duration) return { current: 0, total: 0, progress: 0 };
if (previewTrack.nowPlaying) {
const current = currentProgress * previewTrack.duration;
return {
current: current,
total: previewTrack.duration,
progress: currentProgress,
};
} else {
// For non-now playing tracks, show 30% progress as example
return {
current: previewTrack.duration * 0.3,
total: previewTrack.duration,
progress: 0.3,
};
}
};
const activityType = get("listeningTo") ? "Listening to" : "Playing";
const appName = get("appName", "Music");
if (isLoading) {
return (
<RN.View
style={{
backgroundColor: "#2f3136",
borderRadius: 8,
padding: 16,
marginHorizontal: 10,
marginBottom: 16,
borderWidth: 1,
borderColor: "#40444b",
alignItems: "center",
justifyContent: "center",
minHeight: 120,
}}
>
<RN.Text style={{ color: "#b9bbbe", fontSize: 14 }}>
Loading preview from Last.fm...
</RN.Text>
</RN.View>
);
}
if (!get("username") || !get("apiKey")) {
return (
<RN.View
style={{
backgroundColor: "#2f3136",
borderRadius: 8,
padding: 16,
marginHorizontal: 10,
marginBottom: 16,
borderWidth: 1,
borderColor: "#40444b",
alignItems: "center",
justifyContent: "center",
minHeight: 120,
}}
>
<RN.Text
style={{ color: "#b9bbbe", fontSize: 14, textAlign: "center" }}
>
Configure Last.fm credentials to see preview
</RN.Text>
</RN.View>
);
}
if (!previewTrack) {
return (
<RN.View
style={{
backgroundColor: "#2f3136",
borderRadius: 8,
padding: 16,
marginHorizontal: 10,
marginBottom: 16,
borderWidth: 1,
borderColor: "#40444b",
alignItems: "center",
justifyContent: "center",
minHeight: 120,
}}
>
<RN.Text
style={{ color: "#b9bbbe", fontSize: 14, textAlign: "center" }}
>
No recent tracks found or error loading data
</RN.Text>
</RN.View>
);
}
const progressData = getCurrentProgressData();
return (
<RN.View
style={{
backgroundColor: "#2f3136",
borderRadius: 8,
padding: 16,
marginHorizontal: 10,
marginBottom: 16,
borderWidth: 1,
borderColor: "#40444b",
}}
>
{/* Activity Type Header - replaces "RPC Preview" */}
<RN.View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 12,
}}
>
<RN.Text
style={{
color: "#b9bbbe",
fontSize: 14,
fontWeight: "600",
}}
>
{activityType} {appName}
</RN.Text>
{/* Now Playing Indicator - subtle text instead of blue badge */}
{previewTrack.nowPlaying && (
<RN.Text
style={{
color: "#72767d",
fontSize: 12,
fontStyle: "italic",
}}
>
RPC Preview
</RN.Text>
)}
</RN.View>
<RN.View
style={{
flexDirection: "row",
alignItems: "center",
}}
>
{/* Album Art */}
<RN.View
style={{
width: 60,
height: 60,
backgroundColor: "#36393f",
borderRadius: 4,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
borderWidth: 1,
borderColor: "#40444b",
overflow: "hidden",
}}
>
{previewTrack.image ? (
<RN.Image
source={{ uri: previewTrack.image }}
style={{ width: 60, height: 60 }}
resizeMode="cover"
/>
) : (
<RN.Text
style={{
color: "#72767d",
fontSize: 24,
}}
>
🎵
</RN.Text>
)}
</RN.View>
<RN.View style={{ flex: 1 }}>
{/* Track Name */}
<RN.Text
style={{
color: "white",
fontSize: 16,
fontWeight: "600",
marginBottom: 4,
}}
numberOfLines={1}
>
{previewTrack.name}
</RN.Text>
{/* Artist Name */}
<RN.Text
style={{
color: "#b9bbbe",
fontSize: 14,
marginBottom: 4,
}}
numberOfLines={1}
>
{previewTrack.artist}
</RN.Text>
{/* Tooltip Text */}
{get("showLargeText") && getPreviewText() !== "No tooltip text" && (
<RN.Text
style={{
color: "#72767d",
fontSize: 12,
fontStyle: "italic",
marginBottom: 4,
}}
numberOfLines={1}
>
{getPreviewText()}
</RN.Text>
)}
{/* Timestamps */}
{get("showTimestamp") && previewTrack.duration && (
<RN.View
style={{
flexDirection: "row",
alignItems: "center",
}}
>
<RN.View
style={{
flex: 1,
height: 4,
backgroundColor: "#36393f",
borderRadius: 2,
marginRight: 8,
overflow: "hidden",
}}
>
<RN.View
style={{
width: `${progressData.progress * 100}%`,
height: 4,
backgroundColor: "#5865f2",
borderRadius: 2,
}}
/>
</RN.View>
<RN.Text
style={{
color: "#72767d",
fontSize: 12,
minWidth: 70,
textAlign: "right",
}}
>
{formatTime(progressData.current)} /{" "}
{formatTime(progressData.total)}
</RN.Text>
</RN.View>
)}
</RN.View>
</RN.View>
</RN.View>
);
}
// Last.fm Settings Page // Last.fm Settings Page
function LastFmSettingsPage() { function LastFmSettingsPage() {
useProxy(plugin.storage); useProxy(plugin.storage);
@ -323,7 +707,36 @@ function DisplaySettingsPage() {
</Stack> </Stack>
</TableRowGroup> </TableRowGroup>
<TableRowGroup title="Activity Options"> <TableRowGroup title="About Display Settings">
<TableRow
label="App Name"
subLabel="The name shown in Discord for your activity"
/>
<TableRow
label="Update Interval"
subLabel="How often the plugin checks for new tracks (in seconds)"
/>
<TableRow
label="Minimum Interval"
subLabel={`The plugin will never check more frequently than ${Constants.MIN_UPDATE_INTERVAL} seconds`}
/>
</TableRowGroup>
</Stack>
<RN.View style={{ height: 64 }} />
</ScrollView>
);
}
// RPC Customization Settings Page
function RPCCustomizationSettingsPage() {
useProxy(plugin.storage);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
return (
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 10 }}>
<RPCPreview />
<Stack spacing={8}>
<TableRowGroup title="RPC Display Options">
<TableSwitchRow <TableSwitchRow
label="Show as Listening" label="Show as Listening"
subLabel="Display as 'Listening to' instead of 'Playing'" subLabel="Display as 'Listening to' instead of 'Playing'"
@ -335,7 +748,7 @@ function DisplaySettingsPage() {
/> />
<TableSwitchRow <TableSwitchRow
label="Show Tooltip Text" label="Show Tooltip Text"
subLabel="Show album name and/or track duration in Discord activity tooltip" subLabel="Show album name and track duration in Discord activity tooltip"
value={get("showLargeText", true)} value={get("showLargeText", true)}
onValueChange={(value: boolean) => { onValueChange={(value: boolean) => {
set("showLargeText", value); set("showLargeText", value);
@ -354,6 +767,24 @@ function DisplaySettingsPage() {
forceUpdate(); forceUpdate();
}} }}
/> />
<TableSwitchRow
label="Show Album in Tooltip"
subLabel="Include album name in the tooltip text"
value={get("showAlbumInTooltip", true)}
onValueChange={(value: boolean) => {
set("showAlbumInTooltip", value);
forceUpdate();
}}
/>
<TableSwitchRow
label="Show Duration in Tooltip"
subLabel="Include track duration in the tooltip text"
value={get("showDurationInTooltip", true)}
onValueChange={(value: boolean) => {
set("showDurationInTooltip", value);
forceUpdate();
}}
/>
</TableRowGroup> </TableRowGroup>
</Stack> </Stack>
<RN.View style={{ height: 64 }} /> <RN.View style={{ height: 64 }} />
@ -624,7 +1055,7 @@ export default function Settings() {
<TableRowGroup title="Plugin Configuration"> <TableRowGroup title="Plugin Configuration">
<TableRow <TableRow
label="Display Settings" label="Display Settings"
subLabel="Customize how music activity appears" subLabel="Customize app name and update interval"
trailing={<TableRow.Arrow />} trailing={<TableRow.Arrow />}
onPress={() => onPress={() =>
navigation.push("VendettaCustomPage", { navigation.push("VendettaCustomPage", {
@ -633,6 +1064,17 @@ export default function Settings() {
}) })
} }
/> />
<TableRow
label="RPC Customization"
subLabel="Customize Discord rich presence display options"
trailing={<TableRow.Arrow />}
onPress={() =>
navigation.push("VendettaCustomPage", {
title: "RPC Customization",
render: RPCCustomizationSettingsPage,
})
}
/>
<TableRow <TableRow
label="Ignore List" label="Ignore List"
subLabel="Configure apps that should hide your status" subLabel="Configure apps that should hide your status"
@ -657,32 +1099,11 @@ export default function Settings() {
/> />
<TableSwitchRow <TableSwitchRow
label="Add to Sidebar" label="Add to Sidebar"
subLabel="Show plugin in Discord settings (may crash Discord)" subLabel="Show plugin in Discord settings"
value={get("addToSidebar", false)} value={get("addToSidebar", false)}
onValueChange={(value: boolean) => { onValueChange={(value: boolean) => {
if (value) { set("addToSidebar", value);
const { showConfirmationAlert } = window; forceUpdate();
showConfirmationAlert({
title: "Warning: Potential Discord Crash",
content:
"Enabling 'Add to Sidebar' may cause Discord to crash or become unstable. This feature is experimental and not recommended.",
confirmText: "Enable Anyway",
cancelText: "Cancel",
confirmColor: "red",
onConfirm: () => {
set("addToSidebar", true);
forceUpdate();
showToast(
"Sidebar feature enabled - use with caution",
getAssetIDByName("Warning"),
);
},
onCancel: () => forceUpdate(),
});
} else {
set("addToSidebar", false);
forceUpdate();
}
}} }}
/> />
</TableRowGroup> </TableRowGroup>