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, createSpring, 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 onMount(() => {
154 utils.set(photoControls, { translateX: '-50%' });
155 utils.set(photoTrayCloseBtn, { translateX: '-50%', opacity: 0, scale: '0.75', bottom: '10px' });
156 utils.set(photoLayerManager, { translateY: '20px', opacity: 0, display: 'none' });
157
158 window.addEventListener('keyup', switchPhotoWithKey);
159
160 let contextMenuOpen = false;
161 window.CloseAllPopups.push(() => {
162 contextMenuOpen = false;
163 utils.set(viewerContextMenu, { opacity: 1, rotate: '0deg' });
164
165 animate(viewerContextMenu, {
166 opacity: 0,
167 easing: 'easeInOutQuad',
168 rotate: '30deg',
169 duration: 100,
170 onComplete: () => {
171 viewerContextMenu.style.display = 'none';
172 }
173 })
174 });
175
176 window.CloseAllPopups.push(() => {
177 layerManagerOpen = false;
178 if(layerManagerAnimation)layerManagerAnimation.cancel();
179
180 layerManagerAnimation = animate(photoLayerManager, { translateY: '20px', opacity: 0, duration: 100, onComplete: () => utils.set(photoLayerManager, { display: 'none' }) });
181 });
182
183 viewerContextMenuButtons[0].onclick = async () => {
184 window.CloseAllPopups.forEach(p => p());
185 // Context Menu -> Open file location
186
187 let path = await invoke('get_user_photos_path') + '\\' + window.PhotoViewerManager.CurrentPhoto()?.path;
188 invoke('open_folder', { url: path });
189 }
190
191 viewerContextMenuButtons[1].onclick = () => {
192 window.CloseAllPopups.forEach(p => p());
193 // Context Menu -> Copy image
194 copyImage();
195 }
196
197 imageViewer.oncontextmenu = ( e ) => {
198 if(contextMenuOpen){
199 contextMenuOpen = false;
200
201 utils.set(viewerContextMenu, { opacity: 1, rotate: '0deg' });
202
203 animate(viewerContextMenu, {
204 opacity: 0,
205 rotate: '30deg',
206 easing: 'easeInOutQuad',
207 duration: 100,
208 onComplete: () => {
209 viewerContextMenu.style.display = 'none';
210 }
211 })
212 } else{
213 contextMenuOpen = true;
214
215 viewerContextMenu.style.top = e.clientY + 'px';
216 viewerContextMenu.style.left = e.clientX + 'px';
217 viewerContextMenu.style.display = 'block';
218
219 utils.set(viewerContextMenu, { opacity: 0, rotate: '-30deg' });
220
221 animate(viewerContextMenu, {
222 opacity: 1,
223 rotate: '0deg',
224 easing: 'easeInOutQuad',
225 duration: 100
226 })
227 }
228 }
229
230 createEffect(() => {
231 let photo = window.PhotoViewerManager.CurrentPhoto();
232 allowedToOpenTray = false;
233
234 imageViewer.style.opacity = '0';
235
236 if(photo){
237 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + window.PhotoViewerManager.CurrentPhoto()?.path.split('\\').join('/') + "?full";
238 imageViewer.crossOrigin = 'anonymous';
239
240 animate(imageViewer, {
241 opacity: 1,
242 delay: 50,
243 duration: 150,
244 easing: 'easeInOutQuad'
245 })
246
247 let handleMetaDataLoaded = () => {
248 console.log(photo.metadata);
249 if(photo.metadata){
250 photo.onMetaLoaded = () => {}
251
252 try{
253 // Try JSON format ( VRCX )
254 let meta = JSON.parse(photo.metadata);
255
256 allowedToOpenTray = true;
257 trayButton.style.display = 'flex';
258
259 authorProfileButton!.style.display = 'none';
260
261 photoTray.innerHTML = '';
262 photoTray.appendChild(
263 <div class="photo-tray-columns">
264 <div class="photo-tray-column" style={{ width: '20%' }}><br />
265 <div class="tray-heading">People</div>
266
267 <For each={meta.players}>
268 {( item ) =>
269 <div>
270 { item.displayName }
271 <Show when={item.id}>
272 <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' }} />
273 </Show>
274 </div>
275 }
276 </For><br />
277 </div>
278 <div class="photo-tray-column"><br />
279 <div class="tray-heading">World</div>
280
281 <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div>
282 </div>
283 </div> as Node
284 );
285
286 window.WorldCacheManager.getWorldById(meta.world.id)
287 .then(worldData => {
288 if(worldData)
289 loadWorldData(worldData);
290 });
291 } catch(e){
292 try{
293 // Not json lets try XML (vrc prints)
294 let parser = new DOMParser();
295 let doc = parser.parseFromString(photo.metadata, "text/xml");
296
297 let id = doc.getElementsByTagName('xmp:Author')[0]!.innerHTML;
298
299 authorProfileButton!.style.display = 'flex';
300 authorProfileButton!.onclick = () => {
301 console.log(id);
302 invoke('open_url', { url: 'https://vrchat.com/home/user/' + id });
303 }
304 } catch(e){
305 console.error(e);
306 console.log('Couldn\'t decode metadata')
307
308 authorProfileButton!.style.display = 'none';
309 }
310
311 trayButton.style.display = 'none';
312 closeTray();
313 }
314 } else{
315 trayButton.style.display = 'none';
316 authorProfileButton!.style.display = 'none';
317
318 closeTray();
319 }
320 }
321
322 handleMetaDataLoaded();
323 }
324
325 if(photo && !isOpen){
326 viewer.style.display = 'flex';
327
328 animate(viewer, {
329 opacity: 1,
330 easing: 'easeInOutQuad',
331 duration: 150
332 });
333
334 utils.set('.prev-button', { left: '-50px', top: '50%' });
335 utils.set('.next-button', { right: '-50px', top: '50%' });
336
337 animate('.prev-button', { left: '0', easing: 'easeInOutQuad', duration: 100 });
338 animate('.next-button', { right: '0', easing: 'easeInOutQuad', duration: 100 });
339
340 window.CloseAllPopups.forEach(p => p());
341 } else if(!photo && isOpen){
342 animate(viewer, {
343 opacity: 0,
344 easing: 'easeInOutQuad',
345 duration: 150,
346 onComplete: () => {
347 viewer.style.display = 'none';
348 }
349 });
350
351 window.CloseAllPopups.forEach(p => p());
352
353 animate('.prev-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 });
354 animate('.next-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 });
355 }
356
357 isOpen = photo != null;
358 })
359 })
360
361 onCleanup(() => {
362 window.removeEventListener('keyup', switchPhotoWithKey);
363 })
364
365 let loadWorldData = ( data: WorldCache ) => {
366 let meta = window.PhotoViewerManager.CurrentPhoto()?.metadata;
367 if(!meta)return;
368
369 worldInfoContainer.innerHTML = '';
370 worldInfoContainer.appendChild(
371 <div>
372 <Show when={ data.worldData.found == false && meta }>
373 <div>
374 <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>
375 <div style={{ width: '75%', margin: 'auto' }}>Could not fetch world information... Is the world private?</div>
376 </div>
377 </Show>
378 <Show when={ data.worldData.found == true }>
379 <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>
380 <div style={{ width: '75%', margin: 'auto' }}>{ data.worldData.desc }</div>
381
382 <br />
383 <div class="world-tags">
384 <For each={data.worldData.tags}>
385 {( tag ) =>
386 <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div>
387 }
388 </For>
389 </div><br />
390 </Show>
391 </div> as Node
392 )
393 }
394
395 enum LayerManagerView{
396 DEFAULT,
397 PLAYER,
398 ENVIRONMENT
399 }
400
401 let layerManagerOpen = false;
402 let layerManagerAnimation: null | JSAnimation = null;
403 let layerManagerViewing = LayerManagerView.DEFAULT;
404
405 let toggleLayerManager = () => {
406 if(layerManagerOpen){
407 // Close
408 layerManagerOpen = false;
409 if(layerManagerAnimation)layerManagerAnimation.cancel();
410
411 layerManagerAnimation = animate(photoLayerManager, { translateY: '20px', opacity: 0, duration: 100, onComplete: () => utils.set(photoLayerManager, { display: 'none' }) });
412 } else{
413 // Open
414 layerManagerOpen = true;
415 if(layerManagerAnimation)layerManagerAnimation.cancel();
416
417 utils.set(photoLayerManager, { display: 'block' });
418 layerManagerAnimation = animate(photoLayerManager, { translateY: '0px', opacity: 1, duration: 100 });
419 }
420 }
421
422 // TODO: Make layers selectable
423
424 return (
425 <div class="photo-viewer" ref={( el ) => viewer = el}>
426 <div class="photo-layer-manager" ref={photoLayerManager}>
427 <Show when={window.PhotoViewerManager.CurrentPhoto()?.playerLayer}>
428 <div class="photo-layer-manager-layer" onClick={() => {
429 let photo = window.PhotoViewerManager.CurrentPhoto()?.playerLayer;
430 if(!photo)return;
431
432 layerManagerViewing = LayerManagerView.PLAYER;
433
434 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full";
435 imageViewer.crossOrigin = 'anonymous';
436 }}>Player Layer</div>
437 </Show>
438 <Show when={window.PhotoViewerManager.CurrentPhoto()?.environmentLayer}>
439 <div class="photo-layer-manager-layer" onClick={() => {
440 let photo = window.PhotoViewerManager.CurrentPhoto()?.environmentLayer;
441 if(!photo)return;
442
443 layerManagerViewing = LayerManagerView.ENVIRONMENT;
444
445 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full";
446 imageViewer.crossOrigin = 'anonymous';
447 }}>Environment Layer</div>
448 </Show>
449 <div class="photo-layer-manager-layer" onClick={() => {
450 let photo = window.PhotoViewerManager.CurrentPhoto();
451 if(!photo)return;
452
453 layerManagerViewing = LayerManagerView.DEFAULT;
454
455 imageViewer.src = (window.OS === "windows" ? "http://photo.localhost/" : 'photo://localhost/') + photo.path.split('\\').join('/') + "?full";
456 imageViewer.crossOrigin = 'anonymous';
457 }}>Default Layer</div>
458 </div>
459
460 <div class="photo-context-menu" ref={( el ) => viewerContextMenu = el}>
461 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Open file location</div>
462 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Copy image</div>
463 </div>
464
465 <div class="viewer-close viewer-button" onClick={() => window.PhotoViewerManager.Close()}>
466 <div class="icon-small" style={{ width: '10px', margin: '0' }}>
467 <img draggable="false" src="/icon/x-solid.svg"></img>
468 </div>
469 </div>
470 <img class="image-container" ref={( el ) => imageViewer = el} />
471
472 <div class="prev-button" onClick={() => {
473 window.CloseAllPopups.forEach(p => p());
474 window.PhotoViewerManager.PreviousPhoto();
475 }}>
476 <div class="icon-small" style={{ width: '15px', margin: '0' }}>
477 <img draggable="false" src="/icon/arrow-left-solid.svg"></img>
478 </div>
479 </div>
480
481 <div class="next-button" onClick={() => {
482 window.CloseAllPopups.forEach(p => p());
483 window.PhotoViewerManager.NextPhoto();
484 }}>
485 <div class="icon-small" style={{ width: '15px', margin: '0' }}>
486 <img draggable="false" src="/icon/arrow-right-solid.svg"></img>
487 </div>
488 </div>
489
490 <div class="photo-tray" ref={( el ) => photoTray = el}></div>
491
492 <div class="photo-tray-close"
493 onClick={() => closeTray()}
494 ref={( el ) => photoTrayCloseBtn = el}
495 >
496 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
497 <img draggable="false" src="/icon/angle-down-solid.svg"></img>
498 </div>
499 </div>
500
501 <div class="control-buttons" ref={( el ) => photoControls = el}>
502 <div class="viewer-button"
503 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
504 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
505 onClick={() => { copyImage(); }}>
506 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
507 <img draggable="false" src="/icon/copy-solid.svg"></img>
508 </div>
509 </div>
510 <div class="viewer-button" style={{ width: '50px' }}
511 onMouseOver={( el ) => animate(el.currentTarget, { width: '70px', height: '30px', 'margin-left': '10px', 'margin-right': '10px' })}
512 onMouseLeave={( el ) => animate(el.currentTarget, { width: '50px', height: '30px', 'margin-left': '20px', 'margin-right': '20px' })}
513 ref={( el ) => trayButton = el}
514 onClick={() => openTray()}
515 >
516 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
517 <img draggable="false" src="/icon/angle-up-solid.svg"></img>
518 </div>
519 </div>
520
521 <div class="viewer-button"
522 ref={authorProfileButton!}
523 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
524 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
525 >
526 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
527 <img draggable="false" src="/icon/user-solid.svg"></img>
528 </div>
529 </div>
530
531 <Show when={window.PhotoViewerManager.CurrentPhoto()?.isMultiLayer}>
532 <div class="viewer-button"
533 onClick={toggleLayerManager}
534 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
535 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
536 >
537 <div class="icon-small" style={{ width: '17px', margin: '0' }}>
538 <img draggable="false" src="/icon/layer-group-solid-full.svg"></img>
539 </div>
540 </div>
541 </Show>
542
543 <div class="viewer-button"
544 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
545 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
546 onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("Are you sure you want to delete this photo?", async () => {
547 let photo = window.PhotoViewerManager.CurrentPhoto();
548 if(!photo)return;
549
550 invoke("delete_photo", { path: photo.path });
551
552 if(photo.playerLayer)invoke("delete_photo", { path: photo.playerLayer.path });
553 if(photo.environmentLayer)invoke("delete_photo", { path: photo.environmentLayer.path });
554 })}>
555 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
556 <img draggable="false" src="/icon/trash-solid.svg"></img>
557 </div>
558 </div>
559 </div>
560 </div>
561 )
562}
563
564export default PhotoViewer