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