MultiFunPlayer v1.32.1 - Multi axis funscript player - Now with SLR script streaming

Hey, I keep getting this error when I try to connect my SR 2.0 to MPV.
Error when connecting to MPV:

System.TimeoutException: The operation has timed out.
at System.IO.Pipes.NamedPipeClientStream.ConnectInternal(Int32 timeout, CancellationToken cancellationToken, Int32 startTime)
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
— End of stack trace from previous location —
at System.Threading.ExecutionContext.RunFromThreadPoolDispatchLoop(Thread threadPoolThread, ExecutionContext executionContext, ContextCallback callback, Object state)
at System.Threading.Tasks.Task.ExecuteWithThreadLocal(Task& currentTaskSlot, Thread threadPoolThread)
— End of stack trace from previous location —
at MultiFunPlayer.MediaSource.ViewModels.MpvMediaSource.RunAsync(ConnectionType connectionType, CancellationToken token)
at MultiFunPlayer.Common.ExceptionExtensions.Throw(Exception e)
at MultiFunPlayer.MediaSource.ViewModels.MpvMediaSource.RunAsync(ConnectionType connectionType, CancellationToken token)

I’ve tried everything from running is at administrator to changing the usb port to restarting my pc multiple times. I really don’t know what to do else to get it to work. My device does connect to my pc since it gives a connect sound, but for some reason MFP won’t let me connect it to MPV. ;_;

You probably have auto-start disabled for some reason, it should be the third button from the left under mpv.

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:
https://imgur.com/a/BscdSCr

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!

1 Like

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:
image

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).
image

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)

1 Like

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 :slight_smile:

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

1 Like

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!