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