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