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;