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", | ||||
|     "javascript.preferences.quoteStyle": "double", | ||||
| 
 | ||||
|     "eslint.experimental.useFlatConfig": false, | ||||
| 
 | ||||
|     "gitlens.remotes": [ | ||||
|         { | ||||
|             "domain": "codeberg.org", | ||||
|  |  | |||
|  | @ -261,8 +261,9 @@ export default function PluginSettings() { | |||
|         plugins = []; | ||||
|         requiredPlugins = []; | ||||
| 
 | ||||
|         const showApi = searchValue.value === "API"; | ||||
|         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; | ||||
| 
 | ||||
|             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 | ||||
|      */ | ||||
|     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 | ||||
|      */ | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue