Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 43 additions & 44 deletions src/main/discord.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,26 @@
import { ActivityType, Client } from "@nyabsi/minimal-discord-rpc";
import { ActivityType, Client, StatusDisplayType } from "@nyabsi/minimal-discord-rpc";
import type { IRichPresenceInfo } from "@shared/types.js";

import { APP_VERSION } from "./mainWindow.js";

const DISCORD_CLIENT_ID = "976036303156162570";

export type FormatIcons = "aiff" | "flac" | "mpeg" | "ogg" | "wave";

export interface IRichPresenceInfo {
title: string;
album: string;
timestamps: {
start: number;
end: number;
};
coverUrl: string;
containerFormat: FormatIcons;
pauseStatus: string;
}
export const DEFAULT_DISCORD_CLIENT_ID = "976036303156162570";

export class Discord {
private readonly client: Client;
private client: Client;

private readonly connected: Promise<boolean>;
private connected: Promise<boolean>;

private destroyed: boolean;

constructor() {
this.client = new Client({ clientId: DISCORD_CLIENT_ID });
private clientId: string;

this.connected = this.connect();
constructor(clientId: string = DEFAULT_DISCORD_CLIENT_ID) {
this.clientId = clientId || DEFAULT_DISCORD_CLIENT_ID;
this.client = new Client({ clientId: this.clientId });
this.destroyed = false;
this.bindEvents();
this.connected = this.connect();
}

private bindEvents(): void {
this.client.on("ready", () => {
// Do something when ready
});
Expand All @@ -45,6 +35,22 @@ export class Discord {
});
}

/**
* Swaps the RPC client over to a different Discord application (client) id, e.g. when the
* user configures a custom one in settings. No-op if the id hasn't actually changed.
*/
public setClientId(clientId: string): void {
const id = clientId || DEFAULT_DISCORD_CLIENT_ID;
if (id === this.clientId) return;

this.clientId = id;
this.client.destroy().catch(() => {});
this.client = new Client({ clientId: id });
this.destroyed = false;
this.bindEvents();
this.connected = this.connect();
}

public clearRichPresence(): void {
this.connected.then((check) => {
if (check && !this.destroyed) {
Expand All @@ -57,30 +63,23 @@ export class Discord {
this.connected.then((check) => {
if (check && !this.destroyed) {
this.client.setActivity({
name: info.activityName || undefined,
type: ActivityType.Listening,
details: info.title.toString(),
state: info.album.toString(),
timestamps: info.pauseStatus == "yes"
? {
start: info.timestamps.end,
end: info.timestamps.end,
}
: {
start: info.timestamps.start,
end: info.timestamps.end,
},
details: info.details || undefined,
state: info.state || undefined,
timestamps: info.timestamps,
assets: {
large_image: info.coverUrl !== "" ? `${info.coverUrl}` : "audio_file",
large_text: info.pauseStatus == "yes" ? `Paused - ${info.containerFormat?.toUpperCase() || "Unknown Format"}` : info.containerFormat?.toUpperCase() || "Unknown Format",
small_image: "logo",
small_text: `Amethyst ${APP_VERSION}\n`,
large_image: info.largeImageKey || "audio_file",
large_text: info.largeImageText || undefined,
small_image: info.smallImageKey || undefined,
small_text: info.smallImageText || undefined,
},
buttons: [
{
label: "Find Song",
url: `https://www.youtube.com/results?search_query=${encodeURIComponent(info.title.toString())}`,
},
],
buttons: info.buttonEnabled && info.buttonUrl
? [{ label: info.buttonLabel, url: info.buttonUrl }]
: undefined,
// Picks which of the app's own name/state/details fills the "{name}" slot in the compact
// status text shown in a member list/DM sidebar (e.g. "Listening to {this}").
status_display_type: StatusDisplayType[info.statusDisplayType],
});
}
});
Expand Down
22 changes: 6 additions & 16 deletions src/main/mainWindow.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IRichPresenceInfo } from "@shared/types.js";
import chalk from "chalk";
import type { FSWatcher } from "chokidar";
import chokidar from "chokidar";
Expand All @@ -12,7 +13,6 @@ import os from "os";
import path from "path";

import { ALLOWED_AUDIO_EXTENSIONS } from "../shared/constants.js";
import type { FormatIcons, IRichPresenceInfo } from "./discord.js";
import { Discord } from "./discord.js";
import { getWindow, IS_DEV, store } from "./main.js";
import { __dirname } from "./utility.js";
Expand Down Expand Up @@ -327,24 +327,14 @@ export class MainWindow {
};
},

"update-rich-presence": (_: Event, [args]: string[]) => {
const [title, album, start, end, cover, format, paused] = args;

const info: IRichPresenceInfo = {
title: title,
album: album,
timestamps: {
start: parseInt(start),
end: (parseInt(start) + parseInt(end) * 1000),
},
coverUrl: cover,
containerFormat: format as FormatIcons,
pauseStatus: paused,
};

"update-rich-presence": (_: Event, [info]: [IRichPresenceInfo]) => {
this.discord.updateCurrentSong(info);
},

"set-discord-client-id": (_: Event, [clientId]: [string]) => {
this.discord.setClientId(clientId);
},

"set-vsync": (_: Event, [useVsync]: string[]) => {
store.set("useVsync", useVsync);
console.log(`Set store 'frameRateLimit' to ${useVsync}`);
Expand Down
94 changes: 80 additions & 14 deletions src/renderer/amethyst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { StatusBar } from "@capacitor/status-bar";
import { NavigationBar } from "@hugotomazi/capacitor-navigation-bar";
import messages from "@intlify/unplugin-vue-i18n/messages";
import { ALLOWED_AUDIO_EXTENSIONS } from "@shared/constants.js";
import type { DiscordRpcField, IRichPresenceInfo } from "@shared/types.js";
import { useLocalStorage } from "@vueuse/core";
import type { OpenDialogReturnValue, SaveDialogReturnValue } from "electron";
import { ref, watch } from "vue";
Expand Down Expand Up @@ -453,24 +454,85 @@ export class Amethyst extends AmethystBackend {
let seekDuringPause: boolean = false;
let trackNameBeforePause: string;

const findMediaSourceForTrack = (track: Track) => {
return this.mediaSourceManager.mediaSources.value.find((source) => {
if (source.type !== track.sourceType) return false;
if (track.sourceType === MediaSourceType.LocalFolder) return track.path.startsWith(source.path);
return track.credentials?.url === source.path;
});
};

// Resolves which image key to send Discord: the track's own source-provided art if
// Discord-wide cover art is on and that specific media source allows sharing its art,
// otherwise falls back to a public MusicBrainz lookup, otherwise the static fallback asset.
const resolveCoverArtKey = async (track: Track): Promise<string> => {
const discord = this.state.settings.integrations.discord;
if (!discord.showCoverArt) return "audio_file";

const source = findMediaSourceForTrack(track);
const sourceAllowsCoverArt = source?.sendCoverArtToDiscord ?? true;

if (sourceAllowsCoverArt) {
await track.fetchAlbumCoverUrl();
if (track.coverUrl) return track.coverUrl;
}

const musicBrainzCoverUrl = await track.fetchMusicBrainzCoverUrl();
return musicBrainzCoverUrl || "audio_file";
};

const resolveField = (field: DiscordRpcField, track: Track): string => {
const title = track.getTitleFormatted();
const artist = track.getArtistsFormatted() || "Unknown Artist";
const album = track.getAlbum() || "Unknown Album";
const format = track.getContainer()?.toUpperCase() || "Unknown Format";

switch (field) {
case "Title": return title;
case "Artist": return artist;
case "Album": return album;
case "Artist - Album": return `${artist} - ${album}`;
case "Title - Artist": return `${title} - ${artist}`;
case "Format": return format;
case "App Info": return `Amethyst ${this.VERSION}`;
case "None":
default: return "";
}
};

const clearRichPresence = () => {
richPresenceTimer && clearInterval(richPresenceTimer);
window.electron.ipcRenderer.invoke("clear-rich-presence");
};

const updateRichPresence = async (track: Track) => {
const updateRichPresence = async (track: Track, coverArtKey: string) => {
const sendData = () => {
const args = [
track.getArtists() && track.getTitle() ? `${track.getTitle()}` : track.getFilename(),
`${track.getArtists()} - ${track.getAlbum()}`,
start.toString(),
(track.getDurationSeconds() as number).toString(),
track.sourceType != MediaSourceType.Subsonic && track.coverUrl,
track.getContainer()?.toLowerCase() || "unknown format",
isPaused ? "yes" : "no",
];
window.electron.ipcRenderer.invoke("update-rich-presence", [args]);
if (this.IS_DEV) console.log(`%c[⚐ Discord RPC]%c Updated RPC status`, "background-color: #7289da; color: black; font-weight: bold;", "color: #ffffff;", args);
const discord = this.state.settings.integrations.discord;

let largeImageText = resolveField(discord.fields.largeImageText, track);
if (isPaused) largeImageText = largeImageText ? `Paused - ${largeImageText}` : "Paused";

const trackEnd = start + (track.getDurationSeconds() as number) * 1000;

const info: IRichPresenceInfo = {
activityName: resolveField(discord.fields.activityName, track),
details: resolveField(discord.fields.details, track),
state: resolveField(discord.fields.state, track),
largeImageKey: coverArtKey,
largeImageText,
smallImageKey: "logo",
smallImageText: resolveField(discord.fields.smallImageText, track),
timestamps: isPaused
? { start: trackEnd, end: trackEnd }
: { start, end: trackEnd },
buttonEnabled: discord.showFindSongButton,
buttonLabel: "Find Song",
buttonUrl: `https://www.youtube.com/results?search_query=${encodeURIComponent(track.getTitleFormatted())}`,
statusDisplayType: discord.statusDisplayType,
};

window.electron.ipcRenderer.invoke("update-rich-presence", [info]);
if (this.IS_DEV) console.log(`%c[⚐ Discord RPC]%c Updated RPC status`, "background-color: #7289da; color: black; font-weight: bold;", "color: #ffffff;", info);
};

richPresenceTimer && clearInterval(richPresenceTimer);
Expand All @@ -481,11 +543,15 @@ export class Amethyst extends AmethystBackend {
const updateWithCurrentTrack = async () => {
if (this.getCurrentPlatform() == "mobile") return;
const currentTrack = this.player.getCurrentTrack();
await currentTrack?.fetchAlbumCoverUrl();
if (!currentTrack) return;
await updateRichPresence(currentTrack);
const coverArtKey = await resolveCoverArtKey(currentTrack);
await updateRichPresence(currentTrack, coverArtKey);
};

watch(() => this.state.settings.integrations.discord.clientId, (clientId) => {
window.electron.ipcRenderer.invoke("set-discord-client-id", [clientId]);
}, { immediate: true });

if (this.state.settings.integrations.discord.enabled) {
this.player.on("player:trackChange", async (track) => {
if (isPaused && trackNameBeforePause == track.getTitleFormatted()) {
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"settings.subsonic.password": "Password",
"settings.media_source_type.local_folder": "Local folder",
"settings.media_sources.tracks": "Tracks",
"settings.media_sources.send_cover_art_to_discord": "Cover art in Discord",
"settings.media_sources.connected": "Connected",
"settings.media_sources.disconnected": "Disconnected",
"settings.media_source_type.subsonic": "Subsonic server",
Expand Down Expand Up @@ -183,6 +184,24 @@
"settings.appearance.debug_stats.title": "Debug statistics",
"settings.appearance.debug_stats.description": "Show numbers such as samples, FPS, processor usage and more at the top bar",
"settings.discord_rpc.description": "Discord Rich Presence allows users to share their current activity on Amethyst with Discord. When enabled, the RPC feature updates the user's Discord status to show the version of Amethyst, currently playing song or playlist, along with the artist and album",
"settings.discord_rpc.client_id.title": "Application ID",
"settings.discord_rpc.client_id.description": "Use a custom Discord application ID instead of Amethyst's default one. Leave empty to use the default. Note that a custom application needs its own 'logo' and 'audio_file' assets uploaded in the Discord Developer Portal or the images won't show up",
"settings.discord_rpc.find_song_button.title": "Find song button",
"settings.discord_rpc.find_song_button.description": "Show a button on your Discord profile that lets others search for the current song on YouTube",
"settings.discord_rpc.cover_art.title": "Cover art",
"settings.discord_rpc.cover_art.description": "Show album cover art in your Discord status. Whether a specific media source's own art is used can be configured per source in Media Sources - sources that opt out still fall back to MusicBrainz's public cover art archive when a match is found",
"settings.discord_rpc.fields.activity_name.title": "Activity name",
"settings.discord_rpc.fields.activity_name.description": "Overrides what's shown in place of \"Amethyst\" on the MAIN activity card and everywhere else Discord shows the activity's name (e.g. \"Listening to 'Artist name or Song name here'\"), not just the member list. Leave as None to keep showing application name",
"settings.discord_rpc.status_display_type.title": "Member list status",
"settings.discord_rpc.status_display_type.description": "What shows up in place of \"Amethyst\" in your compact status text in a member list or DM sidebar (e.g. \"Listening to 'Artist name or Song name here'\"). This is separate from the main activity card and doesn't rename the activity itself, so it can be set independently of the activity name above",
"settings.discord_rpc.fields.details.title": "Details",
"settings.discord_rpc.fields.details.description": "The top line of text shown on your Discord status",
"settings.discord_rpc.fields.state.title": "State",
"settings.discord_rpc.fields.state.description": "The second line of text shown on your Discord status",
"settings.discord_rpc.fields.large_image_text.title": "Cover art tooltip",
"settings.discord_rpc.fields.large_image_text.description": "The text shown when hovering over the cover art",
"settings.discord_rpc.fields.small_image_text.title": "Logo tooltip",
"settings.discord_rpc.fields.small_image_text.description": "The text shown when hovering over the small Amethyst logo",
"settings.lastfm.description": "Last.fm integration allows users to scrobble their music listening history to Last.fm. When enabled, Amethyst will automatically send the currently playing track information to Last.fm, allowing users to keep track of their listening habits and discover new music based on their preferences",
"settings.lastfm.username.title": "Username",
"settings.lastfm.username.description": "Your Last.fm username",
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/logic/MediaSource/JellyfinMediaSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,24 @@ export class JellyfinMediaSource extends MediaSource {

public serverInformation: JellyfinPublicSystemInfo | undefined;

public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string, scrobble = true) {
public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string, scrobble = true, sendCoverArtToDiscord = true) {
super(amethyst, url);
this.type = MediaSourceType.Jellyfin;
this.name = this.url;
this.isScrobblingEnabled = ref(scrobble);
this.sendCoverArtToDiscord = ref(sendCoverArtToDiscord);

// Persist toggling the switch in Settings back into the saved source entry
watch(this.isScrobblingEnabled, (enabled) => {
const saved = this.amethyst.state.settings.mediaSources.saveMediaSources.find((s) => s.type == MediaSourceType.Jellyfin && s.url == this.url);
if (saved) saved.scrobble = enabled;
});

watch(this.sendCoverArtToDiscord, (enabled) => {
const saved = this.amethyst.state.settings.mediaSources.saveMediaSources.find((s) => s.type == MediaSourceType.Jellyfin && s.url == this.url);
if (saved) saved.sendCoverArtToDiscord = enabled;
});

this.setupScrobbling();
this.initialize();
}
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/logic/MediaSource/MediaSourceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ export class MediaSourceManager {

if (savedSource.type == MediaSourceType.Subsonic) {
// @ts-ignore
this.mediaSources.value.push(new SubsonicMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password));
this.mediaSources.value.push(new SubsonicMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password, savedSource.sendCoverArtToDiscord ?? true));
}

if (savedSource.type == MediaSourceType.Jellyfin) {
// @ts-ignore
this.mediaSources.value.push(new JellyfinMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password, savedSource.scrobble ?? true));
this.mediaSources.value.push(new JellyfinMediaSource(this.amethyst, savedSource.url, savedSource.username, savedSource.password, savedSource.scrobble ?? true, savedSource.sendCoverArtToDiscord ?? true));
}
});
}
Expand Down
11 changes: 9 additions & 2 deletions src/renderer/logic/MediaSource/SubsonicMediaSource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SubsonicAPI, { Child, SubsonicBaseResponse } from "subsonic-api";
import { Ref, ref } from "vue";
import { Ref, ref, watch } from "vue";

import { Amethyst } from "@/amethyst.js";
import { MediaSource, MediaSourceType } from "@/logic//MediaSource/index.js";
Expand All @@ -16,10 +16,11 @@ export class SubsonicMediaSource extends MediaSource {
private shouldStopSync = false;

public serverInformation: SubsonicBaseResponse | undefined;
public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string) {
public constructor(protected amethyst: Amethyst, public url: string, public username: string, public password: string, sendCoverArtToDiscord = true) {
super(amethyst, url);
this.type = MediaSourceType.Subsonic;
this.name = this.url;
this.sendCoverArtToDiscord = ref(sendCoverArtToDiscord);
this.api = new SubsonicAPI({
url: this.url,
auth: {
Expand All @@ -28,6 +29,12 @@ export class SubsonicMediaSource extends MediaSource {
},
});

// Persist toggling the switch in Settings back into the saved source entry
watch(this.sendCoverArtToDiscord, (enabled) => {
const saved = this.amethyst.state.settings.mediaSources.saveMediaSources.find((s) => s.type == MediaSourceType.Subsonic && s.url == this.url);
if (saved) saved.sendCoverArtToDiscord = enabled;
});

this.initialize();
}

Expand Down
1 change: 1 addition & 0 deletions src/renderer/logic/MediaSource/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class MediaSource {
public totalTracks: Ref<number> = ref(0);
public totalBytes: Ref<number> = ref(0);
public name: string = "generic";
public sendCoverArtToDiscord: Ref<boolean> = ref(false);

public constructor(protected amethyst: Amethyst, public path: string) {

Expand Down
Loading
Loading