diff --git a/Cargo.lock b/Cargo.lock index 653df64..6cc61b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,12 +496,22 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_test" +version = "1.0.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de9e52f2f83e2608a121618b6d3885b514613aac702306232c4f035ff60fdb56" +dependencies = [ + "serde", +] + [[package]] name = "stremio-shell-ng" version = "0.1.0" dependencies = [ "bitflags", "embed-resource", + "heck", "mpv", "native-windows-derive", "native-windows-gui", @@ -509,7 +519,10 @@ dependencies = [ "open", "serde", "serde_json", + "serde_test", "structopt", + "strum", + "strum_macros", "urlencoding", "webview2", "webview2-sys", @@ -556,6 +569,24 @@ dependencies = [ "syn", ] +[[package]] +name = "strum" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf86bbcfd1fa9670b7a129f64fc0c9fcbbfe4f1bc4210e9e98fe71ffc12cde2" + +[[package]] +name = "strum_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06aaeeee809dbc59eb4556183dd927df67db1540de5be8d3ec0b6636358a5ec" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "1.0.73" diff --git a/Cargo.toml b/Cargo.toml index 1ab065f..e82b11d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,5 +19,10 @@ structopt = "0.3" open = "1" urlencoding = "2.1.0" bitflags = "1.2.1" +strum = "0.21" +strum_macros = "0.21" +heck = "0.3" [build-dependencies] embed-resource = "1.3" +[dev-dependencies] +serde_test = "1.0.*" \ No newline at end of file diff --git a/src/stremio_app/stremio_player/communication.rs b/src/stremio_app/stremio_player/communication.rs index f6397dc..565daf7 100644 --- a/src/stremio_app/stremio_player/communication.rs +++ b/src/stremio_app/stremio_player/communication.rs @@ -1,8 +1,14 @@ +use core::convert::TryFrom; +use heck::KebabCase; use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; +use strum_macros::EnumString; +// Responses const JSON_RESPONSES: [&str; 3] = ["track-list", "video-params", "metadata"]; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct PlayerProprChange { name: String, data: serde_json::Value, @@ -35,7 +41,7 @@ impl PlayerProprChange { } } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct PlayerEnded { reason: String, } @@ -73,3 +79,180 @@ impl PlayerResponse<'_> { serde_json::to_value(self).ok() } } + +// Player incoming messages from the web UI +/* +Message general case - ["function-name", ["arguments", ...]] +The function could be either mpv-observe-prop, mpv-set-prop or mpv-command. + +["mpv-observe-prop", "prop-name"] +["mpv-set-prop", ["prop-name", prop-val]] +["mpv-command", ["command-name"<, "arguments">]] + +All the function and property names are in kebab-case. + +MPV requires type for any prop-name when observing or setting it's value. +The type for setting is not always the same as the type for observing the prop. + +"mpv-observe-prop" function is the only one that accepts single string +instead of array of arguments + +"mpv-command" function always takes an array even if the command doesn't +have any arguments. For example this are the commands we support: + +["mpv-command", ["loadfile", "file name"]] +["mpv-command", ["stop"]] +*/ +macro_rules! stringable { + ($t:ident) => { + impl fmt::Display for $t { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", format!("{:?}", self).to_kebab_case()) + } + } + impl From<$t> for String { + fn from(s: $t) -> Self { + s.to_string() + } + } + impl TryFrom for $t { + type Error = strum::ParseError; + fn try_from(s: String) -> Result { + Self::from_str(s.as_str()) + } + } + }; +} + +#[allow(clippy::enum_variant_names)] +#[derive(Serialize, Deserialize, Debug, Clone, EnumString, PartialEq)] +#[serde(try_from = "String", into = "String")] +#[strum(serialize_all = "kebab-case")] +pub enum InMsgFn { + MpvSetProp, + MpvCommand, + MpvObserveProp, +} +stringable!(InMsgFn); + +// Bool +#[derive(Serialize, Deserialize, Debug, Clone, EnumString, PartialEq)] +#[serde(try_from = "String", into = "String")] +#[strum(serialize_all = "kebab-case")] +pub enum BoolProp { + Pause, + PausedForCache, + Seeking, + EofReached, +} +stringable!(BoolProp); +// Int +#[derive(Serialize, Deserialize, Debug, Clone, EnumString, PartialEq)] +#[serde(try_from = "String", into = "String")] +#[strum(serialize_all = "kebab-case")] +pub enum IntProp { + Aid, + Vid, + Sid, +} +stringable!(IntProp); +// Fp +#[derive(Serialize, Deserialize, Debug, Clone, EnumString, PartialEq)] +#[serde(try_from = "String", into = "String")] +#[strum(serialize_all = "kebab-case")] +pub enum FpProp { + TimePos, + Volume, + Duration, + SubScale, + CacheBufferingState, + SubPos, + Speed, +} +stringable!(FpProp); +// Str +#[derive(Serialize, Deserialize, Debug, Clone, EnumString, PartialEq)] +#[serde(try_from = "String", into = "String")] +#[strum(serialize_all = "kebab-case")] +pub enum StrProp { + FfmpegVersion, + Hwdec, + InputDefaltBindings, + InputVoKeyboard, + Metadata, + MpvVersion, + Osc, + Path, + SubAssOverride, + SubBackColor, + SubBorderColor, + SubColor, + TrackList, + VideoParams, + // Vo, +} +stringable!(StrProp); + +// Any +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum PropKey { + Bool(BoolProp), + Int(IntProp), + Fp(FpProp), + Str(StrProp), +} +impl fmt::Display for PropKey { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Bool(v) => write!(f, "{}", v), + Self::Int(v) => write!(f, "{}", v), + Self::Fp(v) => write!(f, "{}", v), + Self::Str(v) => write!(f, "{}", v), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum PropVal { + Bool(bool), + Str(String), + Num(f64), +} + +#[derive(Serialize, Deserialize, Debug, Clone, EnumString, PartialEq)] +#[serde(try_from = "String", into = "String")] +#[strum(serialize_all = "kebab-case")] +#[serde(untagged)] +pub enum MpvCmd { + Loadfile, + Stop, +} +stringable!(MpvCmd); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum CmdVal { + Single((MpvCmd,)), + Double(MpvCmd, String), +} +impl From for Vec { + fn from(cmd: CmdVal) -> Vec { + match cmd { + CmdVal::Single(cmd) => vec![cmd.0.to_string()], + CmdVal::Double(cmd, arg) => vec![cmd.to_string(), arg], + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(untagged)] +pub enum InMsgArgs { + StProp(PropKey, PropVal), + Cmd(CmdVal), + ObProp(PropKey), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InMsg(pub InMsgFn, pub InMsgArgs); diff --git a/src/stremio_app/stremio_player/communication_tests.rs b/src/stremio_app/stremio_player/communication_tests.rs new file mode 100644 index 0000000..855e939 --- /dev/null +++ b/src/stremio_app/stremio_player/communication_tests.rs @@ -0,0 +1,166 @@ +use crate::stremio_app::stremio_player::communication::{ + BoolProp, CmdVal, InMsg, InMsgArgs, InMsgFn, MpvCmd, PlayerEnded, PlayerProprChange, PropKey, + PropVal, +}; + +use serde_test::{assert_tokens, Token}; + +#[test] +fn propr_change_tokens() { + let prop = "test-prop"; + let tokens: [Token; 6] = [ + Token::Struct { + name: "PlayerProprChange", + len: 2, + }, + Token::Str("name"), + Token::None, + Token::Str("data"), + Token::None, + Token::StructEnd, + ]; + + fn tokens_by_type(tokens: &[Token; 6], name: &'static str, val: mpv::Format, token: Token) { + let mut typed_tokens = tokens.clone(); + typed_tokens[2] = Token::Str(name); + typed_tokens[4] = token; + assert_tokens( + &PlayerProprChange::from_name_value(name.to_string(), val), + &typed_tokens, + ); + } + tokens_by_type(&tokens, prop, mpv::Format::Flag(true), Token::Bool(true)); + tokens_by_type(&tokens, prop, mpv::Format::Int(1), Token::F64(1.0)); + tokens_by_type(&tokens, prop, mpv::Format::Double(1.0), Token::F64(1.0)); + tokens_by_type(&tokens, prop, mpv::Format::OsdStr("ok"), Token::Str("ok")); + tokens_by_type(&tokens, prop, mpv::Format::Str("ok"), Token::Str("ok")); + + // JSON response + tokens_by_type( + &tokens, + "track-list", + mpv::Format::Str(r#""ok""#), + Token::Str("ok"), + ); + tokens_by_type( + &tokens, + "video-params", + mpv::Format::Str(r#""ok""#), + Token::Str("ok"), + ); + tokens_by_type( + &tokens, + "metadata", + mpv::Format::Str(r#""ok""#), + Token::Str("ok"), + ); +} + +#[test] +fn ended_tokens() { + let tokens: [Token; 4] = [ + Token::Struct { + name: "PlayerEnded", + len: 1, + }, + Token::Str("reason"), + Token::None, + Token::StructEnd, + ]; + let mut typed_tokens = tokens.clone(); + typed_tokens[2] = Token::Str("error"); + assert_tokens( + &PlayerEnded::from_end_reason(mpv::EndFileReason::MPV_END_FILE_REASON_ERROR), + &typed_tokens, + ); + let mut typed_tokens = tokens.clone(); + typed_tokens[2] = Token::Str("quit"); + assert_tokens( + &PlayerEnded::from_end_reason(mpv::EndFileReason::MPV_END_FILE_REASON_QUIT), + &typed_tokens, + ); +} + +#[test] +fn ob_propr_tokens() { + assert_tokens( + &InMsg( + InMsgFn::MpvObserveProp, + InMsgArgs::ObProp(PropKey::Bool(BoolProp::Pause)), + ), + &[ + Token::TupleStruct { + name: "InMsg", + len: 2, + }, + Token::Str("mpv-observe-prop"), + Token::Str("pause"), + Token::TupleStructEnd, + ], + ); +} + +#[test] +fn set_propr_tokens() { + assert_tokens( + &InMsg( + InMsgFn::MpvSetProp, + InMsgArgs::StProp(PropKey::Bool(BoolProp::Pause), PropVal::Bool(true)), + ), + &[ + Token::TupleStruct { + name: "InMsg", + len: 2, + }, + Token::Str("mpv-set-prop"), + Token::Tuple { len: 2 }, + Token::Str("pause"), + Token::Bool(true), + Token::TupleEnd, + Token::TupleStructEnd, + ], + ); +} + +#[test] +fn command_stop_tokens() { + assert_tokens( + &InMsg( + InMsgFn::MpvCommand, + InMsgArgs::Cmd(CmdVal::Single((MpvCmd::Stop,))), + ), + &[ + Token::TupleStruct { + name: "InMsg", + len: 2, + }, + Token::Str("mpv-command"), + Token::Tuple { len: 1 }, + Token::Str("stop"), + Token::TupleEnd, + Token::TupleStructEnd, + ], + ); +} + +#[test] +fn command_loadfile_tokens() { + assert_tokens( + &InMsg( + InMsgFn::MpvCommand, + InMsgArgs::Cmd(CmdVal::Double(MpvCmd::Loadfile, "some_file".to_string())), + ), + &[ + Token::TupleStruct { + name: "InMsg", + len: 2, + }, + Token::Str("mpv-command"), + Token::Tuple { len: 2 }, + Token::Str("loadfile"), + Token::Str("some_file"), + Token::TupleEnd, + Token::TupleStructEnd, + ], + ); +} diff --git a/src/stremio_app/stremio_player/mod.rs b/src/stremio_app/stremio_player/mod.rs index 64f56d9..14093fd 100644 --- a/src/stremio_app/stremio_player/mod.rs +++ b/src/stremio_app/stremio_player/mod.rs @@ -1,4 +1,9 @@ pub mod player; pub use player::Player; pub mod communication; -pub use communication::{PlayerEnded, PlayerError, PlayerEvent, PlayerProprChange, PlayerResponse}; +pub use communication::{ + BoolProp, CmdVal, FpProp, InMsg, InMsgArgs, InMsgFn, IntProp, MpvCmd, PlayerEnded, PlayerError, + PlayerEvent, PlayerProprChange, PlayerResponse, PropKey, PropVal, StrProp, +}; +#[cfg(test)] +mod communication_tests; diff --git a/src/stremio_app/stremio_player/player.rs b/src/stremio_app/stremio_player/player.rs index bff40d6..d9c7308 100644 --- a/src/stremio_app/stremio_player/player.rs +++ b/src/stremio_app/stremio_player/player.rs @@ -2,19 +2,18 @@ use crate::stremio_app::ipc; use crate::stremio_app::RPCResponse; use native_windows_gui::{self as nwg, PartialUi}; use std::cell::RefCell; -use std::collections::VecDeque; use std::sync::mpsc; use std::sync::{Arc, Mutex}; use std::thread; -use crate::stremio_app::stremio_player::communication::{ - PlayerEnded, PlayerEvent, PlayerProprChange, PlayerResponse, +use crate::stremio_app::stremio_player::{ + InMsg, InMsgArgs, InMsgFn, PlayerEnded, PlayerEvent, PlayerProprChange, PlayerResponse, + PropKey, PropVal, }; #[derive(Default)] pub struct Player { pub channel: ipc::Channel, - message_queue: Arc>>, } impl PartialUi for Player { @@ -30,7 +29,6 @@ impl PartialUi for Player { .into() .hwnd() .expect("Cannot obtain window handle") as i64; - let message = data.message_queue.clone(); thread::spawn(move || { let mut mpv_builder = mpv::MpvHandlerBuilder::new().expect("Error while creating MPV builder"); @@ -50,29 +48,23 @@ impl PartialUi for Player { mpv_builder .try_hardware_decoding() .expect("failed setting hwdec"); + mpv_builder + .set_option("title", "Stremio") + .expect("failed setting title"); mpv_builder .set_option("terminal", "yes") .expect("failed setting terminal"); mpv_builder - .set_option("msg-level", "all=v") + .set_option("msg-level", "all=no,cplayer=debug") .expect("failed setting msg-level"); mpv_builder .set_option("quiet", "yes") .expect("failed setting msg-level"); let mut mpv = mpv_builder.build().expect("Cannot build MPV"); - let thread_messages = Arc::clone(&message); - - thread::spawn(move || loop { - if let Ok(msg) = rx.recv() { - let mut messages = thread_messages.lock().unwrap(); - messages.push_back(msg); - } - }); - 'main: loop { // wait up to X seconds for an event. - while let Some(event) = mpv.wait_event(0.03) { + while let Some(event) = mpv.wait_event(0.0) { // even if you don't do anything with the events, it is still necessary to empty // the event loop @@ -105,90 +97,49 @@ impl PartialUi for Player { } // event processing thread::sleep(std::time::Duration::from_millis(30)); - let mut in_message = message.lock().unwrap(); - for msg in in_message.drain(..) { - let (command, data): (String, serde_json::Value) = - serde_json::from_str(msg.as_str()).unwrap(); - match command.as_str() { - "mpv-observe-prop" => { - let property = data.as_str().unwrap_or_default(); - match property { - "pause" | "paused-for-cache" | "seeking" | "eof-reached" => { - mpv.observe_property::(property, 0).ok(); - } - "aid" | "vid" | "sid" => { - mpv.observe_property::(property, 0).ok(); - } - "time-pos" - | "volume" - | "duration" - | "sub-scale" - | "cache-buffering-state" - | "sub-pos" => { - mpv.observe_property::(property, 0).ok(); - } - "path" | "mpv-version" | "ffmpeg-version" | "track-list" - | "video-params" | "metadata" => { - mpv.observe_property::<&str>(property, 0).ok(); - } - other => { - eprintln!("mpv-observe-prop: not implemented for `{}`", other); - } - }; + for msg in rx.try_iter() { + match serde_json::from_str::(msg.as_str()) { + Ok(InMsg( + InMsgFn::MpvObserveProp, + InMsgArgs::ObProp(PropKey::Bool(prop)), + )) => mpv.observe_property::(prop.to_string().as_str(), 0), + Ok(InMsg( + InMsgFn::MpvObserveProp, + InMsgArgs::ObProp(PropKey::Int(prop)), + )) => mpv.observe_property::(prop.to_string().as_str(), 0), + Ok(InMsg( + InMsgFn::MpvObserveProp, + InMsgArgs::ObProp(PropKey::Fp(prop)), + )) => mpv.observe_property::(prop.to_string().as_str(), 0), + Ok(InMsg( + InMsgFn::MpvObserveProp, + InMsgArgs::ObProp(PropKey::Str(prop)), + )) => mpv.observe_property::<&str>(prop.to_string().as_str(), 0), + Ok(InMsg( + InMsgFn::MpvSetProp, + InMsgArgs::StProp(prop, PropVal::Bool(val)), + )) => mpv.set_property(prop.to_string().as_str(), val), + Ok(InMsg( + InMsgFn::MpvSetProp, + InMsgArgs::StProp(prop, PropVal::Num(val)), + )) => mpv.set_property(prop.to_string().as_str(), val), + Ok(InMsg( + InMsgFn::MpvSetProp, + InMsgArgs::StProp(prop, PropVal::Str(val)), + )) => mpv.set_property(prop.to_string().as_str(), val.as_str()), + Ok(InMsg(InMsgFn::MpvCommand, InMsgArgs::Cmd(cmd))) => { + let cmd: Vec = cmd.into(); + mpv.command(&cmd.iter().map(|s| s.as_str()).collect::>()) } - "mpv-set-prop" => { - match serde_json::from_value::>(data.clone()) { - Ok(prop_vector) if prop_vector.len() == 2 => { - let prop = - prop_vector[0].as_str().expect("Property is not a string"); - let val = prop_vector[1].clone(); - // If we change vo MPV panics - if prop != "vo" { - match val { - serde_json::Value::Bool(v) => { - mpv.set_property(prop, v).ok(); - } - serde_json::Value::Number(v) => { - mpv.set_property(prop, v.as_f64().unwrap()).ok(); - } - serde_json::Value::String(v) => { - mpv.set_property(prop, v.as_str()).ok(); - } - val => eprintln!( - "mpv-set-prop unsupported value {:?} for: {}", - val, prop - ), - }; - }; - } - Ok(prop_vector) => { - eprintln!("mpv-set-prop not implemented for: {:?}", prop_vector) - } - Err(e) => { - eprintln!("mpv-set-prop Error: {:?} for data {}", e, data) - } - }; + _ => { + eprintln!("MPV unsupported message {}", msg); + Ok(()) } - "mpv-command" => { - match serde_json::from_value::>(data.clone()) { - Ok(data) if data.len() > 0 => { - let data: Vec<_> = data.iter().map(|s| s.as_str()).collect(); - if data[0] != "run" { - mpv.command(&data).ok(); - } - } - Ok(_) => {} - Err(e) => { - eprintln!("mpv-command Error: {:?} for data {}", e, data) - } - } - } - _ => {} - }; + } + .ok(); } // incoming message drain loop } // main loop - }); - + }); // builder thread Ok(()) } }