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 _emptyCutDebugItems = []; private IReadOnlyList _allFormats; private SelectionOption? _selectedFormatCategoryOption; private SelectionOption? _selectedTurnoutRegionModeOption; private FormatTemplateDefinition? _selectedFormat; private CutDebugTemplateState? _selectedCutDebugTemplate; private ScheduleRegionOption? _selectedRegionOption; private SelectionOption? _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 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(DataViewModel.TurnoutPhotoSidoMode, "시도별 투표율"), new SelectionOption(DataViewModel.TurnoutPhotoDistrictMode, "선거구별 투표율") ]; _selectedTurnoutRegionModeOption = TurnoutRegionModeOptions[0]; AvailableFormats = new ObservableCollection(); RegionOptions = new ObservableCollection(); EmptyBehaviorOptions = [ new SelectionOption(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"), new SelectionOption(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(RemoveItem); MoveUpCommand = new RelayCommand(MoveUp); MoveDownCommand = new RelayCommand(MoveDown); PromoteToNextCommand = new RelayCommand(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 AvailableFormats { get; } public ObservableCollection> FormatCategoryOptions { get; } public IReadOnlyList> TurnoutRegionModeOptions { get; } public ObservableCollection RegionOptions { get; } public IReadOnlyList> EmptyBehaviorOptions { get; } public ObservableCollection 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 RemoveItemCommand { get; } public RelayCommand MoveUpCommand { get; } public RelayCommand MoveDownCommand { get; } public RelayCommand PromoteToNextCommand { get; } public RelayCommand IncreaseSelectedFormatDurationCommand { get; } public RelayCommand DecreaseSelectedFormatDurationCommand { get; } public RelayCommand ApplySelectedFormatDurationCommand { get; } public event EventHandler? FormatDurationChanged; public SelectionOption? SelectedFormatCategoryOption { get => _selectedFormatCategoryOption; set { if (value is null) { return; } if (SetProperty(ref _selectedFormatCategoryOption, value)) { RebuildAvailableFormats(); _ = RebuildRegionOptionsAsync(); RefreshSummary(); } } } public SelectionOption? 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? 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 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 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> CreateFormatCategoryOptions( IReadOnlyList formats) { List> options = [new(null, "전체보기")]; var seenResultKeys = new HashSet(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(category, CutCategoryResolver.GetLabel(category))); } return options; } private static string BuildCategoryResultKey(IEnumerable 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 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 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? 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 BuildCutDebugItemDescriptors(FormatTemplateDefinition? format) { if (format is null) { return Array.Empty(); } var slotCount = ResolveCutDebugSlotCount(format); var descriptors = new List(); 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 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 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); } }