fix: complete legacy operator parity workflows
This commit is contained in:
@@ -686,7 +686,8 @@ public sealed partial class MainWindow
|
||||
// independent of the scheduler remaining active between refreshes. All
|
||||
// uncertain/pending/busy states below remain fail-closed.
|
||||
var isSafeCutTailAppend = intent is
|
||||
LegacyCutPointerDownIntent { AllowAppend: true };
|
||||
LegacyCutPointerDownIntent { AllowAppend: true } or
|
||||
LegacyDropSelectedCutsIntent;
|
||||
// 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
|
||||
@@ -694,8 +695,26 @@ public sealed partial class MainWindow
|
||||
var isSafeProgramEnabledStateMutation = intent is
|
||||
LegacySetAllPlaylistEnabledIntent or
|
||||
LegacyToggleActivePlaylistEnabledIntent;
|
||||
// Deleting selected rows is also independent of the refresh scheduler, but
|
||||
// only after the exact current row and every selected index are checked by
|
||||
// CanDeleteSelectedPlaylistTail below. The one-slot ordered UI gate keeps
|
||||
// this request behind its preceding row selection without allowing repeats.
|
||||
var isSafeProgramTailDelete = intent is
|
||||
LegacyDeleteSelectedPlaylistRowsIntent;
|
||||
// PList did not stop or replace the active scene when it created, saved,
|
||||
// overwrote, or deleted a named DB playlist. These writes only persist a
|
||||
// snapshot/definition and are serialized with the automatic refresh by the
|
||||
// shared intent and database gates. Loading remains an idle-only structural
|
||||
// replacement below.
|
||||
var isSafeNamedPlaylistPersistenceMutation = intent is
|
||||
LegacyCreateNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistToIntent or
|
||||
LegacyDeleteSelectedNamedPlaylistIntent;
|
||||
var refreshAllowsMutation = isSafeCutTailAppend ||
|
||||
isSafeProgramEnabledStateMutation ||
|
||||
isSafeProgramTailDelete ||
|
||||
isSafeNamedPlaylistPersistenceMutation ||
|
||||
(!refresh.IsActive && (_refreshTask is null || _refreshTask.IsCompleted));
|
||||
var runtimeAcceptsIndependentMutation = Volatile.Read(ref _playoutBusy) == 0 &&
|
||||
Volatile.Read(ref _playoutQuarantined) == 0 &&
|
||||
@@ -711,6 +730,14 @@ public sealed partial class MainWindow
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSafeCutTailAppend &&
|
||||
!CanAppendCutToPlaylistTail(status, workflow))
|
||||
{
|
||||
rejectionMessage =
|
||||
"PROGRAM 상태에서는 현재 송출 행을 확인할 수 있을 때만 새 컷을 대기열 끝에 추가할 수 있습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RequiresIdlePlaylistMutation(intent) && !IsPlaylistMutationIdle(status, workflow))
|
||||
{
|
||||
rejectionMessage = intent is
|
||||
@@ -740,6 +767,39 @@ public sealed partial class MainWindow
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool CanAppendCutToPlaylistTail(
|
||||
PlayoutStatus status,
|
||||
LegacyPlayoutWorkflowState workflow)
|
||||
{
|
||||
if (IsPlaylistMutationIdle(status, workflow))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// SetPlayList in MainForm always inserted after RowCount. Keep that PROGRAM
|
||||
// behavior only while the native playlist still has one exact on-air anchor;
|
||||
// the newly appended row is then guaranteed to be in the future tail.
|
||||
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 RequiresIdlePlaylistMutation(LegacyUiIntent intent) => intent is
|
||||
LegacySetPlaylistEnabledIntent or
|
||||
LegacyMoveSelectedPlaylistRowsIntent or
|
||||
|
||||
@@ -26,6 +26,8 @@ public sealed partial class MainWindow : Window
|
||||
private readonly SemaphoreSlim _intentGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _orderedPlaylistTailMutationIntentGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _orderedNamedPlaylistIntentGate = new(1, 1);
|
||||
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
|
||||
private readonly WindowSubclassProcedure _windowSubclassProcedure;
|
||||
private readonly LegacyOperatorSettingsStore _operatorSettingsStore;
|
||||
@@ -446,6 +448,8 @@ public sealed partial class MainWindow : Window
|
||||
var gateEntered = false;
|
||||
var orderedLocalSelectionIntentEntered = false;
|
||||
var orderedPlayoutIntentEntered = false;
|
||||
var orderedPlaylistTailMutationIntentEntered = false;
|
||||
var orderedNamedPlaylistIntentEntered = false;
|
||||
var databaseGateEntered = false;
|
||||
var dispatchStarted = false;
|
||||
try
|
||||
@@ -468,6 +472,25 @@ public sealed partial class MainWindow : Window
|
||||
gateEntered = true;
|
||||
}
|
||||
}
|
||||
else if (IsOrderedPlaylistTailMutationIntent(intent))
|
||||
{
|
||||
// A Delete click follows the row-selection message in the same
|
||||
// WinForms UI queue. Keep exactly one delete behind that selection
|
||||
// and an in-progress refresh; repeated clicks must not accumulate
|
||||
// and delete a different row after the first mutation completes.
|
||||
orderedPlaylistTailMutationIntentEntered =
|
||||
await _orderedPlaylistTailMutationIntentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
if (orderedPlaylistTailMutationIntentEntered)
|
||||
{
|
||||
await _orderedLocalSelectionIntentGate.WaitAsync(
|
||||
_lifetimeCancellation.Token);
|
||||
orderedLocalSelectionIntentEntered = true;
|
||||
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
gateEntered = true;
|
||||
}
|
||||
}
|
||||
else if (IsQueuedLocalSelectionIntent(intent))
|
||||
{
|
||||
// WinForms handled these gestures on its one UI message queue. Keep
|
||||
@@ -479,6 +502,23 @@ public sealed partial class MainWindow : Window
|
||||
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
gateEntered = true;
|
||||
}
|
||||
else if (IsNamedPlaylistModalIntent(intent))
|
||||
{
|
||||
// MainForm processed its modal PList requests on the same UI queue
|
||||
// as the timer refresh. Preserve one pending user request, including
|
||||
// the acknowledgement produced by a completed write, behind an
|
||||
// in-progress refresh. Zero-timeout admission still prevents repeated
|
||||
// clicks from building an unbounded database-write queue.
|
||||
orderedNamedPlaylistIntentEntered =
|
||||
await _orderedNamedPlaylistIntentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
if (orderedNamedPlaylistIntentEntered)
|
||||
{
|
||||
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
gateEntered = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep the zero-timeout admission used by database and edit intents;
|
||||
@@ -498,6 +538,13 @@ public sealed partial class MainWindow : Window
|
||||
intent,
|
||||
"다른 요청을 처리 중이어서 설정을 변경하지 않았습니다."));
|
||||
}
|
||||
else if (intent is LegacyDismissDialogIntent)
|
||||
{
|
||||
// A second acknowledgement while the one pending dismissal owns
|
||||
// the slot is a harmless duplicate. Do not replace the success
|
||||
// dialog with a synthetic busy error.
|
||||
PostState(_controller.Current);
|
||||
}
|
||||
else
|
||||
{
|
||||
PostState(IsNamedPlaylistModalIntent(intent)
|
||||
@@ -739,6 +786,16 @@ public sealed partial class MainWindow : Window
|
||||
{
|
||||
_orderedPlayoutIntentGate.Release();
|
||||
}
|
||||
|
||||
if (orderedPlaylistTailMutationIntentEntered)
|
||||
{
|
||||
_orderedPlaylistTailMutationIntentGate.Release();
|
||||
}
|
||||
|
||||
if (orderedNamedPlaylistIntentEntered)
|
||||
{
|
||||
_orderedNamedPlaylistIntentGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,11 +813,15 @@ public sealed partial class MainWindow : Window
|
||||
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
|
||||
|
||||
private static bool IsOrderedPlaylistTailMutationIntent(LegacyUiIntent intent) =>
|
||||
intent is LegacyDeleteSelectedPlaylistRowsIntent;
|
||||
|
||||
private static bool IsQueuedLocalSelectionIntent(LegacyUiIntent intent) => intent is
|
||||
LegacySelectStockIntent or
|
||||
LegacySelectOperatorCatalogStockIntent or
|
||||
LegacyActivateOperatorCatalogStockIntent or
|
||||
LegacyCutPointerDownIntent or
|
||||
LegacyDropSelectedCutsIntent or
|
||||
LegacyCutKeyDownIntent or
|
||||
LegacyClearCutSelectionIntent or
|
||||
LegacySelectPlaylistRowIntent or
|
||||
@@ -780,7 +841,8 @@ public sealed partial class MainWindow : Window
|
||||
LegacyCreateNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistIntent or
|
||||
LegacySaveCurrentNamedPlaylistToIntent or
|
||||
LegacyDeleteSelectedNamedPlaylistIntent;
|
||||
LegacyDeleteSelectedNamedPlaylistIntent or
|
||||
LegacyDismissDialogIntent;
|
||||
|
||||
private LegacyOperatorSnapshot ReportIntentFailure(
|
||||
LegacyUiIntent intent,
|
||||
|
||||
@@ -4378,6 +4378,11 @@
|
||||
}
|
||||
namedPlaylistConfirmationModal.hidden = false;
|
||||
lockNamedPlaylistControls();
|
||||
// The confirmation can open immediately after a correlated DB refresh or
|
||||
// selection receipt. Re-establish its own controls here so a transient
|
||||
// busy render cannot leave the affirmative action stale-disabled.
|
||||
namedPlaylistConfirmationYes.disabled = false;
|
||||
namedPlaylistConfirmationNo.disabled = false;
|
||||
syncModalFocus();
|
||||
return true;
|
||||
}
|
||||
@@ -4496,8 +4501,11 @@
|
||||
namedPlaylistDeleteButton.disabled = modalLocked || named.canDelete !== true;
|
||||
namedPlaylistConfirmButton.disabled = modalLocked ||
|
||||
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
|
||||
namedPlaylistConfirmationYes.disabled = operatorMutationLocked || uiBusy ||
|
||||
pendingNamedPlaylistCommand !== null ||
|
||||
// A PROGRAM auto-refresh can begin between pointer-down and pointer-up.
|
||||
// Keep the already-open confirmation clickable through unrelated busy
|
||||
// state; the native ordered gate serializes the request and performs the
|
||||
// authoritative mutation-safety check before any database write.
|
||||
namedPlaylistConfirmationYes.disabled = pendingNamedPlaylistCommand !== null ||
|
||||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
|
||||
if (deferredSelectionAction) {
|
||||
runNamedPlaylistDoubleClick(
|
||||
@@ -4921,7 +4929,10 @@
|
||||
dialogCaption.textContent = state.dialog.caption || "MmoneyCoder";
|
||||
dialogMessage.textContent = state.dialog.message;
|
||||
dialogOk.textContent = legacyDialogIsConfirmation ? "예" : "확인";
|
||||
dialogOk.disabled = state.isBusy === true;
|
||||
// A one-button result alert is safe to acknowledge while an unrelated
|
||||
// refresh is finishing. Disabling it between pointer-down and pointer-up
|
||||
// swallowed real operator clicks; native dispatch now queues one dismissal.
|
||||
dialogOk.disabled = legacyDialogIsConfirmation && state.isBusy === true;
|
||||
dialogNo.hidden = !legacyDialogIsConfirmation;
|
||||
dialogNo.disabled = state.isBusy === true;
|
||||
if (dialog.hidden) rememberModalOpener(legacyAlertDialog);
|
||||
|
||||
Reference in New Issue
Block a user