Files
ersatztv/ErsatzTV/Pages/BlockEditor.razor
T

608 lines
25 KiB
Plaintext

@page "/blocks/{Id:int}"
@using ErsatzTV.Application.Graphics
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Application.Search
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Domain.Scheduling
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<BlockEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
<div class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@SaveChanges" StartIcon="@Icons.Material.Filled.Save">
Save Block
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@AddBlockItem" StartIcon="@Icons.Material.Filled.PlaylistAdd">
Add Block Item
</MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Secondary" OnClick="@PreviewPlayout" StartIcon="@Icons.Material.Filled.Preview">
Preview Block Playout
</MudButton>
</div>
<div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
<div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save Block" OnClick="@SaveChanges"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Block Item" OnClick="@AddBlockItem"/>
<MudMenuItem Icon="@Icons.Material.Filled.Preview" Label="Preview Block Playout" OnClick="@PreviewPlayout"/>
</MudMenu>
</div>
</div>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Block</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Block Group Name</MudText>
</div>
<MudTextField @bind-Value="_block.GroupName" Disabled="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Block Name</MudText>
</div>
<MudTextField @bind-Value="_block.Name" For="@(() => _block.Name)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Duration</MudText>
</div>
<MudTextField T="int"
@bind-Value="_durationHours"
Adornment="Adornment.End"
AdornmentText="hours"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudSelect T="int" @bind-Value="_durationMinutes" Adornment="Adornment.End" AdornmentText="minutes">
<MudSelectItem Value="0"/>
<MudSelectItem Value="5"/>
<MudSelectItem Value="10"/>
<MudSelectItem Value="15"/>
<MudSelectItem Value="20"/>
<MudSelectItem Value="25"/>
<MudSelectItem Value="30"/>
<MudSelectItem Value="35"/>
<MudSelectItem Value="40"/>
<MudSelectItem Value="45"/>
<MudSelectItem Value="50"/>
<MudSelectItem Value="55"/>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Stop scheduling block items</MudText>
</div>
<MudSelect T="BlockStopScheduling"
@bind-Value="_block.StopScheduling"
For="@(() => _block.StopScheduling)">
<MudSelectItem Value="BlockStopScheduling.BeforeDurationEnd">Before Duration End</MudSelectItem>
<MudSelectItem Value="BlockStopScheduling.AfterDurationEnd">After Duration End</MudSelectItem>
</MudSelect>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Block Items</MudText>
<MudDivider Class="mb-6"/>
<MudTable Class="mt-6" Hover="true" Items="_block.Items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem" RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col style="width: 240px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Collection</MudTh>
<MudTh>Playback Order</MudTh>
<MudTh>Show In EPG</MudTh>
<MudTh>Disable Watermarks</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Collection">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.CollectionName
</MudText>
</MudTd>
<MudTd DataLabel="Playback Order">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.PlaybackOrder
</MudText>
</MudTd>
<MudTd DataLabel="Show In EPG">
<MudCheckBox T="bool" Value="@context.IncludeInProgramGuide" ValueChanged="@(e => UpdateEPG(context, e))"/>
</MudTd>
<MudTd DataLabel="Disable Watermarks">
<MudCheckBox T="bool" Value="@context.DisableWatermarks" ValueChanged="@(e => UpdateWatermark(context, e))"/>
</MudTd>
<MudTd>
<div class="d-flex">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyItem(context))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_block.Items.All(x => x.Index >= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_block.Items.All(x => x.Index <= context.Index))">
</MudIconButton>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemoveBlockItem(context))">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@if (_selectedItem is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Block Item</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection Type</MudText>
</div>
<MudSelect @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
@* <MudSelectItem Value="ProgramScheduleItemCollectionType.Artist">Artist</MudSelectItem> *@
@* <MudSelectItem Value="ProgramScheduleItemCollectionType.MultiCollection">Multi Collection</MudSelectItem> *@
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
</MudSelect>
</MudStack>
@if (_selectedItem.CollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
@bind-Value="_selectedItem.Collection" SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
@bind-Value="_selectedItem.MultiCollection" SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
@bind-Value="_selectedItem.SmartCollection" SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_selectedItem.CollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playback Order</MudText>
</div>
<MudSelect @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)">
@switch (_selectedItem.CollectionType)
{
case CollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case CollectionType.Collection:
case CollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.RandomRotation">Random Rotation</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case CollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem> *@
break;
case CollectionType.TelevisionSeason:
case CollectionType.Artist:
case CollectionType.FakeCollection:
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
break;
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Watermarks</MudText>
</div>
<MudSelect T="WatermarkViewModel"
@bind-SelectedValues="_selectedItem.Watermarks"
ToStringFunc="@(wm => wm?.Name)"
Clearable="true"
MultiSelection="true">
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem Value="@watermark">@watermark.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Graphics Elements</MudText>
</div>
<MudSelect T="GraphicsElementViewModel"
@bind-SelectedValues="_selectedItem.GraphicsElements"
ToStringFunc="@(ge => ge?.Name)"
Clearable="true"
MultiSelection="true">
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
{
<MudSelectItem Value="@graphicsElement">@graphicsElement.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
else if (_previewItems != null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
Dense="true"
Items="_previewItems">
<HeaderContent>
<MudTh>Start</MudTh>
<MudTh>Finish</MudTh>
<MudTh>Media Item</MudTh>
<MudTh>Duration</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start">@context.Start.ToString(@"hh\:mm\:ss")</MudTd>
<MudTd DataLabel="Finish">@context.Finish.ToString(@"hh\:mm\:ss")</MudTd>
<MudTd DataLabel="Media Item">@context.Title</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd>
</RowTemplate>
</MudTable>
}
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private BlockItemsEditViewModel _block = new() { Items = [] };
private BlockItemEditViewModel _selectedItem;
private List<WatermarkViewModel> _watermarks = [];
private List<GraphicsElementViewModel> _graphicsElements = [];
private List<PlayoutItemPreviewViewModel> _previewItems;
private int _durationHours;
private int _durationMinutes = 15;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<BlockViewModel> maybeBlock = await Mediator.Send(new GetBlockById(Id), token);
if (maybeBlock.IsNone)
{
NavigationManager.NavigateTo("blocks");
return;
}
_watermarks = await Mediator.Send(new GetAllWatermarks(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_graphicsElements = await Mediator.Send(new GetAllGraphicsElements(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
foreach (BlockViewModel block in maybeBlock)
{
_block = new BlockItemsEditViewModel
{
GroupId = block.GroupId,
GroupName = block.GroupName,
Name = block.Name,
Minutes = block.Minutes,
StopScheduling = block.StopScheduling,
Items = []
};
_durationHours = _block.Minutes / 60;
_durationMinutes = _block.Minutes % 60;
}
Option<IEnumerable<BlockItemViewModel>> maybeResults = await Mediator.Send(new GetBlockItems(Id), token);
foreach (IEnumerable<BlockItemViewModel> items in maybeResults)
{
_block.Items.AddRange(items.Map(ProjectToEditViewModel));
if (_block.Items.Count == 1)
{
_selectedItem = _block.Items.Head();
}
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MediaCollectionViewModel>();
}
return await Mediator.Send(new SearchCollections(value), _cts.Token);
}
private async Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MultiCollectionViewModel>();
}
return await Mediator.Send(new SearchMultiCollections(value), _cts.Token);
}
private async Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<SmartCollectionViewModel>();
}
return await Mediator.Send(new SearchSmartCollections(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionShows(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionSeasons(value), _cts.Token);
}
// private async Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value, CancellationToken cancellationToken)
// {
// if (string.IsNullOrWhiteSpace(value))
// {
// return new List<NamedMediaItemViewModel>();
// }
//
// return await Mediator.Send(new SearchArtists(value), _cts.Token);
// }
private static BlockItemEditViewModel ProjectToEditViewModel(BlockItemViewModel item) =>
new()
{
Id = item.Id,
Index = item.Index,
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
IncludeInProgramGuide = item.IncludeInProgramGuide,
DisableWatermarks = item.DisableWatermarks,
Watermarks = item.Watermarks,
GraphicsElements = item.GraphicsElements
};
private void AddBlockItem()
{
var item = new BlockItemEditViewModel
{
Index = _block.Items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
PlaybackOrder = PlaybackOrder.Chronological,
CollectionType = CollectionType.Collection,
Watermarks = [],
GraphicsElements = []
};
_block.Items.Add(item);
_selectedItem = item;
}
private void CopyItem(BlockItemEditViewModel item)
{
var newItem = new BlockItemEditViewModel
{
Index = item.Index + 1,
PlaybackOrder = item.PlaybackOrder,
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
IncludeInProgramGuide = item.IncludeInProgramGuide,
DisableWatermarks = item.DisableWatermarks,
Watermarks = item.Watermarks.ToList(),
GraphicsElements = item.GraphicsElements.ToList()
};
foreach (BlockItemEditViewModel i in _block.Items.Filter(bi => bi.Index >= newItem.Index))
{
i.Index += 1;
}
_block.Items.Add(newItem);
_selectedItem = newItem;
}
private void RemoveBlockItem(BlockItemEditViewModel item)
{
_selectedItem = null;
_block.Items.Remove(item);
}
private void MoveItemUp(BlockItemEditViewModel item)
{
// swap with lower index
BlockItemEditViewModel toSwap = _block.Items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private void MoveItemDown(BlockItemEditViewModel item)
{
// swap with higher index
BlockItemEditViewModel toSwap = _block.Items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private async Task SaveChanges()
{
Seq<BaseError> errorMessages = await Mediator
.Send(GenerateReplaceRequest(), _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving block: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving block: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("blocks"));
}
private ReplaceBlockItems GenerateReplaceRequest()
{
var items = _block.Items.Map(item => new ReplaceBlockItem(
item.Index,
item.CollectionType,
item.Collection?.Id,
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.MediaItem?.MediaItemId,
item.PlaybackOrder,
item.IncludeInProgramGuide,
item.DisableWatermarks,
item.Watermarks.Map(wm => wm.Id).ToList(),
item.GraphicsElements.Map(wm => wm.Id).ToList())
).ToList();
_block.Minutes = _durationHours * 60 + _durationMinutes;
return new ReplaceBlockItems(_block.GroupId, Id, _block.Name, _block.Minutes, _block.StopScheduling, items);
}
private async Task PreviewPlayout()
{
_selectedItem = null;
_previewItems = await Mediator.Send(new PreviewBlockPlayout(GenerateReplaceRequest()), _cts.Token);
}
private static void UpdateEPG(BlockItemEditViewModel context, bool includeInProgramGuide) => context.IncludeInProgramGuide = includeInProgramGuide;
private static void UpdateWatermark(BlockItemEditViewModel context, bool disableWatermarks) => context.DisableWatermarks = disableWatermarks;
private string SelectedRowClassFunc(BlockItemEditViewModel element, int rowNumber) => _selectedItem != null && _selectedItem == element ? "selected" : string.Empty;
}