916 lines
32 KiB
C#
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();
|
|
}
|
|
}
|
|
|
|
|