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),