Browse Source

lots of mobile updates including detail pages (#2135)

* update artist page layout

* update season page layout

* rework collection view

* cleanup

* update collection editor
pull/2136/head
Jason Dove 1 month ago committed by GitHub
parent
commit
1a09bb26d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      ErsatzTV.Application/MediaCards/Mapper.cs
  2. 2
      ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs
  3. 72
      ErsatzTV/Pages/Artist.razor
  4. 44
      ErsatzTV/Pages/CollectionEditor.razor
  5. 98
      ErsatzTV/Pages/CollectionItems.razor
  6. 58
      ErsatzTV/Pages/Collections.razor
  7. 2
      ErsatzTV/Pages/Movie.razor
  8. 61
      ErsatzTV/Pages/Search.razor
  9. 101
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  10. 2
      ErsatzTV/Pages/TelevisionSeasonList.razor
  11. 2
      ErsatzTV/Pages/TelevisionSeasonSearchResults.razor
  12. 2
      ErsatzTV/Pages/TraktLists.razor
  13. 3
      ErsatzTV/Shared/MediaCard.razor
  14. 9
      ErsatzTV/Validators/CollectionEditViewModelValidator.cs
  15. 4
      ErsatzTV/wwwroot/css/site.css

2
ErsatzTV.Application/MediaCards/Mapper.cs

@ -187,7 +187,7 @@ internal static class Mapper
e.EpisodeMetadata.Head(), e.EpisodeMetadata.Head(),
maybeJellyfin, maybeJellyfin,
maybeEmby, maybeEmby,
false, true,
string.Empty)) string.Empty))
.ToList(), .ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(), collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),

2
ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs

@ -79,9 +79,11 @@ public class GetCollectionCardsHandler :
.ThenInclude(i => (i as Episode).Season) .ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season) .ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.SeasonMetadata) .ThenInclude(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).MediaVersions) .ThenInclude(i => (i as Episode).MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)

72
ErsatzTV/Pages/Artist.razor

@ -19,17 +19,22 @@
<img src="@($"artwork/fanart/{_artist.FanArt}")" alt="fan art"/> <img src="@($"artwork/fanart/{_artist.FanArt}")" alt="fan art"/>
} }
</MudContainer> </MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px"> <MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px">
<div style="display: flex; flex-direction: row;" class="mb-6"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
@if (!string.IsNullOrWhiteSpace(_artist.Thumbnail)) @if (!string.IsNullOrWhiteSpace(_artist.Thumbnail))
{ {
<img class="mud-elevation-2 mr-6" <MudImage Elevation="2" Src="@($"artwork/thumbnails/{_artist.Thumbnail}")" Class="rounded-lg" Style="height: 220px; width: 220px; margin-left: auto; margin-right: auto;" />
style="border-radius: 4px; flex-shrink: 0; height: 220px; width: 220px"
src="@($"artwork/thumbnails/{_artist.Thumbnail}")" alt="artist thumbnail"/>
} }
<div style="display: flex; flex-direction: column; height: 100%"> <div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h2" Class="media-item-title">@_artist.Name</MudText> <MudStack Row="false">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
<MudText Typo="Typo.h4" Class="media-item-title">@_artist?.Name</MudText>
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<MudText Typo="Typo.h2" Class="media-item-title">@_artist?.Name</MudText>
</MudHidden>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_artist.Disambiguation</MudText> <MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_artist.Disambiguation</MudText>
</MudStack>
@if (!string.IsNullOrWhiteSpace(_artist.Biography)) @if (!string.IsNullOrWhiteSpace(_artist.Biography))
{ {
<MudCard Elevation="2" Class="mb-6"> <MudCard Elevation="2" Class="mb-6">
@ -47,23 +52,22 @@
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
} }
<div> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add" StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection"> OnClick="@AddToCollection">
Add To Collection Add To Collection
</MudButton> </MudButton>
<MudButton Class="ml-3" <MudButton Variant="Variant.Filled"
Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule" StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule"> OnClick="@AddToSchedule">
Add To Schedule Add To Schedule
</MudButton> </MudButton>
</MudStack>
</div> </div>
</div> </MudStack>
</div>
<MudCard Class="mb-6"> <MudCard Class="mb-6">
<MudCardContent> <MudCardContent>
@if (_sortedLanguages.Any()) @if (_sortedLanguages.Any())
@ -118,14 +122,15 @@
</MudCard> </MudCard>
</MudContainer> </MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8"> <MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="false" Spacing="6">
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards) @foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards)
{ {
<MudCard Class="mb-6" Style="display: flex; flex-direction: column"> <MudCard>
<div id="@($"music-video-{musicVideo.MusicVideoId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" id="@($"music-video-{musicVideo.MusicVideoId}")">
@if (!string.IsNullOrWhiteSpace(musicVideo.Poster)) @if (!string.IsNullOrWhiteSpace(musicVideo.Poster))
{ {
<MudPaper style="display: flex; flex-direction: column; position: relative"> <div style="display: flex; flex-direction: column; position: relative">
<MudCardMedia Image="@($"artwork/thumbnails/{musicVideo.Poster}")" Style="flex-grow: 1; height: 220px; width: 293px;"/> <MudImage Src="@($"artwork/thumbnails/{musicVideo.Poster}")" Style="height: 220px; max-width: 265px; margin-left: auto; margin-right: auto;"/>
@if (musicVideo.State == MediaItemState.FileNotFound) @if (musicVideo.State == MediaItemState.FileNotFound)
{ {
<div style="position: absolute; right: 10px; top: 8px;"> <div style="position: absolute; right: 10px; top: 8px;">
@ -138,11 +143,32 @@
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/> <MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div> </div>
} }
</MudPaper> </div>
} }
else else
{ {
<div style="display: flex; height: 220px; position: relative; width: 293px;"> <MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
@if (musicVideo.State is not MediaItemState.Normal and not MediaItemState.RemoteOnly)
{
<div style="display: flex; position: relative; height: 220px; width: 265px; ">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False"/>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (musicVideo.State == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</div>
}
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<div style="display: flex; position: relative; height: 220px; width: 265px; ">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False" /> <MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False" />
@if (musicVideo.State == MediaItemState.FileNotFound) @if (musicVideo.State == MediaItemState.FileNotFound)
{ {
@ -157,8 +183,9 @@
</div> </div>
} }
</div> </div>
</MudHidden>
} }
<MudCardContent Class="ml-3"> <MudCardContent>
<div style="display: flex; flex-direction: column; height: 100%"> <div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h4">@musicVideo.Title</MudText> <MudText Typo="Typo.h4">@musicVideo.Title</MudText>
@if (!string.IsNullOrWhiteSpace(musicVideo.Album)) @if (!string.IsNullOrWhiteSpace(musicVideo.Album))
@ -179,7 +206,7 @@
</div> </div>
</div> </div>
</MudCardContent> </MudCardContent>
</div> </MudStack>
@if (musicVideo.State == MediaItemState.FileNotFound) @if (musicVideo.State == MediaItemState.FileNotFound)
{ {
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap"> <div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
@ -198,6 +225,7 @@
} }
</MudCard> </MudCard>
} }
</MudStack>
</MudContainer> </MudContainer>
@code { @code {
@ -242,7 +270,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is MediaCollectionViewModel collection) if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{ {
await Mediator.Send(new AddArtistToCollection(collection.Id, ArtistId), _cts.Token); await Mediator.Send(new AddArtistToCollection(collection.Id, ArtistId), _cts.Token);
NavigationManager.NavigateTo($"media/collections/{collection.Id}"); NavigationManager.NavigateTo($"media/collections/{collection.Id}");
@ -256,7 +284,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule) if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{ {
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token); await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items"); NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
@ -270,7 +298,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is MediaCollectionViewModel collection) if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{ {
var request = new AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId); var request = new AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token); Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);

44
ErsatzTV/Pages/CollectionEditor.razor

@ -7,25 +7,23 @@
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IMediator Mediator @inject IMediator Mediator
<MudForm @ref="_form" @bind-IsValid="@_success" Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-8" OnClick="HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Collection" : "Add Collection")</MudButton>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div style="max-width: 400px;"> <MudText Typo="Typo.h5" Class="mb-2">Collection</MudText>
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Collection" : "Add Collection")</MudText> <MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync"> <div class="d-flex">
<FluentValidationValidator/> <MudText>Name</MudText>
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="ml-auto">
@(IsEdit ? "Save Changes" : "Add Collection")
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div> </div>
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Schedule name is required!"/>
</MudStack>
</MudContainer> </MudContainer>
</div>
</MudForm>
@code { @code {
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
@ -34,8 +32,8 @@
public int Id { get; set; } public int Id { get; set; }
private readonly CollectionEditViewModel _model = new(); private readonly CollectionEditViewModel _model = new();
private EditContext _editContext; private MudForm _form;
private ValidationMessageStore _messageStore; private bool _success;
public void Dispose() public void Dispose()
{ {
@ -60,18 +58,12 @@
} }
} }
protected override void OnInitialized()
{
_editContext = new EditContext(_model);
_messageStore = new ValidationMessageStore(_editContext);
}
private bool IsEdit => Id != 0; private bool IsEdit => Id != 0;
private async Task HandleSubmitAsync() private async Task HandleSubmitAsync()
{ {
_messageStore.Clear(); await _form.Validate();
if (_editContext.Validate()) if (_success)
{ {
Seq<BaseError> errorMessage = IsEdit ? (await Mediator.Send(new UpdateCollection(Id, _model.Name), _cts.Token)).LeftToSeq() : (await Mediator.Send(new CreateCollection(_model.Name), _cts.Token)).LeftToSeq(); Seq<BaseError> errorMessage = IsEdit ? (await Mediator.Send(new UpdateCollection(Id, _model.Name), _cts.Token)).LeftToSeq() : (await Mediator.Send(new CreateCollection(_model.Name), _cts.Token)).LeftToSeq();

98
ErsatzTV/Pages/CollectionItems.razor

@ -5,12 +5,15 @@
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime @inject IJSRuntime JsRuntime
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;"> <MudForm Style="max-height: 100%">
<div style="align-items: center; display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%;" class="ml-6 mr-6"> <MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
@if (IsSelectMode()) @if (IsSelectMode())
{ {
<div class="flex-grow-1">
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText> <MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto"> </div>
<div style="margin-left: auto" class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Error" Color="Color.Error"
StartIcon="@Icons.Material.Filled.Remove" StartIcon="@Icons.Material.Filled.Remove"
@ -25,14 +28,20 @@
Clear Selection Clear Selection
</MudButton> </MudButton>
</div> </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.Remove" Label="Remove From Collection" OnClick="@(_ => RemoveSelectionFromCollection(Id))"/>
<MudMenuItem Icon="@Icons.Material.Filled.Check" Label="Clear Selection" OnClick="ClearSelection"/>
</MudMenu>
</div>
} }
else else
{ {
<div style="align-items: center; display: flex; flex-direction: row;"> <div style="align-items: center; display: flex; width: 100%">
<MudText Typo="Typo.h4">@_data?.Name</MudText> <MudText>@_data?.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Edit" <div class="d-none d-md-flex" style="align-items: center">
Href="@($"media/collections/{Id}/edit")"/> <MudIconButton Icon="@Icons.Material.Filled.Edit" Href="@($"media/collections/{Id}/edit")" />
</div>
@if (_data?.MovieCards.Count > 0) @if (_data?.MovieCards.Count > 0)
{ {
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink> <MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
@ -77,10 +86,10 @@
{ {
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")">@_data.ImageCards.Count Images</MudLink> <MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")">@_data.ImageCards.Count Images</MudLink>
} }
</div>
@if (SupportsCustomOrdering()) @if (SupportsCustomOrdering())
{ {
<div style="margin-left: auto"> <div class="d-none d-md-flex" style="margin-left: auto">
<MudSwitch T="bool" <MudSwitch T="bool"
Value="@(_data?.UseCustomPlaybackOrder == true)" Value="@(_data?.UseCustomPlaybackOrder == true)"
Color="Color.Primary" Color="Color.Primary"
@ -88,11 +97,15 @@
Label="Use Custom Playback Order"/> Label="Use Custom Playback Order"/>
</div> </div>
} }
<div class="d-flex d-sm-none" style="margin-left: auto">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Href="@($"media/collections/{Id}/edit")" />
</div>
</div>
} }
</div> </div>
</MudPaper> </MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px"> <div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@if (_data?.MovieCards.Count > 0) @if (_data?.MovieCards.Count > 0)
{ {
<MudText GutterBottom="true" <MudText GutterBottom="true"
@ -101,8 +114,9 @@
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies Movies
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid" UserAttributes="@(new Dictionary<string, object> { { "id", "sortable-collection" } })"> <MudStack Row="true" Wrap="Wrap.Wrap" UserAttributes="@(new Dictionary<string, object> { { "id", "sortable-collection" } })">
@foreach (MovieCardViewModel card in OrderMovies(_data.MovieCards)) @foreach (MovieCardViewModel card in OrderMovies(_data.MovieCards))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
@ -113,7 +127,7 @@
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
@if (_data?.ShowCards.Count > 0) @if (_data?.ShowCards.Count > 0)
@ -122,10 +136,11 @@
Typo="Typo.h4" Typo="Typo.h4"
Style="scroll-margin-top: 160px" Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Television Shows Shows
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle)) @foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
@ -136,7 +151,7 @@
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
@if (_data?.SeasonCards.Count > 0) @if (_data?.SeasonCards.Count > 0)
@ -145,10 +160,11 @@
Typo="Typo.h4" Typo="Typo.h4"
Style="scroll-margin-top: 160px" Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Television Seasons Seasons
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle)) @foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
@ -161,7 +177,7 @@
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
@if (_data?.EpisodeCards.Count > 0) @if (_data?.EpisodeCards.Count > 0)
@ -170,26 +186,23 @@
Typo="Typo.h4" Typo="Typo.h4"
Style="scroll-margin-top: 160px" Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Television Episodes Episodes
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired)) @foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Href="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")" Href="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
Title="@card.ShowTitle" Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
Subtitle="@card.Title"
ContainerClass="media-card-episode-container mx-2"
CardClass="media-card-episode"
DeleteClicked="@(_ => RemoveEpisodeFromCollection(card))" DeleteClicked="@(_ => RemoveEpisodeFromCollection(card))"
ArtworkKind="@ArtworkKind.Thumbnail"
SelectColor="@Color.Error" SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))" SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
@if (_data?.ArtistCards.Count > 0) @if (_data?.ArtistCards.Count > 0)
@ -200,8 +213,9 @@
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists Artists
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (ArtistCardViewModel card in _data.ArtistCards.OrderBy(e => e.SortTitle)) @foreach (ArtistCardViewModel card in _data.ArtistCards.OrderBy(e => e.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
@ -213,7 +227,7 @@
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
@if (_data?.MusicVideoCards.Count > 0) @if (_data?.MusicVideoCards.Count > 0)
@ -224,8 +238,9 @@
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos Music Videos
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (MusicVideoCardViewModel card in _data.MusicVideoCards.OrderBy(e => e.SortTitle)) @foreach (MusicVideoCardViewModel card in _data.MusicVideoCards.OrderBy(e => e.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
@ -237,7 +252,7 @@
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
@if (_data?.OtherVideoCards.Count > 0) @if (_data?.OtherVideoCards.Count > 0)
@ -248,8 +263,9 @@
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos Other Videos
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (OtherVideoCardViewModel card in _data.OtherVideoCards.OrderBy(e => e.SortTitle)) @foreach (OtherVideoCardViewModel card in _data.OtherVideoCards.OrderBy(e => e.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
@ -261,7 +277,7 @@
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
@if (_data?.SongCards.Count > 0) @if (_data?.SongCards.Count > 0)
@ -272,8 +288,9 @@
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs Songs
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (SongCardViewModel card in _data.SongCards.OrderBy(e => e.SortTitle)) @foreach (SongCardViewModel card in _data.SongCards.OrderBy(e => e.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
@ -285,7 +302,7 @@
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
@if (_data?.ImageCards.Count > 0) @if (_data?.ImageCards.Count > 0)
@ -296,8 +313,9 @@
UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
Images Images
</MudText> </MudText>
<MudDivider Class="mb-6"/>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (ImageCardViewModel card in _data.ImageCards.OrderBy(e => e.SortTitle)) @foreach (ImageCardViewModel card in _data.ImageCards.OrderBy(e => e.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
@ -309,9 +327,11 @@
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudStack>
} }
</MudContainer> </MudContainer>
</div>
</MudForm>
@code { @code {
@ -512,7 +532,7 @@
IDialogReference dialog = await Dialog.ShowAsync<RemoveFromCollectionDialog>("Remove From Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<RemoveFromCollectionDialog>("Remove From Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled) if (result is { Canceled: false })
{ {
await Mediator.Send(request, CancellationToken); await Mediator.Send(request, CancellationToken);
await RefreshData(); await RefreshData();

58
ErsatzTV/Pages/Collections.razor

@ -6,27 +6,40 @@
@inject IDialogService Dialog @inject IDialogService Dialog
@inject IMediator Mediator @inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudForm Style="max-height: 100%">
<div> <MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="media/collections/add"> <div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
<div style="margin-right: auto" class="d-none d-md-flex">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="media/collections/add">
Add Collection Add Collection
</MudButton> </MudButton>
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" Href="media/multi-collections/add"> <MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" Href="media/multi-collections/add">
Add Multi Collection Add Multi Collection
</MudButton> </MudButton>
</div> </div>
<MudTable Class="mt-4" <div style="align-items: center; display: flex; margin-left: auto;" class="d-md-none">
Hover="true" <div class="flex-grow-1"></div>
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Collection" Href="media/collections/add"/>
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Multi Collection" Href="media/multi-collections/add"/>
</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">Collections</MudText>
<MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_collectionsRowsPerPage" @bind-RowsPerPage="@_collectionsRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<MediaCollectionViewModel>>>(ServerReloadCollections))" ServerData="@(new Func<TableState, CancellationToken, Task<TableData<MediaCollectionViewModel>>>(ServerReloadCollections))"
Dense="true" Dense="true"
@ref="_collectionsTable"> @ref="_collectionsTable">
<ToolBarContent>
<MudText Typo="Typo.h6">Collections</MudText>
</ToolBarContent>
<ColGroup> <ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/> <col/>
<col style="width: 120px;"/> <col style="width: 120px;"/>
</MudHidden>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>Name</MudTh>
@ -53,18 +66,18 @@
<MudTablePager/> <MudTablePager/>
</PagerContent> </PagerContent>
</MudTable> </MudTable>
<MudTable Class="mt-4" <MudText Typo="Typo.h5" Class="mt-6 mb-2">Multi Collections</MudText>
Hover="true" <MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_multiCollectionsRowsPerPage" @bind-RowsPerPage="@_multiCollectionsRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<MultiCollectionViewModel>>>(ServerReloadMultiCollections))" ServerData="@(new Func<TableState, CancellationToken, Task<TableData<MultiCollectionViewModel>>>(ServerReloadMultiCollections))"
Dense="true" Dense="true"
@ref="_multiCollectionsTable"> @ref="_multiCollectionsTable">
<ToolBarContent>
<MudText Typo="Typo.h6">Multi Collections</MudText>
</ToolBarContent>
<ColGroup> <ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/> <col/>
<col style="width: 120px;"/> <col style="width: 120px;"/>
</MudHidden>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>Name</MudTh>
@ -91,18 +104,18 @@
<MudTablePager/> <MudTablePager/>
</PagerContent> </PagerContent>
</MudTable> </MudTable>
<MudTable Class="mt-4" <MudText Typo="Typo.h5" Class="mt-6 mb-2">Smart Collections</MudText>
Hover="true" <MudDivider Class="mb-6"/>
<MudTable Hover="true"
@bind-RowsPerPage="@_smartCollectionsRowsPerPage" @bind-RowsPerPage="@_smartCollectionsRowsPerPage"
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<SmartCollectionViewModel>>>(ServerReloadSmartCollections))" ServerData="@(new Func<TableState, CancellationToken, Task<TableData<SmartCollectionViewModel>>>(ServerReloadSmartCollections))"
Dense="true" Dense="true"
@ref="_smartCollectionsTable"> @ref="_smartCollectionsTable">
<ToolBarContent>
<MudText Typo="Typo.h6">Smart Collections</MudText>
</ToolBarContent>
<ColGroup> <ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/> <col/>
<col style="width: 120px;"/> <col style="width: 120px;"/>
</MudHidden>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>Name</MudTh>
@ -129,7 +142,10 @@
<MudTablePager/> <MudTablePager/>
</PagerContent> </PagerContent>
</MudTable> </MudTable>
<div class="mt-6"></div>
</MudContainer> </MudContainer>
</div>
</MudForm>
@code { @code {
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
@ -167,7 +183,7 @@
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled) if (result is { Canceled: false })
{ {
await Mediator.Send(new DeleteCollection(collection.Id), _cts.Token); await Mediator.Send(new DeleteCollection(collection.Id), _cts.Token);
if (_collectionsTable != null) if (_collectionsTable != null)
@ -184,7 +200,7 @@
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Multi Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Multi Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled) if (result is { Canceled: false })
{ {
await Mediator.Send(new DeleteMultiCollection(collection.Id), _cts.Token); await Mediator.Send(new DeleteMultiCollection(collection.Id), _cts.Token);
if (_multiCollectionsTable != null) if (_multiCollectionsTable != null)
@ -201,7 +217,7 @@
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Smart Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Smart Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled) if (result is { Canceled: false })
{ {
await Mediator.Send(new DeleteSmartCollection(collection.Id), _cts.Token); await Mediator.Send(new DeleteSmartCollection(collection.Id), _cts.Token);
if (_smartCollectionsTable != null) if (_smartCollectionsTable != null)

2
ErsatzTV/Pages/Movie.razor

@ -32,7 +32,7 @@
@if (!string.IsNullOrWhiteSpace(_movie?.Poster)) @if (!string.IsNullOrWhiteSpace(_movie?.Poster))
{ {
<div style="display: flex; flex-direction: column; max-height: 325px; position: relative"> <div style="display: flex; flex-direction: column; max-height: 325px; position: relative">
<MudImage Src="@GetPosterUrl(_movie.Poster)" Class="rounded-lg" Style="max-height: 325px" ObjectFit="ObjectFit.Contain" /> <MudImage Src="@GetPosterUrl(_movie.Poster)" Elevation="2" Class="rounded-lg" Style="max-height: 325px; margin-left: auto; margin-right: auto" />
@if (_movie.MediaItemState == MediaItemState.FileNotFound) @if (_movie.MediaItemState == MediaItemState.FileNotFound)
{ {
<div style="position: absolute; right: 10px; top: 8px;"> <div style="position: absolute; right: 10px; top: 8px;">

61
ErsatzTV/Pages/Search.razor

@ -10,10 +10,10 @@
<MudForm Style="max-height: 100%"> <MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;"> <MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6"> <div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%; align-items: center" class="ml-6 mr-6">
@if (IsSelectMode()) @if (IsSelectMode())
{ {
<div style="align-items: center; display: flex; width: 100%;"> <div class="flex-grow-1">
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText> <MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
</div> </div>
<div style="margin-left: auto" class="d-none d-md-flex"> <div style="margin-left: auto" class="d-none d-md-flex">
@ -140,9 +140,7 @@
@if (_movies?.Count > 0) @if (_movies?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies Movies
</MudText> </MudText>
@if (_movies.Count > 50) @if (_movies.Count > 50)
@ -168,9 +166,7 @@
@if (_shows?.Count > 0) @if (_shows?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows Shows
</MudText> </MudText>
@if (_shows.Count > 50) @if (_shows.Count > 50)
@ -196,9 +192,7 @@
@if (_seasons?.Count > 0) @if (_seasons?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Seasons Seasons
</MudText> </MudText>
@if (_seasons.Count > 50) @if (_seasons.Count > 50)
@ -224,9 +218,7 @@
@if (_episodes?.Count > 0) @if (_episodes?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Episodes Episodes
</MudText> </MudText>
@if (_episodes.Count > 50) @if (_episodes.Count > 50)
@ -253,9 +245,7 @@
@if (_artists?.Count > 0) @if (_artists?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists Artists
</MudText> </MudText>
@if (_artists.Count > 50) @if (_artists.Count > 50)
@ -282,9 +272,7 @@
@if (_musicVideos?.Count > 0) @if (_musicVideos?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos Music Videos
</MudText> </MudText>
@if (_musicVideos.Count > 50) @if (_musicVideos.Count > 50)
@ -311,9 +299,7 @@
@if (_otherVideos?.Count > 0) @if (_otherVideos?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos Other Videos
</MudText> </MudText>
@if (_otherVideos.Count > 50) @if (_otherVideos.Count > 50)
@ -340,9 +326,7 @@
@if (_songs?.Count > 0) @if (_songs?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs Songs
</MudText> </MudText>
@if (_songs.Count > 50) @if (_songs.Count > 50)
@ -369,9 +353,7 @@
@if (_images?.Count > 0) @if (_images?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
Images Images
</MudText> </MudText>
@if (_images.Count > 50) @if (_images.Count > 50)
@ -604,6 +586,27 @@
} }
} }
if (card is TelevisionSeasonCardViewModel season)
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{season.Title} Season {season.TelevisionSeasonNumber}" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}");
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {season.Title} Season {season.TelevisionSeasonNumber} to collection {collection.Name}", Severity.Success));
}
}
if (card is ArtistCardViewModel artist) if (card is ArtistCardViewModel artist)
{ {
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } }; var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } };

101
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -27,69 +27,62 @@
} }
} }
</MudContainer> </MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px"> <MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px">
<div style="display: flex; flex-direction: row;"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
@if (!string.IsNullOrWhiteSpace(_season?.Poster)) @if (!string.IsNullOrWhiteSpace(_season?.Poster))
{ {
if (_season.Poster.StartsWith("http://") || _season.Poster.StartsWith("https://")) <MudImage Src="@GetPosterUrl(_season.Poster)" Elevation="2" Class="rounded-lg" Style="max-height: 325px; margin-left: auto; margin-right: auto"/>
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@_season.Poster" alt="season poster"/>
}
else
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@($"artwork/posters/{_season.Poster}")" alt="season poster"/>
} }
} <MudStack Row="false" Style="flex: 1">
<div style="display: flex; flex-direction: column; height: 100%"> <MudStack Row="false">
<MudLink Href="@($"media/tv/shows/{_season?.ShowId}")"> <MudLink Href="@($"media/tv/shows/{_season?.ShowId}")">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
<MudText Typo="Typo.h4" Class="media-item-title">@_season?.Title</MudText>
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<MudText Typo="Typo.h2" Class="media-item-title">@_season?.Title</MudText> <MudText Typo="Typo.h2" Class="media-item-title">@_season?.Title</MudText>
</MudHidden>
</MudLink> </MudLink>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_season?.Name</MudText> <MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_season?.Name</MudText>
<div> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add" StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection"> OnClick="@AddToCollection">
Add To Collection Add To Collection
</MudButton> </MudButton>
<MudButton Class="ml-3" <MudButton Variant="Variant.Filled"
Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlaylistAdd" StartIcon="@Icons.Material.Filled.PlaylistAdd"
OnClick="@AddToPlaylist"> OnClick="@AddToPlaylist">
Add To Playlist Add To Playlist
</MudButton> </MudButton>
<MudButton Class="ml-3" <MudButton Variant="Variant.Filled"
Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Schedule" StartIcon="@Icons.Material.Filled.Schedule"
OnClick="@AddToSchedule"> OnClick="@AddToSchedule">
Add To Schedule Add To Schedule
</MudButton> </MudButton>
</div> </MudStack>
</div> </MudStack>
</div> </MudStack>
</MudContainer> </MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8"> <MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudStack Row="false" Spacing="6">
@foreach (TelevisionEpisodeCardViewModel episode in _data.Cards) @foreach (TelevisionEpisodeCardViewModel episode in _data.Cards)
{ {
<MudCard Class="mb-6"> <MudCard>
<div id="@($"episode-{episode.EpisodeId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" id="@($"episode-{episode.EpisodeId}")">
@if (!string.IsNullOrWhiteSpace(episode.Poster)) @if (!string.IsNullOrWhiteSpace(episode.Poster))
{ {
<MudPaper style="display: flex; flex-direction: column; position: relative"> <div style="display: flex; flex-direction: column; position: relative">
@if (episode.Poster.StartsWith("http://") || episode.Poster.StartsWith("https://")) <MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
{ <MudImage Src="@GetPosterUrl(episode.Poster)" Style="max-height: 220px; max-width: 265px; margin-left: auto; margin-right: auto;"/>
<MudCardMedia Image="@episode.Poster" Style="flex-grow: 1; height: 220px; width: 392px;"/> </MudHidden>
} <MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
else <MudImage Src="@GetPosterUrl(episode.Poster)" Style="max-height: 220px; max-width: 392px; margin-left: auto; margin-right: auto;"/>
{ </MudHidden>
<MudCardMedia Image="@($"artwork/thumbnails/{episode.Poster}")" Style="flex-grow: 1; height: 220px; width: 392px;"/>
}
@if (episode.State == MediaItemState.FileNotFound) @if (episode.State == MediaItemState.FileNotFound)
{ {
<div style="position: absolute; right: 10px; top: 8px;"> <div style="position: absolute; right: 10px; top: 8px;">
@ -102,39 +95,35 @@
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/> <MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div> </div>
} }
</MudPaper> </div>
} }
<MudCardContent Class="ml-3"> <MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%"> <div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h4">@episode.Episode. @episode.Title</MudText> <MudText Typo="Typo.h4" Class="mb-6">@episode.Episode. @episode.Title</MudText>
<MudText Style="flex-grow: 1">@episode.Plot</MudText> <MudText Class="d-none d-md-flex" Style="flex-grow: 1">@episode.Plot</MudText>
<div class="mt-6"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown">
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add" StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddEpisodeToCollection(episode))"> OnClick="@(_ => AddEpisodeToCollection(episode))">
Add To Collection Add To Collection
</MudButton> </MudButton>
</div>
<div class="mt-6">
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlaylistAdd" StartIcon="@Icons.Material.Filled.PlaylistAdd"
OnClick="@(_ => AddEpisodeToPlaylist(episode))"> OnClick="@(_ => AddEpisodeToPlaylist(episode))">
Add To Playlist Add To Playlist
</MudButton> </MudButton>
</div>
<div class="mt-6">
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Secondary" Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Info" StartIcon="@Icons.Material.Filled.Info"
OnClick="@(_ => ShowInfo(episode))"> OnClick="@(_ => ShowInfo(episode))">
Show Media Info Show Media Info
</MudButton> </MudButton>
</div> </MudStack>
</div> </div>
</MudCardContent> </MudCardContent>
</div> </MudStack>
<div class="pl-3 pt-3"> <div class="pl-3 pt-3">
@if (episode.State == MediaItemState.FileNotFound) @if (episode.State == MediaItemState.FileNotFound)
{ {
@ -152,7 +141,7 @@
<MudText>@episode.LocalPath</MudText> <MudText>@episode.LocalPath</MudText>
</div> </div>
} }
<div style="display: flex; flex-direction: row; flex-wrap: wrap"> <div class="d-none d-md-flex" style="flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText> <MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText>
</div> </div>
@if (episode.Directors.Any()) @if (episode.Directors.Any())
@ -186,6 +175,7 @@
</div> </div>
</MudCard> </MudCard>
} }
</MudStack>
</MudContainer> </MudContainer>
@code { @code {
@ -196,8 +186,8 @@
private TelevisionSeasonViewModel _season; private TelevisionSeasonViewModel _season;
private int _pageSize => 100; private static int PageSize => 100;
private readonly int _pageNumber = 1; private const int PageNumber = 1;
private TelevisionEpisodeCardResultsViewModel _data = new(0, new List<TelevisionEpisodeCardViewModel>(), null); private TelevisionEpisodeCardResultsViewModel _data = new(0, new List<TelevisionEpisodeCardViewModel>(), null);
@ -229,7 +219,7 @@
await Mediator.Send(new GetTelevisionSeasonById(SeasonId), _cts.Token) await Mediator.Send(new GetTelevisionSeasonById(SeasonId), _cts.Token)
.IfSomeAsync(vm => _season = vm); .IfSomeAsync(vm => _season = vm);
_data = await Mediator.Send(new GetTelevisionEpisodeCards(SeasonId, _pageNumber, _pageSize), _cts.Token); _data = await Mediator.Send(new GetTelevisionEpisodeCards(SeasonId, PageNumber, PageSize), _cts.Token);
} }
private async Task AddToCollection() private async Task AddToCollection()
@ -239,7 +229,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is MediaCollectionViewModel collection) if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{ {
await Mediator.Send(new AddSeasonToCollection(collection.Id, SeasonId), _cts.Token); await Mediator.Send(new AddSeasonToCollection(collection.Id, SeasonId), _cts.Token);
NavigationManager.NavigateTo($"media/collections/{collection.Id}"); NavigationManager.NavigateTo($"media/collections/{collection.Id}");
@ -253,7 +243,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is PlaylistViewModel playlist) if (result is { Canceled: false, Data: PlaylistViewModel playlist })
{ {
await Mediator.Send(new AddSeasonToPlaylist(playlist.Id, SeasonId), _cts.Token); await Mediator.Send(new AddSeasonToPlaylist(playlist.Id, SeasonId), _cts.Token);
NavigationManager.NavigateTo($"media/playlists/{playlist.Id}"); NavigationManager.NavigateTo($"media/playlists/{playlist.Id}");
@ -267,7 +257,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule) if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{ {
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token); await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items"); NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
@ -281,7 +271,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is MediaCollectionViewModel collection) if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{ {
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId); var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token); Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
@ -302,7 +292,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is PlaylistViewModel playlist) if (result is { Canceled: false, Data: PlaylistViewModel playlist })
{ {
var request = new AddEpisodeToPlaylist(playlist.Id, episode.EpisodeId); var request = new AddEpisodeToPlaylist(playlist.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token); Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
@ -338,4 +328,9 @@
} }
} }
private static string GetPosterUrl(string poster)
{
return poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}";
}
} }

2
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -28,7 +28,7 @@
</MudContainer> </MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px"> <MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px">
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
<MudImage Src="@GetPosterUrl(_show.Poster)" Class="rounded-lg" Style="max-height: 325px" ObjectFit="ObjectFit.Contain" /> <MudImage Src="@GetPosterUrl(_show.Poster)" Elevation="2" Class="rounded-lg" Style="max-height: 325px; margin-left: auto; margin-right: auto" />
<div style="display: flex; flex-direction: column; height: 100%"> <div style="display: flex; flex-direction: column; height: 100%">
<MudStack Row="false"> <MudStack Row="false">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown"> <MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">

2
ErsatzTV/Pages/TelevisionSeasonSearchResults.razor

@ -138,7 +138,7 @@
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options); IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is MediaCollectionViewModel collection) if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{ {
var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId); var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken); Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);

2
ErsatzTV/Pages/TraktLists.razor

@ -152,7 +152,7 @@
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
IDialogReference dialog = await Dialog.ShowAsync<AddTraktListDialog>("Add Trakt List", options); IDialogReference dialog = await Dialog.ShowAsync<AddTraktListDialog>("Add Trakt List", options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is string url) if (result is { Canceled: false, Data: string url })
{ {
await WorkerChannel.WriteAsync(new AddTraktList(url), _cts.Token); await WorkerChannel.WriteAsync(new AddTraktList(url), _cts.Token);
} }

3
ErsatzTV/Shared/MediaCard.razor

@ -1,6 +1,5 @@
@using ErsatzTV.Application.MediaCards @using ErsatzTV.Application.MediaCards
@using static Prelude @using static Prelude
@inject IMediator Mediator
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")" id="@($"item_{Data.MediaItemId}")"> <div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")" id="@($"item_{Data.MediaItemId}")">
@ -78,7 +77,7 @@
<MudText Align="Align.Center" Class="media-card-title" UserAttributes="@(new Dictionary<string, object> { { "title", Data.Title } })"> <MudText Align="Align.Center" Class="media-card-title" UserAttributes="@(new Dictionary<string, object> { { "title", Data.Title } })">
@(Title ?? Data.Title) @(Title ?? Data.Title)
</MudText> </MudText>
<MudText Typo="Typo.body2" Align="Align.Center" Class="mud-text-secondary"> <MudText Typo="Typo.body2" Align="Align.Center" Class="media-card-title mud-text-secondary">
@(Subtitle ?? Data.Subtitle) @(Subtitle ?? Data.Subtitle)
</MudText> </MudText>
</div> </div>

9
ErsatzTV/Validators/CollectionEditViewModelValidator.cs

@ -1,9 +0,0 @@
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators;
public class CollectionEditViewModelValidator : AbstractValidator<CollectionEditViewModel>
{
public CollectionEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty();
}

4
ErsatzTV/wwwroot/css/site.css

@ -6,8 +6,6 @@
.media-card-container { width: 152px; } .media-card-container { width: 152px; }
.media-card-episode-container { width: 392px; }
.media-card { .media-card {
display: flex; display: flex;
/*filter: brightness(100%);*/ /*filter: brightness(100%);*/
@ -22,8 +20,6 @@
.media-card-selected-delete { box-shadow: 0 0 0 3px #f44336, 0 0 4px rgba(0, 0, 0, 0.3); } .media-card-selected-delete { box-shadow: 0 0 0 3px #f44336, 0 0 4px rgba(0, 0, 0, 0.3); }
.media-card-episode { width: 392px; }
.media-card:hover { /*filter: brightness(75%);*/ } .media-card:hover { /*filter: brightness(75%);*/ }
.media-card-title { .media-card-title {

Loading…
Cancel
Save