diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..abab6d5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "examples/RotaryEncoder/PropertyInspector/libs"] + path = examples/RotaryEncoder/PropertyInspector/libs + url = https://github.com/elgatosf/streamdeck-javascript-sdk.git diff --git a/SharpDeck.sln b/SharpDeck.sln index 8c1a94b..3e9bb9d 100644 --- a/SharpDeck.sln +++ b/SharpDeck.sln @@ -44,6 +44,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DynamicProfiles", "examples EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{FB8E473E-E823-47AA-B854-3490E26DF126}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DialCounter", "examples\RotaryEncoder\DialCounter.csproj", "{D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -70,6 +72,10 @@ Global {C878D62A-22AD-4359-8A3F-17508423E46F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C878D62A-22AD-4359-8A3F-17508423E46F}.Release|Any CPU.ActiveCfg = Release|Any CPU {C878D62A-22AD-4359-8A3F-17508423E46F}.Release|Any CPU.Build.0 = Release|Any CPU + {D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -83,6 +89,7 @@ Global {CD620C06-FD35-4A9E-936D-6B00C928ACB1} = {33FA25F3-1583-458E-B86C-80EE90C6EE38} {7C66B2C5-F48E-4FF3-BE63-226F793163CC} = {33FA25F3-1583-458E-B86C-80EE90C6EE38} {C878D62A-22AD-4359-8A3F-17508423E46F} = {91AE8732-D0CC-4618-968A-02EFDE46E4EF} + {D8B1B1BE-0C7B-4DD5-9381-17B79AFCF211} = {91AE8732-D0CC-4618-968A-02EFDE46E4EF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8CE7DE09-893D-4603-807D-674413F38D73} diff --git a/examples/README.md b/examples/README.md index 5a4f7a9..39928e3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,7 @@ * [Counter](/examples/Counter) - Single action counter plugin. * [Shared Counter](/examples/SharedCounter) - Multiple action plugin with a shared counter and reset. +* [Dial Counter](/examples/DialCounter) - Single action counter plugin using SD+ dial. ## How it works diff --git a/examples/RotaryEncoder/Actions/CounterAction.cs b/examples/RotaryEncoder/Actions/CounterAction.cs new file mode 100644 index 0000000..c643ea5 --- /dev/null +++ b/examples/RotaryEncoder/Actions/CounterAction.cs @@ -0,0 +1,113 @@ +namespace DialCounter.Actions; + +using Newtonsoft.Json.Linq; +using SharpDeck; +using SharpDeck.Events.Received; +using SharpDeck.Layouts; +using System.Threading.Tasks; + +/// <summary> +/// The dial counter action; displays the count in A1 layout which +/// - increases/decreases on rotating the dial +/// - reset on pressing the dial +/// - increases on pressing the LED screen +/// - reset on holding the LED screen +/// </summary> +[StreamDeckAction("com.geekyeggo.dialcounter.counter")] +public class CounterAction : StreamDeckAction<CounterSettings> +{ + /// <summary> + /// Occurs when <see cref="IStreamDeckConnection.WillAppear" /> is received for this instance. + /// </summary> + /// <param name="args">The <see cref="ActionEventArgs{T}" /> instance containing the event data.</param> + /// <returns>The task of handling the event.</returns> + protected override Task OnWillAppear(ActionEventArgs<AppearancePayload> args) + { + // get the current, and set the layout + var settings = args.Payload.GetSettings<CounterSettings>(); + return this.UpdateCountAsync(settings.Count); + } + + protected override async Task OnSendToPlugin(ActionEventArgs<JObject> args) + { + switch (args.Payload["sdpi_collection"]?["key"]?.ToString()) + { + case "tickmultiplier": + if (int.TryParse(args.Payload["sdpi_collection"]?["value"]?.ToString(), out var value)) + { + // modify the multiplier + var settings = await this.GetSettingsAsync<CounterSettings>(); + settings.TickMultiplier = value; + + // save the settings + await this.SetSettingsAsync(settings); + } + break; + } + + } + + /// <summary> + /// Occurs when <see cref="IStreamDeckConnection.DialRotate" /> is received for this instance. + /// </summary> + /// <param name="args">The <see cref="ActionEventArgs{T}" /> instance containing the event data.</param> + /// <returns>The task of handling the event.</returns> + protected override async Task OnDialRotate(ActionEventArgs<DialRotatePayload> args) + { + // modify the count + var settings = args.Payload.GetSettings<CounterSettings>(); + settings.Count += args.Payload.Ticks * settings.TickMultiplier; + + // save the settings, and set the layout + await this.SetSettingsAsync(settings); + await this.UpdateCountAsync(settings.Count); + } + + /// <summary> + /// Occurs when <see cref="IStreamDeckConnection.DialPress" /> is received for this instance. + /// </summary> + /// <param name="args">The <see cref="ActionEventArgs{T}" /> instance containing the event data.</param> + /// <returns>The task of handling the event.</returns> + protected override async Task OnDialPress(ActionEventArgs<DialPayload> args) + { + // reset the count + var settings = args.Payload.GetSettings<CounterSettings>(); + settings.Count = 0; + + // save the settings, and set the layout + await this.SetSettingsAsync(settings); + await this.UpdateCountAsync(settings.Count); + } + + /// <summary> + /// Occurs when <see cref="IStreamDeckConnection.TouchTap" /> is received for this instance. + /// </summary> + /// <param name="args">The <see cref="ActionEventArgs{T}" /> instance containing the event data.</param> + /// <returns>The task of handling the event.</returns> + protected override async Task OnTouchTap(ActionEventArgs<TouchTapPayload> args) + { + var settings = args.Payload.GetSettings<CounterSettings>(); + if (args.Payload.Hold) + { + // reset the count + settings.Count = 0; + } + else + { + // increase the count + settings.Count += 1; + } + + // save the settings, and set the layout + await this.SetSettingsAsync(settings); + await this.UpdateCountAsync(settings.Count); + } + + private Task UpdateCountAsync(int count) + { + return this.SetFeedbackAsync(new LayoutA1() + { + Value = count.ToString() + }); + } +} diff --git a/examples/RotaryEncoder/Actions/CounterSettings.cs b/examples/RotaryEncoder/Actions/CounterSettings.cs new file mode 100644 index 0000000..f77b1d0 --- /dev/null +++ b/examples/RotaryEncoder/Actions/CounterSettings.cs @@ -0,0 +1,17 @@ +namespace DialCounter.Actions; + +/// <summary> +/// The <see cref="CounterAction"/> settings. +/// </summary> +public class CounterSettings +{ + /// <summary> + /// Gets or sets the count. + /// </summary> + public int Count { get; set; } = 0; + + /// <summary> + /// Gets or sets the tick multiplier + /// </summary> + public int TickMultiplier { get; set; } = 1; +} diff --git a/examples/RotaryEncoder/DialCounter.csproj b/examples/RotaryEncoder/DialCounter.csproj new file mode 100644 index 0000000..43bdc3b --- /dev/null +++ b/examples/RotaryEncoder/DialCounter.csproj @@ -0,0 +1,35 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> + <OutputPath>$(APPDATA)\Elgato\StreamDeck\Plugins\com.geekyeggo.dialcounter.sdPlugin\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\src\SharpDeck\SharpDeck.csproj" /> + </ItemGroup> + + <ItemGroup> + <None Update="Images\**\*.*"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <None Update="PropertyInspector\**\*.*"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <None Update="manifest.json"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + </ItemGroup> + + <Target Name="PreBuild" BeforeTargets="PreBuildEvent" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> + <Exec Command="taskkill -f -t -im StreamDeck.exe -fi "status eq running"" /> + </Target> + +</Project> diff --git a/examples/RotaryEncoder/Images/Counter/Icon.png b/examples/RotaryEncoder/Images/Counter/Icon.png new file mode 100644 index 0000000..c77662e Binary files /dev/null and b/examples/RotaryEncoder/Images/Counter/Icon.png differ diff --git a/examples/RotaryEncoder/Images/Counter/Icon@2x.png b/examples/RotaryEncoder/Images/Counter/Icon@2x.png new file mode 100644 index 0000000..a819c63 Binary files /dev/null and b/examples/RotaryEncoder/Images/Counter/Icon@2x.png differ diff --git a/examples/RotaryEncoder/Images/Counter/Image.png b/examples/RotaryEncoder/Images/Counter/Image.png new file mode 100644 index 0000000..721cd01 Binary files /dev/null and b/examples/RotaryEncoder/Images/Counter/Image.png differ diff --git a/examples/RotaryEncoder/Images/Counter/Image@2x.png b/examples/RotaryEncoder/Images/Counter/Image@2x.png new file mode 100644 index 0000000..4ca98e8 Binary files /dev/null and b/examples/RotaryEncoder/Images/Counter/Image@2x.png differ diff --git a/examples/RotaryEncoder/Images/Plugin/CategoryIcon.png b/examples/RotaryEncoder/Images/Plugin/CategoryIcon.png new file mode 100644 index 0000000..c9faaa2 Binary files /dev/null and b/examples/RotaryEncoder/Images/Plugin/CategoryIcon.png differ diff --git a/examples/RotaryEncoder/Images/Plugin/CategoryIcon@2x.png b/examples/RotaryEncoder/Images/Plugin/CategoryIcon@2x.png new file mode 100644 index 0000000..cae11dc Binary files /dev/null and b/examples/RotaryEncoder/Images/Plugin/CategoryIcon@2x.png differ diff --git a/examples/RotaryEncoder/Images/Plugin/Icon.png b/examples/RotaryEncoder/Images/Plugin/Icon.png new file mode 100644 index 0000000..13e1f76 Binary files /dev/null and b/examples/RotaryEncoder/Images/Plugin/Icon.png differ diff --git a/examples/RotaryEncoder/Images/Plugin/Icon@2x.png b/examples/RotaryEncoder/Images/Plugin/Icon@2x.png new file mode 100644 index 0000000..9afe4ee Binary files /dev/null and b/examples/RotaryEncoder/Images/Plugin/Icon@2x.png differ diff --git a/examples/RotaryEncoder/Program.cs b/examples/RotaryEncoder/Program.cs new file mode 100644 index 0000000..41e5f36 --- /dev/null +++ b/examples/RotaryEncoder/Program.cs @@ -0,0 +1,4 @@ +#if DEBUG +System.Diagnostics.Debugger.Launch(); +#endif +SharpDeck.StreamDeckPlugin.Run(); diff --git a/examples/RotaryEncoder/PropertyInspector/counter.html b/examples/RotaryEncoder/PropertyInspector/counter.html new file mode 100644 index 0000000..a9005d1 --- /dev/null +++ b/examples/RotaryEncoder/PropertyInspector/counter.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> + +<html lang="en" xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title>Dial Counter Property Inspector</title> + <link rel="stylesheet" href="./libs/css/sdpi.css"> +</head> +<body> + <div class="sdpi-wrapper"> + <div type="range" class="sdpi-item" id="tickmultiplier"> + <div class="sdpi-item-label">Tick Multiplier</div> + <div class="sdpi-item-value"> + <span class="clickable" value="-10">-10</span> + <input class="floating-tooltip" type="range" min="-10" max="10" value=1> + <span class="clickable" value="10">10</span> + </div> + </div> + </div> + + <div class="sdpi-info-label hidden" style="top: -1000;" value=""></div> + + <script src="./libs/js/constants.js"></script> + <script src="./libs/js/events.js"></script> + <script src="./libs/js/api.js"></script> + <script src="./libs/js/property-inspector.js"></script> + <script src="./libs/js/dynamic-styles.js"></script> + <script src="./counter.js"></script> +</body> +</html> diff --git a/examples/RotaryEncoder/PropertyInspector/counter.js b/examples/RotaryEncoder/PropertyInspector/counter.js new file mode 100644 index 0000000..1d855ac --- /dev/null +++ b/examples/RotaryEncoder/PropertyInspector/counter.js @@ -0,0 +1,298 @@ +/** + * Copied and simplified from PI Sample + * https://github.com/elgatosf/streamdeck-pisamples/blob/master/Sources/com.elgato.pisamples.sdPlugin/js/index_pi.js + **/ + +/// <reference path="./libs/js/property-inspector.js" /> +/// <reference path="./libs/js/action.js" /> +/// <reference path="./libs/js/utils.js" /> + +console.log('Property Inspector loaded', $PI); + +// register a callback for the 'connected' event +// this is all you need to communicate with the plugin and the StreamDeck software +$PI.onConnected(jsn => { + console.log('Property Inspector connected', jsn); + initPropertyInspector(); + console.log(jsn.actionInfo.payload.settings); + + // Initialize the slider with current value in settings + document.getElementById('tickmultiplier').getElementsByTagName("input")[0].value = jsn.actionInfo.payload.settings['tickMultiplier']; +}); + +/** + * DEMO + * ---- + * + * This initializes all elements found in the PI and makes them + * interactive. It will also send the values to the plugin. + * + * */ + +function initPropertyInspector(initDelay) { + prepareDOMElements(document); + /** expermimental carousel is not part of the DOM + * so let the DOM get constructed first and then + * inject the carousel */ + setTimeout(function () { + initToolTips(); + }, initDelay || 100); +} + +// our method to pass values to the plugin +function sendValueToPlugin(value, param) { + $PI.sendToPlugin({ [param]: value }); +} + +/** CREATE INTERACTIVE HTML-DOM + * where elements can be clicked or act on their 'change' event. + * Messages are then processed using the 'handleSdpiItemChange' method below. + */ +function prepareDOMElements(baseElement) { + const onchangeevt = 'onchange'; // 'oninput'; // change this, if you want interactive elements act on any change, or while they're modified + + baseElement = baseElement || document; + Array.from(baseElement.querySelectorAll('.sdpi-item-value')).forEach( + (el, i) => { + const elementsToClick = [ + 'BUTTON', + 'OL', + 'UL', + 'TABLE', + 'METER', + 'PROGRESS', + 'CANVAS', + 'DATALIST' + ].includes(el.tagName); + const evt = elementsToClick ? 'onclick' : onchangeevt || 'onchange'; + + /** Look for <input><span> combinations, where we consider the span as label for the input + * we don't use `labels` for that, because a range could have 2 labels. + */ + const inputGroup = el.querySelectorAll('input + span'); + if (inputGroup.length === 2) { + const offs = inputGroup[0].tagName === 'INPUT' ? 1 : 0; + inputGroup[offs].innerText = inputGroup[1 - offs].value; + inputGroup[1 - offs]['oninput'] = function () { + inputGroup[offs].innerText = inputGroup[1 - offs].value; + }; + } + /** We look for elements which have an 'clickable' attribute + * we use these e.g. on an 'inputGroup' (<span><input type="range"><span>) to adjust the value of + * the corresponding range-control + */ + Array.from(el.querySelectorAll('.clickable')).forEach( + (subel, subi) => { + subel['onclick'] = function (e) { + handleSdpiItemChange(e.target, subi); + }; + } + ); + /** Just in case the found HTML element already has an input or change - event attached, + * we clone it, and call it in the callback, right before the freshly attached event + */ + const cloneEvt = el[evt]; + el[evt] = function (e) { + if (cloneEvt) cloneEvt(); + handleSdpiItemChange(e.target, i); + }; + } + ); + + /** + * You could add a 'label' to a textares, e.g. to show the number of charactes already typed + * or contained in the textarea. This helper updates this label for you. + */ + baseElement.querySelectorAll('textarea').forEach((e) => { + const maxl = e.getAttribute('maxlength'); + e.targets = baseElement.querySelectorAll(`[for='${e.id}']`); + if (e.targets.length) { + let fn = () => { + for (let x of e.targets) { + x.textContent = maxl ? `${e.value.length}/${maxl}` : `${e.value.length}`; + } + }; + fn(); + e.onkeyup = fn; + } + }); + + baseElement.querySelectorAll('[data-open-url]').forEach(e => { + const value = e.getAttribute('data-open-url'); + if (value) { + e.onclick = () => { + let path; + if (value.indexOf('http') !== 0) { + path = document.location.href.split('/'); + path.pop(); + path.push(value.split('/').pop()); + path = path.join('/'); + } else { + path = value; + } + $SD.api.openUrl($SD.uuid, path); + }; + } else { + console.log(`${value} is not a supported url`); + } + }); +} + +function handleSdpiItemChange(e, idx) { + + /** Following items are containers, so we won't handle clicks on them */ + + if (['OL', 'UL', 'TABLE'].includes(e.tagName)) { + return; + } + + /** SPANS are used inside a control as 'labels' + * If a SPAN element calls this function, it has a class of 'clickable' set and is thereby handled as + * clickable label. + */ + + if (e.tagName === 'SPAN' || e.tagName === 'OPTION') { + const inp = e.tagName === 'OPTION' ? e.closest('.sdpi-item-value')?.querySelector('input') : e.parentNode.querySelector('input'); + var tmpValue; + + // if there's no attribute set for the span, try to see, if there's a value in the textContent + // and use it as value + if (!e.hasAttribute('value')) { + tmpValue = Number(e.textContent); + if (typeof tmpValue === 'number' && tmpValue !== null) { + e.setAttribute('value', 0 + tmpValue); // this is ugly, but setting a value of 0 on a span doesn't do anything + e.value = tmpValue; + } + } else { + tmpValue = Number(e.getAttribute('value')); + } + console.log("clicked!!!!", e, inp, tmpValue, e.closest('.sdpi-item-value'), e.closest('input')); + + if (inp && tmpValue !== undefined) { + inp.value = tmpValue; + } else return; + } + + const selectedElements = []; + const isList = ['LI', 'OL', 'UL', 'DL', 'TD'].includes(e.tagName); + const sdpiItem = e.closest('.sdpi-item'); + const sdpiItemGroup = e.closest('.sdpi-item-group'); + let sdpiItemChildren = isList + ? sdpiItem.querySelectorAll(e.tagName === 'LI' ? 'li' : 'td') + : sdpiItem.querySelectorAll('.sdpi-item-child > input'); + + if (isList) { + const siv = e.closest('.sdpi-item-value'); + if (!siv.classList.contains('multi-select')) { + for (let x of sdpiItemChildren) x.classList.remove('selected'); + } + if (!siv.classList.contains('no-select')) { + e.classList.toggle('selected'); + } + } + + if (sdpiItemChildren.length && ['radio', 'checkbox'].includes(sdpiItemChildren[0].type)) { + e.setAttribute('_value', e.checked); //'_value' has priority over .value + } + if (sdpiItemGroup && !sdpiItemChildren.length) { + for (let x of ['input', 'meter', 'progress']) { + sdpiItemChildren = sdpiItemGroup.querySelectorAll(x); + if (sdpiItemChildren.length) break; + } + } + + if (e.selectedIndex !== undefined) { + if (e.tagName === 'SELECT') { + sdpiItemChildren.forEach((ec, i) => { + selectedElements.push({ [ec.id]: ec.value }); + }); + } + idx = e.selectedIndex; + } else { + sdpiItemChildren.forEach((ec, i) => { + if (ec.classList.contains('selected')) { + selectedElements.push(ec.textContent); + } + if (ec === e) { + idx = i; + selectedElements.push(ec.value); + } + }); + } + + const returnValue = { + key: e.id && e.id.charAt(0) !== '_' ? e.id : sdpiItem.id, + value: isList + ? e.textContent + : e.hasAttribute('_value') + ? e.getAttribute('_value') + : e.value + ? e.type === 'file' + ? decodeURIComponent(e.value.replace(/^C:\\fakepath\\/, '')) + : e.value + : e.getAttribute('value'), + group: sdpiItemGroup ? sdpiItemGroup.id : false, + index: idx, + selection: selectedElements, + checked: e.checked + }; + + /** Just simulate the original file-selector: + * If there's an element of class '.sdpi-file-info' + * show the filename there + */ + if (e.type === 'file') { + const info = sdpiItem.querySelector('.sdpi-file-info'); + if (info) { + const s = returnValue.value.split('/').pop(); + info.textContent = s.length > 28 + ? s.substr(0, 10) + + '...' + + s.substr(s.length - 10, s.length) + : s; + } + } + + sendValueToPlugin(returnValue, 'sdpi_collection'); +} +function rangeToPercent(value, min, max) { + return (value - min) / (max - min); +}; + +function initToolTips() { + const tooltip = document.querySelector('.sdpi-info-label'); + const arrElements = document.querySelectorAll('.floating-tooltip'); + arrElements.forEach((e, i) => { + initToolTip(e, tooltip); + }); +} + +function initToolTip(element, tooltip) { + + const tw = tooltip.getBoundingClientRect().width; + const suffix = element.getAttribute('data-suffix') || ''; + + const fn = () => { + const elementRect = element.getBoundingClientRect(); + const w = elementRect.width - tw / 2; + const percnt = rangeToPercent(element.value, element.min, element.max); + tooltip.textContent = suffix != "" ? `${element.value} ${suffix}` : String(element.value); + tooltip.style.left = `${elementRect.left + Math.round(w * percnt) - tw / 4}px`; + tooltip.style.top = `${elementRect.top + 20}px`; + }; + + if (element) { + element.addEventListener('mouseenter', function () { + tooltip.classList.remove('hidden'); + tooltip.classList.add('shown'); + fn(); + }, false); + + element.addEventListener('mouseout', function () { + tooltip.classList.remove('shown'); + tooltip.classList.add('hidden'); + fn(); + }, false); + element.addEventListener('input', fn, false); + } +} diff --git a/examples/RotaryEncoder/manifest.json b/examples/RotaryEncoder/manifest.json new file mode 100644 index 0000000..177bb07 --- /dev/null +++ b/examples/RotaryEncoder/manifest.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://raw.githubusercontent.com/GeekyEggo/SharpDeck/master/src/SharpDeck.PropertyInspector/manifest-schema.json", + "Name": "Dial Counter", + "Version": "1.0.0", + "Author": "Hy", + "Actions": [ + { + "Name": "Dial Counter", + "UUID": "com.geekyeggo.dialcounter.counter", + "Icon": "Images/Counter/Icon", + "Tooltip": "A counter action with SD+ dial", + "States": [ + { + "FontSize": "18", + "Image": "Images/Counter/Image", + "TitleAlignment": "middle" + } + ], + "PropertyInspectorPath": "PropertyInspector/counter.html", + "Controllers": [ "Encoder" ], + "Encoder": { + "layout": "$A1", + "TriggerDescription": { + "Rotate": "Change counter value", + "Push": "Reset the counter", + "Touch": "Increase the counter", + "LongTouch": "Reset the counter" + } + } + } + ], + "Category": "Dial Counter", + "CategoryIcon": "Images/Plugin/CategoryIcon", + "CodePath": "DialCounter.exe", + "Description": "A dial counter plugin example", + "Icon": "Images/Plugin/Icon", + "SDKVersion": 2, + "Software": { + "MinimumVersion": "6.0" + }, + "OS": [ + { + "MinimumVersion": "10", + "Platform": "windows" + } + ] +}