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