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": {