new plugin AppleMusicRichPresence (#2455)
Co-authored-by: Vendicated <vendicated@riseup.net>
This commit is contained in:
		
							parent
							
								
									9ab7b8b9c9
								
							
						
					
					
						commit
						0aa7bef9fa
					
				
					 6 changed files with 390 additions and 1 deletions
				
			
		
							
								
								
									
										2
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							|  | @ -14,6 +14,8 @@ | ||||||
|     "typescript.preferences.quoteStyle": "double", |     "typescript.preferences.quoteStyle": "double", | ||||||
|     "javascript.preferences.quoteStyle": "double", |     "javascript.preferences.quoteStyle": "double", | ||||||
| 
 | 
 | ||||||
|  |     "eslint.experimental.useFlatConfig": false, | ||||||
|  | 
 | ||||||
|     "gitlens.remotes": [ |     "gitlens.remotes": [ | ||||||
|         { |         { | ||||||
|             "domain": "codeberg.org", |             "domain": "codeberg.org", | ||||||
|  |  | ||||||
|  | @ -261,8 +261,9 @@ export default function PluginSettings() { | ||||||
|         plugins = []; |         plugins = []; | ||||||
|         requiredPlugins = []; |         requiredPlugins = []; | ||||||
| 
 | 
 | ||||||
|  |         const showApi = searchValue.value === "API"; | ||||||
|         for (const p of sortedPlugins) { |         for (const p of sortedPlugins) { | ||||||
|             if (!p.options && p.name.endsWith("API") && searchValue.value !== "API") |             if (p.hidden || (!p.options && p.name.endsWith("API") && !showApi)) | ||||||
|                 continue; |                 continue; | ||||||
| 
 | 
 | ||||||
|             if (!pluginFilter(p)) continue; |             if (!pluginFilter(p)) continue; | ||||||
|  |  | ||||||
							
								
								
									
										9
									
								
								src/plugins/appleMusic.desktop/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/plugins/appleMusic.desktop/README.md
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | # AppleMusicRichPresence | ||||||
|  | 
 | ||||||
|  | This plugin enables Discord rich presence for your Apple Music! (This only works on macOS with the Music app.) | ||||||
|  | 
 | ||||||
|  |  | ||||||
|  | 
 | ||||||
|  | ## Configuration | ||||||
|  | 
 | ||||||
|  | For the customizable activity format strings, you can use several special strings to include track data in activities! `{name}` is replaced with the track name; `{artist}` is replaced with the artist(s)' name(s); and `{album}` is replaced with the album name. | ||||||
							
								
								
									
										253
									
								
								src/plugins/appleMusic.desktop/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										253
									
								
								src/plugins/appleMusic.desktop/index.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,253 @@ | ||||||
|  | /* | ||||||
|  |  * Vencord, a Discord client mod | ||||||
|  |  * Copyright (c) 2024 Vendicated and contributors | ||||||
|  |  * SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import { definePluginSettings } from "@api/Settings"; | ||||||
|  | import { Devs } from "@utils/constants"; | ||||||
|  | import definePlugin, { OptionType, PluginNative } from "@utils/types"; | ||||||
|  | import { ApplicationAssetUtils, FluxDispatcher, Forms } from "@webpack/common"; | ||||||
|  | 
 | ||||||
|  | const Native = VencordNative.pluginHelpers.AppleMusic as PluginNative<typeof import("./native")>; | ||||||
|  | 
 | ||||||
|  | interface ActivityAssets { | ||||||
|  |     large_image?: string; | ||||||
|  |     large_text?: string; | ||||||
|  |     small_image?: string; | ||||||
|  |     small_text?: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface ActivityButton { | ||||||
|  |     label: string; | ||||||
|  |     url: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface Activity { | ||||||
|  |     state: string; | ||||||
|  |     details?: string; | ||||||
|  |     timestamps?: { | ||||||
|  |         start?: number; | ||||||
|  |         end?: number; | ||||||
|  |     }; | ||||||
|  |     assets?: ActivityAssets; | ||||||
|  |     buttons?: Array<string>; | ||||||
|  |     name: string; | ||||||
|  |     application_id: string; | ||||||
|  |     metadata?: { | ||||||
|  |         button_urls?: Array<string>; | ||||||
|  |     }; | ||||||
|  |     type: number; | ||||||
|  |     flags: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const enum ActivityType { | ||||||
|  |     PLAYING = 0, | ||||||
|  |     LISTENING = 2, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const enum ActivityFlag { | ||||||
|  |     INSTANCE = 1 << 0, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface TrackData { | ||||||
|  |     name: string; | ||||||
|  |     album: string; | ||||||
|  |     artist: string; | ||||||
|  | 
 | ||||||
|  |     appleMusicLink?: string; | ||||||
|  |     songLink?: string; | ||||||
|  | 
 | ||||||
|  |     albumArtwork?: string; | ||||||
|  |     artistArtwork?: string; | ||||||
|  | 
 | ||||||
|  |     playerPosition: number; | ||||||
|  |     duration: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const enum AssetImageType { | ||||||
|  |     Album = "Album", | ||||||
|  |     Artist = "Artist", | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const applicationId = "1239490006054207550"; | ||||||
|  | 
 | ||||||
|  | function setActivity(activity: Activity | null) { | ||||||
|  |     FluxDispatcher.dispatch({ | ||||||
|  |         type: "LOCAL_ACTIVITY_UPDATE", | ||||||
|  |         activity, | ||||||
|  |         socketId: "AppleMusic", | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const settings = definePluginSettings({ | ||||||
|  |     activityType: { | ||||||
|  |         type: OptionType.SELECT, | ||||||
|  |         description: "Which type of activity", | ||||||
|  |         options: [ | ||||||
|  |             { label: "Playing", value: ActivityType.PLAYING, default: true }, | ||||||
|  |             { label: "Listening", value: ActivityType.LISTENING } | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     refreshInterval: { | ||||||
|  |         type: OptionType.SLIDER, | ||||||
|  |         description: "The interval between activity refreshes (seconds)", | ||||||
|  |         markers: [1, 2, 2.5, 3, 5, 10, 15], | ||||||
|  |         default: 5, | ||||||
|  |         restartNeeded: true, | ||||||
|  |     }, | ||||||
|  |     enableTimestamps: { | ||||||
|  |         type: OptionType.BOOLEAN, | ||||||
|  |         description: "Whether or not to enable timestamps", | ||||||
|  |         default: true, | ||||||
|  |     }, | ||||||
|  |     enableButtons: { | ||||||
|  |         type: OptionType.BOOLEAN, | ||||||
|  |         description: "Whether or not to enable buttons", | ||||||
|  |         default: true, | ||||||
|  |     }, | ||||||
|  |     nameString: { | ||||||
|  |         type: OptionType.STRING, | ||||||
|  |         description: "Activity name format string", | ||||||
|  |         default: "Apple Music" | ||||||
|  |     }, | ||||||
|  |     detailsString: { | ||||||
|  |         type: OptionType.STRING, | ||||||
|  |         description: "Activity details format string", | ||||||
|  |         default: "{name}" | ||||||
|  |     }, | ||||||
|  |     stateString: { | ||||||
|  |         type: OptionType.STRING, | ||||||
|  |         description: "Activity state format string", | ||||||
|  |         default: "{artist}" | ||||||
|  |     }, | ||||||
|  |     largeImageType: { | ||||||
|  |         type: OptionType.SELECT, | ||||||
|  |         description: "Activity assets large image type", | ||||||
|  |         options: [ | ||||||
|  |             { label: "Album artwork", value: AssetImageType.Album, default: true }, | ||||||
|  |             { label: "Artist artwork", value: AssetImageType.Artist } | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     largeTextString: { | ||||||
|  |         type: OptionType.STRING, | ||||||
|  |         description: "Activity assets large text format string", | ||||||
|  |         default: "{album}" | ||||||
|  |     }, | ||||||
|  |     smallImageType: { | ||||||
|  |         type: OptionType.SELECT, | ||||||
|  |         description: "Activity assets small image type", | ||||||
|  |         options: [ | ||||||
|  |             { label: "Album artwork", value: AssetImageType.Album }, | ||||||
|  |             { label: "Artist artwork", value: AssetImageType.Artist, default: true } | ||||||
|  |         ], | ||||||
|  |     }, | ||||||
|  |     smallTextString: { | ||||||
|  |         type: OptionType.STRING, | ||||||
|  |         description: "Activity assets small text format string", | ||||||
|  |         default: "{artist}" | ||||||
|  |     }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | function customFormat(formatStr: string, data: TrackData) { | ||||||
|  |     return formatStr | ||||||
|  |         .replaceAll("{name}", data.name) | ||||||
|  |         .replaceAll("{album}", data.album) | ||||||
|  |         .replaceAll("{artist}", data.artist); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function getImageAsset(type: AssetImageType, data: TrackData) { | ||||||
|  |     const source = type === AssetImageType.Album | ||||||
|  |         ? data.albumArtwork | ||||||
|  |         : data.artistArtwork; | ||||||
|  | 
 | ||||||
|  |     if (!source) return undefined; | ||||||
|  | 
 | ||||||
|  |     return ApplicationAssetUtils.fetchAssetIds(applicationId, [source]).then(ids => ids[0]); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default definePlugin({ | ||||||
|  |     name: "AppleMusicRichPresence", | ||||||
|  |     description: "Discord rich presence for your Apple Music!", | ||||||
|  |     authors: [Devs.RyanCaoDev], | ||||||
|  |     hidden: !navigator.platform.startsWith("Mac"), | ||||||
|  | 
 | ||||||
|  |     settingsAboutComponent() { | ||||||
|  |         return <> | ||||||
|  |             <Forms.FormText> | ||||||
|  |                 For the customizable activity format strings, you can use several special strings to include track data in activities!{" "} | ||||||
|  |                 <code>{"{name}"}</code> is replaced with the track name; <code>{"{artist}"}</code> is replaced with the artist(s)' name(s); and <code>{"{album}"}</code> is replaced with the album name. | ||||||
|  |             </Forms.FormText> | ||||||
|  |         </>; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     settings, | ||||||
|  | 
 | ||||||
|  |     start() { | ||||||
|  |         this.updatePresence(); | ||||||
|  |         this.updateInterval = setInterval(() => { this.updatePresence(); }, settings.store.refreshInterval * 1000); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     stop() { | ||||||
|  |         clearInterval(this.updateInterval); | ||||||
|  |         FluxDispatcher.dispatch({ type: "LOCAL_ACTIVITY_UPDATE", activity: null }); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     updatePresence() { | ||||||
|  |         this.getActivity().then(activity => { setActivity(activity); }); | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     async getActivity(): Promise<Activity | null> { | ||||||
|  |         const trackData = await Native.fetchTrackData(); | ||||||
|  |         if (!trackData) return null; | ||||||
|  | 
 | ||||||
|  |         const [largeImageAsset, smallImageAsset] = await Promise.all([ | ||||||
|  |             getImageAsset(settings.store.largeImageType, trackData), | ||||||
|  |             getImageAsset(settings.store.smallImageType, trackData) | ||||||
|  |         ]); | ||||||
|  | 
 | ||||||
|  |         const assets: ActivityAssets = { | ||||||
|  |             large_image: largeImageAsset, | ||||||
|  |             large_text: customFormat(settings.store.largeTextString, trackData), | ||||||
|  |             small_image: smallImageAsset, | ||||||
|  |             small_text: customFormat(settings.store.smallTextString, trackData), | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         const buttons: ActivityButton[] = []; | ||||||
|  | 
 | ||||||
|  |         if (settings.store.enableButtons) { | ||||||
|  |             if (trackData.appleMusicLink) | ||||||
|  |                 buttons.push({ | ||||||
|  |                     label: "Listen on Apple Music", | ||||||
|  |                     url: trackData.appleMusicLink, | ||||||
|  |                 }); | ||||||
|  | 
 | ||||||
|  |             if (trackData.songLink) | ||||||
|  |                 buttons.push({ | ||||||
|  |                     label: "View on SongLink", | ||||||
|  |                     url: trackData.songLink, | ||||||
|  |                 }); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return { | ||||||
|  |             application_id: applicationId, | ||||||
|  | 
 | ||||||
|  |             name: customFormat(settings.store.nameString, trackData), | ||||||
|  |             details: customFormat(settings.store.detailsString, trackData), | ||||||
|  |             state: customFormat(settings.store.stateString, trackData), | ||||||
|  | 
 | ||||||
|  |             timestamps: (settings.store.enableTimestamps ? { | ||||||
|  |                 start: Date.now() - (trackData.playerPosition * 1000), | ||||||
|  |                 end: Date.now() - (trackData.playerPosition * 1000) + (trackData.duration * 1000), | ||||||
|  |             } : undefined), | ||||||
|  | 
 | ||||||
|  |             assets, | ||||||
|  | 
 | ||||||
|  |             buttons: buttons.length ? buttons.map(v => v.label) : undefined, | ||||||
|  |             metadata: { button_urls: buttons.map(v => v.url) || undefined, }, | ||||||
|  | 
 | ||||||
|  |             type: settings.store.activityType, | ||||||
|  |             flags: ActivityFlag.INSTANCE, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  | }); | ||||||
							
								
								
									
										120
									
								
								src/plugins/appleMusic.desktop/native.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/plugins/appleMusic.desktop/native.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,120 @@ | ||||||
|  | /* | ||||||
|  |  * Vencord, a Discord client mod | ||||||
|  |  * Copyright (c) 2024 Vendicated and contributors | ||||||
|  |  * SPDX-License-Identifier: GPL-3.0-or-later | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | import { execFile } from "child_process"; | ||||||
|  | import { promisify } from "util"; | ||||||
|  | 
 | ||||||
|  | import type { TrackData } from "."; | ||||||
|  | 
 | ||||||
|  | const exec = promisify(execFile); | ||||||
|  | 
 | ||||||
|  | // function exec(file: string, args: string[] = []) {
 | ||||||
|  | //     return new Promise<{ code: number | null, stdout: string | null, stderr: string | null; }>((resolve, reject) => {
 | ||||||
|  | //         const process = spawn(file, args, { stdio: [null, "pipe", "pipe"] });
 | ||||||
|  | 
 | ||||||
|  | //         let stdout: string | null = null;
 | ||||||
|  | //         process.stdout.on("data", (chunk: string) => { stdout ??= ""; stdout += chunk; });
 | ||||||
|  | //         let stderr: string | null = null;
 | ||||||
|  | //         process.stderr.on("data", (chunk: string) => { stdout ??= ""; stderr += chunk; });
 | ||||||
|  | 
 | ||||||
|  | //         process.on("exit", code => { resolve({ code, stdout, stderr }); });
 | ||||||
|  | //         process.on("error", err => reject(err));
 | ||||||
|  | //     });
 | ||||||
|  | // }
 | ||||||
|  | 
 | ||||||
|  | async function applescript(cmds: string[]) { | ||||||
|  |     const { stdout } = await exec("osascript", cmds.map(c => ["-e", c]).flat()); | ||||||
|  |     return stdout; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function makeSearchUrl(type: string, query: string) { | ||||||
|  |     const url = new URL("https://tools.applemediaservices.com/api/apple-media/music/US/search.json"); | ||||||
|  |     url.searchParams.set("types", type); | ||||||
|  |     url.searchParams.set("limit", "1"); | ||||||
|  |     url.searchParams.set("term", query); | ||||||
|  |     return url; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const requestOptions: RequestInit = { | ||||||
|  |     headers: { "user-agent": "Mozilla/5.0 (Windows NT 10.0; rv:125.0) Gecko/20100101 Firefox/125.0" }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | interface RemoteData { | ||||||
|  |     appleMusicLink?: string, | ||||||
|  |     songLink?: string, | ||||||
|  |     albumArtwork?: string, | ||||||
|  |     artistArtwork?: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | let cachedRemoteData: { id: string, data: RemoteData; } | { id: string, failures: number; } | null = null; | ||||||
|  | 
 | ||||||
|  | async function fetchRemoteData({ id, name, artist, album }: { id: string, name: string, artist: string, album: string; }) { | ||||||
|  |     if (id === cachedRemoteData?.id) { | ||||||
|  |         if ("data" in cachedRemoteData) return cachedRemoteData.data; | ||||||
|  |         if ("failures" in cachedRemoteData && cachedRemoteData.failures >= 5) return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |         const [songData, artistData] = await Promise.all([ | ||||||
|  |             fetch(makeSearchUrl("songs", artist + " " + album + " " + name), requestOptions).then(r => r.json()), | ||||||
|  |             fetch(makeSearchUrl("artists", artist.split(/ *[,&] */)[0]), requestOptions).then(r => r.json()) | ||||||
|  |         ]); | ||||||
|  | 
 | ||||||
|  |         const appleMusicLink = songData?.songs?.data[0]?.attributes.url; | ||||||
|  |         const songLink = songData?.songs?.data[0]?.id ? `https://song.link/i/${songData?.songs?.data[0]?.id}` : undefined; | ||||||
|  | 
 | ||||||
|  |         const albumArtwork = songData?.songs?.data[0]?.attributes.artwork.url.replace("{w}", "512").replace("{h}", "512"); | ||||||
|  |         const artistArtwork = artistData?.artists?.data[0]?.attributes.artwork.url.replace("{w}", "512").replace("{h}", "512"); | ||||||
|  | 
 | ||||||
|  |         cachedRemoteData = { | ||||||
|  |             id, | ||||||
|  |             data: { appleMusicLink, songLink, albumArtwork, artistArtwork } | ||||||
|  |         }; | ||||||
|  |         return cachedRemoteData.data; | ||||||
|  |     } catch (e) { | ||||||
|  |         console.error("[AppleMusicRichPresence] Failed to fetch remote data:", e); | ||||||
|  |         cachedRemoteData = { | ||||||
|  |             id, | ||||||
|  |             failures: (id === cachedRemoteData?.id && "failures" in cachedRemoteData ? cachedRemoteData.failures : 0) + 1 | ||||||
|  |         }; | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function fetchTrackData(): Promise<TrackData | null> { | ||||||
|  |     try { | ||||||
|  |         await exec("pgrep", ["^Music$"]); | ||||||
|  |     } catch (error) { | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const playerState = await applescript(['tell application "Music"', "get player state", "end tell"]) | ||||||
|  |         .then(out => out.trim()); | ||||||
|  |     if (playerState !== "playing") return null; | ||||||
|  | 
 | ||||||
|  |     const playerPosition = await applescript(['tell application "Music"', "get player position", "end tell"]) | ||||||
|  |         .then(text => Number.parseFloat(text.trim())); | ||||||
|  | 
 | ||||||
|  |     const stdout = await applescript([ | ||||||
|  |         'set output to ""', | ||||||
|  |         'tell application "Music"', | ||||||
|  |         "set t_id to database id of current track", | ||||||
|  |         "set t_name to name of current track", | ||||||
|  |         "set t_album to album of current track", | ||||||
|  |         "set t_artist to artist of current track", | ||||||
|  |         "set t_duration to duration of current track", | ||||||
|  |         'set output to "" & t_id & "\\n" & t_name & "\\n" & t_album & "\\n" & t_artist & "\\n" & t_duration', | ||||||
|  |         "end tell", | ||||||
|  |         "return output" | ||||||
|  |     ]); | ||||||
|  | 
 | ||||||
|  |     const [id, name, album, artist, durationStr] = stdout.split("\n").filter(k => !!k); | ||||||
|  |     const duration = Number.parseFloat(durationStr); | ||||||
|  | 
 | ||||||
|  |     const remoteData = await fetchRemoteData({ id, name, artist, album }); | ||||||
|  | 
 | ||||||
|  |     return { name, album, artist, playerPosition, duration, ...remoteData }; | ||||||
|  | } | ||||||
|  | @ -85,6 +85,10 @@ export interface PluginDef { | ||||||
|      * Whether this plugin is required and forcefully enabled |      * Whether this plugin is required and forcefully enabled | ||||||
|      */ |      */ | ||||||
|     required?: boolean; |     required?: boolean; | ||||||
|  |     /** | ||||||
|  |      * Whether this plugin should be hidden from the user | ||||||
|  |      */ | ||||||
|  |     hidden?: boolean; | ||||||
|     /** |     /** | ||||||
|      * Whether this plugin should be enabled by default, but can be disabled |      * Whether this plugin should be enabled by default, but can be disabled | ||||||
|      */ |      */ | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue