Compare commits
3 Commits
7b0d900bdb
...
28a1011c48
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a1011c48 | |||
| 666d757ff6 | |||
| 05762c0e33 |
@@ -226,3 +226,56 @@ IDLE → READY → SENDING → ON_AIR → NEXT
|
||||
- 컷 단위 송출
|
||||
- 스케줄 큐 구조
|
||||
- 상태 머신 기반 제어
|
||||
---
|
||||
|
||||
## 15. 인코딩 검증 규칙
|
||||
|
||||
- 한글 문자열이 포함된 파일을 수정한 뒤에는 반드시 인코딩 깨짐 여부를 다시 확인한다.
|
||||
- UI 문구, 로그 문구, 기본값 문자열은 저장 직후 한글이 정상 표시되는지 우선 점검한다.
|
||||
- `?`, `<60>`, 비정상 한자 형태의 모지바케가 보이면 즉시 수정 대상으로 간주한다.
|
||||
- 텍스트 파일은 UTF-8 기준으로 관리한다.
|
||||
---
|
||||
|
||||
## 16. Karisma / Tornado3 연동 기준
|
||||
|
||||
- CG 연동 라이브러리는 `Interop.KAsyncEngineLib.dll`을 사용한다.
|
||||
- 기본 접속 대상은 `127.0.0.1:30001`이다.
|
||||
- `TORNADO_KARISMA_HOST`가 있으면 기본 호스트 대신 사용한다.
|
||||
- `TORNADO_KARISMA_PORT`가 있으면 기본 포트 대신 사용한다.
|
||||
- 앱 시작 시 `T3_Cut 경로`가 유효하지 않으면 실CG 대신 Mock Adapter로 폴백한다.
|
||||
- 현재 구현 기준으로는 시작 시 Mock으로 결정된 경우, 설정 변경 후 실CG 재연결을 위해 앱 재시작이 필요할 수 있다.
|
||||
- 채널 기본 바인딩은 `노멀=0:0`, `좌상단=0:1`, `하단=0:2`, `비디오월=1:0`이다.
|
||||
- 환경변수 `TORNADO_KARISMA_BIND_NORMAL`, `TORNADO_KARISMA_BIND_TOPLEFT`, `TORNADO_KARISMA_BIND_BOTTOM`, `TORNADO_KARISMA_BIND_VIDEOWALL`로 채널 바인딩을 덮어쓸 수 있다.
|
||||
|
||||
## 17. T3_Cut 운영 규칙
|
||||
|
||||
- 사용자 설정 명칭은 `이미지 루트 경로`가 아니라 `T3_Cut 경로`로 표기한다.
|
||||
- 송출에 사용하는 컷 파일 확장자는 `.tscn`이다.
|
||||
- 컷 파일은 `T3_Cut` 루트 아래의 고정된 포맷 구조를 기준으로 사용한다.
|
||||
- 포맷 목록은 폴더 스캔으로 동적 생성하지 않고 하드코딩된 목록으로 관리한다.
|
||||
- 같은 컷 이름에 `_loop.tscn` 파일이 있으면 반복 송출 컷으로 사용한다.
|
||||
- 최초 송출 시에는 기본 컷 파일을 사용한다.
|
||||
- 이미 송출 중인 상태에서 같은 컷을 다시 사용할 때는 `_loop.tscn`이 있으면 우선 사용한다.
|
||||
- `_loop.tscn`이 없으면 기본 `.tscn` 파일로 폴백한다.
|
||||
- 예시: `1-2위_광역단체장.tscn`은 최초 송출용, `1-2위_광역단체장_loop.tscn`은 반복 송출용으로 간주한다.
|
||||
|
||||
## 18. CG 연동 상태 UI 표기 기준
|
||||
|
||||
- 메인 화면 상단에는 `CG 연동 상태`를 표시한다.
|
||||
- 사용자는 UI에서 현재 어댑터가 `실CG`인지 `Mock`인지 즉시 식별할 수 있어야 한다.
|
||||
- 상단 상태 영역에는 실CG 연동 여부, 연결 대상, 채널 정상 상태 요약을 함께 표시한다.
|
||||
- 채널 패널별로도 해당 채널이 어떤 백엔드를 사용하는지 표시한다.
|
||||
- 실제 Karisma 사용 시 연결 대상 예시는 `127.0.0.1:30001` 형식으로 표시한다.
|
||||
|
||||
## 19. CG Return Value / Callback 로그 정책
|
||||
|
||||
- CG 시스템으로부터 오는 Return Value 관련 결과는 `로그` 탭에서 확인할 수 있어야 한다.
|
||||
- 즉시 반환되는 값과 비동기 콜백 결과를 모두 로그로 남긴다.
|
||||
- `Connect()` 호출 직후의 반환값은 즉시 로그로 기록한다.
|
||||
- `LoadScene()` 및 `LoadSceneForce()` 호출 결과도 즉시 로그로 기록한다.
|
||||
- `KAEventHandler` 기반 콜백 결과를 `LogService`를 통해 공용 로그에 남긴다.
|
||||
- 로그에는 콜백 이름, 결과 enum 이름, 숫자 코드, 추가 정보(scene, object, output, layer 등)를 함께 남긴다.
|
||||
- `OnConnect(int ErrorCode)`는 `0`을 성공으로 간주하고, `0`이 아닌 값은 실패로 기록한다.
|
||||
- `eKResult.RESULT_SUCCESS`는 정보 로그로 남기고, 그 외 결과는 경고 로그로 남긴다.
|
||||
- 현재 로깅 대상에는 `OnConnect`, `OnClose`, `OnLogMessage`, `OnMessageNo`, `OnLoadScene`, `OnLoadSceneForce`, `OnBeginTransaction`, `OnEndTransaction`, `OnHeartBeat`, `OnSetValue`, `OnScenePrepare`, `OnScenePrepareEx`, `OnPlay`, `OnPlayOut`, `OnPause`, `OnResume`, `OnStop`, `OnStopAll`, `OnCutIn`, `OnCutOut`, `OnTrigger`, `OnTriggerObject`, `OnQueryIsOnAir`, `OnQueryLayerCount`, `OnScenePlayingStarted`, `OnScenePlayed`, `OnSceneAnimationPlayed`, `OnScenePaused`를 포함한다.
|
||||
- CG 콜백 로그와 앱 내부 로그는 같은 로그 시스템에 합쳐서 표시한다.
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl
|
||||
<UserControl
|
||||
x:Class="Tornado3_2026Election.Controls.ChannelSchedulePanel"
|
||||
x:Name="Root"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
@@ -44,6 +44,10 @@
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.OperatorQuickSummary, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind ViewModel.CgStatusSummary, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel
|
||||
@@ -169,7 +173,7 @@
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
Command="{x:Bind ViewModel.AddFormatCommand}"
|
||||
Content="포맷 추가"
|
||||
Content="컷 추가"
|
||||
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
|
||||
<ToggleSwitch
|
||||
@@ -184,11 +188,26 @@
|
||||
ItemsSource="{x:Bind ViewModel.EmptyBehaviorOptions, Mode=OneWay}"
|
||||
SelectedItem="{x:Bind ViewModel.SelectedEmptyBehaviorOption, Mode=TwoWay}" />
|
||||
|
||||
<TextBlock
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
MinHeight="22"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="빈 스케줄 처리, 강제 전환, 순서 변경까지 이 패널에서 즉시 수행합니다." />
|
||||
Content="?"
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="11">
|
||||
<Button.Flyout>
|
||||
<Flyout>
|
||||
<TextBlock
|
||||
MaxWidth="240"
|
||||
Text="스케줄이 비었을 때의 동작입니다. 루프 유지, 정지, 대기 방식 중 하나를 선택합니다."
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<StackPanel
|
||||
@@ -204,11 +223,16 @@
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.ForceNextCommand}"
|
||||
Content="다음 강제"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}">
|
||||
<TextBlock TextAlignment="Center">
|
||||
<Run Text="다음 컷" />
|
||||
<LineBreak />
|
||||
<Run Text="즉시 송출" />
|
||||
</TextBlock>
|
||||
</Button>
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.ResetQueueCommand}"
|
||||
Content="루프 초기화"
|
||||
Content="큐 초기화"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
@@ -227,22 +251,51 @@
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleSectionTitleTextStyle}"
|
||||
Text="스케줄 목록" />
|
||||
Text="대기 중 목록" />
|
||||
<Button
|
||||
Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
MinHeight="22"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="?"
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="11">
|
||||
<Button.Flyout>
|
||||
<Flyout>
|
||||
<TextBlock
|
||||
MaxWidth="280"
|
||||
Text="빨강은 현재 송출 중, 노랑은 다음 송출 예정입니다. 목록의 다음 버튼은 다음 예약만 바꾸고, '다음 컷 즉시 송출'은 노란 컷을 바로 송출합니다."
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="큐 엔진" />
|
||||
Text="실행 순서" />
|
||||
</Grid>
|
||||
|
||||
<ListView
|
||||
x:Name="QueueListView"
|
||||
ItemsSource="{x:Bind ViewModel.Queue, Mode=OneWay}"
|
||||
SelectionMode="None">
|
||||
<ListView.ItemContainerTransitions>
|
||||
<TransitionCollection>
|
||||
<AddDeleteThemeTransition />
|
||||
<ReorderThemeTransition />
|
||||
</TransitionCollection>
|
||||
</ListView.ItemContainerTransitions>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="domain:ChannelScheduleItem">
|
||||
<Border
|
||||
Margin="0,0,0,10"
|
||||
Opacity="{x:Bind CardOpacity, Mode=OneWay}"
|
||||
Padding="14"
|
||||
Background="#122033"
|
||||
BorderBrush="#27405F"
|
||||
@@ -257,7 +310,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border
|
||||
Background="{x:Bind StateBrush}"
|
||||
Background="{x:Bind StateBrush, Mode=OneWay}"
|
||||
CornerRadius="4" />
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="6">
|
||||
@@ -267,11 +320,11 @@
|
||||
CornerRadius="12">
|
||||
<TextBlock
|
||||
Style="{StaticResource MiniSignalTextStyle}"
|
||||
Text="{x:Bind StateLabel}" />
|
||||
Text="{x:Bind StateLabel, Mode=OneWay}" />
|
||||
</Border>
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind LastPlayedLabel}" />
|
||||
Text="{x:Bind LastPlayedLabel, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Spacing="6">
|
||||
@@ -286,8 +339,7 @@
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}">
|
||||
<Run Text="컷 " />
|
||||
<Run Text="{x:Bind TotalCuts}" />
|
||||
<Run Text=" | " />
|
||||
<Run Text="기본 " />
|
||||
<Run Text=" | 기본 " />
|
||||
<Run Text="{x:Bind DefaultCutDurationSeconds}" />
|
||||
<Run Text="초" />
|
||||
</TextBlock>
|
||||
@@ -299,24 +351,20 @@
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button
|
||||
Command="{Binding ViewModel.PromoteToNextCommand, ElementName=Root}"
|
||||
CommandParameter="{Binding}"
|
||||
Click="PromoteToNextButton_Click"
|
||||
Content="다음"
|
||||
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||
<Button
|
||||
Command="{Binding ViewModel.MoveUpCommand, ElementName=Root}"
|
||||
CommandParameter="{Binding}"
|
||||
Click="MoveUpButton_Click"
|
||||
Content="위"
|
||||
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||
<Button
|
||||
Command="{Binding ViewModel.MoveDownCommand, ElementName=Root}"
|
||||
CommandParameter="{Binding}"
|
||||
Click="MoveDownButton_Click"
|
||||
Content="아래"
|
||||
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||
<Button
|
||||
Command="{Binding ViewModel.RemoveItemCommand, ElementName=Root}"
|
||||
CommandParameter="{Binding}"
|
||||
Content="삭제"
|
||||
Click="RemoveItemButton_Click"
|
||||
Content="제거"
|
||||
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -329,3 +377,4 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Animation;
|
||||
using Windows.Foundation;
|
||||
using Tornado3_2026Election.Domain;
|
||||
using Tornado3_2026Election.ViewModels;
|
||||
|
||||
namespace Tornado3_2026Election.Controls;
|
||||
@@ -23,4 +29,133 @@ public sealed partial class ChannelSchedulePanel : UserControl
|
||||
get => (ChannelScheduleViewModel?)GetValue(ViewModelProperty);
|
||||
set => SetValue(ViewModelProperty, value);
|
||||
}
|
||||
|
||||
private void PromoteToNextButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var item = GetItem(sender);
|
||||
var command = ViewModel?.PromoteToNextCommand;
|
||||
if (item is null || command is null || !command.CanExecute(item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
command.Execute(item);
|
||||
}
|
||||
|
||||
private void MoveUpButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = AnimateSwapAsync(sender, moveUp: true);
|
||||
}
|
||||
|
||||
private void MoveDownButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = AnimateSwapAsync(sender, moveUp: false);
|
||||
}
|
||||
|
||||
private void RemoveItemButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var item = GetItem(sender);
|
||||
var command = ViewModel?.RemoveItemCommand;
|
||||
if (item is null || command is null || !command.CanExecute(item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
command.Execute(item);
|
||||
}
|
||||
|
||||
private static ChannelScheduleItem? GetItem(object sender)
|
||||
{
|
||||
return (sender as FrameworkElement)?.DataContext as ChannelScheduleItem;
|
||||
}
|
||||
|
||||
private async Task AnimateSwapAsync(object sender, bool moveUp)
|
||||
{
|
||||
if (ViewModel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var item = GetItem(sender);
|
||||
if (item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentIndex = ViewModel.Queue.IndexOf(item);
|
||||
var targetIndex = currentIndex + (moveUp ? -1 : 1);
|
||||
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= ViewModel.Queue.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var swappedItem = ViewModel.Queue[targetIndex];
|
||||
var movingContainer = QueueListView.ContainerFromItem(item) as ListViewItem;
|
||||
var swappedContainer = QueueListView.ContainerFromItem(swappedItem) as ListViewItem;
|
||||
var movingTop = movingContainer is null ? (double?)null : GetTop(movingContainer);
|
||||
var swappedTop = swappedContainer is null ? (double?)null : GetTop(swappedContainer);
|
||||
|
||||
var command = moveUp ? ViewModel.MoveUpCommand : ViewModel.MoveDownCommand;
|
||||
if (!command.CanExecute(item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
command.Execute(item);
|
||||
|
||||
await Task.Yield();
|
||||
QueueListView.UpdateLayout();
|
||||
|
||||
if (movingTop.HasValue)
|
||||
{
|
||||
AnimateToCurrentPosition(QueueListView.ContainerFromItem(item) as ListViewItem, movingTop.Value);
|
||||
}
|
||||
|
||||
if (swappedTop.HasValue)
|
||||
{
|
||||
AnimateToCurrentPosition(QueueListView.ContainerFromItem(swappedItem) as ListViewItem, swappedTop.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private double GetTop(FrameworkElement element)
|
||||
{
|
||||
return element.TransformToVisual(QueueListView).TransformPoint(new Point(0, 0)).Y;
|
||||
}
|
||||
|
||||
private void AnimateToCurrentPosition(ListViewItem? container, double previousTop)
|
||||
{
|
||||
if (container is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentTop = GetTop(container);
|
||||
var delta = previousTop - currentTop;
|
||||
if (Math.Abs(delta) < 0.5)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var transform = container.RenderTransform as TranslateTransform;
|
||||
if (transform is null)
|
||||
{
|
||||
transform = new TranslateTransform();
|
||||
container.RenderTransform = transform;
|
||||
}
|
||||
|
||||
transform.Y = delta;
|
||||
|
||||
var animation = new DoubleAnimation
|
||||
{
|
||||
To = 0,
|
||||
Duration = new Duration(TimeSpan.FromMilliseconds(180)),
|
||||
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
|
||||
};
|
||||
|
||||
var storyboard = new Storyboard();
|
||||
storyboard.Children.Add(animation);
|
||||
Storyboard.SetTarget(animation, transform);
|
||||
Storyboard.SetTargetProperty(animation, nameof(TranslateTransform.Y));
|
||||
storyboard.Begin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Tornado3_2026Election.Common;
|
||||
|
||||
namespace Tornado3_2026Election.Domain;
|
||||
@@ -38,6 +38,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
{
|
||||
OnPropertyChanged(nameof(StateLabel));
|
||||
OnPropertyChanged(nameof(StateBrush));
|
||||
OnPropertyChanged(nameof(CardOpacity));
|
||||
OnPropertyChanged(nameof(CanDelete));
|
||||
}
|
||||
}
|
||||
@@ -60,7 +61,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
{
|
||||
ScheduleQueueItemState.Next => "다음",
|
||||
ScheduleQueueItemState.Sending => "준비",
|
||||
ScheduleQueueItemState.OnAir => "송출중",
|
||||
ScheduleQueueItemState.OnAir => "송출 중",
|
||||
ScheduleQueueItemState.Completed => "완료",
|
||||
ScheduleQueueItemState.Error => "오류",
|
||||
_ => "대기"
|
||||
@@ -69,19 +70,19 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
[JsonIgnore]
|
||||
public SolidColorBrush StateBrush => new(State switch
|
||||
{
|
||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 255, 145, 77),
|
||||
ScheduleQueueItemState.Sending => ColorHelper.FromArgb(255, 255, 191, 0),
|
||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 202, 52, 52),
|
||||
ScheduleQueueItemState.Completed => ColorHelper.FromArgb(255, 68, 104, 77),
|
||||
ScheduleQueueItemState.Error => ColorHelper.FromArgb(255, 110, 39, 39),
|
||||
_ => ColorHelper.FromArgb(255, 80, 90, 110)
|
||||
ScheduleQueueItemState.Next => ColorHelper.FromArgb(255, 245, 158, 11),
|
||||
ScheduleQueueItemState.OnAir => ColorHelper.FromArgb(255, 239, 68, 68),
|
||||
_ => ColorHelper.FromArgb(255, 100, 116, 139)
|
||||
});
|
||||
|
||||
[JsonIgnore]
|
||||
public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
|
||||
|
||||
[JsonIgnore]
|
||||
public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "not played";
|
||||
public string LastPlayedLabel => LastPlayedAt?.ToString("HH:mm:ss") ?? "아직 송출 안 함";
|
||||
|
||||
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template)
|
||||
{
|
||||
|
||||
@@ -14,7 +14,23 @@ public sealed class FormatTemplateDefinition
|
||||
|
||||
public required bool RequiresImage { get; init; }
|
||||
|
||||
public required bool SupportsPreElection { get; init; }
|
||||
|
||||
public required bool SupportsCounting { get; init; }
|
||||
|
||||
public required bool RequiresCandidateData { get; init; }
|
||||
|
||||
public required LoopMode LoopMode { get; init; }
|
||||
|
||||
public required IReadOnlyList<FormatCutDefinition> Cuts { get; init; }
|
||||
|
||||
public bool IsAvailableInPhase(BroadcastPhase phase)
|
||||
{
|
||||
return phase switch
|
||||
{
|
||||
BroadcastPhase.PreElection => SupportsPreElection,
|
||||
BroadcastPhase.Counting => SupportsCounting,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Window x:Class="Tornado3_2026Election.MainWindow"
|
||||
<Window x:Class="Tornado3_2026Election.MainWindow"
|
||||
x:Name="RootWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
@@ -68,122 +68,135 @@
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Padding="18"
|
||||
<Border Padding="14"
|
||||
Background="{StaticResource ControlRoomHeroBrush}"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="24">
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnSpacing="16">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Style="{StaticResource ConsoleHeroTitleTextStyle}"
|
||||
FontSize="27"
|
||||
FontSize="25"
|
||||
Text="선거방송 송출 상황실" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.HeaderStatus, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10"
|
||||
VerticalAlignment="Top">
|
||||
<TextBlock FontFamily="Consolas"
|
||||
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
|
||||
Text="{x:Bind ViewModel.Data.PollingCountdownText, Mode=OneWay}"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}"
|
||||
Content="수동 수신"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</StackPanel>
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재 메뉴" />
|
||||
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="16" Foreground="{StaticResource ControlRoomSignalBlueBrush}" Text="{x:Bind ViewModel.CurrentPageTitle, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="방송 단계" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="단계" HorizontalAlignment="Center" />
|
||||
<ToggleSwitch x:Name="BroadcastPhaseToggleSwitch"
|
||||
OffContent="사전"
|
||||
OnContent="개표"
|
||||
IsOn="{x:Bind ViewModel.Data.IsCountingPhase, Mode=OneWay}"
|
||||
Toggled="BroadcastPhaseToggleSwitch_Toggled" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.BroadcastPhaseBadgeText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="2" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="운영 모드" />
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="모드" HorizontalAlignment="Center" />
|
||||
<ToggleSwitch x:Name="OperationModeToggleSwitch"
|
||||
OffContent="일반"
|
||||
OnContent="비디오월"
|
||||
IsOn="{x:Bind ViewModel.IsVideoWallOperationMode, Mode=OneWay}"
|
||||
Toggled="OperationModeToggleSwitch_Toggled" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.OperationModeBadgeText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="3" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="Tornado 연결" />
|
||||
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="16" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.TornadoConnectionSummary, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="4" Padding="10,9" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="마지막 수신" />
|
||||
<TextBlock FontFamily="Consolas" FontSize="16" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.LastRefreshDisplay, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
Command="{x:Bind ViewModel.ToggleSituationRoomCommand}"
|
||||
Content="{x:Bind ViewModel.SituationRoomToggleText, Mode=OneWay}"
|
||||
VerticalAlignment="Top"
|
||||
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}"
|
||||
Content="수동 갱신"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
<Button Command="{x:Bind ViewModel.ToggleSituationRoomCommand}"
|
||||
Content="{x:Bind ViewModel.SituationRoomToggleText, Mode=OneWay}"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Visibility="{x:Bind ViewModel.SituationRoomBodyVisibility, Mode=OneWay}"
|
||||
ColumnSpacing="20">
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="1.8*" />
|
||||
<ColumnDefinition Width="1.2*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock FontFamily="Consolas" Foreground="{StaticResource ControlRoomTextSecondaryBrush}" Text="{x:Bind ViewModel.HeaderStatus, Mode=OneWay}" />
|
||||
<Border Padding="10,8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="메뉴 / 방송사" />
|
||||
<TextBlock FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="15"
|
||||
Foreground="{StaticResource ControlRoomSignalBlueBrush}"
|
||||
TextTrimming="CharacterEllipsis">
|
||||
<Run Text="{x:Bind ViewModel.CurrentPageTitle, Mode=OneWay}" />
|
||||
<Run Text=" / " />
|
||||
<Run Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Column="1" ColumnSpacing="12" RowSpacing="12">
|
||||
<Grid.RowDefinitions><RowDefinition Height="*" /><RowDefinition Height="*" /></Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
||||
<Border Padding="16" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
|
||||
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="방송사" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="22" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" /></StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Padding="16" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
|
||||
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="선거구" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="22" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Data.DistrictName, Mode=OneWay}" /></StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Row="1" Padding="16" Background="#18263A" BorderBrush="#35506F" BorderThickness="1" CornerRadius="18">
|
||||
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.SituationMetricPrimaryLabel, Mode=OneWay}" /><TextBlock FontFamily="Consolas" FontSize="24" Foreground="{StaticResource ControlRoomSignalGreenBrush}" Text="{x:Bind ViewModel.Data.SituationMetricPrimaryValue, Mode=OneWay}" /></StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Row="1" Grid.Column="1" Padding="16" Background="#201B2F" BorderBrush="#60475A" BorderThickness="1" CornerRadius="18">
|
||||
<StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Data.SituationMetricSecondaryLabel, Mode=OneWay}" /><TextBlock FontFamily="Consolas" FontSize="24" Foreground="{StaticResource ControlRoomSignalAmberBrush}" Text="{x:Bind ViewModel.Data.SituationMetricSecondaryValue, Mode=OneWay}" /></StackPanel>
|
||||
<Border Grid.Column="1" Padding="10,8" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="14">
|
||||
<StackPanel Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="CG 연결 상태" />
|
||||
<Button Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
MinHeight="22"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="?"
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="11">
|
||||
<Button.Flyout>
|
||||
<Flyout>
|
||||
<TextBlock MaxWidth="260"
|
||||
Text="Karisma TCP 30001 연결 상태입니다. 녹색은 실제 연결 성공, 빨간색은 끊김 상태입니다."
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Ellipse Width="10"
|
||||
Height="10"
|
||||
VerticalAlignment="Center"
|
||||
Fill="{x:Bind ViewModel.CgIntegrationBrush, Mode=OneWay}" />
|
||||
<TextBlock FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="15"
|
||||
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
|
||||
Text="{x:Bind ViewModel.CgIntegrationSummary, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="{x:Bind ViewModel.CgIntegrationDetail, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Border Visibility="{x:Bind ViewModel.SituationRoomBodyVisibility, Mode=OneWay}"
|
||||
Padding="10,8"
|
||||
Background="#101C2E"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="16">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock FontFamily="Consolas"
|
||||
Foreground="{StaticResource ControlRoomTextSecondaryBrush}"
|
||||
Text="{x:Bind ViewModel.HeaderStatus, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}"
|
||||
Text="{x:Bind ViewModel.TornadoConnectionDetail, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -195,8 +208,7 @@
|
||||
<Border Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
|
||||
<Grid>
|
||||
<StackPanel Spacing="10" Visibility="{x:Bind ViewModel.GeneralIntegratedVisibility, Mode=OneWay}">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="통합 송출 매트릭스" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="일반 모드에서는 노멀, 좌상단, 하단 채널을 통합 상황실에서 함께 운영합니다." />
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="통합 채널" />
|
||||
<Grid ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /><ColumnDefinition Width="*" /></Grid.ColumnDefinitions>
|
||||
<Border Padding="14" Background="#16263A" BorderBrush="#284665" BorderThickness="1" CornerRadius="18"><StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="노멀" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.NormalChannel.TransmissionLabel, Mode=OneWay}" /><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.NormalChannel.CurrentItemName, Mode=OneWay}" /></StackPanel></Border>
|
||||
@@ -205,8 +217,7 @@
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="10" Visibility="{x:Bind ViewModel.VideoWallIntegratedVisibility, Mode=OneWay}">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="통합 송출 매트릭스" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="비디오월 모드에서는 대형 화면 연출 채널만 단독으로 운영합니다." />
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="비디오월 채널" />
|
||||
<Border Padding="14" Background="#16263A" BorderBrush="#284665" BorderThickness="1" CornerRadius="18"><StackPanel Spacing="4"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="비디오월" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.VideoWallChannel.TransmissionLabel, Mode=OneWay}" /><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.VideoWallChannel.CurrentItemName, Mode=OneWay}" /></StackPanel></Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -214,22 +225,13 @@
|
||||
<StackPanel Grid.Column="1" Spacing="20">
|
||||
<Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="빠른 실행" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="운영 중 저장과 복원을 빠르게 수행합니다." />
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="저장 / 복원" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Command="{x:Bind ViewModel.SaveStateCommand}" Content="상태 저장" Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
<Button Command="{x:Bind ViewModel.RestoreStateCommand}" Content="복원" Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="운영 정책" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 갱신 중 송출 요청 시 갱신 완료 후 송출" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 수동 수신은 3초 이내 재요청 금지" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="• 이미지 필수 포맷은 사진 누락 시 송출 차단" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -244,10 +246,10 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.NormalVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="노멀 채널은 메인 자막과 시도 루프의 핵심 송출 구간입니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.NormalChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.TopLeftVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="좌상단 채널은 간결한 보조 정보와 속보형 상태 표시를 담당합니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.TopLeftChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.BottomVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="하단 채널은 라인형 득표 바와 속보 자막을 위한 전용 런다운입니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.BottomChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.VideoWallVisibility, Mode=OneWay}"><StackPanel Spacing="20"><Border Padding="18" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24"><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="비디오월 채널은 사진과 당선 카드 중심의 대형 시각 연출을 담당합니다." /></Border><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.VideoWallChannel, Mode=OneWay}" /></StackPanel></ScrollViewer>
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.NormalVisibility, Mode=OneWay}"><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.NormalChannel, Mode=OneWay}" /></ScrollViewer>
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.TopLeftVisibility, Mode=OneWay}"><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.TopLeftChannel, Mode=OneWay}" /></ScrollViewer>
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.BottomVisibility, Mode=OneWay}"><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.BottomChannel, Mode=OneWay}" /></ScrollViewer>
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.VideoWallVisibility, Mode=OneWay}"><controls:ChannelSchedulePanel ViewModel="{x:Bind ViewModel.VideoWallChannel, Mode=OneWay}" /></ScrollViewer>
|
||||
|
||||
<ScrollViewer Visibility="{x:Bind ViewModel.DataVisibility, Mode=OneWay}">
|
||||
<StackPanel Spacing="20">
|
||||
@@ -270,9 +272,9 @@
|
||||
<TextBox Grid.Column="2" Header="지역 코드" Text="{x:Bind ViewModel.Data.DistrictCode, Mode=TwoWay}" />
|
||||
<NumberBox Grid.Column="3" Header="{x:Bind ViewModel.Data.TotalExpectedVotesLabel, Mode=OneWay}" Minimum="1" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.TotalExpectedVotes, Mode=TwoWay}" />
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="10" VerticalAlignment="Bottom">
|
||||
<ToggleSwitch Header="API 자동 수신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
||||
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
||||
<NumberBox Width="140" Header="주기(초)" Minimum="3" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
|
||||
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 수신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
<Button Command="{x:Bind ViewModel.Data.ManualRefreshCommand}" Content="수동 갱신" Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
<Button Command="{x:Bind ViewModel.Data.AddCandidateCommand}" Content="후보 추가" Style="{StaticResource ConsoleGhostButtonStyle}" Visibility="{x:Bind ViewModel.Data.CountingActionsVisibility, Mode=OneWay}" />
|
||||
<Button Command="{x:Bind ViewModel.Data.ResetManualJudgementsCommand}" Content="수동 판정 초기화" Style="{StaticResource ConsoleGhostButtonStyle}" Visibility="{x:Bind ViewModel.Data.CountingActionsVisibility, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
@@ -294,7 +296,7 @@
|
||||
<ColumnDefinition Width="220" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<NumberBox Header="투표자 수"
|
||||
<NumberBox Header="투표수"
|
||||
Minimum="0"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.Data.TurnoutVotes, Mode=TwoWay}" />
|
||||
@@ -319,8 +321,27 @@
|
||||
BorderThickness="1"
|
||||
CornerRadius="18">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="수신 정책" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="사전 단계에서는 투표율과 투표자 수 중심으로 수신하며 후보 득표수 편집은 잠시 숨깁니다." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="개표 단계" />
|
||||
<Button Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
MinHeight="22"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="?"
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="11">
|
||||
<Button.Flyout>
|
||||
<Flyout>
|
||||
<TextBlock MaxWidth="260"
|
||||
Text="현재 송출 기준 단계입니다. 단계에 따라 표시와 갱신 항목이 달라집니다."
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.Data.BroadcastPhaseBadgeText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
@@ -353,7 +374,7 @@
|
||||
SelectedValue="{Binding ManualJudgement, Mode=TwoWay}"
|
||||
SelectedValuePath="Value" />
|
||||
<StackPanel Grid.Column="6" Spacing="6"><TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="판정" /><TextBlock FontFamily="Bahnschrift SemiBold" FontSize="18" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind EffectiveJudgementLabel}" /><ToggleButton IsChecked="{Binding HasImage, Mode=TwoWay}" Content="사진" /></StackPanel>
|
||||
<StackPanel Grid.Column="7" Orientation="Horizontal" Spacing="8" VerticalAlignment="Bottom"><Button Command="{Binding ViewModel.Data.RemoveCandidateCommand, ElementName=RootWindow}" CommandParameter="{Binding}" Content="삭제" Style="{StaticResource ConsoleGhostButtonStyle}" /></StackPanel>
|
||||
<StackPanel Grid.Column="7" Orientation="Horizontal" Spacing="8" VerticalAlignment="Bottom"><Button Command="{Binding ViewModel.Data.RemoveCandidateCommand, ElementName=RootWindow}" CommandParameter="{Binding}" Content="제거" Style="{StaticResource ConsoleGhostButtonStyle}" /></StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
@@ -375,10 +396,32 @@
|
||||
<ComboBox DisplayMemberPath="Name" Header="방송사" ItemsSource="{x:Bind ViewModel.Settings.Stations, Mode=OneWay}" SelectedValue="{x:Bind ViewModel.Settings.SelectedStationId, Mode=TwoWay}" SelectedValuePath="Id" />
|
||||
<Grid Grid.Column="1" ColumnSpacing="10">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||
<TextBox Header="이미지 루트 경로"
|
||||
IsReadOnly="True"
|
||||
<TextBox IsReadOnly="True"
|
||||
IsSpellCheckEnabled="False"
|
||||
Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}" />
|
||||
Text="{x:Bind ViewModel.Settings.ImageRootPath, Mode=OneWay}">
|
||||
<TextBox.Header>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="T3_Cut 경로" />
|
||||
<Button Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
MinHeight="22"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="?"
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="11">
|
||||
<Button.Flyout>
|
||||
<Flyout>
|
||||
<TextBlock MaxWidth="260"
|
||||
Text="송출에 사용할 .tscn 컷 폴더입니다. 폴더를 바꾸면 컷 검색 기준도 함께 바뀝니다."
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</TextBox.Header>
|
||||
</TextBox>
|
||||
<Button Grid.Column="1"
|
||||
Click="PickImageRootFolderButton_Click"
|
||||
Content="폴더 선택"
|
||||
@@ -386,18 +429,35 @@
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="여기서는 현재 선택한 방송사만 편집합니다. 권역 루프에 들어갈 시도는 아래 17개 체크 항목으로 관리됩니다." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Padding="20" Background="{StaticResource ControlRoomPanelGradientBrush}" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="24">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시작 및 수신 옵션" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="첫 실행 때만 방송사를 묻고, 이후에는 이 옵션대로 자동 복원합니다. 변경 내용은 자동 저장됩니다." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시작 및 복원 옵션" />
|
||||
<Button Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
MinHeight="22"
|
||||
Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Content="?"
|
||||
FontFamily="Bahnschrift SemiBold"
|
||||
FontSize="11">
|
||||
<Button.Flyout>
|
||||
<Flyout>
|
||||
<TextBlock MaxWidth="260"
|
||||
Text="앱 시작 시 이전 상태를 얼마나 복원할지 정합니다. 처음에는 기본값으로 두고 필요한 항목만 켜면 됩니다."
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<CheckBox Content="스케줄 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreSchedules, Mode=TwoWay}" />
|
||||
<CheckBox Content="방송사 설정 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStations, Mode=TwoWay}" />
|
||||
<CheckBox Content="상태값 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStatusValues, Mode=TwoWay}" />
|
||||
<ToggleSwitch Header="API 자동 수신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
||||
<NumberBox Header="API 수신 주기(초)"
|
||||
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
||||
<NumberBox Header="API 갱신 주기(초)"
|
||||
Minimum="3"
|
||||
SpinButtonPlacementMode="Compact"
|
||||
Value="{x:Bind ViewModel.Data.PollingIntervalSeconds, Mode=TwoWay}" />
|
||||
@@ -420,7 +480,7 @@
|
||||
|
||||
<Border Padding="18" Background="#132338" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="편집 대상" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="현재 대상" />
|
||||
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="26" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.Settings.SelectedStation.Name, Mode=OneWay}" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="{x:Bind ViewModel.Settings.SelectedStationRegionSummary, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
@@ -428,8 +488,7 @@
|
||||
|
||||
<Border Grid.Column="1" Padding="18" Background="#101C2E" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" CornerRadius="18">
|
||||
<StackPanel Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="권역 정책" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="선택된 방송사 한 곳만 수정됩니다. 체크된 시도만 채널별 권역 루프와 방송사 프로필에 반영됩니다." />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="권역" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="{x:Bind ViewModel.Settings.SelectedStation.RegionFiltersText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -476,7 +535,7 @@
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnSpacing="16">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*" /><ColumnDefinition Width="Auto" /></Grid.ColumnDefinitions>
|
||||
<StackPanel Spacing="6"><TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시스템 이벤트 로그" /><TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="송출, 복원, 수신, 검증, 어댑터 동작 로그를 시간순으로 추적합니다." /></StackPanel>
|
||||
<TextBlock Style="{StaticResource ConsoleSectionTitleTextStyle}" Text="시스템 이벤트 로그" />
|
||||
<Button Grid.Column="1" Command="{x:Bind ViewModel.ClearLogsCommand}" Content="로그 비우기" Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</Grid>
|
||||
<Grid ColumnSpacing="12" RowSpacing="12">
|
||||
@@ -492,7 +551,6 @@
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="로그 표시 현황" />
|
||||
<TextBlock FontFamily="Bahnschrift SemiBold" FontSize="18" Foreground="{StaticResource ControlRoomTextPrimaryBrush}" Text="{x:Bind ViewModel.LogFilterSummary, Mode=OneWay}" />
|
||||
<TextBlock Style="{StaticResource ConsoleBodyTextStyle}" Text="정보·경고·오류 중 필요한 수준만 골라 운영 중 노이즈를 줄일 수 있습니다." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
@@ -518,3 +576,5 @@
|
||||
</Grid>
|
||||
</NavigationView>
|
||||
</Window>
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Tornado3_2026Election.Persistence;
|
||||
|
||||
@@ -10,7 +10,7 @@ public sealed class AppState
|
||||
|
||||
public string SelectedStationId { get; set; } = "KNN";
|
||||
|
||||
public string ImageRootPath { get; set; } = @"C:\ElectionImages";
|
||||
public string ImageRootPath { get; set; } = @"C:\Users\y2keu\Downloads\T3_Cut";
|
||||
|
||||
public bool AutoRestoreSchedules { get; set; } = true;
|
||||
|
||||
@@ -48,3 +48,4 @@ public sealed class AppState
|
||||
|
||||
public Dictionary<string, string> StationRegionFilters { get; set; } = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class ChannelScheduleEngine
|
||||
private readonly SemaphoreSlim _executionLock = new(1, 1);
|
||||
private CancellationTokenSource? _playbackCts;
|
||||
private TaskCompletionSource<bool>? _advanceSignal;
|
||||
private Guid? _preferredNextItemId;
|
||||
|
||||
public ChannelScheduleEngine(
|
||||
BroadcastChannel channel,
|
||||
@@ -82,6 +83,7 @@ public sealed class ChannelScheduleEngine
|
||||
item.State = ScheduleQueueItemState.Queued;
|
||||
}
|
||||
|
||||
_preferredNextItemId = null;
|
||||
IsRunning = false;
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
@@ -89,6 +91,7 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_preferredNextItemId = null;
|
||||
foreach (var item in Queue)
|
||||
{
|
||||
item.State = ScheduleQueueItemState.Queued;
|
||||
@@ -106,6 +109,15 @@ public sealed class ChannelScheduleEngine
|
||||
}
|
||||
|
||||
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
||||
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
|
||||
if (activeItem is not null)
|
||||
{
|
||||
activeItem.State = ScheduleQueueItemState.Completed;
|
||||
activeItem.LastError = string.Empty;
|
||||
}
|
||||
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
_advanceSignal?.TrySetResult(true);
|
||||
}
|
||||
|
||||
@@ -119,6 +131,11 @@ public sealed class ChannelScheduleEngine
|
||||
var removed = Queue.Remove(item);
|
||||
if (removed)
|
||||
{
|
||||
if (_preferredNextItemId == item.Id)
|
||||
{
|
||||
_preferredNextItemId = null;
|
||||
}
|
||||
|
||||
RefreshQueueMarkers();
|
||||
}
|
||||
|
||||
@@ -138,26 +155,17 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
public bool PromoteToNext(ChannelScheduleItem? item)
|
||||
{
|
||||
if (item is null)
|
||||
if (item is null || item.State is not (ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var currentIndex = Queue.IndexOf(item);
|
||||
if (currentIndex < 0)
|
||||
if (_preferredNextItemId == item.Id && item.State == ScheduleQueueItemState.Next)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var onAirIndex = Queue.ToList().FindIndex(entry => entry.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
|
||||
var targetIndex = onAirIndex >= 0 ? onAirIndex + 1 : 0;
|
||||
|
||||
if (targetIndex == currentIndex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Queue.Move(currentIndex, Math.Min(targetIndex, Queue.Count - 1));
|
||||
_preferredNextItemId = item.Id;
|
||||
RefreshQueueMarkers();
|
||||
return true;
|
||||
}
|
||||
@@ -300,13 +308,23 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
private ChannelScheduleItem? GetNextPlayableItem()
|
||||
{
|
||||
return Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next);
|
||||
return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)
|
||||
?? Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
|
||||
}
|
||||
|
||||
public void RefreshQueueMarkers()
|
||||
{
|
||||
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
|
||||
var nextItem = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
|
||||
var pendingItems = Queue
|
||||
.Where(item => item != activeItem && item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next)
|
||||
.ToArray();
|
||||
|
||||
var nextItem = pendingItems.FirstOrDefault(item => _preferredNextItemId == item.Id);
|
||||
if (nextItem is null)
|
||||
{
|
||||
_preferredNextItemId = null;
|
||||
nextItem = pendingItems.FirstOrDefault();
|
||||
}
|
||||
|
||||
foreach (var item in Queue)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Tornado3_2026Election.Domain;
|
||||
|
||||
@@ -6,75 +8,20 @@ namespace Tornado3_2026Election.Services;
|
||||
|
||||
public sealed class FormatCatalogService
|
||||
{
|
||||
private readonly IReadOnlyList<FormatTemplateDefinition> _formats =
|
||||
[
|
||||
new FormatTemplateDefinition
|
||||
private static readonly HashSet<string> MediaWallFormats = new(StringComparer.Ordinal)
|
||||
{
|
||||
Id = "normal-national-summary",
|
||||
Name = "노멀 전국 요약",
|
||||
Description = "개표율과 상위 후보 요약을 2컷으로 송출합니다.",
|
||||
RecommendedChannel = BroadcastChannel.Normal,
|
||||
RequiresImage = false,
|
||||
LoopMode = LoopMode.None,
|
||||
Cuts =
|
||||
[
|
||||
new FormatCutDefinition { Name = "오프닝 컷", DurationSeconds = 5 },
|
||||
new FormatCutDefinition { Name = "상위 후보 컷", DurationSeconds = 7 }
|
||||
]
|
||||
},
|
||||
new FormatTemplateDefinition
|
||||
{
|
||||
Id = "normal-regional-loop",
|
||||
Name = "노멀 시도 루프",
|
||||
Description = "방송사 지역 필터 기준으로 시도별 컷을 반복 송출합니다.",
|
||||
RecommendedChannel = BroadcastChannel.Normal,
|
||||
RequiresImage = false,
|
||||
LoopMode = LoopMode.StationRegions,
|
||||
Cuts =
|
||||
[
|
||||
new FormatCutDefinition { Name = "지역 루프 컷", DurationSeconds = 4 }
|
||||
]
|
||||
},
|
||||
new FormatTemplateDefinition
|
||||
{
|
||||
Id = "top-left-status",
|
||||
Name = "좌상단 현황",
|
||||
Description = "좌상단 영역에 간단한 현황 템플릿을 송출합니다.",
|
||||
RecommendedChannel = BroadcastChannel.TopLeft,
|
||||
RequiresImage = false,
|
||||
LoopMode = LoopMode.None,
|
||||
Cuts =
|
||||
[
|
||||
new FormatCutDefinition { Name = "좌상단 단일 컷", DurationSeconds = 6 }
|
||||
]
|
||||
},
|
||||
new FormatTemplateDefinition
|
||||
{
|
||||
Id = "bottom-candidate-bar",
|
||||
Name = "하단 후보 바",
|
||||
Description = "하단 라인에 후보별 득표 상황을 표시합니다.",
|
||||
RecommendedChannel = BroadcastChannel.Bottom,
|
||||
RequiresImage = false,
|
||||
LoopMode = LoopMode.None,
|
||||
Cuts =
|
||||
[
|
||||
new FormatCutDefinition { Name = "하단 바 컷", DurationSeconds = 8 }
|
||||
]
|
||||
},
|
||||
new FormatTemplateDefinition
|
||||
{
|
||||
Id = "videowall-winner-card",
|
||||
Name = "비디오월 당선 카드",
|
||||
Description = "당선/확실 후보를 사진 포함 카드로 송출합니다.",
|
||||
RecommendedChannel = BroadcastChannel.VideoWall,
|
||||
RequiresImage = true,
|
||||
LoopMode = LoopMode.None,
|
||||
Cuts =
|
||||
[
|
||||
new FormatCutDefinition { Name = "당선 카드 컷", DurationSeconds = 9 }
|
||||
]
|
||||
}
|
||||
];
|
||||
"민방_타이틀",
|
||||
"사전_역대투표율_loop",
|
||||
"투표율_시도별",
|
||||
"1-2위_광역단체장_5760",
|
||||
"1-3위_기초단체장_5760",
|
||||
"모든후보_광역단체장_5760",
|
||||
"이시각1위_광역단체장",
|
||||
"판세_기초단체장_5760",
|
||||
"광역의원표"
|
||||
};
|
||||
|
||||
private readonly IReadOnlyList<FormatTemplateDefinition> _formats = BuildFormats();
|
||||
|
||||
public IReadOnlyList<FormatTemplateDefinition> GetAll() => _formats;
|
||||
|
||||
@@ -85,6 +32,201 @@ public sealed class FormatCatalogService
|
||||
|
||||
public FormatTemplateDefinition? FindById(string formatId)
|
||||
{
|
||||
return _formats.FirstOrDefault(format => string.Equals(format.Id, formatId, System.StringComparison.Ordinal));
|
||||
return _formats.FirstOrDefault(format => string.Equals(format.Id, formatId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FormatTemplateDefinition> BuildFormats()
|
||||
{
|
||||
List<FormatTemplateDefinition> formats = [];
|
||||
|
||||
formats.AddRange(CreateFormats(
|
||||
BroadcastChannel.Bottom,
|
||||
"Elect2026_Bottom_민방",
|
||||
8,
|
||||
"1-2위_광역단체장",
|
||||
"1-2위_기초단체장",
|
||||
"1-3위_광역단체장",
|
||||
"1-3위_기초단체장",
|
||||
"1위_광역단체장",
|
||||
"1위_기초단체장",
|
||||
"당선_광역단체장",
|
||||
"당선_광역의원",
|
||||
"당선_기초단체장",
|
||||
"당선_기초의원",
|
||||
"사전투표율",
|
||||
"전후보_광역단체장",
|
||||
"전후보_교육감",
|
||||
"전후보_기초단체장",
|
||||
"투표율"));
|
||||
|
||||
formats.AddRange(CreateFormats(
|
||||
BroadcastChannel.Normal,
|
||||
"Elect2026_Normal_민방",
|
||||
10,
|
||||
"1-2위_ani_광역단체장",
|
||||
"1-2위_ani_기초단체장",
|
||||
"1-2위_ani_기초단체장_5760",
|
||||
"1-2위_ani_기초단체장_L",
|
||||
"1-2위_광역단체장",
|
||||
"1-2위_광역단체장_5760",
|
||||
"1-2위_광역단체장_L",
|
||||
"1-2위_광역단체장_시도별영상",
|
||||
"1-2위_교육감",
|
||||
"1-2위_기초단체장",
|
||||
"1-2위_기초단체장_시도별영상",
|
||||
"1-2위_보궐선거",
|
||||
"1-3위_ani_광역단체장",
|
||||
"1-3위_ani_기초단체장",
|
||||
"1-3위_기초단체장_5760",
|
||||
"1-3위_기초단체장_L",
|
||||
"1-3위_기초단체장_L_1",
|
||||
"1-3위_보궐선거",
|
||||
"경력_광역단체장_in",
|
||||
"경력_기초단체장_in",
|
||||
"광역의원표",
|
||||
"광역의원표_HD",
|
||||
"광역의원표_L",
|
||||
"광역의원표_L_1",
|
||||
"기초의원표",
|
||||
"기초의원표_HD",
|
||||
"기초의원표_L",
|
||||
"기초의원표_L_1",
|
||||
"당선_광역단체장",
|
||||
"당선_광역단체장_HD",
|
||||
"당선_광역단체장_L",
|
||||
"당선_광역의원",
|
||||
"당선_광역의원_HD",
|
||||
"당선_광역의원_L",
|
||||
"당선_교육감",
|
||||
"당선_교육감_HD",
|
||||
"당선_교육감_L",
|
||||
"당선_기초단체장",
|
||||
"당선_기초단체장_HD",
|
||||
"당선_기초단체장_L",
|
||||
"당선_기초의원",
|
||||
"당선_기초의원_HD",
|
||||
"당선_기초의원_L",
|
||||
"모든후보_광역단체장",
|
||||
"모든후보_광역단체장_5760",
|
||||
"모든후보_광역단체장_5760_END",
|
||||
"모든후보_광역단체장_END",
|
||||
"모든후보_광역단체장_L",
|
||||
"모든후보_광역단체장_L_END",
|
||||
"모든후보_교육감",
|
||||
"모든후보_교육감_5760",
|
||||
"모든후보_교육감_5760_END",
|
||||
"모든후보_교육감_END",
|
||||
"모든후보_교육감_L",
|
||||
"모든후보_교육감_L_END",
|
||||
"모든후보_기초단체장",
|
||||
"모든후보_기초단체장_5760",
|
||||
"모든후보_기초단체장_5760_END",
|
||||
"모든후보_기초단체장_END",
|
||||
"모든후보_기초단체장_L",
|
||||
"모든후보_기초단체장_L_END",
|
||||
"민방_타이틀",
|
||||
"민방_타이틀_1920",
|
||||
"민방_타이틀_1920_notext",
|
||||
"민방_타이틀_5760_nologo",
|
||||
"민방_타이틀_L",
|
||||
"민방_타이틀_L_nologo",
|
||||
"사전_역대당선자",
|
||||
"사전_역대당선자_교육감",
|
||||
"사전_역대당선자_기초단체장",
|
||||
"사전_역대투표율",
|
||||
"사전_역대투표율_loop",
|
||||
"사전_역대투표율_5760",
|
||||
"사전_역대투표율_L",
|
||||
"사전_역대투표율_L_1",
|
||||
"역대시도판세_광역단체장",
|
||||
"역대시도판세_기초단체장",
|
||||
"이시각1위_광역단체장",
|
||||
"이시각1위_광역단체장_HD",
|
||||
"이시각1위_광역단체장_L",
|
||||
"이시각1위_기초단체장",
|
||||
"이시각1위_기초단체장_HD",
|
||||
"이시각1위_기초단체장_L",
|
||||
"접전_광역단체장",
|
||||
"접전_기초단체장",
|
||||
"초접전_광역단체장",
|
||||
"초접전_기초단체장",
|
||||
"투표율_사진",
|
||||
"투표율_선거구별 사전",
|
||||
"투표율_시도별",
|
||||
"투표율_시도별_L",
|
||||
"투표율_영상",
|
||||
"판세_광역단체장",
|
||||
"판세_기초단체장",
|
||||
"판세_기초단체장_5760",
|
||||
"판세_기초단체장_7680"));
|
||||
|
||||
formats.AddRange(CreateFormats(
|
||||
BroadcastChannel.TopLeft,
|
||||
"Elect2026_Top_민방",
|
||||
6,
|
||||
"광역단체장_2인",
|
||||
"광역단체장_2인_텍스트",
|
||||
"기초단체장_2인",
|
||||
"기초단체장_2인_텍스트",
|
||||
"투표율",
|
||||
"투표율_선거구별",
|
||||
"판세_광역단체장",
|
||||
"판세_광역의원",
|
||||
"판세_교육감",
|
||||
"판세_기초단체장",
|
||||
"판세_기초의원"));
|
||||
|
||||
return formats;
|
||||
}
|
||||
|
||||
private static IEnumerable<FormatTemplateDefinition> CreateFormats(
|
||||
BroadcastChannel channel,
|
||||
string relativeFolder,
|
||||
double defaultCutDurationSeconds,
|
||||
params string[] baseNames)
|
||||
{
|
||||
foreach (var baseName in baseNames)
|
||||
{
|
||||
var isAvailableInBothPhases = IsAvailableInBothPhases(baseName);
|
||||
var isPreElectionOnlyFormat = !isAvailableInBothPhases && IsPreElectionOnlyFormat(baseName);
|
||||
|
||||
yield return new FormatTemplateDefinition
|
||||
{
|
||||
Id = Path.Combine(relativeFolder, baseName),
|
||||
Name = baseName,
|
||||
Description = $"{relativeFolder} 컷",
|
||||
RecommendedChannel = ResolveRecommendedChannel(channel, baseName),
|
||||
RequiresImage = false,
|
||||
SupportsPreElection = isAvailableInBothPhases || isPreElectionOnlyFormat,
|
||||
SupportsCounting = isAvailableInBothPhases || !isPreElectionOnlyFormat,
|
||||
RequiresCandidateData = !isPreElectionOnlyFormat,
|
||||
LoopMode = LoopMode.None,
|
||||
Cuts =
|
||||
[
|
||||
new FormatCutDefinition
|
||||
{
|
||||
Name = baseName,
|
||||
DurationSeconds = defaultCutDurationSeconds
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPreElectionOnlyFormat(string baseName)
|
||||
{
|
||||
return baseName.Contains("투표율", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsAvailableInBothPhases(string baseName)
|
||||
{
|
||||
return baseName.StartsWith("사전_역대당선자", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static BroadcastChannel ResolveRecommendedChannel(BroadcastChannel fallbackChannel, string baseName)
|
||||
{
|
||||
return MediaWallFormats.Contains(baseName)
|
||||
? BroadcastChannel.VideoWall
|
||||
: fallbackChannel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,20 @@ namespace Tornado3_2026Election.Services;
|
||||
|
||||
public interface ITornado3Adapter
|
||||
{
|
||||
string BackendName { get; }
|
||||
|
||||
bool IsLiveCg { get; }
|
||||
|
||||
bool IsConnected { get; }
|
||||
|
||||
string ConnectionTarget { get; }
|
||||
|
||||
TornadoConnectionState State { get; }
|
||||
|
||||
event EventHandler<TornadoConnectionState>? StateChanged;
|
||||
|
||||
event EventHandler? ConnectionChanged;
|
||||
|
||||
Task EnsureConnectedAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task ApplyCutAsync(
|
||||
|
||||
267
Tornado3_2026Election/Services/KarismaEventHandler.cs
Normal file
267
Tornado3_2026Election/Services/KarismaEventHandler.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using KAsyncEngineLib;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
public class KarismaEventHandler : KAEventHandler
|
||||
{
|
||||
private readonly LogService _logService;
|
||||
private readonly Action<int>? _onConnect;
|
||||
private readonly Action<int>? _onClose;
|
||||
|
||||
public KarismaEventHandler(LogService logService, Action<int>? onConnect = null, Action<int>? onClose = null)
|
||||
{
|
||||
_logService = logService;
|
||||
_onConnect = onConnect;
|
||||
_onClose = onClose;
|
||||
}
|
||||
|
||||
private void LogResult(string callbackName, eKResult result, string? details = null)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(details)
|
||||
? $"CG callback {callbackName}: {result} ({(int)result})"
|
||||
: $"CG callback {callbackName}: {result} ({(int)result}) / {details}";
|
||||
|
||||
if (result == eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
_logService.Info(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logService.Warning(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void LogEvent(string eventName, string? details = null)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(details)
|
||||
? $"CG event {eventName}"
|
||||
: $"CG event {eventName}: {details}";
|
||||
_logService.Info(message);
|
||||
}
|
||||
public void OnLoadScene(eKResult Result, string SceneName) => LogResult(nameof(OnLoadScene), Result, $"scene={SceneName}");
|
||||
public void OnLoadSceneForce(eKResult Result, string SceneName) => LogResult(nameof(OnLoadSceneForce), Result, $"scene={SceneName}");
|
||||
public void OnLogMessage(string LogMessage) => LogEvent(nameof(OnLogMessage), LogMessage);
|
||||
public void OnMessageNo(uint MessageNo) => LogEvent(nameof(OnMessageNo), $"messageNo={MessageNo}");
|
||||
public void OnConnect(int ErrorCode) { if (ErrorCode == 0) { _logService.Info("CG callback OnConnect: success (errorCode=0)"); } else { _logService.Error($"CG callback OnConnect: failed (errorCode={ErrorCode})"); } _onConnect?.Invoke(ErrorCode); }
|
||||
public void OnClose(int ErrorCode) { if (ErrorCode == 0) { _logService.Info("CG callback OnClose: closed cleanly (errorCode=0)"); } else { _logService.Warning($"CG callback OnClose: closed with errorCode={ErrorCode}"); } _onClose?.Invoke(ErrorCode); }
|
||||
public void OnBeginTransaction(eKResult Result) => LogResult(nameof(OnBeginTransaction), Result);
|
||||
public void OnEndTransaction(eKResult Result) => LogResult(nameof(OnEndTransaction), Result);
|
||||
public void OnHeartBeat(eKResult Result) => LogResult(nameof(OnHeartBeat), Result);
|
||||
virtual public void OnUnloadAll(eKResult Result) { }
|
||||
virtual public void OnSetTrialPlayoutMode(eKResult Result) { }
|
||||
virtual public void OnCheckVersion(eKResult Result, string ServerVersion, string SDKVersion) { }
|
||||
virtual public void OnSetAudioOutput(eKResult Result) { }
|
||||
public void OnScenePrepare(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepare), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnScenePrepareEx(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnScenePrepareEx), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnPlay(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlay), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnPlayOut(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayOut), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnStop(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnStop), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnStopAll(eKResult Result) => LogResult(nameof(OnStopAll), Result);
|
||||
public void OnPause(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPause), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnResume(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnResume), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnQueryIsOnAir(eKResult Result, int OutputChannelIndex, int LayerNo, int bOnAir) => LogResult(nameof(OnQueryIsOnAir), Result, $"output={OutputChannelIndex} layer={LayerNo} onAir={(bOnAir != 0)}");
|
||||
public void OnTrigger(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnTrigger), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnScenePlayingStarted(string SceneName, int OutputChannelIndex, int LayerNo) => LogEvent(nameof(OnScenePlayingStarted), $"scene={SceneName} output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnScenePlayed(string SceneName, int OutputChannelIndex, int LayerNo) => LogEvent(nameof(OnScenePlayed), $"scene={SceneName} output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnSceneAnimationPlayed(string SceneName, int OutputChannelIndex, int LayerNo, string AnimationName) => LogEvent(nameof(OnSceneAnimationPlayed), $"scene={SceneName} output={OutputChannelIndex} layer={LayerNo} animation={AnimationName}");
|
||||
public void OnScenePaused(string SceneName, int OutputChannelIndex, int LayerNo, int bLastPause) => LogEvent(nameof(OnScenePaused), $"scene={SceneName} output={OutputChannelIndex} layer={LayerNo} lastPause={(bLastPause != 0)}");
|
||||
virtual public void OnSceneSaved(eKResult Result, string FileName) { }
|
||||
public void OnTriggerObject(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnTriggerObject), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
virtual public void OnResumeBackground(eKResult Result, int OutputChannelIndex, int LayerNo) { }
|
||||
virtual public void OnSaveMixedPreviewImage(eKResult Result, int OutputChannelIndex, int LayerNo) { }
|
||||
public void OnPlayDirect(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayDirect), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnCutIn(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnCutIn), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnCutOut(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnCutOut), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnClearNextPreview(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnClearNextPreview), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnPlayRange(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayRange), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
virtual public void OnQueryPlaybackRangeCount(eKResult Result, string SceneName, int PlaybackRangeCount) { }
|
||||
virtual public void OnQueryPlaybackRange(eKResult Result, string SceneName, int PlaybackRangeNo, int Start, int End) { }
|
||||
virtual public void OnQueryOutputChannelIndex(eKResult Result, string SceneName, int OutputChannelIndex) { }
|
||||
public void OnPlayInNextPreview(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayInNextPreview), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
public void OnPlayOutNextPreview(eKResult Result, int OutputChannelIndex, int LayerNo) => LogResult(nameof(OnPlayOutNextPreview), Result, $"output={OutputChannelIndex} layer={LayerNo}");
|
||||
virtual public void OnSetBackgroundFill(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetBackgroundTexture(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetBackgroundVideo(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetBackgroundLiveIn(eKResult Result, string SceneName) { }
|
||||
virtual public void OnUseBackground(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetBackgroundVideoPlayInfo(eKResult Result, string SceneName) { }
|
||||
virtual public void OnQueryBackgroundVideoPlayInfo(eKResult Result, string SceneName, ref sKVideoPlayInfo pVideoPlayInfo) { }
|
||||
virtual public void OnSetSceneEffectType(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSaveSceneImage(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSaveScene(eKResult Result, string SceneName) { }
|
||||
virtual public void OnUnloadScene(eKResult Result, string SceneName) { }
|
||||
virtual public void OnReloadScene(eKResult Result, string SceneName) { }
|
||||
virtual public void OnUpdateTextures(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetSceneAudioFile(eKResult Result, string SceneName) { }
|
||||
virtual public void OnEnableSceneAudio(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetSceneDuration(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetBackgroundPauseType(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetBackgroundChangeType(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetBackgroundPauseAtZeroFrameAsStandBy(eKResult Result, string SceneName) { }
|
||||
virtual public void OnResetDuration(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetDuration(eKResult Result, string SceneName) { }
|
||||
virtual public void OnAddObject(eKResult Result, string SceneName) { }
|
||||
virtual public void OnAddCloneObject(eKResult Result, string SceneName) { }
|
||||
virtual public void OnUpdateThumbnail(eKResult Result, string SceneName) { }
|
||||
virtual public void OnExportVideo(eKResult Result, string SceneName) { }
|
||||
virtual public void OnStopVideoExporting(eKResult Result) { }
|
||||
virtual public void OnQueryVideoExportingProgress(eKResult Result, string TargetName, int CurrentFrame, int TotalFrame) { }
|
||||
virtual public void OnFinishedVideoExporting(eKResult Result, string FileName) { }
|
||||
virtual public void OnAddPause(eKResult Result, string SceneName) { }
|
||||
virtual public void OnDeletePause(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetPause(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetPauseWithIndex(eKResult Result, string SceneName) { }
|
||||
virtual public void OnDeletePauseWithIndex(eKResult Result, string SceneName) { }
|
||||
virtual public void OnQueryPauseCount(eKResult Result, string SceneName, int PauseCount) { }
|
||||
virtual public void OnQueryObjectInfos(eKResult Result, string SceneName, KAObjectInfos pObjectInfos) { }
|
||||
virtual public void OnQueryAnimationNames(eKResult Result, string SceneName, KAStrings pAnimationNames) { }
|
||||
virtual public void OnQueryAnimationCount(eKResult Result, string SceneName, int AnimationCount) { }
|
||||
virtual public void OnQueryObjectInfosByScreenPoint(eKResult Result, KAObjectInfos pObjectInfos) { }
|
||||
virtual public void OnQuerySceneEffectType(eKResult Result, string SceneName, int bInEffect, eKEffectType EffectType, int Duration) { }
|
||||
virtual public void OnQueryDuration(eKResult Result, string SceneName, string AnimationName, int Duration) { }
|
||||
virtual public void OnQueryContentsOfTextObjects(eKResult Result, string SceneName, KAStrings pTexts) { }
|
||||
virtual public void OnSetStyleColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetStyleTexture(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetFaceTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetEdgeTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetShadowTextColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetVisible(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetValue(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetValue), Result, $"scene={SceneName} object={ObjectName}");
|
||||
virtual public void OnAddText(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnStoreTextStyle(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetTextStyle(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnEditText(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetFont(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetTextRange(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnResetTextRange(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnQueryObjectType(eKResult Result, string SceneName, string ObjectName, eKObjectType ObjectType) { }
|
||||
virtual public void OnSetChartCSVFile(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetChartCellData(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnQueryChartDataTable(eKResult Result, string SceneName, string ObjectName, KAChartDataTable Table) { }
|
||||
virtual public void OnQuerySize(eKResult Result, string SceneName, string ObjectName, float Width, float Height) { }
|
||||
virtual public void OnSetSize(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterNumberKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetPositionKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetRotationKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetScaleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCylinderAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetSphereAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCircleAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCountDown(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetPosition(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetRotation(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetScale(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnAddPathPoint(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnClearPathPoints(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnAddPathShapePoint(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnClearPathShapePoints(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnQueryScrollRemainingDistance(eKResult Result, string SceneName, string ObjectName, int ScrollRemainingDistance) { }
|
||||
virtual public void OnQueryScrollChildRemainingDistance(eKResult Result, string SceneName, string ObjectName, string ChildName, int ScrollRemainingDistance) { }
|
||||
virtual public void OnAddScrollObject(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnAdjustScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetScrollSpeed(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetVariableName(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetLoftPositionKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetChangeOut(eKResult Result, string SceneName) { }
|
||||
virtual public void OnModifyPathPoint(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnInitScrollObject(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterInfo(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterNumber(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterRange(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterRemainingTime(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCounterElapsedTime(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSaveObjectImage(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetPositionOfPathAnimation(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetPositionKeyOfPathAnimation(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetStartFrame(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetObjectEffectType(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetObjectOutEffectDelay(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetColorKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetEmissiveColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetEmissiveColorKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetTransparencyOpacity(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetTransparencyOpacityKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetExposure(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetExposureKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureType(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureFile(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureOffset(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureOffsetKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureTiling(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureTilingKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureRotation(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureRotationKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureOpacity(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureOpacityKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnQueryGroupType(eKResult Result, string SceneName, string ObjectName, eKGroupType GroupType) { }
|
||||
virtual public void OnQueryImageType(eKResult Result, string SceneName, string ObjectName, eKImageType ImageType) { }
|
||||
virtual public void OnSetVideoPlayInfo(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnQueryVideoPlayInfo(eKResult Result, string SceneName, string ObjectName, ref sKVideoPlayInfo pVideoPlayInfo) { }
|
||||
virtual public void OnQueryIs3D(eKResult Result, string SceneName, string ObjectName, int b3D) { }
|
||||
virtual public void OnQueryPosition(eKResult Result, string SceneName, string ObjectName, float X, float Y, float Z) { }
|
||||
virtual public void OnSetImageType(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMemo(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnQueryMemo(eKResult Result, string SceneName, string ObjectName, string Memo) { }
|
||||
virtual public void OnQueryFont(eKResult Result, string SceneName, string ObjectName, ref sKFont Param) { }
|
||||
virtual public void OnSetImageOriginalSize(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnApplyChangeEffectLibrary(eKResult Result, string SceneName) { }
|
||||
virtual public void OnApplyObjectLibrary(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnApplyTextureEffectLibrary(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetTableValue(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetTableColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnQueryTableValue(eKResult Result, string SceneName, string ObjectName, int Row, int Column, string Value) { }
|
||||
virtual public void OnQueryTableValues(eKResult Result, string SceneName, string ObjectName, KATableValues pValues) { }
|
||||
virtual public void OnSetPathShapeOutlineThickness(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnEnablePathShapeOutline(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetPlaybackCamera(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetMaterialTextureVideoPlayInfo(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnQueryMaterialTextureVideoPlayInfo(eKResult Result, string SceneName, string ObjectName, ref sKVideoPlayInfo VideoPlayInfo) { }
|
||||
virtual public void OnQueryVideoFormat(eKResult Result, ref sKVideoFormat VideoFormat) { }
|
||||
virtual public void OnQueryLiveStreamingStatus(eKResult Result, string StreamingURI, eKLiveStreamingStatus Status) { }
|
||||
virtual public void OnPreloadLiveStreaming(eKResult Result, string StreamingURI) { }
|
||||
virtual public void OnReleaseLiveStreaming(eKResult Result, string StreamingURI) { }
|
||||
public void OnUpdateImageResource(eKResult Result) => LogResult(nameof(OnUpdateImageResource), Result);
|
||||
public void OnQueryLayerCount(eKResult Result, int LayerCount) => LogResult(nameof(OnQueryLayerCount), Result, $"layerCount={LayerCount}");
|
||||
virtual public void OnSetLayerViewportRate(eKResult Result, int OutputChannelIndex, int LayerNo) { }
|
||||
virtual public void OnSetLayerViewportRateEx(eKResult Result, int OutputChannelIndex, int LayerNo) { }
|
||||
virtual public void OnSetFitting(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetFittingOffset(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetFittingScale(eKResult Result, string SceneName) { }
|
||||
virtual public void OnSetLightColor(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnEnableLight(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetDirectionalLight(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetPointLight(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetSpotLight(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetInfinitePointLight(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetMaterialTextureLiveStreamingURI(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetBackgroundLiveStreamingURI(eKResult Result, string SceneName) { }
|
||||
virtual public void OnLoadProject(eKResult Result, string FilePath, string AliasName) { }
|
||||
virtual public void OnNewProject(eKResult Result, string AliasName) { }
|
||||
virtual public void OnUnloadAllProject(eKResult Result) { }
|
||||
virtual public void OnSaveProject(eKResult Result, string AliasName) { }
|
||||
virtual public void OnQuerySceneItemCount(eKResult Result, string AliasName, int SceneItemCount) { }
|
||||
virtual public void OnQuerySceneItemInfos(eKResult Result, string AliasName, KASceneItemInfos SceneItemInfos) { }
|
||||
virtual public void OnAddSceneItem(eKResult Result, string AliasName, int Index) { }
|
||||
virtual public void OnInsertSceneItem(eKResult Result, string AliasName) { }
|
||||
virtual public void OnDeleteSceneItem(eKResult Result, string AliasNAme) { }
|
||||
virtual public void OnQueryProjectFormat(eKResult Result, ref sKVideoFormat ProjectFormat) { }
|
||||
virtual public void OnSetTimecode(eKResult Result, string AliasName) { }
|
||||
virtual public void OnSetTimecodeInOut(eKResult Result, string AliasName) { }
|
||||
virtual public void OnSetTimecodeTrack(eKResult Result, string AliasName) { }
|
||||
virtual public void OnSetTimecodeInOutType(eKResult Result, string AliasName) { }
|
||||
virtual public void OnDeleteTimecode(eKResult Result, string AliasName) { }
|
||||
virtual public void OnQueryTimecode(eKResult Result, int TrackNo, int In, int Out, int bOnTrack) { }
|
||||
virtual public void OnUnloadProject(eKResult Result, string AliasName) { }
|
||||
virtual public void OnEnableSyncWithSceneEffect(eKResult Result, string AliasName) { }
|
||||
virtual public void OnExportProjectVideo(eKResult Result, string AliasName) { }
|
||||
virtual public void OnExportSceneImage(eKResult Result, string SceneName) { }
|
||||
virtual public void OnStartVideoCapture(eKResult Result) { }
|
||||
virtual public void OnStopVideoCapture(eKResult Result) { }
|
||||
virtual public void OnCaptureImage(eKResult Result) { }
|
||||
|
||||
}
|
||||
393
Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
Normal file
393
Tornado3_2026Election/Services/KarismaTornado3Adapter.cs
Normal file
@@ -0,0 +1,393 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tornado3_2026Election.Domain;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
||||
{
|
||||
private const int DefaultKarismaPort = 30001;
|
||||
|
||||
private readonly TornadoManager _manager;
|
||||
private readonly LogService _logService;
|
||||
private readonly Func<string> _t3CutPathProvider;
|
||||
private readonly IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> _bindings;
|
||||
private readonly string _connectionTarget;
|
||||
private readonly Dictionary<BroadcastChannel, string> _pendingScenes = new();
|
||||
private readonly Dictionary<BroadcastChannel, bool> _channelOnAir = new();
|
||||
private TornadoConnectionState _state = TornadoConnectionState.Idle;
|
||||
private bool _disposed;
|
||||
|
||||
private KarismaTornado3Adapter(
|
||||
TornadoManager manager,
|
||||
LogService logService,
|
||||
Func<string> t3CutPathProvider,
|
||||
string connectionTarget,
|
||||
IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> bindings)
|
||||
{
|
||||
_manager = manager;
|
||||
_logService = logService;
|
||||
_t3CutPathProvider = t3CutPathProvider;
|
||||
_connectionTarget = connectionTarget;
|
||||
_bindings = bindings;
|
||||
_manager.ConnectionChanged += (_, _) => ConnectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public string BackendName => "Karisma CG";
|
||||
|
||||
public bool IsLiveCg => true;
|
||||
|
||||
public bool IsConnected => _manager.IsConnected;
|
||||
|
||||
public string ConnectionTarget => _connectionTarget;
|
||||
|
||||
public TornadoConnectionState State
|
||||
{
|
||||
get => _state;
|
||||
private set
|
||||
{
|
||||
if (_state == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_state = value;
|
||||
StateChanged?.Invoke(this, value);
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler<TornadoConnectionState>? StateChanged;
|
||||
|
||||
public event EventHandler? ConnectionChanged;
|
||||
|
||||
public static ITornado3Adapter CreateOrFallback(LogService logService, Func<string> t3CutPathProvider)
|
||||
{
|
||||
return TryCreate(logService, t3CutPathProvider, out var adapter)
|
||||
? adapter
|
||||
: new MockTornado3Adapter(logService);
|
||||
}
|
||||
|
||||
public static bool TryCreate(LogService logService, Func<string> t3CutPathProvider, out ITornado3Adapter adapter)
|
||||
{
|
||||
var host = Environment.GetEnvironmentVariable("TORNADO_KARISMA_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
host = "127.0.0.1";
|
||||
}
|
||||
|
||||
if (!int.TryParse(Environment.GetEnvironmentVariable("TORNADO_KARISMA_PORT"), out var port) || port <= 0)
|
||||
{
|
||||
port = DefaultKarismaPort;
|
||||
logService.Info($"Karisma adapter using default port {DefaultKarismaPort}.");
|
||||
}
|
||||
|
||||
var t3CutPath = t3CutPathProvider();
|
||||
if (string.IsNullOrWhiteSpace(t3CutPath) || !Directory.Exists(t3CutPath))
|
||||
{
|
||||
logService.Warning("Karisma adapter disabled: set a valid T3_Cut path in Settings.");
|
||||
adapter = new MockTornado3Adapter(logService);
|
||||
return false;
|
||||
}
|
||||
|
||||
var manager = new TornadoManager(host, port, logService);
|
||||
adapter = new KarismaTornado3Adapter(
|
||||
manager,
|
||||
logService,
|
||||
t3CutPathProvider,
|
||||
$"{host}:{port}",
|
||||
BuildBindings());
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task EnsureConnectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
async () =>
|
||||
{
|
||||
await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
State = TornadoConnectionState.Ready;
|
||||
},
|
||||
"connect",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ApplyCutAsync(
|
||||
BroadcastChannel channel,
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
BroadcastStationProfile station,
|
||||
string imageRootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
async () =>
|
||||
{
|
||||
var binding = ResolveBinding(channel);
|
||||
var t3CutPath = ResolveT3CutPath();
|
||||
var resolvedScene = ResolveScene(template, t3CutPath, IsChannelOnAir(channel));
|
||||
var values = BuildObjectValues(template, cut, snapshot, station, t3CutPath);
|
||||
|
||||
State = TornadoConnectionState.Sending;
|
||||
await _manager.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _manager.LoadSceneAsync(resolvedScene.Path, resolvedScene.Alias, cancellationToken).ConfigureAwait(false);
|
||||
await _manager.ApplyValuesAsync(resolvedScene.Alias, values, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_pendingScenes[channel] = resolvedScene.Alias;
|
||||
_logService.Info($"[{channel}] Karisma scene prepared alias={resolvedScene.Alias} output={binding.OutputChannelIndex}:{binding.LayerNo}");
|
||||
},
|
||||
$"apply {template.Id}/{cut.Name}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
async () =>
|
||||
{
|
||||
if (!_pendingScenes.TryGetValue(channel, out var sceneAlias))
|
||||
{
|
||||
_logService.Warning($"[{channel}] No Karisma scene pending for Prepare.");
|
||||
return;
|
||||
}
|
||||
|
||||
var binding = ResolveBinding(channel);
|
||||
await _manager.PrepareAsync(binding.OutputChannelIndex, binding.LayerNo, sceneAlias, cancellationToken).ConfigureAwait(false);
|
||||
State = TornadoConnectionState.Ready;
|
||||
},
|
||||
$"prepare {channel}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
async () =>
|
||||
{
|
||||
var binding = ResolveBinding(channel);
|
||||
await _manager.PlayAsync(binding.OutputChannelIndex, binding.LayerNo, cutIn: false, cancellationToken).ConfigureAwait(false);
|
||||
_channelOnAir[channel] = true;
|
||||
State = TornadoConnectionState.OnAir;
|
||||
},
|
||||
$"take {channel}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
async () =>
|
||||
{
|
||||
var binding = ResolveBinding(channel);
|
||||
await _manager.PlayOutAsync(binding.OutputChannelIndex, binding.LayerNo, cutOut: false, cancellationToken).ConfigureAwait(false);
|
||||
_channelOnAir[channel] = false;
|
||||
State = TornadoConnectionState.Idle;
|
||||
},
|
||||
$"out {channel}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_manager.Dispose();
|
||||
}
|
||||
|
||||
private async Task ExecuteAsync(Func<Task> action, string actionName, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await action().ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
State = TornadoConnectionState.Error;
|
||||
_logService.Error($"Karisma adapter failed to {actionName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private KarismaChannelBinding ResolveBinding(BroadcastChannel channel)
|
||||
{
|
||||
return _bindings.TryGetValue(channel, out var binding)
|
||||
? binding
|
||||
: throw new InvalidOperationException($"No Karisma binding configured for channel: {channel}");
|
||||
}
|
||||
|
||||
private string ResolveT3CutPath()
|
||||
{
|
||||
var path = _t3CutPathProvider();
|
||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||
{
|
||||
throw new DirectoryNotFoundException("T3_Cut path is not configured or does not exist.");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private bool IsChannelOnAir(BroadcastChannel channel)
|
||||
{
|
||||
return _channelOnAir.TryGetValue(channel, out var isOnAir) && isOnAir;
|
||||
}
|
||||
|
||||
private static ResolvedScene ResolveScene(FormatTemplateDefinition template, string t3CutPath, bool useLoop)
|
||||
{
|
||||
var baseScenePath = Path.Combine(t3CutPath, template.Id + ".tscn");
|
||||
var loopScenePath = Path.Combine(t3CutPath, template.Id + "_loop.tscn");
|
||||
|
||||
string selectedPath;
|
||||
if (useLoop && File.Exists(loopScenePath))
|
||||
{
|
||||
selectedPath = loopScenePath;
|
||||
}
|
||||
else if (File.Exists(baseScenePath))
|
||||
{
|
||||
selectedPath = baseScenePath;
|
||||
}
|
||||
else if (File.Exists(loopScenePath))
|
||||
{
|
||||
selectedPath = loopScenePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new FileNotFoundException($"Karisma cut file was not found for '{template.Id}'.", baseScenePath);
|
||||
}
|
||||
|
||||
return new ResolvedScene(
|
||||
selectedPath,
|
||||
template.Id.Replace('\\', '_').Replace('/', '_'));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<BroadcastChannel, KarismaChannelBinding> BuildBindings()
|
||||
{
|
||||
return new Dictionary<BroadcastChannel, KarismaChannelBinding>
|
||||
{
|
||||
[BroadcastChannel.Normal] = ParseBinding("TORNADO_KARISMA_BIND_NORMAL", new KarismaChannelBinding(0, 0)),
|
||||
[BroadcastChannel.TopLeft] = ParseBinding("TORNADO_KARISMA_BIND_TOPLEFT", new KarismaChannelBinding(0, 1)),
|
||||
[BroadcastChannel.Bottom] = ParseBinding("TORNADO_KARISMA_BIND_BOTTOM", new KarismaChannelBinding(0, 2)),
|
||||
[BroadcastChannel.VideoWall] = ParseBinding("TORNADO_KARISMA_BIND_VIDEOWALL", new KarismaChannelBinding(1, 0))
|
||||
};
|
||||
}
|
||||
|
||||
private static KarismaChannelBinding ParseBinding(string environmentVariableName, KarismaChannelBinding fallback)
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(environmentVariableName);
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var parts = raw.Split(':', ',', ';');
|
||||
return parts.Length >= 2 &&
|
||||
int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var outputChannelIndex) &&
|
||||
int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var layerNo)
|
||||
? new KarismaChannelBinding(outputChannelIndex, layerNo)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildObjectValues(
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
BroadcastStationProfile station,
|
||||
string t3CutPath)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["TemplateId"] = template.Id,
|
||||
["TemplateName"] = template.Name,
|
||||
["CutName"] = cut.Name,
|
||||
["Title"] = template.Name,
|
||||
["StationId"] = station.Id,
|
||||
["StationName"] = station.Name,
|
||||
["StationLogoPath"] = station.LogoAssetPath,
|
||||
["StationRegions"] = string.Join(", ", station.RegionFilters),
|
||||
["BroadcastPhase"] = snapshot.BroadcastPhase.ToString(),
|
||||
["ElectionType"] = snapshot.ElectionType,
|
||||
["DistrictName"] = snapshot.DistrictName,
|
||||
["DistrictCode"] = snapshot.DistrictCode,
|
||||
["TotalExpectedVotes"] = snapshot.TotalExpectedVotes.ToString(CultureInfo.InvariantCulture),
|
||||
["TotalExpectedVotesDisplay"] = snapshot.TotalExpectedVotes.ToString("N0", CultureInfo.InvariantCulture),
|
||||
["TurnoutVotes"] = snapshot.TurnoutVotes.ToString(CultureInfo.InvariantCulture),
|
||||
["TurnoutVotesDisplay"] = snapshot.TurnoutVotes.ToString("N0", CultureInfo.InvariantCulture),
|
||||
["TurnoutRate"] = snapshot.TurnoutRate.ToString("0.0", CultureInfo.InvariantCulture),
|
||||
["CountedVotes"] = snapshot.CountedVotes.ToString(CultureInfo.InvariantCulture),
|
||||
["CountedVotesDisplay"] = snapshot.CountedVotes.ToString("N0", CultureInfo.InvariantCulture),
|
||||
["RemainingVotes"] = snapshot.RemainingVotes.ToString(CultureInfo.InvariantCulture),
|
||||
["RemainingVotesDisplay"] = snapshot.RemainingVotes.ToString("N0", CultureInfo.InvariantCulture),
|
||||
["ImageRootPath"] = t3CutPath,
|
||||
["T3CutPath"] = t3CutPath,
|
||||
["Timestamp"] = snapshot.ReceivedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
var orderedCandidates = snapshot.Candidates
|
||||
.OrderByDescending(candidate => candidate.VoteCount)
|
||||
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
for (var index = 0; index < orderedCandidates.Length; index++)
|
||||
{
|
||||
var candidate = orderedCandidates[index];
|
||||
var slot = index + 1;
|
||||
values[$"Candidate{slot}Code"] = candidate.CandidateCode;
|
||||
values[$"Candidate{slot}Name"] = candidate.Name;
|
||||
values[$"Candidate{slot}Party"] = candidate.Party;
|
||||
values[$"Candidate{slot}VoteCount"] = candidate.VoteCount.ToString(CultureInfo.InvariantCulture);
|
||||
values[$"Candidate{slot}VoteCountDisplay"] = candidate.VoteCount.ToString("N0", CultureInfo.InvariantCulture);
|
||||
values[$"Candidate{slot}VoteRate"] = candidate.VoteRate.ToString("0.0", CultureInfo.InvariantCulture);
|
||||
values[$"Candidate{slot}Judgement"] = candidate.EffectiveJudgementLabel;
|
||||
values[$"Candidate{slot}ImagePath"] = ResolveCandidateImagePath(t3CutPath, candidate);
|
||||
}
|
||||
|
||||
if (orderedCandidates.FirstOrDefault() is { } leader)
|
||||
{
|
||||
values["LeaderCode"] = leader.CandidateCode;
|
||||
values["LeaderName"] = leader.Name;
|
||||
values["LeaderParty"] = leader.Party;
|
||||
values["LeaderVoteCount"] = leader.VoteCount.ToString(CultureInfo.InvariantCulture);
|
||||
values["LeaderVoteCountDisplay"] = leader.VoteCount.ToString("N0", CultureInfo.InvariantCulture);
|
||||
values["LeaderVoteRate"] = leader.VoteRate.ToString("0.0", CultureInfo.InvariantCulture);
|
||||
values["LeaderJudgement"] = leader.EffectiveJudgementLabel;
|
||||
values["LeaderImagePath"] = ResolveCandidateImagePath(t3CutPath, leader);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static string ResolveCandidateImagePath(string t3CutPath, CandidateEntry candidate)
|
||||
{
|
||||
if (!candidate.HasImage || string.IsNullOrWhiteSpace(t3CutPath) || string.IsNullOrWhiteSpace(candidate.CandidateCode))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
foreach (var extension in new[] { ".png", ".jpg", ".jpeg", ".webp" })
|
||||
{
|
||||
var path = Path.Combine(t3CutPath, candidate.CandidateCode + extension);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private readonly record struct KarismaChannelBinding(int OutputChannelIndex, int LayerNo);
|
||||
|
||||
private readonly record struct ResolvedScene(string Path, string Alias);
|
||||
}
|
||||
@@ -16,6 +16,14 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
|
||||
_logService = logService;
|
||||
}
|
||||
|
||||
public string BackendName => "Mock Adapter";
|
||||
|
||||
public bool IsLiveCg => false;
|
||||
|
||||
public bool IsConnected => false;
|
||||
|
||||
public string ConnectionTarget => "실CG 미연동";
|
||||
|
||||
public TornadoConnectionState State
|
||||
{
|
||||
get => _state;
|
||||
@@ -33,6 +41,8 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
|
||||
|
||||
public event EventHandler<TornadoConnectionState>? StateChanged;
|
||||
|
||||
public event EventHandler? ConnectionChanged;
|
||||
|
||||
public async Task EnsureConnectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
|
||||
443
Tornado3_2026Election/Services/TornadoManager.cs
Normal file
443
Tornado3_2026Election/Services/TornadoManager.cs
Normal file
@@ -0,0 +1,443 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using KAsyncEngineLib;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
|
||||
public sealed class TornadoManager : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan AutoReconnectInterval = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly LogService _logService;
|
||||
private readonly StaDispatcher _dispatcher;
|
||||
private readonly Timer _reconnectTimer;
|
||||
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<int, KAScenePlayer> _scenePlayers = new();
|
||||
|
||||
private IKAEngine? _engine;
|
||||
private KAEventHandler? _eventHandler;
|
||||
private bool _connected;
|
||||
private bool _isTcpConnected;
|
||||
private bool _autoReconnectEnabled;
|
||||
private bool _disposed;
|
||||
private int _reconnectInProgress;
|
||||
|
||||
public bool IsConnected => _isTcpConnected;
|
||||
|
||||
public event EventHandler? ConnectionChanged;
|
||||
|
||||
public TornadoManager(string host, int port, LogService logService)
|
||||
{
|
||||
_host = string.IsNullOrWhiteSpace(host)
|
||||
? throw new ArgumentException("Host is required.", nameof(host))
|
||||
: host;
|
||||
_port = port > 0
|
||||
? port
|
||||
: throw new ArgumentOutOfRangeException(nameof(port), "Port must be a positive integer.");
|
||||
_logService = logService;
|
||||
_dispatcher = new StaDispatcher("Karisma Async Engine");
|
||||
_reconnectTimer = new Timer(OnReconnectTimerTick, null, AutoReconnectInterval, AutoReconnectInterval);
|
||||
}
|
||||
|
||||
public Task EnsureConnectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() => EnsureConnectedCore(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<string> LoadSceneAsync(string scenePath, string sceneAlias, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scenePath))
|
||||
{
|
||||
throw new ArgumentException("Scene path is required.", nameof(scenePath));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sceneAlias))
|
||||
{
|
||||
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
|
||||
}
|
||||
|
||||
if (_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
|
||||
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return sceneAlias;
|
||||
}
|
||||
|
||||
var forceReload = _scenePaths.ContainsKey(sceneAlias);
|
||||
var scene = forceReload
|
||||
? _engine!.LoadSceneForce(scenePath, sceneAlias)
|
||||
: _engine!.LoadScene(scenePath, sceneAlias);
|
||||
|
||||
_logService.Info(
|
||||
$"Karisma {(forceReload ? "LoadSceneForce" : "LoadScene")}() return={(scene is null ? "null" : "scene-handle")} alias={sceneAlias} path={scenePath}");
|
||||
|
||||
_scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
|
||||
_scenePaths[sceneAlias] = scenePath;
|
||||
_logService.Info($"Karisma scene loaded: alias={sceneAlias} path={scenePath}");
|
||||
return sceneAlias;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ApplyValuesAsync(string sceneAlias, IReadOnlyDictionary<string, string> values, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
var scene = GetSceneCore(sceneAlias);
|
||||
_engine!.BeginTransaction();
|
||||
try
|
||||
{
|
||||
foreach (var pair in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sceneObject = scene.GetObject(pair.Key);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sceneObject.SetValue(pair.Value ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning($"Karisma object update skipped: scene={sceneAlias} object={pair.Key} reason={ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_engine!.EndTransaction();
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
GetScenePlayerCore(outputChannelIndex).Prepare(layerNo, GetSceneCore(sceneAlias));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task PlayAsync(int outputChannelIndex, int layerNo, bool cutIn, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
var scenePlayer = GetScenePlayerCore(outputChannelIndex);
|
||||
if (cutIn)
|
||||
{
|
||||
scenePlayer.CutIn(layerNo);
|
||||
return;
|
||||
}
|
||||
|
||||
scenePlayer.Play(layerNo);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task PlayOutAsync(int outputChannelIndex, int layerNo, bool cutOut, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
var scenePlayer = GetScenePlayerCore(outputChannelIndex);
|
||||
if (cutOut)
|
||||
{
|
||||
scenePlayer.CutOut(layerNo);
|
||||
return;
|
||||
}
|
||||
|
||||
scenePlayer.PlayOut(layerNo);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task TriggerAsync(int outputChannelIndex, int layerNo, string animationName, CancellationToken cancellationToken)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
EnsureConnectedCore();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(animationName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GetScenePlayerCore(outputChannelIndex).Trigger(layerNo, animationName);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_autoReconnectEnabled = false;
|
||||
_reconnectTimer.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
foreach (var scene in _scenes.Values)
|
||||
{
|
||||
try
|
||||
{
|
||||
scene.UnloadScene();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_connected)
|
||||
{
|
||||
_engine?.Disconnect();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_scenes.Clear();
|
||||
_scenePaths.Clear();
|
||||
_scenePlayers.Clear();
|
||||
_engine = null;
|
||||
_eventHandler = null;
|
||||
_connected = false;
|
||||
}, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dispatcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConnectedCore()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_engine ??= (IKAEngine)new KAEngineClass();
|
||||
_eventHandler ??= new KarismaEventHandler(_logService, HandleConnect, HandleClose);
|
||||
_autoReconnectEnabled = true;
|
||||
|
||||
var requested = _engine.Connect(_host, _port, _eventHandler);
|
||||
_logService.Info($"Karisma Connect() return={(requested != 0 ? "TRUE" : "FALSE")} raw={requested} target={_host}:{_port}");
|
||||
if (requested == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Karisma Async Engine connection request failed: {_host}:{_port}");
|
||||
}
|
||||
|
||||
_connected = true;
|
||||
_logService.Info($"Karisma Async Engine connect requested: {_host}:{_port}");
|
||||
}
|
||||
|
||||
private void HandleConnect(int errorCode)
|
||||
{
|
||||
if (errorCode == 0)
|
||||
{
|
||||
UpdateTcpConnectionState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
_connected = false;
|
||||
UpdateTcpConnectionState(false);
|
||||
}
|
||||
|
||||
private void HandleClose(int errorCode)
|
||||
{
|
||||
_connected = false;
|
||||
UpdateTcpConnectionState(false);
|
||||
}
|
||||
|
||||
private void UpdateTcpConnectionState(bool isConnected)
|
||||
{
|
||||
if (_isTcpConnected == isConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTcpConnected = isConnected;
|
||||
ConnectionChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void OnReconnectTimerTick(object? state)
|
||||
{
|
||||
if (_disposed || !_autoReconnectEnabled || _connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.Exchange(ref _reconnectInProgress, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = TryAutoReconnectAsync();
|
||||
}
|
||||
|
||||
private async Task TryAutoReconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_disposed || !_autoReconnectEnabled || _connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
if (_disposed || _connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConnectedCore();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning($"Karisma auto reconnect attempt failed: {ex.Message}");
|
||||
}
|
||||
}, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _reconnectInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private KAScene GetSceneCore(string sceneAlias)
|
||||
{
|
||||
if (_scenes.TryGetValue(sceneAlias, out var scene))
|
||||
{
|
||||
return scene;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Karisma scene is not loaded: {sceneAlias}");
|
||||
}
|
||||
|
||||
private KAScenePlayer GetScenePlayerCore(int outputChannelIndex)
|
||||
{
|
||||
if (_scenePlayers.TryGetValue(outputChannelIndex, out var scenePlayer))
|
||||
{
|
||||
return scenePlayer;
|
||||
}
|
||||
|
||||
scenePlayer = _engine!.GetScenePlayer(outputChannelIndex)
|
||||
?? throw new InvalidOperationException($"Karisma scene player is unavailable: output={outputChannelIndex}");
|
||||
_scenePlayers[outputChannelIndex] = scenePlayer;
|
||||
return scenePlayer;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
private sealed class StaDispatcher : IDisposable
|
||||
{
|
||||
private readonly BlockingCollection<Action> _queue = new();
|
||||
private readonly Thread _thread;
|
||||
private bool _disposed;
|
||||
|
||||
public StaDispatcher(string threadName)
|
||||
{
|
||||
_thread = new Thread(Run)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = threadName
|
||||
};
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public Task InvokeAsync(Action action, CancellationToken cancellationToken)
|
||||
{
|
||||
return InvokeAsync(() =>
|
||||
{
|
||||
action();
|
||||
return true;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<T> InvokeAsync<T>(Func<T> action, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_queue.Add(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
completion.SetResult(action());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
completion.SetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
return cancellationToken.CanBeCanceled
|
||||
? completion.Task.WaitAsync(cancellationToken)
|
||||
: completion.Task;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_queue.CompleteAdding();
|
||||
_thread.Join();
|
||||
_queue.Dispose();
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
foreach (var action in _queue.GetConsumingEnumerable())
|
||||
{
|
||||
action();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,28 @@
|
||||
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
|
||||
<Platforms>x86;x64;ARM64</Platforms>
|
||||
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<WinUISDKReferences>false</WinUISDKReferences>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)'=='' and ('$(Platform)'=='' or '$(Platform)'=='AnyCPU' or '$(Platform)'=='x64')">
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)'=='' and '$(Platform)'=='x86'">
|
||||
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)'=='' and '$(Platform)'=='ARM64'">
|
||||
<RuntimeIdentifier>win-arm64</RuntimeIdentifier>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">
|
||||
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Assets\Stations\**\*.*" />
|
||||
<Content Include="Assets\AppIcon.ico">
|
||||
@@ -72,6 +87,11 @@
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7705" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260209005" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Interop.KAsyncEngineLib">
|
||||
<HintPath>..\..\..\..\..\..\Karisma SDK\Bin\C#\Interop.KAsyncEngineLib.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<WebView2EnableCsWinRTProjection>False</WebView2EnableCsWinRTProjection>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Tornado3_2026Election.Common;
|
||||
@@ -12,7 +13,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
private readonly ChannelScheduleEngine _engine;
|
||||
private readonly ITornado3Adapter _adapter;
|
||||
private readonly DataViewModel _data;
|
||||
private readonly LogService _logService;
|
||||
private readonly IReadOnlyList<FormatTemplateDefinition> _allFormats;
|
||||
private FormatTemplateDefinition? _selectedFormat;
|
||||
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
|
||||
private bool _loopEnabled;
|
||||
@@ -22,16 +25,19 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
BroadcastChannel channel,
|
||||
string title,
|
||||
IReadOnlyList<FormatTemplateDefinition> formats,
|
||||
DataViewModel data,
|
||||
ITornado3Adapter adapter,
|
||||
ChannelScheduleEngine engine,
|
||||
LogService logService)
|
||||
{
|
||||
Channel = channel;
|
||||
Title = title;
|
||||
_data = data;
|
||||
_adapter = adapter;
|
||||
_engine = engine;
|
||||
_logService = logService;
|
||||
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>(formats);
|
||||
_allFormats = formats.ToArray();
|
||||
AvailableFormats = new ObservableCollection<FormatTemplateDefinition>();
|
||||
EmptyBehaviorOptions =
|
||||
[
|
||||
new SelectionOption<EmptyScheduleBehavior>(EmptyScheduleBehavior.ImmediateOut, "즉시 아웃"),
|
||||
@@ -42,17 +48,20 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
StartCommand = new AsyncRelayCommand(StartAsync);
|
||||
StopCommand = new AsyncRelayCommand(StopAsync);
|
||||
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
|
||||
AddFormatCommand = new RelayCommand(AddFormat, () => SelectedFormat is not null);
|
||||
AddFormatCommand = new RelayCommand(AddFormat, CanAddFormat);
|
||||
ResetQueueCommand = new RelayCommand(ResetQueue);
|
||||
RemoveItemCommand = new RelayCommand<ChannelScheduleItem>(RemoveItem);
|
||||
MoveUpCommand = new RelayCommand<ChannelScheduleItem>(MoveUp);
|
||||
MoveDownCommand = new RelayCommand<ChannelScheduleItem>(MoveDown);
|
||||
PromoteToNextCommand = new RelayCommand<ChannelScheduleItem>(PromoteToNext);
|
||||
SelectedFormat = AvailableFormats.FirstOrDefault();
|
||||
SelectedEmptyBehaviorOption = FindEmptyBehaviorOption(_emptyScheduleBehavior);
|
||||
|
||||
_engine.QueueChanged += (_, _) => RefreshSummary();
|
||||
_adapter.StateChanged += (_, _) => RefreshSummary();
|
||||
_adapter.ConnectionChanged += (_, _) => RefreshSummary();
|
||||
_data.PropertyChanged += Data_PropertyChanged;
|
||||
|
||||
RebuildAvailableFormats();
|
||||
RefreshSummary();
|
||||
}
|
||||
|
||||
@@ -60,6 +69,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public bool IsLiveCg => _adapter.IsLiveCg;
|
||||
|
||||
public bool IsCgConnected => _adapter.IsConnected;
|
||||
|
||||
public string CgBackendName => _adapter.BackendName;
|
||||
|
||||
public string CgConnectionTarget => _adapter.ConnectionTarget;
|
||||
|
||||
public string CgStatusSummary => IsLiveCg
|
||||
? $"{(IsCgConnected ? "Connected" : "Disconnected")} / {CgBackendName} / {CgConnectionTarget}"
|
||||
: $"Disconnected / {CgBackendName} / {CgConnectionTarget}";
|
||||
|
||||
public ObservableCollection<FormatTemplateDefinition> AvailableFormats { get; }
|
||||
|
||||
public IReadOnlyList<SelectionOption<EmptyScheduleBehavior>> EmptyBehaviorOptions { get; }
|
||||
@@ -162,11 +183,11 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public string CurrentItemName => Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending)?.FormatName ?? "대기 화면";
|
||||
|
||||
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "다음 포맷 없음";
|
||||
public string NextItemName => Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)?.FormatName ?? "다음 컷 없음";
|
||||
|
||||
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
|
||||
|
||||
public string QueueFootnote => $"대기 {QueuedItemCount}건 · 프리셋 {AvailableFormats.Count}개";
|
||||
public string QueueFootnote => $"대기 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
|
||||
|
||||
public string QueueSummary
|
||||
{
|
||||
@@ -178,31 +199,31 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
public string LoopSummary => LoopEnabled ? "전체 반복 켜짐" : "한 번 재생";
|
||||
public string LoopSummary => LoopEnabled ? "반복 재생" : "1회 재생";
|
||||
|
||||
public string EmptyBehaviorLabel => SelectedEmptyBehaviorOption?.Label ?? "즉시 아웃";
|
||||
|
||||
public string OperatorQuickSummary => $"{AdapterStateLabel} · {LoopSummary} · 빈 스케줄 {EmptyBehaviorLabel}";
|
||||
public string OperatorQuickSummary => $"{AdapterStateLabel} / {LoopSummary} / 빈 스케줄 {EmptyBehaviorLabel}";
|
||||
|
||||
private async Task StartAsync()
|
||||
{
|
||||
await _engine.StartAsync().ConfigureAwait(false);
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 스케줄 시작");
|
||||
_logService.Info($"[{Title}] 큐를 시작");
|
||||
}
|
||||
|
||||
private async Task StopAsync()
|
||||
{
|
||||
await _engine.StopAsync().ConfigureAwait(false);
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 스케줄 종료");
|
||||
_logService.Info($"[{Title}] 큐를 종료");
|
||||
}
|
||||
|
||||
private async Task ForceNextAsync()
|
||||
{
|
||||
await _engine.ForceNextAsync().ConfigureAwait(false);
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 현재 포맷 강제 중지 후 전환");
|
||||
_logService.Info($"[{Title}] 현재 컷을 강제로 전환");
|
||||
}
|
||||
|
||||
private void AddFormat()
|
||||
@@ -212,6 +233,18 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
{
|
||||
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_data.ValidateForFormat(SelectedFormat, out var validationError))
|
||||
{
|
||||
_logService.Warning($"[{Title}] {validationError}");
|
||||
return;
|
||||
}
|
||||
|
||||
_engine.Enqueue(ChannelScheduleItem.FromTemplate(SelectedFormat));
|
||||
RefreshSummary();
|
||||
}
|
||||
@@ -220,14 +253,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
_engine.Reset();
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 스케줄을 첫 포맷부터 다시 시작하도록 초기화했습니다.");
|
||||
_logService.Info($"[{Title}] 큐를 첫 컷부터 다시 시작하도록 초기화했습니다.");
|
||||
}
|
||||
|
||||
private void RemoveItem(ChannelScheduleItem? item)
|
||||
{
|
||||
if (!_engine.Remove(item))
|
||||
{
|
||||
_logService.Warning($"[{Title}] 송출 중 포맷은 삭제할 수 없습니다.");
|
||||
_logService.Warning($"[{Title}] 송출 중 항목은 제거할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -265,11 +298,50 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
nameof(QueuedItemCount),
|
||||
nameof(QueueFootnote),
|
||||
nameof(QueueSummary),
|
||||
nameof(IsCgConnected),
|
||||
nameof(CgStatusSummary),
|
||||
nameof(LoopSummary),
|
||||
nameof(EmptyBehaviorLabel),
|
||||
nameof(OperatorQuickSummary));
|
||||
}
|
||||
|
||||
private bool CanAddFormat()
|
||||
{
|
||||
return SelectedFormat is not null && SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase);
|
||||
}
|
||||
|
||||
private void Data_PropertyChanged(object? sender, PropertyChangedEventArgs args)
|
||||
{
|
||||
if (args.PropertyName != nameof(DataViewModel.BroadcastPhase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildAvailableFormats();
|
||||
RefreshSummary();
|
||||
}
|
||||
|
||||
private void RebuildAvailableFormats()
|
||||
{
|
||||
var filteredFormats = _allFormats
|
||||
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
.ToArray();
|
||||
|
||||
AvailableFormats.Clear();
|
||||
foreach (var format in filteredFormats)
|
||||
{
|
||||
AvailableFormats.Add(format);
|
||||
}
|
||||
|
||||
if (SelectedFormat is null || !AvailableFormats.Contains(SelectedFormat))
|
||||
{
|
||||
SelectedFormat = AvailableFormats.FirstOrDefault();
|
||||
}
|
||||
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(QueueFootnote));
|
||||
}
|
||||
|
||||
private SelectionOption<EmptyScheduleBehavior>? FindEmptyBehaviorOption(EmptyScheduleBehavior behavior)
|
||||
{
|
||||
return EmptyBehaviorOptions.FirstOrDefault(option => option.Value == behavior);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
@@ -728,3 +728,4 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
options.Add(new SelectionOption<string>(value, value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -18,13 +19,15 @@ 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 readonly FormatCatalogService _formatCatalogService;
|
||||
private readonly AppStateStore _stateStore;
|
||||
private readonly LogService _logService;
|
||||
private readonly SemaphoreSlim _stateSaveLock = new(1, 1);
|
||||
private AppPage _currentPage = AppPage.IntegratedSchedule;
|
||||
private ChannelOperationMode _operationMode = ChannelOperationMode.General;
|
||||
private bool _isSituationRoomExpanded = true;
|
||||
private bool _isSituationRoomExpanded;
|
||||
private bool _suppressAutomaticSave;
|
||||
private int? _windowX;
|
||||
private int? _windowY;
|
||||
@@ -136,6 +139,8 @@ public sealed class MainViewModel : ObservableObject
|
||||
nameof(BottomVisibility),
|
||||
nameof(VideoWallVisibility),
|
||||
nameof(HeaderStatus),
|
||||
nameof(CgIntegrationSummary),
|
||||
nameof(CgIntegrationDetail),
|
||||
nameof(TornadoConnectionSummary),
|
||||
nameof(TornadoConnectionDetail));
|
||||
QueueAutomaticSave();
|
||||
@@ -265,6 +270,56 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
public string LogFilterSummary => $"표시 {FilteredLogs.Count}건 / 전체 {Logs.Count}건";
|
||||
|
||||
public string CgIntegrationSummary => IsCgConnected ? "Connected" : "Disconnected";
|
||||
|
||||
public Brush CgIntegrationBrush => IsCgConnected ? ConnectedStatusBrush : DisconnectedStatusBrush;
|
||||
|
||||
public string CgIntegrationDetail
|
||||
{
|
||||
get
|
||||
{
|
||||
var activeChannels = GetActiveChannels().ToArray();
|
||||
if (activeChannels.Length == 0)
|
||||
{
|
||||
return "운영 대상 없음";
|
||||
}
|
||||
|
||||
var liveChannels = activeChannels
|
||||
.Where(channel => channel.IsLiveCg)
|
||||
.ToArray();
|
||||
|
||||
if (liveChannels.Length == 0)
|
||||
{
|
||||
return "Mock Adapter";
|
||||
}
|
||||
|
||||
var targets = liveChannels
|
||||
.Select(channel => channel.CgConnectionTarget)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return $"{liveChannels[0].CgBackendName} / {string.Join(", ", targets)}";
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsCgConnected
|
||||
{
|
||||
get
|
||||
{
|
||||
var activeChannels = GetActiveChannels().ToArray();
|
||||
if (activeChannels.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var liveChannels = activeChannels
|
||||
.Where(channel => channel.IsLiveCg)
|
||||
.ToArray();
|
||||
|
||||
return liveChannels.Length > 0 && liveChannels.All(channel => channel.IsCgConnected);
|
||||
}
|
||||
}
|
||||
|
||||
public string TornadoConnectionSummary
|
||||
{
|
||||
get
|
||||
@@ -309,7 +364,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
public string HeaderStatus => $"{Settings.SelectedStation.Name} / {OperationModeLabel} / {Data.DistrictName} / {Data.HeaderMetricSummary}";
|
||||
public string HeaderStatus => $"{Settings.SelectedStation.Name} / {CurrentPageTitle} / {Data.BroadcastPhaseBadgeText} / {OperationModeLabel}";
|
||||
|
||||
public void Navigate(string tag)
|
||||
{
|
||||
@@ -383,7 +438,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
_suppressAutomaticSave = false;
|
||||
}
|
||||
|
||||
_logService.Info("저장된 시작 옵션에 따라 상태를 자동 복원했습니다.");
|
||||
_logService.Info("저장한 시작 옵션에 따라 상태를 자동 복원했습니다.");
|
||||
}
|
||||
|
||||
public async Task RestoreAsync()
|
||||
@@ -495,9 +550,17 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
private void Channel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(ChannelScheduleViewModel.AdapterState) or nameof(ChannelScheduleViewModel.AdapterStateLabel))
|
||||
if (e.PropertyName is nameof(ChannelScheduleViewModel.AdapterState)
|
||||
or nameof(ChannelScheduleViewModel.AdapterStateLabel)
|
||||
or nameof(ChannelScheduleViewModel.IsCgConnected))
|
||||
{
|
||||
OnPropertyChanged(nameof(TornadoConnectionSummary), nameof(TornadoConnectionDetail));
|
||||
OnPropertyChanged(
|
||||
nameof(IsCgConnected),
|
||||
nameof(CgIntegrationSummary),
|
||||
nameof(CgIntegrationBrush),
|
||||
nameof(CgIntegrationDetail),
|
||||
nameof(TornadoConnectionSummary),
|
||||
nameof(TornadoConnectionDetail));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +696,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
|
||||
private ChannelScheduleViewModel CreateChannelViewModel(BroadcastChannel channel, string title)
|
||||
{
|
||||
var adapter = new MockTornado3Adapter(_logService);
|
||||
var adapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath);
|
||||
var queue = new ObservableCollection<ChannelScheduleItem>();
|
||||
var engine = new ChannelScheduleEngine(
|
||||
channel,
|
||||
@@ -649,6 +712,7 @@ public sealed class MainViewModel : ObservableObject
|
||||
channel,
|
||||
title,
|
||||
_formatCatalogService.GetByChannel(channel),
|
||||
Data,
|
||||
adapter,
|
||||
engine,
|
||||
_logService);
|
||||
@@ -752,3 +816,5 @@ public sealed class MainViewModel : ObservableObject
|
||||
channelViewModel.RefreshSummary();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Tornado3_2026Election.Common;
|
||||
@@ -9,7 +9,7 @@ namespace Tornado3_2026Election.ViewModels;
|
||||
public sealed class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private string _selectedStationId = "KNN";
|
||||
private string _imageRootPath = @"C:\ElectionImages";
|
||||
private string _imageRootPath = @"C:\Users\y2keu\Downloads\T3_Cut";
|
||||
|
||||
public SettingsViewModel(IEnumerable<BroadcastStationProfile> stations)
|
||||
{
|
||||
@@ -67,3 +67,4 @@ public sealed class SettingsViewModel : ObservableObject
|
||||
return SelectedStation.ToProfile();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user