Files
Tornado3_2026Election/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs

350 lines
11 KiB
C#

using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Services;
namespace Tornado3_2026Election.ViewModels;
public sealed class ChannelScheduleViewModel : ObservableObject
{
private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter;
private readonly DataViewModel _data;
private readonly LogService _logService;
private readonly IReadOnlyList<FormatTemplateDefinition> _allFormats;
private FormatTemplateDefinition? _selectedFormat;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
public ChannelScheduleViewModel(
BroadcastChannel channel,
string title,
IReadOnlyList<FormatTemplateDefinition> formats,
DataViewModel data,
ITornado3Adapter adapter,
ChannelScheduleEngine engine,
LogService logService)
{
Channel = channel;
Title = title;
_data = data;
_adapter = adapter;
_engine = engine;
_logService = logService;
_allFormats = formats.ToArray();
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
EmptyBehaviorOptions =
[
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"),
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.HoldLastFrame, "마지막 프레임 유지")
];
Queue = engine.Queue;
StartCommand = new AsyncRelayCommand(StartAsync);
StopCommand = new AsyncRelayCommand(StopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
ResetQueueCommand = new RelayCommand(ResetQueue);
RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(PromoteToNext);
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
_engine.QueueChanged += (_, _) => RefreshSummary();
_adapter.StateChanged += (_, _) => RefreshSummary();
_adapter.ConnectionChanged += (_, _) => RefreshSummary();
_data.PropertyChanged += Data_PropertyChanged;
RebuildAvailableFormats();
RefreshSummary();
}
public BroadcastChannel Channel { get; }
public string Title { get; }
public bool IsLiveCg => _adapter.IsLiveCg;
public bool IsCgConnected => _adapter.IsConnected;
public string CgBackendName => _adapter.BackendName;
public string CgConnectionTarget => _adapter.ConnectionTarget;
public string CgStatusSummary => IsLiveCg
? $"{(IsCgConnected ? "Connected" : "Disconnected")} / {CgBackendName} / {CgConnectionTarget}"
: $"Disconnected / {CgBackendName} / {CgConnectionTarget}";
public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
public ObservableCollection<ChannelScheduleItem> Queue { get; }
public AsyncRelayCommand StartCommand { get; }
public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand ForceNextCommand { get; }
public RelayCommand AddFormatCommand { get; }
public RelayCommand ResetQueueCommand { get; }
public RelayCommand<ChannelScheduleItem> RemoveItemCommand { get; }
public RelayCommand<ChannelScheduleItem> MoveUpCommand { get; }
public RelayCommand<ChannelScheduleItem> MoveDownCommand { get; }
public RelayCommand<ChannelScheduleItem> PromoteToNextCommand { get; }
public FormatTemplateDefinition? SelectedFormat
{
get => _selectedFormat;
set
{
if (SetProperty(ref _selectedFormat, value))
{
if (AddFormatCommand is not null)
{
AddFormatCommand.NotifyCanExecuteChanged();
}
}
}
}
public bool LoopEnabled
{
get => _loopEnabled;
set
{
if (SetProperty(ref _loopEnabled, value))
{
_engine.LoopEnabled = value;
RefreshSummary();
}
}
}
public EmptyScheduleBehavior EmptyScheduleBehavior
{
get => _emptyScheduleBehavior;
set
{
if (SetProperty(ref _emptyScheduleBehavior, value))
{
_engine.EmptyScheduleBehavior = value;
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(value);
}
}
}
public SelectionOption<EmptyScheduleBehavior>? SelectedEmptyBehaviorOption
{
get => _selectedEmptyBehaviorOption;
set
{
if (value is null)
{
return;
}
if (SetProperty(ref _selectedEmptyBehaviorOption, value) && _emptyScheduleBehavior != value.Value)
{
EmptyScheduleBehavior = value.Value;
}
}
}
public TornadoConnectionState AdapterState => _adapter.State;
public string AdapterStateLabel => AdapterState switch
{
TornadoConnectionState.Idle => "대기",
TornadoConnectionState.Ready => "준비 완료",
TornadoConnectionState.Sending => "전송 중",
TornadoConnectionState.OnAir => "송출 중",
TornadoConnectionState.Error => "오류",
_ => "대기"
};
public string AdapterStateText => $"Tornado 상태: {AdapterStateLabel}";
public string TransmissionLabel => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
? "송출 중"
: "대기";
public string CurrentItemName => Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.FormatName ?? "대기 화면";
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "다음 컷 없음";
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
public string QueueFootnote => $"대기 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
public string QueueSummary
{
get
{
var current = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.FormatName ?? "-";
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "-";
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
}
}
public string LoopSummary => LoopEnabled ? "반복 재생" : "1회 재생";
public string EmptyBehaviorLabel => SelectedEmptyBehaviorOption?.Label ?? "즉시 아웃";
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
private async Task StartAsync()
{
await _engine.StartAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 큐를 시작");
}
private async Task StopAsync()
{
await _engine.StopAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 큐를 종료");
}
private async Task ForceNextAsync()
{
await _engine.ForceNextAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 현재 컷을 강제로 전환");
}
private void AddFormat()
{
if (SelectedFormat is null)
{
return;
}
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
{
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
return;
}
if (!_data.ValidateForFormat(SelectedFormat, out var validationError))
{
_logService.Warning($"[{Title}] {validationError}");
return;
}
_engine.Enqueue(ChannelScheduleItem.FromTemplate(SelectedFormat));
RefreshSummary();
}
private void ResetQueue()
{
_engine.Reset();
RefreshSummary();
_logService.Info($"[{Title}] 큐를 첫 컷부터 다시 시작하도록 초기화했습니다.");
}
private void RemoveItem(ChannelScheduleItem? item)
{
if (!_engine.Remove(item))
{
_logService.Warning($"[{Title}] 송출 중 항목은 제거할 수 없습니다.");
return;
}
RefreshSummary();
}
private void MoveUp(ChannelScheduleItem? item)
{
_engine.MoveUp(item);
RefreshSummary();
}
private void MoveDown(ChannelScheduleItem? item)
{
_engine.MoveDown(item);
RefreshSummary();
}
private void PromoteToNext(ChannelScheduleItem? item)
{
_engine.PromoteToNext(item);
RefreshSummary();
}
public void RefreshSummary()
{
_engine.RefreshQueueMarkers();
OnPropertyChanged(
nameof(AdapterState),
nameof(AdapterStateText),
nameof(AdapterStateLabel),
nameof(TransmissionLabel),
nameof(CurrentItemName),
nameof(NextItemName),
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary),
nameof(IsCgConnected),
nameof(CgStatusSummary),
nameof(LoopSummary),
nameof(EmptyBehaviorLabel),
nameof(OperatorQuickSummary));
}
private bool CanAddFormat()
{
return SelectedFormat is not null && SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase);
}
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName != nameof(DataViewModel.BroadcastPhase))
{
return;
}
RebuildAvailableFormats();
RefreshSummary();
}
private void RebuildAvailableFormats()
{
var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.ToArray();
AvailableFormats.Clear();
foreach (var format in filteredFormats)
{
AvailableFormats.Add(format);
}
if (SelectedFormat is null || !AvailableFormats.Contains(SelectedFormat))
{
SelectedFormat = AvailableFormats.FirstOrDefault();
}
AddFormatCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote));
}
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
{
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
}
}