diff --git a/.env.example b/.env.example index 696d3a061..c931f866f 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,16 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# ----------------------------------------------------------------------------- +# Desktop integrations (OAuth) +# ----------------------------------------------------------------------------- +# Optional Spotify Web API app client ID. In the Spotify developer dashboard, +# register this redirect URI: +# http://127.0.0.1:18202/oauth/spotify/callback +# Spotify playback controls require Spotify Premium for each connected account. +# BUZZ_SPOTIFY_CLIENT_ID= +# BUZZ_SPOTIFY_REDIRECT_PORT=18202 + # ----------------------------------------------------------------------------- # Git (NIP-34 bare repositories) # ----------------------------------------------------------------------------- diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index f2e918424..33efa0d45 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -259,6 +259,9 @@ pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200; /// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral /// huddle channel with an `h` tag; never stored in the timeline. pub const KIND_HUDDLE_REACTION: u32 = 24810; +/// Ephemeral: live huddle Spotify DJ handoff fallback. Channel-scoped to the +/// ephemeral huddle channel with an `h` tag; never stored in the timeline. +pub const KIND_HUDDLE_SPOTIFY_DJ_LIVE: u32 = 24811; /// Ephemeral: mesh status report (desktop → relay). A relay member reports its /// current mesh serve availability + EndpointAddr(s) so the relay can project a /// sanitized, relay-signed kind:30621 discovery note keyed per reporter. Tagged @@ -391,6 +394,8 @@ pub const KIND_HUDDLE_PARTICIPANT_JOINED: u32 = 48101; pub const KIND_HUDDLE_PARTICIPANT_LEFT: u32 = 48102; /// A huddle ended. pub const KIND_HUDDLE_ENDED: u32 = 48103; +/// Huddle Spotify DJ handoff event. +pub const KIND_HUDDLE_SPOTIFY_DJ: u32 = 48104; /// Huddle channel guidelines/rules document. pub const KIND_HUDDLE_GUIDELINES: u32 = 48106; @@ -471,6 +476,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, KIND_HUDDLE_REACTION, + KIND_HUDDLE_SPOTIFY_DJ_LIVE, KIND_MESH_STATUS_REPORT, KIND_MESH_CONNECT_REQUEST, KIND_MESH_CALL_ME_NOW, @@ -529,6 +535,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_ENDED, + KIND_HUDDLE_SPOTIFY_DJ, KIND_HUDDLE_GUIDELINES, KIND_MEDIA_UPLOAD, KIND_GIT_REPO_ANNOUNCEMENT, diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 61fd196db..a80870cd8 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -44,7 +44,7 @@ fn bounded_kind_label(kind: u32) -> String { 44100..=44101 => kind.to_string(), 45001..=45003 => kind.to_string(), 46001..=46012 | 46020 | 46030..=46031 => kind.to_string(), - 48001 | 48100..=48103 | 48106 => kind.to_string(), + 48001 | 48100..=48104 | 48106 => kind.to_string(), 49001 => kind.to_string(), _ => "other".to_string(), } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index e41d2717f..aa7b61cd0 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -20,10 +20,10 @@ use buzz_core::kind::{ KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, + KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_SPOTIFY_DJ, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, + KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, @@ -225,6 +225,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), // NIP-34: Git repository events KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite), @@ -407,6 +408,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_HUDDLE_PARTICIPANT_JOINED | KIND_HUDDLE_PARTICIPANT_LEFT | KIND_HUDDLE_ENDED + | KIND_HUDDLE_SPOTIFY_DJ | KIND_HUDDLE_GUIDELINES ) } diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6086233e8..ce2c4d8d7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -78,7 +78,7 @@ serde_yaml = "0.9" toml = "0.8" nostr = { version = "0.44", features = ["nip44"] } zeroize = "1" -reqwest = { version = "0.13", features = ["json", "query", "stream"] } +reqwest = { version = "0.13", features = ["form", "json", "query", "stream"] } url = "2" buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 445fe2956..380b10cb6 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -27,6 +27,7 @@ mod profile; mod relay_members; mod relay_reconnect; mod social; +mod spotify; mod teams; mod workflows; mod workspace; @@ -58,6 +59,7 @@ pub use profile::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; +pub use spotify::*; pub use teams::*; pub use workflows::*; pub use workspace::*; diff --git a/desktop/src-tauri/src/commands/spotify.rs b/desktop/src-tauri/src/commands/spotify.rs new file mode 100644 index 000000000..f501970a6 --- /dev/null +++ b/desktop/src-tauri/src/commands/spotify.rs @@ -0,0 +1,758 @@ +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::State; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use url::Url; +use uuid::Uuid; + +use crate::app_state::{AppState, KEYRING_SERVICE}; +use crate::secret_store::SecretStore; + +const SPOTIFY_CREDENTIAL_KEY: &str = "spotify-oauth"; +const SPOTIFY_AUTH_URL: &str = "https://accounts.spotify.com/authorize"; +const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token"; +const SPOTIFY_API_BASE_URL: &str = "https://api.spotify.com/v1"; +const SPOTIFY_SCOPES: &str = + "user-read-playback-state user-read-currently-playing user-modify-playback-state"; +const OAUTH_CALLBACK_PATH: &str = "/oauth/spotify/callback"; +const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(180); +const DEFAULT_SPOTIFY_REDIRECT_PORT: u16 = 18202; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SpotifyCredential { + access_token: Option, + connected_at: i64, + expires_at: Option, + refresh_token: String, + scope: Option, + token_type: Option, +} + +#[derive(Debug, Serialize)] +pub struct SpotifyStatus { + configured: bool, + connected: bool, + connected_at: Option, + scopes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct SpotifyDevice { + id: Option, + name: String, + device_type: String, + is_active: bool, + is_restricted: bool, + volume_percent: Option, +} + +#[derive(Debug, Serialize)] +pub struct SpotifyPlaybackState { + context_uri: Option, + device: Option, + is_playing: bool, + item: Option, + progress_ms: Option, + timestamp: Option, +} + +#[derive(Debug, Serialize)] +pub struct SpotifyPlaybackItem { + artists: Vec, + duration_ms: Option, + image_url: Option, + item_type: Option, + name: String, + uri: String, +} + +#[derive(Debug, Deserialize)] +struct SpotifyTokenResponse { + access_token: Option, + expires_in: Option, + refresh_token: Option, + scope: Option, + token_type: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyDevicesResponse { + devices: Vec, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawDevice { + id: Option, + #[serde(default)] + is_active: bool, + #[serde(default)] + is_restricted: bool, + #[serde(default)] + name: String, + #[serde(default, rename = "type")] + device_type: String, + volume_percent: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawPlaybackState { + context: Option, + device: Option, + #[serde(default)] + is_playing: bool, + item: Option, + progress_ms: Option, + timestamp: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawContext { + uri: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawPlaybackItem { + album: Option, + artists: Option>, + duration_ms: Option, + name: Option, + #[serde(rename = "type")] + item_type: Option, + uri: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawAlbum { + images: Option>, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawArtist { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawImage { + url: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotifyPlaybackInput { + context_uri: Option, + device_id: Option, + position_ms: Option, + uris: Option>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotifyDeviceInput { + device_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotifySeekInput { + device_id: Option, + position_ms: u32, +} + +#[derive(Debug, Serialize)] +struct SpotifyPlaybackBody<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + context_uri: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + position_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + uris: Option<&'a [String]>, +} + +fn spotify_client_id() -> Option { + std::env::var("BUZZ_SPOTIFY_CLIENT_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + option_env!("BUZZ_SPOTIFY_CLIENT_ID") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn spotify_redirect_port() -> u16 { + std::env::var("BUZZ_SPOTIFY_REDIRECT_PORT") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|port| *port != 0) + .unwrap_or(DEFAULT_SPOTIFY_REDIRECT_PORT) +} + +fn credential_store() -> &'static SecretStore { + SecretStore::shared(KEYRING_SERVICE) +} + +fn now_ts() -> i64 { + Utc::now().timestamp() +} + +fn load_credential() -> Result, String> { + let Some(raw) = credential_store().load(SPOTIFY_CREDENTIAL_KEY)? else { + return Ok(None); + }; + serde_json::from_str(&raw).map_err(|e| format!("parse Spotify credential: {e}")) +} + +fn save_credential(credential: &SpotifyCredential) -> Result<(), String> { + let raw = serde_json::to_string(credential) + .map_err(|e| format!("serialize Spotify credential: {e}"))?; + credential_store().store(SPOTIFY_CREDENTIAL_KEY, &raw) +} + +fn scopes(scope: Option<&str>) -> Vec { + scope + .unwrap_or("") + .split_whitespace() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn status_from_credential(credential: Option<&SpotifyCredential>) -> SpotifyStatus { + SpotifyStatus { + configured: spotify_client_id().is_some(), + connected: credential.is_some(), + connected_at: credential.map(|value| value.connected_at), + scopes: scopes(credential.and_then(|value| value.scope.as_deref())), + } +} + +fn pkce_verifier() -> String { + [ + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + ] + .join("") +} + +fn pkce_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +fn oauth_state() -> String { + [ + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + ] + .join("") +} + +fn oauth_authorization_url( + client_id: &str, + redirect_uri: &str, + state: &str, + code_challenge: &str, +) -> Result { + let mut url = Url::parse(SPOTIFY_AUTH_URL).map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", SPOTIFY_SCOPES) + .append_pair("state", state) + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url) +} + +fn callback_response(title: &str, body: &str) -> String { + let html = format!( + "{title}\ +
\ +

{title}

{body}

" + ); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ) +} + +async fn wait_for_oauth_callback( + listener: TcpListener, + expected_state: &str, +) -> Result { + let (mut stream, _) = tokio::time::timeout(OAUTH_CALLBACK_TIMEOUT, listener.accept()) + .await + .map_err(|_| "Timed out waiting for Spotify authorization.".to_string())? + .map_err(|e| format!("accept OAuth callback: {e}"))?; + + let mut buffer = [0_u8; 8192]; + let bytes_read = stream + .read(&mut buffer) + .await + .map_err(|e| format!("read OAuth callback: {e}"))?; + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let request_target = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or_else(|| "Invalid OAuth callback request.".to_string())?; + let callback_url = Url::parse(&format!("http://127.0.0.1{request_target}")) + .map_err(|e| format!("parse OAuth callback: {e}"))?; + + let mut code = None; + let mut state = None; + let mut error = None; + for (key, value) in callback_url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + _ => {} + } + } + + let result = if callback_url.path() != OAUTH_CALLBACK_PATH { + Err("Spotify returned an unexpected OAuth callback path.".to_string()) + } else if let Some(error) = error { + Err(format!("Spotify authorization couldn't complete: {error}")) + } else if state.as_deref() != Some(expected_state) { + Err("Spotify authorization state did not match.".to_string()) + } else { + code.ok_or_else(|| "Spotify authorization returned no code.".to_string()) + }; + + let (title, body) = if result.is_ok() { + ( + "Spotify connected", + "You can close this browser tab and return to Buzz.", + ) + } else { + ( + "Spotify connection incomplete", + "Return to Buzz to try again.", + ) + }; + let response = callback_response(title, body); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + + result +} + +async fn parse_spotify_token_response( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Spotify token response: {e}"))?; + if !status.is_success() { + return Err(format!("Spotify token request returned {status}: {body}")); + } + serde_json::from_str(&body).map_err(|e| format!("parse Spotify token response: {e}")) +} + +async fn exchange_code_for_token( + code: &str, + code_verifier: &str, + redirect_uri: &str, + client_id: &str, + state: &AppState, +) -> Result { + let form = vec![ + ("client_id", client_id.to_string()), + ("code", code.to_string()), + ("code_verifier", code_verifier.to_string()), + ("grant_type", "authorization_code".to_string()), + ("redirect_uri", redirect_uri.to_string()), + ]; + + let response = state + .http_client + .post(SPOTIFY_TOKEN_URL) + .form(&form) + .send() + .await + .map_err(|e| format!("Spotify OAuth token exchange couldn't complete: {e}"))?; + parse_spotify_token_response(response).await +} + +async fn refresh_access_token( + credential: &SpotifyCredential, + client_id: &str, + state: &AppState, +) -> Result { + let form = vec![ + ("client_id", client_id.to_string()), + ("refresh_token", credential.refresh_token.clone()), + ("grant_type", "refresh_token".to_string()), + ]; + + let response = state + .http_client + .post(SPOTIFY_TOKEN_URL) + .form(&form) + .send() + .await + .map_err(|e| format!("Spotify token refresh couldn't complete: {e}"))?; + parse_spotify_token_response(response).await +} + +async fn access_token(state: &AppState) -> Result { + let client_id = spotify_client_id() + .ok_or_else(|| "Set BUZZ_SPOTIFY_CLIENT_ID to enable Spotify.".to_string())?; + let mut credential = load_credential()? + .ok_or_else(|| "Connect Spotify before controlling playback.".to_string())?; + + if let (Some(token), Some(expires_at)) = (&credential.access_token, credential.expires_at) { + if expires_at > now_ts() + 60 { + return Ok(token.clone()); + } + } + + let token = refresh_access_token(&credential, &client_id, state).await?; + let access_token = token + .access_token + .ok_or_else(|| "Spotify token refresh returned no access token.".to_string())?; + credential.access_token = Some(access_token.clone()); + credential.expires_at = token.expires_in.map(|seconds| now_ts() + seconds); + if let Some(refresh_token) = token.refresh_token { + credential.refresh_token = refresh_token; + } + credential.scope = token.scope.or(credential.scope); + credential.token_type = token.token_type.or(credential.token_type); + save_credential(&credential)?; + Ok(access_token) +} + +fn convert_device(device: SpotifyRawDevice) -> SpotifyDevice { + SpotifyDevice { + id: device.id, + name: device.name, + device_type: device.device_type, + is_active: device.is_active, + is_restricted: device.is_restricted, + volume_percent: device.volume_percent, + } +} + +fn convert_playback_item(item: SpotifyRawPlaybackItem) -> Option { + let uri = item.uri?; + let name = item.name.unwrap_or_else(|| "Untitled".to_string()); + let artists = item + .artists + .unwrap_or_default() + .into_iter() + .filter_map(|artist| artist.name) + .collect(); + let image_url = item + .album + .and_then(|album| album.images) + .and_then(|images| images.into_iter().find_map(|image| image.url)); + + Some(SpotifyPlaybackItem { + artists, + duration_ms: item.duration_ms, + image_url, + item_type: item.item_type, + name, + uri, + }) +} + +fn convert_playback_state(state: SpotifyRawPlaybackState) -> SpotifyPlaybackState { + SpotifyPlaybackState { + context_uri: state.context.and_then(|context| context.uri), + device: state.device.map(convert_device), + is_playing: state.is_playing, + item: state.item.and_then(convert_playback_item), + progress_ms: state.progress_ms, + timestamp: state.timestamp, + } +} + +async fn spotify_json Deserialize<'de>>( + response: reqwest::Response, + context: &str, +) -> Result { + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Spotify {context} response: {e}"))?; + if !status.is_success() { + return Err(format!( + "Spotify {context} request returned {status}: {body}" + )); + } + serde_json::from_str(&body).map_err(|e| format!("parse Spotify {context} response: {e}")) +} + +async fn spotify_empty(response: reqwest::Response, context: &str) -> Result<(), String> { + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Spotify {context} response: {e}"))?; + if !status.is_success() { + return Err(format!( + "Spotify {context} request returned {status}: {body}" + )); + } + Ok(()) +} + +#[tauri::command] +pub fn get_spotify_status() -> Result { + if spotify_client_id().is_none() { + return Ok(status_from_credential(None)); + } + + Ok(status_from_credential(load_credential()?.as_ref())) +} + +#[tauri::command] +pub async fn connect_spotify(state: State<'_, AppState>) -> Result { + let client_id = spotify_client_id() + .ok_or_else(|| "Set BUZZ_SPOTIFY_CLIENT_ID to enable Spotify.".to_string())?; + let redirect_port = spotify_redirect_port(); + let listener = TcpListener::bind(("127.0.0.1", redirect_port)) + .await + .map_err(|e| format!("bind Spotify OAuth callback on port {redirect_port}: {e}"))?; + let redirect_uri = format!("http://127.0.0.1:{redirect_port}{OAUTH_CALLBACK_PATH}"); + let code_verifier = pkce_verifier(); + let state_token = oauth_state(); + let auth_url = oauth_authorization_url( + &client_id, + &redirect_uri, + &state_token, + &pkce_challenge(&code_verifier), + )?; + + tauri_plugin_opener::open_url(auth_url.as_str(), None::<&str>) + .map_err(|e| format!("open Spotify authorization page: {e}"))?; + + let code = wait_for_oauth_callback(listener, &state_token).await?; + let token = + exchange_code_for_token(&code, &code_verifier, &redirect_uri, &client_id, &state).await?; + let refresh_token = token.refresh_token.ok_or_else(|| { + "Spotify did not return a refresh token. Disconnect and try again.".to_string() + })?; + let credential = SpotifyCredential { + access_token: token.access_token, + connected_at: now_ts(), + expires_at: token.expires_in.map(|seconds| now_ts() + seconds), + refresh_token, + scope: token.scope, + token_type: token.token_type, + }; + save_credential(&credential)?; + + Ok(status_from_credential(Some(&credential))) +} + +#[tauri::command] +pub fn disconnect_spotify() -> Result { + credential_store().delete(SPOTIFY_CREDENTIAL_KEY)?; + Ok(status_from_credential(None)) +} + +#[tauri::command] +pub async fn get_spotify_devices(state: State<'_, AppState>) -> Result, String> { + let access_token = access_token(&state).await?; + let url = format!("{SPOTIFY_API_BASE_URL}/me/player/devices"); + let response = state + .http_client + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify devices request couldn't complete: {e}"))?; + let raw: SpotifyDevicesResponse = spotify_json(response, "devices").await?; + Ok(raw.devices.into_iter().map(convert_device).collect()) +} + +#[tauri::command] +pub async fn get_spotify_playback_state( + state: State<'_, AppState>, +) -> Result, String> { + let access_token = access_token(&state).await?; + let url = format!("{SPOTIFY_API_BASE_URL}/me/player"); + let response = state + .http_client + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify playback state request couldn't complete: {e}"))?; + + if response.status().as_u16() == 204 { + return Ok(None); + } + + let raw: SpotifyRawPlaybackState = spotify_json(response, "playback state").await?; + Ok(Some(convert_playback_state(raw))) +} + +#[tauri::command] +pub async fn start_spotify_playback( + input: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let input = input.unwrap_or_default(); + let mut url = + Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/play")).map_err(|e| e.to_string())?; + if let Some(device_id) = input.device_id.as_deref().filter(|value| !value.is_empty()) { + url.query_pairs_mut().append_pair("device_id", device_id); + } + + let uris = input.uris.as_ref().filter(|values| !values.is_empty()); + let body = SpotifyPlaybackBody { + context_uri: input + .context_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + position_ms: input.position_ms, + uris: uris.map(Vec::as_slice), + }; + let has_body = body.context_uri.is_some() || body.position_ms.is_some() || body.uris.is_some(); + let request = state.http_client.put(url).bearer_auth(access_token); + let response = if has_body { + request.json(&body).send().await + } else { + request.send().await + } + .map_err(|e| format!("Spotify playback request couldn't complete: {e}"))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Spotify playback response: {e}"))?; + if !status.is_success() { + return Err(format!( + "Spotify playback request returned {status}: {body}" + )); + } + Ok(()) +} + +#[tauri::command] +pub async fn pause_spotify_playback( + input: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let mut url = Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/pause")) + .map_err(|e| e.to_string())?; + if let Some(device_id) = input + .and_then(|input| input.device_id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + url.query_pairs_mut().append_pair("device_id", &device_id); + } + + let response = state + .http_client + .put(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify pause request couldn't complete: {e}"))?; + spotify_empty(response, "pause").await +} + +#[tauri::command] +pub async fn skip_spotify_next( + input: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let mut url = + Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/next")).map_err(|e| e.to_string())?; + if let Some(device_id) = input + .and_then(|input| input.device_id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + url.query_pairs_mut().append_pair("device_id", &device_id); + } + + let response = state + .http_client + .post(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify next track request couldn't complete: {e}"))?; + spotify_empty(response, "next track").await +} + +#[tauri::command] +pub async fn skip_spotify_previous( + input: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let mut url = Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/previous")) + .map_err(|e| e.to_string())?; + if let Some(device_id) = input + .and_then(|input| input.device_id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + url.query_pairs_mut().append_pair("device_id", &device_id); + } + + let response = state + .http_client + .post(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify previous track request couldn't complete: {e}"))?; + spotify_empty(response, "previous track").await +} + +#[tauri::command] +pub async fn seek_spotify_playback( + input: SpotifySeekInput, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let mut url = + Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/seek")).map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("position_ms", &input.position_ms.to_string()); + if let Some(device_id) = input + .device_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + url.query_pairs_mut().append_pair("device_id", device_id); + } + + let response = state + .http_client + .put(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify seek request couldn't complete: {e}"))?; + spotify_empty(response, "seek").await +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 369046a9d..0c265020a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -445,6 +445,16 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, + get_spotify_status, + connect_spotify, + disconnect_spotify, + get_spotify_devices, + get_spotify_playback_state, + start_spotify_playback, + pause_spotify_playback, + skip_spotify_next, + skip_spotify_previous, + seek_spotify_playback, fetch_link_preview_title, discover_acp_providers, install_acp_runtime, diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index f5fd84907..2846bcaba 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -3,9 +3,11 @@ import { listen } from "@tauri-apps/api/event"; import { Bot, Captions, + EllipsisVertical, MessageSquareText, PhoneOff, SmilePlus, + UsersRound, } from "lucide-react"; import * as React from "react"; @@ -24,13 +26,23 @@ import { import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle } from "../HuddleContext"; import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import { MicControls, SpeakerControls } from "./MicControls"; -import { HuddleParticipantsControl } from "./ParticipantList"; +import { HuddleParticipantsPanel } from "./ParticipantList"; +import { SpotifyHuddleControl } from "./SpotifyHuddleControl"; // Mirrors HuddleState in src-tauri/src/huddle/mod.rs. type HuddleState = { @@ -524,6 +536,31 @@ export function HuddleBar({ onOpenThread?.(parentChannelId, huddleThreadEventId); } + async function handleRemoveAgentFromHuddle(pubkey: string) { + const channelId = barState?.ephemeral_channel_id; + if (!channelId) return; + const confirmed = window.confirm("Remove this agent from the huddle?"); + if (!confirmed) return; + try { + await invoke("remove_channel_member", { + channelId, + pubkey, + }); + // Optimistically remove from local state — the backend's 15s membership + // poll will eventually converge. + setState((prev) => { + if (!prev) return prev; + return { + ...prev, + participants: prev.participants.filter((p) => p !== pubkey), + agent_pubkeys: prev.agent_pubkeys.filter((p) => p !== pubkey), + }; + }); + } catch (e) { + console.error("Failed to remove agent from huddle:", e); + } + } + return (
-
- -
- { - if (!barState.ephemeral_channel_id) return; - const confirmed = window.confirm( - "Remove this agent from the huddle?", - ); - if (!confirmed) return; - try { - await invoke("remove_channel_member", { - channelId: barState.ephemeral_channel_id, - pubkey, - }); - // Optimistically remove from local state — the backend's - // 15s membership poll will eventually converge. - setState((prev) => { - if (!prev) return prev; - return { - ...prev, - participants: prev.participants.filter((p) => p !== pubkey), - agent_pubkeys: prev.agent_pubkeys.filter( - (p) => p !== pubkey, - ), - }; - }); - } catch (e) { - console.error("Failed to remove agent from huddle:", e); - } - }} - /> - - - - - - {transcriptionEnabled ? "Stop transcript" : "Start transcript"} - - + - - - + + + + More + + + + setShowAddAgent(true)}> - - - - Add agent - - + Add agent + + void handleToggleTranscript()}> + + {transcriptionEnabled ? "Stop transcript" : "Start transcript"} + + + + + Participants + {barState.participants.length > 0 ? ( + + ({barState.participants.length}) + + ) : null} + + + + void handleRemoveAgentFromHuddle(pubkey) + } + className="p-3" + /> + + + +
diff --git a/desktop/src/features/huddle/components/ParticipantList.tsx b/desktop/src/features/huddle/components/ParticipantList.tsx index a99e5aa6d..fa0461805 100644 --- a/desktop/src/features/huddle/components/ParticipantList.tsx +++ b/desktop/src/features/huddle/components/ParticipantList.tsx @@ -7,7 +7,7 @@ import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; -type ParticipantListProps = { +type ParticipantListBaseProps = { /** Pubkey hex strings from the Rust huddle state */ participants: string[]; activeSpeakers?: string[]; @@ -15,6 +15,9 @@ type ParticipantListProps = { agentPubkeys?: string[]; /** Called when the user clicks the remove button on an agent avatar */ onRemoveAgent?: (pubkey: string) => void; +}; + +type ParticipantListProps = ParticipantListBaseProps & { className?: string; }; @@ -25,20 +28,8 @@ export function HuddleParticipantsControl({ onRemoveAgent, className, }: ParticipantListProps) { - const { data } = useUsersBatchQuery(participants); - const profiles = data?.profiles ?? {}; - const agentSet = React.useMemo( - () => new Set(agentPubkeys ?? []), - [agentPubkeys], - ); - if (participants.length === 0) return null; - const participantLabel = - participants.length === 1 - ? "1 participant" - : `${participants.length} participants`; - return ( @@ -62,70 +53,104 @@ export function HuddleParticipantsControl({ -
-

Participants

- - {participantLabel} - -
-
    - {participants.map((pubkey) => { - const profile = profiles[pubkey.toLowerCase()]; - const displayName = - profile?.displayName || `Participant ${pubkey.slice(0, 8)}`; - const isActive = activeSpeakers?.includes(pubkey); - const isAgent = agentSet.has(pubkey); + + + + ); +} + +export function HuddleParticipantsPanel({ + participants, + activeSpeakers, + agentPubkeys, + onRemoveAgent, + className, +}: ParticipantListProps) { + const { data } = useUsersBatchQuery(participants); + const profiles = data?.profiles ?? {}; + const agentSet = React.useMemo( + () => new Set(agentPubkeys ?? []), + [agentPubkeys], + ); + + if (participants.length === 0) return null; - return ( -
  • - {profile?.displayName || profile?.avatarUrl ? ( - - ) : ( - - )} + const participantLabel = + participants.length === 1 + ? "1 participant" + : `${participants.length} participants`; + + return ( +
    +
    +

    Participants

    + + {participantLabel} + +
    +
      + {participants.map((pubkey) => { + const profile = profiles[pubkey.toLowerCase()]; + const displayName = + profile?.displayName || `Participant ${pubkey.slice(0, 8)}`; + const isActive = activeSpeakers?.includes(pubkey); + const isAgent = agentSet.has(pubkey); -
      -
      - {displayName} -
      -
      - {isActive ? "Speaking" : isAgent ? "Agent" : "In huddle"} -
      + return ( +
    • + {profile?.displayName || profile?.avatarUrl ? ( + + ) : ( + + )} + +
      +
      + {displayName} +
      +
      + {isActive ? "Speaking" : isAgent ? "Agent" : "In huddle"}
      +
      - {isAgent && onRemoveAgent && ( - - )} -
    • - ); - })} -
    - - + {isAgent && onRemoveAgent && ( + + )} +
  • + ); + })} +
+ ); } diff --git a/desktop/src/features/huddle/components/SpotifyHuddleControl.tsx b/desktop/src/features/huddle/components/SpotifyHuddleControl.tsx new file mode 100644 index 000000000..7852daafe --- /dev/null +++ b/desktop/src/features/huddle/components/SpotifyHuddleControl.tsx @@ -0,0 +1,811 @@ +import { + Music, + Music2, + Music3, + Music4, + Pause, + Play, + Plug, + SkipBack, + SkipForward, +} from "lucide-react"; +import * as React from "react"; + +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + useSpotifyConnectionMutations, + useSpotifyDevicesQuery, + useSpotifyPlaybackControlMutations, + useSpotifyPlaybackMutation, + useSpotifyPlaybackStateQuery, + useSpotifyStatusQuery, +} from "@/features/spotify/hooks"; +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_HUDDLE_SPOTIFY_DJ, + KIND_HUDDLE_SPOTIFY_DJ_LIVE, +} from "@/shared/constants/kinds"; +import { cn } from "@/shared/lib/cn"; +import { useNow } from "@/shared/lib/useNow"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +type SpotifyHuddleControlProps = { + agentPubkeys: string[]; + currentPubkey: string | null; + isHuddleVisible: boolean; + participants: string[]; + reactionChannelId: string | null; + reactionSenderName: string; +}; + +const HUDDLE_REACTION_NAME_MAX = 48; +const SPOTIFY_FLOATING_NOTE_LIMIT = 7; +const SPOTIFY_OPTIMISTIC_PLAYBACK_MS = 3_000; + +type SpotifyFloatingNote = { + id: number; + iconIndex: number; + xRem: number; + driftRem: number; + liftRem: number; + sizeRem: number; + rotationDeg: number; + rotationDeltaDeg: number; + durationMs: number; +}; + +type SpotifyFloatingNoteStyle = React.CSSProperties & { + "--buzz-spotify-note-x": string; + "--buzz-spotify-note-drift": string; + "--buzz-spotify-note-lift": string; + "--buzz-spotify-note-size": string; + "--buzz-spotify-note-rotation": string; + "--buzz-spotify-note-rotation-delta": string; + "--buzz-spotify-note-duration": string; +}; + +type OptimisticSpotifyPlayback = { + isPlaying: boolean; + progressMs: number | null; + updatedAt: number; +}; + +const SPOTIFY_FLOATING_NOTE_ICONS = [Music, Music2, Music3, Music4] as const; + +function firstTagValue(event: RelayEvent, name: string): string | null { + return event.tags.find((tag) => tag[0] === name)?.[1] ?? null; +} + +function clampReactionName(name: string): string { + const trimmed = name.trim(); + if (trimmed.length <= HUDDLE_REACTION_NAME_MAX) return trimmed; + return `${trimmed.slice(0, HUDDLE_REACTION_NAME_MAX - 1).trimEnd()}...`; +} + +function fallbackNameForPubkey(pubkey?: string | null): string { + return pubkey ? `Participant ${pubkey.slice(0, 8)}` : "Someone"; +} + +function normalizePubkey(pubkey?: string | null): string | null { + const normalized = pubkey?.trim().toLowerCase() ?? ""; + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null; +} + +function randomBetween(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function createSpotifyFloatingNote(id: number): SpotifyFloatingNote { + return { + id, + iconIndex: Math.floor(Math.random() * SPOTIFY_FLOATING_NOTE_ICONS.length), + xRem: randomBetween(-0.65, 0.65), + driftRem: randomBetween(-0.75, 0.75), + liftRem: randomBetween(1.7, 2.55), + sizeRem: randomBetween(0.55, 0.95), + rotationDeg: randomBetween(-28, 28), + rotationDeltaDeg: randomBetween(-32, 32), + durationMs: randomBetween(1350, 2050), + }; +} + +function parseHuddleSpotifyDjEvent(event: RelayEvent) { + if ( + event.kind !== KIND_HUDDLE_SPOTIFY_DJ && + event.kind !== KIND_HUDDLE_SPOTIFY_DJ_LIVE + ) { + return null; + } + + let contentDjPubkey: string | null = null; + try { + const parsed = JSON.parse(event.content) as { dj_pubkey?: unknown }; + contentDjPubkey = + typeof parsed.dj_pubkey === "string" ? parsed.dj_pubkey : null; + } catch { + contentDjPubkey = null; + } + + const djPubkey = normalizePubkey( + firstTagValue(event, "dj") ?? contentDjPubkey ?? event.pubkey, + ); + if (!djPubkey) return null; + + return { + djPubkey, + senderName: firstTagValue(event, "sender_name"), + }; +} + +function huddleSpotifyDjTags( + channelId: string, + djPubkey: string, + senderName: string, +): string[][] { + return [ + ["h", channelId], + ["dj", djPubkey], + ["sender_name", clampReactionName(senderName)], + ]; +} + +function friendlySpotifyPlaybackError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + + if (lower.includes("premium") || lower.includes("403")) { + return "Spotify playback controls require Spotify Premium."; + } + + if ( + lower.includes("no_active_device") || + lower.includes("no active device") || + lower.includes("404") + ) { + return "Open Spotify on a device first, then try again."; + } + + if (lower.includes("restricted") || lower.includes("restriction")) { + return "Spotify cannot control that device right now."; + } + + return "Spotify couldn't resume. Open Spotify on a device first, then try again."; +} + +function formatSpotifyArtists(artists: string[]): string { + return artists.length > 0 ? artists.join(", ") : "Unknown artist"; +} + +function formatSpotifyDuration(ms: number | null | undefined): string { + if (ms == null || ms <= 0) return "--:--"; + + const totalSeconds = Math.floor(ms / 1_000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3_600); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}`; + } + + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +function spotifyProgressMs({ + durationMs, + isPlaying, + nowMs, + progressMs, + timestamp, +}: { + durationMs: number | null | undefined; + isPlaying: boolean; + nowMs: number; + progressMs: number | null | undefined; + timestamp: number | null | undefined; +}): number { + const base = progressMs ?? 0; + const elapsed = isPlaying && timestamp ? Math.max(0, nowMs - timestamp) : 0; + const next = base + elapsed; + return durationMs && durationMs > 0 ? Math.min(next, durationMs) : next; +} + +function SpotifyFloatingMusicNotes({ + notes, +}: { + notes: SpotifyFloatingNote[]; +}) { + if (notes.length === 0) return null; + + return ( + + ); +} + +export function SpotifyHuddleControl({ + agentPubkeys, + currentPubkey, + isHuddleVisible, + participants, + reactionChannelId, + reactionSenderName, +}: SpotifyHuddleControlProps) { + const spotifyStatusQuery = useSpotifyStatusQuery(); + const spotifyStatus = spotifyStatusQuery.data; + const spotifyConfigured = Boolean(spotifyStatus?.configured); + const spotifyConnected = Boolean(spotifyStatus?.connected); + const spotifyDevicesQuery = useSpotifyDevicesQuery({ + enabled: spotifyConnected, + }); + const spotifyPlaybackStateQuery = useSpotifyPlaybackStateQuery({ + enabled: spotifyConnected, + }); + const spotifyConnection = useSpotifyConnectionMutations(); + const spotifyPlayback = useSpotifyPlaybackMutation(); + const spotifyControls = useSpotifyPlaybackControlMutations(); + const participantProfilesQuery = useUsersBatchQuery(participants, { + enabled: participants.length > 0, + }); + const spotifyNowMs = useNow(1_000); + + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const floatingNoteIdRef = React.useRef(0); + const [floatingNotes, setFloatingNotes] = React.useState< + SpotifyFloatingNote[] + >([]); + const [optimisticPlayback, setOptimisticPlayback] = + React.useState(null); + const [djState, setDjState] = React.useState<{ + pubkey: string | null; + createdAt: number; + }>({ pubkey: null, createdAt: 0 }); + const [isRequestingDj, setIsRequestingDj] = React.useState(false); + const [error, setError] = React.useState(null); + + const currentPubkeyNormalized = normalizePubkey(currentPubkey); + const queriedIsPlaying = Boolean(spotifyPlaybackStateQuery.data?.isPlaying); + const optimisticPlaybackActive = Boolean( + optimisticPlayback && + spotifyNowMs - optimisticPlayback.updatedAt < + SPOTIFY_OPTIMISTIC_PLAYBACK_MS, + ); + const effectiveIsPlaying = optimisticPlaybackActive + ? Boolean(optimisticPlayback?.isPlaying) + : queriedIsPlaying; + const isPlayingInHuddle = Boolean( + isHuddleVisible && spotifyConnected && effectiveIsPlaying, + ); + + React.useEffect(() => { + if (!optimisticPlayback) return; + if (spotifyPlaybackStateQuery.data === undefined) return; + + if (queriedIsPlaying === optimisticPlayback.isPlaying) { + setOptimisticPlayback(null); + } + }, [optimisticPlayback, queriedIsPlaying, spotifyPlaybackStateQuery.data]); + + React.useEffect(() => { + if (!spotifyConnected) { + setOptimisticPlayback(null); + } + }, [spotifyConnected]); + + React.useEffect(() => { + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + if (!isPlayingInHuddle || prefersReducedMotion) { + setFloatingNotes([]); + return; + } + + let disposed = false; + let timeoutId: number | null = null; + + function emitNote() { + const note = createSpotifyFloatingNote(floatingNoteIdRef.current++); + setFloatingNotes((current) => [ + ...current.slice(-(SPOTIFY_FLOATING_NOTE_LIMIT - 1)), + note, + ]); + window.setTimeout(() => { + if (disposed) return; + setFloatingNotes((current) => + current.filter((item) => item.id !== note.id), + ); + }, note.durationMs + 80); + } + + function scheduleNext(delayMs: number) { + timeoutId = window.setTimeout(() => { + emitNote(); + scheduleNext(randomBetween(420, 780)); + }, delayMs); + } + + emitNote(); + scheduleNext(randomBetween(420, 780)); + + return () => { + disposed = true; + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + }; + }, [isPlayingInHuddle]); + + React.useEffect(() => { + if (!reactionChannelId) { + setDjState({ pubkey: null, createdAt: 0 }); + setIsRequestingDj(false); + return; + } + + let disposed = false; + let cleanup: (() => void) | null = null; + + setDjState({ pubkey: null, createdAt: 0 }); + setIsRequestingDj(false); + + void relayClient + .subscribeLive( + { + kinds: [KIND_HUDDLE_SPOTIFY_DJ, KIND_HUDDLE_SPOTIFY_DJ_LIVE], + "#h": [reactionChannelId], + limit: 25, + }, + (event) => { + if (disposed) return; + + const djEvent = parseHuddleSpotifyDjEvent(event); + if (!djEvent) return; + + setDjState((prev) => { + if (event.created_at < prev.createdAt) return prev; + return { + pubkey: djEvent.djPubkey, + createdAt: event.created_at, + }; + }); + if (djEvent.djPubkey === currentPubkeyNormalized) { + setIsRequestingDj(false); + } + }, + ) + .then((dispose) => { + if (disposed) { + void dispose(); + return; + } + cleanup = () => void dispose(); + }) + .catch((subscribeError) => { + console.error( + "[huddle] Failed to subscribe to Spotify DJ state:", + subscribeError, + ); + }); + + return () => { + disposed = true; + cleanup?.(); + }; + }, [currentPubkeyNormalized, reactionChannelId]); + + const devices = + spotifyDevicesQuery.data?.filter( + (device) => device.id && !device.isRestricted, + ) ?? []; + const playbackState = spotifyPlaybackStateQuery.data ?? null; + const playbackDevice = + playbackState?.device?.id && !playbackState.device.isRestricted + ? playbackState.device + : null; + const resumeDevice = + playbackDevice ?? devices.find((device) => device.isActive) ?? devices[0]; + const track = playbackState?.item ?? null; + const durationMs = track?.durationMs ?? null; + const queriedProgressMs = spotifyProgressMs({ + durationMs, + isPlaying: Boolean(playbackState?.isPlaying), + nowMs: spotifyNowMs, + progressMs: playbackState?.progressMs, + timestamp: playbackState?.timestamp, + }); + const currentProgressMs = + optimisticPlaybackActive && optimisticPlayback + ? spotifyProgressMs({ + durationMs, + isPlaying: optimisticPlayback.isPlaying, + nowMs: spotifyNowMs, + progressMs: optimisticPlayback.progressMs ?? queriedProgressMs, + timestamp: optimisticPlayback.updatedAt, + }) + : queriedProgressMs; + const progressPercent = + durationMs && durationMs > 0 + ? Math.max(0, Math.min(100, (currentProgressMs / durationMs) * 100)) + : 0; + const controlBusy = + spotifyPlayback.isPending || + spotifyControls.pause.isPending || + spotifyControls.next.isPending || + spotifyControls.previous.isPending; + const showButton = + spotifyConnected || + spotifyConfigured || + spotifyStatusQuery.isLoading || + spotifyStatusQuery.isError; + const connectBusy = + spotifyStatusQuery.isLoading || spotifyConnection.connect.isPending; + const huddleAgentPubkeys = new Set( + agentPubkeys + .map(normalizePubkey) + .filter((pubkey): pubkey is string => pubkey !== null), + ); + const firstHumanParticipantPubkey = + participants + .map(normalizePubkey) + .find( + (pubkey): pubkey is string => + pubkey !== null && !huddleAgentPubkeys.has(pubkey), + ) ?? null; + const djPubkey = djState.pubkey ?? firstHumanParticipantPubkey; + const isDj = Boolean( + currentPubkeyNormalized && djPubkey === currentPubkeyNormalized, + ); + const participantProfiles = participantProfilesQuery.data?.profiles ?? {}; + const djName = + djPubkey === null + ? null + : djPubkey === currentPubkeyNormalized + ? "You" + : (participantProfiles[djPubkey]?.displayName ?? + fallbackNameForPubkey(djPubkey)); + + async function handlePlaybackControl( + control: "next" | "play" | "previous" | "pause", + ) { + setError(null); + + if (!resumeDevice?.id) { + setError("Open Spotify on a device first, then try again."); + return; + } + + const previousOptimisticPlayback = optimisticPlayback; + if (control === "play" || control === "pause") { + setOptimisticPlayback({ + isPlaying: control === "play", + progressMs: currentProgressMs, + updatedAt: Date.now(), + }); + } + + try { + const input = { deviceId: resumeDevice.id }; + if (control === "play") { + await spotifyPlayback.mutateAsync(input); + } else if (control === "pause") { + await spotifyControls.pause.mutateAsync(input); + } else if (control === "next") { + await spotifyControls.next.mutateAsync(input); + } else { + await spotifyControls.previous.mutateAsync(input); + } + } catch (playbackError) { + setOptimisticPlayback(previousOptimisticPlayback); + setError(friendlySpotifyPlaybackError(playbackError)); + console.error("Failed to control Spotify playback:", playbackError); + } finally { + void spotifyPlaybackStateQuery.refetch(); + } + } + + async function handleRequestDj(checked: boolean) { + if (!checked || isDj || !reactionChannelId || !currentPubkeyNormalized) { + return; + } + + const previousDjState = djState; + const createdAt = Math.floor(Date.now() / 1_000); + setIsRequestingDj(true); + setError(null); + setDjState({ pubkey: currentPubkeyNormalized, createdAt }); + + try { + await relayClient.preconnect(); + const content = JSON.stringify({ + dj_pubkey: currentPubkeyNormalized, + requested_at: createdAt, + }); + const tags = huddleSpotifyDjTags( + reactionChannelId, + currentPubkeyNormalized, + reactionSenderName, + ); + + const liveEvent = await signRelayEvent({ + kind: KIND_HUDDLE_SPOTIFY_DJ_LIVE, + content, + tags, + }); + await relayClient.publishEvent( + liveEvent, + "Timed out while requesting Spotify DJ controls.", + "Failed to request Spotify DJ controls.", + ); + + try { + const storedEvent = await signRelayEvent({ + kind: KIND_HUDDLE_SPOTIFY_DJ, + content, + tags, + }); + await relayClient.publishEvent( + storedEvent, + "Timed out while saving Spotify DJ controls.", + "Failed to save Spotify DJ controls.", + ); + } catch (storedError) { + console.warn( + "[huddle] Spotify DJ controls are live but not persisted:", + storedError, + ); + } + } catch (requestError) { + setDjState(previousDjState); + setError("Could not request DJ controls."); + console.error( + "[huddle] Failed to request Spotify DJ controls:", + requestError, + ); + } finally { + setIsRequestingDj(false); + } + } + + async function handleConnect() { + setError(null); + + try { + await spotifyConnection.connect.mutateAsync(); + } catch (connectError) { + const message = + connectError instanceof Error + ? connectError.message + : String(connectError); + setError(message || "Could not connect Spotify."); + console.error("[huddle] Failed to connect Spotify:", connectError); + } + } + + if (!showButton) return null; + + return ( + { + setIsPopoverOpen(open); + if (open && spotifyConnected) { + void spotifyDevicesQuery.refetch(); + void spotifyPlaybackStateQuery.refetch(); + } + }} + open={isPopoverOpen} + > + + + + + + + + Spotify controls + + + + {error ? ( +

+ {error} +

+ ) : null} + + {spotifyConnected ? ( + <> +
+ {track?.imageUrl ? ( + + ) : ( +
+ +
+ )} +
+

+ {track?.name ?? "No track playing"} +

+

+ {track + ? formatSpotifyArtists(track.artists) + : (resumeDevice?.name ?? "Spotify")} +

+
+
+ +
+
+
+
+
+ {formatSpotifyDuration(currentProgressMs)} + {formatSpotifyDuration(durationMs)} +
+
+ +
+ {isDj ? ( + <> + + + + + ) : ( +
+
+

+ {djName ? `${djName} is DJ` : "No DJ"} +

+

+ Request DJ controls +

+
+ void handleRequestDj(checked)} + /> +
+ )} +
+ + ) : ( +
+
+
+ +
+
+

+ Spotify Premium required +

+

+ Connect a Spotify Premium account. +

+
+
+ + +
+ )} + + + ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 944b32246..b45990131 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -12,8 +12,8 @@ import { Monitor, MonitorCog, Moon, + Plug, Search, - Smartphone, Smile, Stethoscope, Sun, @@ -27,6 +27,7 @@ import type { import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound"; import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard"; import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard"; +import { ConnectionsSettingsCard } from "@/features/spotify/ui/ConnectionsSettingsCard"; import { cn } from "@/shared/lib/cn"; import { ACCENT_COLORS, @@ -40,7 +41,6 @@ import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; -import { MobilePairingCard } from "./MobilePairingCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; @@ -50,6 +50,7 @@ import { SettingsSectionHeader } from "./SettingsSectionHeader"; export type SettingsSection = | "profile" | "notifications" + | "connections" | "experimental" | "agents" | "channel-templates" @@ -58,7 +59,6 @@ export type SettingsSection = | "shortcuts" | "relay-members" | "custom-emoji" - | "mobile" | "updates" | "doctor"; @@ -67,6 +67,7 @@ export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "profile", "notifications", + "connections", "experimental", "agents", "channel-templates", @@ -75,7 +76,6 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "shortcuts", "relay-members", "custom-emoji", - "mobile", "updates", "doctor", ]; @@ -126,6 +126,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Notifications", icon: BellRing, }, + { + value: "connections", + label: "Connections", + icon: Plug, + }, { value: "experimental", label: "Experiments", @@ -164,11 +169,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [ icon: Smile, featureGate: "custom-emoji", }, - { - value: "mobile", - label: "Mobile", - icon: Smartphone, - }, { value: "updates", label: "Updates", @@ -370,6 +370,8 @@ export function renderSettingsSection( onSetSoundForSlot={props.onSetSoundForSlot} /> ); + case "connections": + return ; case "experimental": return ; case "agents": @@ -386,8 +388,6 @@ export function renderSettingsSection( return ; case "custom-emoji": return ; - case "mobile": - return ; case "updates": return ; case "doctor": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 4a8de7cb7..2d0693c9a 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -63,10 +63,10 @@ const settingsNavGroups: Array<{ { label: "App", sections: [ + "connections", "agents", "compute", "experimental", - "mobile", "updates", "doctor", ], diff --git a/desktop/src/features/spotify/api.ts b/desktop/src/features/spotify/api.ts new file mode 100644 index 000000000..50f9ed248 --- /dev/null +++ b/desktop/src/features/spotify/api.ts @@ -0,0 +1,186 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export type SpotifyStatus = { + configured: boolean; + connected: boolean; + connectedAt: number | null; + scopes: string[]; +}; + +export type SpotifyDevice = { + id: string | null; + name: string; + deviceType: string; + isActive: boolean; + isRestricted: boolean; + volumePercent: number | null; +}; + +export type SpotifyPlaybackInput = { + contextUri?: string; + deviceId?: string; + positionMs?: number; + uris?: string[]; +}; + +export type SpotifyPlaybackState = { + contextUri: string | null; + device: SpotifyDevice | null; + isPlaying: boolean; + item: SpotifyPlaybackItem | null; + progressMs: number | null; + timestamp: number | null; +}; + +export type SpotifyPlaybackItem = { + artists: string[]; + durationMs: number | null; + imageUrl: string | null; + itemType: string | null; + name: string; + uri: string; +}; + +export type SpotifyDeviceInput = { + deviceId?: string; +}; + +export type SpotifySeekInput = SpotifyDeviceInput & { + positionMs: number; +}; + +type RawSpotifyStatus = { + configured: boolean; + connected: boolean; + connected_at: number | null; + scopes: string[]; +}; + +type RawSpotifyDevice = { + id: string | null; + name: string; + device_type: string; + is_active: boolean; + is_restricted: boolean; + volume_percent: number | null; +}; + +type RawSpotifyPlaybackState = { + context_uri: string | null; + device: RawSpotifyDevice | null; + is_playing: boolean; + item: RawSpotifyPlaybackItem | null; + progress_ms: number | null; + timestamp: number | null; +}; + +type RawSpotifyPlaybackItem = { + artists: string[]; + duration_ms: number | null; + image_url: string | null; + item_type: string | null; + name: string; + uri: string; +}; + +function fromRawStatus(raw: RawSpotifyStatus): SpotifyStatus { + return { + configured: raw.configured, + connected: raw.connected, + connectedAt: raw.connected_at, + scopes: raw.scopes, + }; +} + +function fromRawDevice(raw: RawSpotifyDevice): SpotifyDevice { + return { + id: raw.id, + name: raw.name, + deviceType: raw.device_type, + isActive: raw.is_active, + isRestricted: raw.is_restricted, + volumePercent: raw.volume_percent, + }; +} + +function fromRawPlaybackItem(raw: RawSpotifyPlaybackItem): SpotifyPlaybackItem { + return { + artists: raw.artists, + durationMs: raw.duration_ms, + imageUrl: raw.image_url, + itemType: raw.item_type, + name: raw.name, + uri: raw.uri, + }; +} + +function fromRawPlaybackState( + raw: RawSpotifyPlaybackState, +): SpotifyPlaybackState { + return { + contextUri: raw.context_uri, + device: raw.device ? fromRawDevice(raw.device) : null, + isPlaying: raw.is_playing, + item: raw.item ? fromRawPlaybackItem(raw.item) : null, + progressMs: raw.progress_ms, + timestamp: raw.timestamp, + }; +} + +export async function getSpotifyStatus(): Promise { + return fromRawStatus( + await invokeTauri("get_spotify_status"), + ); +} + +export async function connectSpotify(): Promise { + return fromRawStatus(await invokeTauri("connect_spotify")); +} + +export async function disconnectSpotify(): Promise { + return fromRawStatus( + await invokeTauri("disconnect_spotify"), + ); +} + +export async function getSpotifyDevices(): Promise { + const raw = await invokeTauri("get_spotify_devices"); + return raw.map(fromRawDevice); +} + +export async function getSpotifyPlaybackState(): Promise { + const raw = await invokeTauri( + "get_spotify_playback_state", + ); + return raw ? fromRawPlaybackState(raw) : null; +} + +export async function startSpotifyPlayback( + input: SpotifyPlaybackInput = {}, +): Promise { + await invokeTauri("start_spotify_playback", { input }); +} + +export async function pauseSpotifyPlayback( + input: SpotifyDeviceInput = {}, +): Promise { + await invokeTauri("pause_spotify_playback", { input }); +} + +export async function skipSpotifyNext( + input: SpotifyDeviceInput = {}, +): Promise { + await invokeTauri("skip_spotify_next", { input }); +} + +export async function skipSpotifyPrevious( + input: SpotifyDeviceInput = {}, +): Promise { + await invokeTauri("skip_spotify_previous", { input }); +} + +export async function seekSpotifyPlayback( + input: SpotifySeekInput, +): Promise { + await invokeTauri("seek_spotify_playback", { input }); +} diff --git a/desktop/src/features/spotify/hooks.ts b/desktop/src/features/spotify/hooks.ts new file mode 100644 index 000000000..a416929e4 --- /dev/null +++ b/desktop/src/features/spotify/hooks.ts @@ -0,0 +1,123 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + connectSpotify, + disconnectSpotify, + getSpotifyDevices, + getSpotifyPlaybackState, + getSpotifyStatus, + pauseSpotifyPlayback, + seekSpotifyPlayback, + skipSpotifyNext, + skipSpotifyPrevious, + startSpotifyPlayback, + type SpotifyDeviceInput, + type SpotifyPlaybackInput, + type SpotifySeekInput, +} from "@/features/spotify/api"; + +export const spotifyStatusQueryKey = ["spotify-status"] as const; +export const spotifyDevicesQueryKey = ["spotify-devices"] as const; +export const spotifyPlaybackStateQueryKey = ["spotify-playback-state"] as const; + +export function useSpotifyStatusQuery() { + return useQuery({ + queryKey: spotifyStatusQueryKey, + queryFn: getSpotifyStatus, + staleTime: 60_000, + }); +} + +export function useSpotifyDevicesQuery({ enabled }: { enabled: boolean }) { + return useQuery({ + enabled, + queryKey: spotifyDevicesQueryKey, + queryFn: getSpotifyDevices, + refetchInterval: enabled ? 30_000 : false, + staleTime: 20_000, + }); +} + +export function useSpotifyPlaybackStateQuery({ + enabled, +}: { + enabled: boolean; +}) { + return useQuery({ + enabled, + queryKey: spotifyPlaybackStateQueryKey, + queryFn: getSpotifyPlaybackState, + refetchInterval: enabled ? 10_000 : false, + staleTime: 5_000, + }); +} + +export function useSpotifyConnectionMutations() { + const queryClient = useQueryClient(); + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: spotifyStatusQueryKey }); + void queryClient.invalidateQueries({ queryKey: spotifyDevicesQueryKey }); + void queryClient.invalidateQueries({ + queryKey: spotifyPlaybackStateQueryKey, + }); + }; + + const connect = useMutation({ + mutationFn: connectSpotify, + onSuccess: invalidate, + }); + const disconnect = useMutation({ + mutationFn: disconnectSpotify, + onSuccess: invalidate, + }); + + return { connect, disconnect }; +} + +export function useSpotifyPlaybackMutation() { + const queryClient = useQueryClient(); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: spotifyDevicesQueryKey }); + void queryClient.invalidateQueries({ + queryKey: spotifyPlaybackStateQueryKey, + }); + }; + + return useMutation({ + mutationFn: (input?: SpotifyPlaybackInput) => + startSpotifyPlayback(input ?? {}), + onSuccess: invalidate, + }); +} + +export function useSpotifyPlaybackControlMutations() { + const queryClient = useQueryClient(); + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: spotifyDevicesQueryKey }); + void queryClient.invalidateQueries({ + queryKey: spotifyPlaybackStateQueryKey, + }); + }; + + const pause = useMutation({ + mutationFn: (input?: SpotifyDeviceInput) => + pauseSpotifyPlayback(input ?? {}), + onSuccess: invalidate, + }); + const next = useMutation({ + mutationFn: (input?: SpotifyDeviceInput) => skipSpotifyNext(input ?? {}), + onSuccess: invalidate, + }); + const previous = useMutation({ + mutationFn: (input?: SpotifyDeviceInput) => + skipSpotifyPrevious(input ?? {}), + onSuccess: invalidate, + }); + const seek = useMutation({ + mutationFn: (input: SpotifySeekInput) => seekSpotifyPlayback(input), + onSuccess: invalidate, + }); + + return { pause, next, previous, seek }; +} diff --git a/desktop/src/features/spotify/ui/ConnectionsSettingsCard.tsx b/desktop/src/features/spotify/ui/ConnectionsSettingsCard.tsx new file mode 100644 index 000000000..6ff055a88 --- /dev/null +++ b/desktop/src/features/spotify/ui/ConnectionsSettingsCard.tsx @@ -0,0 +1,15 @@ +import { SpotifySettingsCard } from "@/features/spotify/ui/SpotifySettingsCard"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; + +export function ConnectionsSettingsCard() { + return ( +
+ + + +
+ ); +} diff --git a/desktop/src/features/spotify/ui/SpotifySettingsCard.tsx b/desktop/src/features/spotify/ui/SpotifySettingsCard.tsx new file mode 100644 index 000000000..92335be93 --- /dev/null +++ b/desktop/src/features/spotify/ui/SpotifySettingsCard.tsx @@ -0,0 +1,80 @@ +import { Music2, Plug, Unplug } from "lucide-react"; + +import { + useSpotifyConnectionMutations, + useSpotifyStatusQuery, +} from "@/features/spotify/hooks"; +import { Button } from "@/shared/ui/button"; +import { + SettingsOptionGroup, + SettingsOptionRow, +} from "@/features/settings/ui/SettingsOptionGroup"; +import { Spinner } from "@/shared/ui/spinner"; + +function errorMessage(error: unknown): string | null { + if (!error) return null; + return error instanceof Error ? error.message : String(error); +} + +export function SpotifySettingsCard() { + const statusQuery = useSpotifyStatusQuery(); + const status = statusQuery.data; + const connected = Boolean(status?.connected); + const { connect, disconnect } = useSpotifyConnectionMutations(); + const isBusy = connect.isPending || disconnect.isPending; + const isLoading = statusQuery.isLoading; + const mutationError = + errorMessage(connect.error) ?? errorMessage(disconnect.error); + const statusError = errorMessage(statusQuery.error); + + return ( +
+ + +
+ + + Spotify + +
+ {connected ? ( + + ) : ( + + )} +
+
+ + {statusError || mutationError ? ( +
+ {mutationError ?? statusError} +
+ ) : null} +
+ ); +} diff --git a/desktop/src/shared/constants/kinds.test.mjs b/desktop/src/shared/constants/kinds.test.mjs index e842574f9..c50a17d65 100644 --- a/desktop/src/shared/constants/kinds.test.mjs +++ b/desktop/src/shared/constants/kinds.test.mjs @@ -17,6 +17,7 @@ import { KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_ENDED, + KIND_HUDDLE_SPOTIFY_DJ, } from "./kinds.ts"; test("isConversationalUnreadKind_streamMessage_counts", () => { @@ -57,6 +58,7 @@ test("isConversationalUnreadKind_huddleLifecycle_excluded", () => { KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_ENDED, + KIND_HUDDLE_SPOTIFY_DJ, ]) { assert.equal(isConversationalUnreadKind(kind), false, `kind ${kind}`); } diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index c5ea0a899..4190d52b1 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -22,10 +22,12 @@ export const KIND_MEMBER_ADDED_NOTIFICATION = 44100; export const KIND_MEMBER_REMOVED_NOTIFICATION = 44101; export const KIND_TYPING_INDICATOR = 20002; export const KIND_HUDDLE_REACTION = 24810; +export const KIND_HUDDLE_SPOTIFY_DJ_LIVE = 24811; export const KIND_HUDDLE_STARTED = 48100; export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101; export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102; export const KIND_HUDDLE_ENDED = 48103; +export const KIND_HUDDLE_SPOTIFY_DJ = 48104; // NIP-78 application-specific data. All use kind 30078; the relay // differentiates them by d-tag ("read-state:", "channel-sections", "channel-mutes", "channel-stars"). export const KIND_READ_STATE = 30078; @@ -132,6 +134,7 @@ const NON_CONVERSATIONAL_UNREAD_KINDS: ReadonlySet = new Set([ KIND_HUDDLE_PARTICIPANT_JOINED, // 48101 KIND_HUDDLE_PARTICIPANT_LEFT, // 48102 KIND_HUDDLE_ENDED, // 48103 + KIND_HUDDLE_SPOTIFY_DJ, // 48104 ]); // Whether a timeline message kind should count toward unread tallies. An diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index b251d1735..e42034410 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.css @@ -141,6 +141,67 @@ width: 0.25rem; } + .buzz-spotify-floating-notes { + inset: 0; + overflow: visible; + pointer-events: none; + position: absolute; + } + + .buzz-spotify-floating-note { + animation: buzz-spotify-floating-note + var(--buzz-spotify-note-duration, 1600ms) linear forwards; + bottom: 58%; + color: currentcolor; + display: block; + height: var(--buzz-spotify-note-size, 0.75rem); + left: 50%; + opacity: 0; + position: absolute; + transform-origin: center; + width: var(--buzz-spotify-note-size, 0.75rem); + will-change: transform, opacity; + } + + @keyframes buzz-spotify-floating-note { + 0% { + opacity: 0; + transform: translate(-50%, 6%) translate(0, 0) scale(0.82) + rotate(var(--buzz-spotify-note-rotation, 0deg)); + } + + 18% { + opacity: 0.42; + } + + 56% { + opacity: 0.26; + } + + 92% { + opacity: 0; + } + + 100% { + opacity: 0; + transform: translate(-50%, 6%) + translate( + calc( + var(--buzz-spotify-note-x, 0rem) + + var(--buzz-spotify-note-drift, 0rem) + ), + calc(var(--buzz-spotify-note-lift, 2.8rem) * -1) + ) + scale(1) + rotate( + calc( + var(--buzz-spotify-note-rotation, 0deg) + + var(--buzz-spotify-note-rotation-delta, 0deg) + ) + ); + } + } + @media (prefers-reduced-motion: reduce) { .buzz-side-panel-enter { animation: none; @@ -154,6 +215,10 @@ .buzz-huddle-mic-meter-bar { transition: none; } + + .buzz-spotify-floating-notes { + display: none; + } } .buzz-startup-shell { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index eaaf6eaa4..02bc25ff5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2055,6 +2055,10 @@ const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; +let mockSpotifyConnected = false; +let mockSpotifyIsPlaying = false; +let mockSpotifyProgressMs = 0; +let mockSpotifyTrackIndex = 0; // Mesh-compute mock state — TEST-ONLY. // @@ -2090,6 +2094,72 @@ function resetMockMesh() { mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; } + +function resetMockSpotify() { + mockSpotifyConnected = false; + mockSpotifyIsPlaying = false; + mockSpotifyProgressMs = 0; + mockSpotifyTrackIndex = 0; +} + +function mockSpotifyStatus() { + return { + configured: true, + connected: mockSpotifyConnected, + connected_at: mockSpotifyConnected ? 1_700_000_000 : null, + scopes: [ + "user-read-playback-state", + "user-read-currently-playing", + "user-modify-playback-state", + ], + }; +} + +function mockSpotifyDevices() { + return mockSpotifyConnected + ? [ + { + id: "mock-spotify-device", + name: "Mock Spotify device", + device_type: "Computer", + is_active: true, + is_restricted: false, + volume_percent: 50, + }, + ] + : []; +} + +const mockSpotifyTracks = [ + { + artists: ["The Mock Tones"], + duration_ms: 182_000, + image_url: "https://i.scdn.co/image/mock-spotify-one", + item_type: "track", + name: "Huddle groove", + uri: "spotify:track:mock-huddle-groove", + }, + { + artists: ["Buzz Quartet"], + duration_ms: 205_000, + image_url: "https://i.scdn.co/image/mock-spotify-two", + item_type: "track", + name: "Pair programming", + uri: "spotify:track:mock-pair-programming", + }, +]; + +function mockSpotifyPlaybackState() { + if (!mockSpotifyConnected) return null; + return { + context_uri: "spotify:playlist:mock-huddle-playlist", + device: mockSpotifyDevices()[0] ?? null, + is_playing: mockSpotifyIsPlaying, + item: mockSpotifyTracks[mockSpotifyTrackIndex], + progress_ms: mockSpotifyProgressMs, + timestamp: Date.now(), + }; +} let mockPersonas: RawPersona[] = []; let mockTeams: RawTeam[] = []; // Listeners registered via the mock __TAURI_INTERNALS__.listen — keyed by event name. @@ -6773,6 +6843,7 @@ export function maybeInstallE2eTauriMocks() { seedMockSearchProfiles(config); resetMockWorkflows(); resetMockMesh(); + resetMockSpotify(); resetMockUserStatuses(); mockWebsocketSendMutexWedged = false; mockWindows("main"); @@ -7490,6 +7561,57 @@ export function maybeInstallE2eTauriMocks() { ); case "get_media_proxy_port": return MOCK_MEDIA_PROXY_PORT; + case "get_spotify_status": + return mockSpotifyStatus(); + case "connect_spotify": + mockSpotifyConnected = true; + return mockSpotifyStatus(); + case "disconnect_spotify": + mockSpotifyConnected = false; + return mockSpotifyStatus(); + case "get_spotify_devices": + return mockSpotifyDevices(); + case "get_spotify_playback_state": + return mockSpotifyPlaybackState(); + case "start_spotify_playback": + if (!mockSpotifyConnected) { + throw new Error("Connect Spotify before controlling playback."); + } + mockSpotifyIsPlaying = true; + mockSpotifyProgressMs = 0; + return undefined; + case "pause_spotify_playback": + if (!mockSpotifyConnected) { + throw new Error("Connect Spotify before controlling playback."); + } + mockSpotifyIsPlaying = false; + return undefined; + case "skip_spotify_next": + if (!mockSpotifyConnected) { + throw new Error("Connect Spotify before controlling playback."); + } + mockSpotifyTrackIndex = + (mockSpotifyTrackIndex + 1) % mockSpotifyTracks.length; + mockSpotifyProgressMs = 0; + return undefined; + case "skip_spotify_previous": + if (!mockSpotifyConnected) { + throw new Error("Connect Spotify before controlling playback."); + } + mockSpotifyTrackIndex = + (mockSpotifyTrackIndex - 1 + mockSpotifyTracks.length) % + mockSpotifyTracks.length; + mockSpotifyProgressMs = 0; + return undefined; + case "seek_spotify_playback": + if (!mockSpotifyConnected) { + throw new Error("Connect Spotify before controlling playback."); + } + mockSpotifyProgressMs = Number( + (payload as { input?: { positionMs?: number } }).input?.positionMs ?? + 0, + ); + return undefined; case "pick_and_upload_media": return await resolveMockUploadDescriptors(activeConfig); case "upload_media_bytes": @@ -7599,6 +7721,8 @@ export function maybeInstallE2eTauriMocks() { return null; case "plugin:process|restart": return handleRestart(activeConfig); + case "plugin:opener|open_url": + return null; case "get_channel_workflows": return handleGetChannelWorkflows( payload as Parameters[0],