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