From 1e4b427254dd0ccc3240765cfea27e01a62e63fc Mon Sep 17 00:00:00 2001 From: Natsumi <11171153+Natsumi-sama@users.noreply.github.com> Date: Sun, 19 Feb 2023 10:14:29 +1300 Subject: [PATCH] Screenshot metadata dialog (#490) * docs: Add json schema for screenshot metadata * feat(.NET): Add function to open a file and read screenshot metadata to AppApi * refactor(.NET): Check initial file dialog directory before set Paranoia is fun * refactor(.NET): Stop reading entire png files into memory to check 8 bytes * refactor(.NET): Handle endianness and keyword encoding correctly for screenshots * docs: Add xmldocs and some comments to parts of the screenshot helper * screenshot metadata dialog * screenshot metadata dialog 1 * fix: file dialog open bool not resetting properly * screenshot metadata dialog 2 * fix: Stop png parser from dying on bad files Parser would keep reading past a file's IEND chunk, and it finally found one that had junk data past IEND. It died. This fixes that. It also encapsulates the Read function in a try/catch so VRCX doesn't crash if it fails due to a corrupted file. * fix: buggy carousel transition animation --------- Co-authored-by: Teacup --- AppApi.cs | 118 ++++++++++ ScreenshotHelper.cs | 312 +++++++++++++++++++++++--- docs/screenshotMetadata-schema.json | 72 ++++++ html/src/app.js | 52 ++++- html/src/index.pug | 35 +++ html/src/localization/strings/en.json | 5 + 6 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 docs/screenshotMetadata-schema.json diff --git a/AppApi.cs b/AppApi.cs index bef767b1..8be9b02a 100644 --- a/AppApi.cs +++ b/AppApi.cs @@ -32,6 +32,7 @@ namespace VRCX public static readonly AppApi Instance; private static readonly MD5 _hasher = MD5.Create(); + private static bool dialogOpen; static AppApi() { @@ -644,6 +645,123 @@ namespace VRCX ScreenshotHelper.WritePNGDescription(path, metadataString); } + // Create a function that opens a file dialog so a user can choose a .png file. Print the name of the file after it is chosen + public void OpenScreenshotFileDialog() + { + if (dialogOpen) return; + dialogOpen = true; + + var thread = new Thread(() => + { + using (var openFileDialog = new OpenFileDialog()) + { + openFileDialog.DefaultExt = ".png"; + openFileDialog.Filter = "PNG Files (*.png)|*.png"; + openFileDialog.FilterIndex = 1; + openFileDialog.RestoreDirectory = true; + + var initialPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "VRChat"); + if (Directory.Exists(initialPath)) + { + openFileDialog.InitialDirectory = initialPath; + } + + if (openFileDialog.ShowDialog() != DialogResult.OK) + { + dialogOpen = false; + return; + } + + dialogOpen = false; + + var path = openFileDialog.FileName; + if (string.IsNullOrEmpty(path)) + return; + + GetScreenshotMetadata(path); + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + + public void GetScreenshotMetadata(string path) + { + var fileName = Path.GetFileNameWithoutExtension(path); + + const string fileNamePrefix = "VRChat_"; + var metadata = new JObject(); + + if (File.Exists(path) && path.EndsWith(".png") && fileName.StartsWith(fileNamePrefix)) + { + string metadataString = null; + bool readPNGFailed = false; + + try + { + metadataString = ScreenshotHelper.ReadPNGDescription(path); + } + catch (Exception ex) + { + metadata.Add("error", $"VRCX encountered an error while trying to parse this file. The file might be an invalid/corrupted PNG file.\n({ex.Message})"); + readPNGFailed = true; + } + + if (!string.IsNullOrEmpty(metadataString)) + { + if (metadataString.StartsWith("lfs") || metadataString.StartsWith("screenshotmanager")) + { + try + { + metadata = ScreenshotHelper.ParseLfsPicture(metadataString); + } + catch (Exception ex) + { + metadata.Add("error", $"This file contains invalid LFS/SSM metadata unable to be parsed by VRCX. \n({ex.Message})\n Text: {metadataString}"); + } + } + else + { + try + { + metadata = JObject.Parse(metadataString); + } + catch (JsonReaderException ex) + { + metadata.Add("error", $"This file contains invalid metadata unable to be parsed by VRCX. \n({ex.Message})\n Text: {metadataString}"); + } + } + } + else + { + if (!readPNGFailed) + metadata.Add("error", "No metadata found in this file."); + } + } + else + { + metadata.Add("error", "Invalid file selected. Please select a valid VRChat screenshot."); + } + + var files = Directory.GetFiles(Path.GetDirectoryName(path), "*.png"); + var index = Array.IndexOf(files, path); + if (index > 0) + { + metadata.Add("previousFilePath", files[index - 1]); + } + + if (index < files.Length - 1) + { + metadata.Add("nextFilePath", files[index + 1]); + } + + metadata.Add("fileName", fileName); + metadata.Add("filePath", path); + metadata.Add("fileSize", $"{(new FileInfo(path).Length / 1024f / 1024f).ToString("0.00")} MB"); + ExecuteAppFunction("displayScreenshotMetadata", metadata.ToString(Formatting.Indented)); + } + public void FlashWindow() { MainForm.Instance.BeginInvoke(new MethodInvoker(() => { WinformThemer.Flash(MainForm.Instance); })); diff --git a/ScreenshotHelper.cs b/ScreenshotHelper.cs index 23f15943..0435a4e0 100644 --- a/ScreenshotHelper.cs +++ b/ScreenshotHelper.cs @@ -3,25 +3,35 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; -using System.Threading.Tasks; +using Newtonsoft.Json.Linq; namespace VRCX { internal static class ScreenshotHelper { - private static byte[] pngSignatureBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; - + private static readonly byte[] pngSignatureBytes = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }; + + /// + /// Writes a text description into a PNG file at the specified path. + /// Creates an iTXt PNG chunk in the target file, using the Description tag, with the specified text. + /// + /// The file path of the PNG file in which the description is to be written. + /// The text description that is to be written into the PNG file. + /// + /// true if the text description is successfully written to the PNG file; + /// otherwise, false. + /// public static bool WritePNGDescription(string path, string text) { if (!File.Exists(path) || !IsPNGFile(path)) return false; - + var png = File.ReadAllBytes(path); - int newChunkIndex = FindChunk(png, "IHDR"); + var newChunkIndex = FindEndOfChunk(png, "IHDR"); if (newChunkIndex == -1) return false; // If this file already has a text chunk, chances are it got logged twice for some reason. Stop. - int existingiTXt = FindChunk(png, "iTXt"); + var existingiTXt = FindChunkIndex(png, "iTXt"); if (existingiTXt != -1) return false; var newChunk = new PNGChunk("iTXt"); @@ -35,37 +45,230 @@ namespace VRCX return true; } - public static bool IsPNGFile(string path) + /// + /// Reads a text description from a PNG file at the specified path. + /// Reads any existing iTXt PNG chunk in the target file, using the Description tag. + /// + /// The file path of the PNG file in which the description is to be read from. + /// + /// The text description that is read from the PNG file. + /// + public static string ReadPNGDescription(string path) { + if (!File.Exists(path) || !IsPNGFile(path)) return null; + var png = File.ReadAllBytes(path); - var pngSignature = png.Take(8).ToArray(); - return pngSignatureBytes.SequenceEqual(pngSignature); + var existingiTXt = FindChunk(png, "iTXt"); + if (existingiTXt == null) return null; + + var text = existingiTXt.GetText("Description"); + + return text; } - static int FindChunk(byte[] png, string type) + /// + /// Determines whether the specified file is a PNG file. We do this by checking if the first 8 bytes in the file path match the PNG signature. + /// + /// The path of the file to check. + /// + public static bool IsPNGFile(string path) { - int index = 8; + // Read only the first 8 bytes of the file to check if it's a PNG file instead of reading the entire thing into memory just to see check a couple bytes. + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + if (fs.Length < 33) return false; + + var signature = new byte[8]; + fs.Read(signature, 0, 8); + return signature.SequenceEqual(pngSignatureBytes); + } + } + + /// + /// Finds the index of the first byte of the specified chunk type in the specified PNG file. + /// + /// Array of bytes representing a PNG file. + /// Type of PMG chunk to find + /// + private static int FindChunkIndex(byte[] png, string type) + { + // The first 8 bytes of the file are the png signature, so we can skip them. + var index = 8; while (index < png.Length) { - byte[] chunkLength = new byte[4]; + var chunkLength = new byte[4]; Array.Copy(png, index, chunkLength, 0, 4); - Array.Reverse(chunkLength); - int length = BitConverter.ToInt32(chunkLength, 0); - byte[] chunkName = new byte[4]; + // BitConverter wants little endian(unless your system is big endian for some reason), PNG multi-byte integers are big endian. So we reverse the array. + if (BitConverter.IsLittleEndian) Array.Reverse(chunkLength); + + var length = BitConverter.ToInt32(chunkLength, 0); + + // We don't need to reverse strings since UTF-8 strings aren't affected by endianess, given that they're a sequence of bytes. + var chunkName = new byte[4]; Array.Copy(png, index + 4, chunkName, 0, 4); - string name = Encoding.ASCII.GetString(chunkName); + var name = Encoding.UTF8.GetString(chunkName); if (name == type) { - return index + length + 12; + return index; } + + if (name == "IEND") // Nothing should exist past IEND in a normal png file, so we should stop parsing here to avoid trying to parse junk data. + { + return -1; + } + + // The chunk length is 4 bytes, the chunk name is 4 bytes, the chunk data is length bytes, and the chunk CRC is 4 bytes. index += length + 12; } return -1; } + + /// + /// Finds the index of the end of the specified chunk type in the specified PNG file. + /// + /// Array of bytes representing a PNG file. + /// Type of PMG chunk to find + /// + private static int FindEndOfChunk(byte[] png, string type) + { + var index = FindChunkIndex(png, type); + if (index == -1) return index; + + var chunkLength = new byte[4]; + Array.Copy(png, index, chunkLength, 0, 4); + Array.Reverse(chunkLength); + var length = BitConverter.ToInt32(chunkLength, 0); + + return index + length + 12; + } + + /// + /// Finds the specified chunk type in the specified PNG file and returns it as a PNGChunk. + /// + /// Array of bytes representing a PNG file + /// Type of PMG chunk to find + /// PNGChunk + private static PNGChunk FindChunk(byte[] png, string type) + { + var index = FindChunkIndex(png, type); + if (index == -1) return null; + + var chunkLength = new byte[4]; + Array.Copy(png, index, chunkLength, 0, 4); + Array.Reverse(chunkLength); + var length = BitConverter.ToInt32(chunkLength, 0); + + var chunkData = new byte[length]; + Array.Copy(png, index + 8, chunkData, 0, length); + + return new PNGChunk(type, chunkData); + } + + // parse LFS screenshot PNG metadata + public static JObject ParseLfsPicture(string metadataString) + { + var metadata = new JObject(); + // lfs|2|author:usr_032383a7-748c-4fb2-94e4-bcb928e5de6b,Natsumi-sama|world:wrld_b016712b-5ce6-4bcb-9144-c8ed089b520f,35372,pet park test|pos:-60.49379,-0.002925932,5.805772|players:usr_9d73bff9-4543-4b6f-a004-9e257869ff50,-0.85,-0.17,-0.58,Olivia.;usr_3097f91e-a816-4c7a-a625-38fbfdee9f96,12.30,13.72,0.08,Zettai Ryouiki;usr_032383a7-748c-4fb2-94e4-bcb928e5de6b,0.68,0.32,-0.28,Natsumi-sama;usr_7525f45f-517e-442b-9abc-fbcfedb29f84,0.51,0.64,0.70,Weyoun + // lfs|2|author:usr_8c0a2f22-26d4-4dc9-8396-2ab40e3d07fc,knah|world:wrld_fb4edc80-6c48-43f2-9bd1-2fa9f1345621,35341,Luminescent Ledge|pos:8.231676,0.257298,-0.1983307|rq:2|players:usr_65b9eeeb-7c91-4ad2-8ce4-addb1c161cd6,0.74,0.59,1.57,Jakkuba;usr_6a50647f-d971-4281-90c3-3fe8caf2ba80,8.07,9.76,0.16,SopwithPup;usr_8c0a2f22-26d4-4dc9-8396-2ab40e3d07fc,0.26,1.03,-0.28,knah;usr_7f593ad1-3e9e-4449-a623-5c1c0a8d8a78,0.15,0.60,1.46,NekOwneD + var lfs = metadataString.Split('|'); + var version = int.Parse(lfs[1]); + var application = lfs[0]; + metadata.Add("application", application); + metadata.Add("version", version); + + if (application == "screenshotmanager") + { + // screenshotmanager|0|author:usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup|wrld_6caf5200-70e1-46c2-b043-e3c4abe69e0f,47213,The Great Pug + var author = lfs[2].Split(','); + metadata.Add("author", new JObject + { + { "id", author[0] }, + { "displayName", author[1] } + }); + var world = lfs[3].Split(','); + metadata.Add("world", new JObject + { + { "id", world[0] }, + { "name", world[2] }, + { "instanceId", world[1] } + }); + return metadata; + } + + for (var i = 2; i < lfs.Length; i++) + { + var split = lfs[i].Split(':'); + switch (split[0]) + { + case "author": + var author = split[1].Split(','); + metadata.Add("author", new JObject + { + { "id", author[0] }, + { "displayName", author[1] } + }); + break; + case "world": + if (version == 1) + { + metadata.Add("world", new JObject + { + { "id", "" }, + { "name", split[1] }, + { "instanceId", "" } + }); + } + else + { + var world = split[1].Split(','); + metadata.Add("world", new JObject + { + { "id", world[0] }, + { "name", world[2] }, + { "instanceId", world[0] } + }); + } + + break; + case "pos": + var pos = split[1].Split(','); + metadata.Add("pos", new JObject + { + { "x", pos[0] }, + { "y", pos[1] }, + { "z", pos[2] } + }); + break; + case "rq": + metadata.Add("rq", split[1]); + break; + case "players": + var players = split[1].Split(';'); + var playersArray = new JArray(); + foreach (var player in players) + { + var playerSplit = player.Split(','); + playersArray.Add(new JObject + { + { "id", playerSplit[0] }, + { "x", playerSplit[1] }, + { "y", playerSplit[2] }, + { "z", playerSplit[3] }, + { "displayName", playerSplit[4] } + }); + } + + metadata.Add("players", playersArray); + break; + } + } + + return metadata; + } } // See http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html 4.2.3 @@ -75,28 +278,42 @@ namespace VRCX // Proper practice here for arbitrary image processing would be to check the PNG file being passed for any existing iTXt chunks with the same keyword that we're trying to use; If we find one, we replace that chunk's data instead of creating a new chunk. // Luckily, VRChat should never do this! Bugs notwithstanding, we should never re-process a png file either. So we're just going to skip that logic. + // This code would be HORRIBLE for general parsing of PNG files/metadata. It's not really meant to do that, it's just meant to do exactly what we need it to do. internal class PNGChunk { - public string ChunkType; - public List ChunkDataBytes; - public int ChunkDataLength; - // crc lookup table private static uint[] crcTable; + // init lookup table and store crc for iTXt - private static uint tEXtCRC = Crc32(new byte[] { (byte)'i', (byte)'T', (byte)'X', (byte)'t' }, 0, 4, 0); + private static readonly uint iTXtCrc = Crc32(new[] { (byte)'i', (byte)'T', (byte)'X', (byte)'t' }, 0, 4, 0); + private readonly Encoding keywordEncoding = Encoding.GetEncoding("ISO-8859-1"); // ISO-8859-1/Latin1 is the encoding used for the keyword in text chunks. + public List ChunkBytes; + public List ChunkDataBytes; + public int ChunkDataLength; + public string ChunkType; public PNGChunk(string chunkType) { - this.ChunkType = chunkType; - this.ChunkDataBytes = new List(); + ChunkType = chunkType; + ChunkDataBytes = new List(); } - // Construct iTXt chunk data + public PNGChunk(string chunkType, byte[] bytes) + { + ChunkType = chunkType; + ChunkDataBytes = bytes.ToList(); + ChunkDataLength = bytes.Length; + } + + /// + /// Initializes this PNGChunk's data in the format of an iTXt chunk with the specified keyword and text. + /// + /// Keyword for text chunk + /// Text data for text chunk public void InitializeTextChunk(string keyword, string text) { // Create our chunk data byte array - ChunkDataBytes.AddRange(Encoding.UTF8.GetBytes(keyword)); // keyword + ChunkDataBytes.AddRange(keywordEncoding.GetBytes(keyword)); // keyword ChunkDataBytes.Add(0x0); // Null separator ChunkDataBytes.Add(0x0); // Compression flag ChunkDataBytes.Add(0x0); // Compression method @@ -107,19 +324,45 @@ namespace VRCX ChunkDataLength = ChunkDataBytes.Count; } - // Construct & return PNG chunk + /// + /// Constructs and returns a full, coherent PNG chunk from this PNGChunk's data. + /// + /// PNG chunk byte array public byte[] ConstructChunkByteArray() { - List chunk = new List(); + var chunk = new List(); - chunk.AddRange(BitConverter.GetBytes(ChunkDataLength).Reverse()); // add data length - chunk.AddRange(Encoding.ASCII.GetBytes(ChunkType)); // add chunk type + var chunkLengthBytes = BitConverter.GetBytes(ChunkDataLength); + var chunkCRCBytes = BitConverter.GetBytes(Crc32(ChunkDataBytes.ToArray(), 0, ChunkDataLength, iTXtCrc)); + + // Reverse the chunk length bytes/CRC bytes if system is little endian since PNG integers are big endian + if (BitConverter.IsLittleEndian) + { + Array.Reverse(chunkLengthBytes); + Array.Reverse(chunkCRCBytes); + } + + chunk.AddRange(chunkLengthBytes); // add data length + chunk.AddRange(Encoding.UTF8.GetBytes(ChunkType)); // add chunk type chunk.AddRange(ChunkDataBytes); // Add chunk data - chunk.AddRange(BitConverter.GetBytes(Crc32(ChunkDataBytes.ToArray(), 0, ChunkDataLength, tEXtCRC)).Reverse()); // Add chunk CRC32 hash + chunk.AddRange(chunkCRCBytes); // Add chunk CRC32 hash. return chunk.ToArray(); } + /// + /// Gets the text from an iTXt chunk + /// + /// Keyword of the text chunk + /// Text from chunk. + public string GetText(string keyword) + { + var offset = keywordEncoding.GetByteCount(keyword) + 5; + // Read string from PNG chunk + return Encoding.UTF8.GetString(ChunkDataBytes.ToArray(), offset, ChunkDataBytes.Count - offset); + } + + // Crc32 implementation from // https://web.archive.org/web/20150825201508/http://upokecenter.dreamhosters.com/articles/png-image-encoder-in-c/ private static uint Crc32(byte[] stream, int offset, int length, uint crc) { @@ -135,18 +378,21 @@ namespace VRCX if ((c & 1) == 1) c = 0xEDB88320 ^ ((c >> 1) & 0x7FFFFFFF); else - c = ((c >> 1) & 0x7FFFFFFF); + c = (c >> 1) & 0x7FFFFFFF; } + crcTable[n] = c; } } + c = crc ^ 0xffffffff; var endOffset = offset + length; for (var i = offset; i < endOffset; i++) { c = crcTable[(c ^ stream[i]) & 255] ^ ((c >> 8) & 0xFFFFFF); } + return c ^ 0xffffffff; } } -} +} \ No newline at end of file diff --git a/docs/screenshotMetadata-schema.json b/docs/screenshotMetadata-schema.json new file mode 100644 index 00000000..9e485f38 --- /dev/null +++ b/docs/screenshotMetadata-schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "This schema is primarily for any developers that want to interface with this outside of VRCX and want an easy way to view/generate the format without crawling through the codebase. It's also for me a couple months from now when I come back to this and have no idea what was done." + "title": "VRChat Screenshot JSON", + "description": "JSON object attached by VRCX to screenshot files taken by users in-game.", + "type": "object", + "required": ["application", "version", "author", "world", "players"], + "properties": { + "application": { + "type": "string", + "default": "VRCX" + "description": "Name of the application writing to the screenshot. Should be VRCX." + }, + "version": { + "type": "integer", + "description": "The version of this schema. If the format changes, this number should change." + "const": 1 + }, + "author": { + "type": "object", + "description": "The details of the user that took the picture.", + "required": ["id", "displayName"], + "properties": { + "id": { + "type": "string", + "description": "The ID of the user." + }, + "displayName": { + "type": "string", + "description": "The display name of the user." + } + } + }, + "world": { + "type": "object", + "description": "Information about the world the picture was taken in.", + "required": ["id", "name", "instanceId"], + "properties": { + "id": { + "type": "string", + "description": "The ID of the world." + }, + "name": { + "type": "string", + "description": "The name of the world." + }, + "instanceId": { + "type": "string", + "description": "The full ID of the game instance." + } + } + }, + "players": { + "type": "array", + "description": "A list of players in the world at the time the picture was taken.", + "items": { + "type": "object", + "required": ["id", "displayName"], + "properties": { + "id": { + "type": "string", + "description": "The ID of the player in the world." + }, + "displayName": { + "type": "string", + "description": "The display name of the player in the world." + } + } + } + } + } +} \ No newline at end of file diff --git a/html/src/app.js b/html/src/app.js index 97716594..a35b14fa 100644 --- a/html/src/app.js +++ b/html/src/app.js @@ -921,7 +921,7 @@ speechSynthesis.getVoices(); this.region = ''; if ($app.isRealInstance(instanceId)) { this.region = L.region; - if (!L.region) { + if (!L.region && L.instanceId) { this.region = 'us'; } } @@ -20189,6 +20189,56 @@ speechSynthesis.getVoices(); ); }; + /** + * This function should only ever be called by .NET + * Function receives an unmodified json string grabbed from the screenshot file + * Error checking and and verification of data is done in .NET already; In the case that the data/file is invalid, a JSON object with the token "error" will be returned containing a description of the problem. + * Example: {"error":"Invalid file selected. Please select a valid VRChat screenshot."} + * See docs/screenshotMetadata.json for schema + * @param {string} metadata - JSON string grabbed from PNG file + */ + $app.methods.displayScreenshotMetadata = function (metadata) { + var D = this.screenshotMetadataDialog; + var json = JSON.parse(metadata); + console.log(json); + D.metadata = json; + this.showScreenshotMetadataDialog(); + }; + + $app.data.screenshotMetadataDialog = { + visible: false, + metadata: {} + }; + + $app.methods.showScreenshotMetadataDialog = function () { + this.$nextTick(() => + adjustDialogZ(this.$refs.screenshotMetadataDialog.$el) + ); + var D = this.screenshotMetadataDialog; + D.visible = true; + }; + + $app.methods.screenshotMetadataCarouselChange = function (index) { + var D = this.screenshotMetadataDialog; + if (index === 0) { + if (D.metadata.previousFilePath) { + AppApi.GetScreenshotMetadata(D.metadata.previousFilePath); + } else { + AppApi.GetScreenshotMetadata(D.metadata.filePath); + } + } + if (index === 2) { + if (D.metadata.nextFilePath) { + AppApi.GetScreenshotMetadata(D.metadata.nextFilePath); + } else { + AppApi.GetScreenshotMetadata(D.metadata.filePath); + } + } + if (typeof this.$refs.screenshotMetadataCarousel !== 'undefined') { + this.$refs.screenshotMetadataCarousel.setActiveItem(1); + } + }; + // YouTube API $app.data.youTubeApiKey = ''; diff --git a/html/src/index.pug b/html/src/index.pug index 4eafab68..04d499e9 100644 --- a/html/src/index.pug +++ b/html/src/index.pug @@ -1403,6 +1403,7 @@ html el-button-group el-button(size="small" icon="el-icon-s-operation" @click="showVRChatConfig()") VRChat config.json el-button(size="small" icon="el-icon-s-operation" @click="showLaunchOptions()") {{ $t('view.settings.advanced.advanced.launch_options') }} + el-button(size="small" icon="el-icon-picture" @click="showScreenshotMetadataDialog()") {{ $t('view.settings.advanced.advanced.screenshot_metadata') }} div.options-container span.sub-header {{ $t('view.settings.advanced.advanced.pending_offline.header') }} div.options-container-item @@ -3795,6 +3796,40 @@ html template(#footer) el-button(type="primary" size="small" :disabled="inviteGroupDialog.loading || !inviteGroupDialog.userIds.length" @click="sendGroupInvite()") Invite + //- dialog: screenshot metadata + el-dialog.x-dialog(:before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" ref="screenshotMetadataDialog" :visible.sync="screenshotMetadataDialog.visible" :title="$t('dialog.screenshot_metadata.header')" width="1050px") + div(v-if="screenshotMetadataDialog.visible") + el-button(size="small" icon="el-icon-folder-opened" @click="AppApi.OpenScreenshotFileDialog()") {{ $t('dialog.screenshot_metadata.browse') }} + br + span(v-text="screenshotMetadataDialog.metadata.fileName") + el-tag(v-if="screenshotMetadataDialog.metadata.fileSize" type="info" effect="plain" size="mini" style="margin-left:5px" v-text="screenshotMetadataDialog.metadata.fileSize") + br + location(v-if="screenshotMetadataDialog.metadata.world" :location="screenshotMetadataDialog.metadata.world.instanceId" :hint="screenshotMetadataDialog.metadata.world.name") + br + span.x-link(v-if="screenshotMetadataDialog.metadata.author" v-text="screenshotMetadataDialog.metadata.author.displayName" @click="showUserDialog(screenshotMetadataDialog.metadata.author.id)" style="color:#909399;font-family:monospace") + br + el-carousel(ref="screenshotMetadataCarousel" :interval="0" initial-index="1" indicator-position="none" arrow="always" height="600px" style="margin-top:10px" @change="screenshotMetadataCarouselChange") + el-carousel-item + el-popover(placement="top" width="700px" trigger="click") + img.x-link(slot="reference" v-lazy="screenshotMetadataDialog.metadata.previousFilePath" style="width:100%;height:100%;object-fit:contain") + img(v-lazy="screenshotMetadataDialog.metadata.previousFilePath" style="height:700px") + el-carousel-item + el-popover(placement="top" width="700px" trigger="click") + img.x-link(slot="reference" v-lazy="screenshotMetadataDialog.metadata.filePath" style="width:100%;height:100%;object-fit:contain") + img(v-lazy="screenshotMetadataDialog.metadata.filePath" style="height:700px") + el-carousel-item + el-popover(placement="top" width="700px" trigger="click") + img.x-link(slot="reference" v-lazy="screenshotMetadataDialog.metadata.nextFilePath" style="width:100%;height:100%;object-fit:contain") + img(v-lazy="screenshotMetadataDialog.metadata.nextFilePath" style="height:700px") + br + template(v-if="screenshotMetadataDialog.metadata.error") + pre(v-text="screenshotMetadataDialog.metadata.error") + br + span(v-for="user in screenshotMetadataDialog.metadata.players" style="margin-top:5px") + span.x-link(v-text="user.displayName" @click="lookupUser(user)") + span(v-if="user.x" v-text="'('+user.x+', '+user.y+', '+user.z+')'" style="margin-left:5px;color:#909399;font-family:monospace") + br + //- dialog: open source software notice el-dialog.x-dialog(:before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" :visible.sync="ossDialog" :title="$t('dialog.open_source.header')" width="650px") div(v-if="ossDialog" style="height:350px;overflow:hidden scroll;word-break:break-all") diff --git a/html/src/localization/strings/en.json b/html/src/localization/strings/en.json index d287abbe..90d5fa4d 100644 --- a/html/src/localization/strings/en.json +++ b/html/src/localization/strings/en.json @@ -348,6 +348,7 @@ "advanced": { "header": "Advanced", "launch_options": "Launch Options", + "screenshot_metadata": "Screenshot Metadata", "pending_offline": { "header": "Pending Offline", "description": "Delay before marking user as offline (fixes false positives)", @@ -1051,6 +1052,10 @@ "password_placeholder": "Input new password", "re_input_placeholder": "Re-input password", "ok": "OK" + }, + "screenshot_metadata": { + "header": "Screenshot Metadata", + "browse": "Browse" } }, "prompt": {