Files
Tornado3_2026Election/Tornado3_2026Election/ViewModels/MainViewModel.cs
2026-05-13 11:21:48 +09:00

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();
}
}