Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ Notes:

---

## v1.2.0.0 - Minor Release

### Features / Improvements

- Added `Adaptive Dial` action for Stream Deck+ devices. Supports assigning separate Star Citizen functions to rotate left, rotate right, and dial push. Rotation executes the assigned function once per tick. Push respects the action's activation mode (e.g., Tap vs Hold).
- Click sound support for the `Adaptive Dial` (same `.wav`/`.mp3` configuration as `Adaptive Key`).

### Internal / Refactor

- `SCActionBase` and `ControlPanelKey` now inherit from `KeypadBase` instead of `KeyAndEncoderBase`, removing stub dial/touchpad overrides that were never used for key actions.
- Added explicit `Controllers` declarations (`Keypad` / `Encoder`) to all actions in `manifest.json`.
- Added unit tests for `AdaptiveDial` covering rotation resolution and executable binding resolution.
- Refactored Property Inspector function picker logic into shared helpers (`SCPI.functionPicker`) used by both key and dial scripts, reducing duplicated code.

## v1.1.3.0 - Patch Release

### Features / Improvements
Expand Down
201 changes: 201 additions & 0 deletions PluginCore/ActionKeys/AdaptiveDial.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using BarRaider.SdTools;
using BarRaider.SdTools.Payloads;
using SCStreamDeck.Common;
using SCStreamDeck.Logging;
using SCStreamDeck.Models;

namespace SCStreamDeck.ActionKeys;

/// <summary>
/// Adaptive Star Citizen dial.
/// Supports separate functions for rotate left, rotate right, and dial press.
/// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Stream Deck action instantiated via SDK reflection")]
[PluginActionId("com.jarex985.scstreamdeck.adaptivedial")]
public sealed class AdaptiveDial(SDConnection connection, InitialPayload payload) : SCDialActionBase(connection, payload)
{
public override async void DialRotate(DialRotatePayload payload)
{
try
{
await ProcessDialRotateAsync(payload.Ticks).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Err($"{GetType().Name}: {ex.Message}", ex);
}
}

public override async void DialDown(DialPayload payload)
{
try
{
PlayClickSoundIfConfigured();
await ProcessPressEventAsync(true).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Err($"{GetType().Name}: {ex.Message}", ex);
}
}

public override async void DialUp(DialPayload payload)
{
try
{
await ProcessPressEventAsync(false).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Err($"{GetType().Name}: {ex.Message}", ex);
}
}

public override async void TouchPress(TouchpadPressPayload payload)
{
try
{
// SDK versions may differ on TouchpadPressPayload shape; read pressed-state defensively.
bool isKeyDown = true;
object? pressedValue = payload.GetType().GetProperty("Pressed")?.GetValue(payload);
if (pressedValue is bool pressed)
{
isKeyDown = pressed;
}

if (isKeyDown)
{
PlayClickSoundIfConfigured();
}

await ProcessPressEventAsync(isKeyDown).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Err($"{GetType().Name}: {ex.Message}", ex);
}
}

private async Task ProcessDialRotateAsync(int ticks)
{
if (ticks == 0)
{
return;
}

string? actionId = ResolveRotationFunction(Settings, ticks);
(KeybindingAction, string)? validationResult = ValidateAndResolve(actionId);
if (validationResult == null || string.IsNullOrWhiteSpace(actionId))
{
return;
}

int rotationSteps = Math.Abs(ticks);
string executableBinding = validationResult.Value.Item2;

for (int i = 0; i < rotationSteps; i++)
{
bool success = await KeybindingService.ExecutePressNoRepeatAsync(actionId, executableBinding)
.ConfigureAwait(false);
if (success)
{
LogRotateExec(actionId, ticks, executableBinding);
}
}
}

private async Task ProcessPressEventAsync(bool isKeyDown)
{
(KeybindingAction, string)? validationResult = ValidateAndResolve(Settings.PressFunction);
if (validationResult == null || string.IsNullOrWhiteSpace(Settings.PressFunction))
{
return;
}

(KeybindingAction action, string executableBinding) = validationResult.Value;

KeybindingExecutionContext context = new()
{
ActionName = Settings.PressFunction,
Binding = executableBinding,
ActivationMode = action.ActivationMode,
IsKeyDown = isKeyDown
};

await ExecuteKeybindingAsync(context).ConfigureAwait(false);
}

internal static string? ResolveRotationFunction(Settings.DialSettings settings, int ticks)
{
ArgumentNullException.ThrowIfNull(settings);

return ticks switch
{
> 0 => settings.RotateRightFunction,
< 0 => settings.RotateLeftFunction,
_ => null
};
}

[ExcludeFromCodeCoverage]
private (KeybindingAction, string)? ValidateAndResolve(string? actionId)
{
if (string.IsNullOrWhiteSpace(actionId) || !CanExecuteBindings)
{
return null;
}

if (!KeybindingService.TryGetAction(actionId, out KeybindingAction? action) || action == null)
{
return null;
}

string? executableBinding = ResolveExecutableBinding(action);
return executableBinding == null ? null : (action, executableBinding);
}

internal static string? ResolveExecutableBinding(KeybindingAction action)
{
ArgumentNullException.ThrowIfNull(action);

if (!string.IsNullOrWhiteSpace(action.KeyboardBinding))
{
return action.KeyboardBinding;
}

if (string.IsNullOrWhiteSpace(action.MouseBinding))
{
return null;
}

InputType bindingType = action.MouseBinding.GetInputType();
return bindingType is InputType.MouseButton or InputType.MouseWheel ? action.MouseBinding : null;
}

private async Task ExecuteKeybindingAsync(KeybindingExecutionContext context)
{
try
{
bool success = await KeybindingService.ExecuteAsync(context).ConfigureAwait(false);
if (success)
{
LogPressExec(context);
}
}
catch (Exception ex)
{
Log.Err($"{GetType().Name}: '{context.ActionName}': {ex.Message}", ex);
}
}

[Conditional("DEBUG")]
private void LogPressExec(KeybindingExecutionContext context) =>
Log.Debug(
$"{GetType().Name}: {(context.IsKeyDown ? "pressed" : "released")} '{context.ActionName}' ({context.ActivationMode}) → '{context.Binding}'");

[Conditional("DEBUG")]
private static void LogRotateExec(string actionName, int ticks, string binding) =>
Log.Debug($"{nameof(AdaptiveDial)}: rotated '{actionName}' ticks={ticks} → '{binding}'");
}
18 changes: 1 addition & 17 deletions PluginCore/ActionKeys/ControlPanelKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ namespace SCStreamDeck.ActionKeys;
/// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Stream Deck action instantiated via SDK reflection")]
[PluginActionId("com.jarex985.scstreamdeck.controlpanel")]
public sealed class ControlPanelKey : KeyAndEncoderBase
public sealed class ControlPanelKey : KeypadBase
{
private const string PiEventConnected = "propertyInspectorConnected";
private const string PiEventSetTheme = "setTheme";
Expand Down Expand Up @@ -67,22 +67,6 @@ public override void OnTick()
{
}

public override void DialRotate(DialRotatePayload payload)
{
}

public override void DialDown(DialPayload payload)
{
}

public override void DialUp(DialPayload payload)
{
}

public override void TouchPress(TouchpadPressPayload payload)
{
}

public override void Dispose()
{
Connection.OnPropertyInspectorDidAppear -= OnPropertyInspectorDidAppear;
Expand Down
36 changes: 2 additions & 34 deletions PluginCore/ActionKeys/SCActionBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
namespace SCStreamDeck.ActionKeys;

/// <summary>
/// Base class for Star Citizen Stream Deck Keys and Dials.
/// Base class for Star Citizen Stream Deck key actions.
/// </summary>
public abstract class SCActionBase : KeyAndEncoderBase
public abstract class SCActionBase : KeypadBase
{
#region Constructor and Initialization

Expand Down Expand Up @@ -269,36 +269,4 @@ public override void OnTick()
}

#endregion

#region Dial and Touchpad Methods

/// <summary>
/// Called when the dial is rotated. Not used for keys.
/// </summary>
public override void DialRotate(DialRotatePayload payload)
{
}

/// <summary>
/// Called when the dial is pressed down. Not used for keys.
/// </summary>
public override void DialDown(DialPayload payload)
{
}

/// <summary>
/// Called when the dial is released. Not used for keys.
/// </summary>
public override void DialUp(DialPayload payload)
{
}

/// <summary>
/// Called when the touchpad is pressed. Not used for keys.
/// </summary>
public override void TouchPress(TouchpadPressPayload payload)
{
}

#endregion
}
Loading