using System.Diagnostics; using System.Drawing; using System.Drawing.Imaging; using Blake3; using Dalamud.Game.ClientState.GamePad; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.IoC; using Dalamud.Plugin; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.UI; using Newtonsoft.Json; using Screenie.Ui; using WebP.Net; namespace Screenie; public sealed class Plugin : IDalamudPlugin { internal const string Name = "Screenie"; [PluginService] #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. internal static IPluginLog Log { get; private set; } #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. [PluginService] internal DalamudPluginInterface Interface { get; init; } [PluginService] internal IChatGui ChatGui { get; init; } [PluginService] internal IClientState ClientState { get; init; } [PluginService] internal ICommandManager CommandManager { get; init; } [PluginService] internal IDataManager DataManager { get; init; } [PluginService] internal IFramework Framework { get; init; } [PluginService] internal IGameGui GameGui { get; init; } [PluginService] internal IGameInteropProvider GameInteropProvider { get; init; } [PluginService] internal IGamepadState GamepadState { get; init; } [PluginService] internal IObjectTable ObjectTable { get; init; } internal Configuration Config { get; } internal GameFunctions GameFunctions { get; } internal NoUi NoUi { get; } internal PenumbraIpc Penumbra { get; } internal Database Database { get; } internal LinkHandlers LinkHandlers { get; } internal PluginUi Ui { get; } private Command Command { get; } #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public Plugin() { this.Config = this.Interface!.GetPluginConfig() as Configuration ?? new Configuration(); if (this.Config.KeyboardKeybind.Count == 0) { this.Config.KeyboardKeybind.Add(SeVirtualKey.PRINT); } this.GameFunctions = new GameFunctions(this); this.NoUi = new NoUi(this); this.Penumbra = new PenumbraIpc(this); this.Database = new Database(this); this.LinkHandlers = new LinkHandlers(this); this.Ui = new PluginUi(this); this.Command = new Command(this); this.Framework!.Update += this.FrameworkUpdate; } #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. public void Dispose() { this.Framework!.Update -= this.FrameworkUpdate; this.Command.Dispose(); this.Ui.Dispose(); this.LinkHandlers.Dispose(); this.Database.Dispose(); // this.Penumbra.Dispose(); this.NoUi.Dispose(); this.GameFunctions.Dispose(); } internal void SaveConfig() { this.Interface.SavePluginConfig(this.Config); } internal readonly Stopwatch GamepadKeybindTimer = new(); internal readonly Stopwatch KeyboardKeybindTimer = new(); private GamepadButtons _buttons; private readonly HashSet _kb = []; private bool _kbPressAck; private bool _gpPressAck; private void FrameworkUpdate(IFramework framework) { var gamepad = this.IsGamepadPressed(); var keyboard = this.IsKeyboardPressed(); if (gamepad || keyboard) { this.SaveScreenshot(); } } private unsafe bool IsKeyboardPressed() { var input = UIInputData.Instance(); if (this.KeyboardKeybindTimer.IsRunning) { if (this.KeyboardKeybindTimer.Elapsed > TimeSpan.FromSeconds(5)) { this._kb.Clear(); this.KeyboardKeybindTimer.Reset(); return false; } var anyDown = false; foreach (var button in Enum.GetValues()) { if (input->IsKeyDown(button)) { anyDown = true; this._kb.Add(button); } } if (!anyDown && this._kb.Count > 0) { this.Config.KeyboardKeybind = [.. this._kb.OrderBy(k => k)]; this.SaveConfig(); this._kb.Clear(); this.KeyboardKeybindTimer.Reset(); return false; } return false; } if (!this.Config.KeyboardKeybind.All(input->IsKeyDown)) { this._kbPressAck = false; return false; } if (this._kbPressAck) { return false; } Log.Info("pressed"); this._kbPressAck = true; return true; } private unsafe bool IsGamepadPressed() { var gamepadInput = (GamepadInput*) this.GamepadState.GamepadInputAddress; if (this.GamepadKeybindTimer.IsRunning) { if (this.GamepadKeybindTimer.Elapsed > TimeSpan.FromSeconds(5)) { this._buttons = 0; this.GamepadKeybindTimer.Reset(); return false; } this._buttons |= (GamepadButtons) gamepadInput->ButtonsRaw; if (gamepadInput->ButtonsRaw == 0 && this._buttons != 0) { this.Config.GamepadKeybind = this._buttons; this.SaveConfig(); this._buttons = 0; this.GamepadKeybindTimer.Reset(); return false; } return false; } if (((GamepadButtons) gamepadInput->ButtonsRaw & this.Config.GamepadKeybind) != this.Config.GamepadKeybind) { this._gpPressAck = false; return false; } if (this._gpPressAck) { return false; } this._gpPressAck = true; return true; } internal void SaveScreenshot() { var meta = ScreenshotMetadata.Capture(this); var bitmapOuter = Photographer.Capture(); if (bitmapOuter == null) { return; } Task.Factory.StartNew(async () => { using var bitmap = bitmapOuter; var saveAs = this.Config.SaveFormat; byte[] imageData; string ext; if (saveAs.ToImageFormat() is { } format) { var data = this.SaveNative(format, bitmap); if (data == null) { return; } (imageData, ext) = data.Value; } else if (saveAs is Format.WebpLossless or Format.WebpLossy) { using var webp = new WebPObject(bitmap); imageData = saveAs == Format.WebpLossless ? webp.GetWebPLossless() : webp.GetWebPLossy(this.Config.SaveFormatData); ext = "webp"; } else { return; } // TODO: use TagLib-Sharp to embed metadata into the image string hash; var (inner, path) = this.OpenFile(ext, meta); await using (var stream = new Blake3Stream(inner)) { await stream.WriteAsync(imageData); hash = Convert.ToHexString(stream.ComputeHash().AsSpan()); } var saved = new SavedMetadata { Path = path, Blake3Hash = hash, Metadata = meta, }; this.Database.Execute( """ insert into screenshots (hash, path, active_character, location, location_sub, area, area_sub, territory_type, world, world_id, captured_at_local, captured_at_utc, eorzea_time, weather, ward, plot, visible_characters, mods_in_use) values ($hash, $path, json($active_character), $location, $location_sub, $area, $area_sub, $territory_type, $world, $world_id, $captured_at_local, $captured_at_utc, $eorzea_time, $weather, $ward, $plot, json($visible_characters), json($mods_in_use)) """, new Dictionary { ["$hash"] = saved.Blake3Hash, ["$path"] = saved.Path, ["$active_character"] = JsonConvert.SerializeObject(saved.Metadata.ActiveCharacter), ["$location"] = saved.Metadata.Location, ["$location_sub"] = saved.Metadata.LocationSub, ["$area"] = saved.Metadata.Area, ["$area_sub"] = saved.Metadata.AreaSub, ["$territory_type"] = saved.Metadata.TerritoryType, ["$world"] = saved.Metadata.World, ["$world_id"] = saved.Metadata.WorldId, ["$captured_at_local"] = saved.Metadata.CapturedAtLocal, ["$captured_at_utc"] = saved.Metadata.CapturedAtUtc, ["$eorzea_time"] = saved.Metadata.EorzeaTime, ["$weather"] = saved.Metadata.Weather, ["$ward"] = saved.Metadata.Ward, ["$plot"] = saved.Metadata.Plot, ["$visible_characters"] = JsonConvert.SerializeObject(saved.Metadata.VisibleCharacters), ["$mods_in_use"] = JsonConvert.SerializeObject(saved.Metadata.ModsInUse), } ); var message = new SeStringBuilder() .AddText("Screenshot saved. [") .AddUiForeground(12) .Add(this.LinkHandlers.OpenFolder) .AddText("Open folder") .Add(RawPayload.LinkTerminator) .AddUiForegroundOff() .AddText("]") .Build(); this.ChatGui.Print(message); }); } private static ImageCodecInfo? GetEncoder(ImageFormat format) { var codecs = ImageCodecInfo.GetImageEncoders(); return codecs.FirstOrDefault(codec => codec.FormatID == format.Guid); } private (byte[], string)? SaveNative(ImageFormat format, Image bitmap) { var encoder = GetEncoder(format); if (encoder == null) { return null; } // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault var (param, ext) = this.Config.SaveFormat switch { Format.Jpg => (Encoder.Quality, "jpg"), Format.Png => (Encoder.Compression, "png"), _ => throw new ArgumentException("not a native-save format", nameof(format)), }; var parameters = new EncoderParameters(1) { Param = [ new EncoderParameter(param, this.Config.SaveFormatData), ], }; using var stream = new MemoryStream(); bitmap.Save(stream, encoder, parameters); return (stream.ToArray(), ext); } private (FileStream, string) OpenFile(string ext, ScreenshotMetadata meta) { Directory.CreateDirectory(this.Config.SaveDirectory); var fileName = this.Config.SaveFileNameTemplate.Render(meta).ReplaceLineEndings(" "); var path = Path.Join(this.Config.SaveDirectory, fileName); path += $".{ext}"; path = Path.GetFullPath(path); var parent = Path.Join(path, ".."); Directory.CreateDirectory(parent); return (new FileStream(path, FileMode.Create, FileAccess.Write), path); } }