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;