Simplify schedule playback order
This commit is contained in:
@@ -192,7 +192,7 @@
|
||||
BorderThickness="1"
|
||||
CornerRadius="18">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="대기열" />
|
||||
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="스케줄 목록" />
|
||||
<TextBlock
|
||||
FontFamily="Consolas"
|
||||
FontSize="30"
|
||||
@@ -754,7 +754,7 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleSectionTitleTextStyle}"
|
||||
Text="대기 중 목록" />
|
||||
Text="스케줄 목록" />
|
||||
<Button
|
||||
Width="22"
|
||||
Height="22"
|
||||
@@ -769,7 +769,7 @@
|
||||
<Flyout>
|
||||
<TextBlock
|
||||
MaxWidth="280"
|
||||
Text="빨강은 현재 송출 중, 노랑은 다음 송출 예정입니다. 목록의 다음 버튼은 다음 예약만 바꾸고, '다음 컷 즉시 송출'은 노란 컷을 바로 송출합니다. '다음 목록 즉시 송출'은 대기열 순서상 다음 목록을 바로 송출합니다."
|
||||
Text="빨강은 현재 송출 중, 노랑은 다음 송출 예정입니다. 스케줄은 항상 목록 위에서 아래로 진행하고, 마지막 컷 이후에는 반복이 켜져 있을 때만 맨 위로 이어집니다."
|
||||
TextWrapping="WrapWholeWords" />
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
@@ -799,24 +799,11 @@
|
||||
Command="{x:Bind ViewModel.ForceNextCommand}"
|
||||
Style="{StaticResource PanelCommandButtonStyle}">
|
||||
<TextBlock TextAlignment="Center">
|
||||
<Run Text="다음 컷" />
|
||||
<Run Text="다음" />
|
||||
<LineBreak />
|
||||
<Run Text="즉시 송출" />
|
||||
</TextBlock>
|
||||
</Button>
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.ForceQueueNextCommand}"
|
||||
Style="{StaticResource PanelCommandButtonStyle}">
|
||||
<TextBlock TextAlignment="Center">
|
||||
<Run Text="다음 목록" />
|
||||
<LineBreak />
|
||||
<Run Text="즉시 송출" />
|
||||
</TextBlock>
|
||||
</Button>
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.ResetQueueCommand}"
|
||||
Content="큐 초기화"
|
||||
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -961,10 +948,6 @@
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button
|
||||
Click="PromoteToNextButton_Click"
|
||||
Content="다음"
|
||||
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||
<Button
|
||||
Click="MoveUpButton_Click"
|
||||
Content="위"
|
||||
|
||||
@@ -91,7 +91,10 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
get => _state;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _state, value))
|
||||
var normalized = value == ScheduleQueueItemState.Completed
|
||||
? ScheduleQueueItemState.Queued
|
||||
: value;
|
||||
if (SetProperty(ref _state, normalized))
|
||||
{
|
||||
OnPropertyChanged(nameof(StateLabel));
|
||||
OnPropertyChanged(nameof(StateBrush));
|
||||
@@ -135,7 +138,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
ScheduleQueueItemState.Next => "다음",
|
||||
ScheduleQueueItemState.Sending => "준비",
|
||||
ScheduleQueueItemState.OnAir => "송출 중",
|
||||
ScheduleQueueItemState.Completed => "완료",
|
||||
ScheduleQueueItemState.Completed => "대기",
|
||||
ScheduleQueueItemState.Error => "오류",
|
||||
_ => "대기"
|
||||
};
|
||||
@@ -181,7 +184,7 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
});
|
||||
|
||||
[JsonIgnore]
|
||||
public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0;
|
||||
public double CardOpacity => 1.0;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class ChannelScheduleEngine
|
||||
private readonly SemaphoreSlim _executionLock = new(1, 1);
|
||||
private CancellationTokenSource? _playbackCts;
|
||||
private TaskCompletionSource<bool>? _advanceSignal;
|
||||
private Guid? _preferredNextItemId;
|
||||
private Guid? _lastPlaybackItemId;
|
||||
private Guid? _skipCurrentItemId;
|
||||
private ChannelScheduleItem? _directPlaybackItem;
|
||||
private PreparedCutFrame? _preparedCutFrame;
|
||||
@@ -86,6 +86,7 @@ public sealed class ChannelScheduleEngine
|
||||
ClearPreparedFrame(resetState: true);
|
||||
}
|
||||
|
||||
_lastPlaybackItemId = null;
|
||||
_playbackCts = new CancellationTokenSource();
|
||||
IsRunning = true;
|
||||
RefreshQueueMarkers();
|
||||
@@ -108,7 +109,7 @@ public sealed class ChannelScheduleEngine
|
||||
}
|
||||
|
||||
ClearPreparedFrame(resetState: true);
|
||||
_preferredNextItemId = null;
|
||||
_lastPlaybackItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
@@ -130,7 +131,7 @@ public sealed class ChannelScheduleEngine
|
||||
item.ClearInternalNextPreview();
|
||||
}
|
||||
|
||||
_preferredNextItemId = null;
|
||||
_lastPlaybackItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
ClearPreparedFrame(resetState: false);
|
||||
IsRunning = false;
|
||||
@@ -148,6 +149,7 @@ public sealed class ChannelScheduleEngine
|
||||
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_lastPlaybackItemId = null;
|
||||
ClearPreparedFrame(resetState: true);
|
||||
RefreshQueueMarkers();
|
||||
|
||||
@@ -251,7 +253,7 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_preferredNextItemId = null;
|
||||
_lastPlaybackItemId = null;
|
||||
ClearPreparedFrame(resetState: false);
|
||||
foreach (var item in Queue)
|
||||
{
|
||||
@@ -272,18 +274,6 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
public async Task ForceQueueNextAsync()
|
||||
{
|
||||
if (!IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nextListItem = GetNextPendingQueueItem();
|
||||
if (nextListItem is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_preferredNextItemId = nextListItem.Id;
|
||||
await ForceNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -298,9 +288,11 @@ public sealed class ChannelScheduleEngine
|
||||
if (activeItem is not null)
|
||||
{
|
||||
_skipCurrentItemId = activeItem.Id;
|
||||
activeItem.State = ScheduleQueueItemState.Completed;
|
||||
_lastPlaybackItemId = activeItem.Id;
|
||||
activeItem.State = ScheduleQueueItemState.Queued;
|
||||
activeItem.LastError = string.Empty;
|
||||
activeItem.CurrentRegionLabel = string.Empty;
|
||||
activeItem.ClearInternalNextPreview();
|
||||
}
|
||||
|
||||
RefreshQueueMarkers();
|
||||
@@ -319,9 +311,9 @@ public sealed class ChannelScheduleEngine
|
||||
var removed = Queue.Remove(item);
|
||||
if (removed)
|
||||
{
|
||||
if (_preferredNextItemId == item.Id)
|
||||
if (_lastPlaybackItemId == item.Id)
|
||||
{
|
||||
_preferredNextItemId = null;
|
||||
_lastPlaybackItemId = null;
|
||||
}
|
||||
|
||||
RefreshQueueMarkers();
|
||||
@@ -343,17 +335,25 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
public bool PromoteToNext(ChannelScheduleItem? item)
|
||||
{
|
||||
if (item is null || item.State is not (ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next))
|
||||
if (item is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_preferredNextItemId == item.Id && item.State == ScheduleQueueItemState.Next)
|
||||
var anchorItem = ActivePlaybackItem ?? GetLastPlaybackItem();
|
||||
var targetIndex = anchorItem is null ? 0 : Queue.IndexOf(anchorItem) + 1;
|
||||
var currentIndex = Queue.IndexOf(item);
|
||||
if (currentIndex < 0 || targetIndex < 0 || targetIndex > Queue.Count || currentIndex == targetIndex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_preferredNextItemId = item.Id;
|
||||
if (currentIndex < targetIndex)
|
||||
{
|
||||
targetIndex--;
|
||||
}
|
||||
|
||||
Queue.Move(currentIndex, targetIndex);
|
||||
RefreshQueueMarkers();
|
||||
return true;
|
||||
}
|
||||
@@ -387,12 +387,6 @@ public sealed class ChannelScheduleEngine
|
||||
var next = GetNextPlayableItem();
|
||||
if (next is null)
|
||||
{
|
||||
if (LoopEnabled && Queue.Count > 0)
|
||||
{
|
||||
Reset();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (EmptyScheduleBehavior == EmptyScheduleBehavior.ImmediateOut)
|
||||
{
|
||||
await _adapter.OutAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
@@ -411,6 +405,7 @@ public sealed class ChannelScheduleEngine
|
||||
{
|
||||
next.State = ScheduleQueueItemState.Error;
|
||||
next.LastError = "포맷을 찾을 수 없습니다.";
|
||||
_lastPlaybackItemId = next.Id;
|
||||
_logService.Error($"[{Channel}] Missing template: {next.FormatId}");
|
||||
RefreshQueueMarkers();
|
||||
continue;
|
||||
@@ -528,6 +523,7 @@ public sealed class ChannelScheduleEngine
|
||||
queueItem.State = ScheduleQueueItemState.Error;
|
||||
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
_lastPlaybackItemId = queueItem.Id;
|
||||
RefreshQueueMarkers();
|
||||
return;
|
||||
}
|
||||
@@ -613,7 +609,8 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
queueItem.ClearInternalNextPreview();
|
||||
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
|
||||
_lastPlaybackItemId = queueItem.Id;
|
||||
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : ScheduleQueueItemState.Error;
|
||||
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
|
||||
ClearSkipCurrentItem(queueItem);
|
||||
RefreshQueueMarkers();
|
||||
@@ -697,7 +694,8 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
queueItem.ClearInternalNextPreview();
|
||||
queueItem.State = playedAny ? ScheduleQueueItemState.Completed : ScheduleQueueItemState.Error;
|
||||
_lastPlaybackItemId = queueItem.Id;
|
||||
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : ScheduleQueueItemState.Error;
|
||||
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
|
||||
ClearSkipCurrentItem(queueItem);
|
||||
RefreshQueueMarkers();
|
||||
@@ -1112,8 +1110,7 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
private ChannelScheduleItem? GetPreviewNextItem(ChannelScheduleItem activeItem)
|
||||
{
|
||||
return Queue.FirstOrDefault(item => item != activeItem && item.State == ScheduleQueueItemState.Next)
|
||||
?? Queue.FirstOrDefault(item => item != activeItem && item.State == ScheduleQueueItemState.Queued);
|
||||
return GetSequentialNextItem(activeItem, allowWrap: LoopEnabled);
|
||||
}
|
||||
|
||||
private bool ShouldSkipCurrentItem(ChannelScheduleItem queueItem)
|
||||
@@ -1615,32 +1612,19 @@ public sealed class ChannelScheduleEngine
|
||||
return preparedItem;
|
||||
}
|
||||
|
||||
return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)
|
||||
?? Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
|
||||
}
|
||||
|
||||
private ChannelScheduleItem? GetNextPendingQueueItem()
|
||||
{
|
||||
return Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.Next or ScheduleQueueItemState.Queued);
|
||||
var anchorItem = ActivePlaybackItem ?? GetLastPlaybackItem();
|
||||
return GetSequentialNextItem(anchorItem, allowWrap: LoopEnabled);
|
||||
}
|
||||
|
||||
public void RefreshQueueMarkers()
|
||||
{
|
||||
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
|
||||
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();
|
||||
}
|
||||
var anchorItem = activeItem ?? GetLastPlaybackItem();
|
||||
var nextItem = GetSequentialNextItem(anchorItem, allowWrap: LoopEnabled);
|
||||
|
||||
foreach (var item in Queue)
|
||||
{
|
||||
if (item == activeItem || item.State == ScheduleQueueItemState.Completed || item.State == ScheduleQueueItemState.Error)
|
||||
if (item == activeItem || item.State == ScheduleQueueItemState.Error)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1649,6 +1633,50 @@ public sealed class ChannelScheduleEngine
|
||||
}
|
||||
}
|
||||
|
||||
private ChannelScheduleItem? GetLastPlaybackItem()
|
||||
{
|
||||
if (_lastPlaybackItemId is not { } itemId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Queue.FirstOrDefault(item => item.Id == itemId);
|
||||
}
|
||||
|
||||
private ChannelScheduleItem? GetSequentialNextItem(ChannelScheduleItem? anchorItem, bool allowWrap)
|
||||
{
|
||||
if (Queue.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (anchorItem is null)
|
||||
{
|
||||
return Queue.FirstOrDefault(IsPlayableQueueItem);
|
||||
}
|
||||
|
||||
var anchorIndex = Queue.IndexOf(anchorItem);
|
||||
if (anchorIndex < 0)
|
||||
{
|
||||
return Queue.FirstOrDefault(IsPlayableQueueItem);
|
||||
}
|
||||
|
||||
var nextItem = Queue.Skip(anchorIndex + 1).FirstOrDefault(IsPlayableQueueItem);
|
||||
if (nextItem is not null)
|
||||
{
|
||||
return nextItem;
|
||||
}
|
||||
|
||||
return allowWrap
|
||||
? Queue.Take(anchorIndex + 1).FirstOrDefault(IsPlayableQueueItem)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool IsPlayableQueueItem(ChannelScheduleItem item)
|
||||
{
|
||||
return item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next or ScheduleQueueItemState.Completed;
|
||||
}
|
||||
|
||||
private sealed record PreparedCutFrame(
|
||||
ChannelScheduleItem Item,
|
||||
FormatTemplateDefinition Template,
|
||||
|
||||
@@ -289,6 +289,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
if (SetProperty(ref _loopEnabled, value))
|
||||
{
|
||||
_engine.LoopEnabled = value;
|
||||
_engine.RefreshQueueMarkers();
|
||||
RefreshSummary();
|
||||
}
|
||||
}
|
||||
@@ -374,9 +375,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public double NextPreviewHeight => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Height;
|
||||
|
||||
public int QueuedItemCount => Queue.Count(item => item.State == ScheduleQueueItemState.Queued);
|
||||
public int QueuedItemCount => Queue.Count;
|
||||
|
||||
public string QueueFootnote => $"대기 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
|
||||
public string QueueFootnote => $"목록 {QueuedItemCount}건 / 컷 {AvailableFormats.Count}개";
|
||||
|
||||
public string QueueSummary
|
||||
{
|
||||
@@ -386,7 +387,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
|
||||
?? QueueNextPlaybackItem?.DisplayName
|
||||
?? "-";
|
||||
return $"현재 {current} / 다음 {next} / 대기 {Queue.Count(item => item.State == ScheduleQueueItemState.Queued)}";
|
||||
return $"현재 {current} / 다음 {next} / 목록 {Queue.Count}건";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,7 +530,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
await _engine.StartAsync().ConfigureAwait(false);
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 큐를 시작");
|
||||
_logService.Info($"[{Title}] 스케줄 시작");
|
||||
}
|
||||
|
||||
private async Task PrepareScheduleAsync()
|
||||
@@ -551,7 +552,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
await _engine.StopAsync().ConfigureAwait(false);
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 큐를 종료");
|
||||
_logService.Info($"[{Title}] 스케줄 정지");
|
||||
}
|
||||
|
||||
private async Task DirectPrepareAsync()
|
||||
@@ -718,7 +719,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
await _engine.ForceQueueNextAsync().ConfigureAwait(false);
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 대기열의 다음 목록을 즉시 송출");
|
||||
_logService.Info($"[{Title}] 다음 순서를 즉시 송출");
|
||||
}
|
||||
|
||||
private void AddFormat()
|
||||
@@ -745,7 +746,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
_engine.Reset();
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 큐를 첫 컷부터 다시 시작하도록 초기화했습니다.");
|
||||
_logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다.");
|
||||
}
|
||||
|
||||
private void RemoveItem(ChannelScheduleItem? item)
|
||||
|
||||
Reference in New Issue
Block a user