Screenie/Plugin.cs

327 lines
11 KiB
C#

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<SeVirtualKey> _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<SeVirtualKey>()) {
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<string, object?> {
["$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);
}
}