import { useEffect, useMemo, useRef, useState } from 'react'; import { Gauge, Plus, Save, X, Zap } from 'lucide-react'; import { useSettingsStore } from '../store/useSettingsStore'; import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; import { useTranslation } from 'react-i18next'; type SpeedUnit = 'KB/s' | 'MB/s'; const MAX_LIMIT_KIB = 10_485_760; const MAX_LIMIT_MB = 10240; const KIB_PER_MIB = 1024; export function speedValueToKiB(value: number, unit: SpeedUnit): number { const numericValue = Number.isFinite(value) ? value : 1; const valueKiB = unit === 'MB/s' ? numericValue * KIB_PER_MIB : numericValue; return Math.max(1, Math.min(MAX_LIMIT_KIB, Math.round(valueKiB))); } export function speedValueFromKiB(valueKiB: number, unit: SpeedUnit): number { const normalizedKiB = Math.max(1, Math.min(MAX_LIMIT_KIB, Math.round(valueKiB))); return unit === 'MB/s' ? normalizedKiB / KIB_PER_MIB : normalizedKiB; } export function convertSpeedValue(value: number, fromUnit: SpeedUnit, toUnit: SpeedUnit): number { return speedValueFromKiB(speedValueToKiB(value, fromUnit), toUnit); } export function parseLimit( limit: string, fallback: number, fallbackUnit: SpeedUnit = 'MB/s' ): { value: number; unit: SpeedUnit } { const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i); const suffix = match?.[2].toLowerCase(); const valueKiB = match ? speedValueToKiB(Number(match[1]) * (suffix === 'm' ? KIB_PER_MIB : 1), 'KB/s') : speedValueToKiB(fallback, 'KB/s'); if (!match) { return { value: speedValueFromKiB(valueKiB, fallbackUnit), unit: fallbackUnit }; } if (suffix === 'm') { return { value: speedValueFromKiB(valueKiB, 'MB/s'), unit: 'MB/s' }; } if (suffix === 'k') { return { value: valueKiB, unit: 'KB/s' }; } return valueKiB >= 1024 && valueKiB % 1024 === 0 ? { value: valueKiB / 1024, unit: 'MB/s' } : { value: valueKiB, unit: 'KB/s' }; } export function formatSpeedLimitForStorage(value: number, unit: SpeedUnit): string { const valueKiB = speedValueToKiB(value, unit); return unit === 'MB/s' ? `${speedValueFromKiB(valueKiB, 'MB/s')}M` : `${valueKiB}K`; } export function clampSpeedDisplayValue(value: number, unit: SpeedUnit): number { const numericValue = Number.isFinite(value) ? value : speedValueFromKiB(1, unit); const minimum = speedValueFromKiB(1, unit); const maximum = speedValueFromKiB(MAX_LIMIT_KIB, unit); return Math.max(minimum, Math.min(maximum, numericValue)); } function sanitizePresetValues(values: number[]): number[] { const cleaned = values .map(value => Number(value)) .filter(value => Number.isFinite(value) && value > 0) .map(value => speedValueFromKiB(speedValueToKiB(value, 'MB/s'), 'MB/s')); return Array.from(new Set(cleaned)).sort((a, b) => a - b); } export function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number { return speedValueFromKiB(speedValueToKiB(value, unit), 'MB/s'); } export function displayValueFromPresetBase(value: number, unit: SpeedUnit): number { return speedValueFromKiB(speedValueToKiB(value, 'MB/s'), unit); } export function formatPresetValue(value: number): string { return String(value); } export default function SpeedLimiterView() { const { t } = useTranslation(); const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit); const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB); const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit); const speedLimitPresetValues = useSettingsStore(state => state.speedLimitPresetValues); const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit); const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB); const setLastCustomSpeedLimitUnit = useSettingsStore(state => state.setLastCustomSpeedLimitUnit); const setSpeedLimitPresetValues = useSettingsStore(state => state.setSpeedLimitPresetValues); const fallbackUnit: SpeedUnit = lastCustomSpeedLimitUnit === 'KB/s' ? 'KB/s' : 'MB/s'; const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit); const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit)); const [value, setValue] = useState(String(initial.value)); const [unit, setUnit] = useState(initial.unit); const [customPresetValue, setCustomPresetValue] = useState(String(initial.value)); const { addToast } = useToast(); const savingRef = useRef(false); const presetValues = useMemo( () => sanitizePresetValues(speedLimitPresetValues), [speedLimitPresetValues] ); useEffect(() => { const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit); setEnabled(Boolean(globalSpeedLimit)); setValue(String(parsed.value)); setUnit(parsed.unit); setCustomPresetValue(String(parsed.value)); }, [globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit]); const [isSaving, setIsSaving] = useState(false); const save = async () => { if (savingRef.current) return; savingRef.current = true; const numericValue = clampSpeedDisplayValue(Number(value), unit); const valueKiB = speedValueToKiB(numericValue, unit); setIsSaving(true); try { await setGlobalSpeedLimit(enabled ? formatSpeedLimitForStorage(numericValue, unit) : ''); setLastCustomSpeedLimitKiB(valueKiB); setLastCustomSpeedLimitUnit(unit); addToast({ message: enabled ? t($ => $.speedLimiter.globalLimitSaved, { value: numericValue, unit }) : t($ => $.speedLimiter.globalLimitDisabled), variant: 'success' }); } catch (error) { addToast({ message: t($ => $.speedLimiter.saveFailed, { detail: String(error) }), variant: 'error', isActionable: true }); } finally { savingRef.current = false; setIsSaving(false); } }; const preset = (presetValue: number) => { setEnabled(true); setValue(String(displayValueFromPresetBase(presetValue, unit))); }; const applyCustomPreset = () => { const numericValue = clampSpeedDisplayValue(Number(customPresetValue), unit); const presetBaseValue = Math.min(MAX_LIMIT_MB, presetBaseFromDisplayValue(numericValue, unit)); const nextPresets = sanitizePresetValues([...presetValues, presetBaseValue]); const alreadyExists = nextPresets.length === presetValues.length; const storedPresetDisplayValue = displayValueFromPresetBase(presetBaseValue, unit); setSpeedLimitPresetValues(nextPresets); setEnabled(true); setValue(String(storedPresetDisplayValue)); addToast({ message: alreadyExists ? t($ => $.speedLimiter.presetAlreadyExists, { value: formatPresetValue(storedPresetDisplayValue), unit }) : t($ => $.speedLimiter.presetAdded, { value: formatPresetValue(storedPresetDisplayValue), unit }), variant: alreadyExists ? 'info' : 'success' }); }; const removePreset = (presetValue: number) => { const displayValue = displayValueFromPresetBase(presetValue, unit); const nextPresets = presetValues.filter(value => value !== presetValue); setSpeedLimitPresetValues(nextPresets); addToast({ message: t($ => $.speedLimiter.presetRemoved, { value: formatPresetValue(displayValue), unit }), variant: 'info' }); }; const changeUnit = (nextUnit: SpeedUnit) => { if (nextUnit === unit) return; setValue(String(convertSpeedValue(Number(value), unit, nextUnit))); setCustomPresetValue(String(convertSpeedValue(Number(customPresetValue), unit, nextUnit))); setUnit(nextUnit); }; const currentDisplayValue = Number.isFinite(Number(value)) && Number(value) > 0 ? value : String(speedValueFromKiB(1, unit)); return (
{t($ => $.speedLimiter.title)}
{enabled ? `${currentDisplayValue} ${unit}` : t($ => $.speedLimiter.unlimited)}
{t($ => $.speedLimiter.globalSpeedLimit)}

{t($ => $.speedLimiter.description)}

setValue(event.target.value)} className="app-control w-28 px-3 py-2 text-right font-mono" />
{(['KB/s', 'MB/s'] as SpeedUnit[]).map(option => ( ))}
{t($ => $.speedLimiter.quickPresets)}
{presetValues.map(presetValue => { const displayValue = displayValueFromPresetBase(presetValue, unit); return (
); })}
setCustomPresetValue(event.target.value)} className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50" aria-label={t($ => $.speedLimiter.customPresetIn, { unit })} /> {unit}
); }