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 window.PhotoListRenderingManager.ComputeLayout();
158
159 render();
160 });
161
162 onMount(() => {
163 ctx = photoContainer.getContext('2d')!;
164
165 window.PhotoManager.Load();
166
167 utils.set(scrollToTop, { opacity: 0, translateY: '-10px', display: 'none' });
168
169 photoContainer.onwheel = ( e: WheelEvent ) => {
170 targetScroll += e.deltaY * 2;
171
172 if(targetScroll < 0)
173 targetScroll = 0;
174 };
175
176 window.addEventListener('keyup', closeWithKey);
177 window.addEventListener('resize', onResize);
178
179 photoContainer.width = window.innerWidth;
180 photoContainer.height = window.innerHeight;
181
182 photoContainer.onclick = ( e: MouseEvent ) => {
183 let photo = window.PhotoManager.FilteredPhotos.find(x =>
184 e.clientX > x.x &&
185 e.clientY > x.y &&
186 e.clientX < x.x + x.scaledWidth! &&
187 e.clientY < x.y + x.scaledHeight! &&
188 x.shown
189 );
190
191 if(photo)
192 window.PhotoViewerManager.OpenPhoto(photo);
193 // else
194 // currentPhotoIndex = -1;
195 }
196 })
197
198 onCleanup(() => {
199 photoContainer.onwheel = () => {};
200 photoContainer.onclick = () => {};
201
202 window.removeEventListener('keyup', closeWithKey);
203 window.removeEventListener('resize', onResize);
204 })
205
206 return (
207 <div class="photo-list">
208 <div ref={filterContainer!} class="filter-container">
209 <FilterMenu />
210 </div>
211
212 <div class="scroll-to-top" ref={( el ) => scrollToTop = el} onClick={() => targetScroll = 0}>
213 <div class="icon">
214 <img draggable="false" src="/icon/angle-up-solid.svg"></img>
215 </div>
216 </div>
217
218 <div class="filter-options">
219 <div>
220 <div onClick={() => {
221 if(currentPopup != ListPopup.NONE)return closeCurrentPopup();
222 currentPopup = ListPopup.FILTERS;
223
224 filterContainer!.style.display = 'block';
225
226 animate(filterContainer!, {
227 opacity: 1,
228 translateY: 0,
229 easing: 'easeInOutQuad',
230 duration: 100
231 });
232 }} class="icon">
233 <img draggable="false" style={{ width: "20px", height: "20px" }} src="/icon/sliders-solid.svg"></img>
234 </div>
235 <div class="icon-label">Filters</div>
236 </div>
237
238 <div>
239 <div onClick={() => {
240 window.location.reload();
241 }} class="icon">
242 <img draggable="false" style={{ width: "20px", height: "20px" }} src="/icon/arrows-rotate-solid.svg"></img>
243 </div>
244 <div class="icon-label">Reload Photos</div>
245 </div>
246
247 <div>
248 <div onClick={() => {
249 utils.set('.settings', { display: 'block' });
250 animate('.settings', {
251 opacity: 1,
252 translateX: '0px',
253 easing: 'easeInOutQuad',
254 duration: 250
255 })
256
257 window.ViewManager.ChangeState(ViewState.SETTINGS);
258 }} class="icon">
259 <img draggable="false" style={{ width: "20px", height: "20px" }} src="/icon/gear-solid-full.svg"></img>
260 </div>
261 <div class="icon-label">Settings</div>
262 </div>
263 </div>
264
265 <canvas class="photo-container" ref={( el ) => photoContainer = el}></canvas>
266 </div>
267 )
268}
269
270export default PhotoList;