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;