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 photoLayerManager!: HTMLDivElement;
28
29 let switchPhotoWithKey = ( e: KeyboardEvent ) => {
30 switch(e.key){
31 case 'Escape':
32 window.PhotoViewerManager.Close();
33
34 break;
35 case 'ArrowUp':
36 if(allowedToOpenTray)
37 openTray();
38
39 break;
40 case 'ArrowDown':
41 closeTray();
42 break;
43 case 'ArrowLeft':
44 window.CloseAllPopups.forEach(p => p());
45 window.PhotoViewerManager.PreviousPhoto();
46
47 break;
48 case 'ArrowRight':
49 window.CloseAllPopups.forEach(p => p());
50 window.PhotoViewerManager.NextPhoto();
51
52 break;
53 }
54 }
55
56 let trayAnimation: JSAnimation[] = [];
57
58 let openTray = () => {
59 if(trayOpen)return;
60 trayOpen = true;
61
62 trayAnimation.forEach(anim => anim.cancel());
63
64 window.CloseAllPopups.forEach(p => p());
65 trayAnimation[0] = animate(photoTray, { bottom: '-150px', duration: 500, ease: 'outElastic' });
66
67 trayAnimation[1] = animate(photoControls, {
68 bottom: '160px',
69 ease: 'outElastic',
70 scale: '0.75',
71 opacity: 0,
72 duration: 500,
73 onComplete: () => {
74 photoControls.style.display = 'none';
75 }
76 });
77
78 photoTrayCloseBtn.style.display = 'flex';
79 trayAnimation[2] = animate(photoTrayCloseBtn, {
80 bottom: '160px',
81 ease: 'outElastic',
82 opacity: 1,
83 scale: 1,
84 duration: 500
85 })
86 }
87
88 let copyImage = () => {
89 let path;
90 let photo = window.PhotoViewerManager.CurrentPhoto()!;
91
92 switch(layerManagerViewing){
93 case LayerManagerView.DEFAULT:
94 path = photo.path;
95 break;
96 case LayerManagerView.ENVIRONMENT:
97 path = photo.environmentLayer!.path;
98 break;
99 case LayerManagerView.PLAYER:
100 path = photo.playerLayer!.path;
101 break;
102 }
103
104 invoke('copy_image', { path })
105 .then(() => {
106 utils.set('.copy-notif', { translateX: '-50%', translateY: '-100px' });
107 animate('.copy-notif', {
108 ease: 'outElastic',
109 opacity: 1,
110 translateY: '0px'
111 });
112
113 setTimeout(() => {
114 animate('.copy-notif', {
115 ease: 'outElastic',
116 opacity: 0,
117 translateY: '-100px'
118 });
119 }, 2000);
120 })
121 }
122
123 let closeTray = () => {
124 if(!trayOpen)return;
125 trayOpen = false;
126
127 trayAnimation.forEach(anim => anim.cancel());
128
129 window.CloseAllPopups.forEach(p => p());
130 trayAnimation[0] = animate(photoTray, { bottom: '-300px', duration: 500, ease: 'outElastic' });
131
132 trayAnimation[2] = animate(photoTrayCloseBtn, {
133 bottom: '10px',
134 scale: '0.75',
135 ease: 'outElastic',
136 opacity: 0,
137 duration: 500,
138 onComplete: () => {
139 photoTrayCloseBtn.style.display = 'none';
140 }
141 });
142
143 photoControls.style.display = 'flex';
144 trayAnimation[1] = animate(photoControls, {
145 bottom: '10px',
146 ease: 'outElastic',
147 opacity: 1,
148 scale: 1,
149 duration: 500,
150 })
151 }
152
153 let resizeImage = () => {
154 let dstWidth;
155 let dstHeight;
156
157 let imgHeight = imageViewer.naturalHeight;
158 let imgWidth = imageViewer.naturalWidth;
159
160 if(
161 imgWidth / window.innerWidth <
162 imgHeight / window.innerHeight
163 ) {
164 dstWidth = imgWidth * (window.innerHeight / imgHeight);
165 dstHeight = window.innerHeight;
166 } else{
167 dstWidth = window.innerWidth;
168 dstHeight = imgHeight * (window.innerWidth / imgWidth);
169 }
170
171 imageViewer.style.width = dstWidth + 'px';
172 imageViewer.style.height = dstHeight + 'px';
173 }
174
175 onMount(() => {
176 utils.set(photoControls, { translateX: '-50%' });
177 utils.set(photoTrayCloseBtn, { translateX: '-50%', opacity: 0, scale: '0.75', bottom: '10px' });
178 utils.set(photoLayerManager, { translateY: '20px', opacity: 0, display: 'none' });
179
180 window.addEventListener('keyup', switchPhotoWithKey);
181 window.addEventListener('resize', () => resizeImage());
182
183 let contextMenuOpen = false;
184 window.CloseAllPopups.push(() => {
185 contextMenuOpen = false;
186 utils.set(viewerContextMenu, { opacity: 1, rotate: '0deg' });
187
188 animate(viewerContextMenu, {
189 opacity: 0,
190 easing: 'easeInOutQuad',
191 rotate: '30deg',
192 duration: 100,
193 onComplete: () => {
194 viewerContextMenu.style.display = 'none';
195 }
196 })
197 });
198
199 window.CloseAllPopups.push(() => {
200 layerManagerOpen = false;
201 if(layerManagerAnimation)layerManagerAnimation.cancel();
202
203 layerManagerAnimation = animate(photoLayerManager, { translateY: '20px', opacity: 0, duration: 100, onComplete: () => utils.set(photoLayerManager, { display: 'none' }) });
204 });
205
206 viewerContextMenuButtons[0].onclick = async () => {
207 window.CloseAllPopups.forEach(p => p());
208 // Context Menu -> Open file location
209
210 let path = await invoke('get_user_photos_path') + '\\' + window.PhotoViewerManager.CurrentPhoto()?.path;
211 invoke('open_folder', { url: path });
212 }
213
214 viewerContextMenuButtons[1].onclick = () => {
215 window.CloseAllPopups.forEach(p => p());
216 // Context Menu -> Copy image
217 copyImage();
218 }
219
220 imageViewer.oncontextmenu = ( e ) => {
221 if(contextMenuOpen){
222 contextMenuOpen = false;
223
224 utils.set(viewerContextMenu, { opacity: 1, rotate: '0deg' });
225
226 animate(viewerContextMenu, {
227 opacity: 0,
228 rotate: '30deg',
229 easing: 'easeInOutQuad',
230 duration: 100,
231 onComplete: () => {
232 viewerContextMenu.style.display = 'none';
233 }
234 })
235 } else{
236 contextMenuOpen = true;
237
238 viewerContextMenu.style.top = e.clientY + 'px';
239 viewerContextMenu.style.left = e.clientX + 'px';
240 viewerContextMenu.style.display = 'block';
241
242 utils.set(viewerContextMenu, { opacity: 0, rotate: '-30deg' });
243
244 animate(viewerContextMenu, {
245 opacity: 1,
246 rotate: '0deg',
247 easing: 'easeInOutQuad',
248 duration: 100
249 })
250 }
251 }
252
253 createEffect(() => {
254 let photo = window.PhotoViewerManager.CurrentPhoto();
255 allowedToOpenTray = false;
256
257 imageViewer.style.opacity = '0';
258
259 if(photo){
260 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + window.PhotoViewerManager.CurrentPhoto()?.path.split('\\').join('/') + "?full";
261 imageViewer.crossOrigin = 'anonymous';
262
263 imageViewer.onload = () => { resizeImage(); }
264
265 animate(imageViewer, {
266 opacity: 1,
267 delay: 50,
268 duration: 150,
269 easing: 'easeInOutQuad'
270 })
271
272 let handleMetaDataLoaded = () => {
273 console.log(photo.metadata);
274 if(photo.metadata){
275 photo.onMetaLoaded = () => {}
276
277 try{
278 // Try JSON format ( VRCX )
279 let meta = JSON.parse(photo.metadata);
280
281 allowedToOpenTray = true;
282 trayButton.style.display = 'flex';
283
284 authorProfileButton!.style.display = 'none';
285
286 photoTray.innerHTML = '';
287 photoTray.appendChild(
288 <div class="photo-tray-columns">
289 <div class="photo-tray-column" style={{ width: '20%' }}><br />
290 <div class="tray-heading">People</div>
291
292 <For each={meta.players}>
293 {( item ) =>
294 <div>
295 { item.displayName }
296 <Show when={item.id}>
297 <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' }} />
298 </Show>
299 </div>
300 }
301 </For><br />
302 </div>
303 <div class="photo-tray-column"><br />
304 <div class="tray-heading">World</div>
305
306 <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div>
307 </div>
308 </div> as Node
309 );
310
311 window.WorldCacheManager.getWorldById(meta.world.id)
312 .then(worldData => {
313 if(worldData)
314 loadWorldData(worldData);
315 });
316 } catch(e){
317 try{
318 // Not json lets try XML (vrc prints)
319 let parser = new DOMParser();
320 let doc = parser.parseFromString(photo.metadata, "text/xml");
321
322 let id = doc.getElementsByTagName('xmp:Author')[0]!.innerHTML;
323
324 authorProfileButton!.style.display = 'flex';
325 authorProfileButton!.onclick = () => {
326 console.log(id);
327 invoke('open_url', { url: 'https://vrchat.com/home/user/' + id });
328 }
329 } catch(e){
330 console.error(e);
331 console.log('Couldn\'t decode metadata')
332
333 authorProfileButton!.style.display = 'none';
334 }
335
336 trayButton.style.display = 'none';
337 closeTray();
338 }
339 } else{
340 trayButton.style.display = 'none';
341 authorProfileButton!.style.display = 'none';
342
343 closeTray();
344 }
345 }
346
347 handleMetaDataLoaded();
348 }
349
350 if(photo && !isOpen){
351 viewer.style.display = 'flex';
352
353 animate(viewer, {
354 opacity: 1,
355 easing: 'easeInOutQuad',
356 duration: 150
357 });
358
359 utils.set('.prev-button', { left: '-50px', top: '50%' });
360 utils.set('.next-button', { right: '-50px', top: '50%' });
361
362 animate('.prev-button', { left: '0', easing: 'easeInOutQuad', duration: 100 });
363 animate('.next-button', { right: '0', easing: 'easeInOutQuad', duration: 100 });
364
365 window.CloseAllPopups.forEach(p => p());
366 } else if(!photo && isOpen){
367 animate(viewer, {
368 opacity: 0,
369 easing: 'easeInOutQuad',
370 duration: 150,
371 onComplete: () => {
372 viewer.style.display = 'none';
373 }
374 });
375
376 window.CloseAllPopups.forEach(p => p());
377
378 animate('.prev-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 });
379 animate('.next-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 });
380 }
381
382 isOpen = photo != null;
383 })
384 })
385
386 onCleanup(() => {
387 window.removeEventListener('keyup', switchPhotoWithKey);
388 })
389
390 let loadWorldData = ( data: WorldCache ) => {
391 let meta = window.PhotoViewerManager.CurrentPhoto()?.metadata;
392 if(!meta)return;
393
394 worldInfoContainer.innerHTML = '';
395 worldInfoContainer.appendChild(
396 <div>
397 <Show when={ data.worldData.found == false && meta }>
398 <div>
399 <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>
400 <div style={{ width: '75%', margin: 'auto' }}>Could not fetch world information... Is the world private?</div>
401 </div>
402 </Show>
403 <Show when={ data.worldData.found == true }>
404 <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>
405 <div style={{ width: '75%', margin: 'auto' }}>{ data.worldData.desc }</div>
406
407 <br />
408 <div class="world-tags">
409 <For each={data.worldData.tags}>
410 {( tag ) =>
411 <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div>
412 }
413 </For>
414 </div><br />
415 </Show>
416 </div> as Node
417 )
418 }
419
420 enum LayerManagerView{
421 DEFAULT,
422 PLAYER,
423 ENVIRONMENT
424 }
425
426 let layerManagerOpen = false;
427 let layerManagerAnimation: null | JSAnimation = null;
428 let layerManagerViewing = LayerManagerView.DEFAULT;
429
430 let toggleLayerManager = () => {
431 if(layerManagerOpen){
432 // Close
433 layerManagerOpen = false;
434 if(layerManagerAnimation)layerManagerAnimation.cancel();
435
436 layerManagerAnimation = animate(photoLayerManager, { translateY: '20px', opacity: 0, duration: 100, onComplete: () => utils.set(photoLayerManager, { display: 'none' }) });
437 } else{
438 // Open
439 layerManagerOpen = true;
440 if(layerManagerAnimation)layerManagerAnimation.cancel();
441
442 utils.set(photoLayerManager, { display: 'block' });
443 layerManagerAnimation = animate(photoLayerManager, { translateY: '0px', opacity: 1, duration: 100 });
444 }
445 }
446
447 return (
448 <div class="photo-viewer" ref={( el ) => viewer = el}>
449 <div class="photo-layer-manager" ref={photoLayerManager}>
450 <Show when={window.PhotoViewerManager.CurrentPhoto()?.playerLayer}>
451 <div class="photo-layer-manager-layer" onClick={() => {
452 let photo = window.PhotoViewerManager.CurrentPhoto()?.playerLayer;
453 if(!photo)return;
454
455 layerManagerViewing = LayerManagerView.PLAYER;
456
457 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full";
458 imageViewer.crossOrigin = 'anonymous';
459 }}>Player Layer</div>
460 </Show>
461 <Show when={window.PhotoViewerManager.CurrentPhoto()?.environmentLayer}>
462 <div class="photo-layer-manager-layer" onClick={() => {
463 let photo = window.PhotoViewerManager.CurrentPhoto()?.environmentLayer;
464 if(!photo)return;
465
466 layerManagerViewing = LayerManagerView.ENVIRONMENT;
467
468 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full";
469 imageViewer.crossOrigin = 'anonymous';
470 }}>Environment Layer</div>
471 </Show>
472 <div class="photo-layer-manager-layer" onClick={() => {
473 let photo = window.PhotoViewerManager.CurrentPhoto();
474 if(!photo)return;
475
476 layerManagerViewing = LayerManagerView.DEFAULT;
477
478 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full";
479 imageViewer.crossOrigin = 'anonymous';
480 }}>Default Layer</div>
481 </div>
482
483 <div class="photo-context-menu" ref={( el ) => viewerContextMenu = el}>
484 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Open file location</div>
485 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Copy image</div>
486 </div>
487
488 <div class="viewer-close viewer-button" onClick={() => window.PhotoViewerManager.Close()}>
489 <div class="icon-small" style={{ width: '10px', margin: '0' }}>
490 <img draggable="false" src="/icon/x-solid.svg"></img>
491 </div>
492 </div>
493
494 <div style={{
495 width: '100%',
496 height: '100%',
497 display: 'flex',
498 "justify-content": 'center',
499 'align-items': 'center'
500 }}>
501 <img class="image-container" ref={( el ) => imageViewer = el} />
502 </div>
503
504 <div class="prev-button" onClick={() => {
505 window.CloseAllPopups.forEach(p => p());
506 window.PhotoViewerManager.PreviousPhoto();
507 }}>
508 <div class="icon-small" style={{ width: '15px', margin: '0' }}>
509 <img draggable="false" src="/icon/arrow-left-solid.svg"></img>
510 </div>
511 </div>
512
513 <div class="next-button" onClick={() => {
514 window.CloseAllPopups.forEach(p => p());
515 window.PhotoViewerManager.NextPhoto();
516 }}>
517 <div class="icon-small" style={{ width: '15px', margin: '0' }}>
518 <img draggable="false" src="/icon/arrow-right-solid.svg"></img>
519 </div>
520 </div>
521
522 <div class="photo-tray" ref={( el ) => photoTray = el}></div>
523
524 <div class="photo-tray-close"
525 onClick={() => closeTray()}
526 ref={( el ) => photoTrayCloseBtn = el}
527 >
528 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
529 <img draggable="false" src="/icon/angle-down-solid.svg"></img>
530 </div>
531 </div>
532
533 <div class="control-buttons" ref={( el ) => photoControls = el}>
534 <div class="viewer-button"
535 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
536 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
537 onClick={() => { copyImage(); }}>
538 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
539 <img draggable="false" src="/icon/copy-solid.svg"></img>
540 </div>
541 </div>
542 <div class="viewer-button" style={{ width: '50px' }}
543 onMouseOver={( el ) => animate(el.currentTarget, { width: '70px', height: '30px', 'margin-left': '10px', 'margin-right': '10px' })}
544 onMouseLeave={( el ) => animate(el.currentTarget, { width: '50px', height: '30px', 'margin-left': '20px', 'margin-right': '20px' })}
545 ref={( el ) => trayButton = el}
546 onClick={() => openTray()}
547 >
548 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
549 <img draggable="false" src="/icon/angle-up-solid.svg"></img>
550 </div>
551 </div>
552
553 <div class="viewer-button"
554 ref={authorProfileButton!}
555 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
556 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
557 >
558 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
559 <img draggable="false" src="/icon/user-solid.svg"></img>
560 </div>
561 </div>
562
563 <Show when={window.PhotoViewerManager.CurrentPhoto()?.isMultiLayer}>
564 <div class="viewer-button"
565 onClick={toggleLayerManager}
566 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
567 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
568 >
569 <div class="icon-small" style={{ width: '17px', margin: '0' }}>
570 <img draggable="false" src="/icon/layer-group-solid-full.svg"></img>
571 </div>
572 </div>
573 </Show>
574
575 <div class="viewer-button"
576 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
577 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
578 onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("Are you sure you want to delete this photo?", async () => {
579 let photo = window.PhotoViewerManager.CurrentPhoto();
580 if(!photo)return;
581
582 invoke("delete_photo", { path: photo.path });
583
584 if(photo.playerLayer)invoke("delete_photo", { path: photo.playerLayer.path });
585 if(photo.environmentLayer)invoke("delete_photo", { path: photo.environmentLayer.path });
586 })}>
587 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
588 <img draggable="false" src="/icon/trash-solid.svg"></img>
589 </div>
590 </div>
591 </div>
592 </div>
593 )
594}
595
596export default PhotoViewer