diff --git a/src/App.tsx b/src/App.tsx index 85bcd91..ff5ade4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,9 +27,26 @@ import logo from "./iloader.svg"; import { GlassCard } from "./components/GlassCard"; import { useTranslation } from "react-i18next"; import { usePlatform } from "./PlatformContext"; +import { useStore } from "./StoreContext"; +import { + anisetteServers, + AnisetteMeasurement, + measureAnisetteServers, +} from "./anisette"; + +const anisetteCheckIntervalMs = 30000; function App() { const { t } = useTranslation(); + const [anisetteServer, setAnisetteServer] = useStore( + "anisetteServer", + "ani.sidestore.io", + ); + const [anisetteMeasurements, setAnisetteMeasurements] = useState< + AnisetteMeasurement[] + >([]); + const anisetteServerRef = useRef(anisetteServer); + const setAnisetteServerRef = useRef(setAnisetteServer); const [operationState, setOperationState] = useState( null, @@ -72,6 +89,70 @@ function App() { checkForUpdates(); }, []); + useEffect(() => { + anisetteServerRef.current = anisetteServer; + }, [anisetteServer]); + + useEffect(() => { + setAnisetteServerRef.current = setAnisetteServer; + }, [setAnisetteServer]); + + useEffect(() => { + let cancelled = false; + let intervalId: number | undefined; + let isInitialCheck = true; + + const checkAnisetteServers = async () => { + if (document.visibilityState !== "visible") return; + + const measurements = await measureAnisetteServers(); + if (cancelled) return; + + setAnisetteMeasurements(measurements); + + const currentAnisetteServer = anisetteServerRef.current; + + // User is on a custom (non-preset) server: never auto-pick for them. + const isCustomServer = !anisetteServers.some( + (server) => server.value === currentAnisetteServer, + ); + if (isCustomServer) { + isInitialCheck = false; + return; + } + + const currentMeasurement = measurements.find( + (measurement) => measurement.value === currentAnisetteServer, + ); + + // First run: silently pick the fastest server outright. + // After that, only switch when the current server stops responding. + if (!isInitialCheck && currentMeasurement?.ttfbMs !== null) return; + isInitialCheck = false; + + const fastestMeasurement = measurements + .filter((measurement) => measurement.ttfbMs !== null) + .sort((left, right) => left.ttfbMs! - right.ttfbMs!)[0]; + + if (!fastestMeasurement || fastestMeasurement.value === currentAnisetteServer) { + return; + } + + setAnisetteServerRef.current(fastestMeasurement.value); + }; + + checkAnisetteServers(); + intervalId = window.setInterval( + checkAnisetteServers, + anisetteCheckIntervalMs, + ); + + return () => { + cancelled = true; + if (intervalId !== undefined) window.clearInterval(intervalId); + }; + }, []); + const shortcutLabel = useCallback( (mac: string, windows: string, linux?: string) => { if (platform === "mac") return mac; @@ -393,6 +474,7 @@ function App() { platform={platform} shortcutLabel={shortcutLabel} checkKeyring={checkKeyring} + anisetteMeasurements={anisetteMeasurements} /> diff --git a/src/anisette.ts b/src/anisette.ts new file mode 100644 index 0000000..57a0cdf --- /dev/null +++ b/src/anisette.ts @@ -0,0 +1,85 @@ +export type AnisetteServer = { + value: string; + label: string; +}; + +export type AnisetteSpeedGrade = + | "very_fast" + | "fast" + | "good" + | "normal" + | "slow" + | "very_slow" + | "no_response"; + +export type AnisetteMeasurement = { + value: string; + ttfbMs: number | null; + grade: AnisetteSpeedGrade; +}; + +export const anisetteServers: AnisetteServer[] = [ + { value: "ani.sidestore.io", label: "SideStore" }, + { value: "ani.stikstore.app", label: "StikStore" }, + { value: "ani.sidestore.app", label: "SideStore" }, + { value: "ani.sidestore.zip", label: "SideStore" }, + { value: "ani.846969.xyz", label: "SideStore" }, + { value: "ani.neoarz.xyz", label: "neoarz" }, + { value: "ani.xu30.top", label: "SteX" }, + { value: "anisette.wedotstud.io", label: "WE. Studio" }, + { value: "ani.waterwave.space", label: "waterwave" }, +]; + +const timeoutMs = 5000; + +export const normalizeAnisetteUrl = (server: string) => { + return server.startsWith("http://") || server.startsWith("https://") + ? server + : `https://${server}`; +}; + +export const getAnisetteSpeedGrade = ( + ttfbMs: number | null, +): AnisetteSpeedGrade => { + if (ttfbMs === null) return "no_response"; + if (ttfbMs <= 50) return "very_fast"; + if (ttfbMs <= 120) return "fast"; + if (ttfbMs <= 250) return "good"; + if (ttfbMs <= 500) return "normal"; + if (ttfbMs <= 1000) return "slow"; + return "very_slow"; +}; + +export const measureAnisetteServer = async ( + server: AnisetteServer, +): Promise => { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + const startedAt = performance.now(); + + try { + await fetch(normalizeAnisetteUrl(server.value), { + cache: "no-store", + mode: "no-cors", + signal: controller.signal, + }); + const ttfbMs = Math.round(performance.now() - startedAt); + return { + value: server.value, + ttfbMs, + grade: getAnisetteSpeedGrade(ttfbMs), + }; + } catch { + return { + value: server.value, + ttfbMs: null, + grade: "no_response", + }; + } finally { + window.clearTimeout(timeout); + } +}; + +export const measureAnisetteServers = async () => { + return Promise.all(anisetteServers.map(measureAnisetteServer)); +}; diff --git a/src/components/Dropdown.css b/src/components/Dropdown.css index 0dc9625..9ff57ff 100644 --- a/src/components/Dropdown.css +++ b/src/components/Dropdown.css @@ -16,6 +16,42 @@ font-weight: 500; } +.dropdown-selected-text, +.dropdown-option-text { + min-width: 0; + display: flex; + align-items: baseline; + gap: 0.45rem; +} + +.dropdown-selected-text { + flex: 1; +} + +.dropdown-option-text { + flex: 1; + justify-content: space-between; +} + +.dropdown-option-main { + min-width: 0; + display: flex; + align-items: baseline; + gap: 0.45rem; +} + +.dropdown-sub-label, +.dropdown-detail { + color: rgba(255, 255, 255, 0.52); + font-size: 0.82rem; + font-weight: 400; + white-space: nowrap; +} + +.dropdown-detail { + margin-left: auto; +} + .custom-dropdown.open .dropdown-toggle { border-color: var(--accent-primary); background: #3a3a3c; @@ -47,6 +83,7 @@ z-index: 5; max-height: 260px; overflow-y: auto; + overflow-anchor: none; } .custom-dropdown.flip-up .dropdown-menu { @@ -71,6 +108,7 @@ color: inherit; font: inherit; text-align: left; + scroll-margin: 0; } .dropdown-option:hover, @@ -87,4 +125,5 @@ color: var(--accent-primary); font-weight: 700; font-size: 0.95rem; + flex: 0 0 auto; } diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 7d87b21..344be13 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -5,6 +5,8 @@ import { useTranslation } from "react-i18next"; export type DropdownOption = { value: string; label: string; + subLabel?: string; + detail?: string; }; type DropdownProps = { @@ -41,6 +43,7 @@ export const Dropdown = ({ const [flipUp, setFlipUp] = useState(false); const dropdownRef = useRef(null); const toggleRef = useRef(null); + const frozenOptionsRef = useRef(options); const customInputRef = useRef(null); const lastCustomValueRef = useRef( allowCustom && options.every((option) => option.value !== value) @@ -53,6 +56,15 @@ export const Dropdown = ({ return options.every((option) => option.value !== value); }, [allowCustom, options, value]); + const openMenu = () => { + frozenOptionsRef.current = options; + setOpen(true); + }; + + const closeMenu = () => { + setOpen(false); + }; + useEffect(() => { if (!open || !toggleRef.current) return; const rect = toggleRef.current.getBoundingClientRect(); @@ -69,20 +81,20 @@ export const Dropdown = ({ dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { - setOpen(false); + closeMenu(); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { - setOpen(false); + closeMenu(); } }; - document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("click", handleClickOutside); document.addEventListener("keydown", handleEscape); return () => { - document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("click", handleClickOutside); document.removeEventListener("keydown", handleEscape); }; }, [open]); @@ -96,7 +108,7 @@ export const Dropdown = ({ }, [allowCustom, isCustom, value]); const activateCustom = () => { - setOpen(false); + closeMenu(); const nextValue = lastCustomValueRef.current || defaultCustomValue; if (nextValue) { onChange(nextValue); @@ -104,7 +116,7 @@ export const Dropdown = ({ }; const deactivateCustom = () => { - setOpen(false); + closeMenu(); if (options.length > 0) { onChange(options[0].value); } @@ -122,17 +134,25 @@ export const Dropdown = ({ lastCustomValueRef.current && lastCustomValueRef.current.length > 0 ? `${resolvedCustomLabel} (${lastCustomValueRef.current})` : resolvedCustomLabel; + const menuOptions = open ? frozenOptionsRef.current : options; const dropdownOptions = allowCustom - ? [...options, { value: customValueKey, label: menuLabel }] - : options; + ? [...menuOptions, { value: customValueKey, label: menuLabel }] + : menuOptions; const selectedValue = isCustom ? customValueKey : value; const presetLabel = options.find((option) => option.value === value)?.label ?? t("dropdown.select"); + const presetSubLabel = options.find((option) => option.value === value) + ?.subLabel; const selectedLabel = isCustom ? lastCustomValueRef.current || resolvedCustomLabel : presetLabel; + const selectOption = (optionValue: string) => { + closeMenu(); + onChange(optionValue); + }; + return (