diff --git a/StreamDeckSimHub.Plugin/Actions/FlagsAction.cs b/StreamDeckSimHub.Plugin/Actions/FlagsAction.cs
index 5875907..e11a42b 100644
--- a/StreamDeckSimHub.Plugin/Actions/FlagsAction.cs
+++ b/StreamDeckSimHub.Plugin/Actions/FlagsAction.cs
@@ -1,9 +1,13 @@
-// Copyright (C) 2023 Martin Renner
+// Copyright (C) 2024 Martin Renner
// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER)
using Microsoft.Extensions.Logging;
using SharpDeck;
using SharpDeck.Events.Received;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
using StreamDeckSimHub.Plugin.SimHub;
using StreamDeckSimHub.Plugin.Tools;
@@ -14,82 +18,124 @@ namespace StreamDeckSimHub.Plugin.Actions;
///
internal class FlagState
{
- internal bool black;
- internal bool blue;
- internal bool checkered;
- internal bool green;
- internal bool orange;
- internal bool white;
- internal bool yellow;
+ internal bool Black;
+ internal bool Blue;
+ internal bool Checkered;
+ internal bool Green;
+ internal bool Orange;
+ internal bool White;
+ internal bool Yellow;
+ internal bool YellowSec1;
+ internal bool YellowSec2;
+ internal bool YellowSec3;
}
///
/// Displays the flags on a Stream Deck key.
///
[StreamDeckAction("net.planetrenner.simhub.flags")]
-public class FlagsAction : StreamDeckAction
+public class FlagsAction : StreamDeckAction
{
private readonly SimHubConnection _simHubConnection;
+ private readonly ImageManager _imageManager;
private readonly IPropertyChangedReceiver _propertyChangedReceiver;
+ private StreamDeckKeyInfo? _sdKeyInfo;
private bool _gameRunning;
private FlagState _flagState = new();
- private readonly string _noFlag;
- private readonly string _blackFlag;
- private readonly string _blueFlag;
- private readonly string _checkeredFlag;
- private readonly string _greenFlag;
- private readonly string _orangeFlag;
- private readonly string _whiteFlag;
- private readonly string _yellowFlag;
-
- public FlagsAction(SimHubConnection simHubConnection, ImageUtils imageUtils)
+ private Image _noFlag;
+ private Image _blackFlag;
+ private Image _blueFlag;
+ private Image _checkeredFlag;
+ private Image _greenFlag;
+ private Image _orangeFlag;
+ private Image _whiteFlag;
+ private Image _yellowFlag;
+ private Image _yellowFlagSec1;
+ private Image _yellowFlagSec2;
+ private Image _yellowFlagSec3;
+
+ public FlagsAction(SimHubConnection simHubConnection, ImageUtils imageUtils, ImageManager imageManager)
{
_simHubConnection = simHubConnection;
+ _imageManager = imageManager;
_propertyChangedReceiver = new PropertyChangedDelegate(PropertyChanged);
- _noFlag = imageUtils.EncodeSvg("");
- _blackFlag = imageUtils.LoadSvg("images/icons/flag-black.svg");
- _blueFlag = imageUtils.LoadSvg("images/icons/flag-blue.svg");
- _checkeredFlag = imageUtils.LoadSvg("images/icons/flag-checkered.svg");
- _greenFlag = imageUtils.LoadSvg("images/icons/flag-green.svg");
- _orangeFlag = imageUtils.LoadSvg("images/icons/flag-orange.svg");
- _whiteFlag = imageUtils.LoadSvg("images/icons/flag-white.svg");
- _yellowFlag = imageUtils.LoadSvg("images/icons/flag-yellow.svg");
+ _noFlag = imageUtils.GetEmptyImage();
+ _blackFlag = imageUtils.GetEmptyImage();
+ _blueFlag = imageUtils.GetEmptyImage();
+ _checkeredFlag = imageUtils.GetEmptyImage();
+ _greenFlag = imageUtils.GetEmptyImage();
+ _orangeFlag = imageUtils.GetEmptyImage();
+ _whiteFlag = imageUtils.GetEmptyImage();
+ _yellowFlag = imageUtils.GetEmptyImage();
+ _yellowFlagSec1 = imageUtils.GetEmptyImage();
+ _yellowFlagSec2 = imageUtils.GetEmptyImage();
+ _yellowFlagSec3 = imageUtils.GetEmptyImage();
}
protected override async Task OnWillAppear(ActionEventArgs args)
{
- Logger.LogInformation("OnWillAppear ({coords})", args.Payload.Coordinates);
- await SetImageAsync(_checkeredFlag);
-
- await _simHubConnection.Subscribe("dcp.GameRunning", _propertyChangedReceiver);
- await _simHubConnection.Subscribe("dcp.gd.Flag_Black", _propertyChangedReceiver);
- await _simHubConnection.Subscribe("dcp.gd.Flag_Blue", _propertyChangedReceiver);
- await _simHubConnection.Subscribe("dcp.gd.Flag_Checkered", _propertyChangedReceiver);
- await _simHubConnection.Subscribe("dcp.gd.Flag_Green", _propertyChangedReceiver);
- await _simHubConnection.Subscribe("dcp.gd.Flag_Orange", _propertyChangedReceiver);
- await _simHubConnection.Subscribe("dcp.gd.Flag_White", _propertyChangedReceiver);
- await _simHubConnection.Subscribe("dcp.gd.Flag_Yellow", _propertyChangedReceiver);
+ var settings = args.Payload.GetSettings();
+ Logger.LogInformation("OnWillAppear ({coords}): {settings}", args.Payload.Coordinates, settings);
+
+ _sdKeyInfo = StreamDeckKeyInfoBuilder.Build(StreamDeck.Info, args.Device, args.Payload.Controller);
+ PopulateImages(settings, _sdKeyInfo);
+
+ await SetImageAsync(_checkeredFlag.ToBase64String(PngFormat.Instance));
+
+ await _simHubConnection.Subscribe("DataCorePlugin.GameRunning", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameData.Flag_Black", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameData.Flag_Blue", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameData.Flag_Checkered", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameData.Flag_Green", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameData.Flag_Orange", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameData.Flag_White", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameData.Flag_Yellow", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameRawData.Graphics.globalYellow1", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameRawData.Graphics.globalYellow2", _propertyChangedReceiver);
+ await _simHubConnection.Subscribe("DataCorePlugin.GameRawData.Graphics.globalYellow3", _propertyChangedReceiver);
await base.OnWillAppear(args);
}
protected override async Task OnWillDisappear(ActionEventArgs args)
{
- Logger.LogInformation("OnWillDisappear ({coords})", args.Payload.Coordinates);
+ Logger.LogInformation("OnWillDisappear ({coords}): {settings}", args.Payload.Coordinates, args.Payload.GetSettings());
- await _simHubConnection.Unsubscribe("dcp.GameRunning", _propertyChangedReceiver);
- await _simHubConnection.Unsubscribe("dcp.gd.Flag_Black", _propertyChangedReceiver);
- await _simHubConnection.Unsubscribe("dcp.gd.Flag_Blue", _propertyChangedReceiver);
- await _simHubConnection.Unsubscribe("dcp.gd.Flag_Checkered", _propertyChangedReceiver);
- await _simHubConnection.Unsubscribe("dcp.gd.Flag_Green", _propertyChangedReceiver);
- await _simHubConnection.Unsubscribe("dcp.gd.Flag_Orange", _propertyChangedReceiver);
- await _simHubConnection.Unsubscribe("dcp.gd.Flag_White", _propertyChangedReceiver);
- await _simHubConnection.Unsubscribe("dcp.gd.Flag_Yellow", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameRunning", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameData.Flag_Black", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameData.Flag_Blue", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameData.Flag_Checkered", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameData.Flag_Green", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameData.Flag_Orange", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameData.Flag_White", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameData.Flag_Yellow", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameRawData.Graphics.globalYellow1", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameRawData.Graphics.globalYellow2", _propertyChangedReceiver);
+ await _simHubConnection.Unsubscribe("DataCorePlugin.GameRawData.Graphics.globalYellow3", _propertyChangedReceiver);
- await SetImageAsync(_checkeredFlag);
+ await SetImageAsync(_checkeredFlag.ToBase64String(PngFormat.Instance));
- await base.OnWillAppear(args);
+ await base.OnWillDisappear(args);
+ }
+
+ protected override async Task OnDidReceiveSettings(ActionEventArgs args, FlagsSettings settings)
+ {
+ Logger.LogInformation("OnDidReceiveSettings ({coords}): {settings}", args.Payload.Coordinates, settings);
+
+ PopulateImages(settings, _sdKeyInfo!);
+
+ await SetImageAsync(_checkeredFlag.ToBase64String(PngFormat.Instance));
+
+ await base.OnDidReceiveSettings(args, settings);
+ }
+
+ protected override async Task OnPropertyInspectorDidAppear(ActionEventArgs args)
+ {
+ Logger.LogInformation("OnPropertyInspectorDidAppear");
+
+ var images = _imageManager.ListCustomImages();
+ await SendToPropertyInspectorAsync(new { message = "customImages", images });
}
///
@@ -97,76 +143,122 @@ protected override async Task OnWillDisappear(ActionEventArgs
///
private async Task PropertyChanged(PropertyChangedArgs args)
{
- if (args.PropertyName == "dcp.GameRunning")
+ if (args.PropertyName == "DataCorePlugin.GameRunning")
{
- _gameRunning = Equals(args.PropertyValue, true);
+ _gameRunning = Equals(args.PropertyValue, 1);
}
if (!_gameRunning)
{
// game not running, show checked flag as placeholder
- _flagState = new();
- await SetImageAsync(_checkeredFlag);
+ _flagState = new FlagState();
+ await SetImageAsync(_checkeredFlag.ToBase64String(PngFormat.Instance));
return;
}
-
switch (args.PropertyName)
{
- case "dcp.gd.Flag_Black":
- _flagState.black = Equals(args.PropertyValue, 1);
+ case "DataCorePlugin.GameData.Flag_Black":
+ _flagState.Black = Equals(args.PropertyValue, 1);
+ break;
+ case "DataCorePlugin.GameData.Flag_Blue":
+ _flagState.Blue = Equals(args.PropertyValue, 1);
+ break;
+ case "DataCorePlugin.GameData.Flag_Checkered":
+ _flagState.Checkered = Equals(args.PropertyValue, 1);
break;
- case "dcp.gd.Flag_Blue":
- _flagState.blue = Equals(args.PropertyValue, 1);
+ case "DataCorePlugin.GameData.Flag_Green":
+ _flagState.Green = Equals(args.PropertyValue, 1);
break;
- case "dcp.gd.Flag_Checkered":
- _flagState.checkered = Equals(args.PropertyValue, 1);
+ case "DataCorePlugin.GameData.Flag_Orange":
+ _flagState.Orange = Equals(args.PropertyValue, 1);
break;
- case "dcp.gd.Flag_Green":
- _flagState.green = Equals(args.PropertyValue, 1);
+ case "DataCorePlugin.GameData.Flag_White":
+ _flagState.White = Equals(args.PropertyValue, 1);
break;
- case "dcp.gd.Flag_Orange":
- _flagState.orange = Equals(args.PropertyValue, 1);
+ case "DataCorePlugin.GameData.Flag_Yellow":
+ _flagState.Yellow = Equals(args.PropertyValue, 1);
break;
- case "dcp.gd.Flag_White":
- _flagState.white = Equals(args.PropertyValue, 1);
+ case "DataCorePlugin.GameRawData.Graphics.globalYellow1":
+ _flagState.YellowSec1 = Equals(args.PropertyValue, 1);
break;
- case "dcp.gd.Flag_Yellow":
- _flagState.yellow = Equals(args.PropertyValue, 1);
+ case "DataCorePlugin.GameRawData.Graphics.globalYellow2":
+ _flagState.YellowSec2 = Equals(args.PropertyValue, 1);
+ break;
+ case "DataCorePlugin.GameRawData.Graphics.globalYellow3":
+ _flagState.YellowSec3 = Equals(args.PropertyValue, 1);
break;
}
- if (_flagState.black)
+ // "Green" must be after other flags, because several flags can be "on" in the same time. We want to have
+ // "Green" after "Yellow".
+ if (_flagState.Black)
{
- await SetImageAsync(_blackFlag);
+ await SetImageAsync(_blackFlag.ToBase64String(PngFormat.Instance));
}
- else if (_flagState.blue)
+ else if (_flagState.Blue)
{
- await SetImageAsync(_blueFlag);
+ await SetImageAsync(_blueFlag.ToBase64String(PngFormat.Instance));
}
- else if (_flagState.checkered)
+ else if (_flagState.Checkered)
{
- await SetImageAsync(_checkeredFlag);
+ await SetImageAsync(_checkeredFlag.ToBase64String(PngFormat.Instance));
}
- else if (_flagState.green)
+ else if (_flagState.Orange)
{
- await SetImageAsync(_greenFlag);
+ await SetImageAsync(_orangeFlag.ToBase64String(PngFormat.Instance));
}
- else if (_flagState.orange)
+ else if (_flagState.White)
{
- await SetImageAsync(_orangeFlag);
+ await SetImageAsync(_whiteFlag.ToBase64String(PngFormat.Instance));
}
- else if (_flagState.white)
+ else if (_flagState.Yellow)
{
- await SetImageAsync(_whiteFlag);
+ await SetImageAsync(_yellowFlag.ToBase64String(PngFormat.Instance));
}
- else if (_flagState.yellow)
+ else if (_flagState.YellowSec1 || _flagState.YellowSec2 || _flagState.YellowSec3)
{
- await SetImageAsync(_yellowFlag);
+ // We have to combine them on a new image with the same size
+ var image = new Image(_yellowFlagSec1.Width, _yellowFlagSec1.Height);
+ if (_flagState.YellowSec1)
+ {
+ image.Mutate(x => x.DrawImage(_yellowFlagSec1, 1f));
+ }
+
+ if (_flagState.YellowSec2)
+ {
+ image.Mutate(x => x.DrawImage(_yellowFlagSec2, 1f));
+ }
+
+ if (_flagState.YellowSec3)
+ {
+ image.Mutate(x => x.DrawImage(_yellowFlagSec3, 1f));
+ }
+
+ await SetImageAsync(image.ToBase64String(PngFormat.Instance));
+ }
+ else if (_flagState.Green)
+ {
+ await SetImageAsync(_greenFlag.ToBase64String(PngFormat.Instance));
}
else
{
- await SetImageAsync(_noFlag);
+ await SetImageAsync(_noFlag.ToBase64String(PngFormat.Instance));
}
}
+
+ private void PopulateImages(FlagsSettings settings, StreamDeckKeyInfo sdKeyInfo)
+ {
+ _noFlag = _imageManager.GetCustomImage(settings.NoFlag, sdKeyInfo);
+ _blackFlag = _imageManager.GetCustomImage(settings.BlackFlag, sdKeyInfo);
+ _blueFlag = _imageManager.GetCustomImage(settings.BlueFlag, sdKeyInfo);
+ _checkeredFlag = _imageManager.GetCustomImage(settings.CheckeredFlag, sdKeyInfo);
+ _greenFlag = _imageManager.GetCustomImage(settings.GreenFlag, sdKeyInfo);
+ _orangeFlag = _imageManager.GetCustomImage(settings.OrangeFlag, sdKeyInfo);
+ _whiteFlag = _imageManager.GetCustomImage(settings.WhiteFlag, sdKeyInfo);
+ _yellowFlag = _imageManager.GetCustomImage(settings.YellowFlag, sdKeyInfo);
+ _yellowFlagSec1 = _imageManager.GetCustomImage(settings.YellowFlagSec1, sdKeyInfo);
+ _yellowFlagSec2 = _imageManager.GetCustomImage(settings.YellowFlagSec2, sdKeyInfo);
+ _yellowFlagSec3 = _imageManager.GetCustomImage(settings.YellowFlagSec3, sdKeyInfo);
+ }
}
\ No newline at end of file
diff --git a/StreamDeckSimHub.Plugin/Actions/FlagsSettings.cs b/StreamDeckSimHub.Plugin/Actions/FlagsSettings.cs
new file mode 100644
index 0000000..8f0f60c
--- /dev/null
+++ b/StreamDeckSimHub.Plugin/Actions/FlagsSettings.cs
@@ -0,0 +1,25 @@
+namespace StreamDeckSimHub.Plugin.Actions;
+
+///
+/// Settings for Flags Action, which are set in the Stream Deck UI.
+///
+public class FlagsSettings
+{
+ public string NoFlag { get; set; } = "flags/flag-none.svg";
+ public string BlackFlag { get; set; } = "flags/flag-black.svg";
+ public string BlueFlag { get; set; } = "flags/flag-blue.svg";
+ public string CheckeredFlag { get; set; } = "flags/flag-checkered.svg";
+ public string GreenFlag { get; set; } = "flags/flag-green.svg";
+ public string OrangeFlag { get; set; } = "flags/flag-orange.svg";
+ public string WhiteFlag { get; set; } = "flags/flag-white.svg";
+ public string YellowFlag { get; set; } = "flags/flag-yellow.svg";
+ public string YellowFlagSec1 { get; set; } = "flags/flag-yellow-s1.png";
+ public string YellowFlagSec2 { get; set; } = "flags/flag-yellow-s2.png";
+ public string YellowFlagSec3 { get; set; } = "flags/flag-yellow-s3.png";
+
+ public override string ToString()
+ {
+ return
+ $"No: {NoFlag}, Black: {BlackFlag}, Blue: {BlueFlag}, Checkered: {CheckeredFlag}, Green: {GreenFlag}, Orange: {OrangeFlag}, White: {WhiteFlag}, Yellow: {YellowFlag}, YellowFlagSec1: {YellowFlagSec1}, YellowFlagSec2: {YellowFlagSec2}, YellowFlagSec3: {YellowFlagSec3}";
+ }
+}
\ No newline at end of file
diff --git a/StreamDeckSimHub.Plugin/Program.cs b/StreamDeckSimHub.Plugin/Program.cs
index f7a6ef4..50b0a55 100644
--- a/StreamDeckSimHub.Plugin/Program.cs
+++ b/StreamDeckSimHub.Plugin/Program.cs
@@ -1,6 +1,7 @@
// Copyright (C) 2023 Martin Renner
// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER)
+using System.IO.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -35,4 +36,6 @@ void ConfigureServices(IServiceCollection serviceCollection)
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
-}
\ No newline at end of file
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton(new FileSystem());
+}
diff --git a/StreamDeckSimHub.Plugin/StreamDeckSimHub.Plugin.csproj b/StreamDeckSimHub.Plugin/StreamDeckSimHub.Plugin.csproj
index 14a3990..49ced41 100644
--- a/StreamDeckSimHub.Plugin/StreamDeckSimHub.Plugin.csproj
+++ b/StreamDeckSimHub.Plugin/StreamDeckSimHub.Plugin.csproj
@@ -27,6 +27,8 @@
+
+
..\lib\SharpDeck.dll
@@ -83,6 +85,12 @@
PreserveNewest
+
+ PreserveNewest
+
+
+ PreserveNewest
+
PreserveNewest
@@ -146,25 +154,10 @@
PreserveNewest
-
- PreserveNewest
-
-
- PreserveNewest
-
PreserveNewest
-
- PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
+
PreserveNewest
@@ -215,6 +208,48 @@
PreserveNewest
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
diff --git a/StreamDeckSimHub.Plugin/Tools/ImageManager.cs b/StreamDeckSimHub.Plugin/Tools/ImageManager.cs
new file mode 100644
index 0000000..68d9e81
--- /dev/null
+++ b/StreamDeckSimHub.Plugin/Tools/ImageManager.cs
@@ -0,0 +1,85 @@
+// Copyright (C) 2024 Martin Renner
+// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER)
+
+using System.IO.Abstractions;
+using System.Text.RegularExpressions;
+using NLog;
+using SixLabors.ImageSharp;
+
+namespace StreamDeckSimHub.Plugin.Tools;
+
+public class ImageManager(IFileSystem fileSystem, ImageUtils imageUtils)
+{
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+ private static readonly string[] SupportedExtensions = [".svg", ".png", ".jpg", ".jpeg", ".gif"];
+ private readonly IDirectoryInfo _customImagesDirectory = fileSystem.DirectoryInfo.New(Path.Combine("images", "custom"));
+
+ ///
+ /// Returns an array with all custom images. The images are returned with their relative path inside
+ /// the "custom images" directory. Images with quality suffix in their name ("@2x") are excluded from the list.
+ ///
+ public string[] ListCustomImages()
+ {
+ var imageQualityRegex = new Regex(@"@\dx\."); // "image@2x.png", "image@3x.png", ...
+ try
+ {
+ var fn = _customImagesDirectory.FullName;
+ return _customImagesDirectory.GetFiles("*.*", SearchOption.AllDirectories)
+ .Where(fileInfo => SupportedExtensions.Contains(fileInfo.Extension.ToLowerInvariant()))
+ .Select(fileInfo => fileInfo.FullName[(fn.Length + 1)..])
+ .Select(fileName => imageQualityRegex.Replace(fileName, "."))
+ .Select(fileName => fileName.Replace(@"\", "/"))
+ .ToList()
+ .Distinct()
+ .OrderBy(fileName => fileName.Count(c => c == '/') + fileName)
+ .ToArray();
+ }
+ catch (Exception e)
+ {
+ Logger.Error($"Could not load custom images: {e.Message}");
+ return [];
+ }
+ }
+
+ ///
+ /// Returns an image from the "custom image" folder.
+ ///
+ /// The relative path inside the "custom images" folder
+ /// Information about the specific Stream Deck key or dial
+ /// The image in encoded form, or a default error image, if it could not be loaded.
+ public Image GetCustomImage(string relativePath, StreamDeckKeyInfo sdKeyInfo)
+ {
+ relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
+ if (Path.GetExtension(relativePath).Equals(".svg", StringComparison.InvariantCultureIgnoreCase))
+ {
+ // No quality suffix for SVG files.
+ var fn = Path.Combine(_customImagesDirectory.FullName, relativePath);
+ return imageUtils.FromSvgFile(fn, sdKeyInfo);
+ }
+
+ var newName = FindResolutionForKeyInfo(_customImagesDirectory.FullName, relativePath, sdKeyInfo);
+ var newFn = Path.Combine(_customImagesDirectory.FullName, newName);
+ try
+ {
+ using var stream = fileSystem.File.OpenRead(newFn);
+ return Image.Load(stream);
+ }
+ catch (Exception e)
+ {
+ Logger.Warn($"Could not load custom image '{newFn}': {e.Message}");
+ return imageUtils.GetErrorImage(sdKeyInfo);
+ }
+ }
+
+ private string FindResolutionForKeyInfo(string baseDir, string relativePath, StreamDeckKeyInfo sdKeyInfo)
+ {
+ if (sdKeyInfo.IsHighRes)
+ {
+ var extension = Path.GetExtension(relativePath);
+ var hqFile = relativePath[..relativePath.LastIndexOf('.')] + "@2x" + extension;
+ if (fileSystem.File.Exists(Path.Combine(baseDir, hqFile))) return hqFile;
+ }
+
+ return relativePath;
+ }
+}
\ No newline at end of file
diff --git a/StreamDeckSimHub.Plugin/Tools/ImageUtils.cs b/StreamDeckSimHub.Plugin/Tools/ImageUtils.cs
index 7bc64b8..b81ff41 100644
--- a/StreamDeckSimHub.Plugin/Tools/ImageUtils.cs
+++ b/StreamDeckSimHub.Plugin/Tools/ImageUtils.cs
@@ -1,55 +1,110 @@
// Copyright (C) 2023 Martin Renner
// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER)
-using System.Text.RegularExpressions;
using NLog;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
+using SkiaSharp;
+using Svg.Skia;
namespace StreamDeckSimHub.Plugin.Tools;
public class ImageUtils
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
- private readonly Regex _lineBreakRegex = new("[\r\n]+");
- ///
- /// Loads a given SVG file from the file system and removes all line breaks. This is important so that
- /// Stream Deck can handle the SVG image.
- ///
- public string LoadSvg(string svgFile)
+ private readonly Image _errorSmall;
+ private readonly Image _errorLarge;
+ private readonly Image _empty;
+
+ public ImageUtils()
{
- try
- {
- var svgAsText = File.ReadAllText(svgFile);
- return EncodeSvg(_lineBreakRegex.Replace(svgAsText, ""));
- }
- catch (Exception e)
- {
- Logger.Warn($"Could not find file '{svgFile}' : {e.Message}");
- return EncodeSvg("");
- }
+ _errorSmall = CreateErrorImage(72, 72);
+ _errorLarge = CreateErrorImage(144, 144);
+
+ _empty = new Image(144, 144);
+ }
+
+ private Image CreateErrorImage(int width, int height)
+ {
+ var image = new Image(width, height);
+ var thickness = (float)width / 10;
+ image.Mutate(x => x
+ .DrawLine(Color.Red, thickness, new PointF(0, 0), new Point(width, height))
+ .DrawLine(Color.Red, thickness, new PointF(0, height), new Point(width, 0)));
+
+ return image;
+ }
+
+ public Image GetErrorImage(StreamDeckKeyInfo keyInfo)
+ {
+ return keyInfo.IsHighRes ? _errorLarge : _errorSmall;
}
///
- /// Prepends the mime type to a given SVG image. Stream Deck expects it like that.
+ /// Returns a static image without content. Can be used to initialize images before they are actually loaded.
///
- public string EncodeSvg(string svg)
+ /// Do not modify the returned image, because this will affect all instances.
+ /// Always a high-res image. Stream Deck can scale it for us, because there will be no scaling losses.
+ public Image GetEmptyImage()
{
- return "data:image/svg+xml;charset=utf8," + svg;
+ return _empty;
}
///
- /// Converts the given Image into the PNG format and prepends the mime type. Stream Deck expects it like that.
+ /// Loads an SVG file and coverts it into a JPEG file. Internally, we only handle Image instances, that's the
+ /// reason why we convert vector to bitmap data.
///
- private string EncodePng(Image image)
+ /// The path to the image
+ /// The vector data is scaled for the given StreamDeckKeyInfo
+ /// A bitmap image. If the SVG cannot be loaded, a static error image is returned.
+ public virtual Image FromSvgFile(string svgFileName, StreamDeckKeyInfo sdKeyInfo)
{
- using var stream = new MemoryStream();
- image.SaveAsPng(stream);
- return "data:image/png;base64," + Convert.ToBase64String(stream.ToArray());
+ try
+ {
+ using var svg = SKSvg.CreateFromFile(svgFileName);
+ var keyWidth = sdKeyInfo.KeySize.width;
+ var keyHeight = sdKeyInfo.KeySize.height;
+
+ if (svg.Model is null)
+ {
+ Logger.Warn($"Could not determine model of SVG file '{svgFileName}'. The file seems to be invalid.");
+ return GetErrorImage(sdKeyInfo);
+ }
+
+ if (svg.Picture is null)
+ {
+ Logger.Warn($"Could not determine picture of SVG file '{svgFileName}'. The file seems to be invalid.");
+ return GetErrorImage(sdKeyInfo);
+ }
+
+
+ var scaleX = keyWidth / svg.Model.CullRect.Width;
+ var scaleY = keyHeight / svg.Model.CullRect.Height;
+
+ // Stream Deck always scales to a square. So no need to keep the aspect ratio here - just scale to square.
+ //var scale = scaleX > scaleY ? scaleY : scaleX;
+
+ var bitmap = svg.Picture.ToBitmap(SKColor.Empty, scaleX, scaleY, SKColorType.Rgba8888, SKAlphaType.Premul,
+ SKColorSpace.CreateSrgb());
+ if (bitmap is null)
+ {
+ // We have a valid SVG file, but no bitmap. This means that the SVG file is empty (empty "svg" element).
+ return _empty;
+ }
+
+ var data = bitmap.Encode(SKEncodedImageFormat.Png, 90);
+ return Image.Load(data.ToArray());
+ }
+ catch (Exception e)
+ {
+ Logger.Warn(e, $"Could not read SVG file '{svgFileName}' and convert it into a bitmap");
+ return sdKeyInfo.IsHighRes ? _errorLarge : _errorSmall;
+ }
}
///
@@ -66,6 +121,6 @@ public string GenerateDialImage(string title)
};
image.Mutate(x => x.DrawText(textOptions, title, Color.White));
- return EncodePng(image);
+ return image.ToBase64String(PngFormat.Instance);
}
}
diff --git a/StreamDeckSimHub.Plugin/Tools/StreamDeckKeyInfo.cs b/StreamDeckSimHub.Plugin/Tools/StreamDeckKeyInfo.cs
new file mode 100644
index 0000000..429653a
--- /dev/null
+++ b/StreamDeckSimHub.Plugin/Tools/StreamDeckKeyInfo.cs
@@ -0,0 +1,61 @@
+// Copyright (C) 2024 Martin Renner
+// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER)
+
+using SharpDeck.Enums;
+using SharpDeck.Events.Received;
+
+namespace StreamDeckSimHub.Plugin.Tools;
+
+///
+/// Holds information about a specific Stream Deck Key or Dial.
+///
+public class StreamDeckKeyInfo(DeviceType deviceType, bool isDial, (int width, int height) keySize, bool isHighRes)
+{
+ public DeviceType DeviceType { get; set; } = deviceType;
+ public bool IsDial { get; set; } = isDial;
+ public (int width, int height) KeySize { get; set; } = keySize;
+ public bool IsHighRes { get; set; } = isHighRes;
+}
+
+///
+/// Creates instance of StreamDeckKeyInfo.
+///
+public abstract class StreamDeckKeyInfoBuilder
+{
+ private static readonly IdentifiableDeviceInfo DefaultDevice = new()
+ {
+ Id = "dummy", Name = "Stream Deck", Size = new Size() { Columns = 5, Rows = 3 }, Type = DeviceType.StreamDeck,
+ };
+
+ public static StreamDeckKeyInfo Build(RegistrationInfo registrationInfo, string deviceId, Controller controller)
+ {
+ // Actually, it should not happen that we cannot find the ID.
+ var deviceInfo = registrationInfo.Devices.FirstOrDefault(deviceInfo => deviceInfo.Id == deviceId) ?? DefaultDevice;
+
+ var isDial = controller == Controller.Encoder;
+ (int width, int height) keySize;
+ var isHighRes = false;
+ if (isDial)
+ {
+ // SD+ Slot (https://docs.elgato.com/sdk/plugins/layouts-sd+)
+ keySize = (200, 100);
+ isHighRes = true;
+ }
+ else
+ {
+ if (deviceInfo.Type == DeviceType.StreamDeckXL || deviceInfo.Type == DeviceType.StreamDeckPlus)
+ {
+ // SD XL or SD+ (https://docs.elgato.com/sdk/plugins/style-guide#sizes)
+ keySize = (144, 144);
+ isHighRes = true;
+ }
+ else
+ {
+ // SD or SD Mini (https://docs.elgato.com/sdk/plugins/style-guide#sizes)
+ keySize = (72, 72);
+ }
+ }
+
+ return new StreamDeckKeyInfo(deviceInfo?.Type ?? DefaultDevice.Type, isDial, keySize, isHighRes);
+ }
+}
diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-black.svg b/StreamDeckSimHub.Plugin/images/custom/flags/flag-black.svg
new file mode 100644
index 0000000..9bf7693
--- /dev/null
+++ b/StreamDeckSimHub.Plugin/images/custom/flags/flag-black.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-blue.svg b/StreamDeckSimHub.Plugin/images/custom/flags/flag-blue.svg
new file mode 100644
index 0000000..c4d9372
--- /dev/null
+++ b/StreamDeckSimHub.Plugin/images/custom/flags/flag-blue.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-checkered.svg b/StreamDeckSimHub.Plugin/images/custom/flags/flag-checkered.svg
new file mode 100644
index 0000000..35c29fe
--- /dev/null
+++ b/StreamDeckSimHub.Plugin/images/custom/flags/flag-checkered.svg
@@ -0,0 +1,45 @@
+
+
diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-green.svg b/StreamDeckSimHub.Plugin/images/custom/flags/flag-green.svg
new file mode 100644
index 0000000..a86afec
--- /dev/null
+++ b/StreamDeckSimHub.Plugin/images/custom/flags/flag-green.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-none.svg b/StreamDeckSimHub.Plugin/images/custom/flags/flag-none.svg
new file mode 100644
index 0000000..aa820ec
--- /dev/null
+++ b/StreamDeckSimHub.Plugin/images/custom/flags/flag-none.svg
@@ -0,0 +1,3 @@
+
+
diff --git a/StreamDeckSimHub.Plugin/images/icons/flag-orange.svg b/StreamDeckSimHub.Plugin/images/custom/flags/flag-orange.svg
similarity index 52%
rename from StreamDeckSimHub.Plugin/images/icons/flag-orange.svg
rename to StreamDeckSimHub.Plugin/images/custom/flags/flag-orange.svg
index 8652655..3b722e6 100644
--- a/StreamDeckSimHub.Plugin/images/icons/flag-orange.svg
+++ b/StreamDeckSimHub.Plugin/images/custom/flags/flag-orange.svg
@@ -1,4 +1,7 @@
-