feat: complete legacy UI and playout parity migration

This commit is contained in:
2026-07-15 13:11:38 +09:00
parent f14800656b
commit 6e0c275fdd
108 changed files with 43738 additions and 560 deletions

View File

@@ -0,0 +1,836 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using Microsoft.UI.Xaml;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow
{
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
private IPlayoutEngine? _playoutEngine;
private LegacyPlayoutWorkflow? _playoutWorkflow;
private PlayoutOptions? _playoutOptions;
private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions;
private string? _playoutInitializationError;
private string? _playoutInitializationWarning;
private CancellationTokenSource? _refreshCancellation;
private Task? _refreshTask;
private int _playoutBusy;
private int _playoutQuarantined;
private int _playoutShutdownStarted;
private int _operatorMutationQuarantined;
private int _refreshCompletedCount;
private bool _refreshLimitReached;
private string _refreshMessage = string.Empty;
private void InitializePlayoutRuntime()
{
try
{
var explicitLocalConfigurationExists = File.Exists(
PlayoutOptionsLoader.DefaultPath);
_playoutOptions = PlayoutOptionsLoader.Load();
var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
_playoutOptions,
explicitLocalConfigurationExists);
var initialComposition = startupComposition.Composition;
_playoutInitializationWarning = startupComposition.HasWarning
? startupComposition.WarningMessage
: null;
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
initialComposition);
_playoutEngine = PlayoutEngineFactory.Create(_playoutOptions);
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
if (_databaseRuntime is not null)
{
_playoutWorkflow = global::MBN_STOCK_WEBVIEW.LegacySceneRuntimeFactory.Create(
_playoutEngine,
_databaseRuntime.Executor,
compositionOptionsSource: _compositionOptions,
trustedManualSource: _manualNetSellStore,
trustedViSource: _manualViStore);
}
}
catch (Exception exception)
{
_playoutInitializationWarning = null;
_playoutInitializationError = exception is PlayoutConfigurationException
? exception.Message
: "Tornado 송출 어댑터를 초기화하지 못했습니다.";
}
}
private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
{
var engine = _playoutEngine;
if (engine is null)
{
QueueOperatorState();
return;
}
try
{
var result = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess)
{
_playoutInitializationWarning = null;
_playoutInitializationError = result.Message;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch
{
_playoutInitializationWarning = null;
_playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
}
QueueOperatorState();
}
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
{
_playoutWorkflow?.ObserveEngineStatus(args.Current);
if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
{
StopRefreshLoop();
}
QueueOperatorState();
}
private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(
LegacyOperatorPlayoutCommand command,
CancellationToken cancellationToken)
{
var engine = _playoutEngine;
var workflow = _playoutWorkflow;
if (engine is null || workflow is null)
{
return _controller.ReportError(
_playoutInitializationError ?? "Tornado 송출 기능을 사용할 수 없습니다.");
}
if (Volatile.Read(ref _playoutQuarantined) != 0 ||
engine.Status.State == PlayoutConnectionState.OutcomeUnknown)
{
return _controller.ReportError(
"송출 결과를 확인할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 앱을 다시 시작하세요.");
}
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("다른 송출 명령을 처리하고 있습니다.");
}
try
{
Interlocked.Exchange(ref _playoutBusy, 1);
StopRefreshLoop();
var nextKindBefore = workflow.State.NextKind;
if (command == LegacyOperatorPlayoutCommand.Next &&
engine.Status.IsPlayCompletionPending)
{
return _controller.ReportError(
"Tornado 재생 완료 이벤트를 기다리는 중입니다. NEXT를 반복하지 마세요.");
}
PlayoutResult result;
switch (command)
{
case LegacyOperatorPlayoutCommand.Prepare:
var request = _controller.CreatePlayoutRequest();
result = await workflow.PrepareAsync(
request.Playlist,
request.SelectedIndexZeroBased,
cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.TakeIn:
// MainForm.btnTake_Click performed ONAirMode (load/prepare) and
// Play in one visible F8 action. Keep the explicit PREPARE control
// for engineering checks, but preserve the original one-click path
// whenever no scene is already prepared.
if (string.IsNullOrWhiteSpace(workflow.State.PreparedCutCode))
{
var takeInRequest = _controller.CreatePlayoutRequest();
result = await workflow.PrepareAndTakeInAsync(
takeInRequest.Playlist,
takeInRequest.SelectedIndexZeroBased,
cancellationToken).ConfigureAwait(false);
}
else
{
result = await workflow.TakeInAsync(cancellationToken).ConfigureAwait(false);
}
break;
case LegacyOperatorPlayoutCommand.Next:
result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.TakeOut:
result = await workflow.TakeOutAsync(
PlayoutTakeOutScope.All,
cancellationToken).ConfigureAwait(false);
break;
default:
throw new ArgumentOutOfRangeException(nameof(command));
}
if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut)
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
return _controller.ReportError(
"송출 명령 결과가 불명확합니다. 명령을 반복하지 말고 PGM과 Network Monitoring을 확인하세요.");
}
if (!result.IsSuccess)
{
return _controller.ReportError(result.Message);
}
if (command == LegacyOperatorPlayoutCommand.TakeIn ||
command == LegacyOperatorPlayoutCommand.Next &&
nextKindBefore == LegacyWorkflowNextKind.PlaylistNext)
{
StartRefreshLoop(workflow);
}
return _controller.ReportInformation(result.Message);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return _controller.ReportError("송출 요청이 취소되었습니다.");
}
catch (DatabaseOperationException exception)
{
return _controller.ReportError(exception.Message);
}
catch (Exception exception) when (
exception is LegacySceneDataException or ArgumentException or InvalidOperationException)
{
return _controller.ReportError(exception.Message);
}
catch
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
return _controller.ReportError(
"송출 엔진 명령의 결과를 확인할 수 없습니다. 명령을 반복하지 마세요.");
}
finally
{
Interlocked.Exchange(ref _playoutBusy, 0);
_playoutCommandGate.Release();
QueueOperatorState();
}
}
private async Task<LegacyOperatorSnapshot> SetFadeDurationAsync(
int fadeDuration,
CancellationToken cancellationToken)
{
if (fadeDuration is < 0 or > 60)
{
return _controller.ReportError("Fade 값이 올바르지 않습니다.");
}
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 Fade를 변경할 수 없습니다.");
}
try
{
if (!CanChangeComposition())
{
return _controller.ReportError("Fade는 IDLE 상태에서만 변경할 수 있습니다.");
}
var source = _compositionOptions!;
source.Update(source.Current with { FadeDuration = fadeDuration });
return _controller.ReportInformation(
$"Fade {fadeDuration}을 다음 PREPARE부터 적용합니다.");
}
finally
{
_playoutCommandGate.Release();
}
}
private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(
CancellationToken cancellationToken)
{
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
}
try
{
if (!CanChangeComposition())
{
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
}
var source = _compositionOptions!;
if (source.Current.BackgroundKind == LegacySceneBackgroundKind.None)
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
_playoutOptions!,
source.Current.FadeDuration));
_playoutInitializationWarning = null;
return _controller.ReportInformation(
"기본 배경을 다음 PREPARE부터 적용합니다.");
}
source.Update(new LegacySceneCueCompositionOptions(
source.Current.FadeDuration,
LegacySceneBackgroundKind.None));
return _controller.ReportInformation("배경 사용을 해제했습니다.");
}
catch (PlayoutConfigurationException exception)
{
return _controller.ReportError(exception.Message);
}
finally
{
_playoutCommandGate.Release();
}
}
private async Task<LegacyOperatorSnapshot> ChooseBackgroundAsync(
CancellationToken cancellationToken)
{
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다.");
}
try
{
if (!CanChangeComposition())
{
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
}
// MainForm.btnback_Click assigns 배경\기본.vrv before opening the
// picker. Preserve that observable behavior when the trusted default is
// present, while still allowing the operator to choose another asset if it
// is not.
var source = _compositionOptions!;
try
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
_playoutOptions!,
source.Current.FadeDuration));
_playoutInitializationWarning = null;
}
catch (PlayoutConfigurationException)
{
}
var picker = new FileOpenPicker
{
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
ViewMode = PickerViewMode.List
};
foreach (var extension in new[] { ".vrv", ".jpg", ".jpeg", ".png" })
{
picker.FileTypeFilter.Add(extension);
}
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
var selected = await picker.PickSingleFileAsync();
if (selected is null)
{
return _controller.Current;
}
if (!CanChangeComposition())
{
return _controller.ReportError(
"파일 선택 중 송출 상태가 바뀌어 배경 변경을 취소했습니다.");
}
source.Update(PlayoutSceneCompositionFactory.CreateOperatorSelection(
_playoutOptions!,
source.Current.FadeDuration,
selected.Path));
_playoutInitializationWarning = null;
return _controller.ReportInformation(
"선택한 배경을 다음 PREPARE부터 적용합니다.");
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
PlayoutConfigurationException)
{
return _controller.ReportError(
"신뢰 배경 폴더 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다.");
}
finally
{
_playoutCommandGate.Release();
}
}
private bool CanChangeComposition()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
return _playoutOptions is not null && _compositionOptions is not null &&
status is not null && workflow is not null &&
Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
!status.IsPlayCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
(_refreshTask is null || _refreshTask.IsCompleted);
}
private bool CanAcceptOperatorMutation()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
if (status is null || workflow is null)
{
// A disabled or unavailable playout runtime must not prevent the operator
// from composing a playlist. PREPARE will still fail closed later.
return true;
}
return Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
status.State != PlayoutConnectionState.OutcomeUnknown &&
!status.IsPlayCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
(_refreshTask is null || _refreshTask.IsCompleted);
}
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
LegacyDropSelectedCutsIntent or
LegacySetPlaylistEnabledIntent or
LegacySetAllPlaylistEnabledIntent or
LegacyMoveSelectedPlaylistRowsIntent or
LegacyDeleteSelectedPlaylistRowsIntent or
LegacyDeleteAllPlaylistRowsIntent or
LegacyToggleActivePlaylistEnabledIntent or
LegacyActivateFixedActionIntent or
LegacyActivateFixedSectionIntent or
LegacyActivateIndustryActionIntent or
LegacyActivateIndustrySectionIntent or
LegacyActivateOverseasActionIntent or
LegacyAddComparisonPairIntent or
LegacyMoveComparisonPairIntent or
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
LegacyActivateComparisonActionIntent or
LegacyActivateComparisonSectionIntent or
LegacyActivateThemeActionIntent or
LegacyActivateThemeResultIntent or
LegacyActivateExpertActionIntent or
LegacyActivateTradingHaltActionIntent or
LegacyBeginUc4ThemeCatalogIntent or
LegacyEditUc4SelectedThemeIntent or
LegacyDeleteUc4SelectedThemeIntent or
LegacyBeginUc6ExpertCatalogIntent or
LegacyEditUc6SelectedExpertIntent or
LegacyDeleteUc6SelectedExpertIntent or
LegacyBeginCreateThemeCatalogIntent or
LegacyBeginCreateExpertCatalogIntent or
LegacyAddOperatorCatalogStockIntent or
LegacyActivateOperatorCatalogStockIntent or
LegacyMoveOperatorCatalogDraftRowIntent or
LegacyRemoveOperatorCatalogDraftRowIntent or
LegacySetOperatorCatalogDraftBuyAmountIntent or
LegacySaveOperatorCatalogIntent or
LegacyDeleteOperatorCatalogIntent or
LegacyLoadSelectedNamedPlaylistIntent or
LegacyLoadNamedPlaylistByIdIntent or
LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent or
LegacySaveManualFinancialIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacyActivateManualFinancialActionIntent or
LegacyActivateManualFinancialResultIntent or
LegacySetManualNetSellCellIntent or
LegacySaveManualNetSellIntent or
LegacyAddManualNetSellCutIntent or
LegacyAddManualViResultIntent or
LegacyMoveManualViItemIntent or
LegacyDeleteManualViItemIntent or
LegacyDeleteAllManualViItemsIntent or
LegacySaveManualViIntent or
LegacyAddManualViCutIntent or
LegacyConfirmManualListIntent or
LegacyImportManualListsIntent;
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
LegacySetManualNetSellCellIntent or
LegacyAddManualViResultIntent or
LegacyMoveManualViItemIntent or
LegacyDeleteManualViItemIntent or
LegacyDeleteAllManualViItemsIntent or
LegacyConfirmManualListIntent or
LegacyImportManualListsIntent;
private void ObserveOperatorMutationQuarantine(LegacyOperatorSnapshot snapshot)
{
if (snapshot.ManualFinancial?.WritesQuarantined == true ||
snapshot.NamedPlaylist?.IsWriteQuarantined == true ||
snapshot.ManualLists?.WritesQuarantined == true ||
snapshot.OperatorCatalog?.WritesQuarantined == true)
{
Interlocked.Exchange(ref _operatorMutationQuarantined, 1);
}
}
private bool IsOperatorMutationQuarantined() =>
Volatile.Read(ref _operatorMutationQuarantined) != 0;
private LegacyOperatorPlayoutSnapshot CreatePlayoutSnapshot()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
var composition = _compositionOptions?.Current ??
LegacySceneCueCompositionOptions.Default;
var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 ||
status?.State == PlayoutConnectionState.OutcomeUnknown;
var phase = !string.IsNullOrWhiteSpace(status?.OnAirSceneName)
? LegacyOperatorPlayoutPhase.Program
: !string.IsNullOrWhiteSpace(status?.PreparedSceneName)
? LegacyOperatorPlayoutPhase.Prepared
: LegacyOperatorPlayoutPhase.Idle;
var backgroundEnabled =
composition.BackgroundKind != LegacySceneBackgroundKind.None;
return new LegacyOperatorPlayoutSnapshot(
status?.Mode ?? PlayoutMode.Disabled,
quarantined
? PlayoutConnectionState.OutcomeUnknown
: status?.State ?? PlayoutConnectionState.Disabled,
phase,
status?.IsProcessRunning ?? false,
status?.IsConnected ?? false,
(status?.IsCommandAvailable ?? false) && !quarantined,
(status?.LiveTakeInAllowed ?? false) && !quarantined,
status?.IsPlayCompletionPending ?? false,
quarantined,
status?.PreparedSceneName,
status?.OnAirSceneName,
workflow?.CurrentCueIndexZeroBased ?? -1,
workflow?.CurrentEntryId,
workflow?.BuilderKey,
workflow?.PageIndexZeroBased ?? 0,
workflow?.PageCount ?? 0,
workflow?.PageSize ?? 0,
workflow?.ItemCount ?? 0,
workflow?.CurrentPageItemCount ?? 0,
workflow?.IsLastPage ?? true,
workflow?.NextKind ?? LegacyWorkflowNextKind.None,
Volatile.Read(ref _playoutBusy) != 0,
_refreshTask is { IsCompleted: false },
Volatile.Read(ref _refreshCompletedCount),
_playoutOptions?.MaximumAutomaticRefreshesPerTakeIn,
_refreshLimitReached,
composition.FadeDuration,
backgroundEnabled,
backgroundEnabled
? Path.GetFileName(composition.BackgroundAssetPath) ?? string.Empty
: string.Empty,
CanChangeComposition(),
quarantined
? "결과 불명확 상태입니다. 명령을 반복하지 마세요."
: _refreshMessage.Length > 0
? _refreshMessage
: _playoutInitializationWarning ?? status?.Message ??
_playoutInitializationError ??
"송출 어댑터를 사용할 수 없습니다.");
}
private void StartRefreshLoop(LegacyPlayoutWorkflow workflow)
{
StopRefreshLoop();
Interlocked.Exchange(ref _refreshCompletedCount, 0);
_refreshLimitReached = false;
_refreshMessage = string.Empty;
var cutCode = workflow.State.OnAirCutCode;
if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var firstInterval) ||
firstInterval <= TimeSpan.Zero)
{
return;
}
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
_refreshCancellation = cancellation;
_refreshTask = RunRefreshLoopAsync(workflow, firstInterval, cancellation);
}
private void StopRefreshLoop()
{
var cancellation = Interlocked.Exchange(ref _refreshCancellation, null);
if (cancellation is not null)
{
try
{
cancellation.Cancel();
}
catch (ObjectDisposedException)
{
}
}
}
private async Task RunRefreshLoopAsync(
LegacyPlayoutWorkflow workflow,
TimeSpan firstInterval,
CancellationTokenSource cancellation)
{
var token = cancellation.Token;
var delay = firstInterval;
var maximum = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn;
try
{
while (!token.IsCancellationRequested)
{
var engine = _playoutEngine;
if (engine is null)
{
return;
}
if (maximum is { } cap && Volatile.Read(ref _refreshCompletedCount) >= cap)
{
if (!await WaitForPlayCompletionAsync(engine, token)
.ConfigureAwait(false))
{
_refreshMessage =
"자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. PGM을 확인하세요.";
return;
}
_refreshLimitReached = true;
_refreshMessage = $"자동 갱신 상한 {cap}회에 도달했습니다.";
return;
}
if (!await WaitForPlayCompletionAsync(engine, token)
.ConfigureAwait(false))
{
_refreshMessage = "재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다.";
return;
}
await Task.Delay(delay, token).ConfigureAwait(false);
await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false);
try
{
Interlocked.Exchange(ref _playoutBusy, 1);
var result = await workflow.RefreshOnAirAsync(token).ConfigureAwait(false);
if (!result.IsSuccess)
{
if (result.Code is PlayoutResultCode.OutcomeUnknown or
PlayoutResultCode.TimedOut)
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
}
_refreshMessage =
"자동 장면 갱신을 중단했습니다. TAKE OUT 전 PGM을 확인하세요.";
return;
}
Interlocked.Increment(ref _refreshCompletedCount);
delay = LegacySceneRefreshPolicy.RecurringInterval;
}
finally
{
Interlocked.Exchange(ref _playoutBusy, 0);
_playoutCommandGate.Release();
QueueOperatorState();
}
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
}
catch
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
_refreshMessage = "자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요.";
}
finally
{
if (ReferenceEquals(_refreshCancellation, cancellation))
{
Interlocked.CompareExchange(ref _refreshCancellation, null, cancellation);
}
cancellation.Dispose();
QueueOperatorState();
}
}
private static async Task<bool> WaitForPlayCompletionAsync(
IPlayoutEngine engine,
CancellationToken cancellationToken)
{
if (!engine.Status.IsPlayCompletionPending)
{
return true;
}
var timeout = Math.Clamp(
engine.Status.OperationTimeoutMilliseconds,
100,
300_000);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linked.CancelAfter(timeout);
while (engine.Status.IsPlayCompletionPending)
{
var completion = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
void OnStatus(object? sender, PlayoutStatusChangedEventArgs args)
{
if (!args.Current.IsPlayCompletionPending)
{
completion.TrySetResult();
}
}
engine.StatusChanged += OnStatus;
try
{
if (!engine.Status.IsPlayCompletionPending)
{
return true;
}
await completion.Task.WaitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return false;
}
finally
{
engine.StatusChanged -= OnStatus;
}
}
return true;
}
private async ValueTask QuarantinePlayoutAsync()
{
if (Interlocked.Exchange(ref _playoutQuarantined, 1) != 0)
{
return;
}
StopRefreshLoop();
if (_playoutEngine is not null)
{
try
{
await _playoutEngine.QuarantineAsync().ConfigureAwait(false);
}
catch
{
}
}
}
private void QueueOperatorState()
{
if (_closing || !_webViewReady)
{
return;
}
DispatcherQueue.TryEnqueue(() =>
{
if (!_closing)
{
PostState(_controller.Current);
}
});
}
private async Task ShutdownPlayoutRuntimeAsync()
{
if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
{
return;
}
StopRefreshLoop();
var refresh = _refreshTask;
if (refresh is not null)
{
try
{
await refresh.WaitAsync(TimeSpan.FromSeconds(2));
}
catch
{
}
}
var engine = _playoutEngine;
_playoutEngine = null;
_playoutWorkflow = null;
if (engine is null)
{
return;
}
engine.StatusChanged -= OnPlayoutStatusChanged;
try
{
if (Volatile.Read(ref _playoutQuarantined) == 0 &&
engine.Status.State != PlayoutConnectionState.OutcomeUnknown &&
string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) &&
!engine.Status.IsPlayCompletionPending)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false);
}
}
catch
{
}
await engine.DisposeAsync().ConfigureAwait(false);
}
private void ShutdownPlayoutRuntime()
{
try
{
ShutdownPlayoutRuntimeAsync()
.Wait(TimeSpan.FromSeconds(8));
}
catch
{
// Window shutdown is bounded and best-effort. The engine itself keeps an
// outcome-unknown session quarantined and will not send a duplicate BYE.
}
}
}