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 isSyncing: window.AccountManager.hasAccount() ? window.AccountManager.Storage()?.isSyncing : false 462 }); 463 })}> 464 <div class="icon" style={{ width: '12px', margin: '0' }}> 465 <img draggable="false" src="/icon/trash-solid.svg"></img> 466 </div> 467 </div> 468 </div> 469 </div> 470 ) 471} 472 473export default PhotoViewer