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 Queue<System.Action> mainThreadActions = new Queue<System.Action>();
|
||||||
private readonly object lockObject = new object();
|
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 readonly List<Camera> previewCameraPool = new List<Camera>();
|
||||||
private RenderTexture previewRT;
|
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()
|
void LateUpdate()
|
||||||
@ -133,6 +144,8 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
// 0.0.0.0 으로 바인딩하여 LAN 내 다른 기기에서도 접속 가능
|
// 0.0.0.0 으로 바인딩하여 LAN 내 다른 기기에서도 접속 가능
|
||||||
server = new WebSocketServer(port);
|
server = new WebSocketServer(port);
|
||||||
|
server.KeepClean = true; // 비활성 세션 자동 정리
|
||||||
|
server.WaitTime = TimeSpan.FromSeconds(3); // ping-pong 응답 대기 시간
|
||||||
server.AddWebSocketService<StreamDeckService>("/");
|
server.AddWebSocketService<StreamDeckService>("/");
|
||||||
server.Start();
|
server.Start();
|
||||||
Debug.Log($"[StreamDeckServerManager] WebSocket 서버 시작됨, 포트: {port} (모든 인터페이스)");
|
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()
|
private void StopServer()
|
||||||
{
|
{
|
||||||
if (server != null)
|
if (server != null)
|
||||||
@ -404,7 +423,6 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
case "ping":
|
case "ping":
|
||||||
SendJson(service, new { type = "pong", timestamp = DateTime.UtcNow.ToString("o") });
|
SendJson(service, new { type = "pong", timestamp = DateTime.UtcNow.ToString("o") });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// SystemController 명령어들
|
// SystemController 명령어들
|
||||||
case "toggle_optitrack_markers":
|
case "toggle_optitrack_markers":
|
||||||
case "show_optitrack_markers":
|
case "show_optitrack_markers":
|
||||||
@ -433,7 +451,17 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
|
|
||||||
// 카메라 프리뷰
|
// 카메라 프리뷰
|
||||||
case "subscribe_preview":
|
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;
|
break;
|
||||||
case "unsubscribe_preview":
|
case "unsubscribe_preview":
|
||||||
PreviewUnsubscribe(service);
|
PreviewUnsubscribe(service);
|
||||||
@ -868,22 +896,18 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int PreviewMaxFailCount = 5;
|
||||||
|
|
||||||
private void BroadcastPreviewBinary(int index, string name, byte[] jpegData)
|
private void BroadcastPreviewBinary(int index, string name, byte[] jpegData)
|
||||||
{
|
{
|
||||||
int currentIndex = cameraManager.GetCameraListData().current_index;
|
int currentIndex = cameraManager.GetCameraListData().current_index;
|
||||||
bool isActive = (currentIndex == index);
|
bool isActive = (currentIndex == index);
|
||||||
int totalCameras = cameraManager.cameraPresets.Count;
|
int totalCameras = cameraManager.cameraPresets.Count;
|
||||||
|
|
||||||
byte[] nameBytes = System.Text.Encoding.UTF8.GetBytes(name);
|
// 바이너리 패킷 (필요 시 생성)
|
||||||
byte nameLen = (byte)Math.Min(nameBytes.Length, 255);
|
byte[] binaryPacket = null;
|
||||||
|
// Base64 텍스트 JSON (필요 시 생성)
|
||||||
byte[] packet = new byte[4 + nameLen + jpegData.Length];
|
string base64Json = null;
|
||||||
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);
|
|
||||||
|
|
||||||
StreamDeckService[] clients;
|
StreamDeckService[] clients;
|
||||||
lock (previewSubscriberLock)
|
lock (previewSubscriberLock)
|
||||||
@ -893,22 +917,61 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
|
|
||||||
foreach (var client in clients)
|
foreach (var client in clients)
|
||||||
{
|
{
|
||||||
try
|
if (client.previewFailCount >= PreviewMaxFailCount)
|
||||||
{
|
|
||||||
client.SendBinary(packet);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
{
|
||||||
lock (previewSubscriberLock)
|
lock (previewSubscriberLock)
|
||||||
{
|
{
|
||||||
previewSubscribers.Remove(client);
|
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)
|
lock (previewSubscriberLock)
|
||||||
{
|
{
|
||||||
previewSubscribers.Add(client);
|
previewSubscribers.Add(client);
|
||||||
@ -1042,6 +1105,11 @@ public class StreamDeckService : WebSocketBehavior
|
|||||||
{
|
{
|
||||||
private StreamDeckServerManager serverManager;
|
private StreamDeckServerManager serverManager;
|
||||||
|
|
||||||
|
// 프리뷰 비동기 전송 상태
|
||||||
|
public volatile bool previewSendBusy;
|
||||||
|
public int previewFailCount;
|
||||||
|
public bool previewUseBase64; // iOS 등 바이너리 WS 불안정 기기용 텍스트 모드
|
||||||
|
|
||||||
protected override void OnOpen()
|
protected override void OnOpen()
|
||||||
{
|
{
|
||||||
serverManager = StreamDeckServerManager.Instance;
|
serverManager = StreamDeckServerManager.Instance;
|
||||||
@ -1054,14 +1122,24 @@ public class StreamDeckService : WebSocketBehavior
|
|||||||
|
|
||||||
protected override void OnMessage(WebSocketSharp.MessageEventArgs e)
|
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)
|
if (serverManager != null)
|
||||||
{
|
{
|
||||||
serverManager.ProcessMessageOnMainThread(e.Data, this);
|
serverManager.ProcessMessageOnMainThread(e.Data, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsConnected => ReadyState == WebSocketSharp.WebSocketState.Open;
|
||||||
|
|
||||||
public void SendMessage(string message)
|
public void SendMessage(string message)
|
||||||
{
|
{
|
||||||
|
if (!IsConnected) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Send(message);
|
Send(message);
|
||||||
@ -1074,6 +1152,7 @@ public class StreamDeckService : WebSocketBehavior
|
|||||||
|
|
||||||
public void SendBinary(byte[] data)
|
public void SendBinary(byte[] data)
|
||||||
{
|
{
|
||||||
|
if (!IsConnected) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Send(data);
|
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)
|
protected override void OnClose(WebSocketSharp.CloseEventArgs e)
|
||||||
{
|
{
|
||||||
if (serverManager != null)
|
if (serverManager != null)
|
||||||
|
|||||||
@ -211,7 +211,7 @@ public class StreamingleDashboardServer
|
|||||||
byte[] buffer = Encoding.UTF8.GetBytes(responseContent);
|
byte[] buffer = Encoding.UTF8.GetBytes(responseContent);
|
||||||
context.Response.ContentType = contentType;
|
context.Response.ContentType = contentType;
|
||||||
context.Response.ContentLength64 = buffer.Length;
|
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.OutputStream.Write(buffer, 0, buffer.Length);
|
||||||
context.Response.Close();
|
context.Response.Close();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user