A photo manager for VRChat.
1import { For, Show, createEffect, onCleanup, onMount } from "solid-js";
2import { invoke } from '@tauri-apps/api/core';
3import { WorldCache } from "./Structs/WorldCache";
4import { animate, JSAnimation, utils } from "animejs";
5
6let PhotoViewer = () => {
7 let viewer: HTMLElement;
8 let imageViewer: HTMLImageElement;
9 let isOpen = false;
10 let trayOpen = false;
11
12 let trayButton: HTMLElement;
13
14 let photoTray: HTMLElement;
15 let photoControls: HTMLElement;
16 let photoTrayCloseBtn: HTMLElement;
17
18 let worldInfoContainer: HTMLElement;
19
20 let viewerContextMenu: HTMLElement;
21 let viewerContextMenuButtons: HTMLElement[] = [];
22
23 let allowedToOpenTray = false;
24
25 let authorProfileButton: HTMLDivElement;
26
27 let switchPhotoWithKey = ( e: KeyboardEvent ) => {
28 switch(e.key){
29 case 'Escape':
30 window.PhotoViewerManager.Close();
31
32 break;
33 case 'ArrowUp':
34 if(allowedToOpenTray)
35 openTray();
36
37 break;
38 case 'ArrowDown':
39 closeTray();
40 break;
41 case 'ArrowLeft':
42 window.CloseAllPopups.forEach(p => p());
43 window.PhotoViewerManager.PreviousPhoto();
44
45 break;
46 case 'ArrowRight':
47 window.CloseAllPopups.forEach(p => p());
48 window.PhotoViewerManager.NextPhoto();
49
50 break;
51 }
52 }
53
54 let trayAnimation: JSAnimation[] = [];
55
56 let openTray = () => {
57 if(trayOpen)return;
58 trayOpen = true;
59
60 trayAnimation.forEach(anim => anim.cancel());
61
62 window.CloseAllPopups.forEach(p => p());
63 trayAnimation[0] = animate(photoTray, { bottom: '-150px', duration: 500, ease: 'outElastic' });
64
65 trayAnimation[1] = animate(photoControls, {
66 bottom: '160px',
67 ease: 'outElastic',
68 scale: '0.75',
69 opacity: 0,
70 duration: 500,
71 onComplete: () => {
72 photoControls.style.display = 'none';
73 }
74 });
75
76 photoTrayCloseBtn.style.display = 'flex';
77 trayAnimation[2] = animate(photoTrayCloseBtn, {
78 bottom: '160px',
79 ease: 'outElastic',
80 opacity: 1,
81 scale: 1,
82 duration: 500
83 })
84 }
85
86 let copyImage = () => {
87 invoke('copy_image', { path: window.PhotoViewerManager.CurrentPhoto()!.path })
88 .then(() => {
89 utils.set('.copy-notif', { translateX: '-50%', translateY: '-100px' });
90 animate('.copy-notif', {
91 ease: 'outElastic',
92 opacity: 1,
93 translateY: '0px'
94 });
95
96 setTimeout(() => {
97 animate('.copy-notif', {
98 ease: 'outElastic',
99 opacity: 0,
100 translateY: '-100px'
101 });
102 }, 2000);
103 })
104 }
105
106 let closeTray = () => {
107 if(!trayOpen)return;
108 trayOpen = false;
109
110 trayAnimation.forEach(anim => anim.cancel());
111
112 window.CloseAllPopups.forEach(p => p());
113 trayAnimation[0] = animate(photoTray, { bottom: '-300px', duration: 500, ease: 'outElastic' });
114
115 trayAnimation[2] = animate(photoTrayCloseBtn, {
116 bottom: '10px',
117 scale: '0.75',
118 ease: 'outElastic',
119 opacity: 0,
120 duration: 500,
121 onComplete: () => {
122 photoTrayCloseBtn.style.display = 'none';
123 }
124 });
125
126 photoControls.style.display = 'flex';
127 trayAnimation[1] = animate(photoControls, {
128 bottom: '10px',
129 ease: 'outElastic',
130 opacity: 1,
131 scale: 1,
132 duration: 500,
133 })
134 }
135
136 onMount(() => {
137 utils.set(photoControls, { translateX: '-50%' });
138 utils.set(photoTrayCloseBtn, { translateX: '-50%', opacity: 0, scale: '0.75', bottom: '10px' });
139
140 window.addEventListener('keyup', switchPhotoWithKey);
141
142 let contextMenuOpen = false;
143 window.CloseAllPopups.push(() => {
144 contextMenuOpen = false;
145 utils.set(viewerContextMenu, { opacity: 1, rotate: '0deg' });
146
147 animate(viewerContextMenu, {
148 opacity: 0,
149 easing: 'easeInOutQuad',
150 rotate: '30deg',
151 duration: 100,
152 onComplete: () => {
153 viewerContextMenu.style.display = 'none';
154 }
155 })
156 });
157
158 viewerContextMenuButtons[0].onclick = async () => {
159 window.CloseAllPopups.forEach(p => p());
160 // Context Menu -> Open file location
161
162 let path = await invoke('get_user_photos_path') + '\\' + window.PhotoViewerManager.CurrentPhoto()?.path;
163 invoke('open_folder', { url: path });
164 }
165
166 viewerContextMenuButtons[1].onclick = () => {
167 window.CloseAllPopups.forEach(p => p());
168 // Context Menu -> Copy image
169 copyImage();
170 }
171
172 imageViewer.oncontextmenu = ( e ) => {
173 if(contextMenuOpen){
174 contextMenuOpen = false;
175
176 utils.set(viewerContextMenu, { opacity: 1, rotate: '0deg' });
177
178 animate(viewerContextMenu, {
179 opacity: 0,
180 rotate: '30deg',
181 easing: 'easeInOutQuad',
182 duration: 100,
183 onComplete: () => {
184 viewerContextMenu.style.display = 'none';
185 }
186 })
187 } else{
188 contextMenuOpen = true;
189
190 viewerContextMenu.style.top = e.clientY + 'px';
191 viewerContextMenu.style.left = e.clientX + 'px';
192 viewerContextMenu.style.display = 'block';
193
194 utils.set(viewerContextMenu, { opacity: 0, rotate: '-30deg' });
195
196 animate(viewerContextMenu, {
197 opacity: 1,
198 rotate: '0deg',
199 easing: 'easeInOutQuad',
200 duration: 100
201 })
202 }
203 }
204
205 createEffect(() => {
206 let photo = window.PhotoViewerManager.CurrentPhoto();
207 allowedToOpenTray = false;
208
209 imageViewer.style.opacity = '0';
210
211 if(photo){
212 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + window.PhotoViewerManager.CurrentPhoto()?.path.split('\\').join('/') + "?full";
213 imageViewer.crossOrigin = 'anonymous';
214
215 animate(imageViewer, {
216 opacity: 1,
217 delay: 50,
218 duration: 150,
219 easing: 'easeInOutQuad'
220 })
221
222 let handleMetaDataLoaded = () => {
223 console.log(photo.metadata);
224 if(photo.metadata){
225 photo.onMetaLoaded = () => {}
226
227 try{
228 // Try JSON format ( VRCX )
229 let meta = JSON.parse(photo.metadata);
230
231 allowedToOpenTray = true;
232 trayButton.style.display = 'flex';
233
234 authorProfileButton!.style.display = 'none';
235
236 photoTray.innerHTML = '';
237 photoTray.appendChild(
238 <div class="photo-tray-columns">
239 <div class="photo-tray-column" style={{ width: '20%' }}><br />
240 <div class="tray-heading">People</div>
241
242 <For each={meta.players}>
243 {( item ) =>
244 <div>
245 { item.displayName }
246 <Show when={item.id}>
247 <img width="15" src="/icon/up-right-from-square-solid.svg" onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/user/' + item.id })} style={{ "margin-left": '10px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} />
248 </Show>
249 </div>
250 }
251 </For><br />
252 </div>
253 <div class="photo-tray-column"><br />
254 <div class="tray-heading">World</div>
255
256 <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div>
257 </div>
258 </div> as Node
259 );
260
261 window.WorldCacheManager.getWorldById(meta.world.id)
262 .then(worldData => {
263 if(worldData)
264 loadWorldData(worldData);
265 });
266 } catch(e){
267 try{
268 // Not json lets try XML (vrc prints)
269 let parser = new DOMParser();
270 let doc = parser.parseFromString(photo.metadata, "text/xml");
271
272 let id = doc.getElementsByTagName('xmp:Author')[0]!.innerHTML;
273
274 authorProfileButton!.style.display = 'flex';
275 authorProfileButton!.onclick = () =>
276 invoke('open_url', { url: 'https://vrchat.com/home/user/' + id });
277 } catch(e){
278 console.error(e);
279 console.log('Couldn\'t decode metadata')
280
281 authorProfileButton!.style.display = 'none';
282 }
283
284 trayButton.style.display = 'none';
285 closeTray();
286 }
287 } else{
288 trayButton.style.display = 'none';
289 closeTray();
290 }
291 }
292
293 handleMetaDataLoaded();
294 }
295
296 if(photo && !isOpen){
297 viewer.style.display = 'flex';
298
299 animate(viewer, {
300 opacity: 1,
301 easing: 'easeInOutQuad',
302 duration: 150
303 });
304
305 utils.set('.prev-button', { left: '-50px', top: '50%' });
306 utils.set('.next-button', { right: '-50px', top: '50%' });
307
308 animate('.prev-button', { left: '0', easing: 'easeInOutQuad', duration: 100 });
309 animate('.next-button', { right: '0', easing: 'easeInOutQuad', duration: 100 });
310
311 window.CloseAllPopups.forEach(p => p());
312 } else if(!photo && isOpen){
313 animate(viewer, {
314 opacity: 0,
315 easing: 'easeInOutQuad',
316 duration: 150,
317 onComplete: () => {
318 viewer.style.display = 'none';
319 }
320 });
321
322 window.CloseAllPopups.forEach(p => p());
323
324 animate('.prev-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 });
325 animate('.next-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 });
326 }
327
328 isOpen = photo != null;
329 })
330 })
331
332 onCleanup(() => {
333 window.removeEventListener('keyup', switchPhotoWithKey);
334 })
335
336 let loadWorldData = ( data: WorldCache ) => {
337 let meta = window.PhotoViewerManager.CurrentPhoto()?.metadata;
338 if(!meta)return;
339
340 worldInfoContainer.innerHTML = '';
341 worldInfoContainer.appendChild(
342 <div>
343 <Show when={ data.worldData.found == false && meta }>
344 <div>
345 <div class="world-name">{ JSON.parse(meta).world.name } <img width="15" src="/icon/up-right-from-square-solid.svg" onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/world/' + data.worldData.id })} style={{ "margin-left": '0px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} /></div>
346 <div style={{ width: '75%', margin: 'auto' }}>Could not fetch world information... Is the world private?</div>
347 </div>
348 </Show>
349 <Show when={ data.worldData.found == true }>
350 <div class="world-name">{ data.worldData.name } <img width="15" src="/icon/up-right-from-square-solid.svg" onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/world/' + data.worldData.id })} style={{ "margin-left": '0px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} /></div>
351 <div style={{ width: '75%', margin: 'auto' }}>{ data.worldData.desc }</div>
352
353 <br />
354 <div class="world-tags">
355 <For each={data.worldData.tags}>
356 {( tag ) =>
357 <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div>
358 }
359 </For>
360 </div><br />
361 </Show>
362 </div> as Node
363 )
364 }
365
366 return (
367 <div class="photo-viewer" ref={( el ) => viewer = el}>
368 <div class="photo-context-menu" ref={( el ) => viewerContextMenu = el}>
369 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Open file location</div>
370 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Copy image</div>
371 </div>
372
373 <div class="viewer-close viewer-button" onClick={() => window.PhotoViewerManager.Close()}>
374 <div class="icon-small" style={{ width: '10px', margin: '0' }}>
375 <img draggable="false" src="/icon/x-solid.svg"></img>
376 </div>
377 </div>
378 <img class="image-container" ref={( el ) => imageViewer = el} />
379
380 <div class="prev-button" onClick={() => {
381 window.CloseAllPopups.forEach(p => p());
382 window.PhotoViewerManager.PreviousPhoto();
383 }}>
384 <div class="icon-small" style={{ width: '15px', margin: '0' }}>
385 <img draggable="false" src="/icon/arrow-left-solid.svg"></img>
386 </div>
387 </div>
388
389 <div class="next-button" onClick={() => {
390 window.CloseAllPopups.forEach(p => p());
391 window.PhotoViewerManager.NextPhoto();
392 }}>
393 <div class="icon-small" style={{ width: '15px', margin: '0' }}>
394 <img draggable="false" src="/icon/arrow-right-solid.svg"></img>
395 </div>
396 </div>
397
398 <div class="photo-tray" ref={( el ) => photoTray = el}></div>
399
400 <div class="photo-tray-close"
401 onClick={() => closeTray()}
402 ref={( el ) => photoTrayCloseBtn = el}
403 >
404 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
405 <img draggable="false" src="/icon/angle-down-solid.svg"></img>
406 </div>
407 </div>
408
409 <div class="control-buttons" ref={( el ) => photoControls = el}>
410 <div class="viewer-button"
411 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
412 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
413 onClick={() => { copyImage(); }}>
414 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
415 <img draggable="false" src="/icon/copy-solid.svg"></img>
416 </div>
417 </div>
418 <div class="viewer-button" style={{ width: '50px' }}
419 onMouseOver={( el ) => animate(el.currentTarget, { width: '70px', height: '30px', 'margin-left': '10px', 'margin-right': '10px' })}
420 onMouseLeave={( el ) => animate(el.currentTarget, { width: '50px', height: '30px', 'margin-left': '20px', 'margin-right': '20px' })}
421 ref={( el ) => trayButton = el}
422 onClick={() => openTray()}
423 >
424 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
425 <img draggable="false" src="/icon/angle-up-solid.svg"></img>
426 </div>
427 </div>
428
429 <div class="viewer-button"
430 ref={authorProfileButton!}
431 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
432 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
433 >
434 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
435 <img draggable="false" src="/icon/user-solid.svg"></img>
436 </div>
437 </div>
438
439 <div class="viewer-button"
440 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
441 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
442 onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("Are you sure you want to delete this photo?", async () => { invoke("delete_photo", {
443 path: window.PhotoViewerManager.CurrentPhoto()?.path
444 });
445 })}>
446 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
447 <img draggable="false" src="/icon/trash-solid.svg"></img>
448 </div>
449 </div>
450 </div>
451 </div>
452 )
453}
454
455export default PhotoViewer