feat: align legacy operator and playout behavior

This commit is contained in:
2026-07-15 21:04:05 +09:00
parent 06a16e5741
commit 83398044c6
77 changed files with 6377 additions and 391 deletions

View File

@@ -136,42 +136,44 @@ public sealed partial class MainWindow
try
{
Interlocked.Exchange(ref _playoutBusy, 1);
StopRefreshLoop();
var nextKindBefore = workflow.State.NextKind;
if (command == LegacyOperatorPlayoutCommand.Next &&
var pageNavigationEnabledBefore = workflow.State.IsPageNavigationEnabled;
if (engine.Status.IsTakeOutCompletionPending)
{
return _controller.ReportError(
"Tornado TAKE OUT 완료 이벤트를 기다리는 동안에는 송출 명령을 실행할 수 없습니다.");
}
if (command != LegacyOperatorPlayoutCommand.TakeOut &&
engine.Status.IsPlayCompletionPending)
{
return _controller.ReportError(
"Tornado 재생 완료 이벤트를 기다리는 중입니다. NEXT를 반복하지 마세요.");
"Tornado 재생 완료 이벤트를 기다리는 중입니다. 결과가 확인될 때까지 TAKE OUT만 사용할 수 있습니다.");
}
StopRefreshLoop();
PlayoutResult result;
switch (command)
{
case LegacyOperatorPlayoutCommand.Prepare:
var request = _controller.CreatePlayoutRequest();
var request = _controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration);
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);
}
// MainForm.btnTake_Click always copied m_rowidx (the operator's
// candidate) to m_crow and ran ONAirMode + Play, even while another
// row was already on air. Re-read the current selection on every F8;
// a stale explicit PREPARE must never override a later row click.
var takeInRequest = _controller.CreatePlayoutRequest(
_compositionOptions!.Current.FadeDuration);
result = await workflow.TakeSelectedAsync(
takeInRequest.Playlist,
takeInRequest.SelectedIndexZeroBased,
cancellationToken).ConfigureAwait(false);
break;
case LegacyOperatorPlayoutCommand.Next:
result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false);
@@ -185,6 +187,23 @@ public sealed partial class MainWindow
throw new ArgumentOutOfRangeException(nameof(command));
}
if (result.IsSuccess &&
command != LegacyOperatorPlayoutCommand.TakeOut &&
workflow.State.CurrentEntryId is { Length: > 0 } currentEntryId &&
workflow.State.PageCount > 0)
{
// FarPoint column 5 was mutable operator state: F8/PREPARE reset a
// paged cut to 1/N, while Page NEXT left 2/N, 3/N, ... in the row.
// NEXT must not replace the independently selected F8 candidate.
_controller.ReflectSuccessfulPlayout(
currentEntryId,
workflow.State.PageIndexZeroBased,
workflow.State.PageCount,
synchronizeOperatorSelection:
command is LegacyOperatorPlayoutCommand.Prepare or
LegacyOperatorPlayoutCommand.TakeIn);
}
if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut)
{
await QuarantinePlayoutAsync().ConfigureAwait(false);
@@ -197,9 +216,15 @@ public sealed partial class MainWindow
return _controller.ReportError(result.Message);
}
if (command == LegacyOperatorPlayoutCommand.TakeOut)
{
ResetRefreshState();
}
if (command == LegacyOperatorPlayoutCommand.TakeIn ||
command == LegacyOperatorPlayoutCommand.Next &&
nextKindBefore == LegacyWorkflowNextKind.PlaylistNext)
nextKindBefore == LegacyWorkflowNextKind.PlaylistNext &&
!pageNavigationEnabledBefore)
{
StartRefreshLoop(workflow);
}
@@ -237,9 +262,9 @@ public sealed partial class MainWindow
int fadeDuration,
CancellationToken cancellationToken)
{
if (fadeDuration is < 0 or > 60)
if (fadeDuration is < 0 or > 19)
{
return _controller.ReportError("Fade 값이 올바르지 않습니다.");
return _controller.ReportError("DissolveTime 값이 올바르지 않습니다.");
}
if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
@@ -251,13 +276,13 @@ public sealed partial class MainWindow
{
if (!CanChangeComposition())
{
return _controller.ReportError("Fade는 IDLE 상태에서만 변경할 수 있습니다.");
return _controller.ReportError("DissolveTime은 IDLE 상태에서만 변경할 수 있습니다.");
}
var source = _compositionOptions!;
source.Update(source.Current with { FadeDuration = fadeDuration });
return _controller.ReportInformation(
$"Fade {fadeDuration}을 다음 PREPARE부터 적용합니다.");
$"DissolveTime {fadeDuration + 1}을 다음 PREPARE부터 적용합니다.");
}
finally
{
@@ -265,6 +290,14 @@ public sealed partial class MainWindow
}
}
private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(bool last)
{
// MainForm.ProcessCmdKey stopped timer1 before applying either Home or End.
// Ordinary row selection deliberately does not use this path.
StopRefreshLoop();
return _controller.SelectPlaylistBoundary(last);
}
private async Task<LegacyOperatorSnapshot> ToggleBackgroundAsync(
CancellationToken cancellationToken)
{
@@ -390,6 +423,7 @@ public sealed partial class MainWindow
Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
!status.IsPlayCompletionPending &&
!status.IsTakeOutCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName) &&
string.IsNullOrWhiteSpace(workflow.PreparedCutCode) &&
@@ -413,6 +447,7 @@ public sealed partial class MainWindow
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) &&
@@ -421,6 +456,9 @@ public sealed partial class MainWindow
}
private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is
LegacyCutPointerDownIntent or
LegacyCutKeyDownIntent or
LegacyClearCutSelectionIntent or
LegacyDropSelectedCutsIntent or
LegacySetPlaylistEnabledIntent or
LegacySetAllPlaylistEnabledIntent or
@@ -521,7 +559,7 @@ public sealed partial class MainWindow
: LegacyOperatorPlayoutPhase.Idle;
var backgroundEnabled =
composition.BackgroundKind != LegacySceneBackgroundKind.None;
return new LegacyOperatorPlayoutSnapshot(
var snapshot = new LegacyOperatorPlayoutSnapshot(
status?.Mode ?? PlayoutMode.Disabled,
quarantined
? PlayoutConnectionState.OutcomeUnknown
@@ -563,6 +601,10 @@ public sealed partial class MainWindow
: _playoutInitializationWarning ?? status?.Message ??
_playoutInitializationError ??
"송출 어댑터를 사용할 수 없습니다.");
return snapshot with
{
IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false
};
}
private void StartRefreshLoop(LegacyPlayoutWorkflow workflow)
@@ -599,6 +641,14 @@ public sealed partial class MainWindow
}
}
private void ResetRefreshState()
{
StopRefreshLoop();
Interlocked.Exchange(ref _refreshCompletedCount, 0);
_refreshLimitReached = false;
_refreshMessage = string.Empty;
}
private async Task RunRefreshLoopAsync(
LegacyPlayoutWorkflow workflow,
TimeSpan firstInterval,
@@ -809,7 +859,8 @@ public sealed partial class MainWindow
if (Volatile.Read(ref _playoutQuarantined) == 0 &&
engine.Status.State != PlayoutConnectionState.OutcomeUnknown &&
string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) &&
!engine.Status.IsPlayCompletionPending)
!engine.Status.IsPlayCompletionPending &&
!engine.Status.IsTakeOutCompletionPending)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false);