diff --git a/src/components/motion/tabs.tsx b/src/components/motion/tabs.tsx index b9602513..c2f091b1 100644 --- a/src/components/motion/tabs.tsx +++ b/src/components/motion/tabs.tsx @@ -113,7 +113,6 @@ export function TabsTrigger({ }) { const { value: current, setValue, layoutId, variant } = useTabs(); const active = current === value; - const usesDefaultIndicator = indicatorClassName === undefined; if (variant === "underline") { return ( @@ -142,9 +141,8 @@ export function TabsTrigger({ ); } - // The default max-contrast pill uses exclusion so labels invert exactly as - // the indicator passes beneath them. Custom indicators retain explicit text - // colors because their background may not be suitable for blending. + // Selected labels use the theme foreground directly. `mix-blend-exclusion` made white + // text turn cyan against the primary red and visibly flicker during hover transitions. const radius = variant === "pill" ? "rounded-full" : "rounded-md"; return ( @@ -167,16 +165,8 @@ export function TabsTrigger({ onClick={() => setValue(value)} className={cn( "relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none", - usesDefaultIndicator - ? "text-white mix-blend-exclusion transition-opacity" - : "transition-colors", - usesDefaultIndicator - ? active - ? "opacity-100" - : "opacity-70 hover:opacity-100" - : active - ? "text-primary-foreground" - : "text-muted-foreground hover:text-foreground", + "transition-colors", + active ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground", radius, className, )} diff --git a/src/player/DiscordRPC.check.ts b/src/player/DiscordRPC.check.ts index f82e742a..54e7913a 100644 --- a/src/player/DiscordRPC.check.ts +++ b/src/player/DiscordRPC.check.ts @@ -8,7 +8,12 @@ */ export {}; -import { presenceDedupeKey, type DiscordPresenceData } from "./DiscordRPC"; +import { + createPresenceSynchronizer, + presenceDedupeKey, + shouldClearPresence, + type DiscordPresenceData, +} from "./DiscordRPC"; function check(condition: boolean, message: string): void { if (!condition) throw new Error(`FAILED: ${message}`); @@ -39,4 +44,62 @@ check( "play/pause change is not deduped away", ); +check(shouldClearPresence("paused", true, true), "paused presence clears when enabled"); +check(!shouldClearPresence("paused", true, false), "paused presence remains when disabled"); +check(shouldClearPresence("idle", false, false), "idle presence clears"); +check(shouldClearPresence("loading", true, false), "loading presence clears"); + +const calls: string[] = []; +const synchronizer = createPresenceSynchronizer({ + clear: async () => { calls.push("clear"); }, + update: async () => { calls.push("update"); }, +}); +await synchronizer.sync({ data: base, status: "paused" }, { enabled: true, hideWhenPaused: true }); +await synchronizer.sync({ data: base, status: "paused" }, { enabled: true, hideWhenPaused: true }); +await synchronizer.sync({ data: { ...base, isPlaying: true }, status: "playing" }, { enabled: true, hideWhenPaused: true }); +check(calls.join(",") === "clear,update", "paused clear is deduped and resume republishes"); + +const toggleCalls: string[] = []; +const toggleSynchronizer = createPresenceSynchronizer({ + clear: async () => { toggleCalls.push("clear"); }, + update: async () => { toggleCalls.push("update"); }, +}); +await toggleSynchronizer.sync({ data: base, status: "paused" }, { enabled: true, hideWhenPaused: false }); +await toggleSynchronizer.sync({ data: base, status: "paused" }, { enabled: true, hideWhenPaused: true }); +await toggleSynchronizer.sync({ data: base, status: "paused" }, { enabled: true, hideWhenPaused: false }); +check(toggleCalls.join(",") === "update,clear,update", "changing the pause setting synchronizes immediately"); + +let releaseClear!: () => void; +const clearStarted = new Promise((resolve) => { releaseClear = resolve; }); +const raceCalls: string[] = []; +const racingSynchronizer = createPresenceSynchronizer({ + clear: async () => { + raceCalls.push("clear"); + await clearStarted; + }, + update: async () => { raceCalls.push("update"); }, +}); +const pause = racingSynchronizer.sync({ data: base, status: "paused" }, { enabled: true, hideWhenPaused: true }); +await Promise.resolve(); +const resume = racingSynchronizer.sync({ data: { ...base, isPlaying: true }, status: "playing" }, { enabled: true, hideWhenPaused: true }); +releaseClear(); +await Promise.all([pause, resume]); +check(raceCalls.join(",") === "clear,update", "resume publishes after an in-flight pause clear"); + +let failedClear = true; +const retryCalls: string[] = []; +const retrySynchronizer = createPresenceSynchronizer({ + clear: async () => { + retryCalls.push("clear"); + if (failedClear) { + failedClear = false; + throw new Error("Discord unavailable"); + } + }, + update: async () => { retryCalls.push("update"); }, +}); +await retrySynchronizer.sync({ data: base, status: "paused" }, { enabled: true, hideWhenPaused: true }); +await retrySynchronizer.sync({ data: base, status: "paused" }, { enabled: true, hideWhenPaused: true }); +check(retryCalls.join(",") === "clear,clear", "failed clear is retried without wedging later syncs"); + console.log("DiscordRPC.check.ts passed"); diff --git a/src/player/DiscordRPC.ts b/src/player/DiscordRPC.ts index 006be816..7e224181 100644 --- a/src/player/DiscordRPC.ts +++ b/src/player/DiscordRPC.ts @@ -1,7 +1,11 @@ import { invoke } from "@tauri-apps/api/core"; +import type { PlayerStatus } from "./PlayerController"; +import { createSerialQueue } from "../internal/asyncQueue"; import { logInternalDebug, logInternalWarn } from "../internal/logging"; import { + getDiscordHideWhenPaused, getDiscordPresenceEnabled, + setDiscordHideWhenPaused as saveDiscordHideWhenPaused, setDiscordPresenceEnabled, } from "../ui/settings/discord"; @@ -13,11 +17,17 @@ export interface DiscordPresenceData { songUrl?: string; artistUrl?: string; albumUrl?: string; - duration: number; // in seconds - currentTime: number; // in seconds + duration: number; + currentTime: number; isPlaying: boolean; } +type PresenceSnapshot = { data: DiscordPresenceData; status: PlayerStatus } | null; +type PresenceTransport = { + clear: () => Promise; + update: (data: DiscordPresenceData) => Promise; +}; + const DISCORD_TEXT_LIMIT = 128; const DISCORD_ASSET_URL_LIMIT = 256; const TRUSTED_ARTWORK_HOSTS = new Set([ @@ -42,11 +52,9 @@ function sanitizeArtworkUrl(value?: string): string | undefined { try { const parsed = new URL(value); - if (parsed.protocol !== "https:") return undefined; - if (!TRUSTED_ARTWORK_HOSTS.has(parsed.hostname)) return undefined; + if (parsed.protocol !== "https:" || !TRUSTED_ARTWORK_HOSTS.has(parsed.hostname)) return undefined; const url = parsed.toString(); - if (url.length > DISCORD_ASSET_URL_LIMIT) return undefined; - return url; + return url.length <= DISCORD_ASSET_URL_LIMIT ? url : undefined; } catch { return undefined; } @@ -57,8 +65,7 @@ function sanitizePresenceLink(value?: string): string | undefined { try { const parsed = new URL(value); - if (parsed.protocol !== "https:") return undefined; - if (!TRUSTED_PRESENCE_LINK_HOSTS.has(parsed.hostname)) return undefined; + if (parsed.protocol !== "https:" || !TRUSTED_PRESENCE_LINK_HOSTS.has(parsed.hostname)) return undefined; return parsed.toString(); } catch { return undefined; @@ -71,6 +78,15 @@ export function presenceDedupeKey(data: DiscordPresenceData): string { return JSON.stringify(rest); } +export function shouldClearPresence( + status: PlayerStatus, + hasTrack: boolean, + hideWhenPaused: boolean, +): boolean { + return !hasTrack || status === "idle" || status === "error" || status === "loading" || + (status === "paused" && hideWhenPaused); +} + function sanitizePresenceData(data: DiscordPresenceData): DiscordPresenceData { return { title: sanitizeDiscordText(data.title), @@ -87,116 +103,115 @@ function sanitizePresenceData(data: DiscordPresenceData): DiscordPresenceData { } /** - * Manages Discord Rich Presence integration - * Calls Tauri commands that handle the actual Discord connection in Rust + * Serializes presence commands and coalesces bursts to their latest desired state. + * `publishedKey` is undefined before the first command, null after a clear, or the last sent + * track key. One field deliberately models all three states, so clear/update dedupe cannot drift. */ -export class DiscordRpcService { - /** - * Read per call rather than cached, so toggling the setting takes effect on the next track - * update without anything having to notify this service. - */ - private static get isEnabled(): boolean { - return getDiscordPresenceEnabled(); +export function createPresenceSynchronizer(transport: PresenceTransport) { + let latest: PresenceSnapshot = null; + let enabled = true; + let hideWhenPaused = false; + let publishedKey: string | null | undefined; + const enqueue = createSerialQueue(); + + async function publishLatest(): Promise { + if (!latest || !enabled || shouldClearPresence(latest.status, true, hideWhenPaused)) { + if (publishedKey === null) return; + try { + await transport.clear(); + publishedKey = null; + } catch { + // Keep the old state so the next sync retries it. + } + return; + } + + const data = sanitizePresenceData(latest.data); + const key = presenceDedupeKey(data); + if (key === publishedKey) return; + try { + await transport.update(data); + publishedKey = key; + } catch { + // Keep the old state so the next sync retries it. + } } - /** - * The last payload actually sent, everything but `currentTime`. - * - * `PlayerController.emit()` fires on every state change — a queue reorder, a rate change, a - * sleep timer — most of which leave the track and play state untouched. Discord runs its own - * clock off the timestamps `discord_rpc.rs` derives from `currentTime`, so it never needed - * repolling either; comparing on everything else and always excluding `currentTime` is what - * turns those into no-ops instead of a fresh IPC round trip (and a jittered progress bar) on - * every unrelated change. - */ - private static lastSentKey: string | null = null; - - /** - * Initialize Discord RPC - * The actual connection happens on the Rust backend - */ + return { + sync(next: PresenceSnapshot, options: { enabled: boolean; hideWhenPaused: boolean }): Promise { + latest = next; + enabled = options.enabled; + hideWhenPaused = options.hideWhenPaused; + return enqueue(publishLatest); + }, + }; +} + +/** Manages Discord Rich Presence and owns its playback-to-presence policy. */ +export class DiscordRpcService { + private static lastPlayback: PresenceSnapshot = null; + private static synchronizer = createPresenceSynchronizer({ + async clear() { + try { + logInternalDebug("Discord.clearPresence", {}); + await invoke("discord_rpc_clear"); + logInternalDebug("Discord.clearPresence.success", {}); + } catch (error) { + logInternalWarn("Discord.clearPresence.failed", error as Record); + throw error; + } + }, + async update(data) { + try { + logInternalDebug("Discord.updatePresence", { + title: data.title, + artist: data.artist, + isPlaying: data.isPlaying, + }); + await invoke("discord_rpc_update", { + title: data.title, + artist: data.artist, + album: data.album, + artworkUrl: data.artworkUrl, + songUrl: data.songUrl, + artistUrl: data.artistUrl, + albumUrl: data.albumUrl, + duration: data.duration, + currentTime: data.currentTime, + isPlaying: data.isPlaying, + }); + logInternalDebug("Discord.updatePresence.success", {}); + } catch (error) { + logInternalWarn("Discord.updatePresence.failed", error as Record); + throw error; + } + }, + }); + static async init(): Promise { logInternalDebug("Discord.init", { message: "Rust backend will handle connection" }); } - /** - * Stops publishing presence and wipes whatever is already showing. - * - * Turning the setting off has to clear as well as stop: presence persists on Discord's side - * until something replaces it, so without this the last track stays on the user's profile - * indefinitely — the opposite of what switching it off is asking for. - */ - static async setEnabled(enabled: boolean): Promise { - setDiscordPresenceEnabled(enabled); - if (enabled) return; - - try { - await invoke("discord_rpc_clear"); - this.lastSentKey = null; - logInternalDebug("Discord.setEnabled cleared presence", {}); - } catch (error) { - logInternalWarn("Discord.setEnabled.clearFailed", error as Record); - } + static async setHideWhenPaused(hidden: boolean): Promise { + saveDiscordHideWhenPaused(hidden); + await this.syncLatest(); } - /** - * Update Discord presence with current track information - * @param data The current track and playback information - */ - static async updatePresence(data: DiscordPresenceData): Promise { - if (!this.isEnabled) { - return; - } - - const safeData = sanitizePresenceData(data); - const nextKey = presenceDedupeKey(safeData); - if (nextKey === this.lastSentKey) return; + static async syncPresence(data: DiscordPresenceData | null, status: PlayerStatus): Promise { + this.lastPlayback = data ? { data, status } : null; + await this.syncLatest(); + } - try { - logInternalDebug("Discord.updatePresence", { - title: safeData.title, - artist: safeData.artist, - isPlaying: safeData.isPlaying, - }); - - // Call Tauri command to update presence in Rust backend - await invoke("discord_rpc_update", { - title: safeData.title, - artist: safeData.artist, - album: safeData.album, - artworkUrl: safeData.artworkUrl, - songUrl: safeData.songUrl, - artistUrl: safeData.artistUrl, - albumUrl: safeData.albumUrl, - duration: safeData.duration, - currentTime: safeData.currentTime, - isPlaying: safeData.isPlaying, - }); - - this.lastSentKey = nextKey; - logInternalDebug("Discord.updatePresence.success", {}); - } catch (error) { - logInternalWarn("Discord.updatePresence.failed", error as Record); - } + static async setEnabled(enabled: boolean): Promise { + setDiscordPresenceEnabled(enabled); + await this.syncLatest(); } - /** - * Clear Discord presence (show as idle) - */ - static async clearPresence(): Promise { - if (!this.isEnabled) { - return; - } - try { - logInternalDebug("Discord.clearPresence", {}); - await invoke("discord_rpc_clear"); - // The next real track has to go out even if it matches whatever was showing before - // the clear. - this.lastSentKey = null; - logInternalDebug("Discord.clearPresence.success", {}); - } catch (error) { - logInternalWarn("Discord.clearPresence.failed", error as Record); - } + private static syncLatest(): Promise { + return this.synchronizer.sync(this.lastPlayback, { + enabled: getDiscordPresenceEnabled(), + hideWhenPaused: getDiscordHideWhenPaused(), + }); } } diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 5b1e730c..b970e9e0 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -1679,38 +1679,26 @@ export class PlayerController { const currentTrack = this.state.currentTrack; logInternalDebug("updateDiscordPresence", { status: this.state.status, hasTrack: !!currentTrack }); - // Clear presence if idle or error - if (this.state.status === "idle" || this.state.status === "error" || !currentTrack) { - logInternalDebug("Discord.clearPresence", {}); - void DiscordRpcService.clearPresence(); + if (!currentTrack || (this.state.status !== "playing" && this.state.status !== "paused")) { + void DiscordRpcService.syncPresence(null, this.state.status); return; } - // Update presence with current track info - if (this.state.status === "playing" || this.state.status === "paused") { - const currentTime = this.loadedTrackId === currentTrack.id - ? this.audioEngine.getCurrentTime() - : (this.pendingSeekTime ?? 0); - - logInternalDebug("Discord.updatePresence", { - title: currentTrack.title, - artist: currentTrack.artist, - status: this.state.status, - }); - - void DiscordRpcService.updatePresence({ - title: currentTrack.title, - artist: currentTrack.artist, - album: currentTrack.album ?? "", - artworkUrl: getDiscordArtworkUrl(currentTrack), - songUrl: getYouTubeMusicTrackUrl(currentTrack), - artistUrl: getYouTubeMusicArtistUrl(currentTrack), - albumUrl: getYouTubeMusicAlbumUrl(currentTrack), - duration: Math.floor(currentTrack.durationSec ?? 0), - currentTime: Math.floor(Math.max(0, currentTime)), - isPlaying: this.state.status === "playing", - }); - } + const currentTime = this.loadedTrackId === currentTrack.id + ? this.audioEngine.getCurrentTime() + : (this.pendingSeekTime ?? 0); + void DiscordRpcService.syncPresence({ + title: currentTrack.title, + artist: currentTrack.artist, + album: currentTrack.album ?? "", + artworkUrl: getDiscordArtworkUrl(currentTrack), + songUrl: getYouTubeMusicTrackUrl(currentTrack), + artistUrl: getYouTubeMusicArtistUrl(currentTrack), + albumUrl: getYouTubeMusicAlbumUrl(currentTrack), + duration: Math.floor(currentTrack.durationSec ?? 0), + currentTime: Math.floor(Math.max(0, currentTime)), + isPlaying: this.state.status === "playing", + }, this.state.status); } async seekTo(time: number): Promise { diff --git a/src/ui/pages/SettingsPage.tsx b/src/ui/pages/SettingsPage.tsx index be871f2d..7bc2d1a4 100644 --- a/src/ui/pages/SettingsPage.tsx +++ b/src/ui/pages/SettingsPage.tsx @@ -197,7 +197,10 @@ import { } from "../../player/localPlaylists"; import { LastFmService, type LastFmAuthStart, type LastFmSessionStatus } from "../../player/LastFm"; import { DiscordRpcService } from "../../player/DiscordRPC"; -import { useDiscordPresenceEnabled } from "../settings/discord"; +import { + useDiscordHideWhenPaused, + useDiscordPresenceEnabled, +} from "../settings/discord"; import { setLastFmScrobblingEnabled, useLastFmScrobblingEnabled, @@ -791,6 +794,7 @@ export function SettingsPage({ const [clearingDownloads, setClearingDownloads] = useState(false); const lastFmScrobblingEnabled = useLastFmScrobblingEnabled(); const discordPresenceEnabled = useDiscordPresenceEnabled(); + const discordHideWhenPaused = useDiscordHideWhenPaused(); const localPlaylists = useSyncExternalStore( subscribeToLocalPlaylists, getLocalPlaylists, @@ -1351,6 +1355,13 @@ export function SettingsPage({ checked={discordPresenceEnabled} onCheckedChange={(enabled) => void DiscordRpcService.setEnabled(enabled)} /> + void DiscordRpcService.setHideWhenPaused(hidden)} + /> diff --git a/src/ui/settings/discord.ts b/src/ui/settings/discord.ts index 188a8daf..9d026b06 100644 --- a/src/ui/settings/discord.ts +++ b/src/ui/settings/discord.ts @@ -6,6 +6,7 @@ import { } from "../../internal/durableLocalSetting"; const STORAGE_KEY = "discord-presence-enabled"; +const HIDE_WHEN_PAUSED_KEY = "discord-hide-when-paused"; const CHANGE_EVENT = "discord-settings-change"; function readDiscordPresenceEnabled() { @@ -30,10 +31,25 @@ export function getDiscordPresenceEnabled() { return readDiscordPresenceEnabled(); } +export function setDiscordHideWhenPaused(hidden: boolean) { + writeLocalBooleanSetting(HIDE_WHEN_PAUSED_KEY, hidden, CHANGE_EVENT); +} + +export function getDiscordHideWhenPaused() { + return readLocalBooleanSetting(HIDE_WHEN_PAUSED_KEY, false); +} + export async function hydrateDiscordSettings() { - await hydrateLocalBooleanSetting(STORAGE_KEY, true, CHANGE_EVENT); + await Promise.all([ + hydrateLocalBooleanSetting(STORAGE_KEY, true, CHANGE_EVENT), + hydrateLocalBooleanSetting(HIDE_WHEN_PAUSED_KEY, false, CHANGE_EVENT), + ]); } export function useDiscordPresenceEnabled() { return useSyncExternalStore(subscribe, readDiscordPresenceEnabled, () => true); } + +export function useDiscordHideWhenPaused() { + return useSyncExternalStore(subscribe, getDiscordHideWhenPaused, () => false); +}