A photo manager for VRChat.
1import { onCleanup, onMount, Show } from "solid-js";
2import { bytesToFormatted } from "../utils";
3import { invoke } from '@tauri-apps/api/core';
4import { ViewState } from "./Managers/ViewManager";
5import { animate, utils } from "animejs";
6
7let SettingsMenu = () => {
8 let sliderBar: HTMLElement;
9 let settingsContainer: HTMLElement;
10 let currentButton = 0;
11 let lastClickedButton = -1;
12 let finalPathConfirm: HTMLElement;
13 let finalPathInput: HTMLElement;
14 let finalPathData: string;
15 let finalPathPreviousData: string;
16
17 let closeWithKey = ( e: KeyboardEvent ) => {
18 if(e.key === 'Escape'){
19 window.ViewManager.ChangeState(ViewState.PHOTO_LIST);
20 animate('.settings', {
21 opacity: 0,
22 translateX: '500px',
23 easing: 'easeInOutQuad',
24 duration: 250,
25 onComplete: () => {
26 utils.set('.settings', { display: 'none' });
27 }
28 })
29 }
30 }
31
32 onMount(async () => {
33 if(await invoke('get_config_value_string', { key: 'transparent' }) === "true"){
34 invoke('set_config_value_string', { key: 'transparent', value: 'true' });
35
36 animate(document.body, { background: 'rgba(0, 0, 0, 0.5)', easing: 'linear', duration: 100 });
37 animate('.settings', { background: 'rgba(0, 0, 0, 0.5)', easing: 'linear', duration: 100 });
38 } else{
39 invoke('set_config_value_string', { key: 'transparent', value: 'false' });
40
41 animate(document.body, { background: 'rgba(0, 0, 0, 1)', easing: 'linear', duration: 100 });
42 animate('.settings', { background: 'rgba(0, 0, 0, 0)', easing: 'linear', duration: 100 });
43 }
44
45 let sliderMouseDown = false;
46 let mouseStartX = 0;
47
48 let width = window.innerWidth;
49 let buttons = [ 370, 680 ];
50
51 let sliderPos = width / 2 - buttons[currentButton];
52 let sliderScale = width / (buttons[1] - buttons[0]);
53
54 let render = () => {
55 requestAnimationFrame(render);
56
57 if(!sliderMouseDown){
58 sliderPos = sliderPos + (width / 2 - buttons[currentButton] - sliderPos) * 0.25;
59 utils.set(sliderBar, { translateX: sliderPos });
60
61 settingsContainer.style.left = (sliderPos - (width / 2 - buttons[0])) * sliderScale + 'px';
62 }
63 }
64
65 render();
66 utils.set(sliderBar, { translateX: sliderPos });
67
68 sliderBar.addEventListener('touchstart', ( e: TouchEvent ) => {
69 sliderMouseDown = true;
70 mouseStartX = e.touches[0].clientX;
71 })
72
73 window.addEventListener('touchmove', ( e: TouchEvent ) => {
74 if(sliderMouseDown){
75 utils.set(sliderBar, { translateX: sliderPos - (mouseStartX - e.touches[0].clientX) });
76 settingsContainer.style.left = (sliderPos - (mouseStartX - e.touches[0].clientX) - (width / 2 - buttons[0])) * sliderScale + 'px';
77 }
78 })
79
80 window.addEventListener('keyup', closeWithKey);
81
82 window.addEventListener('touchend', ( e: TouchEvent ) => {
83 if(sliderMouseDown){
84 sliderPos = sliderPos - (mouseStartX - e.touches[0].clientX);
85
86 utils.set(sliderBar, { translateX: sliderPos - (mouseStartX - e.touches[0].clientX) });
87 sliderMouseDown = false;
88
89 if(Math.abs(mouseStartX - e.touches[0].clientX) > 50){
90 let shortestDistance = 0;
91 let selectedButton = -1;
92
93 buttons.forEach(( pos, indx ) => {
94 let dis = Math.abs(sliderPos - (width / 2 - pos));
95
96 if(selectedButton === -1){
97 shortestDistance = dis;
98 selectedButton = indx;
99 } else if(shortestDistance > dis){
100 shortestDistance = dis;
101 selectedButton = indx;
102 }
103 })
104
105 currentButton = selectedButton;
106 } else if(lastClickedButton != -1){
107 currentButton = lastClickedButton;
108 lastClickedButton = -1
109 }
110 }
111 })
112
113 sliderBar.addEventListener('mousedown', ( e: MouseEvent ) => {
114 sliderMouseDown = true;
115 mouseStartX = e.clientX;
116 });
117
118 window.addEventListener('mousemove', ( e: MouseEvent ) => {
119 if(sliderMouseDown){
120 utils.set(sliderBar, { translateX: sliderPos - (mouseStartX - e.clientX) });
121 settingsContainer.style.left = sliderPos - (mouseStartX - e.clientX) + 'px';
122 settingsContainer.style.left = (sliderPos - (mouseStartX - e.clientX) - (width / 2 - buttons[0])) * sliderScale + 'px';
123 }
124 })
125
126 window.addEventListener('mouseup', ( e: MouseEvent ) => {
127 if(sliderMouseDown){
128 sliderPos = sliderPos - (mouseStartX - e.clientX);
129
130 utils.set(sliderBar, { translateX: sliderPos - (mouseStartX - e.clientX) });
131 sliderMouseDown = false;
132
133 if(Math.abs(mouseStartX - e.clientX) > 50){
134 let shortestDistance = 0;
135 let selectedButton = -1;
136
137 buttons.forEach(( pos, indx ) => {
138 let dis = Math.abs(sliderPos - (width / 2 - pos));
139
140 if(selectedButton === -1){
141 shortestDistance = dis;
142 selectedButton = indx;
143 } else if(shortestDistance > dis){
144 shortestDistance = dis;
145 selectedButton = indx;
146 }
147 })
148
149 currentButton = selectedButton;
150 } else if(lastClickedButton != -1){
151 currentButton = lastClickedButton;
152 lastClickedButton = -1
153 }
154 }
155 })
156
157 window.addEventListener('resize', () => {
158 width = window.innerWidth;
159 sliderPos = width / 2 - buttons[currentButton];
160 sliderScale = width / (buttons[1] - buttons[0]);
161
162 utils.set(sliderBar, { translateX: sliderPos });
163 })
164
165 sliderBar.addEventListener('wheel', ( e: WheelEvent ) => {
166 if(e.deltaY > 0){
167 if(buttons[currentButton + 1])
168 currentButton++;
169 } else{
170 if(buttons[currentButton - 1])
171 currentButton--;
172 }
173 })
174 })
175
176 onCleanup(() => {
177 window.removeEventListener('keyup', closeWithKey);
178 })
179
180 return (
181 <div class="settings">
182 <div class="settings-close" onClick={() => {
183 window.ViewManager.ChangeState(ViewState.PHOTO_LIST);
184 animate('.settings',
185 {
186 opacity: 0,
187 translateX: '500px',
188 easing: 'easeInOutQuad',
189 duration: 250,
190 onComplete: () => {
191 utils.set('.settings', { display: 'none' });
192 }
193 })
194 }}>
195 <div class="icon"><img draggable="false" src="/icon/x-solid.svg"></img></div>
196 </div>
197 <div class="settings-container" ref={( el ) => settingsContainer = el}>
198 <div class="settings-block">
199 <h1>Storage Settings</h1>
200 <p>{ window.PhotoManager.PhotoCount() } Photos ({ bytesToFormatted(window.PhotoManager.PhotoSize(), 0) })</p>
201
202 <div class="selector">
203 <input type="checkbox" id="start-in-bg-check" ref={async ( el ) => {
204 el.checked = await invoke('get_config_value_string', { key: 'start-in-bg' }) === "true" ? true : false;
205 }} onChange={( el ) => {
206 if(el.target.checked){
207 invoke('set_config_value_string', { key: 'start-in-bg', value: 'true' });
208 } else{
209 invoke('set_config_value_string', { key: 'start-in-bg', value: 'false' });
210 }
211 }} />
212 Start in background
213
214 <label for="start-in-bg-check">
215 <div class="selection-box">
216 <div class="icon-small" style={{ margin: '0', display: 'inline-flex' }}>
217 <img draggable="false" width="10" height="10" src="/icon/check-solid.svg"></img>
218 </div>
219 </div>
220 </label>
221 </div>
222
223 <Show when={window.OS === 'windows'}>
224 <div class="selector">
225 <input type="checkbox" id="start-with-win-check" ref={async ( el ) => {
226 el.checked = await invoke('get_config_value_string', { key: 'start-with-win' }) === "true" ? true : false;
227 }} onChange={( el ) => {
228 if(el.target.checked){
229 invoke('set_config_value_string', { key: 'start-with-win', value: 'true' });
230 invoke("start_with_win", { start: true });
231 } else{
232 invoke('set_config_value_string', { key: 'start-with-win', value: 'false' });
233 invoke("start_with_win", { start: false });
234 }
235 }} />
236 Start with windows
237
238 <label for="start-with-win-check">
239 <div class="selection-box">
240 <div class="icon-small" style={{ margin: '0', display: 'inline-flex' }}>
241 <img draggable="false" width="10" height="10" src="/icon/check-solid.svg"></img>
242 </div>
243 </div>
244 </label>
245 </div>
246 </Show>
247
248 <div class="selector">
249 <input type="checkbox" id="transparent-check" ref={async ( el ) => {
250 el.checked = await invoke('get_config_value_string', { key: 'transparent' }) === "true" ? true : false;
251 }} onChange={( el ) => {
252 if(el.target.checked){
253 invoke('set_config_value_string', { key: 'transparent', value: 'true' });
254
255 animate(document.body, { background: 'rgba(0, 0, 0, 0.5)', easing: 'linear', duration: 100 });
256 animate('.settings', { background: 'rgba(0, 0, 0, 0.5)', easing: 'linear', duration: 100 });
257 } else{
258 invoke('set_config_value_string', { key: 'transparent', value: 'false' });
259
260 animate(document.body, { background: 'rgba(0, 0, 0, 1)', easing: 'linear', duration: 100 });
261 animate('.settings', { background: 'rgba(0, 0, 0, 0)', easing: 'linear', duration: 100 });
262 }
263 }} />
264 Window Transparency
265
266 <label for="transparent-check">
267 <div class="selection-box">
268 <div class="icon-small" style={{ margin: '0', display: 'inline-flex' }}>
269 <img draggable="false" width="10" height="10" src="/icon/check-solid.svg"></img>
270 </div>
271 </div>
272 </label>
273 </div>
274
275 <br />
276 <p>
277 VRChat Photo Path:
278 <span class="path" ref={( el ) =>
279 invoke('get_user_photos_path').then(( path: any ) => {
280 el.innerHTML = '';
281 el.appendChild(<span style={{ outline: 'none' }} ref={( el ) => finalPathInput = el} onInput={( el ) => {
282 finalPathConfirm.style.display = 'inline-block';
283 finalPathData = el.target.innerHTML;
284 }} contenteditable>{path}</span> as Node);
285
286 finalPathPreviousData = path;
287 })
288 }>
289 Loading...
290 </span>
291 <span style={{ display: 'none' }} ref={( el ) => finalPathConfirm = el}>
292 <span class="path" style={{ color: 'green' }} onClick={async () => {
293 finalPathPreviousData = finalPathData;
294 finalPathConfirm.style.display = 'none';
295
296 await invoke('change_final_path', { newPath: finalPathData });
297 window.location.reload();
298
299 animate('.settings', {
300 opacity: 0,
301 translateX: '500px',
302 easing: 'easeInOutQuad',
303 duration: 250,
304 onComplete: () => {
305 utils.set('.settings', { display: 'none' });
306 }
307 })
308
309 window.location.reload();
310 }}>
311 Save
312 </span>
313
314 <span class="path" style={{ color: 'red' }} onClick={() => {
315 finalPathData = finalPathPreviousData;
316 finalPathInput.innerHTML = finalPathPreviousData;
317 finalPathConfirm.style.display = 'none';
318 }}>
319 Cancel
320 </span>
321 </span><br /><br />
322
323 VRCPM Version: <span ref={( el ) => invoke('get_version').then((ver: any) => el.innerHTML = ver)}>Loading...</span>
324 </p>
325
326 <br />
327 <p>To change the directory VRChat outputs photos to, you can change the "picture_output_folder" key in the <span style={{ color: '#00ccff', cursor: 'pointer' }} onClick={() => invoke('open_url', { url: 'https://docs.vrchat.com/docs/configuration-file#camera-and-screenshot-settings' })}>config.json file</span><br />Alternitavely, you can use VRCX to edit the config file.</p>
328
329 <br />
330 <p>VRChat Photo Manager supports photos with extra metadata provided by VRCX.</p>
331 </div>
332 <div class="settings-block">
333 <p>WIP</p>
334 </div>
335 </div>
336
337 <div class="slide-bar-tri"></div>
338 <div class="slide-bar">
339 <div class="inner-slide-bar" ref={( el ) => sliderBar = el}>
340 <div class="slider-dot"></div>
341 <div class="slider-dot"></div>
342 <div class="slider-dot"></div>
343 <div class="slider-dot"></div>
344 <div class="slider-dot"></div>
345 <div class="slider-text" onMouseDown={() => lastClickedButton = 0}>Program Settings</div>
346 <div class="slider-dot"></div>
347 <div class="slider-dot"></div>
348 <div class="slider-text" onMouseDown={() => lastClickedButton = 1}>Sync Settings</div>
349 <div class="slider-dot"></div>
350 <div class="slider-dot"></div>
351 <div class="slider-dot"></div>
352 <div class="slider-dot"></div>
353 <div class="slider-dot"></div>
354 </div>
355 </div>
356 </div>
357 )
358}
359
360export default SettingsMenu;