diff --git a/AppApi.cs b/AppApi.cs
index aa55eecc..83d7d5d7 100644
--- a/AppApi.cs
+++ b/AppApi.cs
@@ -25,6 +25,8 @@ using librsync.net;
using Microsoft.Win32;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
+using NLog;
+using Newtonsoft.Json.Serialization;
namespace VRCX
{
@@ -32,6 +34,7 @@ namespace VRCX
{
public static readonly AppApi Instance;
+ private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly MD5 _hasher = MD5.Create();
private static bool dialogOpen;
@@ -959,7 +962,8 @@ namespace VRCX
if (string.IsNullOrEmpty(path))
return;
- GetScreenshotMetadata(path);
+ ExecuteAppFunction("screenshotMetadataResetSearch", null);
+ ExecuteAppFunction("getAndDisplayScreenshot", path);
}
});
@@ -967,110 +971,128 @@ namespace VRCX
thread.Start();
}
+ public string GetExtraScreenshotData(string path, bool carouselCache)
+ {
+ var fileName = Path.GetFileNameWithoutExtension(path);
+ var metadata = new JObject();
+
+ if (!File.Exists(path) || !path.EndsWith(".png"))
+ return null;
+
+ var files = Directory.GetFiles(Path.GetDirectoryName(path), "*.png");
+
+ // Add previous/next file paths to metadata so the screenshot viewer carousel can request metadata for next/previous images in directory
+ if (carouselCache)
+ {
+ 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("fileResolution", ScreenshotHelper.ReadPNGResolution(path));
+
+ var creationDate = File.GetCreationTime(path);
+ metadata.Add("creationDate", creationDate.ToString("yyyy-MM-dd HH:mm:ss"));
+
+ var fileSizeBytes = new FileInfo(path).Length;
+ metadata.Add("fileSizeBytes", fileSizeBytes.ToString());
+ metadata.Add("fileName", fileName);
+ metadata.Add("filePath", path);
+ metadata.Add("fileSize", $"{(fileSizeBytes / 1024f / 1024f).ToString("0.00")} MB");
+
+ return metadata.ToString(Formatting.Indented);
+ }
+
///
/// Retrieves metadata from a PNG screenshot file and send the result to displayScreenshotMetadata in app.js
///
/// The path to the PNG screenshot file.
- public void GetScreenshotMetadata(string path)
+ public string GetScreenshotMetadata(string path)
{
if (string.IsNullOrEmpty(path))
- return;
+ return null;
- var fileName = Path.GetFileNameWithoutExtension(path);
- var metadata = new JObject();
- if (File.Exists(path) && path.EndsWith(".png"))
+
+ var metadata = ScreenshotHelper.GetScreenshotMetadata(path);
+
+ if (metadata == null)
{
- string metadataString = null;
- var readPNGFailed = false;
+ var obj = new JObject
+ {
+ { "sourceFile", path },
+ { "error", "Screenshot contains no metadata." }
+ };
- 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;
- }
+ return obj.ToString(Formatting.Indented);
+ };
- 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})\nText: {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})\nText: {metadataString}");
- }
- }
- }
- else
- {
- if (!readPNGFailed)
- metadata.Add("error", "No metadata found in this file.");
- }
- }
- else
+ if (metadata.Error != null)
{
- metadata.Add("error", "Invalid file selected. Please select a valid VRChat screenshot.");
+ var obj = new JObject
+ {
+ { "sourceFile", path },
+ { "error", metadata.Error }
+ };
+
+ return obj.ToString(Formatting.Indented);
}
- var files = Directory.GetFiles(Path.GetDirectoryName(path), "*.png");
- var index = Array.IndexOf(files, path);
- if (index > 0)
+ return JsonConvert.SerializeObject(metadata, Formatting.Indented, new JsonSerializerSettings
{
- metadata.Add("previousFilePath", files[index - 1]);
+ ContractResolver = new DefaultContractResolver
+ {
+ NamingStrategy = new CamelCaseNamingStrategy() // This'll serialize our .net property names to their camelCase equivalents. Ex; "FileName" -> "fileName"
+ }
+ });
+ }
+
+ public string FindScreenshotsBySearch(string searchQuery, int searchType = 0)
+ {
+ var stopwatch = new Stopwatch();
+ stopwatch.Start();
+
+ var searchPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "VRChat");
+ var screenshots = ScreenshotHelper.FindScreenshots(searchQuery, searchPath, (ScreenshotHelper.ScreenshotSearchType)searchType);
+
+ JArray json = new JArray();
+
+ foreach (var screenshot in screenshots)
+ {
+ json.Add(screenshot.SourceFile);
}
- if (index < files.Length - 1)
- {
- metadata.Add("nextFilePath", files[index + 1]);
- }
+ stopwatch.Stop();
- metadata.Add("fileResolution", ScreenshotHelper.ReadPNGResolution(path));
- var creationDate = File.GetCreationTime(path);
- metadata.Add("creationDate", creationDate.ToString("yyyy-MM-dd HH:mm:ss"));
- metadata.Add("fileName", fileName);
- metadata.Add("filePath", path);
- var fileSizeBytes = new FileInfo(path).Length;
- metadata.Add("fileSizeBytes", fileSizeBytes.ToString());
- metadata.Add("fileSize", $"{(fileSizeBytes / 1024f / 1024f).ToString("0.00")} MB");
- ExecuteAppFunction("displayScreenshotMetadata", metadata.ToString(Formatting.Indented));
+ logger.Info($"FindScreenshotsBySearch took {stopwatch.ElapsedMilliseconds}ms to complete.");
+
+ return json.ToString();
}
///
- /// Gets the last screenshot taken by VRChat and retrieves its metadata.
+ /// Gets and returns the path of the last screenshot taken by VRChat.
///
- public void GetLastScreenshot()
+ public string GetLastScreenshot()
{
// Get the last screenshot taken by VRChat
var path = GetVRChatPhotosLocation();
if (!Directory.Exists(path))
- return;
+ return null;
var lastDirectory = Directory.GetDirectories(path).OrderByDescending(Directory.GetCreationTime).FirstOrDefault();
if (lastDirectory == null)
- return;
+ return null;
var lastScreenshot = Directory.GetFiles(lastDirectory, "*.png").OrderByDescending(File.GetCreationTime).FirstOrDefault();
if (lastScreenshot == null)
- return;
+ return null;
- GetScreenshotMetadata(lastScreenshot);
+ return lastScreenshot;
}
///
diff --git a/ScreenshotHelper.cs b/ScreenshotHelper.cs
index bf6e74d0..c2e4117f 100644
--- a/ScreenshotHelper.cs
+++ b/ScreenshotHelper.cs
@@ -2,14 +2,197 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Numerics;
using System.Text;
+using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
+using NLog;
namespace VRCX
{
internal static class ScreenshotHelper
{
+ private static readonly ILogger logger = LogManager.GetCurrentClassLogger();
private static readonly byte[] pngSignatureBytes = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
+ private static readonly ScreenshotMetadataDatabase cacheDatabase = new ScreenshotMetadataDatabase(Path.Combine(Program.AppDataDirectory, "metadataCache.db"));
+ private static readonly Dictionary metadataCache = new Dictionary();
+
+ public enum ScreenshotSearchType
+ {
+ Username,
+ UserID,
+ WorldName,
+ WorldID,
+ }
+
+ public static bool TryGetCachedMetadata(string filePath, out ScreenshotMetadata metadata)
+ {
+ if (metadataCache.TryGetValue(filePath, out metadata))
+ return true;
+
+ int id = cacheDatabase.IsFileCached(filePath);
+
+ if (id != -1)
+ {
+ string metadataStr = cacheDatabase.GetMetadataById(id);
+ var metadataObj = metadataStr == null ? null : JsonConvert.DeserializeObject(metadataStr);
+
+ metadataCache.Add(filePath, metadataObj);
+
+ metadata = metadataObj;
+ return true;
+ }
+
+ return false;
+ }
+
+ public static List FindScreenshots(string query, string directory, ScreenshotSearchType searchType)
+ {
+ var result = new List();
+
+ var files = Directory.GetFiles(directory, "*.png", SearchOption.AllDirectories);
+
+ var addToCache = new List();
+
+ int amtFromCache = 0;
+ foreach (var file in files)
+ {
+ ScreenshotMetadata metadata = null;
+
+ if (TryGetCachedMetadata(file, out metadata))
+ {
+ amtFromCache++;
+ }
+ else
+ {
+ metadata = GetScreenshotMetadata(file, false);
+ var dbEntry = new MetadataCache()
+ {
+ FilePath = file,
+ Metadata = null,
+ CachedAt = DateTimeOffset.Now
+ };
+
+ if (metadata == null || metadata.Error != null)
+ {
+ addToCache.Add(dbEntry);
+ metadataCache.Add(file, null);
+ continue;
+ }
+
+ dbEntry.Metadata = JsonConvert.SerializeObject(metadata);
+ addToCache.Add(dbEntry);
+ metadataCache.Add(file, metadata);
+ }
+
+ if (metadata == null) continue;
+
+ switch (searchType)
+ {
+ case ScreenshotSearchType.Username:
+ if (metadata.ContainsPlayerName(query, true, true))
+ result.Add(metadata);
+
+ break;
+ case ScreenshotSearchType.UserID:
+ if (metadata.ContainsPlayerID(query))
+ result.Add(metadata);
+
+ break;
+ case ScreenshotSearchType.WorldName:
+ if (metadata.World.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) != -1)
+ result.Add(metadata);
+
+ break;
+ case ScreenshotSearchType.WorldID:
+ if (metadata.World.Id == query)
+ result.Add(metadata);
+
+ break;
+
+ }
+ }
+
+ if (addToCache.Count > 0)
+ cacheDatabase.BulkAddMetadataCache(addToCache);
+
+ logger.ConditionalDebug("Found {0}/{1} screenshots matching query '{2}' of type '{3}'. {4}/{5} pulled from cache.", result.Count, files.Length, query, searchType, amtFromCache, files.Length);
+
+ return result;
+ }
+
+ ///
+ /// Retrieves metadata from a PNG screenshot file and attempts to parse it.
+ ///
+ /// The path to the PNG screenshot file.
+ /// A JObject containing the metadata or null if no metadata was found.
+ public static ScreenshotMetadata GetScreenshotMetadata(string path, bool includeJSON = false)
+ {
+ // Early return if file doesn't exist, or isn't a PNG(Check both extension and file header)
+ if (!File.Exists(path) || !path.EndsWith(".png") || !IsPNGFile(path))
+ return null;
+
+ ///if (metadataCache.TryGetValue(path, out var cachedMetadata))
+ // return cachedMetadata;
+
+ string metadataString;
+
+ // Get the metadata string from the PNG file
+ try
+ {
+ metadataString = ReadPNGDescription(path);
+ }
+ catch (Exception ex)
+ {
+ logger.Error(ex, "Failed to read PNG description for file '{0}'", path);
+ return ScreenshotMetadata.JustError(path, "Failed to read PNG description. Check logs.");
+ }
+
+ // If the metadata string is empty for some reason, there's nothing to parse.
+ if (string.IsNullOrEmpty(metadataString))
+ return null;
+
+ // Check for specific metadata string start sequences
+ if (metadataString.StartsWith("lfs") || metadataString.StartsWith("screenshotmanager"))
+ {
+ try
+ {
+ var result = ScreenshotHelper.ParseLfsPicture(metadataString);
+ result.SourceFile = path;
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ logger.Error(ex, "Failed to parse LFS/ScreenshotManager metadata for file '{0}'", path);
+ return ScreenshotMetadata.JustError(path, "Failed to parse LFS/ScreenshotManager metadata.");
+ }
+ }
+
+ // If not JSON metadata, return early so we're not throwing/catching pointless exceptions
+ if (!metadataString.StartsWith("{"))
+ {
+ logger.ConditionalDebug("Screenshot file '{0}' has unknown non-JSON metadata:\n{1}\n", path, metadataString);
+ return ScreenshotMetadata.JustError(path, "File has unknown non-JSON metadata.");
+ }
+
+ // Parse the metadata as VRCX JSON metadata
+ try
+ {
+ var result = JsonConvert.DeserializeObject(metadataString);
+ result.SourceFile = path;
+
+ if (includeJSON)
+ result.JSON = metadataString;
+
+ return result;
+ }
+ catch (JsonException ex)
+ {
+ logger.Error(ex, "Failed to parse screenshot metadata JSON for file '{0}'", path);
+ return ScreenshotMetadata.JustError(path, "Failed to parse screenshot metadata JSON. Check logs.");
+ }
+ }
///
/// Writes a text description into a PNG file at the specified path.
@@ -57,26 +240,31 @@ namespace VRCX
{
if (!File.Exists(path) || !IsPNGFile(path)) return null;
- var png = File.ReadAllBytes(path);
- var existingiTXt = FindChunk(png, "iTXt");
- if (existingiTXt == null) return null;
+ using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512))
+ {
+ var existingiTXt = FindChunk(stream, "iTXt", true);
+ if (existingiTXt == null) return null;
- var text = existingiTXt.GetText("Description");
-
- return text;
+ return existingiTXt.GetText("Description");
+ }
}
+ ///
+ /// Reads the PNG resolution.
+ ///
+ /// The path.
+ ///
public static string ReadPNGResolution(string path)
{
if (!File.Exists(path) || !IsPNGFile(path)) return null;
- var png = File.ReadAllBytes(path);
- var existingpHYs = FindChunk(png, "IHDR");
- if (existingpHYs == null) return null;
+ using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 512))
+ {
+ var existingiHDR = FindChunk(stream, "IHDR", false);
+ if (existingiHDR == null) return null;
- var text = existingpHYs.GetResolution();
-
- return text;
+ return existingiHDR.GetResolution();
+ }
}
///
@@ -89,7 +277,7 @@ namespace VRCX
// 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;
+ if (fs.Length < 33) return false; // I don't remember how I came up with this number, but a PNG file below this size is not going to be valid for our purposes.
var signature = new byte[8];
fs.Read(signature, 0, 8);
@@ -98,13 +286,18 @@ namespace VRCX
}
///
- /// Finds the index of the first byte of the specified chunk type in the specified PNG file.
+ /// Finds the index of the first of a 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)
{
+ int chunksProcessed = 0;
+ int chunkSeekLimit = 5;
+
+ bool isLittleEndian = BitConverter.IsLittleEndian;
+
// The first 8 bytes of the file are the png signature, so we can skip them.
var index = 8;
@@ -114,7 +307,7 @@ namespace VRCX
Array.Copy(png, index, chunkLength, 0, 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);
+ if (isLittleEndian) Array.Reverse(chunkLength);
var length = BitConverter.ToInt32(chunkLength, 0);
@@ -134,7 +327,80 @@ namespace VRCX
}
// 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.
+ // We add 12 to the index to get to the start of the next chunk in the file on the next loop.
index += length + 12;
+ chunksProcessed++;
+
+ if (chunksProcessed > chunkSeekLimit) break;
+ }
+
+ return -1;
+ }
+
+ private static int FindChunkIndex(FileStream fs, string type, bool seekEnd)
+ {
+ int chunksProcessed = 0;
+ int chunkSeekLimit = 5;
+
+ bool isLittleEndian = BitConverter.IsLittleEndian;
+
+ fs.Seek(8, SeekOrigin.Begin);
+
+ byte[] buffer = new byte[8];
+
+ while (fs.Position < fs.Length)
+ {
+ int chunkIndex = (int)fs.Position;
+
+ fs.Read(buffer, 0, 8); // Read both chunkLength and chunkName at once into this buffer
+
+ // 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 (isLittleEndian) Array.Reverse(buffer, 0, 4); // Only reverse the chunkLength part
+
+ int chunkLength = BitConverter.ToInt32(buffer, 0);
+ string chunkType = Encoding.UTF8.GetString(buffer, 4, 4); // We don't need to reverse strings since UTF-8 strings aren't affected by endianess, given that they're a sequence of bytes.
+
+ if (chunkType == type) return chunkIndex;
+ if (chunkType == "IEND") return -1; // Nothing should exist past IEND in a normal png file, so we should stop parsing here to avoid trying to parse junk data.
+
+ // The chunk length is 4 bytes, the chunk name is 4 bytes, the chunk data is chunkLength bytes, and the chunk CRC after chunk data is 4 bytes.
+ // We've already read the length/type which is the first 8 bytes, so we'll seek the chunk length + 4(CRC) to get to the start of the next chunk in the file.
+ fs.Seek(chunkLength + 4, SeekOrigin.Current);
+ chunksProcessed++;
+
+ if (chunksProcessed > chunkSeekLimit) break;
+ }
+
+ // If we've processed more than 5 chunks and still haven't found the chunk we're looking for, we'll start searching from the end of the file.
+
+ // We start at an offset of 12 since the IEND chunk (should) always be the last chunk in the file, be 12 bytes, and we don't need to check it.
+ fs.Seek(-12, SeekOrigin.End);
+
+ // We're going to read the last 4096 bytes of the file, which (should) be enough to find any trailing iTXt chunks we're looking for.
+ // If an LFS screenshots has the metadata of like 80 players attached to it, this likely won't be enough to find the iTXt chunk.
+ // I don't have any screenshots with that much metadata to test with and will not create them manually, so I'm not going to worry about it for now.
+ var chunkNameBytes = Encoding.UTF8.GetBytes(type);
+ fs.Seek(-4096, SeekOrigin.Current);
+
+ byte[] trailingBytes = new byte[4096];
+ fs.Read(trailingBytes, 0, 4096);
+
+ // At this scale we can just brute force/naive search for the chunk name in the trailing bytes and performance will be fine.
+ for (int i = 0; i <= trailingBytes.Length - chunkNameBytes.Length; i++)
+ {
+ bool isMatch = true;
+ for (int j = 0; j < chunkNameBytes.Length; j++)
+ {
+ if (trailingBytes[i + j] != chunkNameBytes[j])
+ {
+ isMatch = false;
+ break;
+ }
+ }
+ if (isMatch)
+ {
+ return (int)fs.Position - 4096 + i - 4;
+ }
}
return -1;
@@ -181,143 +447,142 @@ namespace VRCX
return new PNGChunk(type, chunkData);
}
+ ///
+ /// Finds the specified chunk type in the specified PNG file and returns it as a PNGChunk.
+ ///
+ /// FileStream of a PNG file.
+ /// Type of PMG chunk to find
+ /// PNGChunk
+ private static PNGChunk FindChunk(FileStream fs, string type, bool seekFromEnd)
+ {
+ var index = FindChunkIndex(fs, type, seekFromEnd);
+ if (index == -1) return null;
+
+ // Seek back to start of found chunk
+ fs.Seek(index, SeekOrigin.Begin);
+
+ var chunkLength = new byte[4];
+ fs.Read(chunkLength, 0, 4);
+ Array.Reverse(chunkLength);
+ var length = BitConverter.ToInt32(chunkLength, 0);
+
+ // Skip the chunk type bytes
+ fs.Seek(4, SeekOrigin.Current);
+
+ var chunkData = new byte[length];
+ fs.Read(chunkData, 0, length);
+
+ return new PNGChunk(type, chunkData);
+ }
+
///
/// Parses the metadata string of a vrchat screenshot with taken with LFS and returns a JObject containing the parsed data.
///
/// The metadata string to parse.
/// A JObject containing the parsed data.
- public static JObject ParseLfsPicture(string metadataString)
+ public static ScreenshotMetadata ParseLfsPicture(string metadataString)
{
- var metadata = new JObject();
+ var metadata = new ScreenshotMetadata();
+ // LFS v2 format: https://github.com/knah/VRCMods/blob/c7e84936b52b6f476db452a37ab889eabe576845/LagFreeScreenshots/API/MetadataV2.cs#L35
+ // Normal entry
// 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
+ // Entry with image rotation enabled (rq:)
// 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
- // lfs|cvr|1|author:047b30bd-089d-887c-8734-b0032df5d176,Hordini|world:2e73b387-c6d4-45e9-b998-0fd6aa122c1d,i+efec20004ef1cd8b-404003-93833f-1aee112a,Bono's Basement (Anime) (#816724)|pos:2.196716,0.01250899,-3.817466|players:5301af21-eb8d-7b36-3ef4-b623fa51c2c6,3.778407,0.01250887,-3.815876,DDAkebono;f9e5c36c-41b0-7031-1185-35b4034010c0,4.828233,0.01250893,-3.920135,Natsumi
- var lfs = metadataString.Split('|');
- if (lfs[1] == "cvr")
- lfs = lfs.Skip(1).ToArray();
- var version = int.Parse(lfs[1]);
- var application = lfs[0];
- metadata.Add("application", application);
- metadata.Add("version", version);
+ // LFS v1 format: https://github.com/knah/VRCMods/blob/23c3311fdfc4af4b568eedfb2e366710f2a9f925/LagFreeScreenshots/LagFreeScreenshotsMod.cs
+ // Why support this tho
+ // lfs|1|world:wrld_6caf5200-70e1-46c2-b043-e3c4abe69e0f:47213,The Great Pug|players:usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup;usr_290c03d6-66cc-4f0e-b782-c07f5cfa8deb,VirtualTeacup
+
+ // LFS CVR Edition v1 format: https://github.com/dakyneko/DakyModsCVR/blob/48eecd1bccd1a5b2ea844d899d59cf1186ec9912/LagFreeScreenshots/API/MetadataV2.cs#L41
+ // lfs|cvr|1|author:047b30bd-089d-887c-8734-b0032df5d176,Hordini|world:2e73b387-c6d4-45e9-b998-0fd6aa122c1d,i+efec20004ef1cd8b-404003-93833f-1aee112a,Bono's Basement (Anime) (#816724)|pos:2.196716,0.01250899,-3.817466|players:5301af21-eb8d-7b36-3ef4-b623fa51c2c6,3.778407,0.01250887,-3.815876,DDAkebono;f9e5c36c-41b0-7031-1185-35b4034010c0,4.828233,0.01250893,-3.920135,Natsumi
+
+ var lfsParts = metadataString.Split('|');
+ if (lfsParts[1] == "cvr")
+ lfsParts = lfsParts.Skip(1).ToArray();
+
+ var version = int.Parse(lfsParts[1]);
+ var application = lfsParts[0];
+ metadata.Application = application;
+ metadata.Version = version;
+
+ bool isCVR = application == "cvr";
if (application == "screenshotmanager")
{
+ // ScreenshotManager format: https://github.com/DragonPlayerX/ScreenshotManager/blob/33950b98003e795d29c68ce5fe1d86e7e65c92ad/ScreenshotManager/Core/FileDataHandler.cs#L94
// 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] }
- });
+ var author = lfsParts[2].Split(',');
+
+ metadata.Author.Id = author[0];
+ metadata.Author.DisplayName = author[1];
+
+ var world = lfsParts[3].Split(',');
+
+ metadata.World.Id = world[0];
+ metadata.World.Name = world[2];
+ metadata.World.InstanceId = string.Join(":", world[0], world[1]); // worldId:instanceId format, same as vrcx format, just minimal
return metadata;
}
- for (var i = 2; i < lfs.Length; i++)
+ for (var i = 2; i < lfsParts.Length; i++)
{
- var split = lfs[i].Split(':');
- switch (split[0])
+ var split = lfsParts[i].Split(':');
+ var key = split[0];
+ var value = split[1];
+
+ if (String.IsNullOrEmpty(value)) // One of my LFS files had an empty value for 'players:'. not pog
+ continue;
+
+ var parts = value.Split(',');
+
+ switch (key)
{
case "author":
- var author = split[1].Split(',');
- if (application == "cvr")
- {
- metadata.Add("author", new JObject
- {
- { "id", string.Empty },
- { "displayName", $"{author[1]} ({author[0]})" }
- });
- break;
- }
-
- metadata.Add("author", new JObject
- {
- { "id", author[0] },
- { "displayName", author[1] }
- });
+ metadata.Author.Id = isCVR ? string.Empty : parts[0];
+ metadata.Author.DisplayName = isCVR ? $"{parts[1]} ({parts[0]})" : parts[1];
break;
+
case "world":
- if (application == "cvr")
- {
- var world = split[1].Split(',');
- metadata.Add("world", new JObject
- {
- { "id", string.Empty },
- { "name", $"{world[2]} ({world[0]})" },
- { "instanceId", string.Empty }
- });
- }
- else if (version == 1)
- {
- metadata.Add("world", new JObject
- {
- { "id", string.Empty },
- { "name", split[1] },
- { "instanceId", string.Empty }
- });
- }
- else
- {
- var world = split[1].Split(',');
- metadata.Add("world", new JObject
- {
- { "id", world[0] },
- { "name", world[2] },
- { "instanceId", world[0] }
- });
- }
+ metadata.World.Id = isCVR || version == 1 ? string.Empty : parts[0];
+ metadata.World.InstanceId = isCVR || version == 1 ? string.Empty : string.Join(":", parts[0], parts[1]); // worldId:instanceId format, same as vrcx format, just minimal
+ metadata.World.Name = isCVR ? $"{parts[2]} ({parts[0]})" : (version == 1 ? value : parts[2]);
+ break;
- 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]);
+ float.TryParse(parts[0], out float x);
+ float.TryParse(parts[1], out float y);
+ float.TryParse(parts[2], out float z);
+
+ metadata.Pos = new Vector3(x, y, z);
break;
+
+ // We don't use this, so don't parse it.
+ /*case "rq":
+ // Image rotation
+ metadata.Add("rq", value);
+ break;*/
+
case "players":
- var players = split[1].Split(';');
- var playersArray = new JArray();
+ var playersArray = metadata.Players;
+ var players = value.Split(';');
+
foreach (var player in players)
{
- var playerSplit = player.Split(',');
- if (application == "cvr")
- {
- playersArray.Add(new JObject
- {
- { "id", string.Empty },
- { "x", playerSplit[1] },
- { "y", playerSplit[2] },
- { "z", playerSplit[3] },
- { "displayName", $"{playerSplit[4]} ({playerSplit[0]})" }
- });
- }
- else
- {
- playersArray.Add(new JObject
- {
- { "id", playerSplit[0] },
- { "x", playerSplit[1] },
- { "y", playerSplit[2] },
- { "z", playerSplit[3] },
- { "displayName", playerSplit[4] }
- });
- }
- }
+ var playerParts = player.Split(',');
- metadata.Add("players", playersArray);
+ float.TryParse(playerParts[1], out float x2);
+ float.TryParse(playerParts[2], out float y2);
+ float.TryParse(playerParts[3], out float z2);
+
+ var playerDetail = new ScreenshotMetadata.PlayerDetail
+ {
+ Id = isCVR ? string.Empty : playerParts[0],
+ DisplayName = isCVR ? $"{playerParts[4]} ({playerParts[0]})" : playerParts[4],
+ Pos = new Vector3(x2, y2, z2)
+ };
+
+ playersArray.Add(playerDetail);
+ }
break;
}
}
diff --git a/ScreenshotMetadata.cs b/ScreenshotMetadata.cs
new file mode 100644
index 00000000..a3f4f024
--- /dev/null
+++ b/ScreenshotMetadata.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using Newtonsoft.Json;
+
+namespace VRCX
+{
+ public class ScreenshotMetadata
+ {
+ ///
+ /// Name of the application writing to the screenshot. Should be VRCX.
+ ///
+ public string Application { get; set; }
+
+ ///
+ /// The version of this schema. If the format changes, this number should change.
+ ///
+ public int Version { get; set; }
+
+ ///
+ /// The details of the user that took the picture.
+ ///
+ public AuthorDetail Author { get; set; }
+
+ ///
+ /// Information about the world the picture was taken in.
+ ///
+ public WorldDetail World { get; set; }
+
+ ///
+ /// A list of players in the world at the time the picture was taken.
+ ///
+ public List Players { get; set; }
+
+ ///
+ /// If this class was serialized from a file, this should be the path to the file.
+ ///
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ internal string SourceFile;
+
+ ///
+ /// The position of the player that took the picture when the shot was taken. Not written by VRCX, this is legacy support for reading LFS files.
+ ///
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ public Vector3? Pos { get; set; }
+
+ ///
+ /// Any error that occurred while parsing the file. This being true implies nothing else is set.
+ ///
+ [JsonIgnore]
+ internal string Error;
+
+ [JsonIgnore]
+ internal string JSON;
+
+ public ScreenshotMetadata()
+ {
+ Application = "VRCX";
+ Version = 1;
+ Author = new AuthorDetail();
+ World = new WorldDetail();
+ Players = new List();
+ }
+
+ public static ScreenshotMetadata JustError(string sourceFile, string error)
+ {
+ return new ScreenshotMetadata
+ {
+ Error = error,
+ SourceFile = sourceFile
+ };
+ }
+
+ public bool ContainsPlayerID(string id)
+ {
+ return Players.Any(p => p.Id == id);
+ }
+
+ public bool ContainsPlayerName(string playerName, bool partial, bool ignoreCase)
+ {
+ var comparisonType = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+
+ if (partial)
+ {
+ return Players.Any(p => p.DisplayName.IndexOf(playerName, comparisonType) != -1);
+ }
+
+ return Players.Any(p => p.DisplayName.Equals(playerName, comparisonType));
+ }
+
+ public class AuthorDetail
+ {
+ ///
+ /// The ID of the user.
+ ///
+ public string Id { get; set; }
+
+ ///
+ /// The display name of the user.
+ ///
+ public string DisplayName { get; set; }
+ }
+
+ public class WorldDetail
+ {
+ ///
+ /// The ID of the world.
+ ///
+ public string Id { get; set; }
+
+ ///
+ /// The name of the world.
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// The full ID of the game instance.
+ ///
+ public string InstanceId { get; set; }
+ }
+
+ public class PlayerDetail
+ {
+ ///
+ /// The ID of the player in the world.
+ ///
+ public string Id { get; set; }
+
+ ///
+ /// The display name of the player in the world.
+ ///
+ public string DisplayName { get; set; }
+
+ ///
+ /// The position of the player in the world. Not written by VRCX, this is legacy support for reading LFS files.
+ ///
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ public Vector3? Pos { get; set; } = null;
+ }
+ }
+}
diff --git a/ScreenshotMetadataDatabase.cs b/ScreenshotMetadataDatabase.cs
new file mode 100644
index 00000000..65470a58
--- /dev/null
+++ b/ScreenshotMetadataDatabase.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using SQLite;
+
+namespace VRCX
+{
+ [Table("cache")]
+ public class MetadataCache
+ {
+ [PrimaryKey, AutoIncrement]
+ [Column("id")]
+ public int Id { get; set; }
+ [Column("file_path"), NotNull, Indexed]
+ public string FilePath { get; set; }
+ [Column("metadata")]
+ public string Metadata { get; set; }
+ [Column("cached_at"), NotNull]
+ public DateTimeOffset CachedAt { get; set; }
+ }
+
+ // Imagine using SQLite to store json strings in one table lmao
+ // Couldn't be me... oh wait
+ internal class ScreenshotMetadataDatabase
+ {
+ private SQLiteConnection sqlite;
+
+ public ScreenshotMetadataDatabase(string databaseLocation)
+ {
+ var options = new SQLiteConnectionString(databaseLocation, true);
+ sqlite = new SQLiteConnection(options);
+
+ sqlite.CreateTable();
+ }
+
+ public void AddMetadataCache(string filePath, string metadata)
+ {
+ var cache = new MetadataCache()
+ {
+ FilePath = filePath,
+ Metadata = metadata,
+ CachedAt = DateTimeOffset.Now
+ };
+ sqlite.Insert(cache);
+ }
+
+ public void BulkAddMetadataCache(IEnumerable cache)
+ {
+ sqlite.InsertAll(cache, runInTransaction: true);
+ }
+
+ public int IsFileCached(string filePath)
+ {
+ var query = sqlite.Table().Where(c => c.FilePath == filePath).Select(c => c.Id);
+
+ if (query.Any())
+ {
+ return query.First();
+ }
+ else
+ {
+ return -1;
+ }
+ }
+
+ public string GetMetadata(string filePath)
+ {
+ var query = sqlite.Table().Where(c => c.FilePath == filePath).Select(c => c.Metadata);
+ return query.FirstOrDefault();
+ }
+
+ public string GetMetadataById(int id)
+ {
+ var query = sqlite.Table().Where(c => c.Id == id).Select(c => c.Metadata);
+ return query.FirstOrDefault();
+ }
+
+ public void Close()
+ {
+ sqlite.Close();
+ }
+ }
+}
diff --git a/VRCX.csproj b/VRCX.csproj
index bf57f777..f3681c2c 100644
--- a/VRCX.csproj
+++ b/VRCX.csproj
@@ -1,4 +1,4 @@
-
+
@@ -87,6 +87,8 @@
+
+
diff --git a/html/src/app.js b/html/src/app.js
index e256c562..d9be5806 100644
--- a/html/src/app.js
+++ b/html/src/app.js
@@ -22033,20 +22033,53 @@ speechSynthesis.getVoices();
);
};
+ $app.methods.getAndDisplayScreenshot = function (
+ path,
+ needsCarouselFiles = true
+ ) {
+ AppApi.GetScreenshotMetadata(path).then((metadata) =>
+ this.displayScreenshotMetadata(metadata, needsCarouselFiles)
+ );
+ };
+
+ $app.methods.getAndDisplayLastScreenshot = function () {
+ this.screenshotMetadataResetSearch();
+ AppApi.GetLastScreenshot().then((path) =>
+ this.getAndDisplayScreenshot(path)
+ );
+ };
+
/**
- * 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
+ * @param {string} needsCarouselFiles - Whether or not to get the last/next files for the carousel
+ * @returns {void}
*/
- $app.methods.displayScreenshotMetadata = function (metadata) {
+ $app.methods.displayScreenshotMetadata = async function (
+ json,
+ needsCarouselFiles = true
+ ) {
var D = this.screenshotMetadataDialog;
- var json = JSON.parse(metadata);
- D.metadata = json;
+ var metadata = JSON.parse(json);
- var regex = json.fileName.match(
+ // Get extra data for display dialog like resolution, file size, etc
+ D.loading = true;
+ var extraData = await AppApi.GetExtraScreenshotData(
+ metadata.sourceFile,
+ needsCarouselFiles
+ );
+ D.loading = false;
+ var extraDataObj = JSON.parse(extraData);
+ Object.assign(metadata, extraDataObj);
+
+ // console.log("Displaying screenshot metadata", json, "extra data", extraDataObj, "path", json.filePath)
+
+ D.metadata = metadata;
+
+ var regex = metadata.fileName.match(
/VRChat_((\d{3,})x(\d{3,})_(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.(\d{1,})|(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.(\d{3})_(\d{3,})x(\d{3,}))/
);
if (regex) {
@@ -22073,11 +22106,19 @@ speechSynthesis.getVoices();
D.metadata.dateTime = Date.parse(json.creationDate);
}
- this.openScreenshotMetadataDialog();
+ if (this.fullscreenImageDialog?.visible) {
+ this.showFullscreenImageDialog(D.metadata.filePath);
+ } else {
+ this.openScreenshotMetadataDialog();
+ }
};
$app.data.screenshotMetadataDialog = {
visible: false,
+ loading: false,
+ search: '',
+ searchType: 'Player Name',
+ searchTypes: ['Player Name', 'Player ID', 'World Name', 'World ID'],
metadata: {},
isUploading: false
};
@@ -22093,30 +22134,135 @@ speechSynthesis.getVoices();
$app.methods.showScreenshotMetadataDialog = function () {
var D = this.screenshotMetadataDialog;
if (!D.metadata.filePath) {
- AppApi.GetLastScreenshot();
+ this.getAndDisplayLastScreenshot();
}
this.openScreenshotMetadataDialog();
};
+ $app.methods.screenshotMetadataResetSearch = function () {
+ var D = this.screenshotMetadataDialog;
+
+ D.search = '';
+ D.searchIndex = null;
+ D.searchResults = null;
+ };
+
+ $app.data.screenshotMetadataSearchInputs = 0;
+ $app.methods.screenshotMetadataSearch = function () {
+ var D = this.screenshotMetadataDialog;
+
+ // Don't search if user is still typing
+ this.screenshotMetadataSearchInputs++;
+ let current = this.screenshotMetadataSearchInputs;
+ setTimeout(() => {
+ if (current !== this.screenshotMetadataSearchInputs) {
+ return;
+ }
+ this.screenshotMetadataSearchInputs = 0;
+
+ if (D.search === '') {
+ this.screenshotMetadataResetSearch();
+ if (D.metadata.filePath !== null) {
+ // Re-retrieve the current screenshot metadata and get previous/next files for regular carousel directory navigation
+ this.getAndDisplayScreenshot(D.metadata.filePath, true);
+ }
+ return;
+ }
+
+ var searchType = D.searchTypes.indexOf(D.searchType); // Matches the search type enum in .NET
+ D.loading = true;
+ AppApi.FindScreenshotsBySearch(D.search, searchType)
+ .then((json) => {
+ var results = JSON.parse(json);
+
+ if (results.length === 0) {
+ D.metadata = {};
+ D.metadata.error = 'No results found';
+
+ D.searchIndex = null;
+ D.searchResults = null;
+ return;
+ }
+
+ D.searchIndex = 0;
+ D.searchResults = results;
+
+ // console.log("Search results", results)
+ this.getAndDisplayScreenshot(results[0], false);
+ })
+ .finally(() => {
+ D.loading = false;
+ });
+ }, 500);
+ };
+
+ $app.methods.screenshotMetadataCarouselChangeSearch = function (index) {
+ var D = this.screenshotMetadataDialog;
+ var searchIndex = D.searchIndex;
+ var filesArr = D.searchResults;
+
+ if (searchIndex === null) {
+ return;
+ }
+
+ if (index === 0) {
+ if (searchIndex > 0) {
+ this.getAndDisplayScreenshot(filesArr[searchIndex - 1], false);
+ searchIndex--;
+ } else {
+ this.getAndDisplayScreenshot(
+ filesArr[filesArr.length - 1],
+ false
+ );
+ searchIndex = filesArr.length - 1;
+ }
+ } else if (index === 2) {
+ if (searchIndex < filesArr.length - 1) {
+ this.getAndDisplayScreenshot(filesArr[searchIndex + 1], false);
+ searchIndex++;
+ } else {
+ this.getAndDisplayScreenshot(filesArr[0], false);
+ searchIndex = 0;
+ }
+ }
+
+ if (typeof this.$refs.screenshotMetadataCarousel !== 'undefined') {
+ this.$refs.screenshotMetadataCarousel.setActiveItem(1);
+ }
+
+ D.searchIndex = searchIndex;
+ };
+
$app.methods.screenshotMetadataCarouselChange = function (index) {
var D = this.screenshotMetadataDialog;
+ var searchIndex = D.searchIndex;
+
+ if (searchIndex !== null) {
+ this.screenshotMetadataCarouselChangeSearch(index);
+ return;
+ }
+
if (index === 0) {
if (D.metadata.previousFilePath) {
- AppApi.GetScreenshotMetadata(D.metadata.previousFilePath);
+ this.getAndDisplayScreenshot(D.metadata.previousFilePath);
} else {
- AppApi.GetScreenshotMetadata(D.metadata.filePath);
+ this.getAndDisplayScreenshot(D.metadata.filePath);
}
}
if (index === 2) {
if (D.metadata.nextFilePath) {
- AppApi.GetScreenshotMetadata(D.metadata.nextFilePath);
+ this.getAndDisplayScreenshot(D.metadata.nextFilePath);
} else {
- AppApi.GetScreenshotMetadata(D.metadata.filePath);
+ this.getAndDisplayScreenshot(D.metadata.filePath);
}
}
if (typeof this.$refs.screenshotMetadataCarousel !== 'undefined') {
this.$refs.screenshotMetadataCarousel.setActiveItem(1);
}
+
+ if (this.fullscreenImageDialog.visible) {
+ // TODO
+ }
};
$app.methods.uploadScreenshotToGallery = function () {
@@ -22165,8 +22311,10 @@ speechSynthesis.getVoices();
if (this.currentlyDroppingFile === null) {
return;
}
- console.log('Dropped file into window: ', this.currentlyDroppingFile);
- AppApi.GetScreenshotMetadata(this.currentlyDroppingFile);
+ console.log('Dropped file into viewer: ', this.currentlyDroppingFile);
+
+ this.screenshotMetadataResetSearch();
+ this.getAndDisplayScreenshot(this.currentlyDroppingFile);
event.preventDefault();
};
diff --git a/html/src/index.pug b/html/src/index.pug
index 7e2ad861..cef6dc6f 100644
--- a/html/src/index.pug
+++ b/html/src/index.pug
@@ -2535,21 +2535,30 @@ html
//- 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" @dragover.prevent @dragenter.prevent @drop="handleDrop" style="-webkit-app-region: drag")
+ div(v-if="screenshotMetadataDialog.visible" v-loading="screenshotMetadataDialog.loading" @dragover.prevent @dragenter.prevent @drop="handleDrop" style="-webkit-app-region: drag")
span(style="margin-left:5px;color:#909399;font-family:monospace") {{ $t('dialog.screenshot_metadata.drag') }}
br
el-button(size="small" icon="el-icon-folder-opened" @click="AppApi.OpenScreenshotFileDialog()") {{ $t('dialog.screenshot_metadata.browse') }}
- el-button(size="small" icon="el-icon-picture-outline" @click="AppApi.GetLastScreenshot()") {{ $t('dialog.screenshot_metadata.last_screenshot') }}
+ el-button(size="small" icon="el-icon-picture-outline" @click="getAndDisplayLastScreenshot()") {{ $t('dialog.screenshot_metadata.last_screenshot') }}
el-button(size="small" icon="el-icon-copy-document" @click="copyImageToClipboard(screenshotMetadataDialog.metadata.filePath)") {{ $t('dialog.screenshot_metadata.copy_image') }}
el-button(size="small" icon="el-icon-folder" @click="openImageFolder(screenshotMetadataDialog.metadata.filePath)") {{ $t('dialog.screenshot_metadata.open_folder') }}
el-button(v-if="API.currentUser.$isVRCPlus && screenshotMetadataDialog.metadata.filePath" size="small" icon="el-icon-upload2" @click="uploadScreenshotToGallery") {{ $t('dialog.screenshot_metadata.upload') }}
br
+ //- Search bar input
+ el-input(v-model="screenshotMetadataDialog.search" size="small" placeholder="Search" clearable style="width:200px" @input="screenshotMetadataSearch")
+ //- Search index/total label
+ template(v-if="screenshotMetadataDialog.searchIndex != null")
+ span(style="white-space:pre-wrap;font-size:12px;margin-left:10px") {{ (screenshotMetadataDialog.searchIndex + 1) + "/" + screenshotMetadataDialog.searchResults.length }}
+ //- Search type dropdown
+ el-select(v-model="screenshotMetadataDialog.searchType" size="small" placeholder="Search Type" style="width:150px;margin-left:10px" @change="screenshotMetadataSearch")
+ el-option(v-for="type in screenshotMetadataDialog.searchTypes" :key="type" :label="type" :value="type")
+ br
br
span(v-text="screenshotMetadataDialog.metadata.fileName")
br
- span(v-if="screenshotMetadataDialog.metadata.dateTime") {{ screenshotMetadataDialog.metadata.dateTime | formatDate('long') }}
- span(v-if="screenshotMetadataDialog.metadata.fileResolution" v-text="screenshotMetadataDialog.metadata.fileResolution" style="margin-left:5px")
- el-tag(v-if="screenshotMetadataDialog.metadata.fileSize" type="info" effect="plain" size="mini" style="margin-left:5px" v-text="screenshotMetadataDialog.metadata.fileSize")
+ span(v-if="screenshotMetadataDialog.metadata.dateTime" style="margin-right:5px") {{ screenshotMetadataDialog.metadata.dateTime | formatDate('long') }}
+ span(v-if="screenshotMetadataDialog.metadata.fileResolution" v-text="screenshotMetadataDialog.metadata.fileResolution" style="margin-right:5px")
+ el-tag(v-if="screenshotMetadataDialog.metadata.fileSize" type="info" effect="plain" size="mini" v-text="screenshotMetadataDialog.metadata.fileSize")
br
location(v-if="screenshotMetadataDialog.metadata.world" :location="screenshotMetadataDialog.metadata.world.instanceId" :hint="screenshotMetadataDialog.metadata.world.name")
br
@@ -2557,24 +2566,21 @@ html
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")
+ span(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")
+ span(placement="top" width="700px" trigger="click" @click="showFullscreenImageDialog(screenshotMetadataDialog.metadata.filePath)")
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")
+ span(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" style="white-space:pre-wrap;font-size:12px")
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")
+ span(v-if="user.pos" v-text="'('+user.pos.x+', '+user.pos.y+', '+user.pos.z+')'" style="margin-left:5px;color:#909399;font-family:monospace")
br
//- dialog: change log
@@ -2605,10 +2611,10 @@ html
img.avatar(v-lazy="image.versions[image.versions.length - 1].file.url")
//- dialog: full screen image
- el-dialog.x-dialog(ref="fullscreenImageDialog" :before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" :visible.sync="fullscreenImageDialog.visible" top="5vh" width="90vw")
+ el-dialog.x-dialog(ref="fullscreenImageDialog" :before-close="beforeDialogClose" @mousedown.native="dialogMouseDown" @mouseup.native="dialogMouseUp" :visible.sync="fullscreenImageDialog.visible" top="3vh" width="97vw")
el-button(@click="copyImageUrl(fullscreenImageDialog.imageUrl)" size="mini" icon="el-icon-s-order" circle)
el-button(type="default" size="mini" icon="el-icon-download" circle @click="downloadAndSaveImage(fullscreenImageDialog.imageUrl)" style="margin-left:5px")
- img(v-lazy="fullscreenImageDialog.imageUrl" style="width:100%;height:80vh;object-fit:contain")
+ img(v-lazy="fullscreenImageDialog.imageUrl" style="width:100%;height:100vh;object-fit:contain")
//- 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")