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 @@ - + + + diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-white.svg b/StreamDeckSimHub.Plugin/images/custom/flags/flag-white.svg new file mode 100644 index 0000000..51fb8cd --- /dev/null +++ b/StreamDeckSimHub.Plugin/images/custom/flags/flag-white.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s1.png b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s1.png new file mode 100644 index 0000000..b6b9fd8 Binary files /dev/null and b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s1.png differ diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s1@2x.png b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s1@2x.png new file mode 100644 index 0000000..f0f0999 Binary files /dev/null and b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s1@2x.png differ diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s2.png b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s2.png new file mode 100644 index 0000000..918ba21 Binary files /dev/null and b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s2.png differ diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s2@2x.png b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s2@2x.png new file mode 100644 index 0000000..5c0d97d Binary files /dev/null and b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s2@2x.png differ diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s3.png b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s3.png new file mode 100644 index 0000000..986dd8b Binary files /dev/null and b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s3.png differ diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s3@2x.png b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s3@2x.png new file mode 100644 index 0000000..9374ffb Binary files /dev/null and b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow-s3@2x.png differ diff --git a/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow.svg b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow.svg new file mode 100644 index 0000000..0a6cc40 --- /dev/null +++ b/StreamDeckSimHub.Plugin/images/custom/flags/flag-yellow.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/StreamDeckSimHub.Plugin/images/icons/flag-black.svg b/StreamDeckSimHub.Plugin/images/icons/flag-black.svg deleted file mode 100644 index f5dc890..0000000 --- a/StreamDeckSimHub.Plugin/images/icons/flag-black.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/StreamDeckSimHub.Plugin/images/icons/flag-blue.svg b/StreamDeckSimHub.Plugin/images/icons/flag-blue.svg deleted file mode 100644 index 449dedd..0000000 --- a/StreamDeckSimHub.Plugin/images/icons/flag-blue.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/StreamDeckSimHub.Plugin/images/icons/flag-checkered.svg b/StreamDeckSimHub.Plugin/images/icons/flag-checkered.svg index afac0c7..97bee1c 100644 --- a/StreamDeckSimHub.Plugin/images/icons/flag-checkered.svg +++ b/StreamDeckSimHub.Plugin/images/icons/flag-checkered.svg @@ -1,4 +1,6 @@ - + + diff --git a/StreamDeckSimHub.Plugin/images/icons/flag-green.svg b/StreamDeckSimHub.Plugin/images/icons/flag-green.svg deleted file mode 100644 index 50b6ff7..0000000 --- a/StreamDeckSimHub.Plugin/images/icons/flag-green.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/StreamDeckSimHub.Plugin/images/icons/flag-white.svg b/StreamDeckSimHub.Plugin/images/icons/flag-white.svg deleted file mode 100644 index 26c008c..0000000 --- a/StreamDeckSimHub.Plugin/images/icons/flag-white.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/StreamDeckSimHub.Plugin/images/icons/flag-yellow.svg b/StreamDeckSimHub.Plugin/images/icons/flag-yellow.svg deleted file mode 100644 index 1b0c253..0000000 --- a/StreamDeckSimHub.Plugin/images/icons/flag-yellow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/StreamDeckSimHub.Plugin/images/icons/undefined.svg b/StreamDeckSimHub.Plugin/images/icons/undefined.svg new file mode 100644 index 0000000..7704cbd --- /dev/null +++ b/StreamDeckSimHub.Plugin/images/icons/undefined.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/StreamDeckSimHub.Plugin/manifest.json b/StreamDeckSimHub.Plugin/manifest.json index a87ea9a..90b432f 100644 --- a/StreamDeckSimHub.Plugin/manifest.json +++ b/StreamDeckSimHub.Plugin/manifest.json @@ -20,6 +20,7 @@ { "Icon": "images/actions/flag", "Name": "Flags", + "PropertyInspectorPath": "pi/flags.html", "States": [ { "Image": "images/icons/flag-checkered", @@ -28,7 +29,6 @@ ], "Tooltip": "Flags", "SupportedInMultiActions": false, - "UserTitleEnabled": false, "UUID": "net.planetrenner.simhub.flags" }, { diff --git a/StreamDeckSimHub.Plugin/pi/css/pi.css b/StreamDeckSimHub.Plugin/pi/css/pi.css index 074683c..0ffbf4b 100644 --- a/StreamDeckSimHub.Plugin/pi/css/pi.css +++ b/StreamDeckSimHub.Plugin/pi/css/pi.css @@ -19,3 +19,7 @@ /* Reset min-width which was set from sdpi.css "input[type="checkbox"]+label" */ margin-right: auto; } + +.sdpi-item>img { + align-self: center; +} diff --git a/StreamDeckSimHub.Plugin/pi/flags.html b/StreamDeckSimHub.Plugin/pi/flags.html new file mode 100644 index 0000000..b4503c2 --- /dev/null +++ b/StreamDeckSimHub.Plugin/pi/flags.html @@ -0,0 +1,76 @@ + + + + + + + + + net.planetrenner.simhub.flagsPI + + + + + + +
+ +
Flags
+ +
+
No Flag
+ +
+
+
Black Flag
+ +
+
+
Blue Flag
+ +
+
+
Checkered Flag
+ +
+
+
Green Flag
+ +
+
+
Orange Flag
+ +
+
+
White Flag
+ +
+
+
Yellow Flag
+ +
+
+
Yellow Sec 1
+ +
+
+
Yellow Sec 2
+ +
+
+
Yellow Sec 3
+ +
+
+ + + + + + + + + + + diff --git a/StreamDeckSimHub.Plugin/pi/js/common.js b/StreamDeckSimHub.Plugin/pi/js/common.js index a2521d6..70d476f 100644 --- a/StreamDeckSimHub.Plugin/pi/js/common.js +++ b/StreamDeckSimHub.Plugin/pi/js/common.js @@ -40,3 +40,34 @@ const restoreSettings = (settings) => { } } } + +/** + * Iterates over "localSettings". The keys are used to find the corresponding HTML element in the DOM, the value of + * the element is then set into "localSettings". + */ +const domToLocalSettings = (localSettings) => { + for (const id in localSettings) { + const element = document.getElementById(id); + if (!element) { + console.log('domToLocalSettings: Could not find element ' + id + ' on page'); + $PI.logMessage('domToLocalSettings: Could not find element ' + id); + continue; + } + if (element.getAttribute('type') === 'checkbox') { + localSettings[id] = element.checked; + } else { + localSettings[id] = element.value; + } + } +} + +/** + * Creates a new "option" element in a given "select" element. + */ +const addSelectOption = (selectElement, optionName) => { + const optionElement = document.createElement('option'); + optionElement.value = optionName; + optionElement.text = optionName; + selectElement.add(optionElement); +} + diff --git a/StreamDeckSimHub.Plugin/pi/js/flags.js b/StreamDeckSimHub.Plugin/pi/js/flags.js new file mode 100644 index 0000000..9ce5f75 --- /dev/null +++ b/StreamDeckSimHub.Plugin/pi/js/flags.js @@ -0,0 +1,87 @@ +let localSettings = { + noFlag: 'flags/flag-none.svg', + blackFlag: 'flags/flag-black.svg', + blueFlag: 'flags/flag-blue.svg', + checkeredFlag: 'flags/flag-checkered.svg', + greenFlag: 'flags/flag-green.svg', + orangeFlag: 'flags/flag-orange.svg', + whiteFlag: 'flags/flag-white.svg', + yellowFlag: 'flags/flag-yellow.svg', + yellowFlagSec1: 'flags/flag-yellow-s1.png', + yellowFlagSec2: 'flags/flag-yellow-s2.png', + yellowFlagSec3: 'flags/flag-yellow-s3.png', +} + +$PI.onConnected(async jsn => { + loadSettings(jsn.actionInfo.payload.settings); + + $PI.onSendToPropertyInspector(jsn.actionInfo.action, jsn => { + if (jsn.payload?.message === 'customImages') { + fillImageSelectBoxes(jsn.payload.images); + } + }); +}); + +const loadSettings = (settings) => { + // As we do not yet have the list of available images, we cannot write the settings directly into the DOM - the selected + // image is not yet in the DOM tree. Therefore, we store the settings locally. + Object.keys(settings).forEach((key) => { + if (key in settings) { + localSettings[key] = settings[key]; + } + }); +} + +const saveSettings = () => { + domToLocalSettings(localSettings); + $PI.setSettings(localSettings); + + Object.keys(localSettings).forEach(key => { + updatePreviewImage(key, localSettings[key]); + }); +} + +const fillImageSelectBoxes = (images) => { + console.log('Received images from backend', images); + fillImageSelectBox('noFlag', images); + fillImageSelectBox('blackFlag', images); + fillImageSelectBox('blueFlag', images); + fillImageSelectBox('checkeredFlag', images); + fillImageSelectBox('greenFlag', images); + fillImageSelectBox('orangeFlag', images); + fillImageSelectBox('whiteFlag', images); + fillImageSelectBox('yellowFlag', images); + fillImageSelectBox('yellowFlagSec1', images); + fillImageSelectBox('yellowFlagSec2', images); + fillImageSelectBox('yellowFlagSec3', images); +} + +const fillImageSelectBox = (id, images) => { + const selected = localSettings[id]; + const selectElement = document.getElementById(id); + images.forEach(image => { + addSelectOption(selectElement, image); + }); + + selectElement.value = selected; + if (!selectElement.value) { + // This means that the "selected" image is not in the list "images". + setErrorPreviewImage(id); + } else { + updatePreviewImage(id, selected); + } +} + +const setErrorPreviewImage = (id) => { + const previewElement = document.getElementById('preview' + id.charAt(0).toUpperCase() + id.substring(1)); + if (previewElement) { + previewElement.src = '../images/icons/undefined.svg'; + } +} + +const updatePreviewImage = (id, selected) => { + const previewElement = document.getElementById('preview' + id.charAt(0).toUpperCase() + id.substring(1)); + if (previewElement) { + previewElement.src = '../images/custom/' + selected; + } +} diff --git a/StreamDeckSimHub.Plugin/pi/js/simhubrole.js b/StreamDeckSimHub.Plugin/pi/js/simhubrole.js index de82600..97521dc 100644 --- a/StreamDeckSimHub.Plugin/pi/js/simhubrole.js +++ b/StreamDeckSimHub.Plugin/pi/js/simhubrole.js @@ -1,31 +1,25 @@ - +// requires "common.js" + class SimHubRole { static placeholder = '----'; // use same default value as wotever - addRoleName = (selectElement, roleName) => { - const option = document.createElement('option'); - option.value = roleName; - option.text = roleName; - selectElement.add(option); - } - updateSimHubRoles = (elementId, roleList, currentRole) => { const selectElement = document.getElementById(elementId); let seenCurrentRole = false; selectElement.innerHTML = ''; - this.addRoleName(selectElement, SimHubRole.placeholder); + addSelectOption(selectElement, SimHubRole.placeholder); seenCurrentRole = SimHubRole.placeholder === currentRole; roleList.forEach(roleName => { - this.addRoleName(selectElement, roleName); + addSelectOption(selectElement, roleName); if (roleName === currentRole) { seenCurrentRole = true; } }); if (!seenCurrentRole && currentRole) { - this.addRoleName(selectElement, currentRole); + addSelectOption(selectElement, currentRole); } selectElement.value = currentRole ? currentRole : SimHubRole.placeholder; } diff --git a/StreamDeckSimHub.PluginTests/StreamDeckSimHub.PluginTests.csproj b/StreamDeckSimHub.PluginTests/StreamDeckSimHub.PluginTests.csproj index 2db8cff..e7c23bb 100644 --- a/StreamDeckSimHub.PluginTests/StreamDeckSimHub.PluginTests.csproj +++ b/StreamDeckSimHub.PluginTests/StreamDeckSimHub.PluginTests.csproj @@ -15,6 +15,7 @@ + ..\lib\SharpDeck.dll diff --git a/StreamDeckSimHub.PluginTests/Tools/ImageManagerTests.cs b/StreamDeckSimHub.PluginTests/Tools/ImageManagerTests.cs new file mode 100644 index 0000000..35bc7ea --- /dev/null +++ b/StreamDeckSimHub.PluginTests/Tools/ImageManagerTests.cs @@ -0,0 +1,98 @@ +// Copyright (C) 2024 Martin Renner +// LGPL-3.0-or-later (see file COPYING and COPYING.LESSER) + +using System.IO.Abstractions.TestingHelpers; +using SharpDeck.Enums; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using StreamDeckSimHub.Plugin.Tools; + +namespace StreamDeckSimHub.PluginTests.Tools; + +public class ImageManagerTests +{ + [Test] + public void TestListCustomImages() + { + var imageUtils = Mock.Of(); + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Combine("images", "custom", "test.svg"), new MockFileData(string.Empty) }, + { Path.Combine("images", "custom", "subDir", "testSub.svg"), new MockFileData(string.Empty) }, + { Path.Combine("images", "custom", "image1.png"), new MockFileData(string.Empty) }, + { Path.Combine("images", "custom", "image1@2x.png"), new MockFileData(string.Empty) }, + { Path.Combine("images", "custom", "img@2x.png"), new MockFileData(string.Empty) }, + }); + + var imageManager = new ImageManager(fileSystem, imageUtils); + var customImages = imageManager.ListCustomImages(); + + Assert.That(customImages.Count, Is.EqualTo(4)); + Assert.That(customImages[0], Is.EqualTo("image1.png")); + Assert.That(customImages[1], Is.EqualTo("img.png")); + Assert.That(customImages[2], Is.EqualTo("test.svg")); + Assert.That(customImages[3], Is.EqualTo("subDir/testSub.svg")); + } + + [Test] + public void TestGetCustomImageSvg() + { + var imageUtils = new Mock(); + imageUtils + .Setup(iu => iu.FromSvgFile(It.IsAny(), It.IsAny())) + .Returns((string _, StreamDeckKeyInfo sdki) => new Image(sdki.KeySize.width, sdki.KeySize.height)); + var fileSystem = new MockFileSystem(new Dictionary + { + { @"images\custom\sub\test@2x.svg", new MockFileData(string.Empty) }, + { @"images\custom\sub\test.svg", new MockFileData(string.Empty) }, + }); + + var imageManager = new ImageManager(fileSystem, imageUtils.Object); + var sdXl = new StreamDeckKeyInfo(DeviceType.StreamDeckXL, false, (144, 144), true); + using var image = imageManager.GetCustomImage("sub/test.svg", sdXl); + + // ImageUtils must have been called - especially not with "@2x" even for the Hires XL, because SVGs have no suffix. + var customImages = fileSystem.DirectoryInfo.New(Path.Combine("images", "custom")).FullName; + imageUtils.Verify(iu => iu.FromSvgFile(Path.Combine(customImages, "sub", "test.svg"), sdXl)); + Assert.That(image, Is.Not.Null); + } + + [Test] + public void TestGetCustomImageBitmap() + { + var imageUtils = new Mock(); + var fileSystem = new MockFileSystem(new Dictionary + { + { @"images\custom\sub\test@2x.png", new MockFileData(CreateTestImage(144, 144)) }, + { @"images\custom\sub\test.png", new MockFileData(CreateTestImage(72, 72)) }, + }); + + var imageManager = new ImageManager(fileSystem, imageUtils.Object); + + // Test with SD + var sd = new StreamDeckKeyInfo(DeviceType.StreamDeck, false, (72, 72), false); + using var image = imageManager.GetCustomImage("sub/test.png", sd); + + // Get lo-res image for SD + Assert.That(image, Is.Not.Null); + Assert.That(image.Width, Is.EqualTo(72)); + Assert.That(image.Height, Is.EqualTo(72)); + + // Test with SD XL + var sdXl = new StreamDeckKeyInfo(DeviceType.StreamDeckXL, false, (144, 144), true); + using var imageHires = imageManager.GetCustomImage("sub/test.png", sdXl); + + // Get hi-res image for SD XL + Assert.That(imageHires, Is.Not.Null); + Assert.That(imageHires.Width, Is.EqualTo(144)); + Assert.That(imageHires.Height, Is.EqualTo(144)); + } + + private byte[] CreateTestImage(int width, int height) + { + using var testImage = new Image(width, height); + using var testImageStream = new MemoryStream(); + testImage.SaveAsPng(testImageStream); + return testImageStream.ToArray(); + } +} \ No newline at end of file diff --git a/doc/flags/Flags.adoc b/doc/flags/Flags.adoc index ced4938..ce83fdb 100644 --- a/doc/flags/Flags.adoc +++ b/doc/flags/Flags.adoc @@ -3,9 +3,11 @@ :sectnums: ifdef::env-github[] :tip-caption: :bulb: +:warning-caption: :warning: endif::[] ifndef::env-github[] :tip-caption: 💡 +:warning-caption: ⚠️ endif::[] TIP: Always read the *correct version* of the documentation, which matches the version of the plugin that you have installed. To do so, use the dropdown in the top left, which usually contains the value "main". Select the "tag" that matches your installed version. @@ -13,17 +15,56 @@ TIP: Always read the *correct version* of the documentation, which matches the v == Description -This is a special button that displays the race flags. It does not have any configuration or interaction options. Therefore, it is enough to simply drag it to a free slot. +This is a special button that displays the race flags: -When no race is active, it displays the finish flag: +image::Flags.png[Flags] + +All flags can be customized by selecting different images. + + +== Custom Images + +The plugin lists all images which are in the following directory and its subdirectories: + +---- +%appdata%\Elgato\StreamDeck\Plugins\net.planetrenner.simhub.sdPlugin\images\custom +---- + +This string can be copied like it is into the address bar of the File Explorer, which will open the directory `C:\Users\yourname\AppData\Roaming\...`. + +Custom images can be copied into this directory or a subdirectory. The folder `images\custom` will not be deleted during upgrades of the plugin. + +WARNING: Images that were originally delivered by the plugin will be overwritten during the update! It is therefore advisable to create a folder “my-flags” (or similar) for your own flags. Do not modify existing images or your changes will be lost after an upgrade. + +The plugin supports the following file formats: + +* SVG +* PNG +* JPEG +* GIF + +SVG is the format recommended by Elgato, because it is a vector format and can therefore be scaled without loss. If SVG files are not available (or the library used by the plugin is not able to decode the SVG image), one of the bitmap formats has to be used, preferably PNG. + +Bitmap formats follow the same standard as Elgato: `myimage.png` should have a resolution of 72 x 72 pixels and will be used for the devices with standard resolution (Stream Deck, Stream Deck Mini). `myimage@2x.png` should have a resolution of 144 x 144 pixels and will be used for the high resolution devices (Stream Deck XL, Stream Deck +). If no `@2x` image is available for a high resolution device, the regular will be used as fallback. + +The images should never be larger than 144 x 144 pixels, because the plugin keeps the images in memory. Larger images cost unnecessary memory and performance. + +The plugin only ever lists the base name of the image, and it will choose the correct resolution at runtime. So even if there are `myimage.png` and `myimage@2x.png` in the folder, the plugin will only list `myimage.png`, but use `myimage@2x.png`, if the device is a high-res device. + +Unfortunately, the Stream Deck SDK does not support animated GIF images - only static GIF images. + + +== Behavior + +When no race is active, the plugin displays the "Checkered Flag": image::Flag-Finish.png[Finish Flag] -If a race is active, it shows a blank screen if no flag is active in the game: +When a race is active, but no flags are shown, it shows the "No Flag" image: image::Flag-Empty.png[Empty] -Otherwise it shows the currently active flag, like the green or the blue flag: +Otherwise, it shows the currently active flag, like the green or the blue flag: image::Flag-Green.png[Green Flag] image::Flag-Blue.png[Blue Flag] @@ -34,11 +75,21 @@ The button currently supports the following flags (depending on the simulation): |=== | Flag | Summary -| Black | Disqualification -| Blue | Faster vehicles approaching -| Checkered | End of Session -| Green | Start of race/End of Caution/Pit Lane open -| Orange | Mechanical problem -| White | Slow moving vehicle ahead -| Yellow | Caution +| Black | Disqualification +| Blue | Faster vehicles approaching +| Checkered | End of Session +| Green | Start of race/End of Caution/Pit Lane open +| Orange | Mechanical problem +| White | Slow moving vehicle ahead +| Yellow | Caution +| Yellow Sector 1 | Caution in sector 1 +| Yellow Sector 2 | Caution in sector 2 +| Yellow Sector 3 | Caution in sector 3 |=== + + +== Sector Flags + +If the game supports yellow flags for sectors (ACC does), the plugin will derive an image from "Yellow Sec 1", "Yellow Sec 2" and "Yellow Sec 3". For example, if yellow flags are active in sector 1 and 3, the plugin will combine the images "Sec 1" and "Sec 3" into a new image, which will then be displayed on the button. + +It must therefore be ensured that these three images can be stacked visually on top of each other. diff --git a/doc/flags/Flags.png b/doc/flags/Flags.png new file mode 100644 index 0000000..42b0a6a Binary files /dev/null and b/doc/flags/Flags.png differ diff --git a/images/flag-yellow-s1.svg b/images/flag-yellow-s1.svg new file mode 100644 index 0000000..2be8f20 --- /dev/null +++ b/images/flag-yellow-s1.svg @@ -0,0 +1,65 @@ + + + + + + + + 1 + diff --git a/images/flag-yellow-s2.svg b/images/flag-yellow-s2.svg new file mode 100644 index 0000000..11ce616 --- /dev/null +++ b/images/flag-yellow-s2.svg @@ -0,0 +1,62 @@ + + + + + + + + 2 + diff --git a/images/flag-yellow-s3.svg b/images/flag-yellow-s3.svg new file mode 100644 index 0000000..19e1d2c --- /dev/null +++ b/images/flag-yellow-s3.svg @@ -0,0 +1,62 @@ + + + + + + + + 3 + diff --git a/images/flag-yellow.md b/images/flag-yellow.md new file mode 100644 index 0000000..313cbd7 --- /dev/null +++ b/images/flag-yellow.md @@ -0,0 +1,16 @@ +# Creation of icons + +Svg.Skia has two problems with the text: + +- The text is not aligned correctly (baseline) +- The embedded font is ignored + +Thus, we use inkscape to create PNG files: + + inkscape -w 72 -h 72 images\flag-yellow-s1.svg -o StreamDeckSimHub.Plugin\images\custom\flags\flag-yellow-s1.png + inkscape -w 72 -h 72 images\flag-yellow-s2.svg -o StreamDeckSimHub.Plugin\images\custom\flags\flag-yellow-s2.png + inkscape -w 72 -h 72 images\flag-yellow-s3.svg -o StreamDeckSimHub.Plugin\images\custom\flags\flag-yellow-s3.png + + inkscape -w 144 -h 144 images\flag-yellow-s1.svg -o StreamDeckSimHub.Plugin\images\custom\flags\flag-yellow-s1@2x.png + inkscape -w 144 -h 144 images\flag-yellow-s2.svg -o StreamDeckSimHub.Plugin\images\custom\flags\flag-yellow-s2@2x.png + inkscape -w 144 -h 144 images\flag-yellow-s3.svg -o StreamDeckSimHub.Plugin\images\custom\flags\flag-yellow-s3@2x.png diff --git a/lib/SharpDeck.dll b/lib/SharpDeck.dll index ab237be..ec841df 100644 Binary files a/lib/SharpDeck.dll and b/lib/SharpDeck.dll differ