From 1928b45207275cf2fb6d0f712399bd7b8188f97a Mon Sep 17 00:00:00 2001 From: kmmiio99o <164557248+kmmiio99o@users.noreply.github.com> Date: Sun, 16 Nov 2025 15:39:56 +0100 Subject: [PATCH] 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. --- package.json | 2 +- plugins/multi-scrobbler/src/constants.ts | 2 +- plugins/multi-scrobbler/src/index.tsx | 2 +- plugins/multi-scrobbler/src/manager.ts | 60 ++- plugins/multi-scrobbler/src/sidebar.tsx | 108 ++-- .../multi-scrobbler/src/ui/pages/Settings.tsx | 477 +++++++++++++++++- 6 files changed, 554 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index b141a6c..d503516 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "build": "node scripts/build.mjs", "watch": "node scripts/build.mjs -w", "lint": "eslint plugins", - "make": "node make.mjs", + "make": "node scripts/make.mjs", "serve": "node scripts/serve.mjs" }, "devDependencies": { diff --git a/plugins/multi-scrobbler/src/constants.ts b/plugins/multi-scrobbler/src/constants.ts index 0cd71ea..f33dbbe 100644 --- a/plugins/multi-scrobbler/src/constants.ts +++ b/plugins/multi-scrobbler/src/constants.ts @@ -4,7 +4,7 @@ const Constants = { DEFAULT_APP_NAME: "Music", DEFAULT_TIME_INTERVAL: 5, // Discord application ID - APPLICATION_ID: "1054951789318909972", + APPLICATION_ID: "1368513179272871956", // Don't check more than once every 3 seconds to avoid getting rate limited MIN_UPDATE_INTERVAL: 3, // How many times to retry failed API calls diff --git a/plugins/multi-scrobbler/src/index.tsx b/plugins/multi-scrobbler/src/index.tsx index e024620..c1c4381 100644 --- a/plugins/multi-scrobbler/src/index.tsx +++ b/plugins/multi-scrobbler/src/index.tsx @@ -18,7 +18,7 @@ export const pluginState = { } as { pluginStopped: boolean; lastActivity?: any; - updateInterval?: NodeJS.Timer; + updateInterval?: NodeJS.Timeout; lastTrackUrl?: string; }; diff --git a/plugins/multi-scrobbler/src/manager.ts b/plugins/multi-scrobbler/src/manager.ts index e890f90..5db9580 100644 --- a/plugins/multi-scrobbler/src/manager.ts +++ b/plugins/multi-scrobbler/src/manager.ts @@ -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) { const assetUrls = lastTrack.albumArt ? [lastTrack.albumArt] : []; 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) { - const largeText = lastTrack.album - ? `on ${lastTrack.album}` - : `${lastTrack.artist} - ${lastTrack.name}`; + let largeText = ""; - const durationText = - currentSettings.showTimestamp && lastTrack.duration - ? ` • ${formatDuration(lastTrack.duration)}` - : ""; + if (currentSettings.showAlbumInTooltip && lastTrack.album) { + largeText += `on ${lastTrack.album}`; + } - activity.assets = { - large_image: assets[0], - large_text: largeText + durationText, - }; - } else { - activity.assets = { - large_image: assets[0], - }; + if (currentSettings.showDurationInTooltip && lastTrack.duration) { + const durationText = formatDuration(lastTrack.duration); + if (largeText) { + largeText += ` • ${durationText}`; + } else { + largeText = durationText; + } + } + + if (largeText) { + activity.assets.large_text = largeText; + } } - logVerbose("Album art set:", assets[0]); - } else if (lastTrack.album) { - activity.assets = currentSettings.showLargeText - ? { large_text: `on ${lastTrack.album}` } - : {}; + logVerbose("Album art set:", largeImageAsset); + if (activity.assets.large_text) { + logVerbose("Tooltip text set:", activity.assets.large_text); + } + } else if ( + lastTrack.album && + currentSettings.showLargeText && + currentSettings.showAlbumInTooltip + ) { + activity.assets = { + large_text: `on ${lastTrack.album}`, + }; } } diff --git a/plugins/multi-scrobbler/src/sidebar.tsx b/plugins/multi-scrobbler/src/sidebar.tsx index 8fee40b..bdcef3e 100644 --- a/plugins/multi-scrobbler/src/sidebar.tsx +++ b/plugins/multi-scrobbler/src/sidebar.tsx @@ -21,6 +21,7 @@ const tabsNavigationRef = bunny?.metro?.findByPropsLazy("getRootNavigationRef"); const settingConstants = bunny?.metro?.findByPropsLazy( "SETTING_RENDERER_CONFIG", ); +const createListModule = bunny?.metro?.findByPropsLazy("createList"); const SettingsOverviewScreen = bunny?.metro?.findByNameLazy( "SettingsOverviewScreen", false, @@ -96,37 +97,32 @@ function patchPanelUI(tabs, patches) { } function patchTabsUI(tabs, patches) { - if (!settingConstants || !SettingsOverviewScreen || !tabsNavigationRef) { + if (!settingConstants || !tabsNavigationRef) { console.warn("[LastFm] Missing required constants for tabs UI patch"); return; } - const row = {}; - row[tabs.key] = { - type: "pressable", - title: tabs.title, - icon: tabs.icon, - IconComponent: - tabs.icon && - (() => { - const actualIconSource = - typeof tabs.icon === "object" && tabs.icon.uri !== undefined - ? tabs.icon.uri - : tabs.icon; - return React.createElement(TableRowIcon, { source: actualIconSource }); - }), - usePredicate: tabs.predicate, - useTrailing: tabs.trailing, - onPress: () => { - const navigation = tabsNavigationRef.getRootNavigationRef(); - const Component = tabs.page; + const row = { + [tabs.key]: { + type: "pressable", + title: tabs.title, + icon: tabs.icon, + IconComponent: + tabs.icon && + (() => React.createElement(TableRowIcon, { source: tabs.icon })), + usePredicate: tabs.predicate, + useTrailing: tabs.trailing, + onPress: () => { + const navigation = tabsNavigationRef.getRootNavigationRef(); + const Component = tabs.page; - navigation.navigate("VendettaCustomPage", { - title: tabs.title(), - render: () => React.createElement(Component), - }); + navigation.navigate("VendettaCustomPage", { + title: tabs.title(), + render: () => React.createElement(Component), + }); + }, + withArrow: true, }, - withArrow: true, }; let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG; @@ -141,30 +137,54 @@ function patchTabsUI(tabs, patches) { set: (v) => (rendererConfigValue = v), }); - const firstRender = Symbol("LastFm-pinToSettings"); + const firstRender = Symbol("pinToSettings meow meow"); - patches.push( - after("default", SettingsOverviewScreen, (args, ret) => { - if (!(args[0] as { [key: symbol]: boolean })[firstRender]) { - (args[0] as { [key: symbol]: boolean })[firstRender] = true; + try { + if (!createListModule) return; + patches.push( + after("createList", createListModule, function (args, ret) { + if (!args[0][firstRender]) { + args[0][firstRender] = true; - const { sections } = findInReactTree( - ret, - (i) => i.props?.sections, - ).props; + const [config] = args; + const sections = config.sections; - const section = sections?.find((x) => - ["Bunny", "Revenge", "Kettu", "Vencore", "ShiggyCord"].some( - (mod) => x.label === mod && x.title === mod, - ), - ); + 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]; + if (section?.settings) { + 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) { diff --git a/plugins/multi-scrobbler/src/ui/pages/Settings.tsx b/plugins/multi-scrobbler/src/ui/pages/Settings.tsx index 17153ab..3e21968 100644 --- a/plugins/multi-scrobbler/src/ui/pages/Settings.tsx +++ b/plugins/multi-scrobbler/src/ui/pages/Settings.tsx @@ -33,13 +33,397 @@ plugin.storage.librefmUsername ??= ""; plugin.storage.librefmApiKey ??= ""; plugin.storage.listenbrainzUsername ??= ""; plugin.storage.listenbrainzToken ??= ""; -plugin.storage.addToSidebar ??= false; +plugin.storage.addToSidebar ??= true; plugin.storage.showLargeText ??= true; 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 set = (k: string, v: any) => (plugin.storage[k] = v); +// RPC Preview Component +function RPCPreview() { + useProxy(plugin.storage); + const [previewTrack, setPreviewTrack] = React.useState(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 ( + + + Loading preview from Last.fm... + + + ); + } + + if (!get("username") || !get("apiKey")) { + return ( + + + Configure Last.fm credentials to see preview + + + ); + } + + if (!previewTrack) { + return ( + + + No recent tracks found or error loading data + + + ); + } + + const progressData = getCurrentProgressData(); + + return ( + + {/* Activity Type Header - replaces "RPC Preview" */} + + + {activityType} {appName} + + + {/* Now Playing Indicator - subtle text instead of blue badge */} + {previewTrack.nowPlaying && ( + + RPC Preview + + )} + + + + {/* Album Art */} + + {previewTrack.image ? ( + + ) : ( + + 🎵 + + )} + + + + {/* Track Name */} + + {previewTrack.name} + + + {/* Artist Name */} + + {previewTrack.artist} + + + {/* Tooltip Text */} + {get("showLargeText") && getPreviewText() !== "No tooltip text" && ( + + {getPreviewText()} + + )} + + {/* Timestamps */} + {get("showTimestamp") && previewTrack.duration && ( + + + + + + {formatTime(progressData.current)} /{" "} + {formatTime(progressData.total)} + + + )} + + + + ); +} + // Last.fm Settings Page function LastFmSettingsPage() { useProxy(plugin.storage); @@ -323,7 +707,36 @@ function DisplaySettingsPage() { - + + + + + + + + + ); +} + +// RPC Customization Settings Page +function RPCCustomizationSettingsPage() { + useProxy(plugin.storage); + const [, forceUpdate] = React.useReducer((x) => x + 1, 0); + + return ( + + + + { set("showLargeText", value); @@ -354,6 +767,24 @@ function DisplaySettingsPage() { forceUpdate(); }} /> + { + set("showAlbumInTooltip", value); + forceUpdate(); + }} + /> + { + set("showDurationInTooltip", value); + forceUpdate(); + }} + /> @@ -624,7 +1055,7 @@ export default function Settings() { } onPress={() => navigation.push("VendettaCustomPage", { @@ -633,6 +1064,17 @@ export default function Settings() { }) } /> + } + onPress={() => + navigation.push("VendettaCustomPage", { + title: "RPC Customization", + render: RPCCustomizationSettingsPage, + }) + } + /> { - if (value) { - const { showConfirmationAlert } = window; - 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(); - } + set("addToSidebar", value); + forceUpdate(); }} />