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;