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:
parent
ea3b97b822
commit
53fa19e834
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
Binary file not shown.
BIN
Assets/Resources/StreamingleDashboard/dashboard_style.txt
(Stored with Git LFS)
BIN
Assets/Resources/StreamingleDashboard/dashboard_style.txt
(Stored with Git LFS)
Binary file not shown.
@ -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)
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user