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