feat(discord): add server cover art source (#462)

* feat(discord): add server cover art source

The old Apple Music toggle is replaced with a radio selector which let's you choose
between Apple Music, Server and no image.

It's important to note that the server needs to be publicly accessible.

Translations have been added for all locales

* feat(discord): toggle UI for cover source and tightened defaults

- Replace cover-source radio buttons with three indented sub-toggles
  (none / server / apple) under Discord Rich Presence; mutex via
  setDiscordCoverSource — turning one on flips the others off.
- Default discordCoverSource is now 'server' for fresh installs
  (opt-in friendly: own server, no third-party data leak). Existing
  users keep their state via the legacy bool migration.
- Tighten template defaults: details {artist}, state {title}, largeText
  unchanged. Existing users keep their persisted values.

* docs(contributors): credit Sayykii + changelog entry for #462

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
Sayykii
2026-05-05 22:30:23 +03:00
committed by GitHub
parent 8d8c1aa8a3
commit e1f2cb4c37
13 changed files with 159 additions and 51 deletions
+15 -6
View File
@@ -26,6 +26,7 @@ export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thic
export type LoggingMode = 'off' | 'normal' | 'debug';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
export type AnimationMode = 'full' | 'reduced' | 'static';
export type DiscordCoverSource = 'none' | 'apple' | 'server';
/** Integrated-loudness target presets (Settings + analysis). */
export type LoudnessLufsPreset = -16 | -14 | -12 | -10;
@@ -136,7 +137,7 @@ interface AuthState {
* touch Orbit can hide it so the header stays uncluttered. */
showOrbitTrigger: boolean;
discordRichPresence: boolean;
enableAppleMusicCoversDiscord: boolean;
discordCoverSource: DiscordCoverSource;
/** Opt-in: fetch upcoming tour dates from Bandsintown for the Now-Playing info panel. */
enableBandsintown: boolean;
discordTemplateDetails: string;
@@ -311,7 +312,7 @@ interface AuthState {
setMinimizeToTray: (v: boolean) => void;
setShowOrbitTrigger: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setEnableAppleMusicCoversDiscord: (v: boolean) => void;
setDiscordCoverSource: (v: DiscordCoverSource) => void;
setEnableBandsintown: (v: boolean) => void;
setDiscordTemplateDetails: (v: string) => void;
setDiscordTemplateState: (v: string) => void;
@@ -448,10 +449,10 @@ export const useAuthStore = create<AuthState>()(
minimizeToTray: false,
showOrbitTrigger: true,
discordRichPresence: false,
enableAppleMusicCoversDiscord: false,
discordCoverSource: 'server',
enableBandsintown: false,
discordTemplateDetails: '{artist} - {title}',
discordTemplateState: '{album}',
discordTemplateDetails: '{artist}',
discordTemplateState: '{title}',
discordTemplateLargeText: '{album}',
useCustomTitlebar: false,
preloadMiniPlayer: false,
@@ -611,7 +612,7 @@ export const useAuthStore = create<AuthState>()(
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }),
setDiscordCoverSource: (v) => set({ discordCoverSource: v }),
setEnableBandsintown: (v) => set({ enableBandsintown: v }),
setDiscordTemplateDetails: (v) => set({ discordTemplateDetails: v }),
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
@@ -864,6 +865,13 @@ export const useAuthStore = create<AuthState>()(
)
: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB);
// Migrate enableAppleMusicCoversDiscord boolean → discordCoverSource enum.
let discordCoverSourceMigrated: { discordCoverSource?: DiscordCoverSource } = {};
const legacyAppleCovers = (state as { enableAppleMusicCoversDiscord?: unknown }).enableAppleMusicCoversDiscord;
if (legacyAppleCovers === true && (!state.discordCoverSource || state.discordCoverSource === 'none')) {
discordCoverSourceMigrated = { discordCoverSource: 'apple' };
}
useAuthStore.setState({
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
@@ -880,6 +888,7 @@ export const useAuthStore = create<AuthState>()(
...wheelSmoothOneTime,
...seekbarStyleMigrated,
...animationModeMigrated,
...discordCoverSourceMigrated,
});
},
}
+38 -19
View File
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { showToast } from '../utils/toast';
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic';
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
@@ -1968,13 +1968,15 @@ export function initAudioListeners(): () => void {
let discordPrevTemplateDetails: string | null = null;
let discordPrevTemplateState: string | null = null;
let discordPrevTemplateLargeText: string | null = null;
let discordPrevCoverSource: string | null = null;
const discordServerCoverCache = new Map<string, string | null>();
function syncDiscord() {
const { currentTrack, isPlaying } = usePlayerStore.getState();
const currentTime = getPlaybackProgressSnapshot().currentTime;
const {
discordRichPresence,
enableAppleMusicCoversDiscord,
discordCoverSource,
discordTemplateDetails,
discordTemplateState,
discordTemplateLargeText,
@@ -1985,6 +1987,7 @@ export function initAudioListeners(): () => void {
discordPrevTrackId = null;
discordPrevIsPlaying = null;
discordPrevFetchCovers = null;
discordPrevCoverSource = null;
discordPrevTemplateDetails = null;
discordPrevTemplateState = null;
discordPrevTemplateLargeText = null;
@@ -1995,33 +1998,49 @@ export function initAudioListeners(): () => void {
const trackChanged = currentTrack.id !== discordPrevTrackId;
const playingChanged = isPlaying !== discordPrevIsPlaying;
const coversSettingChanged = enableAppleMusicCoversDiscord !== discordPrevFetchCovers;
const coverSourceChanged = discordCoverSource !== discordPrevCoverSource;
const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails;
const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState;
const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText;
if (!trackChanged && !playingChanged && !coversSettingChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return;
if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return;
discordPrevTrackId = currentTrack.id;
discordPrevIsPlaying = isPlaying;
discordPrevFetchCovers = enableAppleMusicCoversDiscord;
discordPrevFetchCovers = discordCoverSource === 'apple';
discordPrevCoverSource = discordCoverSource;
discordPrevTemplateDetails = discordTemplateDetails;
discordPrevTemplateState = discordTemplateState;
discordPrevTemplateLargeText = discordTemplateLargeText;
invoke('discord_update_presence', {
title: currentTrack.title,
artist: currentTrack.artist ?? 'Unknown Artist',
album: currentTrack.album ?? null,
isPlaying,
elapsedSecs: isPlaying ? currentTime : null,
// coverArtUrl is intentionally not passed — Subsonic URLs require auth.
// iTunes cover fetching is only done when explicitly opted in.
coverArtUrl: null,
fetchItunesCovers: enableAppleMusicCoversDiscord,
detailsTemplate: discordTemplateDetails,
stateTemplate: discordTemplateState,
largeTextTemplate: discordTemplateLargeText,
}).catch(() => {});
const sendPresence = (coverArtUrl: string | null) => {
invoke('discord_update_presence', {
title: currentTrack.title,
artist: currentTrack.artist ?? 'Unknown Artist',
album: currentTrack.album ?? null,
isPlaying,
elapsedSecs: isPlaying ? currentTime : null,
coverArtUrl,
fetchItunesCovers: discordCoverSource === 'apple',
detailsTemplate: discordTemplateDetails,
stateTemplate: discordTemplateState,
largeTextTemplate: discordTemplateLargeText,
}).catch(() => {});
};
if (discordCoverSource === 'server' && currentTrack.albumId) {
const cached = discordServerCoverCache.get(currentTrack.albumId);
if (cached !== undefined) {
sendPresence(cached);
} else {
getAlbumInfo2(currentTrack.albumId).then(info => {
const url = info?.largeImageUrl || info?.mediumImageUrl || info?.smallImageUrl || null;
discordServerCoverCache.set(currentTrack.albumId, url);
sendPresence(url);
});
}
} else {
sendPresence(null);
}
}
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);