Files
Tornado3_2026Election/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs

1554 lines
61 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;
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 static readonly Brush PlaybackActiveIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 90, 84));
private static readonly Brush PlaybackIdleIconBrush = new SolidColorBrush(ColorHelper.FromArgb(255, 183, 197, 216));
private static readonly Brush DataIssueBackgroundBrushValue = new SolidColorBrush(ColorHelper.FromArgb(255, 57, 38, 16));
private static readonly Brush DataIssueBorderBrushValue = new SolidColorBrush(ColorHelper.FromArgb(255, 255, 184, 28));
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 ChannelScheduleItem? _preparedDirectItem;
private string _preparedDirectFormatId = string.Empty;
private string _preparedDirectRegionKey = string.Empty;
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;
SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
StopCommand = new AsyncRelayCommand(StopAsync);
DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
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);
RetryDataIssueCommand = new RelayCommand<ChannelScheduleItem>(RetryDataIssue, CanRetryDataIssue);
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 SchedulePrepareCommand { get; }
public AsyncRelayCommand StartCommand { get; }
public AsyncRelayCommand StopCommand { get; }
public AsyncRelayCommand DirectPrepareCommand { 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> RetryDataIssueCommand { 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();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
}
}
}
public ScheduleRegionOption? SelectedRegionOption
{
get => _selectedRegionOption;
set
{
if (SetProperty(ref _selectedRegionOption, value))
{
AddFormatCommand.NotifyCanExecuteChanged();
DirectPrepareCommand.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;
_engine.RefreshQueueMarkers();
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 bool IsPlaying => Queue.Any(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)
|| _engine.ActivePlaybackItem is not null;
public Brush PlaybackIconBrush => IsPlaying ? PlaybackActiveIconBrush : PlaybackIdleIconBrush;
public string TransmissionLabel => _engine.ActivePlaybackItem?.State == ScheduleQueueItemState.Sending
? "준비"
: IsPlaying
? "송출 중"
: "대기";
public string CurrentItemName => CurrentPlaybackItem?.DisplayName ?? "대기 화면";
public string NextItemName => InternalNextPlaybackItem?.InternalNextPreviewDisplayName
?? QueueNextPlaybackItem?.DisplayName
?? "다음 컷 없음";
public ImageSource? CurrentPreviewSource => CurrentPlaybackItem?.PreviewSource;
public ImageSource? NextPreviewSource => InternalNextPlaybackItem?.InternalNextPreviewSource
?? QueueNextPlaybackItem?.PreviewSource;
public string CurrentPreviewStatusLabel => CurrentPlaybackItem?.PreviewStatusLabel ?? "송출 중인 컷 없음";
public string NextPreviewStatusLabel => InternalNextPlaybackItem?.InternalNextPreviewStatusLabel
?? QueueNextPlaybackItem?.PreviewStatusLabel
?? "다음 컷 없음";
public Visibility PlaybackCountdownVisibility =>
CurrentPlaybackItem?.PlaybackCountdownVisibility ?? Visibility.Collapsed;
public string PlaybackCountdownText => CurrentPlaybackItem?.PlaybackCountdownText ?? "대기 중";
public string PlaybackCountdownDetail => CurrentPlaybackItem?.PlaybackCountdownDetail ?? string.Empty;
public double PlaybackCountdownProgress => CurrentPlaybackItem?.PlaybackCountdownProgress ?? 0d;
public double CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
public double NextPreviewWidth => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Width;
public double NextPreviewHeight => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Height;
public int QueuedItemCount => Queue.Count;
public string QueueFootnote => $"목록 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
public string QueueSummary
{
get
{
var current = CurrentPlaybackItem?.DisplayName ?? "-";
var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
?? QueueNextPlaybackItem?.DisplayName
?? "-";
return $"현재 {current} / 다음 {next} / 목록 {Queue.Count}건";
}
}
public string LoopSummary => LoopEnabled ? "반복 재생" : "1회 재생";
public string EmptyBehaviorLabel => SelectedEmptyBehaviorOption?.Label ?? "즉시 아웃";
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
public Visibility ScheduleDataIssueVisibility =>
LatestScheduleDataIssueItem is null ? Visibility.Collapsed : Visibility.Visible;
public Brush ScheduleDataIssueBackgroundBrush => DataIssueBackgroundBrushValue;
public Brush ScheduleDataIssueBorderBrush => DataIssueBorderBrushValue;
public string ScheduleDataIssueTitle => LatestScheduleDataIssueItem is { } item
? $"{item.FormatName} 송출 보류"
: string.Empty;
public string ScheduleDataIssueMessage => LatestScheduleDataIssueItem?.IssueDetail ?? string.Empty;
public string ScheduleDataIssueDetail => LatestScheduleDataIssueItem is { } item
? $"{item.DisplayRegionLabel} / {item.LastIssueLabel}"
: string.Empty;
public string ScheduleDataIssueHint => LatestScheduleDataIssueItem is null
? string.Empty
: "시스템 오류가 아니라 현재 조건 데이터가 부족한 상태입니다. 데이터가 들어오면 항목의 다시 확인을 눌러 스케줄 대상에 복귀시킬 수 있습니다.";
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;
private ChannelScheduleItem? CurrentPlaybackItem =>
_engine.ActivePlaybackItem;
private ChannelScheduleItem? InternalNextPlaybackItem =>
CurrentPlaybackItem is { HasInternalNextPreview: true } item ? item : null;
private ChannelScheduleItem? QueueNextPlaybackItem =>
Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next);
private ChannelScheduleItem? NextPlaybackItem =>
InternalNextPlaybackItem ?? QueueNextPlaybackItem;
private ChannelScheduleItem? LatestScheduleDataIssueItem =>
Queue
.Where(item => item.State == ScheduleQueueItemState.DataUnavailable)
.OrderByDescending(item => item.LastIssueAt ?? DateTimeOffset.MinValue)
.FirstOrDefault();
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();
NotifyPlaybackPreviewChanged();
}
private async Task StartAsync()
{
await _engine.StartAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 스케줄 시작");
}
private async Task PrepareScheduleAsync()
{
try
{
await _engine.PrepareNextAsync(CancellationToken.None).ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 스케줄 다음 컷 준비");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
RefreshSummary();
_logService.Error($"[{Title}] Schedule prepare failed: {ex.Message}");
}
}
private async Task StopAsync()
{
await _engine.StopAsync().ConfigureAwait(false);
RefreshSummary();
_logService.Info($"[{Title}] 스케줄 정지");
}
private async Task DirectPrepareAsync()
{
var selectedFormat = SelectedFormat;
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (selectedFormat is null || regionOption is null)
{
_logService.Warning($"[{Title}] 바로 송출 준비할 컷과 지역을 먼저 선택해 주세요.");
return;
}
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
_directPlaybackCts?.Dispose();
var prepareCts = new CancellationTokenSource();
_directPlaybackCts = prepareCts;
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
selectedFormat,
_videoWallLayoutPreset,
ThumbnailDisplayContext.Queue));
_preparedDirectItem = item;
_preparedDirectFormatId = selectedFormat.Id;
_preparedDirectRegionKey = BuildRegionOptionKey(regionOption);
try
{
_logService.Info($"[{Title}] 선택 컷 준비: {selectedFormat.Name} / {regionOption.Label}");
await _engine.PrepareDirectAsync(item, selectedFormat, prepareCts.Token).ConfigureAwait(false);
if (!prepareCts.IsCancellationRequested)
{
_logService.Info($"[{Title}] 선택 컷 준비 완료: {selectedFormat.Name}");
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
ClearPreparedDirectState(item);
_logService.Error($"[{Title}] 선택 컷 준비 실패: {ex.Message}");
}
finally
{
if (ReferenceEquals(_directPlaybackCts, prepareCts))
{
_directPlaybackCts = null;
}
prepareCts.Dispose();
RefreshSummary();
}
}
private async Task DirectStartAsync()
{
var selectedFormat = SelectedFormat;
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
if (selectedFormat is null || regionOption is null)
{
_logService.Warning($"[{Title}] 바로 송출할 컷과 지역을 먼저 선택해 주세요.");
return;
}
var preparedItem = ResolvePreparedDirectItem(selectedFormat, regionOption);
if (preparedItem is null)
{
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
_directPlaybackCts?.Cancel();
_directPlaybackCts?.Dispose();
}
var playbackCts = new CancellationTokenSource();
_directPlaybackCts = playbackCts;
var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
if (preparedItem is null)
{
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
{
ClearPreparedDirectState(item);
if (ReferenceEquals(_directPlaybackCts, playbackCts))
{
_directPlaybackCts = null;
}
playbackCts.Dispose();
RefreshSummary();
}
}
private async Task DirectStopAsync()
{
_directPlaybackCts?.Cancel();
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
ClearPreparedDirectState(_preparedDirectItem);
_engine.ClearDirectPlayback();
RefreshSummary();
_logService.Info($"[{Title}] 선택 컷 송출 정지");
}
private ChannelScheduleItem? ResolvePreparedDirectItem(
FormatTemplateDefinition selectedFormat,
ScheduleRegionOption regionOption)
{
if (_preparedDirectItem is null ||
!_engine.IsPreparedItem(_preparedDirectItem) ||
!string.Equals(_preparedDirectFormatId, selectedFormat.Id, StringComparison.Ordinal) ||
!string.Equals(_preparedDirectRegionKey, BuildRegionOptionKey(regionOption), StringComparison.Ordinal))
{
return null;
}
return _preparedDirectItem;
}
private void ClearPreparedDirectState(ChannelScheduleItem? item)
{
if (item is not null && !ReferenceEquals(_preparedDirectItem, item))
{
return;
}
_preparedDirectItem = null;
_preparedDirectFormatId = string.Empty;
_preparedDirectRegionKey = string.Empty;
}
private static string BuildRegionOptionKey(ScheduleRegionOption regionOption)
{
return string.Join(
"\u001F",
regionOption.Scope,
regionOption.ElectionType ?? string.Empty,
regionOption.Label ?? string.Empty,
regionOption.DistrictCode ?? string.Empty);
}
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;
}
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 RetryDataIssue(ChannelScheduleItem? item)
{
if (!_engine.RetryDataUnavailable(item))
{
return;
}
RefreshSummary();
_logService.Info($"[{Title}] 데이터 없음 항목을 다시 스케줄 대상으로 전환: {item?.DisplayName}");
}
private static bool CanRetryDataIssue(ChannelScheduleItem? item)
{
return item?.State == ScheduleQueueItemState.DataUnavailable;
}
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(IsPlaying),
nameof(PlaybackIconBrush),
nameof(TransmissionLabel),
nameof(CurrentItemName),
nameof(NextItemName),
nameof(CurrentPreviewSource),
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),
nameof(NextPreviewHeight),
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary),
nameof(ScheduleDataIssueVisibility),
nameof(ScheduleDataIssueBackgroundBrush),
nameof(ScheduleDataIssueBorderBrush),
nameof(ScheduleDataIssueTitle),
nameof(ScheduleDataIssueMessage),
nameof(ScheduleDataIssueDetail),
nameof(ScheduleDataIssueHint),
nameof(IsCgConnected),
nameof(CgStatusSummary),
nameof(LoopSummary),
nameof(EmptyBehaviorLabel),
nameof(OperatorQuickSummary));
}
private bool CanAddFormat()
{
return SelectedFormat is not null &&
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 => 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();
DirectPrepareCommand.NotifyCanExecuteChanged();
DirectStartCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(QueueFootnote));
}
private void RebuildFormatCategoryOptions()
{
var selectedCategory = SelectedFormatCategoryOption?.Value;
var options = CreateFormatCategoryOptions(_allFormats);
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();
DirectPrepareCommand.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();
DirectPrepareCommand.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();
}
public void RefreshSelectedFormatDuration(FormatTemplateDefinition template)
{
if (SelectedFormat is null ||
!string.Equals(SelectedFormat.Id, template.Id, StringComparison.Ordinal))
{
return;
}
ResetSelectedFormatDurationDraft();
}
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)
{
if (e.OldItems is not null)
{
foreach (var item in e.OldItems.OfType<ChannelScheduleItem>())
{
item.PropertyChanged -= QueueItem_PropertyChanged;
}
}
if (e.NewItems is not null)
{
foreach (var item in e.NewItems.OfType<ChannelScheduleItem>())
{
item.PropertyChanged -= QueueItem_PropertyChanged;
item.PropertyChanged += QueueItem_PropertyChanged;
}
}
ApplyQueueThumbnailLayouts();
NotifyPlaybackStateChanged();
NotifyPlaybackPreviewChanged();
}
private void QueueItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ChannelScheduleItem.State)
or nameof(ChannelScheduleItem.DisplayName)
or nameof(ChannelScheduleItem.CurrentRegionLabel)
or nameof(ChannelScheduleItem.PreviewSource)
or nameof(ChannelScheduleItem.PreviewStatusLabel)
or nameof(ChannelScheduleItem.InternalNextPreviewSource)
or nameof(ChannelScheduleItem.InternalNextPreviewStatusLabel)
or nameof(ChannelScheduleItem.InternalNextPreviewDisplayName)
or nameof(ChannelScheduleItem.HasInternalNextPreview)
or nameof(ChannelScheduleItem.LastError)
or nameof(ChannelScheduleItem.LastIssueAt)
or nameof(ChannelScheduleItem.PlaybackCountdownVisibility)
or nameof(ChannelScheduleItem.PlaybackCountdownText)
or nameof(ChannelScheduleItem.PlaybackCountdownDetail)
or nameof(ChannelScheduleItem.PlaybackCountdownProgress)
or nameof(ChannelScheduleItem.ThumbnailSource))
{
if (e.PropertyName is nameof(ChannelScheduleItem.State)
or nameof(ChannelScheduleItem.DisplayName)
or nameof(ChannelScheduleItem.CurrentRegionLabel)
or nameof(ChannelScheduleItem.LastError)
or nameof(ChannelScheduleItem.LastIssueAt))
{
NotifyPlaybackStateChanged();
}
RetryDataIssueCommand.NotifyCanExecuteChanged();
NotifyPlaybackPreviewChanged();
}
}
private void NotifyPlaybackStateChanged()
{
OnPropertyChanged(
nameof(IsPlaying),
nameof(PlaybackIconBrush),
nameof(TransmissionLabel),
nameof(CurrentItemName),
nameof(NextItemName),
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(ScheduleDataIssueVisibility),
nameof(ScheduleDataIssueBackgroundBrush),
nameof(ScheduleDataIssueBorderBrush),
nameof(ScheduleDataIssueTitle),
nameof(ScheduleDataIssueMessage),
nameof(ScheduleDataIssueDetail),
nameof(ScheduleDataIssueHint));
}
private void NotifyPlaybackPreviewChanged()
{
OnPropertyChanged(
nameof(CurrentPreviewSource),
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),
nameof(NextPreviewHeight));
}
private ThumbnailDisplayMetrics ResolvePlaybackPreviewMetrics(ChannelScheduleItem? item)
{
var template = item is null
? null
: _allFormats.FirstOrDefault(format => string.Equals(format.Id, item.FormatId, StringComparison.Ordinal));
return template is null
? ThumbnailLayoutResolver.ResolveDisplayMetrics(Channel, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview)
: ThumbnailLayoutResolver.ResolveDisplayMetrics(template, _videoWallLayoutPreset, ThumbnailDisplayContext.PlaybackPreview);
}
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)
{
if (IsBottomTurnoutSidoFormat(format))
{
return true;
}
if (IsBottomTurnoutDistrictFormat(format))
{
return false;
}
var source = $"{format.Name} {format.Id}";
return source.Contains("광역단체장", StringComparison.Ordinal) ||
source.Contains("교육감", StringComparison.Ordinal);
}
private static bool IsBottomTurnoutSidoFormat(FormatTemplateDefinition format)
{
return format.RecommendedChannel == BroadcastChannel.Bottom &&
(string.Equals(format.Name, "사전투표율_시도", StringComparison.Ordinal) ||
string.Equals(format.Name, "투표율_시도", StringComparison.Ordinal));
}
private static bool IsBottomTurnoutDistrictFormat(FormatTemplateDefinition format)
{
return format.RecommendedChannel == BroadcastChannel.Bottom &&
(string.Equals(format.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
string.Equals(format.Name, "투표율_시군구", 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);
}
}