A photo manager for VRChat.
1import { onCleanup, onMount } from "solid-js"; 2import { listen } from '@tauri-apps/api/event'; 3import { Window } from "@tauri-apps/api/window"; 4 5import anime from "animejs"; 6import FilterMenu from "./FilterMenu"; 7import { ViewState } from "./Managers/ViewManager"; 8import { invoke } from "@tauri-apps/api/core"; 9 10enum ListPopup{ 11 FILTERS, 12 NONE 13} 14 15let PhotoList = () => { 16 let photoTreeLoadingContainer: HTMLElement; 17 18 let scrollToTop: HTMLElement; 19 let scrollToTopActive = false; 20 21 let photoContainer: HTMLCanvasElement; 22 let photoContainerBG: HTMLCanvasElement; 23 24 let filterContainer: HTMLDivElement; 25 26 let ctx: CanvasRenderingContext2D; 27 let ctxBG: CanvasRenderingContext2D; 28 29 let scroll: number = 0; 30 let targetScroll: number = 0; 31 32 let quitRender: boolean = true; 33 34 let currentPopup = ListPopup.NONE; 35 36 Window.getCurrent().isVisible().then(visible => { 37 quitRender = !visible; 38 }) 39 40 41 window.ViewManager.OnStateTransition(ViewState.PHOTO_LIST, ViewState.SETTINGS, () => { 42 anime({ targets: photoContainer, opacity: 0, easing: 'easeInOutQuad', duration: 100 }); 43 anime({ targets: '.filter-options', opacity: 0, easing: 'easeInOutQuad', duration: 100 }); 44 anime({ targets: '.reload-photos', opacity: 0, easing: 'easeInOutQuad', duration: 100 }); 45 }); 46 47 window.ViewManager.OnStateTransition(ViewState.SETTINGS, ViewState.PHOTO_LIST, () => { 48 anime({ targets: photoContainer, opacity: 1, easing: 'easeInOutQuad', duration: 100 }); 49 anime({ targets: '.filter-options', opacity: 1, easing: 'easeInOutQuad', duration: 100 }); 50 anime({ targets: '.reload-photos', opacity: 1, easing: 'easeInOutQuad', duration: 100 }); 51 }); 52 53 54 window.ViewManager.OnStateTransition(ViewState.PHOTO_LIST, ViewState.PHOTO_VIEWER, () => { 55 anime({ targets: photoContainer, opacity: 0, easing: 'easeInOutQuad', duration: 100 }); 56 anime({ targets: '.filter-options', opacity: 0, easing: 'easeInOutQuad', duration: 100 }); 57 anime({ targets: '.reload-photos', opacity: 0, easing: 'easeInOutQuad', duration: 100 }); 58 }); 59 60 window.ViewManager.OnStateTransition(ViewState.PHOTO_VIEWER, ViewState.PHOTO_LIST, () => { 61 anime({ targets: photoContainer, opacity: 1, easing: 'easeInOutQuad', duration: 100 }); 62 anime({ targets: '.filter-options', opacity: 1, easing: 'easeInOutQuad', duration: 100 }); 63 anime({ targets: '.reload-photos', opacity: 1, easing: 'easeInOutQuad', duration: 100 }); 64 }); 65 66 67 let closeWithKey = ( e: KeyboardEvent ) => { 68 if(e.key === 'Escape'){ 69 closeCurrentPopup(); 70 } 71 } 72 73 let onResize = () => { 74 photoContainer.width = window.innerWidth; 75 photoContainer.height = window.innerHeight; 76 77 photoContainerBG.width = window.innerWidth; 78 photoContainerBG.height = window.innerHeight; 79 80 window.PhotoListRenderingManager.ComputeLayout(); 81 } 82 83 let closeCurrentPopup = () => { 84 switch(currentPopup){ 85 case ListPopup.FILTERS: 86 anime({ 87 targets: filterContainer!, 88 opacity: 0, 89 easing: 'easeInOutQuad', 90 duration: 100, 91 complete: () => { 92 filterContainer!.style.display = 'none'; 93 currentPopup = ListPopup.NONE; 94 } 95 }); 96 97 break; 98 } 99 } 100 101 let fps = 0; 102 setInterval(() => { 103 console.log('FPS: ' + fps); 104 fps = 0; 105 }, 1000); 106 107 let render = () => { 108 if(!quitRender) 109 requestAnimationFrame(render); 110 else 111 return quitRender = false; 112 113 if(!scrollToTopActive && scroll > photoContainer.height){ 114 scrollToTop.style.display = 'flex'; 115 anime({ targets: scrollToTop, opacity: 1, translateY: '0px', easing: 'easeInOutQuad', duration: 100 }); 116 117 scrollToTopActive = true; 118 } else if(scrollToTopActive && scroll < photoContainer.height){ 119 anime({ targets: scrollToTop, opacity: 0, translateY: '-10px', complete: () => scrollToTop.style.display = 'none', easing: 'easeInOutQuad', duration: 100 }); 120 scrollToTopActive = false; 121 } 122 123 if(!ctx || !ctxBG)return; 124 ctx.clearRect(0, 0, photoContainer.width, photoContainer.height); 125 ctxBG.clearRect(0, 0, photoContainerBG.width, photoContainerBG.height); 126 127 scroll = scroll + (targetScroll - scroll) * 0.1; 128 129 window.PhotoListRenderingManager.Render(ctx, photoContainer!, scroll); 130 131 if(window.PhotoManager.FilteredPhotos.length == 0){ 132 ctx.textAlign = 'center'; 133 ctx.textBaseline = 'middle'; 134 ctx.globalAlpha = 1; 135 ctx.fillStyle = '#fff'; 136 ctx.font = '40px Rubik'; 137 138 ctx.fillText("It's looking empty in here! You have no photos :O", photoContainer.width / 2, photoContainer.height / 2); 139 } 140 141 ctxBG.drawImage(photoContainer, 0, 0); 142 fps += 1; 143 } 144 145 listen('hide-window', () => { 146 quitRender = true; 147 console.log('Hide Window'); 148 }) 149 150 listen('show-window', () => { 151 if(quitRender)quitRender = false; 152 console.log('Shown Window'); 153 154 photoContainer.width = window.innerWidth; 155 photoContainer.height = window.innerHeight; 156 157 photoContainerBG.width = window.innerWidth; 158 photoContainerBG.height = window.innerHeight; 159 160 if(window.PhotoManager.HasFirstLoaded){ 161 requestAnimationFrame(render); 162 window.PhotoManager.HasFirstLoaded = false; 163 } 164 }) 165 166 window.PhotoManager.OnLoadingFinished(() => { 167 invoke('close_splashscreen'); 168 169 anime({ 170 targets: photoTreeLoadingContainer, 171 height: 0, 172 easing: 'easeInOutQuad', 173 duration: 500, 174 opacity: 0, 175 complete: () => { 176 photoTreeLoadingContainer.style.display = 'none'; 177 } 178 }) 179 180 anime({ 181 targets: '.reload-photos', 182 opacity: 1, 183 duration: 150, 184 easing: 'easeInOutQuad' 185 }) 186 187 window.PhotoListRenderingManager.SetCanvas(photoContainer!); 188 window.PhotoListRenderingManager.ComputeLayout(); 189 190 render(); 191 }); 192 193 onMount(() => { 194 ctx = photoContainer.getContext('2d')!; 195 ctxBG = photoContainerBG.getContext('2d')!; 196 197 window.PhotoManager.Load(); 198 199 anime.set(scrollToTop, { opacity: 0, translateY: '-10px', display: 'none' }); 200 201 photoContainer.onwheel = ( e: WheelEvent ) => { 202 targetScroll += e.deltaY * 2; 203 204 if(targetScroll < 0) 205 targetScroll = 0; 206 }; 207 208 window.addEventListener('keyup', closeWithKey); 209 window.addEventListener('resize', onResize); 210 211 photoContainer.width = window.innerWidth; 212 photoContainer.height = window.innerHeight; 213 214 photoContainerBG.width = window.innerWidth; 215 photoContainerBG.height = window.innerHeight; 216 217 photoContainer.onclick = ( e: MouseEvent ) => { 218 let photo = window.PhotoManager.FilteredPhotos.find(x => 219 e.clientX > x.x && 220 e.clientY > x.y && 221 e.clientX < x.x + x.scaledWidth! && 222 e.clientY < x.y + x.scaledHeight! && 223 x.shown 224 ); 225 226 if(photo) 227 window.PhotoViewerManager.OpenPhoto(photo); 228 // else 229 // currentPhotoIndex = -1; 230 } 231 }) 232 233 onCleanup(() => { 234 photoContainer.onwheel = () => {}; 235 photoContainer.onclick = () => {}; 236 237 window.removeEventListener('keyup', closeWithKey); 238 window.removeEventListener('resize', onResize); 239 }) 240 241 return ( 242 <div class="photo-list"> 243 <div ref={filterContainer!} class="filter-container" style={{ 244 height: window.PhotoManager.HasBeenIndexed() ? '83px' : '110px', 245 width: window.PhotoManager.HasBeenIndexed() ? '600px' : '650px' 246 }}> 247 <FilterMenu /> 248 </div> 249 250 <div class="photo-tree-loading" ref={( el ) => photoTreeLoadingContainer = el}>Scanning Photo Tree...</div> 251 252 <div class="scroll-to-top" ref={( el ) => scrollToTop = el} onClick={() => targetScroll = 0}> 253 <div class="icon"> 254 <img draggable="false" src="/icon/angle-up-solid.svg"></img> 255 </div> 256 </div> 257 <div class="reload-photos" onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("Are you sure you want to reload all photos? This can cause the application to slow down while it is loading...", () => window.location.reload())}> 258 <div class="icon" style={{ width: '17px' }}> 259 <img draggable="false" width="17" height="17" src="/icon/arrows-rotate-solid.svg"></img> 260 </div> 261 </div> 262 263 <div class="filter-options"> 264 <div> 265 <div onClick={() => { 266 if(currentPopup != ListPopup.NONE)return closeCurrentPopup(); 267 currentPopup = ListPopup.FILTERS; 268 269 filterContainer!.style.display = 'block'; 270 271 anime({ 272 targets: filterContainer!, 273 opacity: 1, 274 easing: 'easeInOutQuad', 275 duration: 100 276 }); 277 }} class="icon" style={{ width: '20px', height: '20px', padding: '20px' }}> 278 <img draggable="false" style={{ width: "20px", height: "20px" }} src="/icon/sliders-solid.svg"></img> 279 </div> 280 <div class="icon-label">Filters</div> 281 </div> 282 </div> 283 284 <canvas class="photo-container" ref={( el ) => photoContainer = el}></canvas> 285 <canvas class="photo-container-bg" ref={( el ) => photoContainerBG = el}></canvas> 286 </div> 287 ) 288} 289 290export default PhotoList;