Skip to content
Closed
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
6 changes: 6 additions & 0 deletions windows/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions windows/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod hooks;
mod integrations;
mod island;
mod log;
mod media;
mod pipe;
mod platform;
mod secrets;
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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();
Expand All @@ -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!())
Expand Down
159 changes: 159 additions & 0 deletions windows/src-tauri/src/media.rs
Original file line number Diff line number Diff line change
@@ -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> {
Manager::RequestAsync()?.get()
}

pub(super) fn session() -> Option<Session> {
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::<crate::Shared>()
.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"));
}
}
8 changes: 8 additions & 0 deletions windows/src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -43,6 +50,7 @@ impl Default for Settings {
autostart: false,
hooks_installed: false,
model: default_model(),
music_enabled: true,
}
}
}
Expand Down
9 changes: 8 additions & 1 deletion windows/src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,6 +99,12 @@ export const Bridge = {

/** Tray → Pause. Stops the integration pollers, not just the island. */
setPaused: (paused: boolean) => call<void>("set_paused", { paused }),

// ── Now playing (SMTC) ────────────────────────────────────────────────────
mediaState: () => call<NowPlaying>("media_state"),
/** "play" | "pause" | "toggle" | "next" | "prev". True when the player took it. */
mediaAction: (action: "play" | "pause" | "toggle" | "next" | "prev") =>
call<boolean>("media_action", { action }),
};

export interface IntegrationUpdate {
Expand Down Expand Up @@ -143,6 +149,7 @@ export type BridgeEvent =
| { name: "cursor"; payload: { x: number; y: number } }
| { name: "tray"; payload: string }
| { name: "hook"; payload: Record<string, unknown> }
| { name: "media"; payload: NowPlaying }
| { name: "screen-changed"; payload: null };

export interface DragDropPayload {
Expand Down
13 changes: 13 additions & 0 deletions windows/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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 = {
Expand All @@ -106,6 +117,7 @@ export const DEFAULT_SETTINGS: Settings = {
autostart: false,
hooksInstalled: false,
model: "claude-opus-5",
musicEnabled: true,
};

type Listener = () => void;
Expand Down Expand Up @@ -137,6 +149,7 @@ class AppState {
searchResult: SearchResult | null = null;
chatHistory: ChatMessage[] = [];
pendingApproval: ApprovalInfo | null = null;
nowPlaying: NowPlaying | null = null;

integrations: Record<string, IntegrationInfo> = {};

Expand Down
1 change: 1 addition & 0 deletions windows/src/island/island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 21 additions & 1 deletion windows/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -53,6 +53,26 @@ async function main() {

await onEvent<null>("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<NowPlaying>("media", applyMedia);
void Bridge.mediaState().then(applyMedia);

// The settings window writes preferences; apply them here without a restart.
await onEvent<Settings>("settings-changed", (s) => {
State.settings = { ...State.settings, ...s };
Expand Down
Loading