Simplify schedule playback order

This commit is contained in:
2026-05-14 11:50:16 +09:00
parent e76c37ef56
commit f9596a2033
4 changed files with 96 additions and 81 deletions

View File

@@ -192,7 +192,7 @@
BorderThickness="1" BorderThickness="1"
CornerRadius="18"> CornerRadius="18">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="대기열" /> <TextBlock Style="{StaticResource ConsoleLabelTextStyle}" Text="스케줄 목록" />
<TextBlock <TextBlock
FontFamily="Consolas" FontFamily="Consolas"
FontSize="30" FontSize="30"
@@ -754,7 +754,7 @@
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock <TextBlock
Style="{StaticResource ConsoleSectionTitleTextStyle}" Style="{StaticResource ConsoleSectionTitleTextStyle}"
Text="대기 중 목록" /> Text="스케줄 목록" />
<Button <Button
Width="22" Width="22"
Height="22" Height="22"
@@ -769,7 +769,7 @@
<Flyout> <Flyout>
<TextBlock <TextBlock
MaxWidth="280" MaxWidth="280"
Text="빨강은 현재 송출 중, 노랑은 다음 송출 예정입니다. 목록의 다음 버튼은 다음 예약만 바꾸고, '다음 컷 즉시 송출'은 노란 컷을 바로 송출합니다. '다음 목록 즉시 송출'은 대기열 순서상 다음 목록을 바로 송출합니다." Text="빨강은 현재 송출 중, 노랑은 다음 송출 예정입니다. 스케줄은 항상 목록 위에서 아래로 진행하고, 마지막 컷 이후에는 반복이 켜져 있을 때만 맨 위로 이어집니다."
TextWrapping="WrapWholeWords" /> TextWrapping="WrapWholeWords" />
</Flyout> </Flyout>
</Button.Flyout> </Button.Flyout>
@@ -799,24 +799,11 @@
Command="{x:Bind ViewModel.ForceNextCommand}" Command="{x:Bind ViewModel.ForceNextCommand}"
Style="{StaticResource PanelCommandButtonStyle}"> Style="{StaticResource PanelCommandButtonStyle}">
<TextBlock TextAlignment="Center"> <TextBlock TextAlignment="Center">
<Run Text="다음" /> <Run Text="다음" />
<LineBreak /> <LineBreak />
<Run Text="즉시 송출" /> <Run Text="즉시 송출" />
</TextBlock> </TextBlock>
</Button> </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> </StackPanel>
</Grid> </Grid>
@@ -961,10 +948,6 @@
Orientation="Horizontal" Orientation="Horizontal"
Spacing="8" Spacing="8"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Button
Click="PromoteToNextButton_Click"
Content="다음"
Style="{StaticResource PanelCommandButtonStyle}" />
<Button <Button
Click="MoveUpButton_Click" Click="MoveUpButton_Click"
Content="위" Content="위"

View File

@@ -91,7 +91,10 @@ public sealed class ChannelScheduleItem : ObservableObject
get => _state; get => _state;
set set
{ {
if (SetProperty(ref _state, value)) var normalized = value == ScheduleQueueItemState.Completed
? ScheduleQueueItemState.Queued
: value;
if (SetProperty(ref _state, normalized))
{ {
OnPropertyChanged(nameof(StateLabel)); OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StateBrush)); OnPropertyChanged(nameof(StateBrush));
@@ -135,7 +138,7 @@ public sealed class ChannelScheduleItem : ObservableObject
ScheduleQueueItemState.Next => "다음", ScheduleQueueItemState.Next => "다음",
ScheduleQueueItemState.Sending => "준비", ScheduleQueueItemState.Sending => "준비",
ScheduleQueueItemState.OnAir => "송출 중", ScheduleQueueItemState.OnAir => "송출 중",
ScheduleQueueItemState.Completed => "완료", ScheduleQueueItemState.Completed => "대기",
ScheduleQueueItemState.Error => "오류", ScheduleQueueItemState.Error => "오류",
_ => "대기" _ => "대기"
}; };
@@ -181,7 +184,7 @@ public sealed class ChannelScheduleItem : ObservableObject
}); });
[JsonIgnore] [JsonIgnore]
public double CardOpacity => State == ScheduleQueueItemState.Completed ? 0.45 : 1.0; public double CardOpacity => 1.0;
[JsonIgnore] [JsonIgnore]
public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending; public bool CanDelete => State is not ScheduleQueueItemState.OnAir and not ScheduleQueueItemState.Sending;

View File

@@ -24,7 +24,7 @@ public sealed class ChannelScheduleEngine
private readonly SemaphoreSlim _executionLock = new(1, 1); private readonly SemaphoreSlim _executionLock = new(1, 1);
private CancellationTokenSource? _playbackCts; private CancellationTokenSource? _playbackCts;
private TaskCompletionSource<bool>? _advanceSignal; private TaskCompletionSource<bool>? _advanceSignal;
private Guid? _preferredNextItemId; private Guid? _lastPlaybackItemId;
private Guid? _skipCurrentItemId; private Guid? _skipCurrentItemId;
private ChannelScheduleItem? _directPlaybackItem; private ChannelScheduleItem? _directPlaybackItem;
private PreparedCutFrame? _preparedCutFrame; private PreparedCutFrame? _preparedCutFrame;
@@ -86,6 +86,7 @@ public sealed class ChannelScheduleEngine
ClearPreparedFrame(resetState: true); ClearPreparedFrame(resetState: true);
} }
_lastPlaybackItemId = null;
_playbackCts = new CancellationTokenSource(); _playbackCts = new CancellationTokenSource();
IsRunning = true; IsRunning = true;
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -108,7 +109,7 @@ public sealed class ChannelScheduleEngine
} }
ClearPreparedFrame(resetState: true); ClearPreparedFrame(resetState: true);
_preferredNextItemId = null; _lastPlaybackItemId = null;
_skipCurrentItemId = null; _skipCurrentItemId = null;
RefreshQueueMarkers(); RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty); QueueChanged?.Invoke(this, EventArgs.Empty);
@@ -130,7 +131,7 @@ public sealed class ChannelScheduleEngine
item.ClearInternalNextPreview(); item.ClearInternalNextPreview();
} }
_preferredNextItemId = null; _lastPlaybackItemId = null;
_skipCurrentItemId = null; _skipCurrentItemId = null;
ClearPreparedFrame(resetState: false); ClearPreparedFrame(resetState: false);
IsRunning = false; IsRunning = false;
@@ -148,6 +149,7 @@ public sealed class ChannelScheduleEngine
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false); await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
_lastPlaybackItemId = null;
ClearPreparedFrame(resetState: true); ClearPreparedFrame(resetState: true);
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -251,7 +253,7 @@ public sealed class ChannelScheduleEngine
public void Reset() public void Reset()
{ {
_preferredNextItemId = null; _lastPlaybackItemId = null;
ClearPreparedFrame(resetState: false); ClearPreparedFrame(resetState: false);
foreach (var item in Queue) foreach (var item in Queue)
{ {
@@ -272,18 +274,6 @@ public sealed class ChannelScheduleEngine
public async Task ForceQueueNextAsync() public async Task ForceQueueNextAsync()
{ {
if (!IsRunning)
{
return;
}
var nextListItem = GetNextPendingQueueItem();
if (nextListItem is null)
{
return;
}
_preferredNextItemId = nextListItem.Id;
await ForceNextAsync().ConfigureAwait(false); await ForceNextAsync().ConfigureAwait(false);
} }
@@ -298,9 +288,11 @@ public sealed class ChannelScheduleEngine
if (activeItem is not null) if (activeItem is not null)
{ {
_skipCurrentItemId = activeItem.Id; _skipCurrentItemId = activeItem.Id;
activeItem.State = ScheduleQueueItemState.Completed; _lastPlaybackItemId = activeItem.Id;
activeItem.State = ScheduleQueueItemState.Queued;
activeItem.LastError = string.Empty; activeItem.LastError = string.Empty;
activeItem.CurrentRegionLabel = string.Empty; activeItem.CurrentRegionLabel = string.Empty;
activeItem.ClearInternalNextPreview();
} }
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -319,9 +311,9 @@ public sealed class ChannelScheduleEngine
var removed = Queue.Remove(item); var removed = Queue.Remove(item);
if (removed) if (removed)
{ {
if (_preferredNextItemId == item.Id) if (_lastPlaybackItemId == item.Id)
{ {
_preferredNextItemId = null; _lastPlaybackItemId = null;
} }
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -343,17 +335,25 @@ public sealed class ChannelScheduleEngine
public bool PromoteToNext(ChannelScheduleItem? item) public bool PromoteToNext(ChannelScheduleItem? item)
{ {
if (item is null || item.State is not (ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next)) if (item is null)
{ {
return false; 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; return false;
} }
_preferredNextItemId = item.Id; if (currentIndex < targetIndex)
{
targetIndex--;
}
Queue.Move(currentIndex, targetIndex);
RefreshQueueMarkers(); RefreshQueueMarkers();
return true; return true;
} }
@@ -387,12 +387,6 @@ public sealed class ChannelScheduleEngine
var next = GetNextPlayableItem(); var next = GetNextPlayableItem();
if (next is null) if (next is null)
{ {
if (LoopEnabled && Queue.Count > 0)
{
Reset();
continue;
}
if (EmptyScheduleBehavior == EmptyScheduleBehavior.ImmediateOut) if (EmptyScheduleBehavior == EmptyScheduleBehavior.ImmediateOut)
{ {
await _adapter.OutAsync(Channel, cancellationToken).ConfigureAwait(false); await _adapter.OutAsync(Channel, cancellationToken).ConfigureAwait(false);
@@ -411,6 +405,7 @@ public sealed class ChannelScheduleEngine
{ {
next.State = ScheduleQueueItemState.Error; next.State = ScheduleQueueItemState.Error;
next.LastError = "포맷을 찾을 수 없습니다."; next.LastError = "포맷을 찾을 수 없습니다.";
_lastPlaybackItemId = next.Id;
_logService.Error($"[{Channel}] Missing template: {next.FormatId}"); _logService.Error($"[{Channel}] Missing template: {next.FormatId}");
RefreshQueueMarkers(); RefreshQueueMarkers();
continue; continue;
@@ -528,6 +523,7 @@ public sealed class ChannelScheduleEngine
queueItem.State = ScheduleQueueItemState.Error; queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다."; queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
queueItem.CurrentRegionLabel = string.Empty; queueItem.CurrentRegionLabel = string.Empty;
_lastPlaybackItemId = queueItem.Id;
RefreshQueueMarkers(); RefreshQueueMarkers();
return; return;
} }
@@ -613,7 +609,8 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty; queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview(); 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); queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
ClearSkipCurrentItem(queueItem); ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -697,7 +694,8 @@ public sealed class ChannelScheduleEngine
queueItem.CurrentRegionLabel = string.Empty; queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview(); 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); queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
ClearSkipCurrentItem(queueItem); ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers(); RefreshQueueMarkers();
@@ -1112,8 +1110,7 @@ public sealed class ChannelScheduleEngine
private ChannelScheduleItem? GetPreviewNextItem(ChannelScheduleItem activeItem) private ChannelScheduleItem? GetPreviewNextItem(ChannelScheduleItem activeItem)
{ {
return Queue.FirstOrDefault(item => item != activeItem && item.State == ScheduleQueueItemState.Next) return GetSequentialNextItem(activeItem, allowWrap: LoopEnabled);
?? Queue.FirstOrDefault(item => item != activeItem && item.State == ScheduleQueueItemState.Queued);
} }
private bool ShouldSkipCurrentItem(ChannelScheduleItem queueItem) private bool ShouldSkipCurrentItem(ChannelScheduleItem queueItem)
@@ -1615,32 +1612,19 @@ public sealed class ChannelScheduleEngine
return preparedItem; return preparedItem;
} }
return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next) var anchorItem = ActivePlaybackItem ?? GetLastPlaybackItem();
?? Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued); return GetSequentialNextItem(anchorItem, allowWrap: LoopEnabled);
}
private ChannelScheduleItem? GetNextPendingQueueItem()
{
return Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.Next or ScheduleQueueItemState.Queued);
} }
public void RefreshQueueMarkers() public void RefreshQueueMarkers()
{ {
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending); var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
var pendingItems = Queue var anchorItem = activeItem ?? GetLastPlaybackItem();
.Where(item => item != activeItem && item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next) var nextItem = GetSequentialNextItem(anchorItem, allowWrap: LoopEnabled);
.ToArray();
var nextItem = pendingItems.FirstOrDefault(item => _preferredNextItemId == item.Id);
if (nextItem is null)
{
_preferredNextItemId = null;
nextItem = pendingItems.FirstOrDefault();
}
foreach (var item in Queue) foreach (var item in Queue)
{ {
if (item == activeItem || item.State == ScheduleQueueItemState.Completed || item.State == ScheduleQueueItemState.Error) if (item == activeItem || item.State == ScheduleQueueItemState.Error)
{ {
continue; 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( private sealed record PreparedCutFrame(
ChannelScheduleItem Item, ChannelScheduleItem Item,
FormatTemplateDefinition Template, FormatTemplateDefinition Template,

View File

@@ -289,6 +289,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
if (SetProperty(ref _loopEnabled, value)) if (SetProperty(ref _loopEnabled, value))
{ {
_engine.LoopEnabled = value; _engine.LoopEnabled = value;
_engine.RefreshQueueMarkers();
RefreshSummary(); RefreshSummary();
} }
} }
@@ -374,9 +375,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
public double NextPreviewHeight => ResolvePlaybackPreviewMetrics(NextPlaybackItem).Height; 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 public string QueueSummary
{ {
@@ -386,7 +387,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName var next = InternalNextPlaybackItem?.InternalNextPreviewDisplayName
?? QueueNextPlaybackItem?.DisplayName ?? 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); await _engine.StartAsync().ConfigureAwait(false);
RefreshSummary(); RefreshSummary();
_logService.Info($"[{Title}] 큐를 시작"); _logService.Info($"[{Title}] 스케줄 시작");
} }
private async Task PrepareScheduleAsync() private async Task PrepareScheduleAsync()
@@ -551,7 +552,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{ {
await _engine.StopAsync().ConfigureAwait(false); await _engine.StopAsync().ConfigureAwait(false);
RefreshSummary(); RefreshSummary();
_logService.Info($"[{Title}] 큐를 종료"); _logService.Info($"[{Title}] 스케줄 정지");
} }
private async Task DirectPrepareAsync() private async Task DirectPrepareAsync()
@@ -718,7 +719,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{ {
await _engine.ForceQueueNextAsync().ConfigureAwait(false); await _engine.ForceQueueNextAsync().ConfigureAwait(false);
RefreshSummary(); RefreshSummary();
_logService.Info($"[{Title}] 대기열의 다음 목록을 즉시 송출"); _logService.Info($"[{Title}] 다음 순서를 즉시 송출");
} }
private void AddFormat() private void AddFormat()
@@ -745,7 +746,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
{ {
_engine.Reset(); _engine.Reset();
RefreshSummary(); RefreshSummary();
_logService.Info($"[{Title}] 큐를 첫 컷부터 다시 시작하도록 초기화했습니다."); _logService.Info($"[{Title}] 스케줄을 맨 위 컷부터 다시 시작하도록 되돌렸습니다.");
} }
private void RemoveItem(ChannelScheduleItem? item) private void RemoveItem(ChannelScheduleItem? item)