feat: complete legacy parity and modernize operator UI
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user