diff --git a/WebPageHtml.pas b/WebPageHtml.pas
index c1fd882..a752033 100644
--- a/WebPageHtml.pas
+++ b/WebPageHtml.pas
@@ -97,6 +97,36 @@ begin
'.wf-area canvas{position:absolute;top:0;left:0;width:100%;height:100%}' +
'.sbar{flex:0 0 16px;display:flex;align-items:center;padding:0 6px;gap:10px;background:#0e0e0e;border-top:1px solid #222;font-size:10px;color:#555}' +
'#latEl{margin-left:auto}' +
+ '@media screen and (max-width:640px){' +
+ // Тулбар 1: горизонтальный скролл вместо переноса
+ '.tbar:not(.row2){overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;}' +
+ '.tbar:not(.row2)::-webkit-scrollbar{display:none;}' +
+ '.tbar:not(.row2)>*{flex-shrink:0;}' +
+ // Крупнее зоны касания
+ 'button{height:30px;}select{height:30px;}' +
+ // VFO-строка: стекаем smeter / vfo-mid / right-g каждый на своей строке
+ '.row2{height:auto;flex-wrap:wrap;padding:3px;gap:3px;}' +
+ '#smeter{width:100%;height:40px;}' +
+ '.vfo-mid{width:100%;gap:2px;justify-content:flex-start;}' +
+ '.vfo-ro{font-size:20px;height:34px;padding:0 2px;padding-top:6px;}' +
+ '.vfo-ro .dg,.vfo-ro .sp{font-size:20px;}' +
+ '.vfo-hz .dg{font-size:10px;}' +
+ '.vbtn{width:26px;height:26px;font-size:10px;}' +
+ '.swap-g button{height:22px;padding:0 4px;font-size:9px;}' +
+ '.vfo-sp{display:none;}' +
+ '.right-g{width:100%;margin-left:0;}' +
+ '.right-g button,.right-g select{height:28px;font-size:11px;}' +
+ // Водопад короче на телефоне
+ '.wf-area{flex:0 0 180px;}' +
+ // Слайдеры чуть уже
+ 'input[type=range]{width:55px;}' +
+ // TX+MOX пинним в начало тулбара чтобы были видны без скролла
+ '#toneBtn{order:-3;}' +
+ '.tx-g{order:-2;}' +
+ '#moxBtn{order:-1;min-width:52px;height:30px;font-weight:bold;}' +
+ // iOS Safari: 100vh включает адресную строку — используем реальную высоту
+ 'body,.wrap{height:-webkit-fill-available;}' +
+ '}' +
'' +
'
' +
@@ -110,7 +140,7 @@ begin
'
|' +
'
' +
'
|' +
- '
TX50
' +
+ '
TX50
' +
'
' +
'
|' +
'
' +
@@ -146,7 +176,7 @@ begin
'
' +
'
' +
'
' +
- '' +
+ '' +
'' +
'' +
'' +
@@ -543,6 +573,13 @@ begin
'});' +
'cv.addEventListener("mouseleave",()=>{cv.style.cursor="";if(!markerOn)hoverX=-1;wTarget="";dragging=false;});}' +
'bindCv(specCv);bindCv(wfCv);' +
+ // Touch→mouse мост для канвасов: drag-to-pan и tap-to-tune на мобильных
+ 'function addTouch(cv){' +
+ 'function mkMs(type,t){return new MouseEvent(type,{bubbles:true,cancelable:true,clientX:t.clientX,clientY:t.clientY});}' +
+ 'cv.addEventListener("touchstart",e=>{e.preventDefault();audioKick();cv.dispatchEvent(mkMs("mousedown",e.touches[0]));},{passive:false});' +
+ 'cv.addEventListener("touchmove",e=>{e.preventDefault();cv.dispatchEvent(mkMs("mousemove",e.touches[0]));},{passive:false});' +
+ 'cv.addEventListener("touchend",e=>{e.preventDefault();cv.dispatchEvent(mkMs("mouseup",e.changedTouches[0]));},{passive:false});}' +
+ 'addTouch(specCv);addTouch(wfCv);' +
'window.addEventListener("wheel",e=>{if(e.target?.closest?.(".vfo-ro"))return;if(!wTarget){if(e.target===specCv)wTarget="spec";else if(e.target===wfCv)wTarget="wf";}if(wTarget)doWheel(e);},{passive:false,capture:true});' +
// Opus: async decodeFrame, очередь, оверлей для user gesture
diff --git a/WebServer.pas b/WebServer.pas
index 782ecef..0dd7666 100644
--- a/WebServer.pas
+++ b/WebServer.pas
@@ -588,6 +588,9 @@ begin
SockClose(CSock);
Continue;
end;
+ // 1 секунда на отправку: если TCP-буфер клиента переполнен, SockSend
+ // вернёт ошибку вместо того чтобы висеть и держать FClientLock вечно.
+ SockSetSndTimeout(CSock, 1000);
Client := TWsClient.Create(CSock);
FClientLock.Enter;
try
diff --git a/WebUtils.pas b/WebUtils.pas
index 6ec261e..a995f5f 100644
--- a/WebUtils.pas
+++ b/WebUtils.pas
@@ -54,6 +54,11 @@ function SockRecv(S: TSocket; Buf: Pointer; Len, Flags: Integer): Integer; inli
function SockSend(S: TSocket; Buf: Pointer; Len, Flags: Integer): Integer; inline;
procedure SockSetNonBlock(S: TSocket; NB: Boolean);
+{ SockSetSndTimeout — ограничивает время блокирующего SockSend.
+ Без таймаута SockSend может зависнуть на минуты (TCP retransmit timeout)
+ если буфер клиента переполнен, держа при этом FClientLock и блокируя Stop(). }
+procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
+
{ ── SHA-1 ─────────────────────────────────────────────────────────────────── }
type
@@ -111,6 +116,13 @@ begin
ioctlsocket(S, FIONBIO, @Mode);
end;
+procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
+var T: DWORD;
+begin
+ T := Ms;
+ setsockopt(S, SOL_SOCKET, SO_SNDTIMEO, @T, SizeOf(T));
+end;
+
{$ELSE}
function SockClose(S: TSocket): Integer;
@@ -145,6 +157,14 @@ begin
fpFcntl(S, F_SETFL, Flags);
end;
+procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
+var TV: TTimeVal;
+begin
+ TV.tv_sec := Ms div 1000;
+ TV.tv_usec := (Ms mod 1000) * 1000;
+ fpSetSockOpt(S, SOL_SOCKET, SO_SNDTIMEO, @TV, SizeOf(TV));
+end;
+
{$ENDIF}
{ ═══════════════════════════════════════════════════════════════════════════
diff --git a/WsClient.pas b/WsClient.pas
index aa5c46e..57a8f8b 100644
--- a/WsClient.pas
+++ b/WsClient.pas
@@ -145,6 +145,9 @@ begin
P := @Data;
Result := SendRaw(P^, Len);
end;
+ // Ошибка отправки (таймаут SO_SNDTIMEO, разрыв) — немедленно закрываем
+ // клиент чтобы BroadcastBinary не держал FClientLock на следующих тиках.
+ if not Result then FState := wsClosed;
finally
FLock.Leave;
end;