feat: complete legacy UI and playout parity migration
This commit is contained in:
836
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
Normal file
836
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user