Improve: 카메라 프리뷰 모바일 대응 + WebSocket 안정성 강화

- iOS/iPadOS 감지 → base64 텍스트 JSON 프리뷰 전송 (바이너리 WS 회피)
- Android/PC는 기존 바이너리 패킷 유지 (클라이언트별 포맷 분기)
- 양방향 WebSocket keepalive (클라이언트 ping 3초 + 서버 keepalive 5초)
- websocket-sharp KeepClean + WaitTime 설정
- 하트비트 타임아웃 완화 (12초) + 재연결 지수 백오프
- 재연결 후 프리뷰 구독 딜레이 (iOS 1.5초)
- SendMessage/SendBinary에 연결 상태 체크 추가
- 모바일 CSS 반응형 (태블릿/폰 터치 타겟, 그리드, 풀스크린 safe-area)
- 대시보드 Cache-Control 강화 (no-store)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user 2026-02-17 22:35:38 +09:00
parent ea3b97b822
commit 53fa19e834
4 changed files with 154 additions and 23 deletions

Binary file not shown.

Binary file not shown.

View File

@ -43,6 +43,10 @@ public class StreamDeckServerManager : MonoBehaviour
private readonly Queue<System.Action> mainThreadActions = new Queue<System.Action>();
private readonly object lockObject = new object();
// WebSocket keep-alive
private float lastPingTime = 0f;
private const float WsPingInterval = 5f;
// 카메라 프리뷰 내부 상태
private readonly List<Camera> previewCameraPool = new List<Camera>();
private RenderTexture previewRT;
@ -108,6 +112,13 @@ public class StreamDeckServerManager : MonoBehaviour
}
}
}
// WebSocket 프로토콜 레벨 ping — WiFi 라우터의 유휴 연결 드롭 방지
if (Time.time - lastPingTime >= WsPingInterval)
{
lastPingTime = Time.time;
BroadcastWsPing();
}
}
void LateUpdate()
@ -133,6 +144,8 @@ public class StreamDeckServerManager : MonoBehaviour
{
// 0.0.0.0 으로 바인딩하여 LAN 내 다른 기기에서도 접속 가능
server = new WebSocketServer(port);
server.KeepClean = true; // 비활성 세션 자동 정리
server.WaitTime = TimeSpan.FromSeconds(3); // ping-pong 응답 대기 시간
server.AddWebSocketService<StreamDeckService>("/");
server.Start();
Debug.Log($"[StreamDeckServerManager] WebSocket 서버 시작됨, 포트: {port} (모든 인터페이스)");
@ -143,6 +156,12 @@ public class StreamDeckServerManager : MonoBehaviour
}
}
private void BroadcastWsPing()
{
if (server == null || !server.IsListening) return;
BroadcastMessage("{\"type\":\"keepalive\"}");
}
private void StopServer()
{
if (server != null)
@ -404,7 +423,6 @@ public class StreamDeckServerManager : MonoBehaviour
case "ping":
SendJson(service, new { type = "pong", timestamp = DateTime.UtcNow.ToString("o") });
break;
// SystemController 명령어들
case "toggle_optitrack_markers":
case "show_optitrack_markers":
@ -433,7 +451,17 @@ public class StreamDeckServerManager : MonoBehaviour
// 카메라 프리뷰
case "subscribe_preview":
PreviewSubscribe(service);
{
bool useBase64 = false;
if (message.ContainsKey("data"))
{
if (message["data"] is Newtonsoft.Json.Linq.JObject jData && jData.ContainsKey("format"))
useBase64 = jData["format"]?.ToString() == "base64";
else if (message["data"] is Dictionary<string, object> dData && dData.ContainsKey("format"))
useBase64 = dData["format"]?.ToString() == "base64";
}
PreviewSubscribe(service, useBase64);
}
break;
case "unsubscribe_preview":
PreviewUnsubscribe(service);
@ -868,22 +896,18 @@ public class StreamDeckServerManager : MonoBehaviour
});
}
private const int PreviewMaxFailCount = 5;
private void BroadcastPreviewBinary(int index, string name, byte[] jpegData)
{
int currentIndex = cameraManager.GetCameraListData().current_index;
bool isActive = (currentIndex == index);
int totalCameras = cameraManager.cameraPresets.Count;
byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(name);
byte nameLen = (byte)Math.Min(nameBytes.Length, 255);
byte[] packet = new byte[4 + nameLen + jpegData.Length];
packet[0] = (byte)index;
packet[1] = (byte)totalCameras;
packet[2] = (byte)(isActive ? 1 : 0);
packet[3] = nameLen;
Buffer.BlockCopy(nameBytes, 0, packet, 4, nameLen);
Buffer.BlockCopy(jpegData, 0, packet, 4 + nameLen, jpegData.Length);
// 바이너리 패킷 (필요 시 생성)
byte[] binaryPacket = null;
// Base64 텍스트 JSON (필요 시 생성)
string base64Json = null;
StreamDeckService[] clients;
lock (previewSubscriberLock)
@ -893,22 +917,61 @@ public class StreamDeckServerManager : MonoBehaviour
foreach (var client in clients)
{
try
{
client.SendBinary(packet);
}
catch (Exception)
if (client.previewFailCount >= PreviewMaxFailCount)
{
lock (previewSubscriberLock)
{
previewSubscribers.Remove(client);
}
Debug.Log($"[StreamDeckServerManager] 프리뷰 구독 제거 (연속 {PreviewMaxFailCount}회 전송 실패)");
continue;
}
if (client.previewUseBase64)
{
// iOS 등: base64 텍스트 JSON으로 전송 (WebSocket 바이너리 프레임 불안정 기기용)
if (base64Json == null)
{
base64Json = JsonConvert.SerializeObject(new
{
type = "camera_preview_frame",
data = new
{
camera_index = index,
camera_name = name,
image = Convert.ToBase64String(jpegData),
total_cameras = totalCameras,
is_active = isActive
}
});
}
client.SendPreviewTextAsync(base64Json);
}
else
{
// Android/PC: 바이너리 패킷 (효율적)
if (binaryPacket == null)
{
byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(name);
byte nameLen = (byte)Math.Min(nameBytes.Length, 255);
binaryPacket = new byte[4 + nameLen + jpegData.Length];
binaryPacket[0] = (byte)index;
binaryPacket[1] = (byte)totalCameras;
binaryPacket[2] = (byte)(isActive ? 1 : 0);
binaryPacket[3] = nameLen;
Buffer.BlockCopy(nameBytes, 0, binaryPacket, 4, nameLen);
Buffer.BlockCopy(jpegData, 0, binaryPacket, 4 + nameLen, jpegData.Length);
}
client.SendPreviewAsync(binaryPacket);
}
}
}
private void PreviewSubscribe(StreamDeckService client)
private void PreviewSubscribe(StreamDeckService client, bool useBase64 = false)
{
client.previewSendBusy = false;
client.previewFailCount = 0;
client.previewUseBase64 = useBase64;
lock (previewSubscriberLock)
{
previewSubscribers.Add(client);
@ -1042,6 +1105,11 @@ public class StreamDeckService : WebSocketBehavior
{
private StreamDeckServerManager serverManager;
// 프리뷰 비동기 전송 상태
public volatile bool previewSendBusy;
public int previewFailCount;
public bool previewUseBase64; // iOS 등 바이너리 WS 불안정 기기용 텍스트 모드
protected override void OnOpen()
{
serverManager = StreamDeckServerManager.Instance;
@ -1054,14 +1122,24 @@ public class StreamDeckService : WebSocketBehavior
protected override void OnMessage(WebSocketSharp.MessageEventArgs e)
{
// client_ping은 메인 스레드를 거치지 않고 즉시 응답 (iOS WiFi keepalive용)
if (e.Data != null && e.Data.Contains("\"client_ping\""))
{
try { Send("{\"type\":\"client_pong\"}"); } catch { }
return;
}
if (serverManager != null)
{
serverManager.ProcessMessageOnMainThread(e.Data, this);
}
}
public bool IsConnected => ReadyState == WebSocketSharp.WebSocketState.Open;
public void SendMessage(string message)
{
if (!IsConnected) return;
try
{
Send(message);
@ -1074,6 +1152,7 @@ public class StreamDeckService : WebSocketBehavior
public void SendBinary(byte[] data)
{
if (!IsConnected) return;
try
{
Send(data);
@ -1084,6 +1163,58 @@ public class StreamDeckService : WebSocketBehavior
}
}
/// <summary>
/// 프리뷰 프레임 비동기 전송 (바이너리). 이전 프레임 전송 중이면 스킵 (backpressure 방지).
/// </summary>
public void SendPreviewAsync(byte[] data)
{
if (previewSendBusy || !IsConnected) return;
previewSendBusy = true;
try
{
SendAsync(data, completed =>
{
previewSendBusy = false;
if (completed)
previewFailCount = 0;
else
previewFailCount++;
});
}
catch (Exception)
{
previewSendBusy = false;
previewFailCount++;
}
}
/// <summary>
/// 프리뷰 프레임 비동기 전송 (텍스트/Base64). iOS 등 바이너리 WS 불안정 기기용.
/// </summary>
public void SendPreviewTextAsync(string json)
{
if (previewSendBusy || !IsConnected) return;
previewSendBusy = true;
try
{
SendAsync(json, completed =>
{
previewSendBusy = false;
if (completed)
previewFailCount = 0;
else
previewFailCount++;
});
}
catch (Exception)
{
previewSendBusy = false;
previewFailCount++;
}
}
protected override void OnClose(WebSocketSharp.CloseEventArgs e)
{
if (serverManager != null)

View File

@ -211,7 +211,7 @@ public class StreamingleDashboardServer
byte[] buffer = Encoding.UTF8.GetBytes(responseContent);
context.Response.ContentType = contentType;
context.Response.ContentLength64 = buffer.Length;
context.Response.AddHeader("Cache-Control", "no-cache");
context.Response.AddHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
context.Response.OutputStream.Write(buffer, 0, buffer.Length);
context.Response.Close();
}