From 285b00e973f66598522352f78a954f746a34fcc9 Mon Sep 17 00:00:00 2001 From: Shayne Hartford Date: Wed, 21 Feb 2024 00:58:36 -0500 Subject: [PATCH] 2.6.2 - Spam Timeout --- Cargo.lock | 16 +-- Cargo.toml | 2 +- plugin-control/src/windows.rs | 83 +++++------ plugin-spotify/Cargo.toml | 1 - plugin-spotify/src/chatbox.rs | 85 ++++++++++++ plugin-spotify/src/control.rs | 156 +++++++++++++++++++++ plugin-spotify/{ => src}/lib.rs | 236 ++------------------------------ 7 files changed, 301 insertions(+), 278 deletions(-) create mode 100644 plugin-spotify/src/chatbox.rs create mode 100644 plugin-spotify/src/control.rs rename plugin-spotify/{ => src}/lib.rs (51%) diff --git a/Cargo.lock b/Cargo.lock index d8955e9..21fc553 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2281,7 +2281,7 @@ checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] name = "plugin-chatbox" -version = "2.6.1" +version = "2.6.2" dependencies = [ "anyhow", "async-ffi", @@ -2295,7 +2295,7 @@ dependencies = [ [[package]] name = "plugin-clock" -version = "2.6.1" +version = "2.6.2" dependencies = [ "anyhow", "derive-config", @@ -2307,7 +2307,7 @@ dependencies = [ [[package]] name = "plugin-control" -version = "2.6.1" +version = "2.6.2" dependencies = [ "anyhow", "enigo", @@ -2318,7 +2318,7 @@ dependencies = [ [[package]] name = "plugin-debug" -version = "2.6.1" +version = "2.6.2" dependencies = [ "anyhow", "rosc", @@ -2327,7 +2327,7 @@ dependencies = [ [[package]] name = "plugin-lastfm" -version = "2.6.1" +version = "2.6.2" dependencies = [ "anyhow", "async-ffi", @@ -2346,7 +2346,7 @@ dependencies = [ [[package]] name = "plugin-spotify" -version = "2.6.1" +version = "2.6.2" dependencies = [ "anyhow", "async-ffi", @@ -2367,7 +2367,7 @@ dependencies = [ [[package]] name = "plugin-steamvr" -version = "2.6.1" +version = "2.6.2" dependencies = [ "anyhow", "derive-config", @@ -3593,7 +3593,7 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "vrc-osc" -version = "2.6.1" +version = "2.6.2" dependencies = [ "anyhow", "async-ffi", diff --git a/Cargo.toml b/Cargo.toml index 0ba5b92..51f3b1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "2.6.1" +version = "2.6.2" authors = ["Shayne Hartford "] edition = "2021" readme = "README.md" diff --git a/plugin-control/src/windows.rs b/plugin-control/src/windows.rs index d4b5f43..2606a47 100644 --- a/plugin-control/src/windows.rs +++ b/plugin-control/src/windows.rs @@ -3,10 +3,7 @@ use std::{collections::HashMap, net::UdpSocket}; use anyhow::Result; use rosc::{decoder::MTU, OscMessage, OscPacket, OscType}; use windows::Media::{ - Control::{ - GlobalSystemMediaTransportControlsSession, - GlobalSystemMediaTransportControlsSessionManager as GSMTCSM, - }, + Control::GlobalSystemMediaTransportControlsSessionManager as GSMTCSM, MediaPlaybackAutoRepeatMode, }; @@ -18,6 +15,7 @@ use windows::Media::{ #[tokio::main(flavor = "current_thread")] pub async extern "Rust" fn load(socket: UdpSocket) -> Result<()> { let manager = GSMTCSM::RequestAsync()?.await?; + let mut previous_parameters = HashMap::new(); let mut buf = [0u8; MTU]; loop { @@ -85,47 +83,50 @@ pub async extern "Rust" fn load(socket: UdpSocket) -> Result<()> { session.TryChangePlaybackPositionAsync(playback_position) } _ => { - let _ = try_sync_media_state(&socket, &session); - continue; - } - }? - .await?; - } -} - -/// Try to synchronize the media session state to the `VRChat` menu parameters -fn try_sync_media_state( - socket: &UdpSocket, - session: &GlobalSystemMediaTransportControlsSession, -) -> Result<()> { - let playback_info = session.GetPlaybackInfo()?; - let mut parameters = HashMap::new(); + let playback_info = session.GetPlaybackInfo()?; + let mut parameters = HashMap::new(); + + if let Ok(playback_status) = playback_info.PlaybackStatus() { + let play = OscType::Bool(playback_status.0 == 4); + if previous_parameters.get("Play") != Some(&play) { + parameters.insert("Play", play.clone()); + previous_parameters.insert("Play", play); + } + } - if let Ok(playback_status) = playback_info.PlaybackStatus() { - parameters.insert("Play", OscType::Bool(playback_status.0 == 4)); - } + if let Ok(shuffle_ref) = playback_info.IsShuffleActive() { + if let Ok(shuffle) = shuffle_ref.Value() { + let shuffle = OscType::Bool(shuffle); + if previous_parameters.get("Shuffle") != Some(&shuffle) { + parameters.insert("Shuffle", shuffle.clone()); + previous_parameters.insert("Shuffle", shuffle); + } + } + } - if let Ok(shuffle_ref) = playback_info.IsShuffleActive() { - if let Ok(shuffle) = shuffle_ref.Value() { - parameters.insert("Shuffle", OscType::Bool(shuffle)); - } - } + if let Ok(repeat_mode_ref) = playback_info.AutoRepeatMode() { + if let Ok(repeat_mode) = repeat_mode_ref.Value() { + let repeat = OscType::Int(repeat_mode.0); + if previous_parameters.get("Repeat") != Some(&repeat) { + parameters.insert("Repeat", repeat.clone()); + previous_parameters.insert("Repeat", repeat); + } + } + } - if let Ok(repeat_mode_ref) = playback_info.AutoRepeatMode() { - if let Ok(repeat_mode) = repeat_mode_ref.Value() { - parameters.insert("Repeat", OscType::Int(repeat_mode.0)); - } - } + for (param, arg) in parameters { + let packet = OscPacket::Message(OscMessage { + addr: format!("/avatar/parameters/VRCOSC/Media/{param}"), + args: vec![arg], + }); - for (param, arg) in parameters { - let packet = OscPacket::Message(OscMessage { - addr: format!("/avatar/parameters/VRCOSC/Media/{param}"), - args: vec![arg], - }); + let msg_buf = rosc::encoder::encode(&packet)?; + socket.send(&msg_buf)?; + } - let msg_buf = rosc::encoder::encode(&packet)?; - socket.send(&msg_buf)?; + continue; + } + }? + .await?; } - - Ok(()) } diff --git a/plugin-spotify/Cargo.toml b/plugin-spotify/Cargo.toml index 44cfb69..888aea4 100644 --- a/plugin-spotify/Cargo.toml +++ b/plugin-spotify/Cargo.toml @@ -7,7 +7,6 @@ edition.workspace = true [lib] name = "spotify" -path = "lib.rs" crate-type = ["cdylib"] [dependencies] diff --git a/plugin-spotify/src/chatbox.rs b/plugin-spotify/src/chatbox.rs new file mode 100644 index 0000000..45e4324 --- /dev/null +++ b/plugin-spotify/src/chatbox.rs @@ -0,0 +1,85 @@ +use anyhow::{bail, Context, Result}; +use async_ffi::async_ffi; +use ferrispot::{ + model::{playback::PlayingType, track::FullTrack}, + prelude::*, +}; +use terminal_link::Link; +use tokio::runtime::Handle; + +use crate::{LYRICS, SPOTIFY}; + +#[no_mangle] +#[async_ffi(?Send)] +#[allow(clippy::unnecessary_wraps)] +#[allow(clippy::needless_pass_by_value)] +async extern "Rust" fn chat( + mut chatbox: String, + mut console: String, + handle: Handle, +) -> Result<(String, String)> { + let _enter = handle.enter(); + let config = crate::config()?; + let spotify = SPOTIFY.get().context("Spotify is Authenticating...")?; + let mut lyrics = LYRICS.get().context("Lyrics is Authenticating...")?.clone(); + + let current_item = spotify + .currently_playing_item() + .send_async() + .await? + .context("None")?; + + let public_item = current_item.public_playing_item().context("None")?; + let PlayingType::Track(track) = public_item.item() else { + bail!("None") + }; + + if config.enable_lyrics { + if let Ok(color_lyrics) = lyrics.get_color_lyrics(track.id().as_str()).await { + let words = color_lyrics + .lyrics + .lines + .iter() + .rev() + .try_find(|line| { + u64::try_from(public_item.progress().as_millis()) + .map(|progress| line.start_time_ms < progress) + }) + .iter() + .flatten() + .map(|line| line.words.clone()) + .collect::>() + .join(" "); + + if !words.is_empty() && words != "♪" { + chatbox = words.clone(); + console = words; + + return Ok((chatbox, console)); + } + }; + } + + replace(&mut chatbox, track); + replace(&mut console, track); + + let href = track.external_urls().spotify.clone().context("None")?; + let link = Link::new(&console, &href); + Ok((chatbox, link.to_string())) +} + +fn replace(message: &mut String, track: &FullTrack) { + let id = &track.id().to_string(); + let song = track.name(); + let artists = track + .artists() + .iter() + .map(CommonArtistInformation::name) + .collect::>() + .join(", "); + + *message = message + .replace("{id}", id) + .replace("{song}", song) + .replace("{artists}", &artists); +} diff --git a/plugin-spotify/src/control.rs b/plugin-spotify/src/control.rs new file mode 100644 index 0000000..412df67 --- /dev/null +++ b/plugin-spotify/src/control.rs @@ -0,0 +1,156 @@ +use std::{collections::HashMap, net::UdpSocket}; + +use anyhow::Result; +use ferrispot::{ + client::authorization_code::AsyncAuthorizationCodeUserClient, + model::playback::RepeatState, + prelude::*, +}; +use rosc::{decoder::MTU, OscMessage, OscPacket, OscType}; + +#[allow(clippy::too_many_lines)] +pub async fn start_loop( + socket: UdpSocket, + spotify: AsyncAuthorizationCodeUserClient, +) -> Result<()> { + let mut previous_parameters = HashMap::new(); + let mut muted_volume = None; + let mut buf = [0u8; MTU]; + loop { + let size = socket.recv(&mut buf)?; + let (_buf, packet) = rosc::decoder::decode_udp(&buf[..size])?; + let OscPacket::Message(packet) = packet else { + continue; // I don't think VRChat uses bundles + }; + + let addr = packet.addr.replace("/avatar/parameters/VRCOSC/Media/", ""); + let Some(arg) = packet.args.first() else { + continue; // No first argument was supplied + }; + + let Some(playback_state) = spotify.playback_state().send_async().await? else { + continue; // No media is currently playing + }; + + if muted_volume.is_none() { + muted_volume = Some(playback_state.device().volume_percent()); + } + + let request = match addr.as_ref() { + "Play" => { + let OscType::Bool(play) = arg.to_owned() else { + continue; + }; + + if play { + spotify.resume() + } else { + spotify.pause() + } + } + "Next" => spotify.next(), + "Prev" | "Previous" => spotify.previous(), + "Shuffle" => { + let OscType::Bool(shuffle) = arg.to_owned() else { + continue; + }; + + spotify.shuffle(shuffle) + } + // Seeking is not required because position is not used multiple times + // "Seeking" => continue, + "Muted" => { + let OscType::Bool(mute) = arg.to_owned() else { + continue; + }; + + let volume = if mute { + muted_volume = Some(playback_state.device().volume_percent()); + 0 + } else { + muted_volume.expect("Failed to get previous volume") + }; + + spotify.volume(volume) + } + "Repeat" => { + let OscType::Int(repeat) = arg.to_owned() else { + continue; + }; + + let repeat_state = match repeat { + 0 => RepeatState::Off, + 1 => RepeatState::Track, + 2 => RepeatState::Context, + _ => continue, + }; + + spotify.repeat_state(repeat_state) + } + "Volume" => { + let OscType::Float(volume) = arg.to_owned() else { + continue; + }; + + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + spotify.volume((volume * 100.0) as u8) + } + "Position" => { + let OscType::Float(position) = arg.to_owned() else { + continue; + }; + + let min = 0; + let max = playback_state.currently_playing_item().timestamp(); + + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + spotify.seek((min + (max - min) * (position * 100.0) as u64) / 100) + } + _ => { + let Some(playback_state) = spotify.playback_state().send_async().await? else { + return Ok(()); // No media is currently playing + }; + + let mut parameters = HashMap::new(); + + let play = OscType::Bool(playback_state.device().is_active()); + if previous_parameters.get("Play") != Some(&play) { + parameters.insert("Play", play.clone()); + previous_parameters.insert("Play", play); + } + + let shuffle = OscType::Bool(playback_state.shuffle_state()); + if previous_parameters.get("Shuffle") != Some(&shuffle) { + parameters.insert("Shuffle", shuffle.clone()); + previous_parameters.insert("Shuffle", shuffle); + } + + let repeat = OscType::Int(match playback_state.repeat_state() { + RepeatState::Off => 0, + RepeatState::Track => 1, + RepeatState::Context => 2, + }); + if previous_parameters.get("Repeat") != Some(&repeat) { + parameters.insert("Repeat", repeat.clone()); + previous_parameters.insert("Repeat", repeat); + } + + for (param, arg) in parameters { + let packet = OscPacket::Message(OscMessage { + addr: format!("/avatar/parameters/VRCOSC/Media/{param}"), + args: vec![arg], + }); + + let msg_buf = rosc::encoder::encode(&packet)?; + socket.send(&msg_buf)?; + } + + continue; + } + }; + + if let Err(error) = request.send_async().await { + eprintln!("Spotify Control Error: {error}"); + }; + } +} diff --git a/plugin-spotify/lib.rs b/plugin-spotify/src/lib.rs similarity index 51% rename from plugin-spotify/lib.rs rename to plugin-spotify/src/lib.rs index f0002c0..4788ae2 100644 --- a/plugin-spotify/lib.rs +++ b/plugin-spotify/src/lib.rs @@ -1,10 +1,12 @@ #![feature(once_cell_try)] #![feature(try_find)] -use std::{collections::HashMap, net::UdpSocket, sync::OnceLock, time::Duration}; +mod chatbox; +mod control; -use anyhow::{bail, Context, Result}; -use async_ffi::async_ffi; +use std::{net::UdpSocket, sync::OnceLock, time::Duration}; + +use anyhow::{bail, Result}; use derive_config::DeriveTomlConfig; #[cfg(debug_assertions)] use dotenvy_macro::dotenv; @@ -16,20 +18,13 @@ use ferrispot::{ }, SpotifyClientBuilder, }, - model::{ - playback::{PlayingType, RepeatState}, - track::FullTrack, - }, prelude::*, scope::Scope, }; use inquire::{Confirm, Text}; -use rosc::{decoder::MTU, OscMessage, OscPacket, OscType}; use serde::{Deserialize, Serialize}; use spotify_lyrics::{Browser, SpotifyLyrics}; -use terminal_link::Link; use tiny_http::{Header, Response, Server}; -use tokio::runtime::Handle; use url::Url; #[cfg(debug_assertions)] @@ -119,81 +114,6 @@ fn config() -> Result<&'static Config> { }) } -#[no_mangle] -#[async_ffi(?Send)] -#[allow(clippy::unnecessary_wraps)] -#[allow(clippy::needless_pass_by_value)] -async extern "Rust" fn chat( - mut chatbox: String, - mut console: String, - handle: Handle, -) -> Result<(String, String)> { - let _enter = handle.enter(); - let config = config()?; - let spotify = SPOTIFY.get().context("Spotify is Authenticating...")?; - let mut lyrics = LYRICS.get().context("Lyrics is Authenticating...")?.clone(); - - let current_item = spotify - .currently_playing_item() - .send_async() - .await? - .context("None")?; - - let public_item = current_item.public_playing_item().context("None")?; - let PlayingType::Track(track) = public_item.item() else { - bail!("None") - }; - - if config.enable_lyrics { - if let Ok(color_lyrics) = lyrics.get_color_lyrics(track.id().as_str()).await { - let words = color_lyrics - .lyrics - .lines - .iter() - .rev() - .try_find(|line| { - u64::try_from(public_item.progress().as_millis()) - .map(|progress| line.start_time_ms < progress) - }) - .iter() - .flatten() - .map(|line| line.words.clone()) - .collect::>() - .join(" "); - - if !words.is_empty() && words != "♪" { - chatbox = words.clone(); - console = words; - - return Ok((chatbox, console)); - } - }; - } - - replace(&mut chatbox, track); - replace(&mut console, track); - - let href = track.external_urls().spotify.clone().context("None")?; - let link = Link::new(&console, &href); - Ok((chatbox, link.to_string())) -} - -fn replace(message: &mut String, track: &FullTrack) { - let id = &track.id().to_string(); - let song = track.name(); - let artists = track - .artists() - .iter() - .map(CommonArtistInformation::name) - .collect::>() - .join(", "); - - *message = message - .replace("{id}", id) - .replace("{song}", song) - .replace("{artists}", &artists); -} - #[no_mangle] #[allow(clippy::needless_pass_by_value)] #[tokio::main(flavor = "current_thread")] @@ -211,152 +131,14 @@ async extern "Rust" fn load(socket: UdpSocket) -> Result<()> { SPOTIFY.set(spotify.clone()).expect("Failed to set SPOTIFY"); LYRICS.set(lyrics).expect("Failed to set LYRICS"); - if !config.enable_control { - loop { - // Keep the threads alive - STATUS_ACCESS_VIOLATION - tokio::time::sleep(Duration::from_secs(u64::MAX)).await; - } + if config.enable_control { + control::start_loop(socket, spotify).await?; } - let mut muted_volume = None; - let mut buf = [0u8; MTU]; loop { - let size = socket.recv(&mut buf)?; - let (_buf, packet) = rosc::decoder::decode_udp(&buf[..size])?; - let OscPacket::Message(packet) = packet else { - continue; // I don't think VRChat uses bundles - }; - - let addr = packet.addr.replace("/avatar/parameters/VRCOSC/Media/", ""); - let Some(arg) = packet.args.first() else { - continue; // No first argument was supplied - }; - - let Some(playback_state) = spotify.playback_state().send_async().await? else { - continue; // No media is currently playing - }; - - if muted_volume.is_none() { - muted_volume = Some(playback_state.device().volume_percent()); - } - - let request = match addr.as_ref() { - "Play" => { - let OscType::Bool(play) = arg.to_owned() else { - continue; - }; - - if play { - spotify.resume() - } else { - spotify.pause() - } - } - "Next" => spotify.next(), - "Prev" | "Previous" => spotify.previous(), - "Shuffle" => { - let OscType::Bool(shuffle) = arg.to_owned() else { - continue; - }; - - spotify.shuffle(shuffle) - } - // Seeking is not required because position is not used multiple times - // "Seeking" => continue, - "Muted" => { - let OscType::Bool(mute) = arg.to_owned() else { - continue; - }; - - let volume = if mute { - muted_volume = Some(playback_state.device().volume_percent()); - 0 - } else { - muted_volume.expect("Failed to get previous volume") - }; - - spotify.volume(volume) - } - "Repeat" => { - let OscType::Int(repeat) = arg.to_owned() else { - continue; - }; - - let repeat_state = match repeat { - 0 => RepeatState::Off, - 1 => RepeatState::Track, - 2 => RepeatState::Context, - _ => continue, - }; - - spotify.repeat_state(repeat_state) - } - "Volume" => { - let OscType::Float(volume) = arg.to_owned() else { - continue; - }; - - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - spotify.volume((volume * 100.0) as u8) - } - "Position" => { - let OscType::Float(position) = arg.to_owned() else { - continue; - }; - - let min = 0; - let max = playback_state.currently_playing_item().timestamp(); - - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - spotify.seek((min + (max - min) * (position * 100.0) as u64) / 100) - } - _ => { - let _ = try_sync_media_state(&socket, &spotify).await; - continue; - } - }; - - if let Err(error) = request.send_async().await { - eprintln!("Spotify Control Error: {error}"); - }; - } -} - -/// Try to synchronize the media session state to the `VRChat` menu parameters -async fn try_sync_media_state( - socket: &UdpSocket, - spotify: &AsyncAuthorizationCodeUserClient, -) -> Result<()> { - let Some(playback_state) = spotify.playback_state().send_async().await? else { - return Ok(()); // No media is currently playing - }; - - let mut parameters = HashMap::new(); - - let play = playback_state.device().is_active(); - parameters.insert("Play", OscType::Bool(play)); - - let shuffle = playback_state.shuffle_state(); - parameters.insert("Shuffle", OscType::Bool(shuffle)); - - let repeat = match playback_state.repeat_state() { - RepeatState::Off => 0, - RepeatState::Track => 1, - RepeatState::Context => 2, - }; - parameters.insert("Repeat", OscType::Int(repeat)); - - for (param, arg) in parameters { - let packet = OscPacket::Message(OscMessage { - addr: format!("/avatar/parameters/VRCOSC/Media/{param}"), - args: vec![arg], - }); - - let msg_buf = rosc::encoder::encode(&packet)?; - socket.send(&msg_buf)?; + // Keep the threads alive - STATUS_ACCESS_VIOLATION + tokio::time::sleep(Duration::from_secs(u64::MAX)).await; } - - Ok(()) } async fn login_to_spotify(config: &mut Config) -> Result {