Files
Tornado3_2026Election/Tornado3_2026Election/ViewModels/MainViewModel.cs
2026-04-17 00:39:25 +09:00

916 lines
32 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.Persistence;
using Tornado3_2026Election.Services;
namespace Tornado3_2026Election.ViewModels;
public sealed class MainViewModel : ObservableObject
{
private static readonly Brush ConnectedStatusBrush = new SolidColorBrush(Colors.LimeGreen);
private static readonly Brush DisconnectedStatusBrush = new SolidColorBrush(Colors.OrangeRed);
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
private readonly FormatCatalogService _formatCatalogService;
private readonly AppStateStore _stateStore;
private readonly LogService _logService;
private readonly ITornado3Adapter _sharedTornadoAdapter;
private readonly SemaphoreSlim _stateSaveLock = new(1, 1);
private AppPage _currentPage = AppPage.IntegratedSchedule;
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
private bool _isSituationRoomExpanded;
private bool _suppressAutomaticSave;
private CancellationTokenSource? _automaticSaveCts;
private int? _windowX;
private int? _windowY;
private int? _windowWidth;
private int? _windowHeight;
private bool _isWindowMaximized;
private SelectionOption<LogLevel?>? _selectedLogFilterOption;
public MainViewModel()
{
_formatCatalogService = new FormatCatalogService();
_stateStore = new AppStateStore();
_logService = new LogService();
Data = new DataViewModel(_logService);
Settings = new SettingsViewModel(new StationCatalogService().GetAll());
Data.SetConfiguredRegions(Settings.BuildSelectedStationProfile().RegionFilters);
RestoreSelection = new RestoreSelectionViewModel();
LogFilterOptions =
[
new SelectionOption<LogLevel?>(null, "전체"),
new SelectionOption<LogLevel?>(LogLevel.Info, "정보"),
new SelectionOption<LogLevel?>(LogLevel.Warning, "경고"),
new SelectionOption<LogLevel?>(LogLevel.Error, "오류")
];
FilteredLogs = [];
Settings.PropertyChanged += Settings_PropertyChanged;
Data.PropertyChanged += Data_PropertyChanged;
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath);
NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter);
TopLeftChannel = CreateChannelViewModel(BroadcastChannel.TopLeft, "좌상단", _sharedTornadoAdapter);
BottomChannel = CreateChannelViewModel(BroadcastChannel.Bottom, "하단", _sharedTornadoAdapter);
VideoWallChannel = CreateChannelViewModel(BroadcastChannel.VideoWall, "비디오월", _sharedTornadoAdapter);
Channels = [NormalChannel, TopLeftChannel, BottomChannel, VideoWallChannel];
foreach (var channel in Channels)
{
channel.PropertyChanged += Channel_PropertyChanged;
}
SaveStateCommand = new AsyncRelayCommand(SaveStateAsync);
RestoreStateCommand = new AsyncRelayCommand(RestoreAsync);
ClearLogsCommand = new RelayCommand(_logService.Clear);
ToggleSituationRoomCommand = new RelayCommand(ToggleSituationRoom);
RestoreSelection.PropertyChanged += RestoreSelection_PropertyChanged;
foreach (var station in Settings.Stations)
{
station.PropertyChanged += Station_PropertyChanged;
}
_logService.Entries.CollectionChanged += (_, _) => RebuildFilteredLogs();
SelectedLogFilterOption = LogFilterOptions[0];
_logService.Info("SYSTEM_SPEC 기반 MVVM 구조를 초기화했습니다.");
_ = WarmupSharedCgConnectionAsync();
}
public DataViewModel Data { get; }
public SettingsViewModel Settings { get; }
public RestoreSelectionViewModel RestoreSelection { get; }
public ChannelScheduleViewModel NormalChannel { get; }
public ChannelScheduleViewModel TopLeftChannel { get; }
public ChannelScheduleViewModel BottomChannel { get; }
public ChannelScheduleViewModel VideoWallChannel { get; }
public IReadOnlyList<ChannelScheduleViewModel> Channels { get; }
public AsyncRelayCommand SaveStateCommand { get; }
public AsyncRelayCommand RestoreStateCommand { get; }
public RelayCommand ClearLogsCommand { get; }
public RelayCommand ToggleSituationRoomCommand { get; }
public ObservableCollection<LogEntry> Logs => _logService.Entries;
public ObservableCollection<LogEntry> FilteredLogs { get; }
public IReadOnlyList<SelectionOption<LogLevel?>> LogFilterOptions { get; }
public ChannelOperationMode OperationMode
{
get => _operationMode;
set
{
if (SetProperty(ref _operationMode, value))
{
EnsureCurrentPageAvailableForMode();
OnPropertyChanged(
nameof(IsGeneralOperationMode),
nameof(IsVideoWallOperationMode),
nameof(OperationModeLabel),
nameof(OperationModeBadgeText),
nameof(OperationModeDetailText),
nameof(NormalMenuVisibility),
nameof(TopLeftMenuVisibility),
nameof(BottomMenuVisibility),
nameof(VideoWallMenuVisibility),
nameof(GeneralIntegratedVisibility),
nameof(VideoWallIntegratedVisibility),
nameof(NormalVisibility),
nameof(TopLeftVisibility),
nameof(BottomVisibility),
nameof(VideoWallVisibility),
nameof(HeaderStatus),
nameof(CgIntegrationSummary),
nameof(CgIntegrationDetail),
nameof(TornadoConnectionSummary),
nameof(TornadoConnectionDetail));
QueueAutomaticSave();
}
}
}
public AppPage CurrentPage
{
get => _currentPage;
set
{
if (SetProperty(ref _currentPage, value))
{
OnPropertyChanged(
nameof(IntegratedScheduleVisibility),
nameof(NormalVisibility),
nameof(TopLeftVisibility),
nameof(BottomVisibility),
nameof(VideoWallVisibility),
nameof(DataVisibility),
nameof(SettingsVisibility),
nameof(LogVisibility),
nameof(CurrentPageTitle));
}
}
}
public string CurrentPageTitle => CurrentPage switch
{
AppPage.Normal => "노멀",
AppPage.TopLeft => "좌상단",
AppPage.Bottom => "하단",
AppPage.VideoWall => "비디오월",
AppPage.Data => "데이터",
AppPage.Settings => "설정",
AppPage.Log => "로그",
_ => "통합 스케줄"
};
public bool IsGeneralOperationMode => OperationMode == ChannelOperationMode.General;
public bool IsVideoWallOperationMode => OperationMode == ChannelOperationMode.VideoWall;
public string OperationModeLabel => IsGeneralOperationMode ? "일반" : "비디오월";
public string OperationModeBadgeText => IsGeneralOperationMode ? "일반 3채널" : "비디오월 단독";
public string OperationModeDetailText => IsGeneralOperationMode
? "일반 모드에서는 노멀, 좌상단, 하단만 노출하고 운영합니다."
: "비디오월 모드에서는 비디오월만 단독으로 노출하고 운영합니다.";
public Visibility NormalMenuVisibility => IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility TopLeftMenuVisibility => IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility BottomMenuVisibility => IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility VideoWallMenuVisibility => IsVideoWallOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility GeneralIntegratedVisibility => IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility VideoWallIntegratedVisibility => IsVideoWallOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility IntegratedScheduleVisibility => CurrentPage == AppPage.IntegratedSchedule ? Visibility.Visible : Visibility.Collapsed;
public Visibility NormalVisibility => CurrentPage == AppPage.Normal && IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility TopLeftVisibility => CurrentPage == AppPage.TopLeft && IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility BottomVisibility => CurrentPage == AppPage.Bottom && IsGeneralOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility VideoWallVisibility => CurrentPage == AppPage.VideoWall && IsVideoWallOperationMode ? Visibility.Visible : Visibility.Collapsed;
public Visibility DataVisibility => CurrentPage == AppPage.Data ? Visibility.Visible : Visibility.Collapsed;
public Visibility SettingsVisibility => CurrentPage == AppPage.Settings ? Visibility.Visible : Visibility.Collapsed;
public Visibility LogVisibility => CurrentPage == AppPage.Log ? Visibility.Visible : Visibility.Collapsed;
public bool IsSituationRoomExpanded
{
get => _isSituationRoomExpanded;
set
{
if (SetProperty(ref _isSituationRoomExpanded, value))
{
OnPropertyChanged(nameof(SituationRoomBodyVisibility), nameof(SituationRoomToggleText));
}
}
}
public Visibility SituationRoomBodyVisibility => IsSituationRoomExpanded ? Visibility.Visible : Visibility.Collapsed;
public string SituationRoomToggleText => IsSituationRoomExpanded ? "상황실 접기" : "상황실 펼치기";
public ImageSource? SelectedStationLogo
{
get
{
var logoPath = TryGetSelectedStationLogoPath();
return logoPath is null ? null : new BitmapImage(new Uri(logoPath, UriKind.Absolute));
}
}
public Visibility SelectedStationLogoVisibility => TryGetSelectedStationLogoPath() is null
? Visibility.Collapsed
: Visibility.Visible;
public SelectionOption<LogLevel?>? SelectedLogFilterOption
{
get => _selectedLogFilterOption;
set
{
if (value is null)
{
return;
}
if (SetProperty(ref _selectedLogFilterOption, value))
{
RebuildFilteredLogs();
OnPropertyChanged(nameof(LogFilterSummary));
}
}
}
public string LogFilterSummary => $"표시 {FilteredLogs.Count}건 / 전체 {Logs.Count}건";
public string CgIntegrationSummary => IsCgConnected ? "Connected" : "Disconnected";
public Brush CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
public string CgIntegrationDetail
{
get
{
var activeChannels = GetActiveChannels().ToArray();
if (activeChannels.Length == 0)
{
return "운영 대상 없음";
}
var liveChannels = activeChannels
.Where(channel => channel.IsLiveCg)
.ToArray();
if (liveChannels.Length == 0)
{
return "Mock Adapter";
}
var targets = liveChannels
.Select(channel => channel.CgConnectionTarget)
.Distinct(StringComparer.Ordinal)
.ToArray();
return $"{liveChannels[0].CgBackendName} / {string.Join(", ", targets)}";
}
}
public bool IsCgConnected
{
get
{
var activeChannels = GetActiveChannels().ToArray();
if (activeChannels.Length == 0)
{
return false;
}
var liveChannels = activeChannels
.Where(channel => channel.IsLiveCg)
.ToArray();
return liveChannels.Length > 0 && liveChannels.All(channel => channel.IsCgConnected);
}
}
public string TornadoConnectionSummary
{
get
{
var activeChannels = GetActiveChannels().ToArray();
if (activeChannels.Length == 0)
{
return "채널 없음";
}
var healthyCount = activeChannels.Count(channel => channel.AdapterState != TornadoConnectionState.Error);
return $"{healthyCount}/{activeChannels.Length} 채널 정상";
}
}
public string TornadoConnectionDetail
{
get
{
var activeChannels = GetActiveChannels().ToArray();
if (activeChannels.Length == 0)
{
return "운영 대상 없음";
}
if (activeChannels.Any(channel => channel.AdapterState == TornadoConnectionState.Error))
{
return "오류 채널 확인 필요";
}
if (activeChannels.Any(channel => channel.AdapterState == TornadoConnectionState.Sending))
{
return "전송 중 채널 포함";
}
if (activeChannels.Any(channel => channel.AdapterState == TornadoConnectionState.OnAir))
{
return "송출 중 채널 포함";
}
return "전체 준비 상태";
}
}
public string HeaderStatus => $"{Settings.SelectedStation.Name} / {CurrentPageTitle} / {Data.BroadcastPhaseBadgeText} / {OperationModeLabel}";
public void Navigate(string tag)
{
CurrentPage = tag switch
{
"normal" when IsGeneralOperationMode => AppPage.Normal,
"top-left" when IsGeneralOperationMode => AppPage.TopLeft,
"bottom" when IsGeneralOperationMode => AppPage.Bottom,
"videowall" when IsVideoWallOperationMode => AppPage.VideoWall,
"data" => AppPage.Data,
"settings" => AppPage.Settings,
"log" => AppPage.Log,
_ => AppPage.IntegratedSchedule
};
}
public bool IsPageAvailable(AppPage page)
{
return page switch
{
AppPage.Normal or AppPage.TopLeft or AppPage.Bottom => IsGeneralOperationMode,
AppPage.VideoWall => IsVideoWallOperationMode,
_ => true
};
}
public async Task<bool> HasRestorableStateAsync()
{
return await _stateStore.LoadAsync() is not null;
}
public async Task<(int? X, int? Y, int Width, int Height, bool IsMaximized)?> GetSavedWindowPlacementAsync()
{
var state = await _stateStore.LoadAsync();
if (state is null || state.WindowWidth <= 0 || state.WindowHeight <= 0)
{
return null;
}
_windowX = state.WindowX;
_windowY = state.WindowY;
_windowWidth = state.WindowWidth;
_windowHeight = state.WindowHeight;
_isWindowMaximized = state.IsWindowMaximized;
return (state.WindowX, state.WindowY, state.WindowWidth, state.WindowHeight, state.IsWindowMaximized);
}
public async Task RestoreStartupStateAsync()
{
var state = await _stateStore.LoadAsync();
if (state is null)
{
return;
}
_suppressAutomaticSave = true;
try
{
RestoreSelection.RestoreSchedules = state.AutoRestoreSchedules;
RestoreSelection.RestoreStations = state.AutoRestoreStations;
RestoreSelection.RestoreStatusValues = state.AutoRestoreStatusValues;
_windowX = state.WindowX;
_windowY = state.WindowY;
_windowWidth = state.WindowWidth > 0 ? state.WindowWidth : _windowWidth;
_windowHeight = state.WindowHeight > 0 ? state.WindowHeight : _windowHeight;
_isWindowMaximized = state.IsWindowMaximized;
ApplyState(state);
}
finally
{
_suppressAutomaticSave = false;
}
_logService.Info("저장한 시작 옵션에 따라 상태를 자동 복원했습니다.");
_ = WarmupSharedCgConnectionAsync();
}
public async Task RestoreAsync()
{
var state = await _stateStore.LoadAsync();
if (state is null)
{
_logService.Warning("복원 가능한 저장 상태가 없습니다.");
return;
}
_suppressAutomaticSave = true;
try
{
ApplyState(state);
}
finally
{
_suppressAutomaticSave = false;
}
_logService.Info("저장 상태 복원을 완료했습니다.");
}
public async Task SaveStateAsync()
{
await SaveStateCoreAsync(writeLog: true);
}
public async Task ShutdownAsync()
{
CancelPendingAutomaticSave();
await SaveStateCoreAsync(writeLog: false);
if (_sharedTornadoAdapter is IDisposable disposableAdapter)
{
disposableAdapter.Dispose();
}
Data.Dispose();
}
public void UpdateWindowPlacement(int? x, int? y, int width, int height, bool isMaximized)
{
if (width <= 0 || height <= 0)
{
return;
}
_windowX = x;
_windowY = y;
_windowWidth = width;
_windowHeight = height;
_isWindowMaximized = isMaximized;
QueueAutomaticSave();
}
public void ApplyBroadcastPhase(BroadcastPhase phase)
{
Data.ApplyBroadcastPhase(phase);
OnPropertyChanged(nameof(HeaderStatus));
}
public void ApplyOperationMode(ChannelOperationMode mode)
{
if (OperationMode == mode)
{
return;
}
OperationMode = mode;
_logService.Info($"운영 모드를 {OperationModeLabel}로 전환했습니다.");
}
private void ToggleSituationRoom()
{
IsSituationRoomExpanded = !IsSituationRoomExpanded;
}
private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName is nameof(SettingsViewModel.SelectedStation) or nameof(SettingsViewModel.SelectedStationId))
{
Data.SetConfiguredRegions(Settings.BuildSelectedStationProfile().RegionFilters);
OnPropertyChanged(nameof(HeaderStatus), nameof(SelectedStationLogo), nameof(SelectedStationLogoVisibility));
}
if (args.PropertyName is not nameof(SettingsViewModel.SelectedStationLogoAssetPath)
and not nameof(SettingsViewModel.SelectedStationRegions)
and not nameof(SettingsViewModel.SelectedStationRegionSummary))
{
QueueAutomaticSave();
}
if (args.PropertyName is nameof(SettingsViewModel.SelectedStationId) or nameof(SettingsViewModel.ImageRootPath))
{
_ = WarmupSharedCgConnectionAsync();
}
}
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName is nameof(DataViewModel.DistrictName) or nameof(DataViewModel.HeaderMetricSummary) or nameof(DataViewModel.BroadcastPhase))
{
OnPropertyChanged(nameof(HeaderStatus));
}
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
or nameof(DataViewModel.PollingIntervalSeconds)
or nameof(DataViewModel.BroadcastPhase)
or nameof(DataViewModel.ElectionType)
or nameof(DataViewModel.DistrictName)
or nameof(DataViewModel.DistrictCode)
or nameof(DataViewModel.ShowOnlyConfiguredRegions)
or nameof(DataViewModel.TotalExpectedVotes)
or nameof(DataViewModel.TurnoutVotes))
{
QueueAutomaticSave();
}
}
private void RestoreSelection_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
QueueAutomaticSave();
}
private void Channel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ChannelScheduleViewModel.AdapterState)
or nameof(ChannelScheduleViewModel.AdapterStateLabel)
or nameof(ChannelScheduleViewModel.IsCgConnected))
{
OnPropertyChanged(
nameof(IsCgConnected),
nameof(CgIntegrationSummary),
nameof(CgIntegrationBrush),
nameof(CgIntegrationDetail),
nameof(TornadoConnectionSummary),
nameof(TornadoConnectionDetail));
}
}
private void Station_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(StationFilterItemViewModel.RegionFiltersText)
or nameof(StationFilterItemViewModel.RegionSelectionSummary)
or nameof(StationFilterItemViewModel.SelectedRegionCount))
{
if (sender == Settings.SelectedStation)
{
Data.SetConfiguredRegions(Settings.BuildSelectedStationProfile().RegionFilters);
}
QueueAutomaticSave();
}
}
private void ApplyState(AppState state)
{
if (RestoreSelection.RestoreStations)
{
Settings.SelectedStationId = state.SelectedStationId;
Settings.ImageRootPath = state.ImageRootPath;
foreach (var station in Settings.Stations)
{
if (state.StationRegionFilters.TryGetValue(station.Id, out var filters))
{
station.RegionFiltersText = filters;
}
}
}
if (RestoreSelection.RestoreStatusValues)
{
if (!Enum.TryParse<ChannelOperationMode>(state.OperationMode, ignoreCase: true, out var operationMode))
{
operationMode = ChannelOperationMode.General;
}
OperationMode = operationMode;
if (!Enum.TryParse<BroadcastPhase>(state.BroadcastPhase, ignoreCase: true, out var broadcastPhase))
{
broadcastPhase = BroadcastPhase.Counting;
}
Data.BroadcastPhase = broadcastPhase;
Data.ElectionType = state.ElectionType;
Data.DistrictName = string.IsNullOrWhiteSpace(state.DistrictName) ? Data.DistrictName : state.DistrictName;
Data.DistrictCode = string.IsNullOrWhiteSpace(state.DistrictCode) ? Data.DistrictCode : state.DistrictCode;
Data.ShowOnlyConfiguredRegions = state.ShowOnlyConfiguredRegions;
Data.TotalExpectedVotes = state.TotalExpectedVotes > 0 ? state.TotalExpectedVotes : Data.TotalExpectedVotes;
Data.TurnoutVotes = state.TurnoutVotes;
Data.IsPollingEnabled = state.IsPollingEnabled;
Data.PollingIntervalSeconds = state.PollingIntervalSeconds;
Data.ReplaceCandidates(state.Candidates.Select(candidate => new CandidateEntry
{
CandidateCode = candidate.CandidateCode,
Name = candidate.Name,
Party = candidate.Party,
VoteCount = candidate.VoteCount,
VoteRate = candidate.VoteRate,
HasImage = candidate.HasImage,
ManualJudgement = candidate.ManualJudgement
}));
}
if (RestoreSelection.RestoreSchedules)
{
RestoreChannelState(NormalChannel, state, BroadcastChannel.Normal);
RestoreChannelState(TopLeftChannel, state, BroadcastChannel.TopLeft);
RestoreChannelState(BottomChannel, state, BroadcastChannel.Bottom);
RestoreChannelState(VideoWallChannel, state, BroadcastChannel.VideoWall);
}
OnPropertyChanged(nameof(HeaderStatus));
}
private void QueueAutomaticSave()
{
if (_suppressAutomaticSave)
{
return;
}
CancelPendingAutomaticSave();
var automaticSaveCts = new CancellationTokenSource();
_automaticSaveCts = automaticSaveCts;
_ = RunAutomaticSaveAsync(automaticSaveCts);
}
private async Task RunAutomaticSaveAsync(CancellationTokenSource automaticSaveCts)
{
try
{
var cancellationToken = automaticSaveCts.Token;
await Task.Delay(AutomaticSaveDelay, cancellationToken).ConfigureAwait(false);
if (_suppressAutomaticSave || cancellationToken.IsCancellationRequested)
{
return;
}
await SaveStateCoreAsync(writeLog: false).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
_logService.Warning($"자동 저장 실패: {ex.Message}");
}
finally
{
if (ReferenceEquals(_automaticSaveCts, automaticSaveCts))
{
_automaticSaveCts = null;
}
automaticSaveCts.Dispose();
}
}
private void CancelPendingAutomaticSave()
{
if (_automaticSaveCts is null)
{
return;
}
_automaticSaveCts.Cancel();
_automaticSaveCts.Dispose();
_automaticSaveCts = null;
}
private async Task SaveStateCoreAsync(bool writeLog)
{
await _stateSaveLock.WaitAsync();
try
{
var state = new AppState
{
SelectedStationId = Settings.SelectedStationId,
ImageRootPath = Settings.ImageRootPath,
AutoRestoreSchedules = RestoreSelection.RestoreSchedules,
AutoRestoreStations = RestoreSelection.RestoreStations,
AutoRestoreStatusValues = RestoreSelection.RestoreStatusValues,
WindowX = _windowX,
WindowY = _windowY,
WindowWidth = _windowWidth ?? 0,
WindowHeight = _windowHeight ?? 0,
IsWindowMaximized = _isWindowMaximized,
OperationMode = OperationMode.ToString(),
BroadcastPhase = Data.BroadcastPhase.ToString(),
IsPollingEnabled = Data.IsPollingEnabled,
PollingIntervalSeconds = Data.PollingIntervalSeconds,
ElectionType = Data.ElectionType,
DistrictName = Data.DistrictName,
DistrictCode = Data.DistrictCode,
ShowOnlyConfiguredRegions = Data.ShowOnlyConfiguredRegions,
TotalExpectedVotes = Data.TotalExpectedVotes,
TurnoutVotes = Data.TurnoutVotes,
Candidates = Data.Candidates.Select(candidate => new CandidateState
{
CandidateCode = candidate.CandidateCode,
Name = candidate.Name,
Party = candidate.Party,
VoteCount = candidate.VoteCount,
VoteRate = candidate.VoteRate,
HasImage = candidate.HasImage,
ManualJudgement = candidate.ManualJudgement
}).ToList(),
Channels = BuildChannelStateMap(),
StationRegionFilters = Settings.Stations.ToDictionary(station => station.Id, station => station.RegionFiltersText)
};
await _stateStore.SaveAsync(state);
if (writeLog)
{
_logService.Info("현재 통합 방송 상태값을 저장했습니다.");
}
}
finally
{
_stateSaveLock.Release();
}
}
private async Task WarmupSharedCgConnectionAsync()
{
if (!_sharedTornadoAdapter.IsLiveCg)
{
return;
}
try
{
await _sharedTornadoAdapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logService.Warning($"CG startup connection attempt failed: {ex.Message}");
}
}
private ChannelScheduleViewModel CreateChannelViewModel(BroadcastChannel channel, string title, ITornado3Adapter adapter)
{
var queue = new ObservableCollection<ChannelScheduleItem>();
var engine = new ChannelScheduleEngine(
channel,
queue,
adapter,
Data,
Settings.BuildSelectedStationProfile,
() => Settings.ImageRootPath,
formatId => _formatCatalogService.FindById(formatId),
_logService);
return new ChannelScheduleViewModel(
channel,
title,
_formatCatalogService.GetByChannel(channel),
Data,
adapter,
engine,
_logService);
}
private Dictionary<string, ChannelState> BuildChannelStateMap()
{
return Channels.ToDictionary(
channel => channel.Channel.ToString(),
channel => new ChannelState
{
LoopEnabled = channel.LoopEnabled,
EmptyScheduleBehavior = channel.EmptyScheduleBehavior,
Items = channel.Queue.Select(item => new ScheduleItemState
{
Id = item.Id,
FormatId = item.FormatId,
FormatName = item.FormatName,
Description = item.Description,
Channel = item.Channel,
RequiresImage = item.RequiresImage,
DefaultCutDurationSeconds = item.DefaultCutDurationSeconds,
TotalCuts = item.TotalCuts,
State = item.State
}).ToList()
});
}
private void EnsureCurrentPageAvailableForMode()
{
if (!IsPageAvailable(CurrentPage))
{
CurrentPage = AppPage.IntegratedSchedule;
}
}
private string? TryGetSelectedStationLogoPath()
{
var relativePath = Settings.SelectedStationLogoAssetPath;
if (string.IsNullOrWhiteSpace(relativePath))
{
return null;
}
var normalizedPath = relativePath
.Replace('\\', Path.DirectorySeparatorChar)
.Replace('/', Path.DirectorySeparatorChar);
var absolutePath = Path.Combine(AppContext.BaseDirectory, normalizedPath);
return File.Exists(absolutePath) ? absolutePath : null;
}
private IEnumerable<ChannelScheduleViewModel> GetActiveChannels()
{
return IsGeneralOperationMode
? [NormalChannel, TopLeftChannel, BottomChannel]
: [VideoWallChannel];
}
private void RebuildFilteredLogs()
{
var selectedLevel = SelectedLogFilterOption?.Value;
var filteredEntries = Logs
.Where(entry => selectedLevel is null || entry.Level == selectedLevel.Value)
.ToArray();
FilteredLogs.Clear();
foreach (var entry in filteredEntries)
{
FilteredLogs.Add(entry);
}
OnPropertyChanged(nameof(LogFilterSummary));
}
private static void RestoreChannelState(ChannelScheduleViewModel channelViewModel, AppState state, BroadcastChannel channel)
{
if (!state.Channels.TryGetValue(channel.ToString(), out var channelState))
{
return;
}
channelViewModel.Queue.Clear();
foreach (var item in channelState.Items)
{
channelViewModel.Queue.Add(new ChannelScheduleItem
{
Id = item.Id,
FormatId = item.FormatId,
FormatName = item.FormatName,
Description = item.Description,
Channel = item.Channel,
RequiresImage = item.RequiresImage,
DefaultCutDurationSeconds = item.DefaultCutDurationSeconds,
TotalCuts = item.TotalCuts,
State = item.State
});
}
channelViewModel.LoopEnabled = channelState.LoopEnabled;
channelViewModel.EmptyScheduleBehavior = channelState.EmptyScheduleBehavior;
channelViewModel.RefreshSummary();
}
}