diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d9..1277b67bf 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -25,6 +25,12 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus [target.'cfg(windows)'.dependencies] keyring = { version = "3", features = ["windows-native"] } windows = { version = "0.61", features = [ + # GlobalSystemMediaTransportControlsSession — what is playing, right now. + "Foundation", + "Foundation_Collections", + "Media", + "Media_Control", + "Media_Playback", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..1ba65eeee 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod hooks; mod integrations; mod island; mod log; +mod media; mod pipe; mod platform; mod secrets; @@ -177,6 +178,19 @@ fn set_paused(paused: bool) { integrations::set_paused(paused); } +/// What is playing, read from SMTC. The island normally gets this pushed as the +/// `media` event; this is only the first render. +#[tauri::command] +async fn media_state() -> media::NowPlaying { + media::snapshot().await +} + +/// Transport buttons: "play" | "pause" | "toggle" | "next" | "prev". +#[tauri::command] +async fn media_action(action: String) -> bool { + media::control(&action).await +} + // ── Claude Code hooks ───────────────────────────────────────────────────────── #[tauri::command] @@ -401,6 +415,8 @@ pub fn run() { open_n8n, open_settings_window, set_paused, + media_state, + media_action, ]) .setup(move |app| { let handle = app.handle().clone(); @@ -426,6 +442,7 @@ pub fn run() { hooks::ensure_hook_exe(&handle); pipe::start(handle.clone()); integrations::start(handle.clone()); + media::start(handle.clone()); Ok(()) }) .run(tauri::generate_context!()) diff --git a/windows/src-tauri/src/media.rs b/windows/src-tauri/src/media.rs new file mode 100644 index 000000000..978794266 --- /dev/null +++ b/windows/src-tauri/src/media.rs @@ -0,0 +1,159 @@ +//! What is playing right now, and the transport buttons, over SMTC. +//! +//! macOS reads Apple Music through `NSAppleScript`; Windows has no scripting +//! bridge to any player, so this goes through the System Media Transport +//! Controls session manager instead — which is player-agnostic: Spotify, VLC, +//! browsers and Apple Music's own preview all publish a session there. +//! +//! WinRT's async operations here are cheap and always complete, so they are +//! pulled with the blocking `get()` on a blocking thread rather than fought +//! with inside the tokio runtime. + +use std::sync::atomic::Ordering; +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager}; + +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct NowPlaying { + pub title: String, + pub artist: String, + pub album: String, + pub app: String, + pub playing: bool, +} + +impl NowPlaying { + pub fn live(&self) -> bool { + !(self.title.is_empty() && self.artist.is_empty()) + } +} + +#[cfg(windows)] +mod imp { + use super::NowPlaying; + use windows::Media::Control::{ + GlobalSystemMediaTransportControlsSession as Session, + GlobalSystemMediaTransportControlsSessionManager as Manager, + GlobalSystemMediaTransportControlsSessionPlaybackStatus as Status, + }; + + fn manager() -> windows::core::Result { + Manager::RequestAsync()?.get() + } + + pub(super) fn session() -> Option { + manager().ok()?.GetCurrentSession().ok() + } + + pub fn snapshot() -> NowPlaying { + let Some(session) = session() else { + return NowPlaying::default(); + }; + let Ok(props) = session.TryGetMediaPropertiesAsync().and_then(|op| op.get()) else { + return NowPlaying::default(); + }; + let mut now = NowPlaying { + title: props.Title().map(|s| s.to_string()).unwrap_or_default(), + artist: props.Artist().map(|s| s.to_string()).unwrap_or_default(), + album: props.AlbumTitle().map(|s| s.to_string()).unwrap_or_default(), + ..NowPlaying::default() + }; + if !now.live() { + return NowPlaying::default(); + } + now.playing = session + .GetPlaybackInfo() + .and_then(|info| info.PlaybackStatus()) + .map(|s| s == Status::Playing) + .unwrap_or(false); + now.app = session + .SourceAppUserModelId() + .map(|s| s.to_string()) + .unwrap_or_default(); + now + } + + pub fn control(action: &str) -> bool { + let Some(session) = session() else { + return false; + }; + let op = match action { + "play" => session.TryPlayAsync(), + "pause" => session.TryPauseAsync(), + "toggle" => session.TryTogglePlayPauseAsync(), + "next" => session.TrySkipNextAsync(), + "prev" => session.TrySkipPreviousAsync(), + _ => return false, + }; + op.and_then(|op| op.get()).is_ok() + } +} + +#[cfg(not(windows))] +mod imp { + use super::NowPlaying; + + pub fn snapshot() -> NowPlaying { + NowPlaying::default() + } + + pub fn control(_action: &str) -> bool { + false + } +} + +pub async fn snapshot() -> NowPlaying { + tokio::task::spawn_blocking(imp::snapshot) + .await + .unwrap_or_default() +} + +pub async fn control(action: &str) -> bool { + let action = action.to_string(); + tokio::task::spawn_blocking(move || imp::control(&action)) + .await + .unwrap_or(false) +} + +fn enabled(app: &AppHandle) -> bool { + app.try_state::() + .map(|shared| shared.settings.lock().unwrap().music_enabled) + .unwrap_or(false) +} + +/// Emits `media` only when what is playing actually changes, so a track sitting +/// still costs nothing and the island is not woken for a status it already shows. +/// Turning the setting off emits one empty payload to clear the card. +pub fn start(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + let mut ticker = tokio::time::interval(Duration::from_secs(2)); + let mut last = NowPlaying::default(); + loop { + ticker.tick().await; + let on = !crate::integrations::PAUSED.load(Ordering::Relaxed) && enabled(&app); + let now = if on { snapshot().await } else { NowPlaying::default() }; + if now != last { + last = now.clone(); + let _ = app.emit("media", &now); + } + } + }); +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + #[ignore = "needs a media player running on this machine"] + fn whatever_is_playing_is_readable() { + println!("session available: {}", imp::session().is_some()); + println!("now playing: {:?}", imp::snapshot()); + // An action nobody implements: reaches the session lookup and the WinRT + // call dispatch without touching the user's playback. + println!("unknown action -> {}", imp::control("noop")); + } +} diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359..4a4979614 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,12 +20,19 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + /// Show what is playing and let Mochi dance along (SMTC on Windows). + #[serde(default = "default_true")] + pub music_enabled: bool, } fn default_model() -> String { crate::claude::DEFAULT_MODEL.to_string() } +fn default_true() -> bool { + true +} + impl Default for Settings { fn default() -> Self { Self { @@ -43,6 +50,7 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + music_enabled: true, } } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..f0eb3d4da 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -5,7 +5,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWebview } from "@tauri-apps/api/webview"; -import type { Settings } from "./state"; +import type { NowPlaying, Settings } from "./state"; export const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -99,6 +99,12 @@ export const Bridge = { /** Tray → Pause. Stops the integration pollers, not just the island. */ setPaused: (paused: boolean) => call("set_paused", { paused }), + + // ── Now playing (SMTC) ──────────────────────────────────────────────────── + mediaState: () => call("media_state"), + /** "play" | "pause" | "toggle" | "next" | "prev". True when the player took it. */ + mediaAction: (action: "play" | "pause" | "toggle" | "next" | "prev") => + call("media_action", { action }), }; export interface IntegrationUpdate { @@ -143,6 +149,7 @@ export type BridgeEvent = | { name: "cursor"; payload: { x: number; y: number } } | { name: "tray"; payload: string } | { name: "hook"; payload: Record } + | { name: "media"; payload: NowPlaying } | { name: "screen-changed"; payload: null }; export interface DragDropPayload { diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..d273cd216 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -50,6 +50,15 @@ export interface SearchResult { note?: string; } +/** What SMTC says is playing. `title` and `artist` empty = nothing to show. */ +export interface NowPlaying { + title: string; + artist: string; + album: string; + app: string; + playing: boolean; +} + const task = ( id: string, name: string, color: string, source: AgentSource, ): AgentTask => ({ @@ -92,6 +101,8 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Show what is playing and let Mochi dance along. */ + musicEnabled: boolean; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +117,7 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + musicEnabled: true, }; type Listener = () => void; @@ -137,6 +149,7 @@ class AppState { searchResult: SearchResult | null = null; chatHistory: ChatMessage[] = []; pendingApproval: ApprovalInfo | null = null; + nowPlaying: NowPlaying | null = null; integrations: Record = {}; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..9bd44c6c9 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -789,6 +789,7 @@ export class Island { const focus = State.focusTask; this.engine.bodyColor = focus?.isIntegration ? hexToRGB(focus.color) : null; + this.engine.setDancing(State.nowPlaying?.playing === true); this.engine.particleOverhang = BOT_OVERHANG; this.engine.lookX = this.lookX(); this.engine.lookY = this.lookY(); diff --git a/windows/src/main.ts b/windows/src/main.ts index f65d5e757..dd67c48af 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -3,7 +3,7 @@ import "./style.css"; import { Bridge, IS_TAURI, onEvent } from "./core/bridge"; import { Sound } from "./core/sound"; -import { State, type Settings } from "./core/state"; +import { State, type NowPlaying, type Settings } from "./core/state"; import { Island } from "./island/island"; import { registerHookHandlers } from "./island/hooks"; import { registerIntegrationHandlers, refreshConfigured } from "./island/integrations"; @@ -53,6 +53,26 @@ async function main() { await onEvent("screen-changed", () => void Bridge.reposition()); + // What is playing. Rust only emits when it changes, so this is never busy; + // the first call is the initial read, and it is logged only if it found + // something — an idle machine should not write a line on every launch. + let lastMedia = ""; + const applyMedia = (np: NowPlaying | null) => { + const live = np && (np.title !== "" || np.artist !== "") ? np : null; + const key = live ? [live.app, live.title, live.artist, live.playing].join("~") : ""; + State.nowPlaying = live; + if (key === lastMedia) return; + lastMedia = key; + State.notify(); + if (live) { + void Bridge.log( + `media: ${live.title} — ${live.artist} (${live.app}) ${live.playing ? "playing" : "paused"}`, + ); + } + }; + await onEvent("media", applyMedia); + void Bridge.mediaState().then(applyMedia); + // The settings window writes preferences; apply them here without a restart. await onEvent("settings-changed", (s) => { State.settings = { ...State.settings, ...s }; diff --git a/windows/src/mochi/engine.ts b/windows/src/mochi/engine.ts index f1856b28d..912b86720 100644 --- a/windows/src/mochi/engine.ts +++ b/windows/src/mochi/engine.ts @@ -199,6 +199,10 @@ export class BotEngine { private badgeKey = "none"; private badgeToken = 0; + /** Music is playing: Mochi sways along. Ramped by setDancing, never popped. */ + private dancing = false; + danceLevel = 0; + private tweens = new Map(); private locks = new Set(); private particles: Particle[] = []; @@ -377,6 +381,12 @@ export class BotEngine { this.miniNextBehavior = now() + 0.8 + Math.random() * 1.7; } + /** Music on/off. The level ramps in over 0.3 s and out over 0.5 s (the same + * ramps as the macOS build) so Mochi starts and stops on the beat. */ + setDancing(on: boolean) { + this.dancing = on; + } + triggerEmote(emote: BotEmoteName, duration = 1.8) { const t = now(); this.eyeOverride = EMOTE_EYE[emote]; @@ -504,6 +514,14 @@ export class BotEngine { } const t = n - this.t0; + + const danceTarget = this.dancing ? 1 : 0; + const danceStep = dt / (this.dancing ? 0.3 : 0.5); + this.danceLevel = + this.danceLevel < danceTarget + ? Math.min(danceTarget, this.danceLevel + danceStep) + : Math.max(danceTarget, this.danceLevel - danceStep); + let ty = this.lookX * 0.62; let tp = this.lookY * 0.5; @@ -539,8 +557,13 @@ export class BotEngine { const wt = n - this.waveStart; this.tgTilt = -0.06 + Math.sin(2 * Math.PI * 1.2 * wt) * 0.07; } + if (this.danceLevel > 0) { + this.tgTilt += Math.sin(t * 2.5) * 0.17 * this.danceLevel; + } - const bounce = this.cfg.bounces ? -Math.abs(Math.sin(t * 5.2)) * 0.07 : 0; + const bounce = + (this.cfg.bounces ? -Math.abs(Math.sin(t * 5.2)) * 0.07 : 0) + + Math.sin(t * 5.0) * 0.1 * this.danceLevel; const kGen = 1 - Math.pow(0.0008, dt); if (!this.locks.has("oy")) this.oy += (bounce - this.oy) * kGen; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..e7e723178 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -414,6 +414,11 @@ function generalSection(): HTMLElement { h("label", { text: "Launch at startup" }), toggle(settings.autostart, (v) => { settings.autostart = v; void save(); }), ), + h("div", { class: "row" }, + h("label", { text: "Now playing" }), + toggle(settings.musicEnabled, (v) => { settings.musicEnabled = v; void save(); }), + h("span", { class: "hint", text: "whatever this machine is playing, and Mochi dances along" }), + ), ); } diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..102f8fdc7 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -396,6 +396,76 @@ body { flex: 1 1 auto; } +/* ── Now playing ────────────────────────────────────────────────────────── */ + +.card.with-music { + display: flex; + flex-direction: column; +} + +.card.with-music .pills { + height: auto; + flex: 1 1 auto; +} + +.music { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px 7px; + border-bottom: 1px solid var(--hairline); +} + +.np-meta { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.np-title { + font: 500 12px var(--font); + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.np-artist { + font: 400 10.5px var(--font); + color: var(--dim-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.np-ctrls { + flex: 0 0 auto; + display: flex; + gap: 3px; +} + +.np-ctrl { + width: 22px; + height: 22px; + border: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.07); + color: var(--dim-2); + display: grid; + place-items: center; + cursor: default; + transition: background 0.15s ease, color 0.15s ease; +} + +.np-ctrl:hover { + background: rgba(255, 255, 255, 0.14); + color: var(--ink); +} + .card-body { position: relative; z-index: 1; diff --git a/windows/src/views/icons.ts b/windows/src/views/icons.ts index d2ff82475..529e0a4fb 100644 --- a/windows/src/views/icons.ts +++ b/windows/src/views/icons.ts @@ -38,4 +38,10 @@ export const ICONS = { stack: "M5 8h14v11.5H5V8zm1.8-3h10.4v1.6H6.8V5zm1.6-2.6h7.2V4H8.4V2.4z", // doc.text doc: "M6.5 2.6h7l4 4v14.8h-11V2.6zm6.6 1.6v3.3h3.3l-3.3-3.3zM8.6 11h6.8v1.5H8.6V11zm0 3.4h6.8v1.5H8.6v-1.5z", + // play.fill / pause.fill + play: "M8 5.2v13.6L19 12z", + pause: "M7.2 5h3.4v14H7.2zM13.4 5h3.4v14h-3.4z", + // backward.end.fill / forward.end.fill + prev: "M7 5.5h1.9v13H7zM18 5.5v13L9.5 12z", + next: "M15.1 5.5H17v13h-1.9zM6 5.5v13l8.5-6.5z", } as const; diff --git a/windows/src/views/music.ts b/windows/src/views/music.ts new file mode 100644 index 000000000..d1679c6f8 --- /dev/null +++ b/windows/src/views/music.ts @@ -0,0 +1,59 @@ +// Now playing — the SMTC card. +// +// macOS shows Apple Music on hover in the header; Windows reports every player +// through SMTC instead, so this is a row inside the overview card that only +// exists while something is actually playing. + +import { Bridge } from "../core/bridge"; +import { State } from "../core/state"; +import { clear, h, svg } from "./dom"; +import { ICONS } from "./icons"; + +type MediaAction = "play" | "pause" | "toggle" | "next" | "prev"; + +function ctrl(icon: string, title: string, action: MediaAction): HTMLElement { + const b = h("button", { + class: "np-ctrl", + title, + onclick: () => void Bridge.mediaAction(action), + }); + b.append(svg(icon, 13)); + return b; +} + +export function buildMusic(): { el: HTMLElement; sync: () => void } { + const el = h("div", { class: "music" }); + let key = "\u0000"; + + return { + el, + sync() { + const np = State.nowPlaying; + const live = np && (np.title !== "" || np.artist !== "") ? np : null; + const next = live ? [live.app, live.title, live.artist, live.playing].join("~") : ""; + if (next === key) return; + key = next; + clear(el); + if (!live) { + el.style.display = "none"; + return; + } + el.style.display = ""; + el.append( + h( + "div", + { class: "np-meta" }, + h("span", { class: "np-title", text: live.title }), + h("span", { class: "np-artist", text: live.artist || live.album || live.app }), + ), + h( + "div", + { class: "np-ctrls" }, + ctrl(ICONS.prev, "Previous", "prev"), + ctrl(live.playing ? ICONS.pause : ICONS.play, live.playing ? "Pause" : "Play", "toggle"), + ctrl(ICONS.next, "Next", "next"), + ), + ); + }, + }; +} diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..24b772157 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -9,6 +9,7 @@ import { State, type AgentTask } from "../core/state"; import { washRGBA, type IslandViewName, type Wash } from "../core/layout"; import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; +import { buildMusic } from "./music"; import { buildChoose, buildUpload, buildUploading } from "./upload"; import { renderIntegrationCard, type IntegrationCardHooks } from "./integrations"; @@ -128,7 +129,9 @@ function buildOverview(actions: ViewActions): ViewHost { ); const left = card(null, leftBody, jump); const pills = h("div", { class: "pills" }); - const right = card(null, pills); + const music = buildMusic(); + const right = card(null, music.el, pills); + right.classList.add("with-music"); const el = h("div", { class: "view overview" }, h("div", { class: "left" }, left), @@ -165,6 +168,7 @@ function buildOverview(actions: ViewActions): ViewHost { }, sync() { const task = State.focusTask; + music.sync(); if (task?.id !== lastFocus) { lastFocus = task?.id ?? null; detailOpen = false;