Ah I see, it seems to work now. Thank you! What output mode should I use for the SR1 2.0?
“Yes, when you plug in the FUNSR1 to your USB port, Windows should register a new Serial COM device. (In Device Manager, it’s under “Ports (COM & LPT)”.) If this doesn’t show up, you might have a faulty cable. Once the COM port shows up, open Multifunplayer, click Add Output > Serial. Then click the dropdown menu that says “serial port”. It will have COM1 (default Windows port, not what you are looking for), and the assigned COM port for your SR1. After selecting it, click the connect button to communicate with the SR1.”
I found the answer for the people who want to know this.
Now when I try to drop the video’s into the player after everything is connected it doesn’t let me. I ran the program as admin. What could the problem be?
This is a screenshot of what I see:
I think you are a second person to ever have this issue, I dont know what causes this.
Can you try drag dropping the video file directly onto mpv.exe? It should be in Bin\mpv\mpv.exe
Yes if I do it like that it plays the video, but the funscript isn’t loaded with it.
I made sure that both the video and the funscript have the exact same name.
This is what it looks like in the client while playing the video.
MFP needs to start mpv for scripts to work. Opening the video directly via mpv.exe was just a test.
But I don’t know why you cant drag drop files into the mpv window opened by MFP, did you change the arguments list?
Can I see it?
I managed to fix it by deleting all the other versions of MFP. Everything works now, thank you!
Oh btw is it possible to have video’s looped in MPV?
it can be achieved by pressing L but that repeats only the current file,
Sure, the file attached implements pause script, manual mode and edging mode at the moment. Return to base doesn’t seem to register for me maybe Yoooi knows why. You need the latest version 1.32.0+ as this uses the new plugin API.
What it does at the moment
- Everything is mapped to L0 as I have a FUNOSR1 and no other axis
- Edging mode toggles between an edging and normal range
- Pause script bypasses all motion (motion provider and script)
- Manual mode toggels the motion providor on and off
To use this you’d have to map the buttons to the script actions like so:
Keep in mind that currently you have to press the button twice. I’m hoping Yoooi will reduce this to once as it’s an implementation choice but we’ll see.
The html part is integrated with my Electron application that uses Vue 3 and isn’t easily sharable. however you can easily reproduce something that communicates with the plugin using some basic html and javascript and then just host it on your localhost.
For refrence this is how my plugin ui looks like (speed and delay for manual mode aren’t implemented yet).
You can communicate with the plugin like so, for exampe something like this for is-edging
await fetch(“localhost/set-is-edging”, {
method: “POST”,
headers: {
“Content-Type”: “application/json”
},
body: JSON.stringify({ isEdging: true })
});
RemoteControlPlugin.cs.txt (18.4 KB)
Here’s my one, with a proper HTTP (tho I guess I gonna copypaste routes from your code), and a no-working (I think) enable/disable GUI taken from example
I’ve already used it to play a couple of games, it worked somehow.
ServerPlugin.cs.currently-broken.txt (10.0 KB)
ServerPlugin.xaml.txt (3.0 KB)
code
//#r "System.Net.HttpListener"
using System.Net;
using MultiFunPlayer.MediaSource.ViewModels;
using MultiFunPlayer.MediaSource.Views;
using MultiFunPlayer.Script;
using MultiFunPlayer.Script.Repository.ViewModels;
using Windows.ApplicationModel.Calls;
using FileInfo = System.IO.FileInfo;
[DisplayName("ServerPlugin DisplayName")]
public class ServerPlugin : PluginBase
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private Task _task;
private CancellationTokenSource _cancellationSource;
private ConnectionStatus _status;
private string _text;
public ConnectionStatus Status
{
get => _status;
set => SetAndNotify(ref _status, value);
}
public string Text
{
get => _text;
set => SetAndNotify(ref _text, value);
}
protected override void OnInitialize() { }
protected override void OnDispose()
{
_cancellationSource?.Cancel();
_cancellationSource?.Dispose();
_cancellationSource = null;
_task = null;
}
public void OnConnectClick()
{
if (_task == null)
{
_cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
_task = Task.Run(async () => await RunAsync(_cancellationSource.Token));
}
else
{
OnDispose();
}
}
private HttpListener server;
private async Task RunAsync(CancellationToken token)
{
try
{
Status = ConnectionStatus.Connecting;
server = new HttpListener();
server.Start();
server.Prefixes.Add("http://127.0.0.1:5000/");
server.Prefixes.Add("http://localhost:5000/");
server.Prefixes.Add("http://127.0.0.1:5050/");
server.Prefixes.Add("http://localhost:5050/");
await Task.Delay(500);
Status = ConnectionStatus.Connected;
while (!token.IsCancellationRequested)
{
var context = await server.GetContextAsync();
// There is no response so we close the stream
context.Response.StatusCode = 204;
context.Response.OutputStream.Close();
if (context.Request.Url.LocalPath == "/favicon.ico") continue;
ProcessGeneralRequest(context);
}
}
catch (OperationCanceledException) { }
catch (Exception e)
{
Logger.Error(e);
}
finally
{
Status = ConnectionStatus.Disconnecting;
server.Stop();
await Task.Delay(500);
Status = ConnectionStatus.Disconnected;
Text = null;
}
}
private bool isFiller = false;
private async void ProcessGeneralRequest(HttpListenerContext context)
{
context.Response.OutputStream.Close();
Logger.Info($"url: {context.Request.Url.AbsoluteUri}");
Text = $"url: {context.Request.Url.AbsoluteUri.Substring(22)}";
// var filename = context.Request.QueryString["filename"];
// var chapter = context.Request.QueryString["chapter"];
// var speed = Double.Parse(context.Request.QueryString["speed"] ?? "1.0");
// var A = Double.Parse(context.Request.QueryString["A"] ?? "-1.0");
// var B = Double.Parse(context.Request.QueryString["B"] ?? "-1.0");
// Text += $"\nFilename: {filename}\nChapter: {chapter}\nSpeed: {speed}\nA: {A}\nB: {B}";
// // sleep because Internal::Playlist::PlayByName resets AB
// await Task.Delay(100);
}
private async void ProcessEdiRequest(HttpListenerContext context)
{
context.Response.OutputStream.Close();
Logger.Info($"url: {context.Request.Url.AbsoluteUri}");
if (context.Request.Url.LocalPath != "/game/gallery")
{
return;
}
Text = $"url: {context.Request.Url.AbsoluteUri.Substring(22)}\nLocalPath: {context.Request.Url.LocalPath}";
var code = context.Request.QueryString["code"];
Text += $"\nCode: {code} ({code.Length})";
if (code == "")
{
code = "filler1";
// InvokeAction("Media::PlayPause::Set", false, true);
// InvokeAction("Media::Loop::Set", 10, 11, true);
// return;
}
var csv = System.IO.File.ReadAllLines("./ClassLibrary1/funscript/marielle/Definitions.csv")
.Select(x => x.Split(',')).ToList();
// csv.ForEach(x => Logger.Info($"{x[0]} | {x[1]} | {x[2]} | {x[3]}"));
var line = csv.FirstOrDefault(x => x[0] == code, null);
if (line == null)
{
Logger.Info($"Not found {code}");
return;
}
Logger.Info($"Found {line[0]} | {line[1]} | {line[2]} | {line[3]}");
var file = line[1];
var start = double.Parse(line[2]) * 0.001;
var end = double.Parse(line[3]) * 0.001;
InvokeAction("Internal::Playlist::PlayByName", $"{file}.funscript", true);
InvokeAction("Media::PlayPause::Set", true, true);
InvokeAction("Media::Position::Time::Set", start, true);
// sleep because Internal::Playlist::PlayByName resets AB
await Task.Delay(100);
InvokeAction("Media::Loop::Set", start, end, true);
InvokeAction("Media::Position::Time::Set", start + 0.100, true);
Logger.Info($"Playing {file} from {start} to {end}");
// InvokeAction("Media::Loop::Set", start, end, true);
// InvokeAction("Media::Loop::Start::Set", start, true);
// InvokeAction("Media::Loop::End::Set", end, true);
}
private void ProcessSetariaRequest(HttpListenerContext context)
{
Text = $"url: {context.Request.Url.AbsoluteUri.Substring(22)}\n{String.Join(",\n",
context.Request.QueryString.AllKeys.Select(x => $"{x}={context.Request.QueryString[x]}")
)}";
context.Response.OutputStream.Close();
}
private void ProcessRequest(HttpListenerContext context)
{
if (context.Request.Url.LocalPath == "/favicon.ico")
{
context.Response.OutputStream.Close();
return;
}
Text = $"url: {context.Request.Url.AbsoluteUri}";
// process
var result = new Dictionary<DeviceAxis, IScriptResource>();
var scriptName = context.Request.Url.LocalPath.Replace("/Edi/Play/", "");
var scriptName1 = scriptName.Split("_")[0];
// DeviceAxisUtils.GetBaseNameWithExtension(context.Request.Url.LocalPath);
var fi = new FileInfo($"./ClassLibrary1/funscript/{scriptName}.funscript");
var fr = FunscriptReader.Default;
var readerResult = fr.FromFileInfo(fi);
Logger.Info($"Found IsSuccess:{readerResult.IsSuccess} multiaxis:{readerResult.IsMultiAxis} count:{readerResult.Resources?.Count}");
Text = $"{Text}\n{fi.Exists} {fi}\n{scriptName} / {scriptName1}";
if (readerResult.IsSuccess)
{
result.Merge(readerResult.Resources);
PublishMessage(new ChangeScriptMessage(result));
PublishMessage(new MediaPathChangedMessage($"./ClassLibrary1/funscript/{scriptName}.funscript"));
// Media::Chapter::SeekToByName
InvokeAction("Internal::Playlist::PlayByName", $"{scriptName}.funscript");
InvokeAction("Media::Chapter::SeekToByName", scriptName1);
InvokeAction("Media::Loop::Set::FromCurrentChapter");
InvokeAction("Media::PlayPause::Set", true);
// InvokeAction()
}
}
private void Play(
string filename,
string chapter = null,
double speed = 1.0
)
{
var fi = new FileInfo($"./ClassLibrary1/funscript/{filename}.funscript");
var readerResult = FunscriptReader.Default.FromFileInfo(fi);
Logger.Info($"Found IsSuccess:{readerResult.IsSuccess} multiaxis:{readerResult.IsMultiAxis} count:{readerResult.Resources?.Count}");
if (!readerResult.IsSuccess) return;
var res = readerResult.Resource;
var chap = res.Chapters.FirstOrDefault(x => x.Name == chapter);
if (chap == null) return;
readerResult.Resources.Merge(DeviceAxis.All.Except(readerResult.Resources.Keys).ToDictionary(a => a, _ => default(IScriptResource)));
PublishMessage(new ChangeScriptMessage(readerResult.Resources));
InvokeAction("Media::Chapter::SeekToByName", chapter, invokeDirectly: true);
InvokeAction("Media::Loop::Set::FromCurrentChapter", invokeDirectly: true);
InvokeAction("Media::PlayPause::Set", invokeDirectly: true);
}
// public void LoadFunscript()
// {
// var result = new Dictionary<DeviceAxis, IScriptResource>();
// if (LoadAdditionalScripts)
// {
// var scriptName = DeviceAxisUtils.GetBaseNameWithExtension(item.Name);
// result.Merge(localRepository.SearchForScripts(scriptName, Path.GetDirectoryName(item.FullName), DeviceAxis.All));
// }
// else
// {
// var readerResult = FunscriptReader.Default.FromFileInfo(item.AsFileInfo());
// if (readerResult.IsSuccess)
// {
// if (readerResult.IsMultiAxis)
// result.Merge(readerResult.Resources);
// else
// result.Merge(DeviceAxisUtils.FindAxesMatchingName(item.Name, true).ToDictionary(a => a, _ => readerResult.Resource));
// }
// }
// if (result.Count == 0)
// {
// ResetState();
// return;
// }
// SetDuration(result.Values.Max(s => s.Keyframes[^1].Position));
// SetPosition(0, forceSeek: true);
// result.Merge(DeviceAxis.All.Except(result.Keys).ToDictionary(a => a, _ => default(IScriptResource)));
// PublishMessage(new ChangeScriptMessage(result));
// }
}
Yeah importing using System.Net; didn’t work for me in the previous version of mfp for some reason so I worked around it. However, everything is working fine and dandy on my side so no need for any code but thanks
You need to use //#r "System.Net.HttpListener"
for not-included deps (that was somewhere i changelogs I guess?)
Tho I’m not sure if it works in the build with System dlls
I’ve posted code mostly to post code so it doesn’t just lays on my PC and someone can read and comment on it, and your code post finally made me do this
The plugin base class exposes CancellationToken
property which gets cancelled before the call to OnDispose
. You should use it (or your own) on all your tasks/threads so that they are properly cleaned up when the plugin is unloaded.
Your Action queue
task will never finish, meaning the plugin assembly will always be loaded, so on each recompile you will have a memory leak.
Also I dont think you need the whole action queue, if you want thread safety just add a lock. But if it works it works.
Yes, add --loop-file
to arguments list in MFP. You can find all options here: mpv.io
Its not an implementation choice, thats how it behaves in DeoVR, you saw the logs, there are no events for when you press/release the button, only events for when a feature is enabled/disabled.
You can add two shortcuts one for press (enable feature), one for release (disable feature).
Ahh, I’m mostly a javascript dev, C# is like my fifth language, thanks for the feedback!
Actually no, maybe this was a bit of miscommunication. You get a log line for every button press from DeoVR (I’ve confirmed this). However the action is only triggered once every two clicks. So when it’s disabled initially in DeoVR and you then start the plugin it would be:
first click enabled (no action triggered but a log line is shown)
click two disabled (action is triggered and a log line is shown).
So about the action queue. I had to add this since doing it directly like below doesn’t work:
RegisterAction(“RemoteControlPlugin::ReturnToBase”, () =>
{
InvokeAction<DeviceAxis, double>(“Serial/0::Axis::Range::Minimum::Set”, DeviceAxis.Parse(“L0”), min);
});
The action queue makes sure it is running in the same thread and then it does work. Maybe there is a better way but that’s why it’s there.