Restore playback countdown display

This commit is contained in:
2026-05-14 12:04:55 +09:00
parent a743a5f709
commit 258b3ddaeb
4 changed files with 193 additions and 4 deletions

View File

@@ -46,6 +46,61 @@
</StackPanel> </StackPanel>
</Grid> </Grid>
<Border
Padding="14,12"
Background="#251A0B"
BorderBrush="{StaticResource ControlRoomSignalAmberBrush}"
BorderThickness="2"
CornerRadius="18"
Visibility="{x:Bind ViewModel.PlaybackCountdownVisibility, Mode=OneWay}">
<Grid ColumnSpacing="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border
Width="54"
Height="54"
Background="#33FFB81C"
BorderBrush="{StaticResource ControlRoomSignalAmberBrush}"
BorderThickness="1"
CornerRadius="27">
<SymbolIcon
Foreground="{StaticResource ControlRoomSignalAmberBrush}"
Symbol="Clock"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Spacing="6">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
FontFamily="Consolas"
FontSize="30"
Foreground="{StaticResource ControlRoomTextPrimaryBrush}"
Text="{x:Bind ViewModel.PlaybackCountdownText, Mode=OneWay}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Style="{StaticResource ConsoleBodyTextStyle}"
Text="{x:Bind ViewModel.PlaybackCountdownDetail, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</Grid>
<ProgressBar
Height="8"
Maximum="100"
Minimum="0"
Value="{x:Bind ViewModel.PlaybackCountdownProgress, Mode=OneWay}" />
</StackPanel>
</Grid>
</Border>
<Grid ColumnSpacing="12"> <Grid ColumnSpacing="12">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="1.25*" /> <ColumnDefinition Width="1.25*" />

View File

@@ -29,6 +29,8 @@ public sealed class ChannelScheduleItem : ObservableObject
private ImageSource? _internalNextPreviewSource; private ImageSource? _internalNextPreviewSource;
private string _internalNextPreviewStatusLabel = string.Empty; private string _internalNextPreviewStatusLabel = string.Empty;
private string _internalNextPreviewDisplayName = string.Empty; private string _internalNextPreviewDisplayName = string.Empty;
private double _playbackRemainingSeconds;
private double _playbackTotalSeconds;
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -105,6 +107,7 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(StateBadgeBackgroundBrush)); OnPropertyChanged(nameof(StateBadgeBackgroundBrush));
OnPropertyChanged(nameof(CardOpacity)); OnPropertyChanged(nameof(CardOpacity));
OnPropertyChanged(nameof(CanDelete)); OnPropertyChanged(nameof(CanDelete));
OnPropertyChanged(nameof(PlaybackCountdownVisibility));
OnIssueStateChanged(); OnIssueStateChanged();
} }
} }
@@ -307,6 +310,27 @@ public sealed class ChannelScheduleItem : ObservableObject
? "실데이터 프리뷰 준비 중" ? "실데이터 프리뷰 준비 중"
: _renderedPreviewStatusLabel; : _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] [JsonIgnore]
public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource; public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
@@ -403,6 +427,33 @@ public sealed class ChannelScheduleItem : ObservableObject
OnInternalNextPreviewChanged(); 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) public void UpdateThumbnailLayout(ThumbnailDisplayMetrics metrics)
{ {
ThumbnailWidth = metrics.Width; ThumbnailWidth = metrics.Width;
@@ -453,6 +504,15 @@ public sealed class ChannelScheduleItem : ObservableObject
OnPropertyChanged(nameof(InternalNextPreviewDisplayName)); 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) public static ChannelScheduleItem FromTemplate(FormatTemplateDefinition template, ScheduleRegionOption? regionOption = null)
{ {
var selectedRegion = regionOption ?? new ScheduleRegionOption var selectedRegion = regionOption ?? new ScheduleRegionOption

View File

@@ -130,6 +130,7 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty; item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview(); item.ClearRenderedPreview();
item.ClearInternalNextPreview(); item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
} }
_lastPlaybackItemId = null; _lastPlaybackItemId = null;
@@ -203,6 +204,7 @@ public sealed class ChannelScheduleEngine
item.State = ScheduleQueueItemState.Queued; item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty; item.CurrentRegionLabel = string.Empty;
item.ClearPlaybackCountdown();
throw; throw;
} }
finally finally
@@ -221,6 +223,7 @@ public sealed class ChannelScheduleEngine
_directPlaybackItem.CurrentRegionLabel = string.Empty; _directPlaybackItem.CurrentRegionLabel = string.Empty;
_directPlaybackItem.ClearRenderedPreview(); _directPlaybackItem.ClearRenderedPreview();
_directPlaybackItem.ClearInternalNextPreview(); _directPlaybackItem.ClearInternalNextPreview();
_directPlaybackItem.ClearPlaybackCountdown();
_directPlaybackItem = null; _directPlaybackItem = null;
} }
@@ -244,6 +247,7 @@ public sealed class ChannelScheduleEngine
{ {
item.State = ScheduleQueueItemState.Queued; item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty; item.CurrentRegionLabel = string.Empty;
item.ClearPlaybackCountdown();
} }
finally finally
{ {
@@ -264,6 +268,7 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty; item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview(); item.ClearRenderedPreview();
item.ClearInternalNextPreview(); item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
} }
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -281,6 +286,7 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty; item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview(); item.ClearRenderedPreview();
item.ClearInternalNextPreview(); item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
RefreshQueueMarkers(); RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty); QueueChanged?.Invoke(this, EventArgs.Empty);
return true; return true;
@@ -312,6 +318,7 @@ public sealed class ChannelScheduleEngine
activeItem.LastError = string.Empty; activeItem.LastError = string.Empty;
activeItem.CurrentRegionLabel = string.Empty; activeItem.CurrentRegionLabel = string.Empty;
activeItem.ClearInternalNextPreview(); activeItem.ClearInternalNextPreview();
activeItem.ClearPlaybackCountdown();
} }
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -449,6 +456,7 @@ public sealed class ChannelScheduleEngine
sendingItem.LastError = ex.Message; sendingItem.LastError = ex.Message;
sendingItem.CurrentRegionLabel = string.Empty; sendingItem.CurrentRegionLabel = string.Empty;
sendingItem.ClearInternalNextPreview(); sendingItem.ClearInternalNextPreview();
sendingItem.ClearPlaybackCountdown();
ClearSkipCurrentItem(sendingItem); ClearSkipCurrentItem(sendingItem);
} }
@@ -628,6 +636,7 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty; queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview(); queueItem.ClearInternalNextPreview();
queueItem.ClearPlaybackCountdown();
_lastPlaybackItemId = queueItem.Id; _lastPlaybackItemId = queueItem.Id;
if (playedAny) if (playedAny)
{ {
@@ -727,6 +736,7 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty; queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview(); queueItem.ClearInternalNextPreview();
queueItem.ClearPlaybackCountdown();
_lastPlaybackItemId = queueItem.Id; _lastPlaybackItemId = queueItem.Id;
if (playedAny) if (playedAny)
{ {
@@ -758,6 +768,7 @@ public sealed class ChannelScheduleEngine
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
queueItem.State = ScheduleQueueItemState.Sending; queueItem.State = ScheduleQueueItemState.Sending;
queueItem.ClearPlaybackCountdown();
RefreshQueueMarkers(); RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty); QueueChanged?.Invoke(this, EventArgs.Empty);
@@ -780,8 +791,11 @@ public sealed class ChannelScheduleEngine
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false); await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
var onAirAt = DateTimeOffset.Now; var onAirAt = DateTimeOffset.Now;
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
queueItem.State = ScheduleQueueItemState.OnAir; queueItem.State = ScheduleQueueItemState.OnAir;
queueItem.LastPlayedAt = onAirAt; queueItem.LastPlayedAt = onAirAt;
queueItem.UpdatePlaybackCountdown(playbackDuration, playbackDuration);
RefreshQueueMarkers(); RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty); QueueChanged?.Invoke(this, EventArgs.Empty);
@@ -792,8 +806,6 @@ public sealed class ChannelScheduleEngine
signal.TrySetResult(true); signal.TrySetResult(true);
} }
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false); await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty); QueueChanged?.Invoke(this, EventArgs.Empty);
if (!ShouldSkipCurrentItem(queueItem)) if (!ShouldSkipCurrentItem(queueItem))
@@ -829,17 +841,51 @@ public sealed class ChannelScheduleEngine
if (ShouldSkipCurrentItem(queueItem)) if (ShouldSkipCurrentItem(queueItem))
{ {
queueItem.ClearPlaybackCountdown();
return; return;
} }
var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt); var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (remainingDuration <= TimeSpan.Zero) if (remainingDuration <= TimeSpan.Zero)
{ {
queueItem.ClearPlaybackCountdown();
return; return;
} }
var delayTask = Task.Delay(remainingDuration, cancellationToken); await WaitForPlaybackCountdownAsync(queueItem, onAirAt, playbackDuration, signal, cancellationToken)
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false); .ConfigureAwait(false);
}
private static async Task WaitForPlaybackCountdownAsync(
ChannelScheduleItem queueItem,
DateTimeOffset onAirAt,
TimeSpan playbackDuration,
TaskCompletionSource<bool> 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( private async Task CaptureCurrentPreviewAsync(
@@ -1215,6 +1261,7 @@ public sealed class ChannelScheduleEngine
preparedFrame.Item.CurrentRegionLabel = string.Empty; preparedFrame.Item.CurrentRegionLabel = string.Empty;
preparedFrame.Item.ClearRenderedPreview(); preparedFrame.Item.ClearRenderedPreview();
preparedFrame.Item.ClearInternalNextPreview(); preparedFrame.Item.ClearInternalNextPreview();
preparedFrame.Item.ClearPlaybackCountdown();
} }
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem) private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
@@ -1732,6 +1779,7 @@ public sealed class ChannelScheduleEngine
item.CurrentRegionLabel = string.Empty; item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview(); item.ClearRenderedPreview();
item.ClearInternalNextPreview(); item.ClearInternalNextPreview();
item.ClearPlaybackCountdown();
} }
} }
@@ -1742,6 +1790,7 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty; queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearRenderedPreview(); queueItem.ClearRenderedPreview();
queueItem.ClearInternalNextPreview(); queueItem.ClearInternalNextPreview();
queueItem.ClearPlaybackCountdown();
} }
private static string NormalizeDataUnavailableReason(string reason) private static string NormalizeDataUnavailableReason(string reason)

View File

@@ -372,6 +372,15 @@ public sealed class ChannelScheduleViewModel : ObservableObject
?? QueueNextPlaybackItem?.PreviewStatusLabel ?? 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 CurrentPreviewWidth => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Width;
public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height; public double CurrentPreviewHeight => ResolvePlaybackPreviewMetrics(CurrentPlaybackItem).Height;
@@ -892,6 +901,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(NextPreviewSource), nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel), nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel), nameof(NextPreviewStatusLabel),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(CurrentPreviewWidth), nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight), nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth), nameof(NextPreviewWidth),
@@ -1155,6 +1168,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
or nameof(ChannelScheduleItem.HasInternalNextPreview) or nameof(ChannelScheduleItem.HasInternalNextPreview)
or nameof(ChannelScheduleItem.LastError) or nameof(ChannelScheduleItem.LastError)
or nameof(ChannelScheduleItem.LastIssueAt) or nameof(ChannelScheduleItem.LastIssueAt)
or nameof(ChannelScheduleItem.PlaybackCountdownVisibility)
or nameof(ChannelScheduleItem.PlaybackCountdownText)
or nameof(ChannelScheduleItem.PlaybackCountdownDetail)
or nameof(ChannelScheduleItem.PlaybackCountdownProgress)
or nameof(ChannelScheduleItem.ThumbnailSource)) or nameof(ChannelScheduleItem.ThumbnailSource))
{ {
if (e.PropertyName is nameof(ChannelScheduleItem.State) if (e.PropertyName is nameof(ChannelScheduleItem.State)
@@ -1182,6 +1199,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(QueuedItemCount), nameof(QueuedItemCount),
nameof(QueueFootnote), nameof(QueueFootnote),
nameof(QueueSummary), nameof(QueueSummary),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(ScheduleDataIssueVisibility), nameof(ScheduleDataIssueVisibility),
nameof(ScheduleDataIssueBackgroundBrush), nameof(ScheduleDataIssueBackgroundBrush),
nameof(ScheduleDataIssueBorderBrush), nameof(ScheduleDataIssueBorderBrush),
@@ -1198,6 +1219,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
nameof(NextPreviewSource), nameof(NextPreviewSource),
nameof(CurrentPreviewStatusLabel), nameof(CurrentPreviewStatusLabel),
nameof(NextPreviewStatusLabel), nameof(NextPreviewStatusLabel),
nameof(PlaybackCountdownVisibility),
nameof(PlaybackCountdownText),
nameof(PlaybackCountdownDetail),
nameof(PlaybackCountdownProgress),
nameof(CurrentPreviewWidth), nameof(CurrentPreviewWidth),
nameof(CurrentPreviewHeight), nameof(CurrentPreviewHeight),
nameof(NextPreviewWidth), nameof(NextPreviewWidth),