A photo manager for VRChat.

add filters

Changed files
+180 -53
src
src-tauri
+2 -1
changelog
··· 18 18 - Migrate to tauri v2 19 19 20 20 - Photos shouldn't cause the ui to lag while loading 21 - - Removed the metadata loading screen in favour of loading the metadata just before an image it rendered 22 21 - Added the context menu back to the photo viewer screen 22 + - Added filter menu, you can now search for photos taken in specific worlds or with specific people 23 23 - Fixed some weird bugs where the world data cache would be ignored 24 24 - Fixed the ui forgetting the user account in some cases where the token stored it still valid 25 25 - Updated no photos text to be kinder 26 26 - Settings menu can now be closed with ESC 27 27 - Fixed photos being extremely wide under certain conditions 28 28 - Fixed some icons not showing correctly 29 + - Fixed a bug where it would store multiple versions of cache for a world, and then request the world data again everytime 29 30 30 31 - Photo viewer can now be navigated with keybinds: 31 32 - Up Arrow: Open Tray
+5
src-tauri/src/util/handle_uri_proto.rs
··· 21 21 } 22 22 23 23 // TODO: Only accept files that are in the vrchat photos folder 24 + // Slightly more complex than originally thought, need to find a way to cache the VRC photos path 25 + // since i need to be able to load lots of photos very quickly. This shouldn't be a security issue 26 + // because tauri should only let the frontend of VRCPhotoManager read files throught this. Only 27 + // becomes a potential issue if the frontend gets modified or there's an issue with tauri. 28 + 24 29 let path = uri.path().split_at(1).1; 25 30 let file = fs::File::open(path); 26 31
+37
src/Components/FilterMenu.tsx
··· 1 + enum FilterType{ 2 + USER, WORLD 3 + } 4 + 5 + class FilterMenuProps{ 6 + setFilterType!: ( type: FilterType ) => void; 7 + setFilter!: ( filter: string ) => void; 8 + } 9 + 10 + let FilterMenu = ( props: FilterMenuProps ) => { 11 + let selectionButtons: HTMLDivElement[] = []; 12 + 13 + let select = ( index: number ) => { 14 + selectionButtons.forEach(e => e.classList.remove('selected-filter')); 15 + selectionButtons[index].classList.add('selected-filter'); 16 + } 17 + 18 + return ( 19 + <> 20 + <div class="filter-type-select"> 21 + <div class="selected-filter" ref={( el ) => selectionButtons.push(el)} onClick={() => { 22 + select(0); 23 + props.setFilterType(FilterType.USER); 24 + }}>User</div> 25 + <div ref={( el ) => selectionButtons.push(el)} onClick={() => { 26 + select(1); 27 + props.setFilterType(FilterType.WORLD); 28 + }}>World</div> 29 + </div> 30 + 31 + <input class="filter-search" type="text" onInput={( el ) => props.setFilter(el.target.value)} placeholder="Enter Search Term..."></input> 32 + </> 33 + ) 34 + } 35 + 36 + export default FilterMenu 37 + export { FilterType }
+84 -42
src/Components/PhotoList.tsx
··· 3 3 import { listen } from '@tauri-apps/api/event'; 4 4 5 5 import anime from "animejs"; 6 + import FilterMenu, { FilterType } from "./FilterMenu"; 6 7 7 8 const PHOTO_HEIGHT = 200; 8 9 const MAX_IMAGE_LOAD = 10; ··· 30 31 NONE 31 32 } 32 33 33 - // TODO: Photo filtering / Searching (By users, By date, By world) 34 34 let PhotoList = ( props: PhotoListProps ) => { 35 35 let amountLoaded = 0; 36 36 let imagesLoading = 0; 37 + 38 + let hasFirstLoaded = false; 37 39 38 40 let photoTreeLoadingContainer: HTMLElement; 39 41 ··· 51 53 let photos: Photo[] = []; 52 54 let currentPhotoIndex: number = -1; 53 55 54 - let datesList: any = {}; 55 - 56 56 let scroll: number = 0; 57 57 let targetScroll: number = 0; 58 58 ··· 61 61 62 62 let currentPopup = ListPopup.NONE; 63 63 64 + let filterType: FilterType = FilterType.USER; 65 + let filter = ''; 66 + 67 + let filteredPhotos: Photo[] = []; 68 + 64 69 let closeWithKey = ( e: KeyboardEvent ) => { 65 70 if(e.key === 'Escape'){ 66 71 closeCurrentPopup(); ··· 128 133 this.dateString = this.path.split('_')[1]; 129 134 } 130 135 136 + loadMeta(){ 137 + invoke('load_photo_meta', { photo: this.path }); 138 + } 139 + 131 140 loadImage(){ 132 141 if(this.loading || this.loaded || imagesLoading >= MAX_IMAGE_LOAD)return; 133 142 134 - invoke('load_photo_meta', { photo: this.path }); 143 + this.loadMeta(); 135 144 if(!this.metaLoaded)return; 136 145 137 146 this.loading = true; ··· 164 173 165 174 switch(action){ 166 175 case 'prev': 167 - if(!photos[currentPhotoIndex - 1])break; 168 - props.setCurrentPhotoView(photos[currentPhotoIndex - 1]); 176 + if(!filteredPhotos[currentPhotoIndex - 1])break; 177 + props.setCurrentPhotoView(filteredPhotos[currentPhotoIndex - 1]); 169 178 170 179 currentPhotoIndex--; 171 180 break; 172 181 case 'next': 173 - if(!photos[currentPhotoIndex + 1])break; 174 - props.setCurrentPhotoView(photos[currentPhotoIndex + 1]); 182 + if(!filteredPhotos[currentPhotoIndex + 1])break; 183 + props.setCurrentPhotoView(filteredPhotos[currentPhotoIndex + 1]); 175 184 176 185 currentPhotoIndex++; 177 186 break; ··· 207 216 scroll = scroll + (targetScroll - scroll) * 0.2; 208 217 209 218 let lastPhoto; 210 - for (let i = 0; i < photos.length; i++) { 211 - let p = photos[i]; 219 + for (let i = 0; i < filteredPhotos.length; i++) { 220 + let p = filteredPhotos[i]; 212 221 213 222 if(currentRowIndex * 210 - scroll > photoContainer.height){ 214 223 p.shown = false; ··· 328 337 }) 329 338 } 330 339 331 - if(photos.length == 0){ 340 + if(filteredPhotos.length == 0){ 332 341 ctx.textAlign = 'center'; 333 342 ctx.textBaseline = 'middle'; 334 343 ctx.globalAlpha = 1; 335 344 ctx.fillStyle = '#fff'; 336 - ctx.font = '50px Rubik'; 345 + ctx.font = '40px Rubik'; 337 346 338 347 ctx.fillText("It's looking empty in here! You have no photos :O", photoContainer.width / 2, photoContainer.height / 2); 339 348 } ··· 361 370 362 371 photo.metaLoaded = true; 363 372 photo.onMetaLoaded(); 373 + 374 + if(amountLoaded === photos.length && !hasFirstLoaded){ 375 + filteredPhotos = photos; 376 + hasFirstLoaded = true; 377 + 378 + anime({ 379 + targets: photoTreeLoadingContainer, 380 + height: 0, 381 + easing: 'easeInOutQuad', 382 + duration: 500, 383 + opacity: 0, 384 + complete: () => { 385 + photoTreeLoadingContainer.style.display = 'none'; 386 + } 387 + }) 388 + 389 + anime({ 390 + targets: '.reload-photos', 391 + opacity: 1, 392 + duration: 150, 393 + easing: 'easeInOutQuad' 394 + }) 395 + 396 + render(); 397 + } 364 398 }) 365 399 366 400 listen('photo_create', ( event: any ) => { 367 401 let photo = new Photo(event.payload); 402 + 368 403 photos.splice(0, 0, photo); 404 + photo.loadMeta(); 369 405 370 406 if(!props.isPhotosSyncing() && props.storageInfo().sync){ 371 407 props.setIsPhotosSyncing(true); ··· 392 428 quitRender = true; 393 429 amountLoaded = 0; 394 430 scroll = 0; 431 + 395 432 photos = []; 433 + filteredPhotos = []; 396 434 397 435 anime({ 398 436 targets: '.reload-photos', ··· 418 456 photoPaths.forEach(( path: string ) => { 419 457 let photo = new Photo(path); 420 458 photos.push(photo); 421 - }) 422 459 423 - anime({ 424 - targets: photoTreeLoadingContainer, 425 - height: 0, 426 - easing: 'easeInOutQuad', 427 - duration: 500, 428 - opacity: 0, 429 - complete: () => { 430 - photoTreeLoadingContainer.style.display = 'none'; 431 - } 432 - }) 433 - 434 - anime({ 435 - targets: '.reload-photos', 436 - opacity: 1, 437 - duration: 150, 438 - easing: 'easeInOutQuad' 460 + photo.loadMeta(); 439 461 }) 440 - 441 - photoPaths.forEach(( path: string ) => { 442 - let date = path.split('_')[1]; 443 - 444 - if(!datesList[date]) 445 - datesList[date] = 1; 446 - }); 447 - 448 - render(); 449 462 }) 450 463 } 451 464 ··· 480 493 }) 481 494 482 495 photoContainer.addEventListener('click', ( e: MouseEvent ) => { 483 - let photo = photos.find(x => 496 + let photo = filteredPhotos.find(x => 484 497 e.clientX > x.x && 485 498 e.clientY > x.y && 486 499 e.clientX < x.x + x.scaledWidth! && ··· 490 503 491 504 if(photo){ 492 505 props.setCurrentPhotoView(photo); 493 - currentPhotoIndex = photos.indexOf(photo); 506 + currentPhotoIndex = filteredPhotos.indexOf(photo); 494 507 } else 495 508 currentPhotoIndex = -1; 496 509 }) ··· 500 513 window.removeEventListener('keyup', closeWithKey); 501 514 }) 502 515 503 - return ( 516 + let reloadFilters = () => { 517 + filteredPhotos = []; 518 + 519 + switch(filterType){ 520 + case FilterType.USER: 521 + photos.map(p => { 522 + if(p.metadata){ 523 + let meta = JSON.parse(p.metadata); 524 + let photo = meta.players.find(( y: any ) => y.displayName.toLowerCase().includes(filter) || y.id === filter); 525 + 526 + if(photo)filteredPhotos.push(p); 527 + } 528 + }) 529 + break; 530 + case FilterType.WORLD: 531 + photos.map(p => { 532 + if(p.metadata){ 533 + let meta = JSON.parse(p.metadata); 534 + let photo = meta.world.name.toLowerCase().includes(filter) || meta.world.id === filter; 535 + 536 + if(photo)filteredPhotos.push(p); 537 + } 538 + }) 539 + break; 540 + } 541 + } 542 + 543 + return ( 504 544 <div class="photo-list"> 505 545 <div ref={filterContainer!} class="filter-container"> 506 - <div class="filter-title">Filters</div> 546 + <FilterMenu 547 + setFilter={( f ) => { filter = f.toLowerCase(); reloadFilters(); }} 548 + setFilterType={( t ) => { filterType = t; reloadFilters(); }} /> 507 549 </div> 508 550 509 551 <div class="photo-tree-loading" ref={( el ) => photoTreeLoadingContainer = el}>Scanning Photo Tree...</div>
+3 -6
src/Components/PhotoViewer.tsx
··· 253 253 imageViewer.style.opacity = '0'; 254 254 255 255 if(photo){ 256 - console.log(photo); 257 - 258 256 (async () => { 259 257 if(!photoPath) 260 258 photoPath = await invoke('get_user_photos_path') + '/'; ··· 277 275 278 276 let meta = JSON.parse(photo.metadata); 279 277 let worldData = worldCache.find(x => x.worldData.id === meta.world.id); 280 - 278 + 281 279 allowedToOpenTray = true; 282 280 trayButton.style.display = 'flex'; 283 281 ··· 305 303 </div> 306 304 </div> as Node 307 305 ); 308 - 309 - 306 + 310 307 if(!worldData){ 311 308 console.log('Fetching new world data'); 312 309 ··· 314 311 } else if(worldData.expiresOn < Date.now()){ 315 312 console.log('Fetching new world data since cache has expired'); 316 313 317 - worldCache = worldCache.filter(x => x !== worldData) 314 + worldCache = worldCache.filter(x => x.worldData.id !== meta.world.id) 318 315 invoke('find_world_by_id', { worldId: meta.world.id }); 319 316 } else 320 317 loadWorldData(worldData);
+49 -4
src/styles.css
··· 197 197 .filter-container{ 198 198 display: none; 199 199 position: fixed; 200 - top: 50%; 200 + bottom: 0; 201 201 left: 50%; 202 202 width: 600px; 203 - height: 250px; 204 - transform: translate(-50%, -50%); 203 + height: 83px; 204 + transform: translate(-50%); 205 205 padding: 10px; 206 - border-radius: 5px; 206 + border-radius: 5px 5px 0 0; 207 207 backdrop-filter: blur(5px); 208 208 background: #555a; 209 209 color: #fff; ··· 214 214 215 215 .filter-container > .filter-title{ 216 216 font-size: 30px; 217 + } 218 + 219 + .filter-type-select{ 220 + display: flex; 221 + justify-content: center; 222 + align-items: center; 223 + width: 75%; 224 + margin: auto; 225 + } 226 + 227 + .filter-type-select > div{ 228 + width: 100%; 229 + border: #fff 4px solid; 230 + border-left: #fff 2px solid; 231 + border-right: #fff 2px solid; 232 + padding: 5px 0; 233 + cursor: pointer; 234 + user-select: none; 235 + } 236 + 237 + .filter-type-select > div:first-child{ 238 + border-left: #fff 4px solid; 239 + border-radius: 10px 0 0 10px; 240 + } 241 + 242 + .filter-type-select > div:last-child{ 243 + border-right: #fff 4px solid; 244 + border-radius: 0 10px 10px 0; 245 + } 246 + 247 + .filter-type-select > .selected-filter{ 248 + background: #00ccff55; 249 + } 250 + 251 + .filter-search{ 252 + margin-top: 10px; 253 + padding: 5px; 254 + border: #fff 4px solid; 255 + border-radius: 10px; 256 + background: #0008; 257 + outline: none; 258 + color: white; 259 + font-size: 15px; 260 + font-family: 'Rubik'; 261 + width: calc(75% - 18px); 217 262 } 218 263 219 264 .date-list{