feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

@@ -7,8 +7,6 @@ using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Safety;
using Microsoft.UI.Xaml;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
@@ -19,6 +17,7 @@ public sealed partial class MainWindow
private LegacyPlayoutWorkflow? _playoutWorkflow;
private PlayoutOptions? _playoutOptions;
private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions;
private MutableLegacySceneCueCompositionOptionsSource? _stagedCompositionOptions;
private string? _playoutInitializationError;
private string? _playoutInitializationWarning;
private Task? _refreshTask;
@@ -48,6 +47,8 @@ public sealed partial class MainWindow
: null;
_compositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
initialComposition);
_stagedCompositionOptions = new MutableLegacySceneCueCompositionOptionsSource(
initialComposition);
_playoutEngine = PlayoutEngineFactory.Create(
_playoutOptions,
_playoutLaunchAuthorization);
@@ -175,6 +176,12 @@ public sealed partial class MainWindow
}
StopRefreshLoop();
if (command is LegacyOperatorPlayoutCommand.Prepare or
LegacyOperatorPlayoutCommand.TakeIn or
LegacyOperatorPlayoutCommand.Next)
{
ApplyStagedCompositionForNextCue();
}
PlayoutResult result;
switch (command)
{
@@ -202,7 +209,11 @@ public sealed partial class MainWindow
cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.Next:
result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false);
var livePlaylist = _controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration).Playlist;
result = await workflow.NextAsync(
livePlaylist,
cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.TakeOut:
result = await workflow.TakeOutAsync(
@@ -463,7 +474,9 @@ public sealed partial class MainWindow
}
var source = _compositionOptions!;
source.Update(source.Current with { FadeDuration = fadeDuration });
var updated = source.Current with { FadeDuration = fadeDuration };
source.Update(updated);
_stagedCompositionOptions!.Update(updated);
return _controller.ReportInformation(
$"DissolveTime {fadeDuration + 1}을 다음 PREPARE부터 적용합니다.");
}
@@ -497,12 +510,13 @@ public sealed partial class MainWindow
try
{
if (!CanChangeComposition())
if (!CanStageBackground())
{
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
return _controller.ReportError(
"송출 결과가 확정되지 않은 동안에는 다음 장면 배경을 변경할 수 없습니다.");
}
var source = _compositionOptions!;
var source = _stagedCompositionOptions!;
if (source.Current.BackgroundKind == LegacySceneBackgroundKind.None)
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
@@ -544,16 +558,17 @@ public sealed partial class MainWindow
try
{
if (!CanChangeComposition())
if (!CanStageBackground())
{
return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다.");
return _controller.ReportError(
"송출 결과가 확정되지 않은 동안에는 다음 장면 배경을 변경할 수 없습니다.");
}
// 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!;
var source = _stagedCompositionOptions!;
try
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
@@ -565,24 +580,22 @@ public sealed partial class MainWindow
{
}
var picker = new FileOpenPicker
if (!PlayoutSceneCompositionFactory.TryGetTrustedBackgroundDirectory(
_playoutOptions!,
out var trustedBackgroundDirectory))
{
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
ViewMode = PickerViewMode.List
};
foreach (var extension in new[] { ".vrv", ".jpg", ".jpeg", ".png" })
{
picker.FileTypeFilter.Add(extension);
return _controller.ReportError(
"설정에서 사용할 배경 폴더를 먼저 지정해 주세요.");
}
InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this));
var selected = await picker.PickSingleFileAsync();
if (selected is null)
var selectedPath = await PickBackgroundFileAsync(
trustedBackgroundDirectory).ConfigureAwait(false);
if (selectedPath is null)
{
return _controller.Current;
}
if (!CanChangeComposition())
if (!CanStageBackground())
{
return _controller.ReportError(
"파일 선택 중 송출 상태가 바뀌어 배경 변경을 취소했습니다.");
@@ -591,7 +604,7 @@ public sealed partial class MainWindow
source.Update(PlayoutSceneCompositionFactory.CreateOperatorSelection(
_playoutOptions!,
source.Current.FadeDuration,
selected.Path));
selectedPath));
_playoutInitializationWarning = null;
return _controller.ReportInformation(
"선택한 배경을 다음 PREPARE부터 적용합니다.");
@@ -615,6 +628,7 @@ public sealed partial class MainWindow
var workflow = _playoutWorkflow?.State;
var refresh = _refreshEpoch.ReadState();
return _playoutOptions is not null && _compositionOptions is not null &&
_stagedCompositionOptions is not null &&
status is not null && workflow is not null &&
Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
@@ -628,7 +642,33 @@ public sealed partial class MainWindow
(_refreshTask is null || _refreshTask.IsCompleted);
}
private bool CanAcceptOperatorMutation()
private bool CanStageBackground()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
return _playoutOptions is not null && _compositionOptions is not null &&
_stagedCompositionOptions is not null && status is not null &&
workflow is not null &&
Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
status.State != PlayoutConnectionState.OutcomeUnknown &&
!status.IsPlayCompletionPending &&
!status.IsTakeOutCompletionPending;
}
private void ApplyStagedCompositionForNextCue()
{
var active = _compositionOptions ??
throw new InvalidOperationException("The active composition source is unavailable.");
var staged = _stagedCompositionOptions ??
throw new InvalidOperationException("The staged composition source is unavailable.");
active.Update(staged.Current);
}
private bool CanAcceptOperatorMutation(
LegacyUiIntent intent,
out string rejectionMessage)
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
@@ -637,21 +677,158 @@ public sealed partial class MainWindow
{
// A disabled or unavailable playout runtime must not prevent the operator
// from composing a playlist. PREPARE will still fail closed later.
rejectionMessage = string.Empty;
return true;
}
return Volatile.Read(ref _playoutBusy) == 0 &&
// A cut double-click only appends a local row at the safe playlist tail.
// Once the refresh has released the shared intent gate, that append is
// independent of the scheduler remaining active between refreshes. All
// uncertain/pending/busy states below remain fail-closed.
var isSafeCutTailAppend = intent is
LegacyCutPointerDownIntent { AllowAppend: true };
// MainForm kept its Space and "all" enabled-state gestures available while
// PROGRAM was active. They only change the native operator playlist; NEXT
// stops the refresh loop before the workflow adopts those flags. Therefore
// they are independent between refresh ticks just like a safe tail append.
var isSafeProgramEnabledStateMutation = intent is
LegacySetAllPlaylistEnabledIntent or
LegacyToggleActivePlaylistEnabledIntent;
var refreshAllowsMutation = isSafeCutTailAppend ||
isSafeProgramEnabledStateMutation ||
(!refresh.IsActive && (_refreshTask is null || _refreshTask.IsCompleted));
var runtimeAcceptsIndependentMutation = Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
Volatile.Read(ref _playoutShutdownStarted) == 0 &&
status.State != PlayoutConnectionState.OutcomeUnknown &&
!status.IsPlayCompletionPending &&
!status.IsTakeOutCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
!refresh.IsActive &&
(_refreshTask is null || _refreshTask.IsCompleted);
refreshAllowsMutation;
if (!runtimeAcceptsIndependentMutation)
{
rejectionMessage =
"송출 명령 처리 중이거나 결과를 확인할 수 없는 상태에서는 편성 및 운영 데이터를 변경할 수 없습니다.";
return false;
}
if (RequiresIdlePlaylistMutation(intent) && !IsPlaylistMutationIdle(status, workflow))
{
rejectionMessage = intent is
LegacyLoadSelectedNamedPlaylistIntent or LegacyLoadNamedPlaylistByIdIntent
? "송출 중에는 다른 DB 재생목록으로 교체할 수 없습니다. TAKE OUT 후 다시 시도하세요."
: "송출 중에는 행 이동·마우스 개별 체크·전체 삭제를 할 수 없습니다. Space/전체 활성 체크, 새 컷 추가와 안전한 후속 행 삭제는 계속 사용할 수 있습니다.";
return false;
}
if (isSafeProgramEnabledStateMutation &&
!CanMutatePlaylistEnabledState(status, workflow))
{
rejectionMessage =
"플레이리스트 활성 체크는 대기 또는 PROGRAM 상태에서만 변경할 수 있습니다.";
return false;
}
if (intent is LegacyDeleteSelectedPlaylistRowsIntent &&
!CanDeleteSelectedPlaylistTail(status, workflow))
{
rejectionMessage =
"송출 중에는 현재 송출 행과 그 이전 행을 삭제할 수 없습니다. 현재 행 뒤의 선택 행만 삭제할 수 있습니다.";
return false;
}
rejectionMessage = string.Empty;
return true;
}
private static bool RequiresIdlePlaylistMutation(LegacyUiIntent intent) => intent is
LegacySetPlaylistEnabledIntent or
LegacyMoveSelectedPlaylistRowsIntent or
LegacyReorderPlaylistRowsIntent or
LegacyDeleteAllPlaylistRowsIntent or
LegacyLoadSelectedNamedPlaylistIntent or
LegacyLoadNamedPlaylistByIdIntent;
private static bool IsPlaylistMutationIdle(
PlayoutStatus status,
LegacyPlayoutWorkflowState workflow) =>
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) &&
string.IsNullOrWhiteSpace(workflow.CurrentEntryId);
private bool CanDeleteSelectedPlaylistTail(
PlayoutStatus status,
LegacyPlayoutWorkflowState workflow)
{
if (IsPlaylistMutationIdle(status, workflow))
{
return true;
}
if (string.IsNullOrWhiteSpace(workflow.CurrentEntryId) ||
workflow.CurrentCueIndexZeroBased < 0)
{
return false;
}
var playlist = _controller.Current.Playlist;
var currentMatches = playlist
.Select(static (row, index) => (row, index))
.Where(candidate => string.Equals(
candidate.row.RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal))
.Select(static candidate => candidate.index)
.Take(2)
.ToArray();
if (currentMatches.Length != 1)
{
return false;
}
var currentIndex = currentMatches[0];
if (currentIndex != workflow.CurrentCueIndexZeroBased)
{
return false;
}
return playlist
.Select(static (row, index) => (row, index))
.Where(static candidate => candidate.row.IsSelected)
.All(candidate => candidate.index > currentIndex);
}
private bool CanMutatePlaylistEnabledState(
PlayoutStatus status,
LegacyPlayoutWorkflowState workflow)
{
if (IsPlaylistMutationIdle(status, workflow))
{
return true;
}
// PREPARED is deliberately not opened: unlike PROGRAM, the original parity
// evidence does not establish a safe checkbox contract for that phase.
if (string.IsNullOrWhiteSpace(status.OnAirSceneName) ||
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) ||
string.IsNullOrWhiteSpace(workflow.CurrentEntryId) ||
workflow.CurrentCueIndexZeroBased < 0)
{
return false;
}
var playlist = _controller.Current.Playlist;
var currentIndex = workflow.CurrentCueIndexZeroBased;
return currentIndex < playlist.Count &&
string.Equals(
playlist[currentIndex].RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal) &&
playlist.Count(row => string.Equals(
row.RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal)) == 1;
}
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
@@ -670,6 +847,7 @@ public sealed partial class MainWindow
LegacyActivateIndustrySectionIntent or
LegacyActivateOverseasActionIntent or
LegacyAddComparisonPairIntent or
LegacyImportComparisonPairsIntent or
LegacyMoveComparisonPairIntent or
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
@@ -686,6 +864,7 @@ public sealed partial class MainWindow
LegacyEditUc6SelectedExpertIntent or
LegacyDeleteUc6SelectedExpertIntent or
LegacyBeginCreateThemeCatalogIntent or
LegacyChangeCreateThemeMarketIntent or
LegacyBeginCreateExpertCatalogIntent or
LegacyAddOperatorCatalogStockIntent or
LegacyActivateOperatorCatalogStockIntent or
@@ -704,6 +883,7 @@ public sealed partial class MainWindow
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent or
LegacySaveManualFinancialIntent or
LegacySaveManualFinancialRawIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacyActivateManualFinancialActionIntent or
@@ -730,6 +910,7 @@ public sealed partial class MainWindow
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
LegacySaveManualFinancialIntent or
LegacySaveManualFinancialRawIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacySaveManualNetSellIntent or
@@ -767,12 +948,51 @@ public sealed partial class MainWindow
private bool IsOperatorMutationQuarantined() =>
Volatile.Read(ref _operatorMutationQuarantined) != 0;
private LegacyWorkflowNextKind ResolveOperatorNextKind(
LegacyOperatorPlayoutPhase phase,
LegacyPlayoutWorkflowState? workflow)
{
var retainedKind = workflow?.NextKind ?? LegacyWorkflowNextKind.None;
if (phase != LegacyOperatorPlayoutPhase.Program || workflow is null)
{
return retainedKind;
}
// The workflow snapshot is intentionally frozen until NEXT. MainForm's
// PROGRAM Space/"all" gestures, however, immediately changed which future
// row NEXT would choose. Derive the visible/available NEXT state from the
// live operator flags without mutating the scene retained by the engine.
if (!workflow.IsLastPage)
{
return LegacyWorkflowNextKind.PageNext;
}
var currentIndex = workflow.CurrentCueIndexZeroBased;
var playlist = _controller.Current.Playlist;
if (currentIndex < 0 || currentIndex >= playlist.Count ||
string.IsNullOrWhiteSpace(workflow.CurrentEntryId) ||
!string.Equals(
playlist[currentIndex].RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal))
{
return LegacyWorkflowNextKind.None;
}
return playlist
.Skip(currentIndex + 1)
.Any(static row => row.IsEnabled)
? LegacyWorkflowNextKind.PlaylistNext
: LegacyWorkflowNextKind.EndOfPlaylist;
}
private LegacyOperatorPlayoutSnapshot CreatePlayoutSnapshot()
{
var status = _playoutEngine?.Status;
var workflow = _playoutWorkflow?.State;
var refresh = _refreshEpoch.ReadState();
var composition = _compositionOptions?.Current ??
var composition = _stagedCompositionOptions?.Current ??
_compositionOptions?.Current ??
LegacySceneCueCompositionOptions.Default;
var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 ||
status?.State == PlayoutConnectionState.OutcomeUnknown;
@@ -781,6 +1001,7 @@ public sealed partial class MainWindow
: !string.IsNullOrWhiteSpace(status?.PreparedSceneName)
? LegacyOperatorPlayoutPhase.Prepared
: LegacyOperatorPlayoutPhase.Idle;
var nextKind = ResolveOperatorNextKind(phase, workflow);
var backgroundEnabled =
composition.BackgroundKind != LegacySceneBackgroundKind.None;
var snapshot = new LegacyOperatorPlayoutSnapshot(
@@ -806,7 +1027,7 @@ public sealed partial class MainWindow
workflow?.ItemCount ?? 0,
workflow?.CurrentPageItemCount ?? 0,
workflow?.IsLastPage ?? true,
workflow?.NextKind ?? LegacyWorkflowNextKind.None,
nextKind,
Volatile.Read(ref _playoutBusy) != 0,
refresh.IsActive,
refresh.CompletedRefreshCount,
@@ -817,7 +1038,7 @@ public sealed partial class MainWindow
backgroundEnabled
? Path.GetFileName(composition.BackgroundAssetPath) ?? string.Empty
: string.Empty,
CanChangeComposition(),
CanStageBackground(),
quarantined
? "결과 불명확 상태입니다. 명령을 반복하지 마세요."
: refresh.Message.Length > 0