|
|
@@ -10,13 +10,14 @@ namespace ivf_tl_Operate.Debug
|
|
|
/// <summary>
|
|
|
/// operate 端 MJPEG 预览客户端:流式读 control /debug/preview/stream → 切帧 → 解码 BitmapImage → 事件回调。
|
|
|
/// 断开/异常 → Stopped 事件(View 据此提示操作人员手动重开,不自动重连,spec §7)。
|
|
|
+ /// 实例一次性:Stop/Dispose 后不可复用,需重新 new。View 每次开预览应 new 新实例。
|
|
|
/// </summary>
|
|
|
public sealed class MjpegStreamClient : IDisposable
|
|
|
{
|
|
|
private readonly string _baseUrl; // 如 http://127.0.0.1:38080
|
|
|
private HttpClient _http;
|
|
|
private CancellationTokenSource _cts;
|
|
|
- private Task _readTask;
|
|
|
+ private bool _disposed;
|
|
|
|
|
|
/// <summary>收到一帧(已 Freeze,可跨线程贴 UI)。</summary>
|
|
|
public event Action<BitmapImage> FrameReceived;
|
|
|
@@ -30,12 +31,13 @@ namespace ivf_tl_Operate.Debug
|
|
|
/// <summary>开始预览。sessionId = 第一阶段 acquire 返回的会话 id。</summary>
|
|
|
public void Start(string sessionId)
|
|
|
{
|
|
|
+ if (_disposed) return;
|
|
|
if (IsRunning) return;
|
|
|
IsRunning = true;
|
|
|
_cts = new CancellationTokenSource();
|
|
|
_http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; // 长连接,不超时掐断
|
|
|
string url = $"{_baseUrl}/debug/preview/stream?sessionId={sessionId}";
|
|
|
- _readTask = Task.Run(() => ReadLoop(url, _cts.Token));
|
|
|
+ _ = Task.Run(() => ReadLoop(url, _cts.Token));
|
|
|
}
|
|
|
|
|
|
private async Task ReadLoop(string url, CancellationToken token)
|
|
|
@@ -70,10 +72,16 @@ namespace ivf_tl_Operate.Debug
|
|
|
}
|
|
|
}
|
|
|
catch (OperationCanceledException) { reason = "预览已关闭"; } // 主动 Stop
|
|
|
- catch (Exception ex) { reason = $"预览中断,请重新打开预览({ex.Message})"; }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ reason = token.IsCancellationRequested
|
|
|
+ ? "预览已关闭" // 主动 Stop 期间抛的任何异常都归为"已关闭"
|
|
|
+ : $"预览中断,请重新打开预览({ex.Message})";
|
|
|
+ }
|
|
|
finally
|
|
|
{
|
|
|
IsRunning = false;
|
|
|
+ try { _http?.Dispose(); } catch { } // 谁起的谁清:HttpClient 在 ReadLoop 结束时释放
|
|
|
Stopped?.Invoke(reason);
|
|
|
}
|
|
|
}
|
|
|
@@ -83,24 +91,30 @@ namespace ivf_tl_Operate.Debug
|
|
|
try
|
|
|
{
|
|
|
var img = new BitmapImage();
|
|
|
- img.BeginInit();
|
|
|
- img.CacheOption = BitmapCacheOption.OnLoad; // 解完即脱离流
|
|
|
- img.StreamSource = new MemoryStream(jpeg);
|
|
|
- img.EndInit();
|
|
|
+ using (var ms = new MemoryStream(jpeg))
|
|
|
+ {
|
|
|
+ img.BeginInit();
|
|
|
+ img.CacheOption = BitmapCacheOption.OnLoad; // 解完即脱离流
|
|
|
+ img.StreamSource = ms;
|
|
|
+ img.EndInit();
|
|
|
+ }
|
|
|
img.Freeze(); // 跨线程贴 UI 必须 Freeze
|
|
|
return img;
|
|
|
}
|
|
|
catch { return null; } // 坏帧丢弃,不打断流
|
|
|
}
|
|
|
|
|
|
- /// <summary>主动停止预览(关预览按钮/返回)。</summary>
|
|
|
+ /// <summary>主动停止预览(关预览按钮/返回)。token 取消是唯一停止信号,_http 由 ReadLoop finally 清理。</summary>
|
|
|
public void Stop()
|
|
|
{
|
|
|
try { _cts?.Cancel(); } catch { }
|
|
|
- try { _http?.Dispose(); } catch { }
|
|
|
IsRunning = false;
|
|
|
}
|
|
|
|
|
|
- public void Dispose() => Stop();
|
|
|
+ public void Dispose()
|
|
|
+ {
|
|
|
+ _disposed = true;
|
|
|
+ Stop();
|
|
|
+ }
|
|
|
}
|
|
|
}
|