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 console.log(id);
277 invoke('open_url', { url: 'https://vrchat.com/home/user/' + id });
278 }
279 } catch(e){
280 console.error(e);
281 console.log('Couldn\'t decode metadata')
282
283 authorProfileButton!.style.display = 'none';
284 }
285
286 trayButton.style.display = 'none';
287 closeTray();
288 }
289 } else{
290 trayButton.style.display = 'none';
291 authorProfileButton!.style.display = 'none';
292
293 closeTray();
294 }
295 }
296
297 handleMetaDataLoaded();
298 }
299
300 if(photo && !isOpen){
301 viewer.style.display = 'flex';
302
303 animate(viewer, {
304 opacity: 1,
305 easing: 'easeInOutQuad',
306 duration: 150
307 });
308
309 utils.set('.prev-button', { left: '-50px', top: '50%' });
310 utils.set('.next-button', { right: '-50px', top: '50%' });
311
312 animate('.prev-button', { left: '0', easing: 'easeInOutQuad', duration: 100 });
313 animate('.next-button', { right: '0', easing: 'easeInOutQuad', duration: 100 });
314
315 window.CloseAllPopups.forEach(p => p());
316 } else if(!photo && isOpen){
317 animate(viewer, {
318 opacity: 0,
319 easing: 'easeInOutQuad',
320 duration: 150,
321 onComplete: () => {
322 viewer.style.display = 'none';
323 }
324 });
325
326 window.CloseAllPopups.forEach(p => p());
327
328 animate('.prev-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 });
329 animate('.next-button', { top: '75%', easing: 'easeInOutQuad', duration: 100 });
330 }
331
332 isOpen = photo != null;
333 })
334 })
335
336 onCleanup(() => {
337 window.removeEventListener('keyup', switchPhotoWithKey);
338 })
339
340 let loadWorldData = ( data: WorldCache ) => {
341 let meta = window.PhotoViewerManager.CurrentPhoto()?.metadata;
342 if(!meta)return;
343
344 worldInfoContainer.innerHTML = '';
345 worldInfoContainer.appendChild(
346 <div>
347 <Show when={ data.worldData.found == false && meta }>
348 <div>
349 <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>
350 <div style={{ width: '75%', margin: 'auto' }}>Could not fetch world information... Is the world private?</div>
351 </div>
352 </Show>
353 <Show when={ data.worldData.found == true }>
354 <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>
355 <div style={{ width: '75%', margin: 'auto' }}>{ data.worldData.desc }</div>
356
357 <br />
358 <div class="world-tags">
359 <For each={data.worldData.tags}>
360 {( tag ) =>
361 <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div>
362 }
363 </For>
364 </div><br />
365 </Show>
366 </div> as Node
367 )
368 }
369
370 return (
371 <div class="photo-viewer" ref={( el ) => viewer = el}>
372 <div class="photo-context-menu" ref={( el ) => viewerContextMenu = el}>
373 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Open file location</div>
374 <div ref={( el ) => viewerContextMenuButtons.push(el)}>Copy image</div>
375 </div>
376
377 <div class="viewer-close viewer-button" onClick={() => window.PhotoViewerManager.Close()}>
378 <div class="icon-small" style={{ width: '10px', margin: '0' }}>
379 <img draggable="false" src="/icon/x-solid.svg"></img>
380 </div>
381 </div>
382 <img class="image-container" ref={( el ) => imageViewer = el} />
383
384 <div class="prev-button" onClick={() => {
385 window.CloseAllPopups.forEach(p => p());
386 window.PhotoViewerManager.PreviousPhoto();
387 }}>
388 <div class="icon-small" style={{ width: '15px', margin: '0' }}>
389 <img draggable="false" src="/icon/arrow-left-solid.svg"></img>
390 </div>
391 </div>
392
393 <div class="next-button" onClick={() => {
394 window.CloseAllPopups.forEach(p => p());
395 window.PhotoViewerManager.NextPhoto();
396 }}>
397 <div class="icon-small" style={{ width: '15px', margin: '0' }}>
398 <img draggable="false" src="/icon/arrow-right-solid.svg"></img>
399 </div>
400 </div>
401
402 <div class="photo-tray" ref={( el ) => photoTray = el}></div>
403
404 <div class="photo-tray-close"
405 onClick={() => closeTray()}
406 ref={( el ) => photoTrayCloseBtn = el}
407 >
408 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
409 <img draggable="false" src="/icon/angle-down-solid.svg"></img>
410 </div>
411 </div>
412
413 <div class="control-buttons" ref={( el ) => photoControls = el}>
414 <div class="viewer-button"
415 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
416 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
417 onClick={() => { copyImage(); }}>
418 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
419 <img draggable="false" src="/icon/copy-solid.svg"></img>
420 </div>
421 </div>
422 <div class="viewer-button" style={{ width: '50px' }}
423 onMouseOver={( el ) => animate(el.currentTarget, { width: '70px', height: '30px', 'margin-left': '10px', 'margin-right': '10px' })}
424 onMouseLeave={( el ) => animate(el.currentTarget, { width: '50px', height: '30px', 'margin-left': '20px', 'margin-right': '20px' })}
425 ref={( el ) => trayButton = el}
426 onClick={() => openTray()}
427 >
428 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
429 <img draggable="false" src="/icon/angle-up-solid.svg"></img>
430 </div>
431 </div>
432
433 <div class="viewer-button"
434 ref={authorProfileButton!}
435 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
436 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
437 >
438 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
439 <img draggable="false" src="/icon/user-solid.svg"></img>
440 </div>
441 </div>
442
443 <div class="viewer-button"
444 onMouseOver={( el ) => animate(el.currentTarget, { width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
445 onMouseLeave={( el ) => animate(el.currentTarget, { width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
446 onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("Are you sure you want to delete this photo?", async () => { invoke("delete_photo", {
447 path: window.PhotoViewerManager.CurrentPhoto()?.path
448 });
449 })}>
450 <div class="icon-small" style={{ width: '12px', margin: '0' }}>
451 <img draggable="false" src="/icon/trash-solid.svg"></img>
452 </div>
453 </div>
454 </div>
455 </div>
456 )
457}
458
459export default PhotoViewer