1555 lines
56 KiB
C#
1555 lines
56 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Collections.Specialized;
|
|
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 Brush DataReceivingNavigationBrush = new SolidColorBrush(Colors.LimeGreen);
|
|
private static readonly Brush DataWaitingNavigationBrush = new SolidColorBrush(Colors.White);
|
|
private static readonly TimeSpan AutomaticSaveDelay = TimeSpan.FromMilliseconds(500);
|
|
private FormatCatalogService _formatCatalogService;
|
|
private readonly AppStateStore _stateStore;
|
|
private readonly LogService _logService;
|
|
private readonly KarismaThumbnailGeneratorService _thumbnailGeneratorService;
|
|
private readonly CutDebugStateStore _cutDebugStateStore;
|
|
private readonly ITornado3Adapter _sharedTornadoAdapter;
|
|
private readonly SemaphoreSlim _stateSaveLock = new(1, 1);
|
|
private AppPage _currentPage = AppPage.Normal;
|
|
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
|
|
private bool _isSituationRoomExpanded;
|
|
private bool _suppressAutomaticSave;
|
|
private bool _isSyncingQueuedCutDurations;
|
|
private int _automaticSaveRevision;
|
|
private int? _windowX;
|
|
private int? _windowY;
|
|
private int? _windowWidth;
|
|
private int? _windowHeight;
|
|
private bool _isWindowMaximized;
|
|
private SelectionOption<LogLevel?>? _selectedLogFilterOption;
|
|
private readonly List<(BroadcastChannel Channel, CutListEntryViewModel Entry)> _allCutListEntries = [];
|
|
private SelectionOption<BroadcastChannel?>? _selectedCutListFilterOption;
|
|
private SelectionOption<CutCategory?>? _selectedCutListCategoryOption;
|
|
private string _thumbnailGenerationStatus = string.Empty;
|
|
|
|
public MainViewModel()
|
|
{
|
|
_stateStore = new AppStateStore();
|
|
_logService = new LogService();
|
|
Settings = new SettingsViewModel(new StationCatalogService().GetAll());
|
|
_formatCatalogService = new FormatCatalogService(Settings.ImageRootPath);
|
|
_thumbnailGeneratorService = new KarismaThumbnailGeneratorService(_logService);
|
|
|
|
Data = new DataViewModel(_logService);
|
|
var selectedStationProfile = Settings.BuildSelectedStationProfile();
|
|
Data.SetConfiguredRegions(selectedStationProfile.RegionFilters);
|
|
Data.SetSelectedStationContext(selectedStationProfile.Id, selectedStationProfile.Name);
|
|
RestoreSelection = new RestoreSelectionViewModel();
|
|
LogFilterOptions =
|
|
[
|
|
new SelectionOption<LogLevel?>(null, "전체"),
|
|
new SelectionOption<LogLevel?>(LogLevel.Info, "정보"),
|
|
new SelectionOption<LogLevel?>(LogLevel.Warning, "경고"),
|
|
new SelectionOption<LogLevel?>(LogLevel.Error, "오류")
|
|
];
|
|
CutListFilterOptions =
|
|
[
|
|
new SelectionOption<BroadcastChannel?>(null, "전체"),
|
|
new SelectionOption<BroadcastChannel?>(BroadcastChannel.Normal, "노멀"),
|
|
new SelectionOption<BroadcastChannel?>(BroadcastChannel.TopLeft, "좌상단"),
|
|
new SelectionOption<BroadcastChannel?>(BroadcastChannel.Bottom, "하단"),
|
|
new SelectionOption<BroadcastChannel?>(BroadcastChannel.VideoWall, "비디오월")
|
|
];
|
|
CutListCategoryOptions = [];
|
|
FilteredLogs = [];
|
|
CutListItems = [];
|
|
_selectedCutListFilterOption = CutListFilterOptions[0];
|
|
RebuildCutListCategoryOptions();
|
|
|
|
_cutDebugStateStore = new CutDebugStateStore();
|
|
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
|
|
Settings.PropertyChanged += Settings_PropertyChanged;
|
|
Data.PropertyChanged += Data_PropertyChanged;
|
|
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(
|
|
_logService,
|
|
() => Settings.ImageRootPath,
|
|
_cutDebugStateStore,
|
|
Settings.GetKarismaLayerNo);
|
|
|
|
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];
|
|
UpdateChannelThumbnailLayouts();
|
|
BuildCutListEntries();
|
|
foreach (var channel in Channels)
|
|
{
|
|
channel.PropertyChanged += Channel_PropertyChanged;
|
|
channel.FormatDurationChanged += Channel_FormatDurationChanged;
|
|
channel.Queue.CollectionChanged += ChannelQueue_CollectionChanged;
|
|
}
|
|
|
|
SaveStateCommand = new AsyncRelayCommand(SaveStateAsync);
|
|
RestoreStateCommand = new AsyncRelayCommand(RestoreAsync);
|
|
GenerateCutThumbnailsCommand = new AsyncRelayCommand(GenerateCutThumbnailsAsync, CanGenerateCutThumbnails);
|
|
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];
|
|
ThumbnailGenerationStatus = BuildInitialThumbnailGenerationStatus();
|
|
_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 AsyncRelayCommand GenerateCutThumbnailsCommand { get; }
|
|
|
|
public RelayCommand ClearLogsCommand { get; }
|
|
|
|
public RelayCommand ToggleSituationRoomCommand { get; }
|
|
|
|
public ObservableCollection<LogEntry> Logs => _logService.Entries;
|
|
|
|
public ObservableCollection<LogEntry> FilteredLogs { get; }
|
|
|
|
public ObservableCollection<CutListEntryViewModel> CutListItems { get; }
|
|
|
|
public IReadOnlyList<SelectionOption<LogLevel?>> LogFilterOptions { get; }
|
|
|
|
public IReadOnlyList<SelectionOption<BroadcastChannel?>> CutListFilterOptions { get; }
|
|
|
|
public ObservableCollection<SelectionOption<CutCategory?>> CutListCategoryOptions { 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(PreElectionDataVisibility),
|
|
nameof(DataVisibility),
|
|
nameof(CareerPromiseDataVisibility),
|
|
nameof(CutListVisibility),
|
|
nameof(SettingsVisibility),
|
|
nameof(LogVisibility),
|
|
nameof(CurrentPageTitle),
|
|
nameof(HeaderStatus));
|
|
}
|
|
}
|
|
}
|
|
|
|
public string CurrentPageTitle => CurrentPage switch
|
|
{
|
|
AppPage.Normal => "노멀",
|
|
AppPage.TopLeft => "좌상단",
|
|
AppPage.Bottom => "하단",
|
|
AppPage.VideoWall => "비디오월",
|
|
AppPage.PreElectionData => "사전데이터",
|
|
AppPage.TurnoutData => "투표데이터",
|
|
AppPage.CountingData or AppPage.Data => "개표데이터",
|
|
AppPage.CareerPromiseData => "공약데이터",
|
|
AppPage.CutList => "컷리스트",
|
|
AppPage.Settings => "설정",
|
|
AppPage.Log => "로그",
|
|
_ => GetDefaultPageTitle()
|
|
};
|
|
|
|
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 PreElectionDataVisibility => CurrentPage == AppPage.PreElectionData ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
public Visibility DataVisibility => IsLiveDataPage(CurrentPage) ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
public Visibility CareerPromiseDataVisibility => CurrentPage == AppPage.CareerPromiseData ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
public Visibility CutListVisibility => CurrentPage == AppPage.CutList ? 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 string CutListSummary
|
|
{
|
|
get
|
|
{
|
|
var totalCount = _allCutListEntries.Count;
|
|
var filteredCount = CutListItems.Count;
|
|
var selectedFilters = new List<string>();
|
|
if (SelectedCutListFilterOption?.Value is not null)
|
|
{
|
|
selectedFilters.Add(SelectedCutListFilterOption.Label);
|
|
}
|
|
|
|
if (SelectedCutListCategoryOption?.Value is not null)
|
|
{
|
|
selectedFilters.Add(SelectedCutListCategoryOption.Label);
|
|
}
|
|
|
|
if (selectedFilters.Count == 0)
|
|
{
|
|
return $"등록 컷 {totalCount}개";
|
|
}
|
|
|
|
return $"{string.Join(" / ", selectedFilters)} 컷 {filteredCount}개 / 전체 {totalCount}개";
|
|
}
|
|
}
|
|
|
|
public string CutThumbnailSummary => $"기본 썸네일 {_formatCatalogService.GetAll().Count(template => CutThumbnailAssetCatalog.HasThumbnail(template))}개 / 전체 {_formatCatalogService.GetAll().Count}개";
|
|
|
|
public string ThumbnailGenerationStatus
|
|
{
|
|
get => _thumbnailGenerationStatus;
|
|
private set => SetProperty(ref _thumbnailGenerationStatus, value);
|
|
}
|
|
|
|
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 SelectionOption<BroadcastChannel?>? SelectedCutListFilterOption
|
|
{
|
|
get => _selectedCutListFilterOption;
|
|
set
|
|
{
|
|
if (value is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (SetProperty(ref _selectedCutListFilterOption, value))
|
|
{
|
|
RebuildCutListCategoryOptions();
|
|
ApplyCutListFilter();
|
|
}
|
|
}
|
|
}
|
|
|
|
public SelectionOption<CutCategory?>? SelectedCutListCategoryOption
|
|
{
|
|
get => _selectedCutListCategoryOption;
|
|
set
|
|
{
|
|
if (value is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (SetProperty(ref _selectedCutListCategoryOption, value))
|
|
{
|
|
ApplyCutListFilter();
|
|
}
|
|
}
|
|
}
|
|
|
|
public string LogFilterSummary => $"표시 {FilteredLogs.Count}건 / 전체 {Logs.Count}건";
|
|
|
|
public string CgIntegrationSummary => IsCgConnected ? "Connected" : "Disconnected";
|
|
|
|
public Brush CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
|
|
|
|
public Brush DataNavigationIconBrush => Data.HasLiveDataSignal
|
|
? DataReceivingNavigationBrush
|
|
: DataWaitingNavigationBrush;
|
|
|
|
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)
|
|
{
|
|
var targetPage = tag switch
|
|
{
|
|
"normal" when IsGeneralOperationMode => AppPage.Normal,
|
|
"top-left" when IsGeneralOperationMode => AppPage.TopLeft,
|
|
"bottom" when IsGeneralOperationMode => AppPage.Bottom,
|
|
"videowall" when IsVideoWallOperationMode => AppPage.VideoWall,
|
|
"pre-election-data" => AppPage.PreElectionData,
|
|
"turnout-data" => AppPage.TurnoutData,
|
|
"counting-data" => AppPage.CountingData,
|
|
"data" => Data.IsPreElectionPhase ? AppPage.TurnoutData : AppPage.CountingData,
|
|
"career-promises" => AppPage.CareerPromiseData,
|
|
"cut-list" => AppPage.CutList,
|
|
"settings" => AppPage.Settings,
|
|
"log" => AppPage.Log,
|
|
_ => GetDefaultPage()
|
|
};
|
|
|
|
CurrentPage = targetPage;
|
|
if (targetPage == AppPage.CareerPromiseData)
|
|
{
|
|
Data.EnsureCareerPromiseElectionType();
|
|
}
|
|
|
|
SyncBroadcastPhaseForLiveDataPage(targetPage);
|
|
}
|
|
|
|
public bool IsPageAvailable(AppPage page)
|
|
{
|
|
return page switch
|
|
{
|
|
AppPage.Normal or AppPage.TopLeft or AppPage.Bottom => IsGeneralOperationMode,
|
|
AppPage.VideoWall => IsVideoWallOperationMode,
|
|
_ => true
|
|
};
|
|
}
|
|
|
|
private static bool IsLiveDataPage(AppPage page)
|
|
{
|
|
return page is AppPage.TurnoutData or AppPage.CountingData or AppPage.Data;
|
|
}
|
|
|
|
private void SyncBroadcastPhaseForLiveDataPage(AppPage page)
|
|
{
|
|
var targetPhase = page switch
|
|
{
|
|
AppPage.TurnoutData => BroadcastPhase.PreElection,
|
|
AppPage.CountingData or AppPage.Data or AppPage.CareerPromiseData => BroadcastPhase.Counting,
|
|
_ => (BroadcastPhase?)null
|
|
};
|
|
|
|
if (targetPhase is { } phase)
|
|
{
|
|
Data.ApplyBroadcastPhase(phase);
|
|
}
|
|
}
|
|
|
|
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);
|
|
if (IsLiveDataPage(CurrentPage))
|
|
{
|
|
CurrentPage = phase == BroadcastPhase.PreElection
|
|
? AppPage.TurnoutData
|
|
: AppPage.CountingData;
|
|
}
|
|
|
|
OnPropertyChanged(nameof(HeaderStatus));
|
|
}
|
|
|
|
public void ApplyOperationMode(ChannelOperationMode mode)
|
|
{
|
|
if (OperationMode == mode)
|
|
{
|
|
return;
|
|
}
|
|
|
|
OperationMode = mode;
|
|
_logService.Info($"운영 모드를 {OperationModeLabel}로 전환했습니다.");
|
|
}
|
|
|
|
private void ToggleSituationRoom()
|
|
{
|
|
IsSituationRoomExpanded = !IsSituationRoomExpanded;
|
|
}
|
|
|
|
private bool CanGenerateCutThumbnails()
|
|
{
|
|
return KarismaThumbnailGeneratorService.IsGenerationAvailable() &&
|
|
!string.IsNullOrWhiteSpace(Settings.ImageRootPath);
|
|
}
|
|
|
|
private async Task GenerateCutThumbnailsAsync()
|
|
{
|
|
ThumbnailGenerationStatus = "썸네일 생성 중...";
|
|
|
|
try
|
|
{
|
|
var templates = _formatCatalogService.GetAll();
|
|
var result = _sharedTornadoAdapter is KarismaTornado3Adapter karismaAdapter
|
|
? await karismaAdapter.GenerateThumbnailsAsync(
|
|
templates,
|
|
Settings.SelectedStationVideoWallLayoutPreset,
|
|
CancellationToken.None)
|
|
: await _thumbnailGeneratorService.GenerateAsync(
|
|
templates,
|
|
Settings.ImageRootPath,
|
|
Settings.SelectedStationVideoWallLayoutPreset,
|
|
CancellationToken.None);
|
|
|
|
RefreshCutListThumbnails();
|
|
foreach (var channel in Channels)
|
|
{
|
|
channel.RefreshSelectedFormatPreview();
|
|
channel.RefreshQueueThumbnails();
|
|
}
|
|
ThumbnailGenerationStatus = result.FailedCount == 0
|
|
? $"썸네일 {result.GeneratedCount}개 생성 완료"
|
|
: $"썸네일 {result.GeneratedCount}개 생성, {result.FailedCount}개 실패";
|
|
|
|
_logService.Info($"[Thumbnail] 생성 완료 / success={result.GeneratedCount} failed={result.FailedCount}");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
ThumbnailGenerationStatus = "썸네일 생성이 취소되었습니다.";
|
|
_logService.Warning("[Thumbnail] 생성이 취소되었습니다.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ThumbnailGenerationStatus = $"썸네일 생성 실패: {ex.Message}";
|
|
_logService.Error($"[Thumbnail] 생성 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs args)
|
|
{
|
|
if (args.PropertyName is nameof(SettingsViewModel.IsDebugFeaturesEnabled))
|
|
{
|
|
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
|
|
}
|
|
|
|
if (args.PropertyName is nameof(SettingsViewModel.SelectedStation) or nameof(SettingsViewModel.SelectedStationId))
|
|
{
|
|
var selectedStationProfile = Settings.BuildSelectedStationProfile();
|
|
Data.SetConfiguredRegions(selectedStationProfile.RegionFilters);
|
|
Data.SetSelectedStationContext(selectedStationProfile.Id, selectedStationProfile.Name);
|
|
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.SelectedStationVideoWallLayoutPreset)
|
|
or nameof(SettingsViewModel.ImageRootPath))
|
|
{
|
|
if (args.PropertyName == nameof(SettingsViewModel.ImageRootPath))
|
|
{
|
|
ReloadFormatCatalog();
|
|
}
|
|
else
|
|
{
|
|
UpdateChannelThumbnailLayouts();
|
|
UpdateCutListThumbnailLayouts();
|
|
}
|
|
|
|
_ = WarmupSharedCgConnectionAsync();
|
|
GenerateCutThumbnailsCommand.NotifyCanExecuteChanged();
|
|
}
|
|
}
|
|
|
|
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.HasLiveDataSignal))
|
|
{
|
|
OnPropertyChanged(nameof(DataNavigationIconBrush));
|
|
}
|
|
|
|
if (args.PropertyName is nameof(DataViewModel.IsPollingEnabled)
|
|
or nameof(DataViewModel.BroadcastPhase)
|
|
or nameof(DataViewModel.ElectionType)
|
|
or nameof(DataViewModel.DistrictName)
|
|
or nameof(DataViewModel.DistrictCode)
|
|
or nameof(DataViewModel.ShowOnlyConfiguredRegions)
|
|
or nameof(DataViewModel.CloseRaceThresholdPercent)
|
|
or nameof(DataViewModel.SuperCloseRaceThresholdPercent)
|
|
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.LoopEnabled)
|
|
or nameof(ChannelScheduleViewModel.EmptyScheduleBehavior))
|
|
{
|
|
QueueAutomaticSave();
|
|
}
|
|
|
|
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 ChannelQueue_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs args)
|
|
{
|
|
if (args.OldItems is not null)
|
|
{
|
|
foreach (var item in args.OldItems.OfType<ChannelScheduleItem>())
|
|
{
|
|
item.PropertyChanged -= QueueItem_PropertyChanged;
|
|
}
|
|
}
|
|
|
|
if (args.NewItems is not null)
|
|
{
|
|
foreach (var item in args.NewItems.OfType<ChannelScheduleItem>())
|
|
{
|
|
item.PropertyChanged -= QueueItem_PropertyChanged;
|
|
item.PropertyChanged += QueueItem_PropertyChanged;
|
|
}
|
|
}
|
|
|
|
if (args.Action is NotifyCollectionChangedAction.Add
|
|
or NotifyCollectionChangedAction.Remove
|
|
or NotifyCollectionChangedAction.Move
|
|
or NotifyCollectionChangedAction.Replace
|
|
or NotifyCollectionChangedAction.Reset)
|
|
{
|
|
QueueAutomaticSave();
|
|
}
|
|
}
|
|
|
|
private void QueueItem_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (_isSyncingQueuedCutDurations ||
|
|
e.PropertyName != nameof(ChannelScheduleItem.DefaultCutDurationSeconds) ||
|
|
sender is not ChannelScheduleItem item)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ApplyScheduleDurationToCutList(item);
|
|
}
|
|
|
|
private void Channel_FormatDurationChanged(object? sender, FormatTemplateDefinition template)
|
|
{
|
|
OnCutDurationChanged(template);
|
|
}
|
|
|
|
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)
|
|
{
|
|
var selectedStationProfile = Settings.BuildSelectedStationProfile();
|
|
Data.SetConfiguredRegions(selectedStationProfile.RegionFilters);
|
|
Data.SetSelectedStationContext(selectedStationProfile.Id, selectedStationProfile.Name);
|
|
}
|
|
|
|
QueueAutomaticSave();
|
|
}
|
|
}
|
|
|
|
private void ApplyState(AppState state)
|
|
{
|
|
Settings.IsDebugFeaturesEnabled = state.IsDebugFeaturesEnabled;
|
|
Settings.NormalLayerNo = state.NormalLayerNo;
|
|
Settings.TopLeftLayerNo = state.TopLeftLayerNo;
|
|
Settings.BottomLayerNo = state.BottomLayerNo;
|
|
Settings.VideoWallLayerNo = state.VideoWallLayerNo;
|
|
|
|
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 (state.StationVideoWallLayouts.TryGetValue(station.Id, out var videoWallLayoutValue) &&
|
|
Enum.TryParse<VideoWallLayoutPreset>(videoWallLayoutValue, ignoreCase: true, out var videoWallLayoutPreset))
|
|
{
|
|
station.VideoWallLayoutPreset = videoWallLayoutPreset;
|
|
}
|
|
}
|
|
|
|
UpdateChannelThumbnailLayouts();
|
|
UpdateCutListThumbnailLayouts();
|
|
}
|
|
|
|
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.SetCloseRaceThresholds(state.CloseRaceThresholdPercent, state.SuperCloseRaceThresholdPercent);
|
|
Data.TotalExpectedVotes = state.TotalExpectedVotes > 0 ? state.TotalExpectedVotes : Data.TotalExpectedVotes;
|
|
Data.TurnoutVotes = state.TurnoutVotes;
|
|
Data.IsPollingEnabled = state.IsPollingEnabled;
|
|
Data.PollingIntervalSeconds = DataViewModel.FixedPollingIntervalSeconds;
|
|
Data.ReplaceCandidates(state.Candidates.Select(candidate => new CandidateEntry
|
|
{
|
|
CandidateCode = candidate.CandidateCode,
|
|
BallotNumber = string.IsNullOrWhiteSpace(candidate.BallotNumber) ? candidate.CandidateCode : candidate.BallotNumber,
|
|
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);
|
|
}
|
|
|
|
ApplyCutDurations(state.CutDurations);
|
|
SyncAllQueuedCutDurations();
|
|
|
|
OnPropertyChanged(nameof(HeaderStatus));
|
|
}
|
|
|
|
private void QueueAutomaticSave()
|
|
{
|
|
if (_suppressAutomaticSave)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var revision = Interlocked.Increment(ref _automaticSaveRevision);
|
|
_ = RunAutomaticSaveAsync(revision);
|
|
}
|
|
|
|
private async Task RunAutomaticSaveAsync(int revision)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(AutomaticSaveDelay).ConfigureAwait(false);
|
|
if (_suppressAutomaticSave || revision != Volatile.Read(ref _automaticSaveRevision))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await SaveStateCoreAsync(writeLog: false).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning($"자동 저장 실패: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void CancelPendingAutomaticSave()
|
|
{
|
|
Interlocked.Increment(ref _automaticSaveRevision);
|
|
}
|
|
|
|
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,
|
|
IsDebugFeaturesEnabled = Settings.IsDebugFeaturesEnabled,
|
|
NormalLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.Normal),
|
|
TopLeftLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.TopLeft),
|
|
BottomLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.Bottom),
|
|
VideoWallLayerNo = Settings.GetKarismaLayerNo(BroadcastChannel.VideoWall),
|
|
OperationMode = OperationMode.ToString(),
|
|
BroadcastPhase = Data.BroadcastPhase.ToString(),
|
|
IsPollingEnabled = Data.IsPollingEnabled,
|
|
PollingIntervalSeconds = DataViewModel.FixedPollingIntervalSeconds,
|
|
ElectionType = Data.ElectionType,
|
|
DistrictName = Data.DistrictName,
|
|
DistrictCode = Data.DistrictCode,
|
|
ShowOnlyConfiguredRegions = Data.ShowOnlyConfiguredRegions,
|
|
CloseRaceThresholdPercent = Data.CloseRaceThresholdPercent,
|
|
SuperCloseRaceThresholdPercent = Data.SuperCloseRaceThresholdPercent,
|
|
TotalExpectedVotes = Data.TotalExpectedVotes,
|
|
TurnoutVotes = Data.TurnoutVotes,
|
|
Candidates = Data.Candidates.Select(candidate => new CandidateState
|
|
{
|
|
CandidateCode = candidate.CandidateCode,
|
|
BallotNumber = candidate.BallotNumber,
|
|
Name = candidate.Name,
|
|
Party = candidate.Party,
|
|
VoteCount = candidate.VoteCount,
|
|
VoteRate = candidate.VoteRate,
|
|
HasImage = candidate.HasImage,
|
|
ManualJudgement = candidate.ManualJudgement
|
|
}).ToList(),
|
|
Channels = BuildChannelStateMap(),
|
|
CutDurations = BuildCutDurationMap(),
|
|
StationRegionFilters = Settings.Stations.ToDictionary(station => station.Id, station => station.RegionFiltersText),
|
|
StationVideoWallLayouts = Settings.Stations.ToDictionary(
|
|
station => station.Id,
|
|
station => station.VideoWallLayoutPreset.ToString())
|
|
};
|
|
|
|
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,
|
|
() => Settings.SelectedStationVideoWallLayoutPreset,
|
|
formatId => _formatCatalogService.FindById(formatId),
|
|
_logService);
|
|
|
|
var cutDebug = _cutDebugStateStore.Get(channel);
|
|
return new ChannelScheduleViewModel(
|
|
channel,
|
|
title,
|
|
_formatCatalogService.GetByChannel(channel),
|
|
Data,
|
|
adapter,
|
|
cutDebug,
|
|
_cutDebugStateStore,
|
|
engine,
|
|
_logService);
|
|
}
|
|
|
|
private void ReloadFormatCatalog()
|
|
{
|
|
var cutDurations = BuildCutDurationMap();
|
|
_formatCatalogService = new FormatCatalogService(Settings.ImageRootPath);
|
|
|
|
NormalChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.Normal));
|
|
TopLeftChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.TopLeft));
|
|
BottomChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.Bottom));
|
|
VideoWallChannel.UpdateFormats(_formatCatalogService.GetByChannel(BroadcastChannel.VideoWall));
|
|
|
|
UpdateChannelThumbnailLayouts();
|
|
BuildCutListEntries();
|
|
ApplyCutDurations(cutDurations);
|
|
SyncAllQueuedCutDurations();
|
|
}
|
|
|
|
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,
|
|
RegionScope = item.RegionScope,
|
|
ScheduleElectionType = item.ScheduleElectionType,
|
|
RegionLabel = item.RegionLabel,
|
|
RegionCode = item.RegionCode,
|
|
State = item.State
|
|
}).ToList()
|
|
});
|
|
}
|
|
|
|
private Dictionary<string, double> BuildCutDurationMap()
|
|
{
|
|
var durations = new Dictionary<string, double>(StringComparer.Ordinal);
|
|
foreach (var template in _formatCatalogService.GetAll())
|
|
{
|
|
foreach (var cut in template.Cuts)
|
|
{
|
|
durations[BuildCutDurationKey(template.Id, cut.Name)] = cut.DurationSeconds;
|
|
}
|
|
}
|
|
|
|
return durations;
|
|
}
|
|
|
|
private void EnsureCurrentPageAvailableForMode()
|
|
{
|
|
if (!IsPageAvailable(CurrentPage))
|
|
{
|
|
CurrentPage = GetDefaultPage();
|
|
}
|
|
}
|
|
|
|
private AppPage GetDefaultPage()
|
|
{
|
|
return IsVideoWallOperationMode ? AppPage.VideoWall : AppPage.Normal;
|
|
}
|
|
|
|
private string GetDefaultPageTitle()
|
|
{
|
|
return GetDefaultPage() switch
|
|
{
|
|
AppPage.VideoWall => "비디오월",
|
|
_ => "노멀"
|
|
};
|
|
}
|
|
|
|
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 void BuildCutListEntries()
|
|
{
|
|
var entries = _formatCatalogService
|
|
.GetAll()
|
|
.OrderBy(template => CutListElectionCategoryResolver.Resolve(template.Name))
|
|
.ThenBy(template => template.RecommendedChannel)
|
|
.ThenBy(template => template.Name, StringComparer.Ordinal)
|
|
.SelectMany(template => template.Cuts.Select(cut => (
|
|
template.RecommendedChannel,
|
|
Entry: new CutListEntryViewModel(
|
|
template,
|
|
cut,
|
|
OnCutDurationChanged,
|
|
Settings.SelectedStationVideoWallLayoutPreset))))
|
|
.ToArray();
|
|
|
|
_allCutListEntries.Clear();
|
|
_allCutListEntries.AddRange(entries);
|
|
RebuildCutListCategoryOptions();
|
|
ApplyCutListFilter();
|
|
OnPropertyChanged(nameof(CutThumbnailSummary));
|
|
}
|
|
|
|
private void OnCutDurationChanged(FormatTemplateDefinition template)
|
|
{
|
|
SyncQueuedCutDurations(template);
|
|
QueueAutomaticSave();
|
|
}
|
|
|
|
private void ApplyScheduleDurationToCutList(ChannelScheduleItem item)
|
|
{
|
|
var template = _formatCatalogService.FindById(item.FormatId);
|
|
if (template is null)
|
|
{
|
|
QueueAutomaticSave();
|
|
return;
|
|
}
|
|
|
|
var normalized = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(item.DefaultCutDurationSeconds, template);
|
|
if (Math.Abs(item.DefaultCutDurationSeconds - normalized) >= 0.001d)
|
|
{
|
|
_isSyncingQueuedCutDurations = true;
|
|
try
|
|
{
|
|
item.DefaultCutDurationSeconds = normalized;
|
|
}
|
|
finally
|
|
{
|
|
_isSyncingQueuedCutDurations = false;
|
|
}
|
|
}
|
|
|
|
var changed = false;
|
|
foreach (var cut in template.Cuts)
|
|
{
|
|
if (Math.Abs(cut.DurationSeconds - normalized) < 0.001d)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
cut.DurationSeconds = normalized;
|
|
changed = true;
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
RefreshCutListEntries(template);
|
|
SyncQueuedCutDurations(template);
|
|
QueueAutomaticSave();
|
|
}
|
|
}
|
|
|
|
private void RefreshCutListEntries(FormatTemplateDefinition template)
|
|
{
|
|
foreach (var item in _allCutListEntries.Where(item =>
|
|
string.Equals(item.Entry.FormatId, template.Id, StringComparison.Ordinal)))
|
|
{
|
|
item.Entry.RefreshFromSource();
|
|
}
|
|
}
|
|
|
|
private void ApplyCutDurations(IReadOnlyDictionary<string, double>? durations)
|
|
{
|
|
if (durations is null || durations.Count == 0)
|
|
{
|
|
foreach (var item in _allCutListEntries)
|
|
{
|
|
item.Entry.RefreshFromSource();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
foreach (var template in _formatCatalogService.GetAll())
|
|
{
|
|
foreach (var cut in template.Cuts)
|
|
{
|
|
if (!durations.TryGetValue(BuildCutDurationKey(template.Id, cut.Name), out var duration) ||
|
|
double.IsNaN(duration) ||
|
|
double.IsInfinity(duration))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
cut.DurationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(duration, template);
|
|
}
|
|
}
|
|
|
|
foreach (var item in _allCutListEntries)
|
|
{
|
|
item.Entry.RefreshFromSource();
|
|
}
|
|
}
|
|
|
|
private void ApplyCutListFilter()
|
|
{
|
|
var selectedChannel = SelectedCutListFilterOption?.Value;
|
|
var selectedCategory = SelectedCutListCategoryOption?.Value;
|
|
var filteredEntries = _allCutListEntries
|
|
.Where(item =>
|
|
(selectedChannel is null || item.Channel == selectedChannel.Value) &&
|
|
(selectedCategory is null || item.Entry.IsInCategory(selectedCategory.Value)))
|
|
.Select(item => item.Entry)
|
|
.ToArray();
|
|
|
|
CutListItems.Clear();
|
|
foreach (var entry in filteredEntries)
|
|
{
|
|
CutListItems.Add(entry);
|
|
}
|
|
|
|
OnPropertyChanged(nameof(CutListSummary));
|
|
}
|
|
|
|
private void RebuildCutListCategoryOptions()
|
|
{
|
|
var selectedChannel = SelectedCutListFilterOption?.Value;
|
|
var selectedCategory = SelectedCutListCategoryOption?.Value;
|
|
var entriesInSelectedChannel = _allCutListEntries
|
|
.Where(item => selectedChannel is null || item.Channel == selectedChannel.Value)
|
|
.Select(item => item.Entry)
|
|
.ToArray();
|
|
var options = CreateCutListCategoryOptions(entriesInSelectedChannel);
|
|
|
|
CutListCategoryOptions.Clear();
|
|
foreach (var option in options)
|
|
{
|
|
CutListCategoryOptions.Add(option);
|
|
}
|
|
|
|
var nextSelectedOption = selectedCategory.HasValue
|
|
? CutListCategoryOptions.FirstOrDefault(option => option.Value == selectedCategory.Value)
|
|
: null;
|
|
nextSelectedOption ??= CutListCategoryOptions.FirstOrDefault();
|
|
|
|
if (!ReferenceEquals(_selectedCutListCategoryOption, nextSelectedOption))
|
|
{
|
|
_selectedCutListCategoryOption = nextSelectedOption;
|
|
OnPropertyChanged(nameof(SelectedCutListCategoryOption));
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<SelectionOption<CutCategory?>> CreateCutListCategoryOptions(
|
|
IReadOnlyList<CutListEntryViewModel> entries)
|
|
{
|
|
List<SelectionOption<CutCategory?>> options = [new(null, "전체보기")];
|
|
var seenResultKeys = new HashSet<string>(StringComparer.Ordinal)
|
|
{
|
|
BuildCutListCategoryResultKey(entries.Select(entry => entry.FormatId))
|
|
};
|
|
|
|
foreach (var category in CutCategoryResolver.GetOrderedCategories())
|
|
{
|
|
var matchingEntries = entries
|
|
.Where(entry => entry.IsInCategory(category))
|
|
.ToArray();
|
|
if (matchingEntries.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!seenResultKeys.Add(BuildCutListCategoryResultKey(matchingEntries.Select(entry => entry.FormatId))))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
options.Add(new SelectionOption<CutCategory?>(category, CutCategoryResolver.GetLabel(category)));
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
private static string BuildCutListCategoryResultKey(IEnumerable<string> formatIds)
|
|
{
|
|
return string.Join(
|
|
"\u001F",
|
|
formatIds.OrderBy(formatId => formatId, StringComparer.Ordinal));
|
|
}
|
|
|
|
private void RefreshCutListThumbnails()
|
|
{
|
|
foreach (var item in _allCutListEntries)
|
|
{
|
|
item.Entry.RefreshThumbnail();
|
|
}
|
|
|
|
OnPropertyChanged(nameof(CutThumbnailSummary));
|
|
}
|
|
|
|
private void UpdateChannelThumbnailLayouts()
|
|
{
|
|
var videoWallLayoutPreset = Settings.SelectedStationVideoWallLayoutPreset;
|
|
foreach (var channel in Channels)
|
|
{
|
|
channel.UpdateVideoWallLayoutPreset(videoWallLayoutPreset);
|
|
}
|
|
}
|
|
|
|
private void UpdateCutListThumbnailLayouts()
|
|
{
|
|
var videoWallLayoutPreset = Settings.SelectedStationVideoWallLayoutPreset;
|
|
foreach (var item in _allCutListEntries)
|
|
{
|
|
item.Entry.UpdateVideoWallLayoutPreset(videoWallLayoutPreset);
|
|
}
|
|
}
|
|
|
|
private string BuildInitialThumbnailGenerationStatus()
|
|
{
|
|
return KarismaThumbnailGeneratorService.IsGenerationAvailable()
|
|
? "프로젝트 Assets\\Thumbnail 이미지를 우선 사용합니다."
|
|
: "프로젝트 Assets\\Thumbnail 경로를 찾지 못했습니다.";
|
|
}
|
|
|
|
private void SyncAllQueuedCutDurations()
|
|
{
|
|
foreach (var template in _formatCatalogService.GetAll())
|
|
{
|
|
SyncQueuedCutDurations(template);
|
|
}
|
|
}
|
|
|
|
private void SyncQueuedCutDurations(FormatTemplateDefinition template)
|
|
{
|
|
var defaultDuration = template.Cuts.FirstOrDefault()?.DurationSeconds ?? 0;
|
|
_isSyncingQueuedCutDurations = true;
|
|
try
|
|
{
|
|
foreach (var channel in Channels)
|
|
{
|
|
foreach (var item in channel.Queue.Where(item => string.Equals(item.FormatId, template.Id, StringComparison.Ordinal)))
|
|
{
|
|
item.DefaultCutDurationSeconds = defaultDuration;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_isSyncingQueuedCutDurations = false;
|
|
}
|
|
}
|
|
|
|
private static string BuildCutDurationKey(string formatId, string cutName)
|
|
{
|
|
return $"{formatId}::{cutName}";
|
|
}
|
|
|
|
private 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)
|
|
{
|
|
var template = _formatCatalogService.FindById(item.FormatId);
|
|
channelViewModel.Queue.Add(new ChannelScheduleItem
|
|
{
|
|
Id = item.Id,
|
|
FormatId = template?.Id ?? item.FormatId,
|
|
FormatName = template?.Name ?? item.FormatName,
|
|
Description = template?.Description ?? item.Description,
|
|
Channel = item.Channel,
|
|
RequiresImage = template?.RequiresImage ?? item.RequiresImage,
|
|
DefaultCutDurationSeconds = template is null
|
|
? item.DefaultCutDurationSeconds
|
|
: ScheduleTemplatePolicy.NormalizeCutDurationSeconds(
|
|
item.DefaultCutDurationSeconds > 0
|
|
? item.DefaultCutDurationSeconds
|
|
: template.Cuts.FirstOrDefault()?.DurationSeconds ?? item.DefaultCutDurationSeconds,
|
|
template),
|
|
TotalCuts = template?.Cuts.Count ?? item.TotalCuts,
|
|
RegionScope = item.RegionScope,
|
|
ScheduleElectionType = item.ScheduleElectionType,
|
|
RegionLabel = item.RegionLabel,
|
|
RegionCode = item.RegionCode,
|
|
State = item.State
|
|
});
|
|
}
|
|
|
|
channelViewModel.LoopEnabled = channelState.LoopEnabled;
|
|
channelViewModel.EmptyScheduleBehavior = channelState.EmptyScheduleBehavior;
|
|
channelViewModel.RefreshSummary();
|
|
}
|
|
}
|
|
|
|
|