Files
Tornado3_2026Election/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
2026-05-05 00:50:11 +09:00

1179 lines
47 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Services;
namespace Tornado3_2026Election.ViewModels;
public sealed class ChannelScheduleViewModel : ObservableObject
{
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
private readonly ChannelScheduleEngine _engine;
private readonly ITornado3Adapter _adapter;
private readonly CutDebugStateStore _cutDebugStateStore;
private readonly DataViewModel _data;
private readonly LogService _logService;
private readonly ObservableCollection<CutDebugItemState> _emptyCutDebugItems = [];
private IReadOnlyList<FormatTemplateDefinition> _allFormats;
private SelectionOption<CutCategory?>? _selectedFormatCategoryOption;
private SelectionOption<string>? _selectedTurnoutRegionModeOption;
private FormatTemplateDefinition? _selectedFormat;
private CutDebugTemplateState? _selectedCutDebugTemplate;
private ScheduleRegionOption? _selectedRegionOption;
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
private CancellationTokenSource? _directPlaybackCts;
private bool _loopEnabled;
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
private int _regionOptionsRevision;
private string _lastRegionOptionFormatId = string.Empty;
private VideoWallLayoutPreset _videoWallLayoutPreset = VideoWallLayoutPreset.Auto;
private double _selectedFormatThumbnailWidth = 320;
private double _selectedFormatThumbnailHeight = 180;
private double _selectedFormatDraftDurationSeconds;
public ChannelScheduleViewModel(
BroadcastChannel channel,
string title,
IReadOnlyList<FormatTemplateDefinition> formats,
DataViewModel data,
ITornado3Adapter adapter,
CutDebugSettings cutDebug,
CutDebugStateStore cutDebugStateStore,
ChannelScheduleEngine engine,
LogService logService)
{
Channel = channel;
Title = title;
_data = data;
_adapter = adapter;
CutDebug = cutDebug;
_cutDebugStateStore = cutDebugStateStore;
_engine = engine;
_logService = logService;
_allFormats = formats.ToArray();
FormatCategoryOptions = [];
TurnoutRegionModeOptions =
[
new SelectionOption<string>(DataViewModel.TurnoutPhotoSidoMode, "시도별 투표율"),
new SelectionOption<string>(DataViewModel.TurnoutPhotoDistrictMode, "선거구별 투표율")
];
_selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
RegionOptions = new ObservableCollection<ScheduleRegionOption>();
EmptyBehaviorOptions =
[
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"),
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.HoldLastFrame, "마지막 프레임 유지")
];
Queue = engine.Queue;
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
StopCommand = new AsyncRelayCommand(StopAsync);
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
ForceQueueNextCommand = new AsyncRelayCommand(ForceQueueNextAsync);
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);
IncreaseSelectedFormatDurationCommand = new RelayCommand(IncreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
DecreaseSelectedFormatDurationCommand = new RelayCommand(DecreaseSelectedFormatDuration, CanAdjustSelectedFormatDuration);
ApplySelectedFormatDurationCommand = new RelayCommand(ApplySelectedFormatDuration, CanApplySelectedFormatDuration);
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
_engine.QueueChanged += (_, _) => RefreshSummary();
_adapter.StateChanged += (_, _) => RefreshSummary();
_adapter.ConnectionChanged += (_, _) => RefreshSummary();
CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary), nameof(CutDebugPanelVisibility));
_data.PropertyChanged += Data_PropertyChanged;
Queue.CollectionChanged += Queue_CollectionChanged;
RebuildFormatCategoryOptions();
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
UpdateSelectedFormatThumbnailMetrics();
ApplyQueueThumbnailLayouts();
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 ObservableCollection<SelectionOption<CutCategory?>> FormatCategoryOptions { get; }
public IReadOnlyList<SelectionOption<string>> TurnoutRegionModeOptions { get; }
public ObservableCollection<ScheduleRegionOption> RegionOptions { get; }
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
public ObservableCollection<ChannelScheduleItem> Queue { get; }
public AsyncRelayCommand StartCommand { get; }
public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand DirectStartCommand { get; }
public AsyncRelayCommand DirectStopCommand { get; }
public AsyncRelayCommand ForceNextCommand { get; }
public AsyncRelayCommand ForceQueueNextCommand { get; }
public CutDebugSettings CutDebug { 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 RelayCommand IncreaseSelectedFormatDurationCommand { get; }
public RelayCommand DecreaseSelectedFormatDurationCommand { get; }
public RelayCommand ApplySelectedFormatDurationCommand { get; }
public event EventHandler<FormatTemplateDefinition>? FormatDurationChanged;
public SelectionOption<CutCategory?>? SelectedFormatCategoryOption
{
get => _selectedFormatCategoryOption;
set
{
if (value is null)
{
return;
}
if (SetProperty(ref _selectedFormatCategoryOption, value))
{
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
RefreshSummary();
}
}
}
public SelectionOption<string>? SelectedTurnoutRegionModeOption
{
get => _selectedTurnoutRegionModeOption;
set
{
if (value is null)
{
return;
}
if (SetProperty(ref _selectedTurnoutRegionModeOption, value))
{
_ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged();
RefreshSummary();
}
}
}
public Visibility TurnoutRegionModeVisibility =>
UsesTurnoutRegionMode(SelectedFormat) ? Visibility.Visible : Visibility.Collapsed;
public FormatTemplateDefinition? SelectedFormat
{
get => _selectedFormat;
set
{
if (SetProperty(ref _selectedFormat, value))
{
EnsureTurnoutRegionModeSelection();
ResetSelectedFormatDurationDraft();
OnPropertyChanged(nameof(TurnoutRegionModeVisibility));
NotifySelectedFormatPreviewChanged();
SyncSelectedCutDebugTemplate();
_ = RebuildRegionOptionsAsync();
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
}
}
public ScheduleRegionOption? SelectedRegionOption
{
get => _selectedRegionOption;
set
{
if (SetProperty(ref _selectedRegionOption, value))
{
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
}
}
private void EnsureTurnoutRegionModeSelection()
{
if (!UsesTurnoutRegionMode(SelectedFormat))
{
return;
}
if (_selectedTurnoutRegionModeOption is not null)
{
return;
}
_selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0];
OnPropertyChanged(nameof(SelectedTurnoutRegionModeOption));
}
private static bool UsesTurnoutRegionMode(FormatTemplateDefinition? format)
{
return format is not null &&
string.Equals(format.Name, "투표율_사진", StringComparison.Ordinal);
}
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)?.DisplayName ?? "대기 화면";
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "다음 컷 없음";
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)?.DisplayName ?? "-";
var next = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.DisplayName ?? "-";
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}";
public string SelectedFormatName => SelectedFormat?.Name ?? "컷을 선택하세요";
public string SelectedFormatDescription => SelectedFormat?.Description ?? "선택한 컷의 썸네일이 여기에 표시됩니다.";
public string SelectedFormatPath => SelectedFormat?.Id.Replace('\\', '/') ?? string.Empty;
public string SelectedFormatThumbnailStatus => SelectedFormat is null
? string.Empty
: CutThumbnailAssetCatalog.HasThumbnail(SelectedFormat)
? "등록된 썸네일을 표시 중"
: "썸네일이 없어 기본 아이콘을 표시 중";
public double SelectedFormatMinimumDurationSeconds => SelectedFormat is null
? 1d
: ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(SelectedFormat);
public double SelectedFormatDraftDurationSeconds
{
get => _selectedFormatDraftDurationSeconds;
set
{
var normalized = SelectedFormat is null
? Math.Max(1d, Math.Round(value, 1, MidpointRounding.AwayFromZero))
: ScheduleTemplatePolicy.NormalizeCutDurationSeconds(value, SelectedFormat);
if (SetProperty(ref _selectedFormatDraftDurationSeconds, normalized))
{
NotifySelectedFormatDurationStateChanged();
}
}
}
public bool HasSelectedFormatDurationChange => SelectedFormat is not null &&
Math.Abs(SelectedFormatDraftDurationSeconds - ResolveSelectedFormatDurationSeconds(SelectedFormat)) >= 0.001d;
public string SelectedFormatDurationStatusLabel => HasSelectedFormatDurationChange ? "미적용" : "적용됨";
public string CutDebugSummary => CutDebug.Summary;
public Visibility CutDebugPanelVisibility => CutDebug.IsFeatureEnabled ? Visibility.Visible : Visibility.Collapsed;
public ObservableCollection<CutDebugItemState> CutDebugItems => _selectedCutDebugTemplate?.Items ?? _emptyCutDebugItems;
public int CutDebugItemCount => CutDebugItems.Count;
public string CutDebugTextTargets => BuildCutDebugTextTargets(SelectedFormat);
public string CutDebugImageTargets => BuildCutDebugImageTargets(SelectedFormat);
public string CutDebugVisibilityTargets => BuildCutDebugVisibilityTargets(SelectedFormat);
public string CutDebugVoteRateTextTargets => BuildIndexedTargetRange("득표율", ResolveCutDebugSlotCount(SelectedFormat));
public string CutDebugVoteRateCounterTargets => $"{BuildIndexedTargetRange("", ResolveCutDebugSlotCount(SelectedFormat))} key#1";
public string CutDebugPartyBarColorTargets => JoinTargets(
BuildIndexedTargetRange("기호", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("기호텍스트", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("득표수바", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("정당바", ResolveCutDebugSlotCount(SelectedFormat)));
public string CutDebugPartyPlateColorTargets => JoinTargets(
BuildIndexedTargetRange("정당판", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("정당원", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("정당색", ResolveCutDebugSlotCount(SelectedFormat)),
BuildIndexedTargetRange("정당명", ResolveCutDebugSlotCount(SelectedFormat)));
public string CutDebugVoteRateColorTargets => $"{BuildIndexedTargetRange("", ResolveCutDebugSlotCount(SelectedFormat))} edge/face";
public ImageSource? SelectedFormatThumbnailSource => SelectedFormat is null
? null
: CutThumbnailAssetCatalog.CreateImageSource(SelectedFormat);
public double SelectedFormatThumbnailWidth => _selectedFormatThumbnailWidth;
public double SelectedFormatThumbnailHeight => _selectedFormatThumbnailHeight;
public async Task RefreshRegionOptionsAsync()
{
await RebuildRegionOptionsAsync();
}
public void RefreshSelectedFormatPreview()
{
NotifySelectedFormatPreviewChanged();
}
public void RefreshQueueThumbnails()
{
foreach (var item in Queue)
{
item.RefreshThumbnail();
}
}
public void UpdateFormats(IReadOnlyList<FormatTemplateDefinition> formats)
{
_allFormats = formats.ToArray();
RebuildFormatCategoryOptions();
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
ApplyQueueThumbnailLayouts();
RefreshSummary();
}
public void UpdateVideoWallLayoutPreset(VideoWallLayoutPreset videoWallLayoutPreset)
{
if (_videoWallLayoutPreset == videoWallLayoutPreset)
{
return;
}
_videoWallLayoutPreset = videoWallLayoutPreset;
UpdateSelectedFormatThumbnailMetrics();
ApplyQueueThumbnailLayouts();
}
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 DirectStartAsync()
{
var selectedFormat = SelectedFormat;
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (selectedFormat is null || regionOption is null)
{
_logService.Warning($"[{Title}] 바로 송출할 컷과 지역을 먼저 선택해 주세요.");
return;
}
if (!selectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
{
_logService.Warning($"[{Title}] 현재 단계에서는 '{selectedFormat.Name}' 컷을 바로 송출할 수 없습니다.");
return;
}
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
var playbackCts = new CancellationTokenSource();
_directPlaybackCts = playbackCts;
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
selectedFormat,
_videoWallLayoutPreset,
ThumbnailDisplayContext.Queue));
try
{
_logService.Info($"[{Title}] 선택 컷 바로 송출: {selectedFormat.Name} / {regionOption.Label}");
await _engine.PlayDirectAsync(item, selectedFormat, playbackCts.Token).ConfigureAwait(false);
if (!playbackCts.IsCancellationRequested)
{
_logService.Info($"[{Title}] 선택 컷 바로 송출 완료: {selectedFormat.Name}");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logService.Error($"[{Title}] 선택 컷 바로 송출 실패: {ex.Message}");
}
finally
{
if (ReferenceEquals(_directPlaybackCts, playbackCts))
{
_directPlaybackCts = null;
}
playbackCts.Dispose();
RefreshSummary();
}
}
private async Task DirectStopAsync()
{
_directPlaybackCts?.Cancel();
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 선택 컷 송출 정지");
}
private async Task ForceNextAsync()
{
await _engine.ForceNextAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 현재 컷을 강제로 전환");
}
private async Task ForceQueueNextAsync()
{
await _engine.ForceQueueNextAsync().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;
}
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (regionOption is null)
{
_logService.Warning($"[{Title}] 선택 가능한 지역 정보가 아직 준비되지 않았습니다.");
return;
}
var item = ChannelScheduleItem.FromTemplate(SelectedFormat, regionOption);
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue));
_engine.Enqueue(item);
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();
}
private void IncreaseSelectedFormatDuration()
{
SelectedFormatDraftDurationSeconds += 1d;
}
private void DecreaseSelectedFormatDuration()
{
SelectedFormatDraftDurationSeconds -= 1d;
}
private void ApplySelectedFormatDuration()
{
var selectedFormat = SelectedFormat;
if (selectedFormat is null)
{
return;
}
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(SelectedFormatDraftDurationSeconds, selectedFormat);
var changed = false;
foreach (var cut in selectedFormat.Cuts)
{
if (Math.Abs(cut.DurationSeconds - normalized) < 0.001d)
{
continue;
}
cut.DurationSeconds = normalized;
changed = true;
}
SelectedFormatDraftDurationSeconds = normalized;
NotifySelectedFormatDurationStateChanged();
if (changed)
{
FormatDurationChanged?.Invoke(this, selectedFormat);
_logService.Info($"[{Title}] 컷 송출 시간 적용: {selectedFormat.Name} {normalized:0.#}초");
}
}
private bool CanAdjustSelectedFormatDuration()
{
return SelectedFormat is not null;
}
private bool CanApplySelectedFormatDuration()
{
return HasSelectedFormatDurationChange;
}
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) &&
SelectedRegionOption is not null;
}
private bool CanDirectStart()
{
return CanAddFormat();
}
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName is nameof(DataViewModel.BroadcastPhase) or nameof(DataViewModel.ElectionType))
{
RebuildFormatCategoryOptions();
RebuildAvailableFormats();
_ = RebuildRegionOptionsAsync();
RefreshSummary();
}
}
private void RebuildAvailableFormats()
{
var selectedFormatId = SelectedFormat?.Id;
var selectedCategory = SelectedFormatCategoryOption?.Value;
var filteredFormats = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
.ToArray();
AvailableFormats.Clear();
foreach (var format in filteredFormats)
{
AvailableFormats.Add(format);
}
var nextSelectedFormat = !string.IsNullOrWhiteSpace(selectedFormatId)
? AvailableFormats.FirstOrDefault(format => string.Equals(format.Id, selectedFormatId, StringComparison.Ordinal))
: null;
nextSelectedFormat ??= AvailableFormats.FirstOrDefault();
if (!ReferenceEquals(SelectedFormat, nextSelectedFormat))
{
SelectedFormat = nextSelectedFormat;
}
UpdateSelectedFormatThumbnailMetrics();
SyncSelectedCutDebugTemplate();
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote));
}
private void RebuildFormatCategoryOptions()
{
var selectedCategory = SelectedFormatCategoryOption?.Value;
var formatsInCurrentPhase = _allFormats
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
.ToArray();
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
FormatCategoryOptions.Clear();
foreach (var option in options)
{
FormatCategoryOptions.Add(option);
}
var nextSelectedOption = selectedCategory.HasValue
? FormatCategoryOptions.FirstOrDefault(option => option.Value == selectedCategory.Value)
: null;
nextSelectedOption ??= FormatCategoryOptions.FirstOrDefault();
if (!ReferenceEquals(_selectedFormatCategoryOption, nextSelectedOption))
{
_selectedFormatCategoryOption = nextSelectedOption;
OnPropertyChanged(nameof(SelectedFormatCategoryOption));
}
}
private static IReadOnlyList<SelectionOption<CutCategory?>> CreateFormatCategoryOptions(
IReadOnlyList<FormatTemplateDefinition> formats)
{
List<SelectionOption<CutCategory?>> options = [new(null, "전체보기")];
var seenResultKeys = new HashSet<string>(StringComparer.Ordinal)
{
BuildCategoryResultKey(formats.Select(format => format.Id))
};
foreach (var category in CutCategoryResolver.GetOrderedCategories())
{
var matchingFormats = formats
.Where(format => CutCategoryResolver.IsMatch(format, category))
.ToArray();
if (matchingFormats.Length == 0)
{
continue;
}
if (!seenResultKeys.Add(BuildCategoryResultKey(matchingFormats.Select(format => format.Id))))
{
continue;
}
options.Add(new SelectionOption<CutCategory?>(category, CutCategoryResolver.GetLabel(category)));
}
return options;
}
private static string BuildCategoryResultKey(IEnumerable<string> formatIds)
{
return string.Join(
"\u001F",
formatIds.OrderBy(formatId => formatId, StringComparer.Ordinal));
}
private async Task RebuildRegionOptionsAsync()
{
var revision = Interlocked.Increment(ref _regionOptionsRevision);
var selectedFormat = SelectedFormat;
var previousSelection = SelectedRegionOption;
if (selectedFormat is null)
{
RegionOptions.Clear();
SelectedRegionOption = null;
_lastRegionOptionFormatId = string.Empty;
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
return;
}
var previousRegionOptionFormatId = _lastRegionOptionFormatId;
var turnoutPhotoMode = UsesTurnoutRegionMode(selectedFormat)
? SelectedTurnoutRegionModeOption?.Value
: null;
var options = await _data.GetScheduleRegionOptionsAsync(selectedFormat, turnoutPhotoMode);
if (revision != _regionOptionsRevision)
{
return;
}
RegionOptions.Clear();
foreach (var option in options)
{
RegionOptions.Add(option);
}
var shouldUseDefaultSelection = !string.Equals(
previousRegionOptionFormatId,
selectedFormat.Id,
StringComparison.Ordinal);
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
_lastRegionOptionFormatId = selectedFormat.Id;
AddFormatCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
private void NotifySelectedFormatPreviewChanged()
{
UpdateSelectedFormatThumbnailMetrics();
OnPropertyChanged(
nameof(CutDebugTextTargets),
nameof(CutDebugImageTargets),
nameof(CutDebugVisibilityTargets),
nameof(CutDebugVoteRateTextTargets),
nameof(CutDebugVoteRateCounterTargets),
nameof(CutDebugPartyBarColorTargets),
nameof(CutDebugPartyPlateColorTargets),
nameof(CutDebugVoteRateColorTargets),
nameof(SelectedFormatName),
nameof(SelectedFormatDescription),
nameof(SelectedFormatPath),
nameof(SelectedFormatThumbnailStatus),
nameof(SelectedFormatThumbnailSource),
nameof(SelectedFormatThumbnailWidth),
nameof(SelectedFormatThumbnailHeight));
}
private void ResetSelectedFormatDurationDraft()
{
var durationSeconds = SelectedFormat is null
? 0d
: ResolveSelectedFormatDurationSeconds(SelectedFormat);
SetProperty(ref _selectedFormatDraftDurationSeconds, durationSeconds, nameof(SelectedFormatDraftDurationSeconds));
NotifySelectedFormatDurationStateChanged();
}
private void NotifySelectedFormatDurationStateChanged()
{
OnPropertyChanged(
nameof(SelectedFormatMinimumDurationSeconds),
nameof(HasSelectedFormatDurationChange),
nameof(SelectedFormatDurationStatusLabel));
IncreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
DecreaseSelectedFormatDurationCommand.NotifyCanExecuteChanged();
ApplySelectedFormatDurationCommand.NotifyCanExecuteChanged();
}
private static double ResolveSelectedFormatDurationSeconds(FormatTemplateDefinition format)
{
return ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
format.Cuts.FirstOrDefault()?.DurationSeconds ?? ScheduleTemplatePolicy.GetMinimumCutDurationSeconds(format),
format);
}
private void Queue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
ApplyQueueThumbnailLayouts();
}
private static ScheduleRegionOption? ResolvePreferredRegionOption(
IReadOnlyList<ScheduleRegionOption> options,
ScheduleRegionOption? previousSelection,
FormatTemplateDefinition selectedFormat,
bool shouldUseDefaultSelection)
{
if (options.Count == 0)
{
return null;
}
if (previousSelection is null || shouldUseDefaultSelection)
{
return ResolveDefaultRegionOption(options, selectedFormat);
}
if (previousSelection.Scope is ScheduleRegionScope.Single or ScheduleRegionScope.RegionGroup)
{
var matchedSingle = options.FirstOrDefault(option =>
option.Scope == previousSelection.Scope &&
string.Equals(option.ElectionType, previousSelection.ElectionType, System.StringComparison.Ordinal) &&
string.Equals(option.DistrictCode, previousSelection.DistrictCode, System.StringComparison.OrdinalIgnoreCase));
if (matchedSingle is not null)
{
return matchedSingle;
}
}
return options.FirstOrDefault(option =>
option.Scope == previousSelection.Scope &&
string.Equals(option.ElectionType, previousSelection.ElectionType, System.StringComparison.Ordinal)) ??
options.FirstOrDefault(option => option.Scope == previousSelection.Scope) ??
ResolveDefaultRegionOption(options, selectedFormat);
}
private static ScheduleRegionOption ResolveDefaultRegionOption(
IReadOnlyList<ScheduleRegionOption> options,
FormatTemplateDefinition selectedFormat)
{
var defaultScope = UsesAllDefaultRegionScope(selectedFormat)
? ScheduleRegionScope.All
: ScheduleRegionScope.StationRegions;
return options.FirstOrDefault(option => option.Scope == defaultScope) ?? options[0];
}
private static bool UsesAllDefaultRegionScope(FormatTemplateDefinition format)
{
var source = $"{format.Name} {format.Id}";
return source.Contains("광역단체장", StringComparison.Ordinal) ||
source.Contains("교육감", StringComparison.Ordinal);
}
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
{
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
}
private void UpdateSelectedFormatThumbnailMetrics()
{
var metrics = SelectedFormat is null
? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview)
: ThumbnailLayoutResolver.ResolveDisplayMetrics(SelectedFormat, _videoWallLayoutPreset, ThumbnailDisplayContext.Preview);
SetProperty(ref _selectedFormatThumbnailWidth, metrics.Width, nameof(SelectedFormatThumbnailWidth));
SetProperty(ref _selectedFormatThumbnailHeight, metrics.Height, nameof(SelectedFormatThumbnailHeight));
}
private void ApplyQueueThumbnailLayouts()
{
foreach (var item in Queue)
{
var template = _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
var metrics = template is null
? ThumbnailLayoutResolver.ResolveDisplayMetrics(item.Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue)
: ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.Queue);
item.UpdateThumbnailLayout(metrics);
}
}
private void SyncSelectedCutDebugTemplate()
{
if (SelectedFormat is null)
{
_selectedCutDebugTemplate = null;
OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
return;
}
var templateState = _cutDebugStateStore.GetTemplate(Channel, SelectedFormat.Id, SelectedFormat.Name);
templateState.SyncItems(BuildCutDebugItemDescriptors(SelectedFormat));
_selectedCutDebugTemplate = templateState;
OnPropertyChanged(nameof(CutDebugItems), nameof(CutDebugItemCount));
}
private static string BuildCutDebugTextTargets(FormatTemplateDefinition? format)
{
var slotCount = ResolveCutDebugSlotCount(format);
return JoinTargets(
"시도명01",
"개표율01",
BuildIndexedTargetRange("순위", slotCount),
BuildIndexedTargetRange("후보명", slotCount),
BuildIndexedTargetRange("정당명", slotCount),
BuildIndexedTargetRange("득표수", slotCount),
BuildIndexedTargetRange("득표율", slotCount));
}
private static string BuildCutDebugImageTargets(FormatTemplateDefinition? format)
{
var slotCount = ResolveCutDebugSlotCount(format);
var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
return JoinTargets(
chartTarget,
BuildIndexedTargetRange("유확당", slotCount),
BuildIndexedTargetRange("점선", slotCount),
BuildIndexedTargetRange("후보사진", slotCount),
BuildIndexedTargetRange("득표수바", slotCount),
BuildIndexedTargetRange("정당바", slotCount),
BuildIndexedTargetRange("정당판", slotCount),
BuildIndexedTargetRange("정당원", slotCount),
BuildIndexedTargetRange("정당색", slotCount),
BuildIndexedTargetRange("정당심볼", slotCount));
}
private static string BuildCutDebugVisibilityTargets(FormatTemplateDefinition? format)
{
var slotCount = ResolveCutDebugSlotCount(format);
var chartTarget = ShouldExcludeHistoricalTurnoutGraph(format) ? string.Empty : "차트01";
return JoinTargets(
chartTarget,
BuildIndexedTargetRange("점선", slotCount),
BuildIndexedTargetRange("유확당", slotCount),
BuildIndexedTargetRange("그룹", slotCount),
BuildIndexedTargetRange("공약그룹", 3));
}
private static int ResolveCutDebugSlotCount(FormatTemplateDefinition? format)
{
if (format is null)
{
return 2;
}
var source = $"{format.Name} {format.Id}";
var topRankMatch = TopRankSlotCountPattern.Match(source);
if (topRankMatch.Success && int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlotCount))
{
return Math.Max(1, topRankSlotCount);
}
var peopleMatch = PeopleSlotCountPattern.Match(source);
if (peopleMatch.Success && int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlotCount))
{
return Math.Max(1, peopleSlotCount);
}
return 2;
}
private static string BuildIndexedTargetRange(string prefix, int slotCount)
{
slotCount = Math.Max(1, slotCount);
return slotCount == 1
? $"{prefix}01"
: $"{prefix}01~{slotCount:00}";
}
private static string JoinTargets(params string[] targets)
{
return string.Join(", ", targets.Where(target => !string.IsNullOrWhiteSpace(target)));
}
private static IReadOnlyList<CutDebugItemDescriptor> BuildCutDebugItemDescriptors(FormatTemplateDefinition? format)
{
if (format is null)
{
return Array.Empty<CutDebugItemDescriptor>();
}
var slotCount = ResolveCutDebugSlotCount(format);
var descriptors = new List<CutDebugItemDescriptor>();
AddDescriptor(descriptors, "선거구명01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "시도명01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "개표율01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "기준시01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "기준시02", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "유권자수01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "투표자수01", CutDebugItemKind.TextValue, "공통 텍스트");
AddDescriptor(descriptors, "전국투표율01", CutDebugItemKind.Counter, "카운터");
if (!ShouldExcludeHistoricalTurnoutGraph(format))
{
AddDescriptor(descriptors, "차트01", CutDebugItemKind.ImageValue, "이미지/리소스");
AddDescriptor(descriptors, "차트01", CutDebugItemKind.Visibility, "표시/숨김");
}
AddIndexedDescriptors(descriptors, "순위", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "후보명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "득표수", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "득표차", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "선거구명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "시도명", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "개표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.TextValue, "후보 텍스트");
AddIndexedDescriptors(descriptors, "공약", 3, CutDebugItemKind.TextValue, "공약 텍스트");
AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "후보사진", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "정당심볼", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "바", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.ImageValue, "이미지/리소스");
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.Counter, "카운터");
AddIndexedDescriptors(descriptors, "투표율", slotCount, CutDebugItemKind.Counter, "카운터");
AddIndexedDescriptors(descriptors, "기호", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "기호텍스트", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "득표수바", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당바", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당판", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당원", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당색", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "정당명", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "득표율", slotCount, CutDebugItemKind.StyleColor, "색상");
AddIndexedDescriptors(descriptors, "유확당", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
AddIndexedDescriptors(descriptors, "그룹", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
AddIndexedDescriptors(descriptors, "공약그룹", 3, CutDebugItemKind.Visibility, "표시/숨김");
AddIndexedDescriptors(descriptors, "점선", slotCount, CutDebugItemKind.Visibility, "표시/숨김");
return descriptors;
}
private static void AddIndexedDescriptors(
ICollection<CutDebugItemDescriptor> descriptors,
string prefix,
int slotCount,
CutDebugItemKind kind,
string groupLabel)
{
for (var slot = 1; slot <= Math.Max(1, slotCount); slot++)
{
AddDescriptor(descriptors, $"{prefix}{slot:00}", kind, groupLabel);
}
}
private static void AddDescriptor(
ICollection<CutDebugItemDescriptor> descriptors,
string key,
CutDebugItemKind kind,
string groupLabel)
{
descriptors.Add(new CutDebugItemDescriptor(key, kind, groupLabel));
}
private static bool ShouldExcludeHistoricalTurnoutGraph(FormatTemplateDefinition? format)
{
return format is not null &&
format.Name.StartsWith("사전_역대투표율", StringComparison.Ordinal);
}
}