diff --git a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
index 03661c4..5712e25 100644
--- a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
+++ b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml
@@ -46,6 +46,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tornado3_2026Election/Domain/ChannelScheduleItem.cs b/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
index 7e3ad3c..4cae44b 100644
--- a/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
+++ b/Tornado3_2026Election/Domain/ChannelScheduleItem.cs
@@ -29,6 +29,8 @@ public sealed class ChannelScheduleItem : ObservableObject
private ImageSource? _internalNextPreviewSource;
private string _internalNextPreviewStatusLabel = string.Empty;
private string _internalNextPreviewDisplayName = string.Empty;
+ private double _playbackRemainingSeconds;
+ private double _playbackTotalSeconds;
public Guid Id { get; set; } = Guid.NewGuid();
@@ -105,6 +107,7 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
OnPropertyChanged(nameof(CardOpacity));
OnPropertyChanged(nameof(CanDelete));
+ OnPropertyChanged(nameof(PlaybackCountdownVisibility));
OnIssueStateChanged();
}
}
@@ -307,6 +310,27 @@ public sealed class ChannelScheduleItem : ObservableObject
? "실데이터 프리뷰 준비 중"
: _renderedPreviewStatusLabel;
+ [JsonIgnore]
+ public bool HasPlaybackCountdown => State == ScheduleQueueItemState.OnAir && _playbackTotalSeconds > 0;
+
+ [JsonIgnore]
+ public Visibility PlaybackCountdownVisibility => HasPlaybackCountdown ? Visibility.Visible : Visibility.Collapsed;
+
+ [JsonIgnore]
+ public string PlaybackCountdownText => HasPlaybackCountdown
+ ? $"다음 컷까지 {Math.Max(0, (int)Math.Ceiling(_playbackRemainingSeconds))}초"
+ : "대기 중";
+
+ [JsonIgnore]
+ public string PlaybackCountdownDetail => HasPlaybackCountdown
+ ? $"{FormatName} / {DisplayRegionLabel}"
+ : string.Empty;
+
+ [JsonIgnore]
+ public double PlaybackCountdownProgress => HasPlaybackCountdown
+ ? Math.Clamp((_playbackRemainingSeconds / _playbackTotalSeconds) * 100d, 0d, 100d)
+ : 0d;
+
[JsonIgnore]
public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
@@ -403,6 +427,33 @@ public sealed class ChannelScheduleItem : ObservableObject
OnInternalNextPreviewChanged();
}
+ public void UpdatePlaybackCountdown(TimeSpan remaining, TimeSpan total)
+ {
+ var normalizedTotal = Math.Max(0, total.TotalSeconds);
+ var normalizedRemaining = Math.Clamp(remaining.TotalSeconds, 0, normalizedTotal);
+ if (Math.Abs(_playbackRemainingSeconds - normalizedRemaining) < 0.001d &&
+ Math.Abs(_playbackTotalSeconds - normalizedTotal) < 0.001d)
+ {
+ return;
+ }
+
+ _playbackRemainingSeconds = normalizedRemaining;
+ _playbackTotalSeconds = normalizedTotal;
+ OnPlaybackCountdownChanged();
+ }
+
+ public void ClearPlaybackCountdown()
+ {
+ if (_playbackRemainingSeconds <= 0 && _playbackTotalSeconds <= 0)
+ {
+ return;
+ }
+
+ _playbackRemainingSeconds = 0;
+ _playbackTotalSeconds = 0;
+ OnPlaybackCountdownChanged();
+ }
+
public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
{
ThumbnailWidth = metrics.Width;
@@ -453,6 +504,15 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(InternalNextPreviewDisplayName));
}
+ private void OnPlaybackCountdownChanged()
+ {
+ OnPropertyChanged(nameof(HasPlaybackCountdown));
+ OnPropertyChanged(nameof(PlaybackCountdownVisibility));
+ OnPropertyChanged(nameof(PlaybackCountdownText));
+ OnPropertyChanged(nameof(PlaybackCountdownDetail));
+ OnPropertyChanged(nameof(PlaybackCountdownProgress));
+ }
+
public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
{
var selectedRegion = regionOption ?? new ScheduleRegionOption
diff --git a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
index 67b5dda..b84dbdf 100644
--- a/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
+++ b/Tornado3_2026Election/Services/ChannelScheduleEngine.cs
@@ -130,6 +130,7 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
+ item.ClearPlaybackCountdown();
}
_lastPlaybackItemId = null;
@@ -203,6 +204,7 @@ public sealed class ChannelScheduleEngine
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
+ item.ClearPlaybackCountdown();
throw;
}
finally
@@ -221,6 +223,7 @@ public sealed class ChannelScheduleEngine
_directPlaybackItem.CurrentRegionLabel = string.Empty;
_directPlaybackItem.ClearRenderedPreview();
_directPlaybackItem.ClearInternalNextPreview();
+ _directPlaybackItem.ClearPlaybackCountdown();
_directPlaybackItem = null;
}
@@ -244,6 +247,7 @@ public sealed class ChannelScheduleEngine
{
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
+ item.ClearPlaybackCountdown();
}
finally
{
@@ -264,6 +268,7 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
+ item.ClearPlaybackCountdown();
}
RefreshQueueMarkers();
@@ -281,6 +286,7 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
+ item.ClearPlaybackCountdown();
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
return true;
@@ -312,6 +318,7 @@ public sealed class ChannelScheduleEngine
activeItem.LastError = string.Empty;
activeItem.CurrentRegionLabel = string.Empty;
activeItem.ClearInternalNextPreview();
+ activeItem.ClearPlaybackCountdown();
}
RefreshQueueMarkers();
@@ -449,6 +456,7 @@ public sealed class ChannelScheduleEngine
sendingItem.LastError = ex.Message;
sendingItem.CurrentRegionLabel = string.Empty;
sendingItem.ClearInternalNextPreview();
+ sendingItem.ClearPlaybackCountdown();
ClearSkipCurrentItem(sendingItem);
}
@@ -628,6 +636,7 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview();
+ queueItem.ClearPlaybackCountdown();
_lastPlaybackItemId = queueItem.Id;
if (playedAny)
{
@@ -727,6 +736,7 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview();
+ queueItem.ClearPlaybackCountdown();
_lastPlaybackItemId = queueItem.Id;
if (playedAny)
{
@@ -758,6 +768,7 @@ public sealed class ChannelScheduleEngine
CancellationToken cancellationToken)
{
queueItem.State = ScheduleQueueItemState.Sending;
+ queueItem.ClearPlaybackCountdown();
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
@@ -780,8 +791,11 @@ public sealed class ChannelScheduleEngine
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
var onAirAt = DateTimeOffset.Now;
+ var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
+ var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
queueItem.State = ScheduleQueueItemState.OnAir;
queueItem.LastPlayedAt = onAirAt;
+ queueItem.UpdatePlaybackCountdown(playbackDuration, playbackDuration);
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
@@ -792,8 +806,6 @@ public sealed class ChannelScheduleEngine
signal.TrySetResult(true);
}
- var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
- var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
if (!ShouldSkipCurrentItem(queueItem))
@@ -829,17 +841,51 @@ public sealed class ChannelScheduleEngine
if (ShouldSkipCurrentItem(queueItem))
{
+ queueItem.ClearPlaybackCountdown();
return;
}
var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (remainingDuration <= TimeSpan.Zero)
{
+ queueItem.ClearPlaybackCountdown();
return;
}
- var delayTask = Task.Delay(remainingDuration, cancellationToken);
- await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
+ await WaitForPlaybackCountdownAsync(queueItem, onAirAt, playbackDuration, signal, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private static async Task WaitForPlaybackCountdownAsync(
+ ChannelScheduleItem queueItem,
+ DateTimeOffset onAirAt,
+ TimeSpan playbackDuration,
+ TaskCompletionSource advanceSignal,
+ CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var remaining = playbackDuration - (DateTimeOffset.Now - onAirAt);
+ if (remaining <= TimeSpan.Zero)
+ {
+ queueItem.ClearPlaybackCountdown();
+ return;
+ }
+
+ queueItem.UpdatePlaybackCountdown(remaining, playbackDuration);
+ var updateInterval = remaining > TimeSpan.FromSeconds(1)
+ ? TimeSpan.FromSeconds(1)
+ : remaining;
+ var delayTask = Task.Delay(updateInterval, cancellationToken);
+ var completedTask = await Task.WhenAny(delayTask, advanceSignal.Task).ConfigureAwait(false);
+ if (completedTask == advanceSignal.Task)
+ {
+ queueItem.ClearPlaybackCountdown();
+ return;
+ }
+ }
+
+ queueItem.ClearPlaybackCountdown();
}
private async Task CaptureCurrentPreviewAsync(
@@ -1215,6 +1261,7 @@ public sealed class ChannelScheduleEngine
preparedFrame.Item.CurrentRegionLabel = string.Empty;
preparedFrame.Item.ClearRenderedPreview();
preparedFrame.Item.ClearInternalNextPreview();
+ preparedFrame.Item.ClearPlaybackCountdown();
}
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
@@ -1732,6 +1779,7 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
+ item.ClearPlaybackCountdown();
}
}
@@ -1742,6 +1790,7 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearRenderedPreview();
queueItem.ClearInternalNextPreview();
+ queueItem.ClearPlaybackCountdown();
}
private static string NormalizeDataUnavailableReason(string reason)
diff --git a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
index 9e11db4..4467e8a 100644
--- a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
+++ b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs
@@ -372,6 +372,15 @@ public sealed class ChannelScheduleViewModel : ObservableObject
?? QueueNextPlaybackItem?.PreviewStatusLabel
?? "다음 컷 없음";
+ public Visibility PlaybackCountdownVisibility =>
+ CurrentPlaybackItem?.PlaybackCountdownVisibility ?? Visibility.Collapsed;
+
+ public string PlaybackCountdownText => CurrentPlaybackItem?.PlaybackCountdownText ?? "대기 중";
+
+ public string PlaybackCountdownDetail => CurrentPlaybackItem?.PlaybackCountdownDetail ?? string.Empty;
+
+ public double PlaybackCountdownProgress => CurrentPlaybackItem?.PlaybackCountdownProgress ?? 0d;
+
public double CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
@@ -892,6 +901,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
+ nameof(PlaybackCountdownVisibility),
+ nameof(PlaybackCountdownText),
+ nameof(PlaybackCountdownDetail),
+ nameof(PlaybackCountdownProgress),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),
@@ -1155,6 +1168,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
or nameof(ChannelScheduleItem.HasInternalNextPreview)
or nameof(ChannelScheduleItem.LastError)
or nameof(ChannelScheduleItem.LastIssueAt)
+ or nameof(ChannelScheduleItem.PlaybackCountdownVisibility)
+ or nameof(ChannelScheduleItem.PlaybackCountdownText)
+ or nameof(ChannelScheduleItem.PlaybackCountdownDetail)
+ or nameof(ChannelScheduleItem.PlaybackCountdownProgress)
or nameof(ChannelScheduleItem.ThumbnailSource))
{
if (e.PropertyName is nameof(ChannelScheduleItem.State)
@@ -1182,6 +1199,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(QueuedItemCount),
nameof(QueueFootnote),
nameof(QueueSummary),
+ nameof(PlaybackCountdownVisibility),
+ nameof(PlaybackCountdownText),
+ nameof(PlaybackCountdownDetail),
+ nameof(PlaybackCountdownProgress),
nameof(ScheduleDataIssueVisibility),
nameof(ScheduleDataIssueBackgroundBrush),
nameof(ScheduleDataIssueBorderBrush),
@@ -1198,6 +1219,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel),
+ nameof(PlaybackCountdownVisibility),
+ nameof(PlaybackCountdownText),
+ nameof(PlaybackCountdownDetail),
+ nameof(PlaybackCountdownProgress),
nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth),