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"
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="위"

View File

@@ -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;

View File

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

View File

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