Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78262ea4cb | ||
|
|
3c5d05908a | ||
|
|
4407dd53af | ||
|
|
408b0deb89 |
@@ -5,7 +5,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10
|
||||
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves the remaining un-migrated admin screens — playback troubleshooting, multi/rerun collections, and playlist editing depth; Blazor home = `/system/health`, reachable via the Settings → System "Classic UI" link. Media detail pages + image folder browser landed in the SPA via #141 (PR #183); its removal is #91 phase (b), gated on #145 (playback troubleshooting) and API gaps #151/#152/#153/#155 (scheduling parity #144/#162 DONE 2026-07-07: blocks/templates/decos/deco-templates/playout editors all in the SPA; #141/#158/#161/#180 also DONE)
|
||||
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the ONLY UI. The legacy Blazor Server UI (MudBlazor) was removed in #91 phase (b); root `/` and every legacy route now 302 to `/app`, either via an explicit redirect in `ErsatzTV/LegacyUiRedirects.cs` or the Startup catch-all fallback (any unmatched non-`/api`/`/artwork`/`/docs`/`/openapi` path → `/app`). Historical parity work: media detail pages + image folder browser landed via #141 (PR #183); scheduling parity #144/#162, #141/#158/#161/#180, #145, #151/#152/#153/#155, and the media-source write API/SPA #202 are all DONE.
|
||||
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
|
||||
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
|
||||
@@ -15,7 +15,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, legacy Blazor pages, DI setup |
|
||||
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, DI setup |
|
||||
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
|
||||
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
|
||||
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
|
||||
@@ -55,7 +55,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
|
||||
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, the ChicoryTV SPA, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
|
||||
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
|
||||
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
|
||||
|
||||
@@ -70,7 +70,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
|
||||
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
|
||||
- Keep UI thin: the SPA talks to `/api/*` only; controllers delegate to MediatR handlers. All UI is in the SPA (`web/`)
|
||||
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
|
||||
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
|
||||
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
|
||||
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
|
||||
<PackageVersion Include="BlazorSortable" Version="6.0.2" />
|
||||
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.2" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.79" />
|
||||
@@ -22,8 +19,6 @@
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageVersion Include="Heron.MudCalendar" Version="3.4.0" />
|
||||
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageVersion Include="Jint" Version="4.5.0" />
|
||||
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
|
||||
@@ -33,9 +28,7 @@
|
||||
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Markdig" Version="0.44.0" />
|
||||
<PackageVersion Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageVersion Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
|
||||
@@ -61,8 +54,6 @@
|
||||
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
|
||||
<PackageVersion Include="MudBlazor" Version="8.15.0" />
|
||||
<PackageVersion Include="NaturalSort.Extension" Version="4.4.1" />
|
||||
<PackageVersion Include="NCalcSync" Version="6.3.2" />
|
||||
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
|
||||
@@ -121,9 +121,32 @@ public class LegacyUiRedirectsTests
|
||||
target.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[TestCase("/system/health")] // Blazor home escape hatch
|
||||
[TestCase("/media/sources/plex")] // media sources have no SPA UI (#202)
|
||||
[TestCase("/media/sources/jellyfin")]
|
||||
// #202 replaced the Blazor /media/sources/* pages with the /app/libraries SPA screens;
|
||||
// #91b deletes the Blazor pages and adds these redirects (the #204-era prefix ban is lifted).
|
||||
[TestCase("/media/sources/local", "/app/libraries")]
|
||||
[TestCase("/media/sources/local/add", "/app/libraries/local/new")]
|
||||
[TestCase("/media/sources/local/5/edit", "/app/libraries/local/5")]
|
||||
[TestCase("/media/sources/plex", "/app/libraries/plex")]
|
||||
[TestCase("/media/sources/plex/5/libraries", "/app/libraries/plex/5/sync")]
|
||||
[TestCase("/media/sources/plex/5/paths", "/app/libraries/plex/5/path-replacements")]
|
||||
[TestCase("/media/sources/jellyfin", "/app/libraries/jellyfin")]
|
||||
[TestCase("/media/sources/jellyfin/edit", "/app/libraries/jellyfin/connection")]
|
||||
[TestCase("/media/sources/jellyfin/5/libraries", "/app/libraries/jellyfin/5/sync")]
|
||||
[TestCase("/media/sources/jellyfin/5/paths", "/app/libraries/jellyfin/5/path-replacements")]
|
||||
[TestCase("/media/sources/emby", "/app/libraries/emby")]
|
||||
[TestCase("/media/sources/emby/edit", "/app/libraries/emby/connection")]
|
||||
[TestCase("/media/sources/emby/5/libraries", "/app/libraries/emby/5/sync")]
|
||||
[TestCase("/media/sources/emby/5/paths", "/app/libraries/emby/5/path-replacements")]
|
||||
public void Media_Sources_Routes_Should_Redirect_To_Libraries_Screens(string path, string expected)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
|
||||
target.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[TestCase("/system/health")] // former Blazor home; not mapped (Startup catch-all -> /app)
|
||||
[TestCase("/media/sources")] // bare prefix, no such route/screen
|
||||
[TestCase("/media/sources/plex/paths")] // 4-seg, non-numeric id position
|
||||
[TestCase("/media/sources/plex/abc/libraries")] // id must be a positive integer
|
||||
[TestCase("/api/foo")] // API surface
|
||||
[TestCase("/artwork/1")] // artwork surface
|
||||
[TestCase("/docs")] // docs surface
|
||||
@@ -167,7 +190,7 @@ public class LegacyUiRedirectsTests
|
||||
{
|
||||
string[] forbidden =
|
||||
{
|
||||
"/api", "/artwork", "/docs", "/openapi", "/iptv", "/app", "/media/sources"
|
||||
"/api", "/artwork", "/docs", "/openapi", "/iptv", "/app"
|
||||
};
|
||||
|
||||
foreach (string key in LegacyUiRedirects.Map.Keys)
|
||||
@@ -193,15 +216,16 @@ public class LegacyUiRedirectsTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
|
||||
public void Startup_Should_Redirect_Legacy_Routes_In_Legacy_Branch_Before_Routing()
|
||||
{
|
||||
// The redirect middleware must be wired inside the blazor branch and run
|
||||
// before UseRouting so migrated routes never reach the Blazor fallback.
|
||||
// The redirect middleware must be wired inside the legacy branch and run before
|
||||
// UseRouting so migrated routes never reach the SPA catch-all fallback (#91 phase b:
|
||||
// the Blazor host is gone; unmatched non-API routes 302 to /app).
|
||||
int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal);
|
||||
redirectIndex.ShouldBeGreaterThan(-1);
|
||||
|
||||
// The Blazor branch's UseRouting call that follows the redirect middleware.
|
||||
int routingIndex = StartupSource.IndexOf("blazor.UseRouting()", StringComparison.Ordinal);
|
||||
// The legacy branch's UseRouting call that follows the redirect middleware.
|
||||
int routingIndex = StartupSource.IndexOf("legacy.UseRouting()", StringComparison.Ordinal);
|
||||
routingIndex.ShouldBeGreaterThan(-1);
|
||||
|
||||
redirectIndex.ShouldBeLessThan(routingIndex);
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Pages;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Pages;
|
||||
|
||||
[TestFixture]
|
||||
public class MultiSelectBaseTests
|
||||
{
|
||||
[Test]
|
||||
public void Should_replace_existing_selection_and_return_last_card()
|
||||
{
|
||||
var existingCard = new MediaCardViewModel(1, "Existing", "Sub", "Existing", "", Core.Domain.MediaItemState.Normal, false);
|
||||
var selected = new HashSet<MediaCardViewModel> { existingCard };
|
||||
|
||||
var first = new MediaCardViewModel(2, "First", "Sub", "First", "", Core.Domain.MediaItemState.Normal, false);
|
||||
var second = new MediaCardViewModel(3, "Second", "Sub", "Second", "", Core.Domain.MediaItemState.Normal, false);
|
||||
|
||||
MultiSelectBase<Search>.ResetSelectionWithCards(selected, [first, second]);
|
||||
|
||||
selected.ShouldBe([first, second], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_clear_selection_when_no_cards()
|
||||
{
|
||||
var existingCard = new MediaCardViewModel(1, "Existing", "Sub", "Existing", "", Core.Domain.MediaItemState.Normal, false);
|
||||
var selected = new HashSet<MediaCardViewModel> { existingCard };
|
||||
|
||||
MultiSelectBase<Search>.ResetSelectionWithCards(selected, []);
|
||||
|
||||
selected.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,39 @@ public class StartupSpaHostingTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback()
|
||||
public void Startup_Should_Keep_App_Routes_Out_Of_The_Legacy_Catch_All()
|
||||
{
|
||||
// /app is excluded from the legacy branch's MapWhen predicate, so SPA routes never
|
||||
// reach the catch-all fallback (which 302s unmatched non-API paths to /app — an
|
||||
// included /app route would loop).
|
||||
StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)");
|
||||
StartupSource.ShouldContain("bool IsSpaPath(PathString path)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Have_Removed_The_Blazor_Server_Ui()
|
||||
{
|
||||
// ersatztv#91 phase (b): the legacy Blazor Server UI is deleted. Guard against
|
||||
// reintroduction — none of its wiring may return.
|
||||
StartupSource.ShouldNotContain("MapBlazorHub");
|
||||
StartupSource.ShouldNotContain("_Host");
|
||||
StartupSource.ShouldNotContain("AddServerSideBlazor");
|
||||
StartupSource.ShouldNotContain("AddMudServices");
|
||||
StartupSource.ShouldNotContain("AddRazorPages");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Catch_All_Unmatched_Legacy_Routes_To_The_Spa()
|
||||
{
|
||||
// With _Host gone, a retired Blazor route that matches no controller/redirect 302s to
|
||||
// the SPA rather than hard-404ing; /api, /artwork, /docs, /openapi are excluded so an
|
||||
// unmatched one of those is a genuine 404 (docs/decisions.md #204).
|
||||
StartupSource.ShouldContain("endpoints.MapFallback(");
|
||||
StartupSource.ShouldContain("context.Response.Redirect(context.Request.PathBase + \"/app\")");
|
||||
StartupSource.ShouldContain("path.StartsWithSegments(\"/api\")");
|
||||
StartupSource.ShouldContain("path.StartsWithSegments(\"/artwork\")");
|
||||
}
|
||||
|
||||
private static string FindStartupPath()
|
||||
{
|
||||
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<PageTitle>Not found</PageTitle>
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<p role="alert">Sorry, there's nothing at this address.</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
@@ -280,8 +280,8 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
}
|
||||
|
||||
// System (generated) playlists must not have items added. The AddItemsToPlaylistHandler
|
||||
// also enforces this (defense-in-depth for the Blazor path, which calls the same command
|
||||
// from MultiSelectBase.AddItemsToPlaylist).
|
||||
// also enforces this (defense-in-depth: the handler is the authoritative guard regardless
|
||||
// of caller).
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<!-- ersatztv#25: SonarAnalyzer findings in Blazor .razor @code can't be downgraded via
|
||||
.editorconfig (Razor source-generator limitation), so under TreatWarningsAsErrors they
|
||||
would break the build. Temporarily suppressed here; the #25 burn-down removes each ID
|
||||
as its .razor findings are fixed. (These same rules run at `suggestion` on .cs via
|
||||
.editorconfig — visible, non-blocking.) -->
|
||||
<NoWarn>$(NoWarn);S6966;S3267;S2325;S3260;S2094;S3458;S3358;S6667;S125;S1135;S1751;S1125;S1066;S127;S927;S6610;S6580;S5693;S3440;S2933;S1871</NoWarn>
|
||||
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
@@ -36,16 +30,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<!-- <PackageReference Include="EntityFrameworkProfiler.Appender" /> -->
|
||||
<PackageReference Include="Blazored.FluentValidation" />
|
||||
<PackageReference Include="BlazorSortable" />
|
||||
<PackageReference Include="Chronic.Core" />
|
||||
<PackageReference Include="FluentValidation" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" />
|
||||
<PackageReference Include="Heron.MudCalendar" />
|
||||
<PackageReference Include="HtmlSanitizer" />
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" />
|
||||
@@ -59,8 +46,6 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" />
|
||||
<PackageReference Include="NaturalSort.Extension" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="Serilog" />
|
||||
@@ -112,18 +97,6 @@
|
||||
<EmbeddedResource Include="Resources\empty.sqlite3" />
|
||||
<EmbeddedResource Include="Resources\test.avs" />
|
||||
<EmbeddedResource Include="Resources\Templates\_remoteStream.sbntxt" />
|
||||
<EmbeddedResource Update="Locals\Shared\MainLayout.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>MainLayout.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Locals\Pages\Channels.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Channels.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Locals\Shared\Common.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Common.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -133,36 +106,6 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\FONT-LICENSE" />
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\css\open-iconic-bootstrap.min.css" />
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.eot" />
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.otf" />
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.svg" />
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.ttf" />
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\font\fonts\open-iconic.woff" />
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\ICON-LICENSE" />
|
||||
<_ContentIncludedByDefault Remove="wwwroot\css\open-iconic\README.md" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Locals\Shared\MainLayout.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>MainLayout.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Locals\Pages\Channels.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Channels.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Locals\Shared\Common.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Common.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ProjectExtensions>
|
||||
<VisualStudio>
|
||||
<UserProperties client-app_4package_1json__JsonSchema="https://json.schemastore.org/phraseapp.json" />
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace ErsatzTV.Extensions;
|
||||
|
||||
public static class NavigationManagerExtensions
|
||||
{
|
||||
public static async Task NavigateToFragmentAsync(this NavigationManager navigationManager, IJSRuntime jSRuntime)
|
||||
{
|
||||
Uri uri = navigationManager.ToAbsoluteUri(navigationManager.Uri);
|
||||
|
||||
if (uri.Fragment.Length > 0)
|
||||
{
|
||||
await Task.Delay(250);
|
||||
await jSRuntime.InvokeVoidAsync("blazorHelpers.scrollToFragment", uri.Fragment.Substring(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,19 @@ using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ErsatzTV;
|
||||
|
||||
// Phase (a) of the Blazor -> ChicoryTV SPA cutover (ersatztv#91).
|
||||
// Blazor -> ChicoryTV SPA cutover (ersatztv#91).
|
||||
//
|
||||
// The React SPA (served under /app) is now the default UI: the legacy Blazor
|
||||
// routes below 302-redirect to their SPA equivalents. Only routes that already
|
||||
// have SPA parity are listed here. Blazor pages WITHOUT a SPA equivalent are
|
||||
// deliberately left reachable (no redirect) so their functionality stays
|
||||
// available while the SPA catches up:
|
||||
// /system/health (Blazor home escape hatch; Index.razor also lives here),
|
||||
// /media/sources/* (source add/edit has no SPA UI yet — ersatztv#202).
|
||||
// The React SPA (served under /app) is the default UI: the legacy Blazor routes
|
||||
// below 302-redirect to their SPA equivalents. As of phase (b) the Blazor pages
|
||||
// themselves are DELETED (ersatztv#91). A legacy route that is not in this map
|
||||
// (nor a live controller/artwork/docs route) is caught by the Startup catch-all
|
||||
// and 302s to /app, so nothing hard-404s — but a catch-all can only reach the SPA
|
||||
// root, so this map stays the source of truth for the precise legacy-route ->
|
||||
// SPA-screen mapping.
|
||||
//
|
||||
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on
|
||||
// full SPA parity for every route in this map. Until then this map is the
|
||||
// single source of truth for "what has migrated" and is expected to grow.
|
||||
// The one Blazor route intentionally never redirected was /system/health (the old
|
||||
// Blazor home / escape hatch); it is deleted with Blazor in phase (b) and now
|
||||
// falls through to the catch-all like any other retired route.
|
||||
//
|
||||
// Matching is two-tier (see TryGetRedirect):
|
||||
// Tier 1 = the exact-path dictionary Map below (parameterless routes, plus
|
||||
@@ -26,10 +26,12 @@ namespace ErsatzTV;
|
||||
//
|
||||
// GUARD INVARIANT (see also the collision-freedom note above Patterns): no
|
||||
// Tier-1 key and no Tier-2 template may begin with /api, /artwork, /docs,
|
||||
// /openapi, /iptv, /app, or /media/sources. Rules are always full, specific
|
||||
// templates — never prefix wildcards (a bare "/media/{any}" style rule is
|
||||
// forbidden). The blazor branch does not prefix-guard /api|/artwork|/docs|
|
||||
// /openapi, /iptv, or /app. Rules are always full, specific templates — never
|
||||
// prefix wildcards (a bare "/media/{any}" style rule is forbidden). The legacy
|
||||
// branch (and its catch-all fallback) does not prefix-guard /api|/artwork|/docs|
|
||||
// /openapi, so the matcher's specificity is part of their protection.
|
||||
// (/media/sources IS redirected as of #202/#91b — its Blazor pages were replaced
|
||||
// by the /app/libraries SPA screens, so the #204-era prefix ban is lifted.)
|
||||
public static class LegacyUiRedirects
|
||||
{
|
||||
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
|
||||
@@ -94,7 +96,17 @@ public static class LegacyUiRedirects
|
||||
["/media/music/songs"] = "/app/media?kind=songs",
|
||||
["/media/other/videos"] = "/app/media?kind=other-videos",
|
||||
["/media/remote/streams"] = "/app/media?kind=remote-streams",
|
||||
["/media/images"] = "/app/media?kind=images"
|
||||
["/media/images"] = "/app/media?kind=images",
|
||||
|
||||
// (F) media sources — SPA screens landed in #202; Blazor pages deleted in #91b.
|
||||
// The parameterized ({id}) source routes are Tier-2 patterns below.
|
||||
["/media/sources/local"] = "/app/libraries",
|
||||
["/media/sources/local/add"] = "/app/libraries/local/new",
|
||||
["/media/sources/plex"] = "/app/libraries/plex",
|
||||
["/media/sources/jellyfin"] = "/app/libraries/jellyfin",
|
||||
["/media/sources/jellyfin/edit"] = "/app/libraries/jellyfin/connection",
|
||||
["/media/sources/emby"] = "/app/libraries/emby",
|
||||
["/media/sources/emby/edit"] = "/app/libraries/emby/connection"
|
||||
};
|
||||
|
||||
// Tier-2 segment-template rules, consulted only after the exact Map misses.
|
||||
@@ -157,7 +169,16 @@ public static class LegacyUiRedirects
|
||||
new("/media/music/songs/page/{id}", "/app/media?kind=songs"),
|
||||
new("/media/other/videos/page/{id}", "/app/media?kind=other-videos"),
|
||||
new("/media/remote/streams/page/{id}", "/app/media?kind=remote-streams"),
|
||||
new("/media/images/page/{id}", "/app/media?kind=images")
|
||||
new("/media/images/page/{id}", "/app/media?kind=images"),
|
||||
|
||||
// (F) media sources — id-carrying source sub-routes (#202 SPA; #91b redirect).
|
||||
new("/media/sources/local/{id}/edit", "/app/libraries/local/{id}"),
|
||||
new("/media/sources/plex/{id}/libraries", "/app/libraries/plex/{id}/sync"),
|
||||
new("/media/sources/plex/{id}/paths", "/app/libraries/plex/{id}/path-replacements"),
|
||||
new("/media/sources/jellyfin/{id}/libraries", "/app/libraries/jellyfin/{id}/sync"),
|
||||
new("/media/sources/jellyfin/{id}/paths", "/app/libraries/jellyfin/{id}/path-replacements"),
|
||||
new("/media/sources/emby/{id}/libraries", "/app/libraries/emby/{id}/sync"),
|
||||
new("/media/sources/emby/{id}/paths", "/app/libraries/emby/{id}/path-replacements")
|
||||
};
|
||||
|
||||
public static bool TryGetRedirect(PathString path, out string target)
|
||||
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace ErsatzTV.Locals.Pages {
|
||||
using System;
|
||||
|
||||
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Channels {
|
||||
|
||||
private static System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Channels() {
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.Equals(null, resourceMan)) {
|
||||
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("ErsatzTV.Locals.Pages.Channels", typeof(Channels).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonAddChannel {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonAddChannel", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonEditChannelNumbers {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonEditChannelNumbers", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelChannels {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelChannels", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonShowDisabled {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonShowDisabled", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelNumber {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelNumber", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelLogo {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelLogo", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelName {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelName", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelLanguage {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelLanguage", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelStreamingMode {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelStreamingMode", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelFFmpegProfile {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelFFmpegProfile", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelChannelDisabledHelp {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelChannelDisabledHelp", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelChannelHiddenHelp {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelChannelHiddenHelp", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelChannelHiddenAbbreviation {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelChannelHiddenAbbreviation", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelChannelDisabledAbbreviation {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelChannelDisabledAbbreviation", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonPreviewChannelHelp {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonPreviewChannelHelp", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonPreviewChannelHelpNormalize {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonPreviewChannelHelpNormalize", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonPreviewChannelHelpBrowser {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonPreviewChannelHelpBrowser", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonPreviewChannelHelpSettings {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonPreviewChannelHelpSettings", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonStopTranscodeHelp {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonStopTranscodeHelp", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonEditChannelHelp {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonEditChannelHelp", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonTroubleshootChannelHelp {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonTroubleshootChannelHelp", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonDeleteChannelHelp {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonDeleteChannelHelp", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ButtonAddChannel" xml:space="preserve">
|
||||
<value>Dodaj kanał</value>
|
||||
</data>
|
||||
<data name="ButtonEditChannelNumbers" xml:space="preserve">
|
||||
<value>Edytuj numerację kanałów</value>
|
||||
</data>
|
||||
<data name="LabelChannels" xml:space="preserve">
|
||||
<value>Kanały</value>
|
||||
</data>
|
||||
<data name="ButtonShowDisabled" xml:space="preserve">
|
||||
<value>Pokaż wyłączone</value>
|
||||
</data>
|
||||
<data name="LabelNumber" xml:space="preserve">
|
||||
<value>Numer</value>
|
||||
</data>
|
||||
<data name="LabelLogo" xml:space="preserve">
|
||||
<value>Logo</value>
|
||||
</data>
|
||||
<data name="LabelName" xml:space="preserve">
|
||||
<value>Nazwa</value>
|
||||
</data>
|
||||
<data name="LabelLanguage" xml:space="preserve">
|
||||
<value>Język</value>
|
||||
</data>
|
||||
<data name="LabelStreamingMode" xml:space="preserve">
|
||||
<value>Tryb</value>
|
||||
</data>
|
||||
<data name="LabelFFmpegProfile" xml:space="preserve">
|
||||
<value>Profil FFmpeg</value>
|
||||
</data>
|
||||
<data name="LabelChannelDisabledHelp" xml:space="preserve">
|
||||
<value>Kanał jest wyłączony</value>
|
||||
</data>
|
||||
<data name="LabelChannelHiddenHelp" xml:space="preserve">
|
||||
<value>Kanał jest ukryty w EPG</value>
|
||||
</data>
|
||||
<data name="LabelChannelHiddenAbbreviation" xml:space="preserve">
|
||||
<value>U</value>
|
||||
</data>
|
||||
<data name="LabelChannelDisabledAbbreviation" xml:space="preserve">
|
||||
<value>W</value>
|
||||
</data>
|
||||
<data name="ButtonPreviewChannelHelp" xml:space="preserve">
|
||||
<value>Podgląd kanału</value>
|
||||
</data>
|
||||
<data name="ButtonPreviewChannelHelpNormalize" xml:space="preserve">
|
||||
<value>Podgląd kanału; obraz lub dźwięk mogą nie działać poprawnie bez normalizacji</value>
|
||||
</data>
|
||||
<data name="ButtonPreviewChannelHelpBrowser" xml:space="preserve">
|
||||
<value>Podgląd kanału wymaga profilu FFmpeg kompatybilnego z tą przeglądarką</value>
|
||||
</data>
|
||||
<data name="ButtonPreviewChannelHelpSettings" xml:space="preserve">
|
||||
<value>Podgląd kanału wymaga funkcji playout, segmentera MPEG-TS/HLS oraz kompatybilnego profilu FFmpeg</value>
|
||||
</data>
|
||||
<data name="ButtonStopTranscodeHelp" xml:space="preserve">
|
||||
<value>Zatrzymaj transkodowanie</value>
|
||||
</data>
|
||||
<data name="ButtonEditChannelHelp" xml:space="preserve">
|
||||
<value>Edytuj kanał</value>
|
||||
</data>
|
||||
<data name="ButtonTroubleshootChannelHelp" xml:space="preserve">
|
||||
<value>Diagnostyka kanału</value>
|
||||
</data>
|
||||
<data name="ButtonDeleteChannelHelp" xml:space="preserve">
|
||||
<value>Usuń kanał</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,14 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -1,87 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ButtonAddChannel" xml:space="preserve">
|
||||
<value>Add Channel</value>
|
||||
</data>
|
||||
<data name="ButtonEditChannelNumbers" xml:space="preserve">
|
||||
<value>Edit Channel Numbers</value>
|
||||
</data>
|
||||
<data name="LabelChannels" xml:space="preserve">
|
||||
<value>Channels</value>
|
||||
</data>
|
||||
<data name="ButtonShowDisabled" xml:space="preserve">
|
||||
<value>Show Disabled</value>
|
||||
</data>
|
||||
<data name="LabelNumber" xml:space="preserve">
|
||||
<value>Number</value>
|
||||
</data>
|
||||
<data name="LabelLogo" xml:space="preserve">
|
||||
<value>Logo</value>
|
||||
</data>
|
||||
<data name="LabelName" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="LabelLanguage" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="LabelStreamingMode" xml:space="preserve">
|
||||
<value>Mode</value>
|
||||
</data>
|
||||
<data name="LabelFFmpegProfile" xml:space="preserve">
|
||||
<value>FFmpeg Profile</value>
|
||||
</data>
|
||||
<data name="LabelChannelDisabledHelp" xml:space="preserve">
|
||||
<value>Channel is disabled</value>
|
||||
</data>
|
||||
<data name="LabelChannelHiddenHelp" xml:space="preserve">
|
||||
<value>Channel is hidden from EPG</value>
|
||||
</data>
|
||||
<data name="LabelChannelHiddenAbbreviation" xml:space="preserve">
|
||||
<value>H</value>
|
||||
</data>
|
||||
<data name="LabelChannelDisabledAbbreviation" xml:space="preserve">
|
||||
<value>D</value>
|
||||
</data>
|
||||
<data name="ButtonPreviewChannelHelp" xml:space="preserve">
|
||||
<value>Preview Channel</value>
|
||||
</data>
|
||||
<data name="ButtonPreviewChannelHelpNormalize" xml:space="preserve">
|
||||
<value>Preview Channel; audio and/or video may not work without normalization</value>
|
||||
</data>
|
||||
<data name="ButtonPreviewChannelHelpBrowser" xml:space="preserve">
|
||||
<value>Channel preview requires FFmpeg Profile compatible with this browser</value>
|
||||
</data>
|
||||
<data name="ButtonPreviewChannelHelpSettings" xml:space="preserve">
|
||||
<value>Channel preview requires playout, MPEG-TS/HLS Segmenter, and compatible FFmpeg Profile</value>
|
||||
</data>
|
||||
<data name="ButtonStopTranscodeHelp" xml:space="preserve">
|
||||
<value>Stop Transcode Session</value>
|
||||
</data>
|
||||
<data name="ButtonEditChannelHelp" xml:space="preserve">
|
||||
<value>Edit Channel</value>
|
||||
</data>
|
||||
<data name="ButtonTroubleshootChannelHelp" xml:space="preserve">
|
||||
<value>Troubleshoot Channel</value>
|
||||
</data>
|
||||
<data name="ButtonDeleteChannelHelp" xml:space="preserve">
|
||||
<value>Delete Channel</value>
|
||||
</data>
|
||||
</root>
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace ErsatzTV.Locals.Shared {
|
||||
using System;
|
||||
|
||||
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Common {
|
||||
|
||||
private static System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Common() {
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.Equals(null, resourceMan)) {
|
||||
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("ErsatzTV.Locals.Shared.Common", typeof(Common).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelRowsPerPage {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelRowsPerPage", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="LabelRowsPerPage" xml:space="preserve">
|
||||
<value>Wierszy na stronę:</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,14 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="LabelRowsPerPage" xml:space="preserve">
|
||||
<value>Rows per page:</value>
|
||||
</data>
|
||||
</root>
|
||||
-414
@@ -1,414 +0,0 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace ErsatzTV.Locals.Shared {
|
||||
using System;
|
||||
|
||||
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class MainLayout {
|
||||
|
||||
private static System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal MainLayout() {
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.Equals(null, resourceMan)) {
|
||||
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("ErsatzTV.Locals.Shared.MainLayout", typeof(MainLayout).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonChannels {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonChannels", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonFFmpegProfiles {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonFFmpegProfiles", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonWatermarks {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonWatermarks", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaSources {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaSources", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMedia {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMedia", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonLists {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonLists", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSettings {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSettings", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaSourcesLocal {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaSourcesLocal", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaSourcesEmby {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaSourcesEmby", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaSourcesJellyfin {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaSourcesJellyfin", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaSourcesPlex {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaSourcesPlex", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaLibraries {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaLibraries", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaTrash {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaTrash", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaTvShows {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaTvShows", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaMovies {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaMovies", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaMusic {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaMusic", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaOtherVideos {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaOtherVideos", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaSongs {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaSongs", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaImages {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaImages", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonMediaRemoteStreams {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonMediaRemoteStreams", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonListsManualCollections {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonListsManualCollections", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonListsSmartCollections {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonListsSmartCollections", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonListsMultiCollections {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonListsMultiCollections", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonListsRerunCollections {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonListsRerunCollections", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonListsPlaylists {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonListsPlaylists", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonListsTraktLists {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonListsTraktLists", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonListsFillerPresets {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonListsFillerPresets", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonScheduling {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonScheduling", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSchedulingSchedules {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSchedulingSchedules", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSchedulingBlocks {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSchedulingBlocks", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSchedulingTemplates {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSchedulingTemplates", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSchedulingDecos {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSchedulingDecos", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSchedulingDecoTemplates {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSchedulingDecoTemplates", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSchedulingPlayouts {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSchedulingPlayouts", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSettingsFFmpeg {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSettingsFFmpeg", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSettingsLogging {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSettingsLogging", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSettingsHdHomeRun {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSettingsHdHomeRun", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSettingsScanner {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSettingsScanner", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSettingsPlayout {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSettingsPlayout", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSettingsUi {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSettingsUi", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSettingsXmltv {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSettingsXmltv", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSupport {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSupport", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSupportHealthChecks {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSupportHealthChecks", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSupportLogs {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSupportLogs", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonSupportTroubleshooting {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonSupportTroubleshooting", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelErsatzTVVersion {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelErsatzTVVersion", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelSearchTargetChannel {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelSearchTargetChannel", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelSearchTargetFFmpegProfile {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelSearchTargetFFmpegProfile", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelSearchTargetChannelWatermark {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelSearchTargetChannelWatermark", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelSearchTargetCollection {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelSearchTargetCollection", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelSearchTargetMultiCollection {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelSearchTargetMultiCollection", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelSearchTargetSmartCollection {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelSearchTargetSmartCollection", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelSearchTargetSchedule {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelSearchTargetSchedule", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LabelSearchTargetScheduleItems {
|
||||
get {
|
||||
return ResourceManager.GetString("LabelSearchTargetScheduleItems", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonM3U {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonM3U", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonXmltv {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonXmltv", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonApi {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonApi", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonDocumentation {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonDocumentation", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonGitHub {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonGitHub", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonLogout {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonLogout", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ButtonContact {
|
||||
get {
|
||||
return ResourceManager.GetString("ButtonContact", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ButtonChannels" xml:space="preserve">
|
||||
<value>Kanały</value>
|
||||
</data>
|
||||
<data name="ButtonFFmpegProfiles" xml:space="preserve">
|
||||
<value>Profile FFmpeg</value>
|
||||
</data>
|
||||
<data name="ButtonWatermarks" xml:space="preserve">
|
||||
<value>Znaki wodne</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSources" xml:space="preserve">
|
||||
<value>Źródła multimediów</value>
|
||||
</data>
|
||||
<data name="ButtonMedia" xml:space="preserve">
|
||||
<value>Multimedia</value>
|
||||
</data>
|
||||
<data name="ButtonLists" xml:space="preserve">
|
||||
<value>Listy</value>
|
||||
</data>
|
||||
<data name="ButtonSettings" xml:space="preserve">
|
||||
<value>Ustawienia</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSourcesLocal" xml:space="preserve">
|
||||
<value>Lokalne</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSourcesEmby" xml:space="preserve">
|
||||
<value>Emby</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSourcesJellyfin" xml:space="preserve">
|
||||
<value>Jellyfin</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSourcesPlex" xml:space="preserve">
|
||||
<value>Plex</value>
|
||||
</data>
|
||||
<data name="ButtonMediaLibraries" xml:space="preserve">
|
||||
<value>Biblioteki</value>
|
||||
</data>
|
||||
<data name="ButtonMediaTrash" xml:space="preserve">
|
||||
<value>Kosz</value>
|
||||
</data>
|
||||
<data name="ButtonMediaTvShows" xml:space="preserve">
|
||||
<value>Seriale</value>
|
||||
</data>
|
||||
<data name="ButtonMediaMovies" xml:space="preserve">
|
||||
<value>Filmy</value>
|
||||
</data>
|
||||
<data name="ButtonMediaMusic" xml:space="preserve">
|
||||
<value>Muzyka</value>
|
||||
</data>
|
||||
<data name="ButtonMediaOtherVideos" xml:space="preserve">
|
||||
<value>Inne wideo</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSongs" xml:space="preserve">
|
||||
<value>Utwory</value>
|
||||
</data>
|
||||
<data name="ButtonMediaImages" xml:space="preserve">
|
||||
<value>Obrazy</value>
|
||||
</data>
|
||||
<data name="ButtonMediaRemoteStreams" xml:space="preserve">
|
||||
<value>Strumienie zdalne</value>
|
||||
</data>
|
||||
<data name="ButtonListsManualCollections" xml:space="preserve">
|
||||
<value>Kolekcje ręczne</value>
|
||||
</data>
|
||||
<data name="ButtonListsSmartCollections" xml:space="preserve">
|
||||
<value>Inteligentne kolekcje</value>
|
||||
</data>
|
||||
<data name="ButtonListsMultiCollections" xml:space="preserve">
|
||||
<value>Multikolekcje</value>
|
||||
</data>
|
||||
<data name="ButtonListsRerunCollections" xml:space="preserve">
|
||||
<value>Kolekcje powtórek</value>
|
||||
</data>
|
||||
<data name="ButtonListsPlaylists" xml:space="preserve">
|
||||
<value>Playlisty</value>
|
||||
</data>
|
||||
<data name="ButtonListsTraktLists" xml:space="preserve">
|
||||
<value>Listy Trakt</value>
|
||||
</data>
|
||||
<data name="ButtonListsFillerPresets" xml:space="preserve">
|
||||
<value>Szablony wypełniaczy</value>
|
||||
</data>
|
||||
<data name="ButtonScheduling" xml:space="preserve">
|
||||
<value>Planowanie</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingSchedules" xml:space="preserve">
|
||||
<value>Harmonogramy</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingBlocks" xml:space="preserve">
|
||||
<value>Bloki</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingTemplates" xml:space="preserve">
|
||||
<value>Szablony</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingDecos" xml:space="preserve">
|
||||
<value>Dekoracje</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingDecoTemplates" xml:space="preserve">
|
||||
<value>Szablony dekoracji</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingPlayouts" xml:space="preserve">
|
||||
<value>Plany emisji</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsFFmpeg" xml:space="preserve">
|
||||
<value>FFmpeg</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsLogging" xml:space="preserve">
|
||||
<value>Dzienniki zdarzeń</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsHdHomeRun" xml:space="preserve">
|
||||
<value>HDHomeRun</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsScanner" xml:space="preserve">
|
||||
<value>Skaner</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsPlayout" xml:space="preserve">
|
||||
<value>Emisja</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsUi" xml:space="preserve">
|
||||
<value>Interfejs</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsXmltv" xml:space="preserve">
|
||||
<value>XMLTV</value>
|
||||
</data>
|
||||
<data name="ButtonSupport" xml:space="preserve">
|
||||
<value>Pomoc</value>
|
||||
</data>
|
||||
<data name="ButtonSupportHealthChecks" xml:space="preserve">
|
||||
<value>Diagnostyka</value>
|
||||
</data>
|
||||
<data name="ButtonSupportLogs" xml:space="preserve">
|
||||
<value>Logi</value>
|
||||
</data>
|
||||
<data name="ButtonSupportTroubleshooting" xml:space="preserve">
|
||||
<value>Rozwiązywanie problemów</value>
|
||||
</data>
|
||||
<data name="LabelErsatzTVVersion" xml:space="preserve">
|
||||
<value>Wersja ErsatzTV</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetChannel" xml:space="preserve">
|
||||
<value>Kanał</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetFFmpegProfile" xml:space="preserve">
|
||||
<value>Profil FFmpeg</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetChannelWatermark" xml:space="preserve">
|
||||
<value>Znak wodny kanału</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetCollection" xml:space="preserve">
|
||||
<value>Kolekcja</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetMultiCollection" xml:space="preserve">
|
||||
<value>Multikolekcja</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetSmartCollection" xml:space="preserve">
|
||||
<value>Inteligentna kolekcja</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetSchedule" xml:space="preserve">
|
||||
<value>Harmonogram</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetScheduleItems" xml:space="preserve">
|
||||
<value>Pozycje harmonogramu</value>
|
||||
</data>
|
||||
<data name="ButtonM3U" xml:space="preserve">
|
||||
<value>M3U</value>
|
||||
</data>
|
||||
<data name="ButtonXmltv" xml:space="preserve">
|
||||
<value>XMLTV</value>
|
||||
</data>
|
||||
<data name="ButtonApi" xml:space="preserve">
|
||||
<value>API</value>
|
||||
</data>
|
||||
<data name="ButtonDocumentation" xml:space="preserve">
|
||||
<value>Dokumentacja</value>
|
||||
</data>
|
||||
<data name="ButtonGitHub" xml:space="preserve">
|
||||
<value>GitHub</value>
|
||||
</data>
|
||||
<data name="ButtonLogout" xml:space="preserve">
|
||||
<value>Wyloguj</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,59 +0,0 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ButtonChannels" xml:space="preserve">
|
||||
<value>Canais</value>
|
||||
</data>
|
||||
<data name="ButtonFFmpegProfiles" xml:space="preserve">
|
||||
<value>Perfis FFmpeg</value>
|
||||
</data>
|
||||
<data name="ButtonWatermarks" xml:space="preserve">
|
||||
<value>Marcas d'água</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSources" xml:space="preserve">
|
||||
<value>Fontes de mídia</value>
|
||||
</data>
|
||||
<data name="ButtonMedia" xml:space="preserve">
|
||||
<value>Mídias</value>
|
||||
</data>
|
||||
<data name="ButtonLists" xml:space="preserve">
|
||||
<value>Listas</value>
|
||||
</data>
|
||||
<data name="ButtonSettings" xml:space="preserve">
|
||||
<value>Definições</value>
|
||||
</data>
|
||||
<data name="ButtonMediaTvShows" xml:space="preserve">
|
||||
<value>Séries</value>
|
||||
</data>
|
||||
<data name="ButtonMediaTrash" xml:space="preserve">
|
||||
<value>Lixo</value>
|
||||
</data>
|
||||
<data name="ButtonMediaMovies" xml:space="preserve">
|
||||
<value>Filmes</value>
|
||||
</data>
|
||||
<data name="ButtonMediaMusic" xml:space="preserve">
|
||||
<value>Vídeos de música</value>
|
||||
</data>
|
||||
<data name="ButtonMediaLibraries" xml:space="preserve">
|
||||
<value>Bibliotecas</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingSchedules" xml:space="preserve">
|
||||
<value>Horários</value>
|
||||
</data>
|
||||
<data name="ButtonListsFillerPresets" xml:space="preserve">
|
||||
<value>Predefinições de preenchimento</value>
|
||||
</data>
|
||||
<data name="ButtonListsTraktLists" xml:space="preserve">
|
||||
<value>Listas Trakt</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,207 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ButtonChannels" xml:space="preserve">
|
||||
<value>Channels</value>
|
||||
</data>
|
||||
<data name="ButtonFFmpegProfiles" xml:space="preserve">
|
||||
<value>FFmpeg Profiles</value>
|
||||
</data>
|
||||
<data name="ButtonWatermarks" xml:space="preserve">
|
||||
<value>Watermarks</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSources" xml:space="preserve">
|
||||
<value>Media Sources</value>
|
||||
</data>
|
||||
<data name="ButtonMedia" xml:space="preserve">
|
||||
<value>Media</value>
|
||||
</data>
|
||||
<data name="ButtonLists" xml:space="preserve">
|
||||
<value>Lists</value>
|
||||
</data>
|
||||
<data name="ButtonSettings" xml:space="preserve">
|
||||
<value>Settings</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSourcesLocal" xml:space="preserve">
|
||||
<value>Local</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSourcesEmby" xml:space="preserve">
|
||||
<value>Emby</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSourcesJellyfin" xml:space="preserve">
|
||||
<value>Jellyfin</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSourcesPlex" xml:space="preserve">
|
||||
<value>Plex</value>
|
||||
</data>
|
||||
<data name="ButtonMediaLibraries" xml:space="preserve">
|
||||
<value>Libraries</value>
|
||||
</data>
|
||||
<data name="ButtonMediaTrash" xml:space="preserve">
|
||||
<value>Trash</value>
|
||||
</data>
|
||||
<data name="ButtonMediaTvShows" xml:space="preserve">
|
||||
<value>TV Shows</value>
|
||||
</data>
|
||||
<data name="ButtonMediaMovies" xml:space="preserve">
|
||||
<value>Movies</value>
|
||||
</data>
|
||||
<data name="ButtonMediaMusic" xml:space="preserve">
|
||||
<value>Music</value>
|
||||
</data>
|
||||
<data name="ButtonMediaOtherVideos" xml:space="preserve">
|
||||
<value>Other Videos</value>
|
||||
</data>
|
||||
<data name="ButtonMediaSongs" xml:space="preserve">
|
||||
<value>Songs</value>
|
||||
</data>
|
||||
<data name="ButtonMediaImages" xml:space="preserve">
|
||||
<value>Images</value>
|
||||
</data>
|
||||
<data name="ButtonMediaRemoteStreams" xml:space="preserve">
|
||||
<value>Remote Streams</value>
|
||||
</data>
|
||||
<data name="ButtonListsManualCollections" xml:space="preserve">
|
||||
<value>Manual Collections</value>
|
||||
</data>
|
||||
<data name="ButtonListsSmartCollections" xml:space="preserve">
|
||||
<value>Smart Collections</value>
|
||||
</data>
|
||||
<data name="ButtonListsMultiCollections" xml:space="preserve">
|
||||
<value>Multi Collections</value>
|
||||
</data>
|
||||
<data name="ButtonListsRerunCollections" xml:space="preserve">
|
||||
<value>Rerun Collections</value>
|
||||
</data>
|
||||
<data name="ButtonListsPlaylists" xml:space="preserve">
|
||||
<value>Playlists</value>
|
||||
</data>
|
||||
<data name="ButtonListsTraktLists" xml:space="preserve">
|
||||
<value>Trakt Lists</value>
|
||||
</data>
|
||||
<data name="ButtonListsFillerPresets" xml:space="preserve">
|
||||
<value>Filler Presets</value>
|
||||
</data>
|
||||
<data name="ButtonScheduling" xml:space="preserve">
|
||||
<value>Scheduling</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingSchedules" xml:space="preserve">
|
||||
<value>Schedules</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingBlocks" xml:space="preserve">
|
||||
<value>Blocks</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingTemplates" xml:space="preserve">
|
||||
<value>Templates</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingDecos" xml:space="preserve">
|
||||
<value>Decos</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingDecoTemplates" xml:space="preserve">
|
||||
<value>Deco Templates</value>
|
||||
</data>
|
||||
<data name="ButtonSchedulingPlayouts" xml:space="preserve">
|
||||
<value>Playouts</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsFFmpeg" xml:space="preserve">
|
||||
<value>FFmpeg</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsLogging" xml:space="preserve">
|
||||
<value>Logging</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsHdHomeRun" xml:space="preserve">
|
||||
<value>HDHomeRun</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsScanner" xml:space="preserve">
|
||||
<value>Scanner</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsPlayout" xml:space="preserve">
|
||||
<value>Playout</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsUi" xml:space="preserve">
|
||||
<value>UI</value>
|
||||
</data>
|
||||
<data name="ButtonSettingsXmltv" xml:space="preserve">
|
||||
<value>XMLTV</value>
|
||||
</data>
|
||||
<data name="ButtonOpensInChicoryTv" xml:space="preserve">
|
||||
<value>Opens in ChicoryTV</value>
|
||||
</data>
|
||||
<data name="ButtonSupport" xml:space="preserve">
|
||||
<value>Support</value>
|
||||
</data>
|
||||
<data name="ButtonSupportHealthChecks" xml:space="preserve">
|
||||
<value>Health Checks</value>
|
||||
</data>
|
||||
<data name="ButtonSupportLogs" xml:space="preserve">
|
||||
<value>Logs</value>
|
||||
</data>
|
||||
<data name="ButtonSupportTroubleshooting" xml:space="preserve">
|
||||
<value>Troubleshooting</value>
|
||||
</data>
|
||||
<data name="LabelErsatzTVVersion" xml:space="preserve">
|
||||
<value>ErsatzTV Version</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetChannel" xml:space="preserve">
|
||||
<value>Channel</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetFFmpegProfile" xml:space="preserve">
|
||||
<value>FFmpeg Profile</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetChannelWatermark" xml:space="preserve">
|
||||
<value>Channel Watermark</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetCollection" xml:space="preserve">
|
||||
<value>Collection</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetMultiCollection" xml:space="preserve">
|
||||
<value>Multi Collection</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetSmartCollection" xml:space="preserve">
|
||||
<value>Smart Collection</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetSchedule" xml:space="preserve">
|
||||
<value>Schedule</value>
|
||||
</data>
|
||||
<data name="LabelSearchTargetScheduleItems" xml:space="preserve">
|
||||
<value>Schedule Items</value>
|
||||
</data>
|
||||
<data name="ButtonM3U" xml:space="preserve">
|
||||
<value>M3U</value>
|
||||
</data>
|
||||
<data name="ButtonXmltv" xml:space="preserve">
|
||||
<value>XMLTV</value>
|
||||
</data>
|
||||
<data name="ButtonApi" xml:space="preserve">
|
||||
<value>API</value>
|
||||
</data>
|
||||
<data name="ButtonDocumentation" xml:space="preserve">
|
||||
<value>Documentation</value>
|
||||
</data>
|
||||
<data name="ButtonGitHub" xml:space="preserve">
|
||||
<value>GitHub</value>
|
||||
</data>
|
||||
<data name="ButtonLogout" xml:space="preserve">
|
||||
<value>Logout</value>
|
||||
</data>
|
||||
<data name="ButtonContact" xml:space="preserve">
|
||||
<value>Contact</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,339 +0,0 @@
|
||||
@page "/media/music/artists/{ArtistId:int}"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Artists
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Core.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<Artist> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
|
||||
<div class="fanart-tint"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(_artist?.FanArt))
|
||||
{
|
||||
<img src="@($"artwork/fanart/{_artist.FanArt}")" alt="fan art"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False"/>
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
|
||||
@if (!string.IsNullOrWhiteSpace(_artist?.Thumbnail))
|
||||
{
|
||||
<MudImage Elevation="2" Src="@($"artwork/thumbnails/{_artist.Thumbnail}")" Class="rounded-lg z-10" Style="height: 220px; width: 220px; margin-left: auto; margin-right: auto;"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Class="rounded-lg z-10" Animation="Animation.False" Height="220px" Width="220px" Style="margin-left: auto; margin-right: auto"/>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%; width: 100%" class="z-10">
|
||||
<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>
|
||||
</MudStack>
|
||||
<MudCard Elevation="2" Class="mb-6">
|
||||
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
|
||||
@if (!string.IsNullOrWhiteSpace(_artist?.Biography))
|
||||
{
|
||||
<MudText Style="flex-grow: 1">
|
||||
@if (_artist.Biography.Length > 400)
|
||||
{
|
||||
@(_artist.Biography.Substring(0, 400) + "...")
|
||||
}
|
||||
else
|
||||
{
|
||||
@_artist.Biography
|
||||
}
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="height: 130px; flex-grow: 1">
|
||||
</div>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Schedule"
|
||||
OnClick="@AddToSchedule">
|
||||
Add To Schedule
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudCard Class="mb-6">
|
||||
<MudCardContent>
|
||||
@if (_sortedLanguages.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Languages: </MudText>
|
||||
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
|
||||
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedGenres.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Genres: </MudText>
|
||||
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
|
||||
@foreach (string genre in _sortedGenres.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedStyles.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Styles: </MudText>
|
||||
<MudLink Href="@(@$"style:""{_sortedStyles.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStyles.Head()</MudLink>
|
||||
@foreach (string style in _sortedStyles.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"style:""{style.ToLowerInvariant()}""".GetRelativeSearchQuery())">@style</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedMoods.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Moods: </MudText>
|
||||
<MudLink Href="@(@$"mood:""{_sortedMoods.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedMoods.Head()</MudLink>
|
||||
@foreach (string mood in _sortedMoods.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"mood:""{mood.ToLowerInvariant()}""".GetRelativeSearchQuery())">@mood</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="false" Spacing="6">
|
||||
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards)
|
||||
{
|
||||
<MudCard>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" id="@($"music-video-{musicVideo.MusicVideoId}")">
|
||||
@if (!string.IsNullOrWhiteSpace(musicVideo.Poster))
|
||||
{
|
||||
<div style="display: flex; flex-direction: column; position: relative">
|
||||
<MudImage Src="@($"artwork/thumbnails/{musicVideo.Poster}")" Style="height: 220px; max-width: 265px; margin-left: auto; margin-right: auto;"/>
|
||||
@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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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"/>
|
||||
@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>
|
||||
}
|
||||
<MudCardContent>
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h4">@musicVideo.Title</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(musicVideo.Album))
|
||||
{
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<MudText GutterBottom="true">Album: </MudText>
|
||||
<MudLink Href="@(@$"album:""{musicVideo.Album}""".GetRelativeSearchQuery())">@musicVideo.Album</MudLink>
|
||||
</div>
|
||||
}
|
||||
<MudText Style="flex-grow: 1">@musicVideo.Plot</MudText>
|
||||
<div class="mt-6">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(_ => AddMusicVideoToCollection(musicVideo))">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudStack>
|
||||
@if (musicVideo.State == MediaItemState.FileNotFound)
|
||||
{
|
||||
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
|
||||
<MudText>File Not Found: </MudText>
|
||||
<MudText>@musicVideo.Path</MudText>
|
||||
</div>
|
||||
}
|
||||
else if (musicVideo.State == MediaItemState.Unavailable)
|
||||
{
|
||||
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
|
||||
<MudText>Unavailable: </MudText>
|
||||
<MudText>@musicVideo.LocalPath</MudText>
|
||||
</div>
|
||||
}
|
||||
</MudCard>
|
||||
}
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int ArtistId { get; set; }
|
||||
|
||||
private ArtistViewModel _artist;
|
||||
private List<CultureInfo> _sortedLanguages = [];
|
||||
private List<string> _sortedGenres = [];
|
||||
private List<string> _sortedStyles = [];
|
||||
private List<string> _sortedMoods = [];
|
||||
private MusicVideoCardResultsViewModel _musicVideos = new(0, [], new SearchPageMap([]));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await Mediator.Send(new GetArtistById(ArtistId), token).IfSomeAsync(vm =>
|
||||
{
|
||||
_artist = vm;
|
||||
_sortedLanguages = _artist.Languages.OrderBy(ci => ci.EnglishName).ToList();
|
||||
_sortedGenres = _artist.Genres.OrderBy(g => g).ToList();
|
||||
_sortedStyles = _artist.Styles.OrderBy(s => s).ToList();
|
||||
_sortedMoods = _artist.Moods.OrderBy(m => m).ToList();
|
||||
});
|
||||
|
||||
_musicVideos = await Mediator.Send(new GetMusicVideoCards(ArtistId, 1, 100), token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToCollection()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", _artist.Name } };
|
||||
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 })
|
||||
{
|
||||
await Mediator.Send(new AddArtistToCollection(collection.Id, ArtistId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToSchedule()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", _artist.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
|
||||
{
|
||||
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, CollectionType.Artist, ArtistId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddMusicVideoToCollection(MusicVideoCardViewModel musicVideo)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
|
||||
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 AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding music video to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding music video to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
@page "/media/music/artists"
|
||||
@page "/media/music/artists/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits MultiSelectBase<MusicVideoList>
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="ArtistCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href="@($"media/music/artists/{context.ArtistId}")"
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/music/artists"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private ArtistCardResultsViewModel _data = new(0, new List<ArtistCardViewModel>(), null);
|
||||
private string _query;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:artist" : $"type:artist AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexArtists(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/music/artists/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/music/artists/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is ArtistCardViewModel artist)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } };
|
||||
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 AddArtistToCollection(collection.Id, artist.ArtistId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding artist to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding artist to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {artist.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,633 +0,0 @@
|
||||
@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/>
|
||||
<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>
|
||||
<MudSelectItem Value="CollectionType.SearchQuery">Search Query</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.SearchQuery)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Search Title</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="@_selectedItem.SearchTitle"
|
||||
For="@(() => _selectedItem.SearchTitle)"
|
||||
HelperText="Optional title that will be displayed in the UI"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Search Query</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="@_selectedItem.SearchQuery" For="@(() => _selectedItem.SearchQuery)"/>
|
||||
</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,
|
||||
SearchTitle = item.SearchTitle,
|
||||
SearchQuery = item.SearchQuery,
|
||||
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,
|
||||
SearchTitle = item.SearchTitle,
|
||||
SearchQuery = item.SearchQuery,
|
||||
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.SearchTitle,
|
||||
item.SearchQuery,
|
||||
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;
|
||||
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
@page "/playouts/block/{Id:int}"
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IEntityLocker EntityLocker;
|
||||
@inject ILogger<BlockPlayoutEditor> Logger
|
||||
|
||||
<MudForm 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-6" OnClick="@(_ => SaveDefaultDeco())" StartIcon="@Icons.Material.Filled.Save">
|
||||
Save Default Deco
|
||||
</MudButton>
|
||||
</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">@_channelName - Block Playout</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>Playout Templates</MudText>
|
||||
</div>
|
||||
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Primary" Href="@($"playouts/{Id}/templates")" StartIcon="@Icons.Material.Filled.Edit">
|
||||
Edit Templates
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Default Deco</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>Enable Default Deco</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="_enableDefaultDeco" Dense="true"/>
|
||||
</MudStack>
|
||||
@if (_enableDefaultDeco)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco Group</MudText>
|
||||
</div>
|
||||
<MudSelect T="DecoGroupViewModel"
|
||||
Value="@_selectedDefaultDecoGroup"
|
||||
ValueChanged="@(vm => UpdateDefaultDecoTemplateGroupItems(vm))">
|
||||
@foreach (DecoGroupViewModel decoGroup in _decoGroups)
|
||||
{
|
||||
<MudSelectItem Value="@decoGroup">@decoGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_defaultDeco"
|
||||
For="@(() => _defaultDeco)"
|
||||
ToStringFunc="@(d => d?.Name)">
|
||||
@foreach (DecoViewModel deco in _decos)
|
||||
{
|
||||
<MudSelectItem Value="@deco">@deco.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Maintenance</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>Playout Items and History</MudText>
|
||||
</div>
|
||||
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Warning" OnClick="@(_ => EraseItems(eraseHistory: false))" StartIcon="@Icons.Material.Filled.Delete">
|
||||
Erase Items
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Error" OnClick="@(_ => EraseItems(eraseHistory: true))" StartIcon="@Icons.Material.Filled.Delete">
|
||||
Erase Items and History
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly List<DecoGroupViewModel> _decoGroups = [];
|
||||
private readonly List<DecoViewModel> _decos = [];
|
||||
|
||||
private string _channelName;
|
||||
private bool _enableDefaultDeco;
|
||||
private DecoGroupViewModel _selectedDefaultDecoGroup;
|
||||
private DecoViewModel _defaultDeco;
|
||||
|
||||
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<string> maybeName = await Mediator.Send(new GetChannelNameByPlayoutId(Id), token);
|
||||
if (maybeName.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("playouts");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string name in maybeName)
|
||||
{
|
||||
_channelName = name;
|
||||
}
|
||||
|
||||
_decoGroups.Clear();
|
||||
_decoGroups.AddRange(await Mediator.Send(new GetAllDecoGroups(), token));
|
||||
|
||||
Option<DecoViewModel> maybeDefaultDeco = await Mediator.Send(new GetDecoByPlayoutId(Id), token);
|
||||
foreach (DecoViewModel defaultDeco in maybeDefaultDeco)
|
||||
{
|
||||
_enableDefaultDeco = true;
|
||||
_selectedDefaultDecoGroup = _decoGroups.SingleOrDefault(dg => dg.Id == defaultDeco.DecoGroupId);
|
||||
await UpdateDefaultDecoTemplateGroupItems(_selectedDefaultDecoGroup);
|
||||
_defaultDeco = defaultDeco;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateDefaultDecoTemplateGroupItems(DecoGroupViewModel decoGroup)
|
||||
{
|
||||
_selectedDefaultDecoGroup = decoGroup;
|
||||
|
||||
_decos.Clear();
|
||||
_decos.AddRange(await Mediator.Send(new GetDecosByDecoGroupId(_selectedDefaultDecoGroup.Id), _cts.Token));
|
||||
}
|
||||
|
||||
private async Task EraseItems(bool eraseHistory)
|
||||
{
|
||||
IRequest request = eraseHistory ? new ErasePlayoutHistory(Id) : new ErasePlayoutItems(Id);
|
||||
await Mediator.Send(request, _cts.Token);
|
||||
|
||||
string message = eraseHistory ? "Erased playout items and history" : "Erased playout items";
|
||||
Snackbar.Add(message, Severity.Info);
|
||||
}
|
||||
|
||||
private async Task SaveDefaultDeco()
|
||||
{
|
||||
int? decoId = _enableDefaultDeco ? _defaultDeco?.Id : null;
|
||||
Option<BaseError> result = await Mediator.Send(new UpdateDefaultDeco(Id, decoId), _cts.Token);
|
||||
result.Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving default deco: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving default deco: {Error}", error.Value);
|
||||
},
|
||||
() => { Snackbar.Add($"Saved default deco for playout {_channelName}", Severity.Success); });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
@page "/blocks"
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@implements IDisposable
|
||||
@inject ILogger<Blocks> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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 Groups</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="_blockGroupName" For="@(() => _blockGroupName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddBlockGroup())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Block Group
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Blocks</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</MudText>
|
||||
</div>
|
||||
<MudSelect T="string" ValueChanged="@(name => UpdateSelectedBlockGroup(name))">
|
||||
@foreach (BlockGroupViewModel blockGroup in _blockGroups)
|
||||
{
|
||||
<MudSelectItem Value="@blockGroup.Name">@blockGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_blockName" For="@(() => _blockName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddBlock())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Block
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudTable Hover="true"
|
||||
Dense="true"
|
||||
Class="mt-8"
|
||||
Items="@_blocks"
|
||||
GroupBy="@_groupDefinition"
|
||||
GroupHeaderStyle="background-color:var(--mud-palette-appbarbackground)"
|
||||
RowStyle="background-color:var(--mud-palette-background-gray)"
|
||||
Filter="new Func<BlockViewModel,bool>(FilterBlocks)">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col style="width: 60px;"/>
|
||||
<col/>
|
||||
<col style="width: 180px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string"
|
||||
ValueChanged="@(s => OnSearch(s))"
|
||||
Placeholder="Search for blocks"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.FilterList"
|
||||
Clearable="true">
|
||||
</MudTextField>
|
||||
</ToolBarContent>
|
||||
<GroupHeaderTemplate>
|
||||
<MudTd Class="mud-table-cell-custom-group">
|
||||
@($"{context.Key}")
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<div style="width: 48px;"></div>
|
||||
<div style="width: 48px;"></div>
|
||||
<MudTooltip Text="Delete Block Group">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteBlockGroup(context.Items?.FirstOrDefault()))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</GroupHeaderTemplate>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Name</MudTd>
|
||||
<MudTd>
|
||||
<div class="d-flex">
|
||||
@if (context.Id >= 0)
|
||||
{
|
||||
<MudTooltip Text="Edit Block">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"blocks/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Copy Block">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
OnClick="@(_ => CopyBlock(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Block">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteBlock(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="height: 48px; width: 48px"></div>
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly List<BlockViewModel> _blocks = [];
|
||||
private List<BlockGroupViewModel> _blockGroups = [];
|
||||
private BlockGroupViewModel _selectedBlockGroup;
|
||||
private string _blockGroupName;
|
||||
private string _blockName;
|
||||
private string _searchString;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_blockGroups = await Mediator.Send(new GetAllBlockGroups(), token);
|
||||
|
||||
_blocks.Clear();
|
||||
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), token));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private readonly TableGroupDefinition<BlockViewModel> _groupDefinition = new()
|
||||
{
|
||||
GroupName = "Group",
|
||||
Indentation = false,
|
||||
Expandable = true,
|
||||
Selector = (e) => e.GroupName
|
||||
};
|
||||
|
||||
private async Task AddBlockGroup()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_blockGroupName))
|
||||
{
|
||||
Either<BaseError, BlockGroupViewModel> result = await Mediator.Send(new CreateBlockGroup(_blockGroupName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding block group: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (BlockGroupViewModel blockGroup in result.RightToSeq())
|
||||
{
|
||||
_blockGroupName = null;
|
||||
_blockGroups = await Mediator.Send(new GetAllBlockGroups(), _cts.Token);
|
||||
_selectedBlockGroup = _blockGroups.Find(bg => bg.Id == blockGroup.Id);
|
||||
|
||||
_blocks.Clear();
|
||||
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), _cts.Token));
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSelectedBlockGroup(string blockGroupName)
|
||||
{
|
||||
_selectedBlockGroup = _blockGroups.Find(bg => bg.Name == blockGroupName);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task AddBlock()
|
||||
{
|
||||
if (_selectedBlockGroup is not null && !string.IsNullOrWhiteSpace(_blockName))
|
||||
{
|
||||
Either<BaseError, BlockViewModel> result = await Mediator.Send(new CreateBlock(_selectedBlockGroup.Id, _blockName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding block: {Error}", error.Value);
|
||||
}
|
||||
|
||||
if (result.IsRight)
|
||||
{
|
||||
_blocks.Clear();
|
||||
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), _cts.Token));
|
||||
|
||||
_blockName = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
_searchString = query;
|
||||
}
|
||||
|
||||
private bool FilterBlocks(BlockViewModel block) => FilterBlocks(block, _searchString);
|
||||
|
||||
private bool FilterBlocks(BlockViewModel block, string searchString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchString))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (block.Name.Contains(searchString, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task DeleteBlockGroup(BlockViewModel block)
|
||||
{
|
||||
if (block is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new DialogParameters { { "EntityType", "block group" }, { "EntityName", block.GroupName } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Block Group", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
await Mediator.Send(new DeleteBlockGroup(block.GroupId), _cts.Token);
|
||||
if (_selectedBlockGroup?.Id == block.GroupId)
|
||||
{
|
||||
_selectedBlockGroup = null;
|
||||
}
|
||||
|
||||
_blockGroups = await Mediator.Send(new GetAllBlockGroups(), _cts.Token);
|
||||
_blocks.Clear();
|
||||
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), _cts.Token));
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyBlock(BlockViewModel block)
|
||||
{
|
||||
var parameters = new DialogParameters { { "BlockId", block.Id } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<CopyBlockDialog>("Copy Block", parameters, options);
|
||||
DialogResult dialogResult = await dialog.Result;
|
||||
if (dialogResult is { Canceled: false, Data: BlockViewModel data })
|
||||
{
|
||||
NavigationManager.NavigateTo($"blocks/{data.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteBlock(BlockViewModel block)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "block" }, { "EntityName", block.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Block", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
await Mediator.Send(new DeleteBlock(block.Id), _cts.Token);
|
||||
_blocks.Clear();
|
||||
_blocks.AddRange(await Mediator.Send(new GetAllBlocks(), _cts.Token));
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
@page "/channels/{Id:int?}"
|
||||
@page "/channels/add"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Artworks
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.Filler
|
||||
@using ErsatzTV.Application.Images
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.Templates
|
||||
@using ErsatzTV.Application.Watermarks
|
||||
@using ErsatzTV.Core.Domain.Filler
|
||||
@using ErsatzTV.Validators
|
||||
@using FluentValidation.Results
|
||||
@using static Prelude
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ChannelEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudForm Model="@_model" @ref="@_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" 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-6" OnClick="@HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Channel" : "Add Channel")</MudButton>
|
||||
</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">Channel</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>Number</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Group</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Group" For="@(() => _model.Group)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Categories</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Categories" For="@(() => _model.Categories)" HelperText="Comma-separated list of categories"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Transcode Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.TranscodeMode" For="@(() => _model.TranscodeMode)" Disabled="true" HelperText="Controls when the transcoding process is active">
|
||||
<MudSelectItem Value="@(ChannelTranscodeMode.OnDemand)">On Demand</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Idle Behavior</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.IdleBehavior" For="@(() => _model.IdleBehavior)" HelperText="Controls how the transcoding process behaves after all clients have disconnected">
|
||||
<MudSelectItem Value="@(ChannelIdleBehavior.StopOnDisconnect)">Stop On Disconnect</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelIdleBehavior.KeepRunning)">Keep Running</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Playout Source</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.PlayoutSource"
|
||||
For="@(() => _model.PlayoutSource)"
|
||||
HelperText="Controls the source of the channel's content. This cannot be changed when the channel already has a playout."
|
||||
Disabled="@(_channels.Any(c => c.PlayoutSource is ChannelPlayoutSource.Generated && c.Id == Id && c.PlayoutCount > 0))">
|
||||
<MudSelectItem Value="@(ChannelPlayoutSource.Generated)">Generated</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelPlayoutSource.Mirror)">Mirror</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_model.PlayoutSource is ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Mirror Source Channel</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.MirrorSourceChannelId" For="@(() => _model.MirrorSourceChannelId)"
|
||||
Clearable="true">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
||||
@foreach (ChannelViewModel channel in _channels
|
||||
.Where(c => c.Id != Id && c.PlayoutSource is ChannelPlayoutSource.Generated)
|
||||
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)))
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@channel.Id">(@channel.Number) - @channel.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Playout Offset</MudText>
|
||||
</div>
|
||||
<MudTextField T="int"
|
||||
@bind-Value="_model.PlayoutOffsetHours"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentText="hours"/>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Playout Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.PlayoutMode" For="@(() => _model.PlayoutMode)" HelperText="Controls the progression of the channel's playout">
|
||||
<MudSelectItem Value="@(ChannelPlayoutMode.Continuous)">Continuous</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelPlayoutMode.OnDemand)">On Demand</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Is Enabled</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="_model.IsEnabled" For="@(() => _model.IsEnabled)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Show In EPG</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="_model.ShowInEpg" For="@(() => _model.ShowInEpg)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Streaming Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
|
||||
<MudSelectItem Value="@(StreamingMode.TransportStreamHybrid)">MPEG-TS</MudSelectItem>
|
||||
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS (Legacy)</MudSelectItem>
|
||||
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
|
||||
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>FFmpeg Profile</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
|
||||
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">
|
||||
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
|
||||
{
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Slug Seconds</MudText>
|
||||
</div>
|
||||
<MudSelect T="double?" @bind-Value="_model.SlugSeconds" For="@(() => _model.SlugSeconds)" HelperText="Seconds of black video and silent audio to insert between *every* playout item">
|
||||
<MudSelectItem Value="@((double?)null)">(none)</MudSelectItem>
|
||||
<MudSelectItem Value="@((double?)0.5)">0.5 seconds</MudSelectItem>
|
||||
<MudSelectItem Value="@((double?)1)">1 second</MudSelectItem>
|
||||
<MudSelectItem Value="@((double?)2)">2 seconds</MudSelectItem>
|
||||
<MudSelectItem Value="@((double?)3)">3 seconds</MudSelectItem>
|
||||
<MudSelectItem Value="@((double?)5)">5 seconds</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Stream Selector Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.StreamSelectorMode" For="@(() => _model.StreamSelectorMode)">
|
||||
<MudSelectItem Value="@(ChannelStreamSelectorMode.Default)">Default</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelStreamSelectorMode.Custom)">Custom</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_model.StreamSelectorMode is ChannelStreamSelectorMode.Default)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Preferred Audio Language</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.PreferredAudioLanguageCode"
|
||||
For="@(() => _model.PreferredAudioLanguageCode)"
|
||||
Clearable="true">
|
||||
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
|
||||
@foreach (LanguageCodeViewModel culture in _availableCultures)
|
||||
{
|
||||
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Preferred Audio Title</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.PreferredAudioTitle" For="@(() => _model.PreferredAudioTitle)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Preferred Subtitle Language</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.PreferredSubtitleLanguageCode"
|
||||
For="@(() => _model.PreferredSubtitleLanguageCode)"
|
||||
Clearable="true">
|
||||
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
|
||||
@foreach (LanguageCodeViewModel culture in _availableCultures)
|
||||
{
|
||||
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Subtitle Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.SubtitleMode" For="@(() => _model.SubtitleMode)">
|
||||
<MudSelectItem Value="@(ChannelSubtitleMode.None)">None</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelSubtitleMode.Forced)">Forced</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Stream Selector</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.StreamSelector"
|
||||
For="@(() => _model.StreamSelector)">
|
||||
<MudSelectItem T="string" Value="@((string)null)">(none)</MudSelectItem>
|
||||
@foreach (string selector in _streamSelectors)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@selector">@selector</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Music Video Credits Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.MusicVideoCreditsMode" For="@(() => _model.MusicVideoCreditsMode)">
|
||||
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.None)">None</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelMusicVideoCreditsMode.GenerateSubtitles)">Generate Subtitles</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Music Video Credits Template</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.MusicVideoCreditsTemplate"
|
||||
For="@(() => _model.MusicVideoCreditsTemplate)"
|
||||
Disabled="@(_model.MusicVideoCreditsMode != ChannelMusicVideoCreditsMode.GenerateSubtitles)">
|
||||
<MudSelectItem T="string" Value="@((string)null)">(none)</MudSelectItem>
|
||||
@foreach (string template in _musicVideoCreditsTemplates)
|
||||
{
|
||||
<MudSelectItem T="string" Value="@template">@template</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Song Video Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.SongVideoMode" For="@(() => _model.SongVideoMode)">
|
||||
<MudSelectItem Value="@(ChannelSongVideoMode.Default)">Default</MudSelectItem>
|
||||
<MudSelectItem Value="@(ChannelSongVideoMode.WithProgress)">With Progress</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Logo</MudText>
|
||||
</div>
|
||||
<InputFile id="fileInput" OnChange="@UploadLogo" style="display: none;"/>
|
||||
<MudButton HtmlTag="label"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.CloudUpload"
|
||||
for="fileInput">
|
||||
Upload Logo
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>External Logo URL</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.ExternalLogoUrl" For="@(() => _model.ExternalLogoUrl)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Logo Preview</MudText>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(_model.Logo?.Path) || !string.IsNullOrWhiteSpace(_model.ExternalLogoUrl))
|
||||
{
|
||||
<MudElement HtmlTag="img" src="@(string.IsNullOrWhiteSpace(_model.ExternalLogoUrl) ? _model.Logo.UrlWithContentType : _model.ExternalLogoUrl)" Style="max-height: 50px"/>
|
||||
}
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Watermark</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.WatermarkId" For="@(() => _model.WatermarkId)"
|
||||
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)"
|
||||
Clearable="true">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
||||
@foreach (WatermarkViewModel watermark in _watermarks)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@watermark.Id">@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>Fallback Filler</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.FallbackFillerId" For="@(() => _model.FallbackFillerId)" Clearable="true">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
||||
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int? Id { get; set; }
|
||||
|
||||
private readonly ChannelEditViewModel _model = new();
|
||||
private readonly ChannelEditViewModelValidator _validator = new();
|
||||
private MudForm _form;
|
||||
|
||||
private List<ChannelViewModel> _channels = [];
|
||||
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
|
||||
private List<LanguageCodeViewModel> _availableCultures = [];
|
||||
private List<WatermarkViewModel> _watermarks = [];
|
||||
private List<FillerPresetViewModel> _fillerPresets = [];
|
||||
private List<string> _musicVideoCreditsTemplates = [];
|
||||
private List<string> _streamSelectors = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
var loadingTasks = new List<Task>
|
||||
{
|
||||
LoadDataAsync(() => Mediator.Send(new GetAllChannels(), token), r => _channels = r),
|
||||
LoadDataAsync(() => Mediator.Send(new GetAllFFmpegProfiles(), token), r => _ffmpegProfiles = r),
|
||||
LoadDataAsync(() => Mediator.Send(new GetAllLanguageCodes(), token), r => _availableCultures = r),
|
||||
LoadDataAsync(() => Mediator.Send(new GetAllWatermarks(), token), r => _watermarks = r),
|
||||
LoadDataAsync(() => Mediator.Send(new GetAllFillerPresets(), token)
|
||||
.Map(list => list.Filter(vm => vm.FillerKind == FillerKind.Fallback).ToList()), r => _fillerPresets = r),
|
||||
LoadDataAsync(() => Mediator.Send(new GetMusicVideoCreditTemplates(), token), r => _musicVideoCreditsTemplates = r),
|
||||
LoadDataAsync(() => Mediator.Send(new GetChannelStreamSelectors(), token), r => _streamSelectors = r),
|
||||
};
|
||||
|
||||
await Task.WhenAll(loadingTasks);
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
Option<ChannelViewModel> maybeChannel = await Mediator.Send(new GetChannelById(Id.Value), token);
|
||||
maybeChannel.Match(
|
||||
channelViewModel =>
|
||||
{
|
||||
_model.Id = channelViewModel.Id;
|
||||
_model.Name = channelViewModel.Name;
|
||||
_model.Group = channelViewModel.Group;
|
||||
_model.Categories = channelViewModel.Categories;
|
||||
_model.Number = channelViewModel.Number;
|
||||
_model.FFmpegProfileId = channelViewModel.FFmpegProfileId;
|
||||
_model.SlugSeconds = channelViewModel.SlugSeconds;
|
||||
|
||||
if (channelViewModel.Logo.IsExternalUrl)
|
||||
{
|
||||
_model.ExternalLogoUrl = channelViewModel.Logo.Path;
|
||||
}
|
||||
else
|
||||
{
|
||||
_model.Logo = channelViewModel.Logo;
|
||||
}
|
||||
|
||||
_model.PlayoutSource = channelViewModel.PlayoutSource;
|
||||
_model.PlayoutMode = channelViewModel.PlayoutMode;
|
||||
_model.MirrorSourceChannelId = channelViewModel.MirrorSourceChannelId;
|
||||
_model.PlayoutOffset = channelViewModel.PlayoutOffset;
|
||||
_model.StreamingMode = channelViewModel.StreamingMode;
|
||||
_model.StreamSelectorMode = channelViewModel.StreamSelectorMode;
|
||||
_model.StreamSelector = channelViewModel.StreamSelector;
|
||||
_model.PreferredAudioLanguageCode = channelViewModel.PreferredAudioLanguageCode;
|
||||
_model.PreferredAudioTitle = channelViewModel.PreferredAudioTitle;
|
||||
_model.WatermarkId = channelViewModel.WatermarkId;
|
||||
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
|
||||
_model.PreferredSubtitleLanguageCode = channelViewModel.PreferredSubtitleLanguageCode;
|
||||
_model.SubtitleMode = channelViewModel.SubtitleMode;
|
||||
_model.MusicVideoCreditsMode = channelViewModel.MusicVideoCreditsMode;
|
||||
_model.MusicVideoCreditsTemplate = channelViewModel.MusicVideoCreditsTemplate;
|
||||
_model.SongVideoMode = channelViewModel.SongVideoMode;
|
||||
_model.TranscodeMode = channelViewModel.TranscodeMode;
|
||||
_model.IdleBehavior = channelViewModel.IdleBehavior;
|
||||
_model.IsEnabled = channelViewModel.IsEnabled;
|
||||
_model.ShowInEpg = channelViewModel.ShowInEpg;
|
||||
},
|
||||
() => NavigationManager.NavigateTo("404"));
|
||||
}
|
||||
else
|
||||
{
|
||||
FFmpegSettingsViewModel ffmpegSettings = await Mediator.Send(new GetFFmpegSettings(), token);
|
||||
|
||||
// TODO: command for new channel
|
||||
IEnumerable<int> channelNumbers = await Mediator.Send(new GetAllChannels(), token)
|
||||
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0));
|
||||
int maxNumber = Optional(channelNumbers).Flatten().DefaultIfEmpty(0).Max();
|
||||
_model.Number = (maxNumber + 1).ToString();
|
||||
_model.Name = "New Channel";
|
||||
_model.Group = "ErsatzTV";
|
||||
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
|
||||
_model.StreamingMode = StreamingMode.TransportStreamHybrid;
|
||||
_model.IsEnabled = true;
|
||||
_model.ShowInEpg = true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync<T>(Func<Task<T>> fetch, Action<T> assign)
|
||||
{
|
||||
assign(await fetch());
|
||||
}
|
||||
|
||||
private bool IsEdit => Id.HasValue;
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (!_form.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = _cts?.Token ?? CancellationToken.None;
|
||||
|
||||
ValidationResult result = await _validator.ValidateAsync(_model, token);
|
||||
if (result.IsValid)
|
||||
{
|
||||
Seq<BaseError> errorMessage = IsEdit
|
||||
? (await Mediator.Send(_model.ToUpdate(), token)).LeftToSeq()
|
||||
: (await Mediator.Send(_model.ToCreate(), token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("channels"));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadLogo(InputFileChangeEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = _cts?.Token ?? CancellationToken.None;
|
||||
|
||||
Either<BaseError, string> maybeCacheFileName =
|
||||
await Mediator.Send(new SaveArtworkToDisk(e.File.OpenReadStream(10 * 1024 * 1024), ArtworkKind.Logo, e.File.ContentType), token);
|
||||
maybeCacheFileName.Match(
|
||||
relativeFileName =>
|
||||
{
|
||||
_model.Logo = new ArtworkContentTypeModel($"iptv/logos/{relativeFileName}", e.File.ContentType);
|
||||
_model.ExternalLogoUrl = null;
|
||||
StateHasChanged();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Snackbar.Add("Channel logo exceeds maximum allowed file size of 10 MB", Severity.Error);
|
||||
Logger.LogError("Channel logo exceeds maximum allowed file size of 10 MB");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving channel logo: {ex.Message}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel logo: {Error}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
@page "/channels/numbers"
|
||||
@using ErsatzTV.Application.Channels
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<ChannelNumbers> Logger
|
||||
|
||||
<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" StartIcon="@Icons.Material.Filled.Save" OnClick="@SaveChannels">
|
||||
Save Channels
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Error" Class="ml-3" StartIcon="@Icons.Material.Filled.Undo" OnClick="@ReloadAllChannels">
|
||||
Reset Channels
|
||||
</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 Channels" OnClick="@SaveChannels"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Undo" Label="Reset Channels" OnClick="@ReloadAllChannels"/>
|
||||
</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">Edit Channel Numbers</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<Sortable TItem="ChannelSortViewModel" Items="_channels" OnUpdate="OnUpdate">
|
||||
<MudPaper Class="@($"pa-2 ma-2 ")"
|
||||
Style="@($"max-width: 500px; {(context.HasChanged ? "background-color: var(--mud-palette-dark-lighten)" : "")}")">
|
||||
<div class="d-flex align-center">
|
||||
<MudText Style="min-width: 6ch">@context.Number</MudText>
|
||||
|
||||
<MudText Class="flex-grow-1">
|
||||
@context.Name
|
||||
</MudText>
|
||||
|
||||
<MudText Style="@(context.HasChanged ? "" : "visibility: hidden;")">
|
||||
(@context.OriginalNumber)
|
||||
</MudText>
|
||||
</div>
|
||||
</MudPaper>
|
||||
</Sortable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private List<ChannelSortViewModel> _channels = [];
|
||||
private List<string> _channelNumbers = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await ReloadAllChannels();
|
||||
}
|
||||
|
||||
private async Task ReloadAllChannels()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_channels = await Mediator.Send(new GetAllChannelsForSort(), token);
|
||||
_channelNumbers = _channels.Map(c => c.Number).ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveChannels()
|
||||
{
|
||||
var changedChannels = _channels.Where(c => c.HasChanged).ToList();
|
||||
Option<BaseError> maybeError = await Mediator.Send(new UpdateChannelNumbers(changedChannels), _cts.Token);
|
||||
maybeError.Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel numbers: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("channels"));
|
||||
}
|
||||
|
||||
private void OnUpdate(SortableEventArgs<ChannelSortViewModel> obj)
|
||||
{
|
||||
for (var i = 0; i < _channels.Count; i++)
|
||||
{
|
||||
_channels[i].Number = _channelNumbers[i];
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
@page "/channels"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Core.Interfaces.FFmpeg
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject IMediator Mediator
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IFFmpegSegmenterService SegmenterService
|
||||
@inject IStringLocalizer<ErsatzTV.Locals.Pages.Channels> Loc;
|
||||
@inject IStringLocalizer<ErsatzTV.Locals.Shared.Common> CommonLoc;
|
||||
@inject IStringLocalizer<ErsatzTV.Locals.Shared.MainLayout> MainLayoutLoc;
|
||||
|
||||
<MudForm 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-6" StartIcon="@Icons.Material.Filled.Add" Href="channels/add">
|
||||
@Loc["ButtonAddChannel"]
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Class="ml-3" StartIcon="@Icons.Material.Filled.Edit" Href="channels/numbers">
|
||||
@Loc["ButtonEditChannelNumbers"]
|
||||
</MudButton>
|
||||
</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">@MainLayoutLoc["ButtonChannels"]</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
@bind-RowsPerPage="@_rowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<ChannelViewModel>>>(ServerReload))"
|
||||
@ref="_table"
|
||||
RowClassFunc="ChannelRowClassFunc">
|
||||
<ToolBarContent>
|
||||
<MudSwitch T="bool" Class="ml-6" @bind-Value="@ShowDisabled" Color="Color.Secondary" Label="@Loc["ButtonShowDisabled"]"/>
|
||||
</ToolBarContent>
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col style="width: 60px;"/>
|
||||
<col/>
|
||||
<col style="width: 15%"/>
|
||||
<col style="width: 15%"/>
|
||||
<col style="width: 15%"/>
|
||||
<col style="width: 15%"/>
|
||||
<col style="width: 300px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>
|
||||
<MudTableSortLabel InitialDirection="SortDirection.Ascending" SortBy="new Func<ChannelViewModel, object>(x => decimal.Parse(x.Number, CultureInfo.InvariantCulture))">@Loc["LabelNumber"]</MudTableSortLabel>
|
||||
</MudTh>
|
||||
<MudTh>@Loc["LabelLogo"]</MudTh>
|
||||
<MudTh>
|
||||
<MudTableSortLabel SortBy="new Func<ChannelViewModel, object>(x => x.Name)">@Loc["LabelName"]</MudTableSortLabel>
|
||||
</MudTh>
|
||||
<MudTh>@Loc["LabelLanguage"]</MudTh>
|
||||
<MudTh>@Loc["LabelStreamingMode"]</MudTh>
|
||||
<MudTh>@Loc["LabelFFmpegProfile"]</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="@Loc["LabelNumber"]">
|
||||
@if (!context.IsEnabled)
|
||||
{
|
||||
<MudTooltip Text="@Loc["LabelChannelDisabledHelp"]">
|
||||
@($"({Loc["LabelChannelDisabledAbbreviation"]}) {context.Number}")
|
||||
</MudTooltip>
|
||||
}
|
||||
else if (!context.ShowInEpg)
|
||||
{
|
||||
<MudTooltip Text="@Loc["LabelChannelHiddenHelp"]">
|
||||
@($"({Loc["LabelChannelHiddenAbbreviation"]}) {context.Number}")
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
@context.Number
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="@Loc["LabelLogo"]">
|
||||
@if (!string.IsNullOrWhiteSpace(context.Logo?.Path))
|
||||
{
|
||||
<MudElement HtmlTag="img" src="@context.Logo.UrlWithContentType" Style="max-height: 50px"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudElement HtmlTag="img" src="@($"iptv/logos/gen?text={context.WebEncodedName}")" Style="max-height: 50px"/>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="@Loc["LabelName"]">@context.Name</MudTd>
|
||||
<MudTd DataLabel="@Loc["LabelLanguage"]">@context.PreferredAudioLanguageCode</MudTd>
|
||||
<MudTd DataLabel="@Loc["LabelStreamingMode"]">@GetStreamingMode(context.StreamingMode)</MudTd>
|
||||
<MudTd DataLabel="@Loc["LabelFFmpegProfile"]">
|
||||
@if (context.StreamingMode != StreamingMode.HttpLiveStreamingDirect)
|
||||
{
|
||||
@_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId)?.Name
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
@if (_channelsThatCanPreview.Contains(context.Id))
|
||||
{
|
||||
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId));
|
||||
bool notNormalizing = maybeProfile.Any(p => !p.NormalizeVideo || !p.NormalizeAudio);
|
||||
if (notNormalizing)
|
||||
{
|
||||
<MudTooltip Text="@Loc["ButtonPreviewChannelHelpNormalize"]">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle"
|
||||
OnClick="@(_ => PreviewChannel(context))"
|
||||
Style="color: var(--mud-palette-warning-lighten);">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@Loc["ButtonPreviewChannelHelp"]">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle"
|
||||
OnClick="@(_ => PreviewChannel(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
}
|
||||
else if (CanPreviewChannel(context) && _ffmpegProfilesThatCanPreview.ContainsKey(context.FFmpegProfileId) && !_ffmpegProfilesThatCanPreview[context.FFmpegProfileId])
|
||||
{
|
||||
<MudTooltip Text="@Loc["ButtonPreviewChannelHelpBrowser"]">
|
||||
<div style="height: 48px; width: 48px; align-items: center; display: flex; justify-content: center">
|
||||
<!--suppress CssUnresolvedCustomProperty -->
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlayCircle" Style="color: var(--mud-palette-error-lighten);">
|
||||
</MudIcon>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@Loc["ButtonPreviewChannelHelpSettings"]">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle" Disabled="true">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
@if (SegmenterService.IsActive(context.Number))
|
||||
{
|
||||
<MudTooltip Text="@Loc["ButtonStopTranscodeHelp"]">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
OnClick="@(_ => StopChannel(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="width: 48px"></div>
|
||||
}
|
||||
<MudTooltip Text="@Loc["ButtonEditChannelHelp"]">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"channels/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
@if (context.PlayoutCount > 0 && context.IsEnabled)
|
||||
{
|
||||
<MudTooltip Text="@Loc["ButtonTroubleshootChannelHelp"]">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Troubleshoot"
|
||||
Href="@($"system/troubleshooting/playback?channel={context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="width: 48px"></div>
|
||||
}
|
||||
<MudTooltip Text="@Loc["ButtonDeleteChannelHelp"]">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteChannelAsync(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager RowsPerPageString="@CommonLoc["LabelRowsPerPage"]" />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudTable<ChannelViewModel> _table;
|
||||
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
|
||||
private readonly System.Collections.Generic.HashSet<int> _channelsThatCanPreview = [];
|
||||
private readonly Dictionary<int, bool> _ffmpegProfilesThatCanPreview = [];
|
||||
private bool _showDisabled;
|
||||
private int _rowsPerPage = 10;
|
||||
|
||||
private bool ShowDisabled
|
||||
{
|
||||
get => _showDisabled;
|
||||
set
|
||||
{
|
||||
if (_showDisabled != value)
|
||||
{
|
||||
_showDisabled = value;
|
||||
_table?.ReloadServerData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized() => SegmenterService.OnWorkersChanged += WorkersChanged;
|
||||
|
||||
private void WorkersChanged(object sender, EventArgs e) =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SegmenterService.OnWorkersChanged -= WorkersChanged;
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
_showDisabled = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.ChannelsShowDisabled), token)
|
||||
.Map(maybeShow => maybeShow.Match(ce => bool.TryParse(ce.Value, out bool show) && show, () => false));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopChannel(ChannelViewModel channel)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
await SegmenterService.StopChannel(channel.Number, cts.Token);
|
||||
}
|
||||
|
||||
private bool CanPreviewChannel(ChannelViewModel channel)
|
||||
{
|
||||
if (!channel.IsEnabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (channel.StreamingMode is StreamingMode.HttpLiveStreamingDirect or StreamingMode.TransportStream)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (channel.PlayoutCount < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> CanPreviewFFmpegProfile(int ffmpegProfileId)
|
||||
{
|
||||
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == ffmpegProfileId));
|
||||
foreach (FFmpegProfileViewModel profile in maybeProfile)
|
||||
{
|
||||
string videoCodec = profile.VideoFormat switch
|
||||
{
|
||||
FFmpegProfileVideoFormat.Av1 => "av01.0.01M.08",
|
||||
FFmpegProfileVideoFormat.Hevc => "hvc1.1.6.L93.B0",
|
||||
FFmpegProfileVideoFormat.H264 => "avc1.4D4028",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
string audioCodec = profile.AudioFormat switch
|
||||
{
|
||||
FFmpegProfileAudioFormat.Ac3 => "ac-3",
|
||||
FFmpegProfileAudioFormat.Aac => "mp4a.40.2",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
//Console.WriteLine($"Checking video format {videoCodec} and audio format {audioCodec}");
|
||||
|
||||
// good luck
|
||||
if (profile.VideoFormat == FFmpegProfileVideoFormat.Copy || profile.AudioFormat == FFmpegProfileAudioFormat.Copy)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(videoCodec) || string.IsNullOrWhiteSpace(audioCodec))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await BrowserSupportsCodec($"{videoCodec}, {audioCodec}");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task PreviewChannel(ChannelViewModel channel)
|
||||
{
|
||||
if (!CanPreviewChannel(channel) || !await CanPreviewFFmpegProfile(channel.FFmpegProfileId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri));
|
||||
uri.Path = uri.Path.Replace("/channels", $"/iptv/channel/{channel.Number}.m3u8");
|
||||
uri.Query = channel.StreamingMode switch
|
||||
{
|
||||
_ => "?mode=segmenter"
|
||||
};
|
||||
|
||||
if (JwtHelper.IsEnabled)
|
||||
{
|
||||
uri.Query += $"&access_token={JwtHelper.GenerateToken()}";
|
||||
}
|
||||
|
||||
var parameters = new DialogParameters { { "StreamUri", uri.ToString() } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
|
||||
await Dialog.ShowAsync<ChannelPreviewDialog>("Channel Preview", parameters, options);
|
||||
}
|
||||
|
||||
private async Task DeleteChannelAsync(ChannelViewModel channel)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "channel" }, { "EntityName", channel.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Channel", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
await Mediator.Send(new DeleteChannel(channel.Id), cts.Token);
|
||||
if (_table != null)
|
||||
{
|
||||
await _table.ReloadServerData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<ChannelViewModel>> ServerReload(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.ChannelsShowDisabled, _showDisabled.ToString()), cancellationToken);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
List<ChannelViewModel> channels = await Mediator.Send(new GetAllChannels(_showDisabled), cancellationToken);
|
||||
// TODO: properly page this data
|
||||
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
|
||||
.Skip(state.Page * state.PageSize)
|
||||
.Take(state.PageSize)
|
||||
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture));
|
||||
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
var processedChannels = new List<ChannelViewModel>();
|
||||
_channelsThatCanPreview.Clear();
|
||||
_ffmpegProfilesThatCanPreview.Clear();
|
||||
foreach (ChannelViewModel channel in sorted)
|
||||
{
|
||||
Option<CultureInfo> maybeCultureInfo = allCultures.Find(ci => string.Equals(
|
||||
ci.ThreeLetterISOLanguageName,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
maybeCultureInfo.Match(
|
||||
cultureInfo => processedChannels.Add(channel with { PreferredAudioLanguageCode = cultureInfo.EnglishName }),
|
||||
() => processedChannels.Add(channel));
|
||||
|
||||
if (!_ffmpegProfilesThatCanPreview.TryGetValue(channel.FFmpegProfileId, out bool canPreviewFFmpegProfile))
|
||||
{
|
||||
canPreviewFFmpegProfile = await CanPreviewFFmpegProfile(channel.FFmpegProfileId);
|
||||
_ffmpegProfilesThatCanPreview.Add(channel.FFmpegProfileId, canPreviewFFmpegProfile);
|
||||
}
|
||||
|
||||
if (CanPreviewChannel(channel) && canPreviewFFmpegProfile)
|
||||
{
|
||||
_channelsThatCanPreview.Add(channel.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return new TableData<ChannelViewModel>
|
||||
{
|
||||
TotalItems = channels.Count,
|
||||
Items = processedChannels
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch
|
||||
{
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
||||
_ => "MPEG-TS (Legacy)"
|
||||
};
|
||||
|
||||
private async Task<bool> BrowserSupportsCodec(string codecString)
|
||||
{
|
||||
return await JsRuntime.InvokeAsync<bool>("mediaSourceSupports", codecString);
|
||||
}
|
||||
|
||||
private string ChannelRowClassFunc(ChannelViewModel channel, int index)
|
||||
{
|
||||
return channel.IsEnabled && channel.ShowInEpg ? string.Empty : "channel-disabled";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
@page "/playouts/classic/{Id:int}"
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IEntityLocker EntityLocker;
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
|
||||
</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">@_channelName - Classic Playout</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>Alternate Schedules</MudText>
|
||||
</div>
|
||||
@if (_playoutMode is ChannelPlayoutMode.OnDemand)
|
||||
{
|
||||
<MudButton Disabled="@true" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Edit">
|
||||
Edit Alternate Schedules
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Primary" Href="@($"playouts/{Id}/alternate-schedules")" StartIcon="@Icons.Material.Filled.Edit">
|
||||
Edit Alternate Schedules
|
||||
</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Maintenance</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>Playout Items and History</MudText>
|
||||
</div>
|
||||
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Error" OnClick="@(_ => EraseItemsAndHistory())" StartIcon="@Icons.Material.Filled.Delete">
|
||||
Erase Items and History
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private ChannelPlayoutMode _playoutMode;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private string _channelName;
|
||||
|
||||
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<ChannelViewModel> maybeChannel = await Mediator.Send(new GetChannelByPlayoutId(Id), token);
|
||||
if (maybeChannel.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("playouts");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ChannelViewModel channel in maybeChannel)
|
||||
{
|
||||
_channelName = channel.Name;
|
||||
_playoutMode = channel.PlayoutMode;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EraseItemsAndHistory()
|
||||
{
|
||||
await Mediator.Send(new ErasePlayoutHistory(Id), _cts.Token);
|
||||
Snackbar.Add("Erased playout items and history", Severity.Info);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
@page "/media/collections/{Id:int}/edit"
|
||||
@page "/media/collections/add"
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<CollectionEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@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-6" 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">
|
||||
<MudText Typo="Typo.h5" Class="mb-2">Collection</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Collection name is required!"/>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly CollectionEditViewModel _model = new();
|
||||
private MudForm _form;
|
||||
private bool _success;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
if (IsEdit)
|
||||
{
|
||||
Option<MediaCollectionViewModel> maybeCollection = await Mediator.Send(new GetCollectionById(Id), token);
|
||||
maybeCollection.IfSome(collection =>
|
||||
{
|
||||
_model.Id = collection.Id;
|
||||
_model.Name = collection.Name;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_model.Name = "New Collection";
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEdit => Id != 0;
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.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();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Error saving collection: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo(_model.Id > 0 ? $"media/collections/{_model.Id}" : "media/collections"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
@page "/media/collections/{Id:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@inherits MultiSelectBase<CollectionItems>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JsRuntime
|
||||
|
||||
<MudForm Style="max-height: 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%; align-items: center" class="ml-6 mr-6">
|
||||
@if (IsSelectMode())
|
||||
{
|
||||
<div class="flex-grow-1">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
|
||||
</div>
|
||||
<div style="margin-left: auto" class="d-none d-md-flex">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Remove"
|
||||
OnClick="@(_ => RemoveSelectionFromCollection(Id))">
|
||||
Remove From Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</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.Remove" Label="Remove From Collection" OnClick="@(_ => RemoveSelectionFromCollection(Id))"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Check" Label="Clear Selection" OnClick="ClearSelection"/>
|
||||
</MudMenu>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="align-items: center; display: flex; width: 100%">
|
||||
<MudText>@_data?.Name</MudText>
|
||||
<div class="d-none d-md-flex" style="align-items: center">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Href="@($"media/collections/{Id}/edit")"/>
|
||||
@if (_data?.MovieCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.ShowCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.SeasonCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#seasons")">@_data.SeasonCards.Count Seasons</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.EpisodeCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.ArtistCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")">@_data.ArtistCards.Count Artists</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.MusicVideoCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.OtherVideoCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#other_videos")">@_data.OtherVideoCards.Count Other Videos</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.SongCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#songs")">@_data.SongCards.Count Songs</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.ImageCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")">@_data.ImageCards.Count Images</MudLink>
|
||||
}
|
||||
|
||||
@if (_data?.RemoteStreamCards.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")">@_data.RemoteStreamCards.Count Remote Streams</MudLink>
|
||||
}
|
||||
</div>
|
||||
@if (SupportsCustomOrdering())
|
||||
{
|
||||
<div class="d-none d-md-flex" style="margin-left: auto">
|
||||
<MudSwitch T="bool"
|
||||
Value="@(_data?.UseCustomPlaybackOrder == true)"
|
||||
Color="Color.Primary"
|
||||
ValueChanged="@OnUseCustomOrderChanged"
|
||||
Label="Use Custom Playback Order"/>
|
||||
</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>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
@if (_data?.MovieCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
|
||||
Movies
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" UserAttributes="@(new Dictionary<string, object> { { "id", "sortable-collection" } })" Class="mb-10">
|
||||
@foreach (MovieCardViewModel card in OrderMovies(_data.MovieCards))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/movies/{card.MovieId}")"
|
||||
DeleteClicked="@RemoveMovieFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.ShowCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
|
||||
Shows
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/tv/shows/{card.TelevisionShowId}")"
|
||||
DeleteClicked="@RemoveShowFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.SeasonCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
|
||||
Seasons
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
Title="@card.ShowTitle"
|
||||
Subtitle="@card.Title"
|
||||
DeleteClicked="@RemoveSeasonFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.EpisodeCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
|
||||
Episodes
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
|
||||
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
|
||||
DeleteClicked="@(_ => RemoveEpisodeFromCollection(card))"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.ArtistCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
|
||||
Artists
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (ArtistCardViewModel card in _data.ArtistCards.OrderBy(e => e.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/music/artists/{card.ArtistId}")"
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
DeleteClicked="@RemoveArtistFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.MusicVideoCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
|
||||
Music Videos
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (MusicVideoCardViewModel card in _data.MusicVideoCards.OrderBy(e => e.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
DeleteClicked="@RemoveMusicVideoFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.OtherVideoCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
|
||||
Other Videos
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (OtherVideoCardViewModel card in _data.OtherVideoCards.OrderBy(e => e.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
DeleteClicked="@RemoveOtherVideoFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.SongCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
|
||||
Songs
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (SongCardViewModel card in _data.SongCards.OrderBy(e => e.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
DeleteClicked="@RemoveSongFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.ImageCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
|
||||
Images
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (ImageCardViewModel card in _data.ImageCards.OrderBy(e => e.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
DeleteClicked="@RemoveImageFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_data?.RemoteStreamCards.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "remote_streams" } })">
|
||||
Remote Streams
|
||||
</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (RemoteStreamCardViewModel card in _data.RemoteStreamCards.OrderBy(e => e.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
DeleteClicked="@RemoveRemoteStreamFromCollection"
|
||||
SelectColor="@Color.Error"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private CollectionCardResultsViewModel _data;
|
||||
|
||||
private bool SupportsCustomOrdering() =>
|
||||
_data is not null && _data.MovieCards.Any() && !_data.ShowCards.Any() && !_data.SeasonCards.Any() && !_data.EpisodeCards.Any();
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await RefreshData(CancellationToken);
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CollectionCardResultsViewModel> maybeResult =
|
||||
await Mediator.Send(new GetCollectionCards(Id), cancellationToken);
|
||||
|
||||
maybeResult.Match(
|
||||
result => _data = result,
|
||||
_ => NavigationManager.NavigateTo("404"));
|
||||
}
|
||||
|
||||
private IOrderedEnumerable<MovieCardViewModel> OrderMovies(List<MovieCardViewModel> movies)
|
||||
{
|
||||
if (_data.UseCustomPlaybackOrder)
|
||||
{
|
||||
return movies.OrderBy(m => m.CustomIndex);
|
||||
}
|
||||
|
||||
return movies.OrderBy(m => m.SortTitle);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("sortableCollection", Id);
|
||||
if (_data.UseCustomPlaybackOrder)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("enableSorting");
|
||||
}
|
||||
else
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("disableSorting");
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.MovieCards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
|
||||
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
|
||||
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
|
||||
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
|
||||
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
|
||||
.Append(_data.OtherVideoCards.OrderBy(ov => ov.SortTitle))
|
||||
.Append(_data.SongCards.OrderBy(s => s.SortTitle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task RemoveMovieFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is MovieCardViewModel movie)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [movie.MovieId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("movie", $"{movie.Title} ({movie.Subtitle})", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveArtistFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is ArtistCardViewModel artist)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [artist.ArtistId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation(
|
||||
"artist",
|
||||
string.IsNullOrWhiteSpace(artist.Subtitle) ? artist.Title : $"{artist.Title} ({artist.Subtitle})",
|
||||
request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveMusicVideoFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is MusicVideoCardViewModel musicVideo)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [musicVideo.MusicVideoId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("music video", $"{musicVideo.Title} ({musicVideo.Subtitle})", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveShowFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is TelevisionShowCardViewModel show)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [show.TelevisionShowId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("show", $"{show.Title} ({show.Subtitle})", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveSeasonFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is TelevisionSeasonCardViewModel season)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [season.TelevisionSeasonId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("season", $"{season.ShowTitle} - {season.Title}", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveEpisodeFromCollection(TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [episode.EpisodeId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request);
|
||||
}
|
||||
|
||||
private async Task RemoveOtherVideoFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is OtherVideoCardViewModel otherVideo)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [otherVideo.OtherVideoId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("other video", $"{otherVideo.Title}", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveSongFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is SongCardViewModel song)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [song.SongId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("song", $"{song.Title}", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveImageFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is ImageCardViewModel image)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [image.ImageId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("image", $"{image.Title}", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveRemoteStreamFromCollection(MediaCardViewModel vm)
|
||||
{
|
||||
if (vm is RemoteStreamCardViewModel remoteStream)
|
||||
{
|
||||
var request = new RemoveItemsFromCollection(Id)
|
||||
{
|
||||
MediaItemIds = [remoteStream.RemoteStreamId]
|
||||
};
|
||||
|
||||
await RemoveItemsWithConfirmation("remote stream", $"{remoteStream.Title}", request);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveItemsWithConfirmation(
|
||||
string entityType,
|
||||
string entityName,
|
||||
RemoveItemsFromCollection request)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", entityType }, { "EntityName", entityName } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<RemoveFromCollectionDialog>("Remove From Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(request, CancellationToken);
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnUseCustomOrderChanged()
|
||||
{
|
||||
_data.UseCustomPlaybackOrder = !_data.UseCustomPlaybackOrder;
|
||||
var request = new UpdateCollection(Id, _data.Name) { UseCustomPlaybackOrder = _data.UseCustomPlaybackOrder };
|
||||
await Mediator.Send(request, CancellationToken);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,882 +0,0 @@
|
||||
@page "/decos/{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<DecoEditor> 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">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">
|
||||
Save Changes
|
||||
</MudButton>
|
||||
</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">Deco</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>Deco Group Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_deco.GroupName" Disabled="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_deco.Name" For="@(() => _deco.Name)"/>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Watermark</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>Watermark Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_deco.WatermarkMode" For="@(() => _deco.WatermarkMode)">
|
||||
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Override">Replace</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Merge">Merge</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Watermark</MudText>
|
||||
</div>
|
||||
<MudSelect T="WatermarkViewModel"
|
||||
@bind-SelectedValues="_deco.Watermarks"
|
||||
Disabled="@(_deco.WatermarkMode is not (DecoMode.Override or DecoMode.Merge))"
|
||||
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>Use Watermark During Filler</MudText>
|
||||
</div>
|
||||
<MudCheckBox T="bool"
|
||||
Disabled="@(_deco.WatermarkMode != DecoMode.Override)"
|
||||
@bind-Value="_deco.UseWatermarkDuringFiller"
|
||||
Dense="true"/>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Graphics Elements</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>Graphics Elements Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_deco.GraphicsElementsMode" For="@(() => _deco.GraphicsElementsMode)">
|
||||
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Override">Replace</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Merge">Merge</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="_deco.GraphicsElements"
|
||||
Disabled="@(_deco.GraphicsElementsMode is not (DecoMode.Override or DecoMode.Merge))"
|
||||
ToStringFunc="@(ge => ge?.Name)"
|
||||
Clearable="true"
|
||||
MultiSelection="true">
|
||||
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
|
||||
{
|
||||
<MudSelectItem Value="@graphicsElement">@graphicsElement.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Use Graphics Elements During Filler</MudText>
|
||||
</div>
|
||||
<MudCheckBox T="bool"
|
||||
Disabled="@(_deco.GraphicsElementsMode != DecoMode.Override)"
|
||||
@bind-Value="_deco.UseGraphicsElementsDuringFiller"
|
||||
Dense="true"/>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudText Typo="Typo.body2" Class="mb-6">Break content can be used to play specific items (using playlists), and to pad blocks to their full configured duration.</MudText>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Break Content Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_deco.BreakContentMode" For="@(() => _deco.BreakContentMode)">
|
||||
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Override">Override</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_deco.BreakContentMode is DecoMode.Override)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddBreakContent())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Break Content
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content Items</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable T="DecoBreakContentEditViewModel" Class="mt-6" Hover="true" Items="_deco.BreakContent" Dense="true" SelectedItemChanged="@(vm => SelectedBreakContentChanged(vm))" RowClassFunc="@SelectedRowClassFunc">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 60px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Placement</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Type">@context.CollectionType.ToString()</MudTd>
|
||||
<MudTd DataLabel="Name">@context.CollectionName</MudTd>
|
||||
<MudTd DataLabel="Placement">
|
||||
@(context.Placement switch {
|
||||
DecoBreakPlacement.BlockStart => "Block Start",
|
||||
DecoBreakPlacement.BlockFinish => "Block Finish",
|
||||
DecoBreakPlacement.BetweenBlockItems => "Between Block Items",
|
||||
DecoBreakPlacement.ChapterMarkers => "At Chapter Markers",
|
||||
_ => string.Empty
|
||||
})
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div class="d-flex">
|
||||
@* <MudIconButton Icon="@Icons.Material.Filled.ContentCopy" *@
|
||||
@* OnClick="@(_ => CopyItem(context))"> *@
|
||||
@* </MudIconButton> *@
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => RemoveBreakContent(context))">
|
||||
</MudIconButton>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_selectedBreakContent is not null)
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content 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>Placement</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_selectedBreakContent.Placement" For="@(() => _selectedBreakContent.Placement)" Disabled="true">
|
||||
<MudSelectItem Value="DecoBreakPlacement.BlockStart">Block Start</MudSelectItem>
|
||||
<MudSelectItem Value="DecoBreakPlacement.BetweenBlockItems">Between Block Items</MudSelectItem>
|
||||
<MudSelectItem Value="DecoBreakPlacement.ChapterMarkers">At Chapter Markers</MudSelectItem>
|
||||
<MudSelectItem Value="DecoBreakPlacement.BlockFinish">Block Finish</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<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="_selectedBreakContent.CollectionType" For="@(() => _selectedBreakContent.CollectionType)" Disabled="true">
|
||||
@* <MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem> *@
|
||||
@* <MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem> *@
|
||||
@* <MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem> *@
|
||||
|
||||
@* <MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem> *@
|
||||
@* <MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem> *@
|
||||
|
||||
@* <MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem> *@
|
||||
<MudSelectItem Value="CollectionType.Playlist">Playlist</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_selectedBreakContent.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="_selectedBreakContent.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 (_selectedBreakContent.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="_selectedBreakContent.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 (_selectedBreakContent.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="_selectedBreakContent.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 (_selectedBreakContent.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="_selectedBreakContent.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 (_selectedBreakContent.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="_selectedBreakContent.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>
|
||||
}
|
||||
|
||||
@if (_selectedBreakContent.CollectionType == CollectionType.Playlist)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Playlist Group</MudText>
|
||||
</div>
|
||||
<MudSelect T="PlaylistGroupViewModel"
|
||||
Value="@_selectedPlaylistGroup"
|
||||
ValueChanged="@(vm => UpdatePlaylistGroupItems(vm))">
|
||||
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
|
||||
{
|
||||
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Playlist</MudText>
|
||||
</div>
|
||||
<MudSelect T="PlaylistViewModel" @bind-value="_selectedBreakContent.Playlist">
|
||||
@foreach (PlaylistViewModel playlist in _playlists)
|
||||
{
|
||||
<MudSelectItem Value="@playlist">@playlist.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
}
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Default Filler</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudText Typo="Typo.body2" Class="mb-6">After all blocks have been scheduled, a second pass will be made to fill unscheduled time using random items from this collection.</MudText>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Default Filler Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_deco.DefaultFillerMode" For="@(() => _deco.DefaultFillerMode)">
|
||||
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Override">Override</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Default Filler Collection Type</MudText>
|
||||
</div>
|
||||
<MudSelect Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
|
||||
@bind-Value="_deco.DefaultFillerCollectionType"
|
||||
For="@(() => _deco.DefaultFillerCollectionType)">
|
||||
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_deco.DefaultFillerCollectionType == 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"
|
||||
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DefaultFillerCollection"
|
||||
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 (_deco.DefaultFillerCollectionType == 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"
|
||||
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DefaultFillerMultiCollection"
|
||||
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 (_deco.DefaultFillerCollectionType == 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"
|
||||
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DefaultFillerSmartCollection"
|
||||
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 (_deco.DefaultFillerCollectionType == 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"
|
||||
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DefaultFillerMediaItem"
|
||||
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 (_deco.DefaultFillerCollectionType == 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"
|
||||
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
|
||||
@bind-Value="_deco.DefaultFillerMediaItem"
|
||||
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>
|
||||
}
|
||||
@if (_deco.DefaultFillerCollectionType == CollectionType.Artist)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Artist</MudText>
|
||||
</div>
|
||||
<MudAutocomplete T="NamedMediaItemViewModel"
|
||||
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
|
||||
@bind-Value="_deco.DefaultFillerMediaItem"
|
||||
SearchFunc="@SearchArtists"
|
||||
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>Trim To Fit</MudText>
|
||||
</div>
|
||||
<MudCheckBox T="bool"
|
||||
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
|
||||
@bind-Value="_deco.DefaultFillerTrimToFit"
|
||||
Color="Color.Primary"
|
||||
Dense="true"/>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Dead Air Fallback</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudText Typo="Typo.body2">When no playout item is found for the current time, *one* item will be randomly selected from this collection and looped and trimmed to exactly fit until the start of the next playout item.</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-6 mt-3">This replaces the "Channel is Offline" image that would otherwise display.</MudText>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Dead Air Fallback Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_deco.DeadAirFallbackMode" For="@(() => _deco.DeadAirFallbackMode)">
|
||||
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
|
||||
<MudSelectItem Value="DecoMode.Override">Override</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Dead Air Fallback Collection Type</MudText>
|
||||
</div>
|
||||
<MudSelect Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
|
||||
@bind-Value="_deco.DeadAirFallbackCollectionType"
|
||||
For="@(() => _deco.DeadAirFallbackCollectionType)">
|
||||
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_deco.DeadAirFallbackCollectionType == 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"
|
||||
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DeadAirFallbackCollection"
|
||||
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 (_deco.DeadAirFallbackCollectionType == 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"
|
||||
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DeadAirFallbackMultiCollection"
|
||||
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 (_deco.DeadAirFallbackCollectionType == 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"
|
||||
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DeadAirFallbackSmartCollection"
|
||||
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 (_deco.DeadAirFallbackCollectionType == 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"
|
||||
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DeadAirFallbackMediaItem"
|
||||
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 (_deco.DeadAirFallbackCollectionType == 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"
|
||||
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DeadAirFallbackMediaItem"
|
||||
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>
|
||||
}
|
||||
@if (_deco.DeadAirFallbackCollectionType == CollectionType.Artist)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Artist</MudText>
|
||||
</div>
|
||||
<MudAutocomplete T="NamedMediaItemViewModel"
|
||||
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
|
||||
@bind-value="_deco.DeadAirFallbackMediaItem"
|
||||
SearchFunc="@SearchArtists"
|
||||
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>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private DecoEditViewModel _deco = new();
|
||||
|
||||
private List<WatermarkViewModel> _watermarks = [];
|
||||
private List<GraphicsElementViewModel> _graphicsElements = [];
|
||||
private readonly List<PlaylistGroupViewModel> _playlistGroups = [];
|
||||
private readonly List<PlaylistViewModel> _playlists = [];
|
||||
|
||||
private DecoBreakContentEditViewModel _selectedBreakContent;
|
||||
private PlaylistGroupViewModel _selectedPlaylistGroup;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_watermarks = await Mediator.Send(new GetAllWatermarks(), token);
|
||||
_graphicsElements = await Mediator.Send(new GetAllGraphicsElements(), token);
|
||||
_playlistGroups.AddRange(await Mediator.Send(new GetAllPlaylistGroups(), token));
|
||||
|
||||
Option<DecoViewModel> maybeDeco = await Mediator.Send(new GetDecoById(Id), token);
|
||||
if (maybeDeco.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("decos");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (DecoViewModel deco in maybeDeco)
|
||||
{
|
||||
_deco = new DecoEditViewModel
|
||||
{
|
||||
Name = deco.Name,
|
||||
DecoGroupId = deco.DecoGroupId,
|
||||
GroupName = deco.DecoGroupName,
|
||||
WatermarkMode = deco.WatermarkMode,
|
||||
Watermarks = deco.Watermarks,
|
||||
UseWatermarkDuringFiller = deco.UseWatermarkDuringFiller,
|
||||
|
||||
GraphicsElementsMode = deco.GraphicsElementsMode,
|
||||
GraphicsElements = deco.GraphicsElements,
|
||||
UseGraphicsElementsDuringFiller = deco.UseGraphicsElementsDuringFiller,
|
||||
|
||||
BreakContentMode = deco.BreakContentMode,
|
||||
BreakContent = deco.BreakContent.Map(ProjectToEditViewModel).ToList(),
|
||||
|
||||
DefaultFillerMode = deco.DefaultFillerMode,
|
||||
DefaultFillerCollectionType = deco.DefaultFillerCollectionType,
|
||||
DefaultFillerCollection = deco.DefaultFillerCollection,
|
||||
DefaultFillerMediaItem = deco.DefaultFillerMediaItem,
|
||||
DefaultFillerMultiCollection = deco.DefaultFillerMultiCollection,
|
||||
DefaultFillerSmartCollection = deco.DefaultFillerSmartCollection,
|
||||
DefaultFillerTrimToFit = deco.DefaultFillerTrimToFit,
|
||||
|
||||
DeadAirFallbackMode = deco.DeadAirFallbackMode,
|
||||
DeadAirFallbackCollectionType = deco.DeadAirFallbackCollectionType,
|
||||
DeadAirFallbackCollection = deco.DeadAirFallbackCollection,
|
||||
DeadAirFallbackMediaItem = deco.DeadAirFallbackMediaItem,
|
||||
DeadAirFallbackMultiCollection = deco.DeadAirFallbackMultiCollection,
|
||||
DeadAirFallbackSmartCollection = deco.DeadAirFallbackSmartCollection
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private static DecoBreakContentEditViewModel ProjectToEditViewModel(DecoBreakContentViewModel vm) =>
|
||||
new()
|
||||
{
|
||||
Id = vm.Id,
|
||||
CollectionType = vm.CollectionType,
|
||||
Collection = vm.Collection,
|
||||
MediaItem = vm.MediaItem,
|
||||
MultiCollection = vm.MultiCollection,
|
||||
SmartCollection = vm.SmartCollection,
|
||||
Playlist = vm.Playlist,
|
||||
Placement = vm.Placement
|
||||
};
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
var breakContent = _deco.BreakContent
|
||||
.Map(vm => new UpdateDecoBreakContent(vm.Id, vm.CollectionType, vm.Collection?.Id, vm.MediaItem?.MediaItemId, vm.MultiCollection?.Id, vm.SmartCollection?.Id, vm.Playlist?.Id, vm.Placement))
|
||||
.ToList();
|
||||
|
||||
var request = new UpdateDeco(
|
||||
Id,
|
||||
_deco.DecoGroupId,
|
||||
_deco.Name,
|
||||
_deco.WatermarkMode,
|
||||
_deco.Watermarks.Map(wm => wm.Id).ToList(),
|
||||
_deco.UseWatermarkDuringFiller,
|
||||
_deco.GraphicsElementsMode,
|
||||
_deco.GraphicsElements.Map(ge => ge.Id).ToList(),
|
||||
_deco.UseGraphicsElementsDuringFiller,
|
||||
_deco.BreakContentMode,
|
||||
breakContent,
|
||||
_deco.DefaultFillerMode,
|
||||
_deco.DefaultFillerCollectionType,
|
||||
_deco.DefaultFillerCollection?.Id,
|
||||
_deco.DefaultFillerMediaItem?.MediaItemId,
|
||||
_deco.DefaultFillerMultiCollection?.Id,
|
||||
_deco.DefaultFillerSmartCollection?.Id,
|
||||
_deco.DefaultFillerTrimToFit,
|
||||
_deco.DeadAirFallbackMode,
|
||||
_deco.DeadAirFallbackCollectionType,
|
||||
_deco.DeadAirFallbackCollection?.Id,
|
||||
_deco.DeadAirFallbackMediaItem?.MediaItemId,
|
||||
_deco.DeadAirFallbackMultiCollection?.Id,
|
||||
_deco.DeadAirFallbackSmartCollection?.Id);
|
||||
|
||||
Seq<BaseError> errorMessages = await Mediator
|
||||
.Send(request, _cts.Token)
|
||||
.Map(e => e.LeftToSeq());
|
||||
|
||||
errorMessages.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving deco: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving deco: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("decos"));
|
||||
}
|
||||
|
||||
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 async Task SelectedBreakContentChanged(DecoBreakContentEditViewModel vm)
|
||||
{
|
||||
_selectedBreakContent = vm;
|
||||
|
||||
foreach (int playlistGroupId in Optional(_selectedBreakContent.Playlist?.PlaylistGroupId))
|
||||
{
|
||||
foreach (PlaylistGroupViewModel group in Optional(_playlistGroups.Find(g => g.Id == playlistGroupId)))
|
||||
{
|
||||
_selectedPlaylistGroup = group;
|
||||
await UpdatePlaylistGroupItems(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdatePlaylistGroupItems(PlaylistGroupViewModel playlistGroup)
|
||||
{
|
||||
_selectedPlaylistGroup = playlistGroup;
|
||||
|
||||
_playlists.Clear();
|
||||
_playlists.AddRange(await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroup.Id), _cts.Token));
|
||||
}
|
||||
|
||||
private void AddBreakContent()
|
||||
{
|
||||
var item = new DecoBreakContentEditViewModel
|
||||
{
|
||||
//CollectionType = CollectionType.Collection,
|
||||
//Collection = null,
|
||||
CollectionType = CollectionType.Playlist,
|
||||
Playlist = null,
|
||||
Placement = DecoBreakPlacement.BlockStart
|
||||
};
|
||||
|
||||
_deco.BreakContent.Add(item);
|
||||
}
|
||||
|
||||
private void RemoveBreakContent(DecoBreakContentEditViewModel item)
|
||||
{
|
||||
_deco.BreakContent.Remove(item);
|
||||
|
||||
if (_selectedBreakContent == item)
|
||||
{
|
||||
_selectedBreakContent = null;
|
||||
}
|
||||
}
|
||||
|
||||
private string SelectedRowClassFunc(DecoBreakContentEditViewModel element, int rowNumber) => _selectedBreakContent != null && _selectedBreakContent == element ? "selected" : string.Empty;
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
@page "/deco-templates/{Id:int}"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<DecoTemplateEditor> 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">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-6" OnClick="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
|
||||
Save Deco Template
|
||||
</MudButton>
|
||||
</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">General</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>Deco Template Group Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_decoTemplate.GroupName" Disabled="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco Template Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_decoTemplate.Name" For="@(() => _decoTemplate.Name)"/>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Add Content</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>Deco Group</MudText>
|
||||
</div>
|
||||
<MudSelect T="DecoGroupViewModel" ValueChanged="@(vm => UpdateDecoGroupItems(vm))">
|
||||
@foreach (DecoGroupViewModel decoGroup in _decoGroups)
|
||||
{
|
||||
<MudSelectItem Value="@decoGroup">@decoGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco</MudText>
|
||||
</div>
|
||||
<MudSelect T="DecoViewModel" @bind-value="_selectedDeco">
|
||||
@foreach (DecoViewModel deco in _decos)
|
||||
{
|
||||
<MudSelectItem Value="@deco">@deco.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Start Time On Or After</MudText>
|
||||
</div>
|
||||
<MudSelect T="DateTime" @bind-value="_selectedDecoStart">
|
||||
@foreach (DateTime startTime in _startTimes)
|
||||
{
|
||||
<MudSelectItem Value="@startTime">
|
||||
@startTime.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern)
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddDecoToDecoTemplate())" Disabled="@(_selectedDeco is null)" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Deco To Template
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Remove Content</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>Deco To Remove</MudText>
|
||||
</div>
|
||||
<MudSelect T="DecoTemplateItemEditViewModel" @bind-Value="_decoToRemove">
|
||||
<MudSelectItem Value="@((DecoTemplateItemEditViewModel)null)">(none)</MudSelectItem>
|
||||
@foreach (DecoTemplateItemEditViewModel item in _decoTemplate.Items.OrderBy(i => i.Start))
|
||||
{
|
||||
<MudSelectItem Value="@item">@item.Start.ToShortTimeString() - @item.Text</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => RemoveDecoFromDecoTemplate())" Disabled="@(_decoToRemove is null)" StartIcon="@Icons.Material.Filled.Remove">
|
||||
Remove Deco From Template
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Content</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudCalendar T="CalendarItem"
|
||||
Class="mb-6"
|
||||
Items="@_decoTemplate.Items"
|
||||
ShowMonth="false"
|
||||
ShowWeek="false"
|
||||
ShowPrevNextButtons="false"
|
||||
ShowDatePicker="false"
|
||||
ShowTodayButton="false"
|
||||
DayTimeInterval="CalendarTimeInterval.Minutes10"
|
||||
Use24HourClock="@(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains("H"))"
|
||||
EnableDragItems="true"
|
||||
EnableResizeItems="false"
|
||||
ItemChanged="@(ci => CalendarItemChanged(ci))"/>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly List<DecoGroupViewModel> _decoGroups = [];
|
||||
private readonly List<DecoViewModel> _decos = [];
|
||||
private readonly List<DateTime> _startTimes = [];
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private DecoTemplateItemsEditViewModel _decoTemplate = new();
|
||||
private DecoTemplateItemEditViewModel _decoToRemove;
|
||||
private DecoGroupViewModel _selectedDecoGroup;
|
||||
private DecoViewModel _selectedDeco;
|
||||
private DateTime _selectedDecoStart;
|
||||
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
|
||||
{
|
||||
await LoadDecoTemplateItems(token);
|
||||
|
||||
DateTime start = DateTime.Today;
|
||||
_selectedDecoStart = start;
|
||||
while (start.Date == DateTime.Today.Date)
|
||||
{
|
||||
_startTimes.Add(start);
|
||||
start = start.AddMinutes(5);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDecoTemplateItems(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<DecoTemplateViewModel> maybeDecoTemplate = await Mediator.Send(new GetDecoTemplateById(Id), cancellationToken);
|
||||
if (maybeDecoTemplate.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("deco-templates");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (DecoTemplateViewModel template in maybeDecoTemplate)
|
||||
{
|
||||
_decoTemplate = new DecoTemplateItemsEditViewModel
|
||||
{
|
||||
GroupId = template.DecoTemplateGroupId,
|
||||
GroupName = template.GroupName,
|
||||
Name = template.Name
|
||||
};
|
||||
}
|
||||
|
||||
Option<IEnumerable<DecoTemplateItemViewModel>> maybeResults = await Mediator.Send(new GetDecoTemplateItems(Id), cancellationToken);
|
||||
foreach (IEnumerable<DecoTemplateItemViewModel> items in maybeResults)
|
||||
{
|
||||
_decoTemplate.Items.AddRange(items.Map(ProjectToEditViewModel));
|
||||
}
|
||||
|
||||
_decoGroups.AddRange(await Mediator.Send(new GetAllDecoGroups(), cancellationToken));
|
||||
}
|
||||
|
||||
private static DecoTemplateItemEditViewModel ProjectToEditViewModel(DecoTemplateItemViewModel item) =>
|
||||
new()
|
||||
{
|
||||
DecoId = item.DecoId,
|
||||
DecoName = item.DecoName,
|
||||
Start = item.StartTime,
|
||||
End = item.EndTime
|
||||
};
|
||||
|
||||
private async Task UpdateDecoGroupItems(DecoGroupViewModel decoGroup)
|
||||
{
|
||||
_selectedDecoGroup = decoGroup;
|
||||
|
||||
_decos.Clear();
|
||||
_decos.AddRange(await Mediator.Send(new GetDecosByDecoGroupId(_selectedDecoGroup.Id), _cts.Token));
|
||||
}
|
||||
|
||||
private void AddDecoToDecoTemplate()
|
||||
{
|
||||
// find first time where this deco will fit
|
||||
DateTime maybeStart = _selectedDecoStart;
|
||||
while (maybeStart.Date == DateTime.Today)
|
||||
{
|
||||
DateTime maybeEnd = maybeStart.AddHours(_durationHours).AddMinutes(_durationMinutes);
|
||||
if (!IntersectsOthers(null, maybeStart, maybeEnd))
|
||||
{
|
||||
var item = new DecoTemplateItemEditViewModel
|
||||
{
|
||||
DecoId = _selectedDeco.Id,
|
||||
DecoName = _selectedDeco.Name,
|
||||
Start = maybeStart,
|
||||
End = maybeEnd,
|
||||
LastStart = maybeStart,
|
||||
LastEnd = maybeEnd
|
||||
};
|
||||
|
||||
_decoTemplate.Items.Add(item);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
maybeStart = maybeStart.AddMinutes(5);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveDecoFromDecoTemplate()
|
||||
{
|
||||
if (_decoToRemove is not null)
|
||||
{
|
||||
_decoTemplate.Items.Remove(_decoToRemove);
|
||||
_decoToRemove = null;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void CalendarItemChanged(CalendarItem calendarItem)
|
||||
{
|
||||
// don't allow any overlap
|
||||
if (calendarItem is DecoTemplateItemEditViewModel item)
|
||||
{
|
||||
bool intersects = item.End.HasValue && IntersectsOthers(item, item.Start, item.End.Value);
|
||||
bool crossesMidnight = item.End.HasValue && item.End.Value.TimeOfDay > TimeSpan.Zero && item.End.Value.TimeOfDay < item.Start.TimeOfDay;
|
||||
if (intersects || crossesMidnight)
|
||||
{
|
||||
// roll back
|
||||
item.Start = item.LastStart;
|
||||
item.End = item.LastEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
// commit
|
||||
item.LastStart = item.Start;
|
||||
item.LastEnd = item.End;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IntersectsOthers(DecoTemplateItemEditViewModel item, DateTime start, DateTime end)
|
||||
{
|
||||
var willFit = true;
|
||||
|
||||
foreach (DecoTemplateItemEditViewModel existing in _decoTemplate.Items)
|
||||
{
|
||||
if (existing == item)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (start < existing.End && existing.Start < end)
|
||||
{
|
||||
willFit = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return !willFit;
|
||||
}
|
||||
|
||||
// private void RemoveDecoTemplateItem(DecoTemplateItemEditViewModel item)
|
||||
// {
|
||||
// _selectedItem = null;
|
||||
// _decoTemplate.Items.Remove(item);
|
||||
// }
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
if (_decoTemplate.Items.Any(i => i.End is null))
|
||||
{
|
||||
Snackbar.Add("Template item cannot end after midnight", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var items = _decoTemplate.Items.Map(item => new ReplaceDecoTemplateItem(item.DecoId, item.Start.TimeOfDay, item.End!.Value.TimeOfDay)).ToList();
|
||||
|
||||
Seq<BaseError> errorMessages = await Mediator.Send(new ReplaceDecoTemplateItems(Id, _decoTemplate.GroupId, _decoTemplate.Name, items), _cts.Token)
|
||||
.Map(e => e.LeftToSeq());
|
||||
|
||||
errorMessages.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving template: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving template: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("deco-templates"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
@page "/deco-templates"
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@using ErsatzTV.Application.Tree
|
||||
@implements IDisposable
|
||||
@inject ILogger<DecoTemplates> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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">Deco Template Groups</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>Deco Template Group Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_decoTemplateGroupName" For="@(() => _decoTemplateGroupName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddDecoTemplateGroup())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Deco Template Group
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Deco Templates</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>Deco Template Group</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_selectedDecoTemplateGroup">
|
||||
@foreach (DecoTemplateGroupViewModel decoTemplateGroup in _decoTemplateGroups)
|
||||
{
|
||||
<MudSelectItem Value="@decoTemplateGroup">@decoTemplateGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco Template Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_decoTemplateName" For="@(() => _decoTemplateName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddDecoTemplate())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Deco Template
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudTreeView T="DecoTemplateTreeItemViewModel" Items="@_treeItems" Hover="true" Style="width: 100%">
|
||||
<ItemTemplate Context="item">
|
||||
<MudTreeViewItem T="DecoTemplateTreeItemViewModel" Items="@item.Value!.TreeItems" Icon="@item.Value.Icon" Value="@item.Value">
|
||||
<BodyContent>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
||||
<MudGrid Justify="Justify.FlexStart">
|
||||
<MudItem xs="5">
|
||||
<MudText>@item.Value.Text</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
<div style="justify-self: end;">
|
||||
@foreach (int decoTemplateId in Optional(item.Value.DecoTemplateId))
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Href="@($"deco-templates/{decoTemplateId}")"/>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="@(_ => DeleteItem(item.Value))"/>
|
||||
</div>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</MudTreeViewItem>
|
||||
</ItemTemplate>
|
||||
</MudTreeView>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly List<TreeItemData<DecoTemplateTreeItemViewModel>> _treeItems = [];
|
||||
private List<DecoTemplateGroupViewModel> _decoTemplateGroups = [];
|
||||
private DecoTemplateGroupViewModel _selectedDecoTemplateGroup;
|
||||
private string _decoTemplateGroupName;
|
||||
private string _decoTemplateName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_decoTemplateGroups = await Mediator.Send(new GetAllDecoTemplateGroups(), token);
|
||||
|
||||
_treeItems.Clear();
|
||||
TreeViewModel tree = await Mediator.Send(new GetDecoTemplateTree(), token);
|
||||
foreach (TreeGroupViewModel group in tree.Groups)
|
||||
{
|
||||
_treeItems.Add(new TreeItemData<DecoTemplateTreeItemViewModel> { Value = new DecoTemplateTreeItemViewModel(group) });
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddDecoTemplateGroup()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_decoTemplateGroupName))
|
||||
{
|
||||
Either<BaseError, DecoTemplateGroupViewModel> result = await Mediator.Send(new CreateDecoTemplateGroup(_decoTemplateGroupName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding decoTemplate group: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (DecoTemplateGroupViewModel decoTemplateGroup in result.RightToSeq())
|
||||
{
|
||||
_treeItems.Add(new TreeItemData<DecoTemplateTreeItemViewModel> { Value = new DecoTemplateTreeItemViewModel(decoTemplateGroup) });
|
||||
_treeItems.Sort((x, y) => string.Compare(x.Value?.Text, y.Value?.Text, StringComparison.CurrentCulture));
|
||||
_decoTemplateGroupName = null;
|
||||
|
||||
_decoTemplateGroups = await Mediator.Send(new GetAllDecoTemplateGroups(), _cts.Token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddDecoTemplate()
|
||||
{
|
||||
if (_selectedDecoTemplateGroup is not null && !string.IsNullOrWhiteSpace(_decoTemplateName))
|
||||
{
|
||||
Either<BaseError, DecoTemplateViewModel> result = await Mediator.Send(new CreateDecoTemplate(_selectedDecoTemplateGroup.Id, _decoTemplateName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding decoTemplate: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (DecoTemplateViewModel decoTemplate in result.RightToSeq())
|
||||
{
|
||||
foreach (DecoTemplateTreeItemViewModel item in _treeItems.Map(i => i.Value).Where(item => item.DecoTemplateGroupId == _selectedDecoTemplateGroup.Id))
|
||||
{
|
||||
item.TreeItems.Add(new TreeItemData<DecoTemplateTreeItemViewModel> { Value = new DecoTemplateTreeItemViewModel(decoTemplate) });
|
||||
item.TreeItems.Sort((x, y) => string.Compare(x.Value?.Text, y.Value?.Text, StringComparison.CurrentCulture));
|
||||
}
|
||||
|
||||
_decoTemplateName = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteItem(DecoTemplateTreeItemViewModel treeItem)
|
||||
{
|
||||
foreach (int decoTemplateGroupId in Optional(treeItem.DecoTemplateGroupId))
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "decoTemplate group" }, { "EntityName", treeItem.Text } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete DecoTemplate Group", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
await Mediator.Send(new DeleteDecoTemplateGroup(decoTemplateGroupId), _cts.Token);
|
||||
_treeItems.RemoveAll(i => i.Value?.DecoTemplateGroupId == decoTemplateGroupId);
|
||||
if (_selectedDecoTemplateGroup?.Id == decoTemplateGroupId)
|
||||
{
|
||||
_selectedDecoTemplateGroup = null;
|
||||
}
|
||||
|
||||
_decoTemplateGroups = await Mediator.Send(new GetAllDecoTemplateGroups(), _cts.Token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int decoTemplateId in Optional(treeItem.DecoTemplateId))
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "decoTemplate" }, { "EntityName", treeItem.Text } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete DecoTemplate", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
await Mediator.Send(new DeleteDecoTemplate(decoTemplateId), _cts.Token);
|
||||
foreach (DecoTemplateTreeItemViewModel parent in _treeItems.Map(i => i.Value))
|
||||
{
|
||||
parent.TreeItems.RemoveAll(i => i.Value == treeItem);
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
@page "/decos"
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@using ErsatzTV.Application.Tree
|
||||
@implements IDisposable
|
||||
@inject ILogger<Decos> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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">Deco Groups</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>Deco Group Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_decoGroupName" For="@(() => _decoGroupName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddDecoGroup())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Deco Group
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Decos</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>Deco Group</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_selectedDecoGroup">
|
||||
@foreach (DecoGroupViewModel decoGroup in _decoGroups)
|
||||
{
|
||||
<MudSelectItem Value="@decoGroup">@decoGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_decoName" For="@(() => _decoName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddDeco())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Deco
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudTreeView T="DecoTreeItemViewModel" Items="@_treeItems" Hover="true" Style="width: 100%">
|
||||
<ItemTemplate Context="item">
|
||||
<MudTreeViewItem T="DecoTreeItemViewModel" Items="@item.Value!.TreeItems" Icon="@item.Value.Icon" Value="@item.Value">
|
||||
<BodyContent>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
||||
<MudGrid Justify="Justify.FlexStart">
|
||||
<MudItem xs="5">
|
||||
<MudText>@item.Value.Text</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
<div style="justify-self: end;">
|
||||
@foreach (int decoId in Optional(item.Value.DecoId))
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Href="@($"decos/{decoId}")"/>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="@(_ => DeleteItem(item.Value))"/>
|
||||
</div>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</MudTreeViewItem>
|
||||
</ItemTemplate>
|
||||
</MudTreeView>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly List<TreeItemData<DecoTreeItemViewModel>> _treeItems = [];
|
||||
private List<DecoGroupViewModel> _decoGroups = [];
|
||||
private DecoGroupViewModel _selectedDecoGroup;
|
||||
private string _decoGroupName;
|
||||
private string _decoName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_decoGroups = await Mediator.Send(new GetAllDecoGroups(), token);
|
||||
|
||||
_treeItems.Clear();
|
||||
TreeViewModel tree = await Mediator.Send(new GetDecoTree(), token);
|
||||
foreach (TreeGroupViewModel group in tree.Groups)
|
||||
{
|
||||
_treeItems.Add(new TreeItemData<DecoTreeItemViewModel> { Value = new DecoTreeItemViewModel(group) });
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddDecoGroup()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_decoGroupName))
|
||||
{
|
||||
Either<BaseError, DecoGroupViewModel> result = await Mediator.Send(new CreateDecoGroup(_decoGroupName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding deco group: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (DecoGroupViewModel decoGroup in result.RightToSeq())
|
||||
{
|
||||
_treeItems.Add(new TreeItemData<DecoTreeItemViewModel> { Value = new DecoTreeItemViewModel(decoGroup) });
|
||||
_treeItems.Sort((x, y) => string.Compare(x.Value?.Text, y.Value?.Text, StringComparison.CurrentCulture));
|
||||
_decoGroupName = null;
|
||||
|
||||
_decoGroups = await Mediator.Send(new GetAllDecoGroups(), _cts.Token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddDeco()
|
||||
{
|
||||
if (_selectedDecoGroup is not null && !string.IsNullOrWhiteSpace(_decoName))
|
||||
{
|
||||
Either<BaseError, DecoViewModel> result = await Mediator.Send(new CreateDeco(_selectedDecoGroup.Id, _decoName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding deco: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (DecoViewModel deco in result.RightToSeq())
|
||||
{
|
||||
foreach (DecoTreeItemViewModel item in _treeItems.Map(i => i.Value).Where(item => item.DecoGroupId == _selectedDecoGroup.Id))
|
||||
{
|
||||
item.TreeItems.Add(new TreeItemData<DecoTreeItemViewModel> { Value = new DecoTreeItemViewModel(deco) });
|
||||
item.TreeItems.Sort((x, y) => string.Compare(x.Value?.Text, y.Value?.Text, StringComparison.CurrentCulture));
|
||||
}
|
||||
|
||||
_decoName = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteItem(DecoTreeItemViewModel treeItem)
|
||||
{
|
||||
foreach (int decoGroupId in Optional(treeItem.DecoGroupId))
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "deco group" }, { "EntityName", treeItem.Text } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Deco Group", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
await Mediator.Send(new DeleteDecoGroup(decoGroupId), _cts.Token);
|
||||
_treeItems.RemoveAll(i => i.Value?.DecoGroupId == decoGroupId);
|
||||
if (_selectedDecoGroup?.Id == decoGroupId)
|
||||
{
|
||||
_selectedDecoGroup = null;
|
||||
}
|
||||
|
||||
_decoGroups = await Mediator.Send(new GetAllDecoGroups(), _cts.Token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int decoId in Optional(treeItem.DecoId))
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "deco" }, { "EntityName", treeItem.Text } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Deco", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
await Mediator.Send(new DeleteDeco(decoId), _cts.Token);
|
||||
foreach (DecoTreeItemViewModel parent in _treeItems.Map(i => i.Value))
|
||||
{
|
||||
parent.TreeItems.RemoveAll(i => i.Value == treeItem);
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
@page "/media/sources/emby/{Id:int}/libraries"
|
||||
@using ErsatzTV.Application.Emby
|
||||
@using ErsatzTV.Application.MediaSources
|
||||
@inject IMediator Mediator
|
||||
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
|
||||
|
||||
<RemoteMediaSourceLibrariesEditor
|
||||
Id="@Id"
|
||||
Name="Emby"
|
||||
GetUpdateLibraryRequest="GetUpdateLibraryRequest"
|
||||
GetLibrariesBySourceId="GetLibrariesBySourceId"
|
||||
GetMediaSourceById="GetMediaSourceById"
|
||||
SynchronizeLibraryByIdIfNeeded="SynchronizeLibraryByIdIfNeeded"/>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private IRequest<Either<BaseError, Unit>> GetUpdateLibraryRequest(List<RemoteMediaSourceLibraryEditViewModel> libraries) =>
|
||||
new UpdateEmbyLibraryPreferences(
|
||||
libraries.Map(l => new EmbyLibraryPreference(l.Id, l.ShouldSyncItems)).ToList());
|
||||
|
||||
private Task<List<RemoteMediaSourceLibraryEditViewModel>> GetLibrariesBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
Mediator.Send(new GetEmbyLibrariesBySourceId(Id), cancellationToken)
|
||||
.Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList());
|
||||
|
||||
private Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
Mediator.Send(new GetEmbyMediaSourceById(Id), cancellationToken)
|
||||
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
|
||||
|
||||
private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(EmbyLibraryViewModel library) => new()
|
||||
{
|
||||
Id = library.Id,
|
||||
Name = library.Name,
|
||||
MediaKind = library.MediaKind,
|
||||
ShouldSyncItems = library.ShouldSyncItems
|
||||
};
|
||||
|
||||
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(parameters.MediaSourceId), cancellationToken);
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraryByIdIfNeeded(parameters.LibraryId), cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
@page "/media/sources/emby/edit"
|
||||
@using ErsatzTV.Application.Emby
|
||||
@using ErsatzTV.Core.Emby
|
||||
@inject IMediator Mediator
|
||||
|
||||
<RemoteMediaSourceEditor
|
||||
Name="Emby"
|
||||
LoadSecrets="LoadSecrets"
|
||||
SaveSecrets="SaveSecrets"/>
|
||||
|
||||
@code {
|
||||
private async Task<Unit> LoadSecrets(RemoteMediaSourceEditViewModel viewModel, CancellationToken cancellationToken)
|
||||
{
|
||||
EmbySecrets secrets = await Mediator.Send(new GetEmbySecrets(), cancellationToken);
|
||||
viewModel.Address = secrets.Address;
|
||||
viewModel.ApiKey = secrets.ApiKey;
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> SaveSecrets(RemoteMediaSourceEditViewModel viewModel, CancellationToken cancellationToken)
|
||||
{
|
||||
var secrets = new EmbySecrets { Address = viewModel.Address, ApiKey = viewModel.ApiKey };
|
||||
return await Mediator.Send(new SaveEmbySecrets(secrets), cancellationToken);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
@page "/media/sources/emby"
|
||||
@using ErsatzTV.Application.Emby
|
||||
@using ErsatzTV.Core.Emby
|
||||
@using ErsatzTV.Core.Interfaces.Emby
|
||||
@inject IEmbySecretStore EmbySecretStore
|
||||
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
|
||||
|
||||
<RemoteMediaSources
|
||||
TViewModel="EmbyMediaSourceViewModel"
|
||||
TSecrets="EmbySecrets"
|
||||
TMediaSource="EmbyMediaSource"
|
||||
Name="Emby"
|
||||
GetAllMediaSourcesCommand="@(new GetAllEmbyMediaSources())"
|
||||
DisconnectCommand="@(new DisconnectEmby())"
|
||||
RefreshLibrariesCommand="@RefreshLibraries"
|
||||
SecretStore="@EmbySecretStore"
|
||||
CanEdit="true"/>
|
||||
|
||||
@code {
|
||||
private async Task RefreshLibraries(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSourceId), cancellationToken);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
@page "/media/sources/emby/{Id:int}/paths"
|
||||
@using ErsatzTV.Application.Emby
|
||||
@using ErsatzTV.Application.MediaSources
|
||||
@inject IMediator Mediator
|
||||
|
||||
<RemoteMediaSourcePathReplacementsEditor
|
||||
Id="@Id"
|
||||
Name="Emby"
|
||||
GetMediaSourceById="GetMediaSourceById"
|
||||
GetUpdatePathReplacementsRequest="GetUpdatePathReplacementsRequest"
|
||||
GetPathReplacementsBySourceId="GetPathReplacementsBySourceId"/>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int id, CancellationToken cancellationToken) =>
|
||||
Mediator.Send(new GetEmbyMediaSourceById(Id), cancellationToken)
|
||||
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
|
||||
|
||||
private Task<List<RemoteMediaSourcePathReplacementEditViewModel>> GetPathReplacementsBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
Mediator.Send(new GetEmbyPathReplacementsBySourceId(Id), cancellationToken)
|
||||
.Map(list => list.Map(ProjectToEditViewModel).ToList());
|
||||
|
||||
private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(EmbyPathReplacementViewModel item) =>
|
||||
new() { Id = item.Id, RemotePath = item.EmbyPath, LocalPath = item.LocalPath };
|
||||
|
||||
private IRequest<Either<BaseError, Unit>> GetUpdatePathReplacementsRequest(List<RemoteMediaSourcePathReplacementEditViewModel> pathReplacements)
|
||||
{
|
||||
var items = pathReplacements
|
||||
.Map(item => new EmbyPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
|
||||
.ToList();
|
||||
|
||||
return new UpdateEmbyPathReplacements(Id, items);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
@page "/media/tv/episodes"
|
||||
@page "/media/tv/episodes/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits MultiSelectBase<EpisodeList>
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="TelevisionEpisodeCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href="@($"media/tv/seasons/{context.SeasonId}#episode-{context.EpisodeId}")"
|
||||
Subtitle="@($"{context.ShowTitle} - S{context.Season} E{context.Episode}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/tv/episodes"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private TelevisionEpisodeCardResultsViewModel _data = new(0, new List<TelevisionEpisodeCardViewModel>(), null);
|
||||
private string _query;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:episode" : $"type:episode AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexEpisodes(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/tv/episodes/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/tv/episodes/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
|
||||
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 AddEpisodeToCollection(collection.Id, episode.EpisodeId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
@page "/ffmpeg"
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm 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-6" StartIcon="@Icons.Material.Filled.Add" Href="ffmpeg/add">
|
||||
Add Profile
|
||||
</MudButton>
|
||||
</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">FFmpeg Profiles</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true" Items="_ffmpegProfiles">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 180px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Resolution</MudTh>
|
||||
<MudTh>Video Format</MudTh>
|
||||
<MudTh>Audio Format</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Resolution">@context.Resolution.Name</MudTd>
|
||||
<MudTd DataLabel="Video Format">
|
||||
@if (context.HardwareAcceleration != HardwareAccelerationKind.None)
|
||||
{
|
||||
@($"{context.VideoFormat.ToString().ToLowerInvariant()} / {context.HardwareAcceleration.ToString().ToLowerInvariant()}")
|
||||
}
|
||||
else
|
||||
{
|
||||
@context.VideoFormat.ToString().ToLowerInvariant()
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Audio Format">@context.AudioFormat.ToString().ToLowerInvariant()</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Profile">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"ffmpeg/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Copy Profile">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
OnClick="@(_ => CopyProfileAsync(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Profile">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteProfileAsync(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await LoadFFmpegProfilesAsync(token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadFFmpegProfilesAsync(CancellationToken cancellationToken) =>
|
||||
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), cancellationToken);
|
||||
|
||||
private async Task DeleteProfileAsync(FFmpegProfileViewModel ffmpegProfile)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "ffmpeg profile" }, { "EntityName", ffmpegProfile.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete FFmpeg Profile", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
Either<BaseError, Unit> deleteResult = await Mediator.Send(new DeleteFFmpegProfile(ffmpegProfile.Id), _cts.Token);
|
||||
foreach (BaseError error in deleteResult.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
}
|
||||
|
||||
if (deleteResult.IsRight)
|
||||
{
|
||||
await LoadFFmpegProfilesAsync(_cts.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyProfileAsync(FFmpegProfileViewModel ffmpegProfile)
|
||||
{
|
||||
var parameters = new DialogParameters { { "FFmpegProfileId", ffmpegProfile.Id } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<CopyFFmpegProfileDialog>("Copy FFmpeg Profile", parameters, options);
|
||||
DialogResult dialogResult = await dialog.Result;
|
||||
if (dialogResult is { Canceled: false, Data: FFmpegProfileViewModel data })
|
||||
{
|
||||
NavigationManager.NavigateTo($"ffmpeg/{data.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
@page "/ffmpeg/{Id:int}"
|
||||
@page "/ffmpeg/add"
|
||||
@using System.Runtime.InteropServices
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.Resolutions
|
||||
@using ErsatzTV.Core.FFmpeg
|
||||
@using ErsatzTV.FFmpeg.Format
|
||||
@using ErsatzTV.Validators
|
||||
@using FluentValidation.Results
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<FFmpegEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IMemoryCache MemoryCache
|
||||
@inject PersistentComponentState ApplicationState
|
||||
|
||||
<MudForm Model="@_model" @ref="@_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" 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-6" OnClick="@HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Profile" : "Add Profile")</MudButton>
|
||||
</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">General</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Immediate="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Thread Count</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="@_model.ThreadCount" For="@(() => _model.ThreadCount)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Normalize Audio</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Normalize Video</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Preferred Resolution</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
|
||||
@foreach (ResolutionViewModel resolution in _resolutions)
|
||||
{
|
||||
<MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_model.NormalizeVideo)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Scaling Behavior</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.ScalingBehavior" For="@(() => _model.ScalingBehavior)">
|
||||
<MudSelectItem Value="@ScalingBehavior.ScaleAndPad">Scale and Pad</MudSelectItem>
|
||||
<MudSelectItem Value="@ScalingBehavior.Stretch">Stretch</MudSelectItem>
|
||||
<MudSelectItem Value="@ScalingBehavior.Crop">Crop</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Pad Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.PadMode"
|
||||
For="@(() => _model.PadMode)"
|
||||
Disabled="_model.HardwareAcceleration is not HardwareAccelerationKind.Vaapi"
|
||||
HelperText="If hardware padding is green, software padding should be used instead">
|
||||
<MudSelectItem Value="@FilterMode.HardwareIfPossible">Hardware If Possible</MudSelectItem>
|
||||
<MudSelectItem Value="@FilterMode.Software">Software</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Video</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
@if (_model.NormalizeVideo)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Format</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.VideoFormat" For="@(() => _model.VideoFormat)">
|
||||
<MudSelectItem Value="@FFmpegProfileVideoFormat.H264">h264</MudSelectItem>
|
||||
<MudSelectItem Value="@FFmpegProfileVideoFormat.Hevc">hevc</MudSelectItem>
|
||||
<MudSelectItem Value="@FFmpegProfileVideoFormat.Mpeg2Video">mpeg-2</MudSelectItem>
|
||||
@if (_model.HardwareAcceleration is (HardwareAccelerationKind.Nvenc or HardwareAccelerationKind.Vaapi or HardwareAccelerationKind.Qsv))
|
||||
{
|
||||
<MudSelectItem Value="@FFmpegProfileVideoFormat.Av1">av1</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Profile</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.VideoProfile"
|
||||
For="@(() => _model.VideoProfile)"
|
||||
Disabled="@(_model.VideoFormat != FFmpegProfileVideoFormat.H264 || !HardwareAccelSupportsH264Profile(_model.HardwareAcceleration))"
|
||||
Clearable="true">
|
||||
<MudSelectItem Value="@VideoProfile.Main">main</MudSelectItem>
|
||||
<MudSelectItem Value="@VideoProfile.High">high</MudSelectItem>
|
||||
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Nvenc)
|
||||
{
|
||||
<MudSelectItem Value="@VideoProfile.High444p">high444p</MudSelectItem>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelectItem Value="@VideoProfile.High10">high10</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Preset</MudText>
|
||||
</div>
|
||||
@{
|
||||
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
|
||||
_model.HardwareAcceleration,
|
||||
_model.VideoFormat,
|
||||
_model.BitDepth);
|
||||
}
|
||||
<MudSelect @bind-Value="_model.VideoPreset"
|
||||
For="@(() => _model.VideoPreset)"
|
||||
Disabled="@(presets.Count == 0)"
|
||||
Clearable="true">
|
||||
@foreach (string preset in presets)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(preset))
|
||||
{
|
||||
<MudSelectItem Value="@preset">@preset</MudSelectItem>
|
||||
}
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Allow B-Frames</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.AllowBFrames" For="@(() => _model.AllowBFrames)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Bit Depth</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.BitDepth" For="@(() => _model.BitDepth)">
|
||||
<MudSelectItem Value="@FFmpegProfileBitDepth.EightBit">8-bit</MudSelectItem>
|
||||
<MudSelectItem Value="@FFmpegProfileBitDepth.TenBit">10-bit</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Bitrate</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Buffer Size</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Hardware Acceleration</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
|
||||
@foreach (HardwareAccelerationKind hwAccel in _hardwareAccelerationKinds)
|
||||
{
|
||||
<MudSelectItem Value="@hwAccel">@hwAccel</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>VAAPI Driver</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.VaapiDriver" For="@(() => _model.VaapiDriver)">
|
||||
@foreach (VaapiDriver driver in Enum.GetValues<VaapiDriver>())
|
||||
{
|
||||
<MudSelectItem Value="@driver">@driver</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>VAAPI Display</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.VaapiDisplay" For="@(() => _model.VaapiDisplay)">
|
||||
@foreach (string display in _vaapiDisplays)
|
||||
{
|
||||
<MudSelectItem Value="@display">@display</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi or HardwareAccelerationKind.Qsv)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>@(_model.HardwareAcceleration == HardwareAccelerationKind.Vaapi ? "VAAPI Device" : "QSV Device")</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.VaapiDevice" For="@(() => _model.VaapiDevice)">
|
||||
@foreach (string device in _vaapiDevices)
|
||||
{
|
||||
<MudSelectItem Value="@device">@device</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_model.HardwareAcceleration == HardwareAccelerationKind.Qsv)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>QSV Extra Hardware Frames</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.QsvExtraHardwareFrames" For="@(() => _model.QsvExtraHardwareFrames)"/>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Tonemap Algorithm</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.TonemapAlgorithm" For="@(() => _model.TonemapAlgorithm)">
|
||||
@foreach (FFmpegProfileTonemapAlgorithm algorithm in Enum.GetValues<FFmpegProfileTonemapAlgorithm>())
|
||||
{
|
||||
<MudSelectItem Value="@algorithm">@algorithm</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Normalize Frame Rate</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.NormalizeFramerate" For="@(() => _model.NormalizeFramerate)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Normalize Colors</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.NormalizeColors" For="@(() => _model.NormalizeColors)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Auto Deinterlace Video</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.DeinterlaceVideo" For="@(() => _model.DeinterlaceVideo)" Dense="true"/>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>Video will be copied as-is, including timestamps, which *will* cause issues with most clients</MudText>
|
||||
}
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Audio</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
@if (_model.NormalizeAudio)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Format</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.AudioFormat" For="@(() => _model.AudioFormat)" HelperText="@(_model.AudioFormat is FFmpegProfileAudioFormat.AacLatm ? "aac (latm) is ONLY intended for DVB-C; anything else should use aac instead which uses ADTS" : string.Empty)"> <MudSelectItem Value="@FFmpegProfileAudioFormat.Aac">aac</MudSelectItem>
|
||||
<MudSelectItem Value="@FFmpegProfileAudioFormat.Ac3">ac3</MudSelectItem>
|
||||
<MudSelectItem Value="@FFmpegProfileAudioFormat.AacLatm">aac (latm)</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Bitrate</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Buffer Size</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Channels</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Sample Rate</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Normalize Loudness</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.NormalizeLoudnessMode" For="@(() => _model.NormalizeLoudnessMode)">
|
||||
<MudSelectItem Value="@NormalizeLoudnessMode.Off">Off</MudSelectItem>
|
||||
<MudSelectItem Value="@NormalizeLoudnessMode.LoudNorm">loudnorm</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_model.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Target Loudness</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.TargetLoudness" For="@(() => _model.TargetLoudness)" Adornment="Adornment.End" AdornmentText="LUFS"/>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>Audio will be copied as-is, including timestamps, which *will* cause issues with most clients</MudText>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private FFmpegProfileEditViewModel _model = new();
|
||||
private readonly FFmpegProfileEditViewModelValidator _validator = new();
|
||||
private MudForm _form;
|
||||
|
||||
private List<ResolutionViewModel> _resolutions = [];
|
||||
private List<HardwareAccelerationKind> _hardwareAccelerationKinds = [];
|
||||
private List<string> _vaapiDisplays = [];
|
||||
private List<string> _vaapiDevices = [];
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
|
||||
|
||||
return base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
if (!ApplicationState.TryTakeFromJson("_resolutions", out List<ResolutionViewModel> restoredResolutions))
|
||||
{
|
||||
_resolutions = await Mediator.Send(new GetAllResolutions(), token);
|
||||
}
|
||||
else
|
||||
{
|
||||
_resolutions = restoredResolutions;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_hardwareAccelerationKinds", out List<HardwareAccelerationKind> restoredHardwareAccelerationKinds))
|
||||
{
|
||||
_hardwareAccelerationKinds = await Mediator.Send(new GetSupportedHardwareAccelerationKinds(), token);
|
||||
}
|
||||
else
|
||||
{
|
||||
_hardwareAccelerationKinds = restoredHardwareAccelerationKinds;
|
||||
}
|
||||
|
||||
if (IsEdit)
|
||||
{
|
||||
if (!ApplicationState.TryTakeFromJson("_model", out FFmpegProfileEditViewModel restoredProfile))
|
||||
{
|
||||
Option<FFmpegProfileViewModel> maybeProfile = await Mediator.Send(new GetFFmpegProfileById(Id), token);
|
||||
foreach (FFmpegProfileViewModel profile in maybeProfile)
|
||||
{
|
||||
_model = new FFmpegProfileEditViewModel(profile);
|
||||
}
|
||||
|
||||
if (maybeProfile.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("404");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_model = restoredProfile;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_model = new FFmpegProfileEditViewModel(await Mediator.Send(new NewFFmpegProfile(), token));
|
||||
}
|
||||
|
||||
if (!_hardwareAccelerationKinds.Contains(_model.HardwareAcceleration))
|
||||
{
|
||||
_model.HardwareAcceleration = HardwareAccelerationKind.None;
|
||||
}
|
||||
|
||||
if (!MemoryCache.TryGetValue("ffmpeg.render_devices", out List<string> vaapiDevices))
|
||||
{
|
||||
vaapiDevices = ["/dev/dri/renderD128"];
|
||||
}
|
||||
|
||||
_vaapiDevices = vaapiDevices.OrderBy(s => s).ToList();
|
||||
|
||||
if (!MemoryCache.TryGetValue("ffmpeg.vaapi_displays", out List<string> vaapiDisplays))
|
||||
{
|
||||
vaapiDisplays = ["drm"];
|
||||
}
|
||||
|
||||
_vaapiDisplays = vaapiDisplays.OrderBy(s => s).ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private bool HardwareAccelSupportsH264Profile(HardwareAccelerationKind accel) =>
|
||||
accel switch
|
||||
{
|
||||
HardwareAccelerationKind.None => true,
|
||||
HardwareAccelerationKind.Nvenc => true,
|
||||
HardwareAccelerationKind.Qsv => true,
|
||||
HardwareAccelerationKind.Vaapi => true,
|
||||
HardwareAccelerationKind.VideoToolbox => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private Task PersistData()
|
||||
{
|
||||
ApplicationState.PersistAsJson("_model", _model);
|
||||
ApplicationState.PersistAsJson("_resolutions", _resolutions);
|
||||
ApplicationState.PersistAsJson("_hardwareAccelerationKinds", _hardwareAccelerationKinds);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool IsEdit => Id != 0;
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (!_form.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
ValidationResult result = await _validator.ValidateAsync(_model, cts.Token);
|
||||
if (result.IsValid)
|
||||
{
|
||||
Seq<BaseError> errorMessage = IsEdit
|
||||
? (await Mediator.Send(_model.ToUpdate(), cts.Token)).LeftToSeq()
|
||||
: (await Mediator.Send(_model.ToCreate(), cts.Token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add("Unexpected error saving ffmpeg profile");
|
||||
Logger.LogError("Unexpected error saving ffmpeg profile: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("ffmpeg"));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
@page "/media/filler/presets/{Id:int}/edit"
|
||||
@page "/media/filler/presets/add"
|
||||
@using ErsatzTV.Application.Artists
|
||||
@using ErsatzTV.Application.Filler
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.Television
|
||||
@using ErsatzTV.Core.Domain.Filler
|
||||
@using ErsatzTV.Validators
|
||||
@using FluentValidation.Results
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<FillerPresetEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudForm Model="@_model" @ref="@_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" 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-6" OnClick="@HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Filler Preset" : "Add Filler Preset")</MudButton>
|
||||
</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">Filler Preset</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Kind</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.FillerKind" For="@(() => _model.FillerKind)" Disabled="@IsEdit">
|
||||
<MudSelectItem Value="@(FillerKind.PreRoll)">Pre-Roll</MudSelectItem>
|
||||
<MudSelectItem Value="@(FillerKind.MidRoll)">Mid-Roll</MudSelectItem>
|
||||
<MudSelectItem Value="@(FillerKind.PostRoll)">Post-Roll</MudSelectItem>
|
||||
<MudSelectItem Value="@(FillerKind.Tail)">Tail</MudSelectItem>
|
||||
<MudSelectItem Value="@(FillerKind.Fallback)">Fallback</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Mode</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.FillerMode" For="@(() => _model.FillerMode)"
|
||||
Disabled="@(_model.FillerKind is FillerKind.Fallback or FillerKind.Tail || _model.CollectionType is CollectionType.Playlist)">
|
||||
<MudSelectItem Value="@(FillerMode.Duration)">Duration</MudSelectItem>
|
||||
<MudSelectItem Value="@(FillerMode.Count)">Count</MudSelectItem>
|
||||
<MudSelectItem Value="@(FillerMode.Pad)">Pad</MudSelectItem>
|
||||
<MudSelectItem Value="@(FillerMode.RandomCount)">Random Count</MudSelectItem>
|
||||
</MudSelect>
|
||||
</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="_model.DurationHours"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentText="hours"
|
||||
Disabled="@(_model.FillerMode != FillerMode.Duration)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudTextField T="int"
|
||||
@bind-Value="_model.DurationMinutes"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentText="minutes"
|
||||
Disabled="@(_model.FillerMode != FillerMode.Duration)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudTextField T="int"
|
||||
@bind-Value="_model.DurationSeconds"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentText="seconds"
|
||||
Disabled="@(_model.FillerMode != FillerMode.Duration)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Count</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="@_model.Count" For="@(() => _model.Count)" Disabled="@(_model.FillerMode != FillerMode.Count && _model.FillerMode != FillerMode.RandomCount)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Pad To Nearest Minute</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_model.PadToNearestMinute" For="@(() => _model.PadToNearestMinute)" Disabled="@(_model.FillerMode != FillerMode.Pad)">
|
||||
<MudSelectItem T="int?" Value="5">5 (:00, :05, :10, :15, :20, etc)</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="10">10 (:00, :10, :20, :30, :40, :50)</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="15">15 (:00, :15, :30, :45)</MudSelectItem>
|
||||
<MudSelectItem T="int?" Value="30">30 (:00, :30)</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Allow Watermarks</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.AllowWatermarks" For="@(() => _model.AllowWatermarks)" Dense="true"/>
|
||||
</MudStack>
|
||||
<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="_model.CollectionType" For="@(() => _model.CollectionType)">
|
||||
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
|
||||
@if (_model.FillerKind is not FillerKind.Fallback and not FillerKind.Tail)
|
||||
{
|
||||
<MudSelectItem Value="CollectionType.Playlist">Playlist</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_model.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>
|
||||
<MudSelect T="MediaCollectionViewModel" @bind-value="_model.Collection">
|
||||
@foreach (MediaCollectionViewModel collection in _mediaCollections)
|
||||
{
|
||||
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
@if (_model.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>
|
||||
<MudSelect T="MultiCollectionViewModel" @bind-value="_model.MultiCollection">
|
||||
@foreach (MultiCollectionViewModel collection in _multiCollections)
|
||||
{
|
||||
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
@if (_model.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>
|
||||
<MudSelect T="SmartCollectionViewModel" @bind-value="_model.SmartCollection">
|
||||
@foreach (SmartCollectionViewModel collection in _smartCollections)
|
||||
{
|
||||
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
@if (_model.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>
|
||||
<MudSelect T="NamedMediaItemViewModel" @bind-value="_model.MediaItem">
|
||||
@foreach (NamedMediaItemViewModel show in _televisionShows)
|
||||
{
|
||||
<MudSelectItem Value="@show">@show.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
@if (_model.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>
|
||||
<MudSelect T="NamedMediaItemViewModel" @bind-value="_model.MediaItem">
|
||||
@foreach (NamedMediaItemViewModel season in _televisionSeasons)
|
||||
{
|
||||
<MudSelectItem Value="@season">@season.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
@if (_model.CollectionType == CollectionType.Artist)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Artist</MudText>
|
||||
</div>
|
||||
<MudSelect T="NamedMediaItemViewModel" @bind-value="_model.MediaItem">
|
||||
@foreach (NamedMediaItemViewModel artist in _artists)
|
||||
{
|
||||
<MudSelectItem Value="@artist">@artist.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
@if (_model.CollectionType == CollectionType.Playlist)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Playlist Group</MudText>
|
||||
</div>
|
||||
<MudSelect T="PlaylistGroupViewModel"
|
||||
Value="@_selectedPlaylistGroup"
|
||||
ValueChanged="@(vm => UpdatePlaylistGroupItems(vm))">
|
||||
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
|
||||
{
|
||||
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Playlist</MudText>
|
||||
</div>
|
||||
<MudSelect T="PlaylistViewModel" @bind-value="_model.Playlist">
|
||||
@foreach (PlaylistViewModel playlist in _playlists)
|
||||
{
|
||||
<MudSelectItem Value="@playlist">@playlist.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Use Chapters As Media Items</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.UseChaptersAsMediaItems"
|
||||
For="@(() => _model.UseChaptersAsMediaItems)"
|
||||
Dense="true"
|
||||
Disabled="@(_model.FillerKind is FillerKind.Fallback)">
|
||||
<MudText Typo="Typo.caption" Style="font-weight: normal">Schedule individual chapters instead of entire files</MudText>
|
||||
</MudCheckBox>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Expression</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Expression"
|
||||
For="@(() => _model.Expression)"
|
||||
Disabled="@(_model.FillerKind is not FillerKind.MidRoll)"
|
||||
HelperText="For mid-roll filler, only add filler when this expression evaluates to true for a given mid-roll point."/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="gap-md-8 mb-5">
|
||||
<div class="d-flex" style="width: 300px"></div>
|
||||
<MudText Typo="Typo.body2">
|
||||
<span style="font-weight: bold;">total_points</span>: total number of potential mid-roll points
|
||||
<br/>
|
||||
<span style="font-weight: bold;">matched_points</span>: number of mid-roll points that have already matched the expression
|
||||
<br/>
|
||||
<span style="font-weight: bold;">total_duration</span>: total duration of the content, in seconds
|
||||
<br/>
|
||||
<span style="font-weight: bold;">total_progress</span>: normalized position from 0 to 1
|
||||
<br/>
|
||||
<span style="font-weight: bold;">last_mid_filler</span>: seconds since last mid-roll filler
|
||||
<br/>
|
||||
<span style="font-weight: bold;">remaining_duration</span>: duration of the content after this mid-roll point, in seconds
|
||||
<br/>
|
||||
<span style="font-weight: bold;">point</span>: the position of the mid-roll point, in seconds
|
||||
<br/>
|
||||
<span style="font-weight: bold;">num</span>: the mid-roll point number, starting with 1
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly FillerPresetEditViewModel _model = new();
|
||||
private readonly FillerPresetEditViewModelValidator _validator = new();
|
||||
private MudForm _form;
|
||||
|
||||
private readonly List<MediaCollectionViewModel> _mediaCollections = [];
|
||||
private readonly List<MultiCollectionViewModel> _multiCollections = [];
|
||||
private readonly List<SmartCollectionViewModel> _smartCollections = [];
|
||||
private readonly List<NamedMediaItemViewModel> _televisionShows = [];
|
||||
private readonly List<NamedMediaItemViewModel> _televisionSeasons = [];
|
||||
private readonly List<NamedMediaItemViewModel> _artists = [];
|
||||
private readonly List<PlaylistGroupViewModel> _playlistGroups = [];
|
||||
private readonly List<PlaylistViewModel> _playlists = [];
|
||||
|
||||
private PlaylistGroupViewModel _selectedPlaylistGroup;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_mediaCollections.AddRange(
|
||||
await Mediator.Send(new GetAllCollections(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
|
||||
_multiCollections.AddRange(
|
||||
await Mediator.Send(new GetAllMultiCollections(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
|
||||
_smartCollections.AddRange(
|
||||
await Mediator.Send(new GetAllSmartCollections(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
|
||||
_televisionShows.AddRange(
|
||||
await Mediator.Send(new GetAllTelevisionShows(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
|
||||
_televisionSeasons.AddRange(
|
||||
await Mediator.Send(new GetAllTelevisionSeasons(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
|
||||
_artists.AddRange(
|
||||
await Mediator.Send(new GetAllArtists(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()));
|
||||
|
||||
_playlistGroups.AddRange(await Mediator.Send(new GetAllPlaylistGroups(), token));
|
||||
|
||||
if (IsEdit)
|
||||
{
|
||||
Option<FillerPresetViewModel> maybeFillerPreset = await Mediator.Send(new GetFillerPresetById(Id), token);
|
||||
maybeFillerPreset.IfSome(fillerPreset =>
|
||||
{
|
||||
_model.Id = fillerPreset.Id;
|
||||
_model.Name = fillerPreset.Name;
|
||||
_model.FillerKind = fillerPreset.FillerKind;
|
||||
_model.FillerMode = fillerPreset.FillerMode;
|
||||
_model.Duration = fillerPreset.Duration;
|
||||
_model.Count = fillerPreset.Count;
|
||||
_model.PadToNearestMinute = fillerPreset.PadToNearestMinute;
|
||||
_model.AllowWatermarks = fillerPreset.AllowWatermarks;
|
||||
_model.CollectionType = fillerPreset.CollectionType;
|
||||
_model.Collection = fillerPreset.CollectionId.HasValue
|
||||
? _mediaCollections.Find(c => c.Id == fillerPreset.CollectionId.Value)
|
||||
: null;
|
||||
_model.MultiCollection = fillerPreset.MultiCollectionId.HasValue
|
||||
? _multiCollections.Find(c => c.Id == fillerPreset.MultiCollectionId.Value)
|
||||
: null;
|
||||
_model.SmartCollection = fillerPreset.SmartCollectionId.HasValue
|
||||
? _smartCollections.Find(c => c.Id == fillerPreset.SmartCollectionId.Value)
|
||||
: null;
|
||||
_model.MediaItem = fillerPreset.MediaItemId.HasValue
|
||||
? _televisionShows.Append(_televisionSeasons).Append(_artists).ToList().Find(vm => vm.MediaItemId == fillerPreset.MediaItemId.Value)
|
||||
: null;
|
||||
_model.Expression = fillerPreset.Expression;
|
||||
_model.UseChaptersAsMediaItems = fillerPreset.UseChaptersAsMediaItems;
|
||||
_model.Playlist = fillerPreset.Playlist;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_model.Name = "New Filler Preset";
|
||||
_model.FillerKind = FillerKind.PreRoll;
|
||||
_model.FillerMode = FillerMode.Duration;
|
||||
}
|
||||
|
||||
foreach (int playlistGroupId in Optional(_model.Playlist?.PlaylistGroupId))
|
||||
{
|
||||
foreach (PlaylistGroupViewModel group in Optional(_playlistGroups.Find(g => g.Id == playlistGroupId)))
|
||||
{
|
||||
_selectedPlaylistGroup = group;
|
||||
await UpdatePlaylistGroupItems(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEdit => Id != 0;
|
||||
|
||||
private async Task UpdatePlaylistGroupItems(PlaylistGroupViewModel playlistGroup)
|
||||
{
|
||||
_selectedPlaylistGroup = playlistGroup;
|
||||
|
||||
_playlists.Clear();
|
||||
_playlists.AddRange(await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroup.Id), _cts.Token));
|
||||
}
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
ValidationResult result = await _validator.ValidateAsync(_model, _cts.Token);
|
||||
if (result.IsValid)
|
||||
{
|
||||
// Create/Update return different result types (Update -> Unit, Create -> CreateFillerPresetResult),
|
||||
// so send each on its own branch and reduce to the shared Left error sequence.
|
||||
Seq<BaseError> errorMessage = IsEdit
|
||||
? (await Mediator.Send(_model.ToEdit(), _cts.Token)).LeftToSeq()
|
||||
: (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Error saving filler preset: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("media/filler/presets"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
@page "/media/filler/presets"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.Filler
|
||||
@using ErsatzTV.Core.Domain.Filler
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@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">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-6" StartIcon="@Icons.Material.Filled.Add" Href="media/filler/presets/add">
|
||||
Add Filler Preset
|
||||
</MudButton>
|
||||
</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">Filler Presets</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
@bind-RowsPerPage="@_fillerPresetsRowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<FillerPresetViewModel>>>(ServerReloadFillerPresets))"
|
||||
Dense="true"
|
||||
@ref="_fillerPresetsTable">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Filler Kind</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Filler Kind">
|
||||
@(
|
||||
context.FillerKind switch
|
||||
{
|
||||
FillerKind.PreRoll => "Pre-Roll",
|
||||
FillerKind.MidRoll => "Mid-Roll",
|
||||
FillerKind.PostRoll => "Post-Roll",
|
||||
FillerKind.Fallback => "Fallback",
|
||||
FillerKind.Tail => "Tail",
|
||||
_ => "None"
|
||||
}
|
||||
)
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Filler Preset">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"media/filler/presets/{context.Id}/edit")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Filler Preset">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteFillerPreset(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudTable<FillerPresetViewModel> _fillerPresetsTable;
|
||||
|
||||
private int _fillerPresetsRowsPerPage = 10;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_fillerPresetsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.FillerPresetsPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteFillerPreset(FillerPresetViewModel fillerPreset)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "filler preset" }, { "EntityName", fillerPreset.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Filler Preset", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new DeleteFillerPreset(fillerPreset.Id), _cts.Token);
|
||||
if (_fillerPresetsTable != null)
|
||||
{
|
||||
await _fillerPresetsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<FillerPresetViewModel>> ServerReloadFillerPresets(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.FillerPresetsPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
PagedFillerPresetsViewModel data = await Mediator.Send(new GetPagedFillerPresets(state.Page, state.PageSize), cancellationToken);
|
||||
return new TableData<FillerPresetViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Extensions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace ErsatzTV.Pages;
|
||||
|
||||
public class FragmentNavigationBase : ComponentBase, IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
private bool _disposedValue;
|
||||
|
||||
protected CancellationToken CancellationToken => _cts.Token;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager NavManager { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
NavManager.LocationChanged -= TryFragmentNavigation;
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
_disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized() => NavManager.LocationChanged += TryFragmentNavigation;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
await NavManager.NavigateToFragmentAsync(JsRuntime);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "VSTHRD100")]
|
||||
private async void TryFragmentNavigation(object sender, LocationChangedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
await NavManager.NavigateToFragmentAsync(JsRuntime);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
@page "/media/browser/images"
|
||||
@using System.Net
|
||||
@using ErsatzTV.Application.Images
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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">Images</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudCard>
|
||||
<MudTreeView T="ImageTreeItemViewModel" ServerData="LoadServerData" Items="@TreeItems" Hover="true" ExpandOnClick="true">
|
||||
<ItemTemplate Context="item">
|
||||
<MudTreeViewItem T="ImageTreeItemViewModel" Items="@item.Value!.TreeItems" Icon="@item.Value.Icon" CanExpand="@item.Value.CanExpand" Value="@item.Value">
|
||||
<BodyContent>
|
||||
<div style="display: flex; align-items: center; width: 100%">
|
||||
<div style="justify-self: start; flex: 1">
|
||||
<MudText>@item.Value.Text</MudText>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; justify-self: end;">
|
||||
<span class="d-none d-sm-flex">@item.Value.EndText</span>
|
||||
<MudTooltip Text="Edit Image Folder Duration" ShowOnHover="true" ShowOnClick="false" ShowOnFocus="false">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
OnClick="@(_ => EditImageFolderDuration(item.Value))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
@{
|
||||
string query = GetSearchQuery(item.Value);
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
<MudIconButton
|
||||
Class="d-none d-sm-flex"
|
||||
Icon="@Icons.Material.Filled.Search"
|
||||
Href="@($"search?query={query}")"/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</MudTreeViewItem>
|
||||
</ItemTemplate>
|
||||
</MudTreeView>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private List<TreeItemData<ImageTreeItemViewModel>> TreeItems { get; set; } = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
List<ImageFolderViewModel> imageFolders = await Mediator.Send(new GetImageFolders(Option<int>.None), token);
|
||||
TreeItems = imageFolders.Map(g => new TreeItemData<ImageTreeItemViewModel> { Value = new ImageTreeItemViewModel(g) }).ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<TreeItemData<ImageTreeItemViewModel>>> LoadServerData(ImageTreeItemViewModel parentNode)
|
||||
{
|
||||
List<ImageFolderViewModel> result = await Mediator.Send(new GetImageFolders(parentNode.LibraryFolderId), _cts.Token);
|
||||
foreach (ImageFolderViewModel imageFolder in result)
|
||||
{
|
||||
parentNode.TreeItems.Add(new TreeItemData<ImageTreeItemViewModel> { Value = new ImageTreeItemViewModel(imageFolder) });
|
||||
}
|
||||
|
||||
return parentNode.TreeItems;
|
||||
}
|
||||
|
||||
private static string GetSearchQuery(ImageTreeItemViewModel item)
|
||||
{
|
||||
var query = $"library_folder_id:{item.LibraryFolderId}";
|
||||
return WebUtility.UrlEncode(query);
|
||||
}
|
||||
|
||||
private async Task EditImageFolderDuration(ImageTreeItemViewModel item)
|
||||
{
|
||||
var parameters = new DialogParameters { { "ImageFolderDuration", item.ImageFolderDuration } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<EditImageFolderDurationDialog>("Edit Image Folder Duration", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
double? duration = await Mediator.Send(new UpdateImageFolderDuration(item.LibraryFolderId, result.Data as double?), _cts.Token);
|
||||
item.UpdateDuration(duration);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
@page "/media/images"
|
||||
@page "/media/images/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits MultiSelectBase<ImageList>
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="ImageCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/images"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private ImageCardResultsViewModel _data = new(0, new List<ImageCardViewModel>(), null);
|
||||
private string _query;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:image" : $"type:image AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexImages(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/images/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/images/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is ImageCardViewModel image)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "image" }, { "EntityName", image.Title } };
|
||||
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 AddImageToCollection(collection.Id, image.ImageId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding image to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding image to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {image.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
@page "/"
|
||||
@page "/system/health"
|
||||
@using System.Reflection
|
||||
@using ErsatzTV.Application.Health
|
||||
@using ErsatzTV.Core.Health
|
||||
@using ErsatzTV.Core.Interfaces.GitHub
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@implements IDisposable
|
||||
@inject IGitHubApiClient GitHubApiClient
|
||||
@inject IMemoryCache MemoryCache
|
||||
@inject IMediator Mediator
|
||||
@inject SystemStartup SystemStartup;
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
@if (!SystemStartup.IsDatabaseReady)
|
||||
{
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h4">Database is initializing</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudText>Please wait, this may take a few minutes.</MudText>
|
||||
<MudText>This page will automatically refresh when the database is ready.</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudProgressCircular Color="Color.Primary" Size="Size.Medium" Indeterminate="true"/>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
}
|
||||
else if (!SystemStartup.IsSearchIndexReady)
|
||||
{
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h4">Search Index is initializing</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudText>Please wait, this may take a few minutes.</MudText>
|
||||
<MudText>This page will automatically refresh when the search index is ready.</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudProgressCircular Color="Color.Primary" Size="Size.Medium" Indeterminate="true"/>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mb-2">Health Checks</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
Dense="true"
|
||||
Breakpoint="Breakpoint.None"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<HealthCheckResult>>>(ServerReload))">
|
||||
<HeaderContent>
|
||||
<MudTh>Check</MudTh>
|
||||
<MudTh>Message</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex; flex-direction: row;">
|
||||
@if (context.Status == HealthCheckStatus.Fail)
|
||||
{
|
||||
<MudIcon Color="@Color.Error" Icon="@Icons.Material.Filled.Error"/>
|
||||
}
|
||||
else if (context.Status == HealthCheckStatus.Warning)
|
||||
{
|
||||
<MudIcon Color="@Color.Warning" Icon="@Icons.Material.Filled.Warning"/>
|
||||
}
|
||||
else if (context.Status == HealthCheckStatus.Info)
|
||||
{
|
||||
<MudIcon Color="@Color.Info" Icon="@Icons.Material.Filled.Info"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Color="@Color.Success" Icon="@Icons.Material.Filled.Check"/>
|
||||
}
|
||||
<div class="ml-2">@context.Title</div>
|
||||
</div>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
@if (context.Link.IsSome)
|
||||
{
|
||||
foreach (HealthCheckLink link in context.Link)
|
||||
{
|
||||
if (link.Link.StartsWith("http"))
|
||||
{
|
||||
<MudLink Href="@link.Link" Target="_blank">
|
||||
@context.Message
|
||||
</MudLink>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudLink Href="@link.Link">
|
||||
@context.Message
|
||||
</MudLink>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>
|
||||
@context.Message
|
||||
</MudText>
|
||||
}
|
||||
</MudHidden>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs" Invert="true">
|
||||
@if (context.Link.IsSome)
|
||||
{
|
||||
foreach (HealthCheckLink link in context.Link)
|
||||
{
|
||||
<MudLink Href="@link.Link">
|
||||
@context.BriefMessage
|
||||
</MudLink>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>
|
||||
@context.BriefMessage
|
||||
</MudText>
|
||||
}
|
||||
</MudHidden>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
<MudCard Class="mt-6">
|
||||
<MudCardHeader>
|
||||
<MudText Class="mt-6">Full changelog is available on <MudLink Href="https://github.com/ErsatzTV/ErsatzTV/blob/main/CHANGELOG.md" Target="_blank">GitHub</MudLink></MudText>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="release-notes mud-typography mud-typography-body1">
|
||||
<MarkdownView Content="@_releaseNotes"/>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private string _releaseNotes;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SystemStartup.OnDatabaseReady += OnStartupProgress;
|
||||
SystemStartup.OnSearchIndexReady += OnStartupProgress;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SystemStartup.OnDatabaseReady -= OnStartupProgress;
|
||||
SystemStartup.OnSearchIndexReady -= OnStartupProgress;
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
private async void OnStartupProgress(object sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
if (MemoryCache.TryGetValue("Index.ReleaseNotesHtml", out string releaseNotesHtml))
|
||||
{
|
||||
_releaseNotes = releaseNotesHtml;
|
||||
}
|
||||
else
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly();
|
||||
if (assembly != null)
|
||||
{
|
||||
string version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
if (version != null)
|
||||
{
|
||||
Either<BaseError, string> maybeNotes;
|
||||
|
||||
if (version != "develop")
|
||||
{
|
||||
string gitHubVersion = version.Split("-").Head();
|
||||
if (!gitHubVersion.StartsWith("v"))
|
||||
{
|
||||
gitHubVersion = $"v{gitHubVersion}";
|
||||
}
|
||||
|
||||
maybeNotes = await GitHubApiClient.GetReleaseNotes(gitHubVersion, token);
|
||||
foreach (string notes in maybeNotes.RightToSeq())
|
||||
{
|
||||
_releaseNotes = notes;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
maybeNotes = await GitHubApiClient.GetLatestReleaseNotes(token);
|
||||
foreach (string notes in maybeNotes.RightToSeq())
|
||||
{
|
||||
_releaseNotes = notes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_releaseNotes != null)
|
||||
{
|
||||
MemoryCache.Set("Index.ReleaseNotesHtml", _releaseNotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<HealthCheckResult>> ServerReload(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
List<HealthCheckResult> healthCheckResults = await Mediator.Send(new GetAllHealthCheckResults(), cancellationToken);
|
||||
|
||||
return new TableData<HealthCheckResult>
|
||||
{
|
||||
TotalItems = healthCheckResults.Count,
|
||||
Items = healthCheckResults
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
@page "/media/sources/jellyfin/{Id:int}/libraries"
|
||||
@using ErsatzTV.Application.Jellyfin
|
||||
@using ErsatzTV.Application.MediaSources
|
||||
@inject IMediator Mediator
|
||||
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
|
||||
|
||||
<RemoteMediaSourceLibrariesEditor
|
||||
Id="@Id"
|
||||
Name="Jellyfin"
|
||||
GetUpdateLibraryRequest="GetUpdateLibraryRequest"
|
||||
GetLibrariesBySourceId="GetLibrariesBySourceId"
|
||||
GetMediaSourceById="GetMediaSourceById"
|
||||
SynchronizeLibraryByIdIfNeeded="SynchronizeLibraryByIdIfNeeded"/>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private IRequest<Either<BaseError, Unit>> GetUpdateLibraryRequest(List<RemoteMediaSourceLibraryEditViewModel> libraries) =>
|
||||
new UpdateJellyfinLibraryPreferences(
|
||||
libraries.Map(l => new JellyfinLibraryPreference(l.Id, l.ShouldSyncItems)).ToList());
|
||||
|
||||
private async Task<List<RemoteMediaSourceLibraryEditViewModel>> GetLibrariesBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
await Mediator.Send(new GetJellyfinLibrariesBySourceId(Id), cancellationToken)
|
||||
.Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList());
|
||||
|
||||
private async Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
await Mediator.Send(new GetJellyfinMediaSourceById(Id), cancellationToken)
|
||||
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
|
||||
|
||||
private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(JellyfinLibraryViewModel library) => new()
|
||||
{
|
||||
Id = library.Id,
|
||||
Name = library.Name,
|
||||
MediaKind = library.MediaKind,
|
||||
ShouldSyncItems = library.ShouldSyncItems
|
||||
};
|
||||
|
||||
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(parameters.MediaSourceId), cancellationToken);
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraryByIdIfNeeded(parameters.LibraryId), cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
@page "/media/sources/jellyfin/edit"
|
||||
@using ErsatzTV.Application.Jellyfin
|
||||
@using ErsatzTV.Core.Jellyfin
|
||||
@inject IMediator Mediator
|
||||
|
||||
<RemoteMediaSourceEditor
|
||||
Name="Jellyfin"
|
||||
LoadSecrets="LoadSecrets"
|
||||
SaveSecrets="SaveSecrets"/>
|
||||
|
||||
@code {
|
||||
private async Task<Unit> LoadSecrets(RemoteMediaSourceEditViewModel viewModel, CancellationToken cancellationToken)
|
||||
{
|
||||
JellyfinSecrets secrets = await Mediator.Send(new GetJellyfinSecrets(), cancellationToken);
|
||||
viewModel.Address = secrets.Address;
|
||||
viewModel.ApiKey = secrets.ApiKey;
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> SaveSecrets(RemoteMediaSourceEditViewModel viewModel, CancellationToken cancellationToken)
|
||||
{
|
||||
var secrets = new JellyfinSecrets { Address = viewModel.Address, ApiKey = viewModel.ApiKey };
|
||||
return await Mediator.Send(new SaveJellyfinSecrets(secrets), cancellationToken);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
@page "/media/sources/jellyfin"
|
||||
@using ErsatzTV.Application.Jellyfin
|
||||
@using ErsatzTV.Core.Interfaces.Jellyfin
|
||||
@using ErsatzTV.Core.Jellyfin
|
||||
@inject IJellyfinSecretStore JellyfinSecretStore
|
||||
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
|
||||
|
||||
<RemoteMediaSources
|
||||
TViewModel="JellyfinMediaSourceViewModel"
|
||||
TSecrets="JellyfinSecrets"
|
||||
TMediaSource="JellyfinMediaSource"
|
||||
Name="Jellyfin"
|
||||
GetAllMediaSourcesCommand="@(new GetAllJellyfinMediaSources())"
|
||||
DisconnectCommand="@(new DisconnectJellyfin())"
|
||||
RefreshLibrariesCommand="@RefreshLibraries"
|
||||
SecretStore="@JellyfinSecretStore"
|
||||
CanEdit="true"/>
|
||||
|
||||
@code {
|
||||
private async Task RefreshLibraries(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSourceId), cancellationToken);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
@page "/media/sources/jellyfin/{Id:int}/paths"
|
||||
@using ErsatzTV.Application.Jellyfin
|
||||
@using ErsatzTV.Application.MediaSources
|
||||
@inject IMediator Mediator
|
||||
|
||||
<RemoteMediaSourcePathReplacementsEditor
|
||||
Id="@Id"
|
||||
Name="Jellyfin"
|
||||
GetMediaSourceById="GetMediaSourceById"
|
||||
GetUpdatePathReplacementsRequest="GetUpdatePathReplacementsRequest"
|
||||
GetPathReplacementsBySourceId="GetPathReplacementsBySourceId"/>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private async Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int id, CancellationToken cancellationToken) =>
|
||||
await Mediator.Send(new GetJellyfinMediaSourceById(Id), cancellationToken)
|
||||
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
|
||||
|
||||
private async Task<List<RemoteMediaSourcePathReplacementEditViewModel>> GetPathReplacementsBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
await Mediator.Send(new GetJellyfinPathReplacementsBySourceId(Id), cancellationToken)
|
||||
.Map(list => list.Map(ProjectToEditViewModel).ToList());
|
||||
|
||||
private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(JellyfinPathReplacementViewModel item) =>
|
||||
new() { Id = item.Id, RemotePath = item.JellyfinPath, LocalPath = item.LocalPath };
|
||||
|
||||
private IRequest<Either<BaseError, Unit>> GetUpdatePathReplacementsRequest(List<RemoteMediaSourcePathReplacementEditViewModel> pathReplacements)
|
||||
{
|
||||
var items = pathReplacements
|
||||
.Map(item => new JellyfinPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
|
||||
.ToList();
|
||||
|
||||
return new UpdateJellyfinPathReplacements(Id, items);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
@page "/media/libraries"
|
||||
@using ErsatzTV.Application.Emby
|
||||
@using ErsatzTV.Application.Jellyfin
|
||||
@using ErsatzTV.Application.Libraries
|
||||
@using ErsatzTV.Application.MediaSources
|
||||
@using ErsatzTV.Application.Plex
|
||||
@using ErsatzTV.Core.Interfaces.Metadata
|
||||
@using ErsatzTV.Core.Metadata
|
||||
@using MediatR.Courier
|
||||
@using PlexLibraryViewModel = ErsatzTV.Application.Libraries.PlexLibraryViewModel
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject IEntityLocker Locker
|
||||
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
|
||||
@inject ICourier Courier
|
||||
@inject IScannerProxyService ScannerProxyService
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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">Libraries</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true" Items="_libraries" Dense="true" Class="mb-5">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
@if (_showServerNames)
|
||||
{
|
||||
<col/>
|
||||
}
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 180px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Library Kind</MudTh>
|
||||
@if (_showServerNames)
|
||||
{
|
||||
<MudTh>Server Name</MudTh>
|
||||
}
|
||||
<MudTh>Library Name</MudTh>
|
||||
<MudTh>Media Kind</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Library Kind">@context.LibraryKind</MudTd>
|
||||
@if (_showServerNames)
|
||||
{
|
||||
<MudTd DataLabel="Server Name">@context.MediaSourceName</MudTd>
|
||||
}
|
||||
<MudTd DataLabel="Library Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Media Kind">
|
||||
@switch (context.MediaKind)
|
||||
{
|
||||
case LibraryMediaKind.MusicVideos:
|
||||
@:Music Videos
|
||||
break;
|
||||
case LibraryMediaKind.OtherVideos:
|
||||
@:Other Videos
|
||||
break;
|
||||
case LibraryMediaKind.RemoteStreams:
|
||||
@:Remote Streams
|
||||
break;
|
||||
default:
|
||||
@(context.MediaKind)
|
||||
break;
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
@if (IsLibraryLocked(context.Id))
|
||||
{
|
||||
<div style="width: 48px">
|
||||
@if (_progressByLibrary[context.Id] > 0)
|
||||
{
|
||||
<MudText Color="Color.Primary">
|
||||
@($"{_progressByLibrary[context.Id]} %")
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
|
||||
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (context is PlexLibraryViewModel or EmbyLibraryViewModel or JellyfinLibraryViewModel)
|
||||
{
|
||||
<MudTooltip Text="Deep Scan Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FindReplace"
|
||||
Disabled="@Locker.IsLibraryLocked(context.Id)"
|
||||
OnClick="@(_ => ScanLibrary(context, true))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="width: 48px"></div>
|
||||
}
|
||||
|
||||
<MudTooltip Text="Scan Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@Locker.IsLibraryLocked(context.Id)"
|
||||
OnClick="@(_ => ScanLibrary(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="Search Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Search"
|
||||
Href="@($"search?query=library_id%3a{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">External Collections</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
@if (_externalCollections.Any())
|
||||
{
|
||||
<MudTable Hover="true" Items="_externalCollections" Dense="true">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col style="width: 180px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Library Kind</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.LibraryKind</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<div style="width: 48px"></div>
|
||||
@if (AreCollectionsLocked(context.LibraryKind))
|
||||
{
|
||||
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
|
||||
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
if (context.LibraryKind is "Plex" or "Emby" or "Jellyfin")
|
||||
{
|
||||
<MudTooltip Text="Deep Scan Collections">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FindReplace"
|
||||
Disabled="@AreCollectionsLocked(context.LibraryKind)"
|
||||
OnClick="@(_ => ScanExternalCollections(context, true))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="width: 48px"></div>
|
||||
}
|
||||
<MudTooltip Text="Scan Collections">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@AreCollectionsLocked(context.LibraryKind)"
|
||||
OnClick="@(_ => ScanExternalCollections(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
<div style="width: 48px"></div>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private IList<LibraryViewModel> _libraries = new List<LibraryViewModel>();
|
||||
private IList<LibraryViewModel> _externalCollections = new List<LibraryViewModel>();
|
||||
private Dictionary<int, int> _progressByLibrary = new();
|
||||
private bool _showServerNames;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Locker.OnLibraryChanged += LockChanged;
|
||||
Locker.OnEmbyCollectionsChanged += LockChanged;
|
||||
Locker.OnJellyfinCollectionsChanged += LockChanged;
|
||||
Locker.OnPlexCollectionsChanged += LockChanged;
|
||||
Courier.Subscribe<LibraryScanProgress>(HandleScanProgress);
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_libraries = await Mediator.Send(new GetConfiguredLibraries(), token);
|
||||
_showServerNames = _libraries.Any(l => l is PlexLibraryViewModel);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
_externalCollections = await Mediator.Send(new GetExternalCollections(), token);
|
||||
|
||||
foreach (var library in _libraries)
|
||||
{
|
||||
decimal progress = ScannerProxyService.GetProgress(library.Id).IfNone(0);
|
||||
_progressByLibrary[library.Id] = (int)(progress * 100);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanLibrary(LibraryViewModel library, bool deepScan = false)
|
||||
{
|
||||
if (Locker.LockLibrary(library.Id))
|
||||
{
|
||||
var token = _cts?.Token ?? CancellationToken.None;
|
||||
|
||||
switch (library)
|
||||
{
|
||||
case LocalLibraryViewModel:
|
||||
await ScannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(library.Id), token);
|
||||
break;
|
||||
case PlexLibraryViewModel:
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(library.MediaSourceId), token);
|
||||
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id, deepScan), token);
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexNetworks(library.Id, true), token);
|
||||
break;
|
||||
case JellyfinLibraryViewModel:
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(library.MediaSourceId), token);
|
||||
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizeJellyfinLibraryById(library.Id, deepScan), token);
|
||||
break;
|
||||
case EmbyLibraryViewModel:
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(library.MediaSourceId), token);
|
||||
await ScannerWorkerChannel.WriteAsync(new ForceSynchronizeEmbyLibraryById(library.Id, deepScan), token);
|
||||
break;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanExternalCollections(LibraryViewModel library, bool deepScan = false)
|
||||
{
|
||||
var token = _cts?.Token ?? CancellationToken.None;
|
||||
switch (library.LibraryKind.ToLowerInvariant())
|
||||
{
|
||||
case "emby":
|
||||
if (Locker.LockEmbyCollections())
|
||||
{
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeEmbyCollections(library.MediaSourceId, true, deepScan), token);
|
||||
}
|
||||
|
||||
break;
|
||||
case "jellyfin":
|
||||
if (Locker.LockJellyfinCollections())
|
||||
{
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizeJellyfinCollections(library.MediaSourceId, true, deepScan), token);
|
||||
}
|
||||
|
||||
break;
|
||||
case "plex":
|
||||
if (Locker.LockPlexCollections())
|
||||
{
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexCollections(library.MediaSourceId, true, deepScan), token);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void LockChanged(object sender, EventArgs e) =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
private bool AreCollectionsLocked(string libraryKind)
|
||||
{
|
||||
switch (libraryKind.ToLowerInvariant())
|
||||
{
|
||||
case "emby":
|
||||
return Locker.AreEmbyCollectionsLocked();
|
||||
case "jellyfin":
|
||||
return Locker.AreJellyfinCollectionsLocked();
|
||||
case "plex":
|
||||
return Locker.ArePlexCollectionsLocked();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task HandleScanProgress(LibraryScanProgress libraryScanProgress, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_progressByLibrary != null && _progressByLibrary.ContainsKey(libraryScanProgress.LibraryId))
|
||||
{
|
||||
_progressByLibrary[libraryScanProgress.LibraryId] = (int)(libraryScanProgress.Progress * 100);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Locker.OnLibraryChanged -= LockChanged;
|
||||
Locker.OnEmbyCollectionsChanged -= LockChanged;
|
||||
Locker.OnJellyfinCollectionsChanged -= LockChanged;
|
||||
Locker.OnPlexCollectionsChanged -= LockChanged;
|
||||
Courier.UnSubscribe<LibraryScanProgress>(HandleScanProgress);
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
private bool IsLibraryLocked(int libraryId)
|
||||
{
|
||||
if (Locker.IsLibraryLocked(libraryId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_progressByLibrary[libraryId] = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
@page "/media/sources/local"
|
||||
@using ErsatzTV.Application.Libraries
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
@inject IEntityLocker Locker
|
||||
|
||||
<MudForm 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-6" StartIcon="@Icons.Material.Filled.Add" Href="media/sources/local/add">
|
||||
Add Local Library
|
||||
</MudButton>
|
||||
</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">Local Libraries</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true" Items="_libraries" Dense="true">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Media Kind</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Media Kind">@StringForMediaKind(context.MediaKind)</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@Locker.IsLibraryLocked(context.Id)"
|
||||
Href="@($"media/sources/local/{context.Id}/edit")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Library">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Disabled="@Locker.IsLibraryLocked(context.Id)"
|
||||
OnClick="@(() => DeleteLibrary(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private IList<LocalLibraryViewModel> _libraries;
|
||||
|
||||
protected override void OnInitialized() => Locker.OnLibraryChanged += LockChanged;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await LoadLibraries(token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadLibraries(CancellationToken cancellationToken)
|
||||
{
|
||||
_libraries = await Mediator.Send(new GetAllLocalLibraries(), cancellationToken);
|
||||
}
|
||||
|
||||
private void LockChanged(object sender, EventArgs e) =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
private async Task DeleteLibrary(LocalLibraryViewModel library)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
int count = await Mediator.Send(new CountMediaItemsByLibrary(library.Id), cts.Token);
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "EntityType", "library" },
|
||||
{ "EntityName", library.Name },
|
||||
{ "DetailText", $"This library contains {count} media items." },
|
||||
{ "DetailHighlight", count.ToString() }
|
||||
};
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Library", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new DeleteLocalLibrary(library.Id), cts.Token);
|
||||
await LoadLibraries(cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private static string StringForMediaKind(LibraryMediaKind mediaKind) =>
|
||||
mediaKind switch
|
||||
{
|
||||
LibraryMediaKind.Movies => "Movies",
|
||||
LibraryMediaKind.Shows => "Shows",
|
||||
LibraryMediaKind.MusicVideos => "Music Videos",
|
||||
LibraryMediaKind.OtherVideos => "Other Videos",
|
||||
LibraryMediaKind.Songs => "Songs",
|
||||
LibraryMediaKind.Images => "Images",
|
||||
LibraryMediaKind.RemoteStreams => "Remote Streams",
|
||||
_ => "Unknown"
|
||||
};
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Locker.OnLibraryChanged -= LockChanged;
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
@page "/media/sources/local/{Id:int}/edit"
|
||||
@page "/media/sources/local/add"
|
||||
@using ErsatzTV.Application.Libraries
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IEntityLocker Locker
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<LocalLibraryEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm @ref="_form" Model="@_model" @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-6" OnClick="@SaveChangesAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Local Library" : "Add Local Library")</MudButton>
|
||||
</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">Local Library</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Local library name is required!"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Media Kind</MudText>
|
||||
</div>
|
||||
<MudSelect Disabled="IsEdit" @bind-Value="_model.MediaKind" For="@(() => _model.MediaKind)">
|
||||
@foreach (LibraryMediaKind mediaKind in Enum.GetValues<LibraryMediaKind>())
|
||||
{
|
||||
<MudSelectItem Value="@mediaKind">@mediaKind</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Path</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_newPath.Path" For="@(() => _newPath.Path)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddLibraryPath())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Path
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudTable Hover="true" Items="_model.Paths" Class="mt-6">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Library Paths</MudText>
|
||||
</ToolBarContent>
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Path</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Path</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Move Library Path">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DriveFileMove"
|
||||
Disabled="@(_model.Id == 0 || context.Id == 0 || Locker.IsLibraryLocked(_model.Id))"
|
||||
OnClick="@(() => MoveLibraryPath(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Library Path">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(() => DeleteLibraryPath(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly LocalLibraryEditViewModel _model = new();
|
||||
private readonly LocalLibraryPathEditViewModel _newPath = new();
|
||||
private MudForm _form;
|
||||
private bool _success;
|
||||
|
||||
private bool IsEdit => Id != 0;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
if (IsEdit)
|
||||
{
|
||||
Option<LocalLibraryViewModel> maybeLibrary = await Mediator.Send(new GetLocalLibraryById(Id), token);
|
||||
await maybeLibrary.Match(
|
||||
async library =>
|
||||
{
|
||||
_model.Id = library.Id;
|
||||
_model.Name = library.Name;
|
||||
_model.MediaKind = library.MediaKind;
|
||||
|
||||
await LoadLibraryPaths();
|
||||
},
|
||||
() =>
|
||||
{
|
||||
NavigationManager.NavigateTo("404");
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_model.HasChanges = true;
|
||||
_model.Name = "New Local Library";
|
||||
_model.MediaKind = LibraryMediaKind.Movies;
|
||||
_model.Paths = [];
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized() => Locker.OnLibraryChanged += LockChanged;
|
||||
|
||||
private void LockChanged(object sender, EventArgs e) =>
|
||||
InvokeAsync(StateHasChanged);
|
||||
|
||||
private async Task LoadLibraryPaths()
|
||||
{
|
||||
_model.HasChanges = false;
|
||||
_model.Paths = await Mediator.Send(new GetLocalLibraryPaths(Id), _cts.Token)
|
||||
.Map(list => list.Map(vm => new LocalLibraryPathEditViewModel
|
||||
{
|
||||
Id = vm.Id,
|
||||
Path = vm.Path
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
private async Task MoveLibraryPath(LocalLibraryPathEditViewModel libraryPath)
|
||||
{
|
||||
var parameters = new DialogParameters { { "MediaKind", _model.MediaKind }, { "SourceLibraryId", Id } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<MoveLocalLibraryPathDialog>("Move Local Library Path", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: LocalLibraryViewModel library })
|
||||
{
|
||||
var request = new MoveLocalLibraryPath(libraryPath.Id, library.Id);
|
||||
Either<BaseError, Unit> moveResult = await Mediator.Send(request, _cts.Token);
|
||||
moveResult.Match(
|
||||
_ => NavigationManager.NavigateTo($"media/sources/local/{library.Id}/edit"),
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error moving local library path: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteLibraryPath(LocalLibraryPathEditViewModel libraryPath)
|
||||
{
|
||||
if (libraryPath.Id == 0)
|
||||
{
|
||||
_model.HasChanges = true;
|
||||
_model.Paths.Remove(libraryPath);
|
||||
}
|
||||
|
||||
int count = await Mediator.Send(new CountMediaItemsByLibraryPath(libraryPath.Id), _cts.Token);
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "EntityType", "library path" },
|
||||
{ "EntityName", libraryPath.Path },
|
||||
{ "DetailText", $"This library path contains {count} media items." },
|
||||
{ "DetailHighlight", count.ToString() }
|
||||
};
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Library Path", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
_model.HasChanges = true;
|
||||
_model.Paths.Remove(libraryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLibraryPath()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newPath.Path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_newPath.Path))
|
||||
{
|
||||
Snackbar.Add("Path must exist on filesystem", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_newPath.Path) && _model.Paths.All(p => NormalizePath(p.Path) != NormalizePath(_newPath.Path)))
|
||||
{
|
||||
_model.HasChanges = true;
|
||||
_model.Paths.Add(
|
||||
new LocalLibraryPathEditViewModel
|
||||
{
|
||||
Path = _newPath.Path
|
||||
});
|
||||
}
|
||||
|
||||
_newPath.Path = null;
|
||||
}
|
||||
|
||||
private async Task SaveChangesAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_success)
|
||||
{
|
||||
Either<BaseError, LocalLibraryViewModel> result = IsEdit
|
||||
? await Mediator.Send(
|
||||
new UpdateLocalLibrary(
|
||||
_model.Id,
|
||||
_model.Name,
|
||||
_model.Paths.Map(p => new UpdateLocalLibraryPath(p.Id, p.Path)).ToList()),
|
||||
_cts.Token)
|
||||
: await Mediator.Send(
|
||||
new CreateLocalLibrary(
|
||||
_model.Name,
|
||||
_model.MediaKind,
|
||||
_model.Paths.Map(p => p.Path).ToList()),
|
||||
_cts.Token);
|
||||
|
||||
result.Match(
|
||||
_ => NavigationManager.NavigateTo("media/sources/local"),
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving local library: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private string NormalizePath(string path) => Path.GetFullPath(new Uri(path).LocalPath)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.ToUpperInvariant();
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Locker.OnLibraryChanged -= LockChanged;
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
@page "/system/logs"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.Logs
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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">Logs</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>Search</MudText>
|
||||
</div>
|
||||
<MudTextField T="string" ValueChanged="@(s => OnSearch(s))" Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium"/>
|
||||
</MudStack>
|
||||
<MudTable FixedHeader="true"
|
||||
@bind-RowsPerPage="@_rowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<LogEntryViewModel>>>(ServerReload))"
|
||||
Dense="true"
|
||||
Class="mt-10"
|
||||
@ref="_table">
|
||||
<HeaderContent>
|
||||
<MudTh>
|
||||
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Timestamp">
|
||||
Timestamp
|
||||
</MudTableSortLabel>
|
||||
</MudTh>
|
||||
<MudTh>
|
||||
<MudTableSortLabel T="LogEntryViewModel" SortLabel="Level">
|
||||
Level
|
||||
</MudTableSortLabel>
|
||||
</MudTh>
|
||||
<MudTh>Message</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Timestamp.ToString("G", _dtf)</MudTd>
|
||||
<MudTd>@context.Level</MudTd>
|
||||
<MudTd>@context.Message</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText>No matching records found</MudText>
|
||||
</NoRecordsContent>
|
||||
<LoadingContent>
|
||||
<MudText>Loading...</MudText>
|
||||
</LoadingContent>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat;
|
||||
|
||||
private MudTable<LogEntryViewModel> _table;
|
||||
private int _rowsPerPage = 10;
|
||||
private string _searchString;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.LogsPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<LogEntryViewModel>> ServerReload(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.LogsPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
PagedLogEntriesViewModel data;
|
||||
|
||||
switch (state.SortLabel?.ToLowerInvariant())
|
||||
{
|
||||
case "timestamp":
|
||||
data = await Mediator.Send(
|
||||
new GetRecentLogEntries(state.Page, state.PageSize, _searchString)
|
||||
{
|
||||
SortExpression = le => le.Timestamp,
|
||||
SortDescending = state.SortDirection == SortDirection.None
|
||||
? Option<bool>.None
|
||||
: state.SortDirection == SortDirection.Descending
|
||||
},
|
||||
cancellationToken);
|
||||
break;
|
||||
case "level":
|
||||
data = await Mediator.Send(
|
||||
new GetRecentLogEntries(state.Page, state.PageSize, _searchString)
|
||||
{
|
||||
SortExpression = le => le.Level,
|
||||
SortDescending = state.SortDirection == SortDirection.None
|
||||
? Option<bool>.None
|
||||
: state.SortDirection == SortDirection.Descending
|
||||
},
|
||||
cancellationToken);
|
||||
break;
|
||||
default:
|
||||
data = await Mediator.Send(
|
||||
new GetRecentLogEntries(state.Page, state.PageSize, _searchString)
|
||||
{
|
||||
SortDescending = Option<bool>.None
|
||||
},
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
return new TableData<LogEntryViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
private void OnSearch(string text)
|
||||
{
|
||||
_searchString = text;
|
||||
_table.ReloadServerData();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
@page "/media/collections"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@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%" 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
|
||||
</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.Add" Label="Add Collection" Href="media/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">Manual Collections</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
@bind-RowsPerPage="@_collectionsRowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<MediaCollectionViewModel>>>(ServerReloadCollections))"
|
||||
Dense="true"
|
||||
@ref="_collectionsTable">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string"
|
||||
ValueChanged="@(s => OnSearch(s))"
|
||||
Placeholder="Search for manual collections"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.FilterList"
|
||||
Clearable="true">
|
||||
</MudTextField>
|
||||
</ToolBarContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"media/collections/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteMediaCollection(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
<div class="mt-6"></div>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudTable<MediaCollectionViewModel> _collectionsTable;
|
||||
|
||||
private int _collectionsRowsPerPage = 10;
|
||||
private string _searchString;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_collectionsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteMediaCollection(MediaCollectionViewModel collection)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "collection" }, { "EntityName", collection.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new DeleteCollection(collection.Id), _cts.Token);
|
||||
if (_collectionsTable != null)
|
||||
{
|
||||
await _collectionsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<MediaCollectionViewModel>> ServerReloadCollections(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.CollectionsPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
PagedMediaCollectionsViewModel data = await Mediator.Send(new GetPagedCollections(_searchString, state.Page, state.PageSize), cancellationToken);
|
||||
return new TableData<MediaCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
_searchString = query;
|
||||
_collectionsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
@page "/media/movies/{MovieId:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.Movies
|
||||
@using ErsatzTV.Extensions
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<Movie> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject PersistentComponentState ApplicationState
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
|
||||
<div class="fanart-tint"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(_movie?.FanArt))
|
||||
{
|
||||
if (_movie.FanArt.StartsWith("http://") || _movie.FanArt.StartsWith("https://"))
|
||||
{
|
||||
<img src="@_movie.FanArt" alt="fan art"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img src="@($"artwork/fanart/{_movie.FanArt}")" alt="fan art"/>
|
||||
}
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
|
||||
@if (!string.IsNullOrWhiteSpace(_movie?.Poster))
|
||||
{
|
||||
<div style="display: flex; flex-direction: column; max-height: 325px; position: relative" class="z-10">
|
||||
<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)
|
||||
{
|
||||
<div style="position: absolute; right: 10px; top: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
else if (_movie.MediaItemState == MediaItemState.Unavailable)
|
||||
{
|
||||
<div style="position: absolute; right: 10px; top: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%" class="z-10">
|
||||
<MudStack Row="false">
|
||||
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
|
||||
<MudText Typo="Typo.h4" Class="media-item-title">@_movie?.Title</MudText>
|
||||
</MudHidden>
|
||||
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
|
||||
<MudText Typo="Typo.h2" Class="media-item-title">@_movie?.Title</MudText>
|
||||
</MudHidden>
|
||||
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_movie?.Year</MudText>
|
||||
</MudStack>
|
||||
@if (!string.IsNullOrWhiteSpace(_movie?.Plot))
|
||||
{
|
||||
<MudCard Elevation="2" Class="mb-6">
|
||||
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
|
||||
<MudText Style="flex-grow: 1">@_movie.Plot</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
|
||||
<MudMenu Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Label="Add To ...">
|
||||
<MudMenuItem OnClick="@AddToCollection" Icon="@Icons.Material.Filled.Add">
|
||||
Add To Collection
|
||||
</MudMenuItem>
|
||||
<MudMenuItem OnClick="@AddToPlaylist" Icon="@Icons.Material.Filled.PlaylistAdd">
|
||||
Add To Playlist
|
||||
</MudMenuItem>
|
||||
</MudMenu>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Info"
|
||||
OnClick="@ShowInfo">
|
||||
Show Media Info
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Troubleshoot"
|
||||
Href="@($"system/troubleshooting/playback?mediaItem={MovieId}")">
|
||||
Troubleshoot Playback
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</div>
|
||||
</MudStack>
|
||||
@if (_movie?.MediaItemState == MediaItemState.FileNotFound)
|
||||
{
|
||||
<MudCard Class="mb-6">
|
||||
<MudCardContent>
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
|
||||
<MudText>File Not Found: </MudText>
|
||||
<MudText>@_movie.Path</MudText>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
else if (_movie?.MediaItemState == MediaItemState.Unavailable)
|
||||
{
|
||||
<MudCard Class="mb-6">
|
||||
<MudCardContent>
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
|
||||
<MudText>Unavailable: </MudText>
|
||||
<MudText>@_movie.LocalPath</MudText>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
<MudCard Class="mt-6 mb-6">
|
||||
<MudCardContent>
|
||||
@if (_sortedContentRatings.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Content Ratings: </MudText>
|
||||
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
|
||||
@foreach (string contentRating in _sortedContentRatings.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedLanguages.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Languages: </MudText>
|
||||
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head()</MudLink>
|
||||
@foreach (string language in _sortedLanguages.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"language:""{language.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedStudios.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Studios: </MudText>
|
||||
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
|
||||
@foreach (string studio in _sortedStudios.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedDirectors.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Directors: </MudText>
|
||||
<MudLink Href="@(@$"director:""{_sortedDirectors.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedDirectors.Head()</MudLink>
|
||||
@foreach (string director in _sortedDirectors.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"director:""{director.ToLowerInvariant()}""".GetRelativeSearchQuery())">@director</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedWriters.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Writers: </MudText>
|
||||
<MudLink Href="@(@$"writer:""{_sortedWriters.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedWriters.Head()</MudLink>
|
||||
@foreach (string writer in _sortedWriters.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"writer:""{writer.ToLowerInvariant()}""".GetRelativeSearchQuery())">@writer</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedGenres.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Genres: </MudText>
|
||||
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
|
||||
@foreach (string genre in _sortedGenres.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedTags.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Tags: </MudText>
|
||||
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
|
||||
@foreach (string tag in _sortedTags.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
@if (_movie is not null && _movie.Actors.Any())
|
||||
{
|
||||
<MudContainer MaxWidth="MaxWidth.Large">
|
||||
<MudText Class="mb-4">Actors</MudText>
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
@foreach (ActorCardViewModel actor in _movie.Actors)
|
||||
{
|
||||
<MediaCard Data="@actor"
|
||||
Href="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
|
||||
ArtworkKind="ArtworkKind.Thumbnail"/>
|
||||
}
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
[Parameter]
|
||||
public int MovieId { get; set; }
|
||||
|
||||
private MovieViewModel _movie;
|
||||
private List<string> _sortedContentRatings = new();
|
||||
private List<string> _sortedLanguages = new();
|
||||
private List<string> _sortedDirectors = new();
|
||||
private List<string> _sortedWriters = new();
|
||||
private List<string> _sortedStudios = new();
|
||||
private List<string> _sortedGenres = new();
|
||||
private List<string> _sortedTags = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
|
||||
|
||||
return base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
if (!ApplicationState.TryTakeFromJson("_movie", out MovieViewModel restored))
|
||||
{
|
||||
Option<MovieViewModel> maybeMovie = await Mediator.Send(new GetMovieById(MovieId), token);
|
||||
foreach (var movie in maybeMovie)
|
||||
{
|
||||
_movie = movie;
|
||||
}
|
||||
|
||||
if (maybeMovie.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("404");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_movie = restored;
|
||||
}
|
||||
|
||||
_sortedContentRatings = _movie?.ContentRatings.OrderBy(cr => cr).ToList();
|
||||
_sortedLanguages = _movie?.Languages.OrderBy(l => l).ToList();
|
||||
_sortedStudios = _movie?.Studios.OrderBy(s => s).ToList();
|
||||
_sortedGenres = _movie?.Genres.OrderBy(g => g).ToList();
|
||||
_sortedTags = _movie?.Tags.OrderBy(t => t).ToList();
|
||||
_sortedDirectors = _movie?.Directors.OrderBy(d => d).ToList();
|
||||
_sortedWriters = _movie?.Writers.OrderBy(w => w).ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private Task PersistData()
|
||||
{
|
||||
ApplicationState.PersistAsJson("_movie", _movie);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task AddToCollection()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", _movie.Title } };
|
||||
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 })
|
||||
{
|
||||
await Mediator.Send(new AddMovieToCollection(collection.Id, MovieId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToPlaylist()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", _movie.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
|
||||
{
|
||||
await Mediator.Send(new AddMovieToPlaylist(playlist.Id, MovieId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"media/playlists/{playlist.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowInfo()
|
||||
{
|
||||
Either<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(MovieId));
|
||||
|
||||
foreach (BaseError error in maybeInfo.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add("Unexpected error loading media info");
|
||||
Logger.LogError("Unexpected error loading media info: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemInfo info in maybeInfo.RightToSeq())
|
||||
{
|
||||
var parameters = new DialogParameters { { "MediaItemInfo", info } };
|
||||
var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
|
||||
IDialogReference dialog = await Dialog.ShowAsync<MediaItemInfoDialog>(_movie.Title, parameters, options);
|
||||
DialogResult _ = await dialog.Result;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetPosterUrl(string poster) => poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}";
|
||||
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
@page "/media/movies"
|
||||
@page "/media/movies/page/{PageNumber:int}"
|
||||
@inherits MultiSelectBase<MovieList>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject PersistentComponentState ApplicationState
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@implements IDisposable
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="MovieCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href="@($"media/movies/{context.MovieId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/movies"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private MovieCardResultsViewModel _data = new(0, new List<MovieCardViewModel>(), null);
|
||||
private string _query;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
return base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_data", out MovieCardResultsViewModel restored))
|
||||
{
|
||||
_data = await RefreshData(CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_data = restored;
|
||||
}
|
||||
}
|
||||
|
||||
private Task PersistData()
|
||||
{
|
||||
ApplicationState.PersistAsJson("_data", _data);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task<MovieCardResultsViewModel> RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:movie" : $"type:movie AND ({_query})";
|
||||
return await Mediator.Send(new QuerySearchIndexMovies(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/movies/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/movies/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is MovieCardViewModel movie)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", movie.Title } };
|
||||
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 AddMovieToCollection(collection.Id, movie.MovieId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding movie to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding movie to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {movie.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
@page "/media/multi-collections/{Id:int}/edit"
|
||||
@page "/media/multi-collections/add"
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<MultiCollectionEditor> Logger
|
||||
|
||||
<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" OnClick="@(_ => HandleSubmitAsync())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">
|
||||
@(IsEdit ? "Save Multi Collection" : "Add Multi Collection")
|
||||
</MudButton>
|
||||
</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">Multi Collection</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Multi-collection name is required!"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Collections</MudText>
|
||||
</div>
|
||||
<MudSelect @ref="_collectionSelect"
|
||||
T="MediaCollectionViewModel"
|
||||
@bind-value="_selectedCollection"
|
||||
HelperText="Disabled collections are already present in this multi collection">
|
||||
@foreach (MediaCollectionViewModel collection in _collections)
|
||||
{
|
||||
<MudSelectItem Disabled="@(_model.Items.Any(i => i.Collection.CollectionType == collection.CollectionType && i.Collection.Id == collection.Id))"
|
||||
Value="@collection">
|
||||
@collection.Name
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddCollection())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Collection
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Smart Collections</MudText>
|
||||
</div>
|
||||
<MudSelect @ref="_smartCollectionSelect"
|
||||
T="SmartCollectionViewModel"
|
||||
@bind-value="_selectedSmartCollection"
|
||||
HelperText="Disabled collections are already present in this multi collection">
|
||||
@foreach (SmartCollectionViewModel collection in _smartCollections)
|
||||
{
|
||||
<MudSelectItem Disabled="@(_model.Items.Any(i => i.Collection.CollectionType == CollectionType.SmartCollection && i.Collection.Id == collection.Id))"
|
||||
Value="@collection">
|
||||
@collection.Name
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddSmartCollection())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Smart Collection
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">@_model.Name Items</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true" Items="_model.Items.OrderBy(i => i.Collection.Name, StringComparer.CurrentCultureIgnoreCase)" Dense="true">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col style="width: 20%"/>
|
||||
<col style="width: 30%"/>
|
||||
<col style="width: 60px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Collection</MudTh>
|
||||
<MudTh>Schedule As Group</MudTh>
|
||||
<MudTh>Playback Order</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
@context.Collection.Name
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudCheckBox @bind-Value="@context.ScheduleAsGroup" For="@(() => context.ScheduleAsGroup)"/>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (context.ScheduleAsGroup)
|
||||
{
|
||||
@(context.Collection.UseCustomPlaybackOrder ? "Custom" : "Chronological")
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => RemoveCollection(context))">
|
||||
</MudIconButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly MultiCollectionEditViewModel _model =
|
||||
new() { Items = new List<MultiCollectionItemEditViewModel>() };
|
||||
|
||||
private MudForm _form;
|
||||
private bool _success;
|
||||
private List<MediaCollectionViewModel> _collections = new();
|
||||
private List<SmartCollectionViewModel> _smartCollections = new();
|
||||
private MediaCollectionViewModel _selectedCollection;
|
||||
private SmartCollectionViewModel _selectedSmartCollection;
|
||||
private MudSelect<MediaCollectionViewModel> _collectionSelect;
|
||||
private MudSelect<SmartCollectionViewModel> _smartCollectionSelect;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_collections = await Mediator.Send(new GetAllCollections(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
|
||||
_smartCollections = await Mediator.Send(new GetAllSmartCollections(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
|
||||
if (IsEdit)
|
||||
{
|
||||
Option<MultiCollectionViewModel> maybeCollection = await Mediator.Send(new GetMultiCollectionById(Id), token);
|
||||
maybeCollection.IfSome(collection =>
|
||||
{
|
||||
_model.Id = collection.Id;
|
||||
_model.Name = collection.Name;
|
||||
_model.Items = collection.Items
|
||||
.Map(item =>
|
||||
new MultiCollectionItemEditViewModel
|
||||
{
|
||||
Collection = item.Collection,
|
||||
ScheduleAsGroup = item.ScheduleAsGroup,
|
||||
PlaybackOrder = item.PlaybackOrder
|
||||
})
|
||||
.Append(
|
||||
collection.SmartItems.Map(item =>
|
||||
new MultiCollectionSmartItemEditViewModel
|
||||
{
|
||||
SmartCollection = item.SmartCollection,
|
||||
ScheduleAsGroup = item.ScheduleAsGroup,
|
||||
PlaybackOrder = item.PlaybackOrder
|
||||
})).ToList();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_model.Name = "New Multi Collection";
|
||||
_model.Items = new List<MultiCollectionItemEditViewModel>();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEdit => Id != 0;
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_success)
|
||||
{
|
||||
Seq<BaseError> errorMessage = IsEdit ? (await Mediator.Send(new UpdateMultiCollection(Id, _model.Name, GetUpdateItems()), _cts.Token)).LeftToSeq() : (await Mediator.Send(new CreateMultiCollection(_model.Name, GetCreateItems()), _cts.Token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Error saving collection: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("media/multi-collections"));
|
||||
}
|
||||
}
|
||||
|
||||
private List<UpdateMultiCollectionItem> GetUpdateItems() =>
|
||||
_model.Items.Map(i =>
|
||||
i switch
|
||||
{
|
||||
MultiCollectionSmartItemEditViewModel smartVm =>
|
||||
new UpdateMultiCollectionItem(
|
||||
null,
|
||||
smartVm.SmartCollection.Id,
|
||||
smartVm.ScheduleAsGroup,
|
||||
smartVm.PlaybackOrder),
|
||||
_ => new UpdateMultiCollectionItem(i.Collection.Id, null, i.ScheduleAsGroup, i.PlaybackOrder)
|
||||
}).ToList();
|
||||
|
||||
private List<CreateMultiCollectionItem> GetCreateItems() =>
|
||||
_model.Items.Map(i =>
|
||||
i switch
|
||||
{
|
||||
MultiCollectionSmartItemEditViewModel smartVm =>
|
||||
new CreateMultiCollectionItem(
|
||||
null,
|
||||
smartVm.SmartCollection.Id,
|
||||
smartVm.ScheduleAsGroup,
|
||||
smartVm.PlaybackOrder),
|
||||
_ => new CreateMultiCollectionItem(i.Collection.Id, null, i.ScheduleAsGroup, i.PlaybackOrder)
|
||||
}).ToList();
|
||||
|
||||
private void RemoveCollection(MultiCollectionItemEditViewModel item) => _model.Items.Remove(item);
|
||||
|
||||
private async Task AddCollection()
|
||||
{
|
||||
if (_selectedCollection != null && _model.Items.All(i => i.Collection != _selectedCollection))
|
||||
{
|
||||
_model.Items.Add(
|
||||
new MultiCollectionItemEditViewModel
|
||||
{
|
||||
Collection = _selectedCollection,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
});
|
||||
|
||||
_selectedCollection = null;
|
||||
await _collectionSelect.ResetAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSmartCollection()
|
||||
{
|
||||
if (_selectedSmartCollection != null && _model.Items.OfType<MultiCollectionSmartItemEditViewModel>().All(i => i.SmartCollection != _selectedSmartCollection))
|
||||
{
|
||||
_model.Items.Add(
|
||||
new MultiCollectionSmartItemEditViewModel
|
||||
{
|
||||
SmartCollection = _selectedSmartCollection,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
});
|
||||
|
||||
_selectedSmartCollection = null;
|
||||
await _smartCollectionSelect.ResetAsync();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
@page "/media/multi-collections"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@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%" class="ml-6 mr-6">
|
||||
<div style="margin-right: auto" class="d-none d-md-flex">
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.PlaylistAdd" Href="media/multi-collections/add">
|
||||
Add Multi Collection
|
||||
</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.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">Multi Collections</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
@bind-RowsPerPage="@_multiCollectionsRowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<MultiCollectionViewModel>>>(ServerReloadMultiCollections))"
|
||||
Dense="true"
|
||||
@ref="_multiCollectionsTable">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string"
|
||||
ValueChanged="@(s => OnSearch(s))"
|
||||
Placeholder="Search for multi collections"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.FilterList"
|
||||
Clearable="true">
|
||||
</MudTextField>
|
||||
</ToolBarContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"media/multi-collections/{context.Id}/edit")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteMultiCollection(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
<div class="mt-6"></div>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudTable<MultiCollectionViewModel> _multiCollectionsTable;
|
||||
|
||||
private int _multiCollectionsRowsPerPage = 10;
|
||||
private string _searchString;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_multiCollectionsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.MultiCollectionsPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteMultiCollection(MultiCollectionViewModel collection)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "multi collection" }, { "EntityName", collection.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Multi Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new DeleteMultiCollection(collection.Id), _cts.Token);
|
||||
if (_multiCollectionsTable != null)
|
||||
{
|
||||
await _multiCollectionsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<MultiCollectionViewModel>> ServerReloadMultiCollections(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.MultiCollectionsPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
PagedMultiCollectionsViewModel data = await Mediator.Send(new GetPagedMultiCollections(_searchString, state.Page, state.PageSize), cancellationToken);
|
||||
return new TableData<MultiCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
_searchString = query;
|
||||
_multiCollectionsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Shared;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using MudBlazor;
|
||||
|
||||
namespace ErsatzTV.Pages;
|
||||
|
||||
public class MultiSelectBase<T> : FragmentNavigationBase
|
||||
{
|
||||
private Option<MediaCardViewModel> _recentlySelected = None;
|
||||
|
||||
[Inject]
|
||||
protected IDialogService Dialog { get; set; }
|
||||
|
||||
[Inject]
|
||||
protected ISnackbar Snackbar { get; set; }
|
||||
|
||||
[Inject]
|
||||
protected ILogger<T> Logger { get; set; }
|
||||
|
||||
[Inject]
|
||||
protected IMediator Mediator { get; set; }
|
||||
|
||||
protected System.Collections.Generic.HashSet<MediaCardViewModel> SelectedItems { get; } = [];
|
||||
|
||||
protected bool IsSelected(MediaCardViewModel card) =>
|
||||
SelectedItems.Contains(card);
|
||||
|
||||
protected bool IsSelectMode() =>
|
||||
SelectedItems.Count != 0;
|
||||
|
||||
protected string SelectionLabel() =>
|
||||
$"{SelectedItems.Count} {(SelectedItems.Count == 1 ? "Item" : "Items")} Selected";
|
||||
|
||||
protected void SelectAllPageItems(IEnumerable<MediaCardViewModel> cards)
|
||||
{
|
||||
ResetSelectionWithCards(SelectedItems, cards);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected void ClearSelection()
|
||||
{
|
||||
SelectedItems.Clear();
|
||||
_recentlySelected = None;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected virtual Task RefreshData(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
protected void SelectClicked(
|
||||
Func<List<MediaCardViewModel>> getSortedItems,
|
||||
MediaCardViewModel card,
|
||||
MouseEventArgs e)
|
||||
{
|
||||
if (!SelectedItems.Remove(card))
|
||||
{
|
||||
if (e.ShiftKey && _recentlySelected.IsSome)
|
||||
{
|
||||
List<MediaCardViewModel> sorted = getSortedItems();
|
||||
|
||||
int start = sorted.IndexOf(_recentlySelected.ValueUnsafe());
|
||||
int finish = sorted.IndexOf(card);
|
||||
if (start > finish)
|
||||
{
|
||||
(start, finish) = (finish, start);
|
||||
}
|
||||
|
||||
for (int i = start; i < finish; i++)
|
||||
{
|
||||
SelectedItems.Add(sorted[i]);
|
||||
}
|
||||
}
|
||||
|
||||
_recentlySelected = card;
|
||||
SelectedItems.Add(card);
|
||||
}
|
||||
}
|
||||
|
||||
protected Task AddSelectionToCollection() => AddItemsToCollection(
|
||||
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
|
||||
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
|
||||
SelectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
|
||||
SelectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
|
||||
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
|
||||
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
|
||||
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
|
||||
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
|
||||
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList(),
|
||||
SelectedItems.OfType<RemoteStreamCardViewModel>().Map(i => i.RemoteStreamId).ToList());
|
||||
|
||||
protected Task AddSelectionToPlaylist() => AddItemsToPlaylist(
|
||||
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
|
||||
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
|
||||
SelectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
|
||||
SelectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
|
||||
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
|
||||
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
|
||||
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
|
||||
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
|
||||
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList(),
|
||||
SelectedItems.OfType<RemoteStreamCardViewModel>().Map(i => i.RemoteStreamId).ToList());
|
||||
|
||||
protected async Task AddItemsToCollection(
|
||||
List<int> movieIds,
|
||||
List<int> showIds,
|
||||
List<int> seasonIds,
|
||||
List<int> episodeIds,
|
||||
List<int> artistIds,
|
||||
List<int> musicVideoIds,
|
||||
List<int> otherVideoIds,
|
||||
List<int> songIds,
|
||||
List<int> imageIds,
|
||||
List<int> remoteStreamIds,
|
||||
string entityName = "selected items")
|
||||
{
|
||||
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
|
||||
musicVideoIds.Count + otherVideoIds.Count + songIds.Count + imageIds.Count;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{ { "EntityType", count.ToString(CultureInfo.InvariantCulture) }, { "EntityName", entityName } };
|
||||
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 AddItemsToCollection(
|
||||
collection.Id,
|
||||
movieIds,
|
||||
showIds,
|
||||
seasonIds,
|
||||
episodeIds,
|
||||
artistIds,
|
||||
musicVideoIds,
|
||||
otherVideoIds,
|
||||
songIds,
|
||||
imageIds,
|
||||
remoteStreamIds);
|
||||
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding items to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding items to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ =>
|
||||
{
|
||||
Snackbar.Add(
|
||||
$"Added {count} items to collection {collection.Name}",
|
||||
Severity.Success);
|
||||
ClearSelection();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task RemoveSelectionFromCollection(int collectionId)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "EntityType", SelectedItems.Count.ToString(CultureInfo.InvariantCulture) },
|
||||
{ "EntityName", "selected items" }
|
||||
};
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<RemoveFromCollectionDialog>(
|
||||
"Remove From Collection",
|
||||
parameters,
|
||||
options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
var itemIds = SelectedItems.Map(vm => vm.MediaItemId).ToList();
|
||||
|
||||
await Mediator.Send(
|
||||
new RemoveItemsFromCollection(collectionId)
|
||||
{
|
||||
MediaItemIds = itemIds
|
||||
},
|
||||
CancellationToken);
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
ClearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task AddItemsToPlaylist(
|
||||
List<int> movieIds,
|
||||
List<int> showIds,
|
||||
List<int> seasonIds,
|
||||
List<int> episodeIds,
|
||||
List<int> artistIds,
|
||||
List<int> musicVideoIds,
|
||||
List<int> otherVideoIds,
|
||||
List<int> songIds,
|
||||
List<int> imageIds,
|
||||
List<int> remoteStreamIds,
|
||||
string entityName = "selected items")
|
||||
{
|
||||
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
|
||||
musicVideoIds.Count + otherVideoIds.Count + songIds.Count + imageIds.Count;
|
||||
|
||||
var parameters = new DialogParameters
|
||||
{ { "EntityType", count.ToString(CultureInfo.InvariantCulture) }, { "EntityName", entityName } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog =
|
||||
await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
|
||||
{
|
||||
var request = new AddItemsToPlaylist(
|
||||
playlist.Id,
|
||||
movieIds,
|
||||
showIds,
|
||||
seasonIds,
|
||||
episodeIds,
|
||||
artistIds,
|
||||
musicVideoIds,
|
||||
otherVideoIds,
|
||||
songIds,
|
||||
imageIds,
|
||||
remoteStreamIds);
|
||||
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding items to playlist: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding items to playlist: {Error}", error.Value);
|
||||
},
|
||||
Right: _ =>
|
||||
{
|
||||
Snackbar.Add(
|
||||
$"Added {count} items to playlist {playlist.Name}",
|
||||
Severity.Success);
|
||||
ClearSelection();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ResetSelectionWithCards(
|
||||
ISet<MediaCardViewModel> selectedItems,
|
||||
IEnumerable<MediaCardViewModel> cards)
|
||||
{
|
||||
selectedItems.Clear();
|
||||
selectedItems.UnionWith(cards);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
@page "/media/music/videos"
|
||||
@page "/media/music/videos/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits MultiSelectBase<MusicVideoList>
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="MusicVideoCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/music/videos"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private MusicVideoCardResultsViewModel _data = new(0, new List<MusicVideoCardViewModel>(), null);
|
||||
private string _query;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:music_video" : $"type:music_video AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexMusicVideos(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/music/videos/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/music/videos/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is MusicVideoCardViewModel musicVideo)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
|
||||
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 AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding music video to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding music video to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
@page "/media/other/videos"
|
||||
@page "/media/other/videos/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits MultiSelectBase<OtherVideoList>
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="OtherVideoCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/other/videos"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private OtherVideoCardResultsViewModel _data = new(0, new List<OtherVideoCardViewModel>(), null);
|
||||
private string _query;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:other_video" : $"type:other_video AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexOtherVideos(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/other/videos/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/other/videos/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is OtherVideoCardViewModel otherVideo)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "other video" }, { "EntityName", otherVideo.Title } };
|
||||
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 AddOtherVideoToCollection(collection.Id, otherVideo.OtherVideoId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding other video to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding other video to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {otherVideo.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,606 +0,0 @@
|
||||
@page "/media/playlists/{Id:int}"
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@using ErsatzTV.Application.Search
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<PlaylistEditor> 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" Disabled="@(_playlist.IsSystem)">
|
||||
Save Playlist
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@AddPlaylistItem" StartIcon="@Icons.Material.Filled.PlaylistAdd" Disabled="@(_playlist.IsSystem)">
|
||||
Add Playlist Item
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Secondary" OnClick="@PreviewPlayout" StartIcon="@Icons.Material.Filled.Preview">
|
||||
Preview Playlist 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 Playlist" OnClick="@SaveChanges" Disabled="@(_playlist.IsSystem)"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Playlist Item" OnClick="@AddPlaylistItem" Disabled="@(_playlist.IsSystem)"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Preview" Label="Preview Playlist 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">Playlist</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_playlist.Name" For="@(() => _playlist.Name)" Disabled="@(_playlist.IsSystem)"/>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playlist Items</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true" Items="_playlist.Items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem" RowClassFunc="@SelectedRowClassFunc">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 240px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Item Type</MudTh>
|
||||
<MudTh>Item</MudTh>
|
||||
<MudTh>Playback Order</MudTh>
|
||||
<MudTh>Count</MudTh>
|
||||
<MudTh>Play All</MudTh>
|
||||
<MudTh>Show In EPG</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Item Type">
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@context.ItemType
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Item">
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@context.ItemName
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Playback Order">
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@(context.PlaybackOrder > 0 ? context.PlaybackOrder : string.Empty)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Count">
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@(context.Count is not null ? context.Count : (context.PlayAll ? string.Empty : "1"))
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Play All">
|
||||
<MudCheckBox T="bool" Value="@context.PlayAll" ValueChanged="@(e => UpdatePlayAll(context, e))" Disabled="@(_playlist.IsSystem)"/>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Show In EPG">
|
||||
<MudCheckBox T="bool" Value="@context.IncludeInProgramGuide" ValueChanged="@(e => UpdateEPG(context, e))" Disabled="@(_playlist.IsSystem)"/>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div class="d-flex">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
OnClick="@(_ => CopyItem(context))"
|
||||
Disabled="@(_playlist.IsSystem)">
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
||||
OnClick="@(_ => MoveItemUp(context))"
|
||||
Disabled="@(_playlist.IsSystem || _playlist.Items.All(x => x.Index >= context.Index))">
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
||||
OnClick="@(_ => MoveItemDown(context))"
|
||||
Disabled="@(_playlist.IsSystem || _playlist.Items.All(x => x.Index <= context.Index))">
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => RemovePlaylistItem(context))"
|
||||
Disabled="@(_playlist.IsSystem)">
|
||||
</MudIconButton>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (!_playlist.IsSystem && _selectedItem is not null)
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playlist 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="CollectionType.Artist">Artist</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.Movie">Movie</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.Episode">Episode</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.MusicVideo">Music Video</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.OtherVideo">Other Video</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.Song">Song</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.Image">Image</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>
|
||||
}
|
||||
|
||||
@if (_selectedItem.CollectionType == CollectionType.Artist)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Artist</MudText>
|
||||
</div>
|
||||
<MudAutocomplete T="NamedMediaItemViewModel"
|
||||
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchArtists"
|
||||
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
|
||||
MaxItems="10">
|
||||
<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.Movie)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Movie</MudText>
|
||||
</div>
|
||||
<MudAutocomplete T="NamedMediaItemViewModel"
|
||||
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchMovies"
|
||||
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
|
||||
MaxItems="10">
|
||||
<MoreItemsTemplate>
|
||||
<MudText Align="Align.Center" Class="px-4 py-1">
|
||||
Only the first 10 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)"
|
||||
Disabled="@(_selectedItem.CollectionType is CollectionType.Movie or CollectionType.Episode or CollectionType.MusicVideo or CollectionType.OtherVideo or CollectionType.Song or CollectionType.Image)">
|
||||
@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.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>Count</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="@_selectedItem.Count" For="@(() => _selectedItem.Count)"/>
|
||||
</MudStack>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(_previewMessage))
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudAlert Severity="Severity.Warning">@_previewMessage</MudAlert>
|
||||
}
|
||||
else if (_previewItems is not null)
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
Dense="true"
|
||||
Items="_previewItems">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Playlist Playout Preview</MudText>
|
||||
</ToolBarContent>
|
||||
<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 PlaylistItemsEditViewModel _playlist = new() { Items = [] };
|
||||
private PlaylistItemEditViewModel _selectedItem;
|
||||
private string _previewMessage;
|
||||
private List<PlayoutItemPreviewViewModel> _previewItems;
|
||||
|
||||
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<PlaylistViewModel> maybePlaylist = await Mediator.Send(new GetPlaylistById(Id), token);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("media/playlists");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (PlaylistViewModel playlist in maybePlaylist)
|
||||
{
|
||||
_playlist = new PlaylistItemsEditViewModel
|
||||
{
|
||||
Name = playlist.Name,
|
||||
IsSystem = playlist.IsSystem,
|
||||
Items = []
|
||||
};
|
||||
}
|
||||
|
||||
Option<IEnumerable<PlaylistItemViewModel>> maybeResults = await Mediator.Send(new GetPlaylistItems(Id), token);
|
||||
foreach (IEnumerable<PlaylistItemViewModel> items in maybeResults)
|
||||
{
|
||||
_playlist.Items.AddRange(items.Map(ProjectToEditViewModel));
|
||||
if (_playlist.Items.Count == 1)
|
||||
{
|
||||
_selectedItem = _playlist.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 async Task<IEnumerable<NamedMediaItemViewModel>> SearchMovies(string value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new List<NamedMediaItemViewModel>();
|
||||
}
|
||||
|
||||
return await Mediator.Send(new SearchMovies(value), _cts.Token);
|
||||
}
|
||||
|
||||
private static PlaylistItemEditViewModel ProjectToEditViewModel(PlaylistItemViewModel 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,
|
||||
Count = item.Count,
|
||||
PlayAll = item.PlayAll,
|
||||
IncludeInProgramGuide = item.IncludeInProgramGuide
|
||||
};
|
||||
|
||||
private void AddPlaylistItem()
|
||||
{
|
||||
var item = new PlaylistItemEditViewModel
|
||||
{
|
||||
Index = _playlist.Items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
CollectionType = CollectionType.Collection
|
||||
};
|
||||
|
||||
_playlist.Items.Add(item);
|
||||
_selectedItem = item;
|
||||
}
|
||||
|
||||
private void CopyItem(PlaylistItemEditViewModel item)
|
||||
{
|
||||
var newItem = new PlaylistItemEditViewModel
|
||||
{
|
||||
Index = item.Index + 1,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
CollectionType = item.CollectionType,
|
||||
Collection = item.Collection,
|
||||
MultiCollection = item.MultiCollection,
|
||||
SmartCollection = item.SmartCollection,
|
||||
MediaItem = item.MediaItem,
|
||||
Count = item.Count,
|
||||
PlayAll = item.PlayAll,
|
||||
IncludeInProgramGuide = item.IncludeInProgramGuide
|
||||
};
|
||||
|
||||
foreach (PlaylistItemEditViewModel i in _playlist.Items.Filter(bi => bi.Index >= newItem.Index))
|
||||
{
|
||||
i.Index += 1;
|
||||
}
|
||||
|
||||
_playlist.Items.Add(newItem);
|
||||
_selectedItem = newItem;
|
||||
}
|
||||
|
||||
private void RemovePlaylistItem(PlaylistItemEditViewModel item)
|
||||
{
|
||||
_selectedItem = null;
|
||||
_playlist.Items.Remove(item);
|
||||
}
|
||||
|
||||
private void MoveItemUp(PlaylistItemEditViewModel item)
|
||||
{
|
||||
// swap with lower index
|
||||
PlaylistItemEditViewModel toSwap = _playlist.Items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
|
||||
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
|
||||
}
|
||||
|
||||
private void MoveItemDown(PlaylistItemEditViewModel item)
|
||||
{
|
||||
// swap with higher index
|
||||
PlaylistItemEditViewModel toSwap = _playlist.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 playlist: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving playlist: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("media/playlists"));
|
||||
}
|
||||
|
||||
private ReplacePlaylistItems GenerateReplaceRequest()
|
||||
{
|
||||
var items = _playlist.Items.Map(item => new ReplacePlaylistItem(
|
||||
item.Index,
|
||||
item.CollectionType,
|
||||
item.Collection?.Id,
|
||||
item.MultiCollection?.Id,
|
||||
item.SmartCollection?.Id,
|
||||
item.MediaItem?.MediaItemId,
|
||||
item.PlaybackOrder,
|
||||
item.Count,
|
||||
item.PlayAll,
|
||||
item.IncludeInProgramGuide)).ToList();
|
||||
|
||||
return new ReplacePlaylistItems(Id, _playlist.Name, items);
|
||||
}
|
||||
|
||||
private async Task PreviewPlayout()
|
||||
{
|
||||
_selectedItem = null;
|
||||
_previewMessage = null;
|
||||
|
||||
Either<BaseError, List<PlayoutItemPreviewViewModel>> result = await Mediator.Send(new PreviewPlaylistPlayout(GenerateReplaceRequest()), _cts.Token);
|
||||
foreach (var error in result.LeftToSeq())
|
||||
{
|
||||
_previewMessage = error.ToString();
|
||||
}
|
||||
|
||||
foreach (List<PlayoutItemPreviewViewModel> items in result.RightToSeq())
|
||||
{
|
||||
_previewItems = items;
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateEPG(PlaylistItemEditViewModel context, bool includeInProgramGuide) => context.IncludeInProgramGuide = includeInProgramGuide;
|
||||
|
||||
private static void UpdatePlayAll(PlaylistItemEditViewModel context, bool playAll) => context.PlayAll = playAll;
|
||||
|
||||
private string SelectedRowClassFunc(PlaylistItemEditViewModel element, int rowNumber) => _selectedItem != null && _selectedItem == element ? "selected" : string.Empty;
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
@page "/media/playlists"
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Tree
|
||||
@implements IDisposable
|
||||
@inject ILogger<Playlists> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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">Playlist Groups</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>Playlist Group Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_playlistGroupName" For="@(() => _playlistGroupName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlaylistGroup())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Playlist Group
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playlists</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>Playlist Group</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_selectedPlaylistGroup">
|
||||
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
|
||||
{
|
||||
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Playlist Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_playlistName" For="@(() => _playlistName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlaylist())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Playlist
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudTreeView T="PlaylistTreeItemViewModel" Items="@_treeItems" Hover="true" Style="width: 100%">
|
||||
<ItemTemplate Context="item">
|
||||
<MudTreeViewItem T="PlaylistTreeItemViewModel" Items="@item.Value!.TreeItems" Icon="@item.Value.Icon" Value="@item.Value">
|
||||
<BodyContent>
|
||||
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
|
||||
<MudGrid Justify="Justify.FlexStart">
|
||||
<MudItem xs="5">
|
||||
<MudText>@item.Value.Text</MudText>
|
||||
</MudItem>
|
||||
@if (!string.IsNullOrWhiteSpace(item.Value.EndText))
|
||||
{
|
||||
<MudItem xs="6">
|
||||
<MudText>@item.Value.EndText</MudText>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
<div style="justify-self: end;">
|
||||
@foreach (int playlistId in Optional(item.Value.PlaylistId))
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Href="@($"media/playlists/{playlistId}")"/>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Size="Size.Medium"
|
||||
Color="Color.Inherit"
|
||||
Disabled="@item.Value.IsSystem"
|
||||
OnClick="@(_ => DeleteItem(item.Value))"/>
|
||||
</div>
|
||||
</div>
|
||||
</BodyContent>
|
||||
</MudTreeViewItem>
|
||||
</ItemTemplate>
|
||||
</MudTreeView>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly List<TreeItemData<PlaylistTreeItemViewModel>> _treeItems = [];
|
||||
private List<PlaylistGroupViewModel> _playlistGroups = [];
|
||||
private PlaylistGroupViewModel _selectedPlaylistGroup;
|
||||
private string _playlistGroupName;
|
||||
private string _playlistName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await ReloadPlaylistTree(token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadPlaylistTree(CancellationToken cancellationToken)
|
||||
{
|
||||
_playlistGroups = await Mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
|
||||
|
||||
_treeItems.Clear();
|
||||
TreeViewModel tree = await Mediator.Send(new GetPlaylistTree(), cancellationToken);
|
||||
foreach (TreeGroupViewModel group in tree.Groups)
|
||||
{
|
||||
_treeItems.Add(new TreeItemData<PlaylistTreeItemViewModel> { Value = new PlaylistTreeItemViewModel(group) });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddPlaylistGroup()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_playlistGroupName))
|
||||
{
|
||||
Either<BaseError, PlaylistGroupViewModel> result = await Mediator.Send(new CreatePlaylistGroup(_playlistGroupName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding playlist group: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (PlaylistGroupViewModel _ in result.RightToSeq())
|
||||
{
|
||||
await ReloadPlaylistTree(_cts.Token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddPlaylist()
|
||||
{
|
||||
if (_selectedPlaylistGroup is not null && !string.IsNullOrWhiteSpace(_playlistName))
|
||||
{
|
||||
Either<BaseError, PlaylistViewModel> result = await Mediator.Send(new CreatePlaylist(_selectedPlaylistGroup.Id, _playlistName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding playlist: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (PlaylistViewModel playlist in result.RightToSeq())
|
||||
{
|
||||
foreach (PlaylistTreeItemViewModel item in _treeItems.Map(i => i.Value).Where(item => item.PlaylistGroupId == _selectedPlaylistGroup.Id))
|
||||
{
|
||||
item.TreeItems.Add(new TreeItemData<PlaylistTreeItemViewModel> { Value = new PlaylistTreeItemViewModel(playlist) });
|
||||
}
|
||||
|
||||
_playlistName = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteItem(PlaylistTreeItemViewModel treeItem)
|
||||
{
|
||||
foreach (int playlistGroupId in Optional(treeItem.PlaylistGroupId))
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "playlist group" }, { "EntityName", treeItem.Text } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playlist Group", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
Option<BaseError> deleteResult = await Mediator.Send(new DeletePlaylistGroup(playlistGroupId), _cts.Token);
|
||||
foreach (BaseError error in deleteResult)
|
||||
{
|
||||
Snackbar.Add(error.ToString(), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_treeItems.RemoveAll(i => i.Value?.PlaylistGroupId == playlistGroupId);
|
||||
if (_selectedPlaylistGroup?.Id == playlistGroupId)
|
||||
{
|
||||
_selectedPlaylistGroup = null;
|
||||
}
|
||||
|
||||
_playlistGroups = await Mediator.Send(new GetAllPlaylistGroups(), _cts.Token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int playlistId in Optional(treeItem.PlaylistId))
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "playlist" }, { "EntityName", treeItem.Text } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playlist", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
Option<BaseError> deleteResult = await Mediator.Send(new DeletePlaylist(playlistId), _cts.Token);
|
||||
foreach (BaseError error in deleteResult)
|
||||
{
|
||||
Snackbar.Add(error.ToString(), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (PlaylistTreeItemViewModel parent in _treeItems.Map(i => i.Value))
|
||||
{
|
||||
parent.TreeItems.RemoveAll(i => i.Value == treeItem);
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
@page "/playouts/{Id:int}/alternate-schedules"
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Playouts
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Core.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ScheduleItemsEditor> 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 Alternate Schedules
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddAlternateSchedule())" StartIcon="@Icons.Material.Filled.PlaylistAdd">
|
||||
Add Alternate Schedule
|
||||
</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 Alternate Schedules" OnClick="@SaveChanges"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Alternate Schedule" OnClick="AddAlternateSchedule"/>
|
||||
</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">@_channelName - Content</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudText Typo="Typo.body2" Class="mb-6">In priority order from top to bottom. The bottom entry will *always* match all days and all months, as a catch-all.</MudText>
|
||||
<MudTable Hover="true" Items="_items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem" RowClassFunc="@SelectedRowClassFunc">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 180px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Schedule</MudTh>
|
||||
<MudTh>Days of the Week</MudTh>
|
||||
<MudTh>Days of the Month</MudTh>
|
||||
<MudTh>Months</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@context.ProgramSchedule.Name
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToDaysOfWeekString(context.DaysOfWeek)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToDaysOfMonthString(context.DaysOfMonth)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToMonthsOfYearString(context.MonthsOfYear)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div class="d-flex">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
||||
OnClick="@(_ => MoveItemUp(context))"
|
||||
Disabled="@(_items.All(x => x.Index >= context.Index))">
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
||||
OnClick="@(_ => MoveItemDown(context))"
|
||||
Disabled="@(_items.All(x => x.Index <= context.Index))">
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => RemoveAlternateSchedule(context))"
|
||||
Disabled="@(_items.Count == 1)">
|
||||
</MudIconButton>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_selectedItem is not null)
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Edit Content</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>Schedule</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_selectedItem.ProgramSchedule" For="@(() => _selectedItem.ProgramSchedule)">
|
||||
@foreach (ProgramScheduleViewModel schedule in _schedules)
|
||||
{
|
||||
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Limit to Date Range</MudText>
|
||||
</div>
|
||||
<MudCheckBox T="bool" @bind-Value="_selectedItem.LimitToDateRange" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Start Day</MudText>
|
||||
</div>
|
||||
<MudSelect T="int" @bind-Value="_selectedItem.StartMonth" Disabled="@(!_selectedItem.LimitToDateRange)">
|
||||
@foreach (int month in Enumerable.Range(1, 12))
|
||||
{
|
||||
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_selectedItem.StartDay" Disabled="@(!_selectedItem.LimitToDateRange)">
|
||||
@foreach (int day in Enumerable.Range(1, 31))
|
||||
{
|
||||
<MudSelectItem Value="@day">@day.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_selectedItem.StartYear" Disabled="@(!_selectedItem.LimitToDateRange)" Clearable="true">
|
||||
@foreach (int year in Enumerable.Range(DateTime.Today.Year, 50))
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@year">@year.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>End Day</MudText>
|
||||
</div>
|
||||
<MudSelect T="int" @bind-Value="_selectedItem.EndMonth" Disabled="@(!_selectedItem.LimitToDateRange)">
|
||||
@foreach (int month in Enumerable.Range(1, 12))
|
||||
{
|
||||
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_selectedItem.EndDay" Disabled="@(!_selectedItem.LimitToDateRange)">
|
||||
@foreach (int day in Enumerable.Range(1, 31))
|
||||
{
|
||||
<MudSelectItem Value="@day">@day.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_selectedItem.EndYear" Disabled="@(!_selectedItem.LimitToDateRange)" Clearable="true">
|
||||
@foreach (int year in Enumerable.Range(DateTime.Today.Year, 50))
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@year">@year.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Days of the Week</MudText>
|
||||
</div>
|
||||
<MudSelect T="DayOfWeek" SelectedValues="@_selectedItem.DaysOfWeek" SelectedValuesChanged="OnSelectedDaysChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Days">
|
||||
<MudSelectItem Value="@DayOfWeek.Monday">@_dtf.GetDayName(DayOfWeek.Monday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Tuesday">@_dtf.GetDayName(DayOfWeek.Tuesday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Wednesday">@_dtf.GetDayName(DayOfWeek.Wednesday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Thursday">@_dtf.GetDayName(DayOfWeek.Thursday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Friday">@_dtf.GetDayName(DayOfWeek.Friday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Saturday">@_dtf.GetDayName(DayOfWeek.Saturday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Sunday">@_dtf.GetDayName(DayOfWeek.Sunday)</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => SelectWeekdays())">
|
||||
Weekdays
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => SelectWeekends())">
|
||||
Weekends
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Days of the Month</MudText>
|
||||
</div>
|
||||
<MudSelect T="int" SelectedValues="@_selectedItem.DaysOfMonth" SelectedValuesChanged="OnSelectedDaysChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Days">
|
||||
@foreach (int day in Enumerable.Range(1, 31))
|
||||
{
|
||||
<MudSelectItem Value="@day">@day</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Months</MudText>
|
||||
</div>
|
||||
<MudSelect T="int" SelectedValues="@_selectedItem.MonthsOfYear" SelectedValuesChanged="OnSelectedMonthsChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Months">
|
||||
@foreach (int month in Enumerable.Range(1, 12))
|
||||
{
|
||||
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private string _channelName;
|
||||
private List<PlayoutAlternateScheduleEditViewModel> _items = [];
|
||||
private List<ProgramScheduleViewModel> _schedules = [];
|
||||
|
||||
private PlayoutAlternateScheduleEditViewModel _selectedItem;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_schedules = await Mediator.Send(new GetAllProgramSchedules(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
|
||||
_channelName = (await Mediator.Send(new GetChannelNameByPlayoutId(Id), token)).IfNone(string.Empty);
|
||||
|
||||
List<PlayoutAlternateScheduleViewModel> results = await Mediator.Send(new GetPlayoutAlternateSchedules(Id), token);
|
||||
_items = results.Map(ProjectToEditViewModel).ToList();
|
||||
if (_items.Count == 1)
|
||||
{
|
||||
_selectedItem = _items.Head();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private PlayoutAlternateScheduleEditViewModel ProjectToEditViewModel(PlayoutAlternateScheduleViewModel item) =>
|
||||
new()
|
||||
{
|
||||
Id = item.Id,
|
||||
Index = item.Index,
|
||||
ProgramSchedule = _schedules.Find(vm => vm.Id == item.ProgramScheduleId),
|
||||
DaysOfWeek = item.DaysOfWeek.OrderBy(x => ((int)x + 6) % 7).ToList(),
|
||||
DaysOfMonth = item.DaysOfMonth.ToList(),
|
||||
MonthsOfYear = item.MonthsOfYear.ToList(),
|
||||
LimitToDateRange = item.LimitToDateRange,
|
||||
StartMonth = item.StartMonth,
|
||||
StartDay = item.StartDay,
|
||||
StartYear = item.StartYear,
|
||||
EndMonth = item.EndMonth,
|
||||
EndDay = item.EndDay,
|
||||
EndYear = item.EndYear
|
||||
};
|
||||
|
||||
private void SelectWeekdays()
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Clear();
|
||||
_selectedItem.DaysOfWeek.AddRange(
|
||||
[
|
||||
DayOfWeek.Monday,
|
||||
DayOfWeek.Tuesday,
|
||||
DayOfWeek.Wednesday,
|
||||
DayOfWeek.Thursday,
|
||||
DayOfWeek.Friday
|
||||
]);
|
||||
}
|
||||
|
||||
private void SelectWeekends()
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Clear();
|
||||
_selectedItem.DaysOfWeek.AddRange(
|
||||
[
|
||||
DayOfWeek.Saturday,
|
||||
DayOfWeek.Sunday
|
||||
]);
|
||||
}
|
||||
|
||||
private void AddAlternateSchedule()
|
||||
{
|
||||
var item = new PlayoutAlternateScheduleEditViewModel
|
||||
{
|
||||
Index = _items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
|
||||
ProgramSchedule = _schedules.Head(),
|
||||
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear(),
|
||||
LimitToDateRange = false,
|
||||
StartMonth = 1,
|
||||
StartDay = 1,
|
||||
StartYear = null,
|
||||
EndMonth = 1,
|
||||
EndDay = 1,
|
||||
EndYear = null
|
||||
};
|
||||
|
||||
_items.Add(item);
|
||||
_selectedItem = item;
|
||||
}
|
||||
|
||||
private void RemoveAlternateSchedule(PlayoutAlternateScheduleEditViewModel item)
|
||||
{
|
||||
_selectedItem = null;
|
||||
_items.Remove(item);
|
||||
}
|
||||
|
||||
private void MoveItemUp(PlayoutAlternateScheduleEditViewModel item)
|
||||
{
|
||||
// swap with lower index
|
||||
PlayoutAlternateScheduleEditViewModel toSwap = _items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
|
||||
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
|
||||
}
|
||||
|
||||
private void MoveItemDown(PlayoutAlternateScheduleEditViewModel item)
|
||||
{
|
||||
// swap with higher index
|
||||
PlayoutAlternateScheduleEditViewModel toSwap = _items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
|
||||
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
|
||||
}
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
var items = _items.Map(item => new ReplacePlayoutAlternateSchedule(
|
||||
item.Id,
|
||||
item.Index,
|
||||
item.ProgramSchedule.Id,
|
||||
item.DaysOfWeek,
|
||||
item.DaysOfMonth,
|
||||
item.MonthsOfYear,
|
||||
item.LimitToDateRange,
|
||||
item.StartMonth,
|
||||
item.StartDay,
|
||||
item.StartYear,
|
||||
item.EndMonth,
|
||||
item.EndDay,
|
||||
item.EndYear)).ToList();
|
||||
|
||||
Seq<BaseError> errorMessages = await Mediator.Send(new ReplacePlayoutAlternateScheduleItems(Id, items), _cts.Token)
|
||||
.Map(e => e.LeftToSeq());
|
||||
|
||||
errorMessages.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving alternate schedules: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving alternate schedules: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("playouts"));
|
||||
}
|
||||
|
||||
private string ToDaysOfWeekString(List<DayOfWeek> daysOfWeek)
|
||||
{
|
||||
if (daysOfWeek.Count is 0 or 7)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
daysOfWeek.Sort();
|
||||
|
||||
return string.Join(", ", daysOfWeek.Map(_dtf.GetAbbreviatedDayName));
|
||||
}
|
||||
|
||||
private string ToDaysOfMonthString(List<int> daysOfMonth)
|
||||
{
|
||||
if (daysOfMonth.Count is 0 or 31)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
return ToRangeString(daysOfMonth);
|
||||
}
|
||||
|
||||
private string ToMonthsOfYearString(List<int> monthsOfYear)
|
||||
{
|
||||
if (monthsOfYear.Count is 0 or 12)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
monthsOfYear.Sort();
|
||||
|
||||
return string.Join(", ", monthsOfYear.Map(_dtf.GetAbbreviatedMonthName));
|
||||
}
|
||||
|
||||
private static string ToRangeString(List<int> list)
|
||||
{
|
||||
list = list.Distinct().ToList();
|
||||
list.Sort();
|
||||
|
||||
var result = new StringBuilder();
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
int temp = list[i];
|
||||
|
||||
//add a number
|
||||
result.Append(list[i]);
|
||||
|
||||
//skip number(s) between a range
|
||||
while (i < list.Count - 1 && list[i + 1] == list[i] + 1)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
//add the range
|
||||
if (temp != list[i])
|
||||
{
|
||||
result.Append("-").Append(list[i]);
|
||||
}
|
||||
|
||||
//add comma
|
||||
if (i != list.Count - 1)
|
||||
{
|
||||
result.Append(", ");
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private string SelectedRowClassFunc(PlayoutAlternateScheduleEditViewModel element, int rowNumber) => _selectedItem != null && _selectedItem == element ? "selected" : string.Empty;
|
||||
|
||||
private void OnSelectedDaysChanged(IEnumerable<DayOfWeek> selectedDays)
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Clear();
|
||||
_selectedItem.DaysOfWeek.AddRange(selectedDays);
|
||||
}
|
||||
|
||||
private void OnSelectedDaysChanged(IEnumerable<int> selectedDays)
|
||||
{
|
||||
_selectedItem.DaysOfMonth.Clear();
|
||||
_selectedItem.DaysOfMonth.AddRange(selectedDays);
|
||||
}
|
||||
|
||||
private void OnSelectedMonthsChanged(IEnumerable<int> selectedMonths)
|
||||
{
|
||||
_selectedItem.MonthsOfYear.Clear();
|
||||
_selectedItem.MonthsOfYear.AddRange(selectedMonths);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
@page "/playouts/add"
|
||||
@page "/playouts/add/{kind}"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Validators
|
||||
@using FluentValidation.Results
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<PlayoutEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudForm @ref="_form" Validation="@(_validator.ValidateValue)" ValidationDelay="0" Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
|
||||
<MudButton Class="ml-6" OnClick="@HandleSubmitAsync" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Playout
|
||||
</MudButton>
|
||||
</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">Playout</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>Channel</MudText>
|
||||
</div>
|
||||
<MudSelect T="ChannelViewModel" @bind-value="_model.Channel" HelperText="Disabled channels already have a playout">
|
||||
@foreach (ChannelViewModel channel in _channels)
|
||||
{
|
||||
<MudSelectItem Disabled="@(channel.PlayoutCount > 0)" Value="@channel">
|
||||
@($"{channel.Number} - {channel.Name}")
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@switch (Kind)
|
||||
{
|
||||
case PlayoutKind.ExternalJson:
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>JSON (dizqueTV) Schedule</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.ScheduleFile" For="@(() => _model.ScheduleFile)"/>
|
||||
</MudStack>
|
||||
break;
|
||||
case PlayoutKind.Sequential:
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Sequential Schedule</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.ScheduleFile" For="@(() => _model.ScheduleFile)" HelperText="The full path to the sequential schedule (YAML) file" />
|
||||
</MudStack>
|
||||
break;
|
||||
case PlayoutKind.Scripted:
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Scripted Schedule</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.ScheduleFile" For="@(() => _model.ScheduleFile)"/>
|
||||
</MudStack>
|
||||
break;
|
||||
case PlayoutKind.Block:
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudText Typo="Typo.caption" Style="font-weight: normal">Block templates are added later</MudText>
|
||||
</MudStack>
|
||||
break;
|
||||
default:
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Classic Schedule</MudText>
|
||||
</div>
|
||||
<MudSelect T="ProgramScheduleViewModel" @bind-value="_model.ProgramSchedule">
|
||||
@foreach (ProgramScheduleViewModel schedule in _programSchedules)
|
||||
{
|
||||
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
break;
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private readonly PlayoutEditViewModel _model = new();
|
||||
private readonly PlayoutEditViewModelValidator _validator = new();
|
||||
private MudForm _form;
|
||||
|
||||
private List<ChannelViewModel> _channels = [];
|
||||
private List<ProgramScheduleViewModel> _programSchedules = [];
|
||||
|
||||
[Parameter]
|
||||
public string Kind { get; set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_model.Kind = Kind;
|
||||
|
||||
_channels = await Mediator.Send(new GetAllChannels(), token)
|
||||
.Map(list => list.OrderBy(vm => decimal.Parse(vm.Number, CultureInfo.InvariantCulture)).ToList());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Kind))
|
||||
{
|
||||
_programSchedules = await Mediator.Send(new GetAllProgramSchedules(), token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name).ToList());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
ValidationResult result = await _validator.ValidateAsync(_model, _cts.Token);
|
||||
if (result.IsValid)
|
||||
{
|
||||
Seq<BaseError> errorMessage = (await Mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving playout: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("playouts"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
@page "/playouts/{Id:int}/templates"
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@using ErsatzTV.Core.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<PlayoutTemplatesEditor> 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 Templates
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddTemplate())" StartIcon="@Icons.Material.Filled.PlaylistAdd">
|
||||
Add Template
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => PreviewCalendar())" StartIcon="@Icons.Material.Filled.Preview">
|
||||
Preview Calendar
|
||||
</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 Templates" OnClick="@SaveChanges"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add Template" OnClick="AddTemplate"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Preview" Label="Preview Calendar" OnClick="PreviewCalendar"/>
|
||||
</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">@_channelName - Templates</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudText Typo="Typo.body2" Class="mb-6">In priority order from top to bottom.</MudText>
|
||||
<MudTable T="PlayoutTemplateEditViewModel" Class="mt-4" Hover="true" Items="_items.OrderBy(i => i.Index)" Dense="true" SelectedItem="@_selectedItem" SelectedItemChanged="@(vm => SelectedItemChanged(vm))" RowClassFunc="@SelectedRowClassFunc">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 180px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Template</MudTh>
|
||||
<MudTh>Days of the Week</MudTh>
|
||||
<MudTh>Days of the Month</MudTh>
|
||||
<MudTh>Months</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@context.Template?.Name
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToDaysOfWeekString(context.DaysOfWeek)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToDaysOfMonthString(context.DaysOfMonth)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToMonthsOfYearString(context.MonthsOfYear)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div class="d-flex">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
||||
OnClick="@(_ => MoveItemUp(context))"
|
||||
Disabled="@(_items.All(x => x.Index >= context.Index))">
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
||||
OnClick="@(_ => MoveItemDown(context))"
|
||||
Disabled="@(_items.All(x => x.Index <= context.Index))">
|
||||
</MudIconButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteTemplate(context))">
|
||||
</MudIconButton>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_selectedItem is not null)
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Edit Template</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>Template Group</MudText>
|
||||
</div>
|
||||
<MudSelect T="TemplateGroupViewModel" Value="@_selectedGroup" ValueChanged="@(vm => UpdateTemplateGroupItems(vm))">
|
||||
@foreach (TemplateGroupViewModel templateGroup in _templateGroups)
|
||||
{
|
||||
<MudSelectItem Value="@templateGroup">
|
||||
@templateGroup.Name
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Template</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_selectedItem.Template" For="@(() => _selectedItem.Template)">
|
||||
@foreach (TemplateViewModel template in _templates)
|
||||
{
|
||||
<MudSelectItem Value="@template">@template.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco Template Group</MudText>
|
||||
</div>
|
||||
<MudSelect T="DecoTemplateGroupViewModel" Value="@_selectedDecoTemplateGroup" ValueChanged="@(vm => UpdateDecoTemplateGroupItems(vm))">
|
||||
@foreach (DecoTemplateGroupViewModel decoTemplateGroup in _decoTemplateGroups)
|
||||
{
|
||||
<MudSelectItem Value="@decoTemplateGroup">
|
||||
@decoTemplateGroup.Name
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Deco Template</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_selectedItem.DecoTemplate" For="@(() => _selectedItem.DecoTemplate)" Clearable="true">
|
||||
@foreach (DecoTemplateViewModel decoTemplate in _decoTemplates)
|
||||
{
|
||||
<MudSelectItem Value="@decoTemplate">@decoTemplate.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Limit to Date Range</MudText>
|
||||
</div>
|
||||
<MudCheckBox T="bool" @bind-Value="_selectedItem.LimitToDateRange" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Start Day</MudText>
|
||||
</div>
|
||||
<MudSelect T="int" @bind-Value="_selectedItem.StartMonth" Disabled="@(!_selectedItem.LimitToDateRange)">
|
||||
@foreach (int month in Enumerable.Range(1, 12))
|
||||
{
|
||||
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_selectedItem.StartDay" Disabled="@(!_selectedItem.LimitToDateRange)">
|
||||
@foreach (int day in Enumerable.Range(1, 31))
|
||||
{
|
||||
<MudSelectItem Value="@day">@day.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_selectedItem.StartYear" Disabled="@(!_selectedItem.LimitToDateRange)" Clearable="true">
|
||||
@foreach (int year in Enumerable.Range(DateTime.Today.Year, 50))
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@year">@year.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>End Day</MudText>
|
||||
</div>
|
||||
<MudSelect T="int" @bind-Value="_selectedItem.EndMonth" Disabled="@(!_selectedItem.LimitToDateRange)">
|
||||
@foreach (int month in Enumerable.Range(1, 12))
|
||||
{
|
||||
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_selectedItem.EndDay" Disabled="@(!_selectedItem.LimitToDateRange)">
|
||||
@foreach (int day in Enumerable.Range(1, 31))
|
||||
{
|
||||
<MudSelectItem Value="@day">@day.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</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="_selectedItem.EndYear" Disabled="@(!_selectedItem.LimitToDateRange)" Clearable="true">
|
||||
@foreach (int year in Enumerable.Range(DateTime.Today.Year, 50))
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@year">@year.ToString()</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Days of the Week</MudText>
|
||||
</div>
|
||||
<MudSelect T="DayOfWeek" SelectedValues="@_selectedItem.DaysOfWeek" SelectedValuesChanged="OnSelectedDaysChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Days">
|
||||
<MudSelectItem Value="@DayOfWeek.Monday">@_dtf.GetDayName(DayOfWeek.Monday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Tuesday">@_dtf.GetDayName(DayOfWeek.Tuesday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Wednesday">@_dtf.GetDayName(DayOfWeek.Wednesday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Thursday">@_dtf.GetDayName(DayOfWeek.Thursday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Friday">@_dtf.GetDayName(DayOfWeek.Friday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Saturday">@_dtf.GetDayName(DayOfWeek.Saturday)</MudSelectItem>
|
||||
<MudSelectItem Value="@DayOfWeek.Sunday">@_dtf.GetDayName(DayOfWeek.Sunday)</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => SelectWeekdays())">
|
||||
Weekdays
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => SelectWeekends())">
|
||||
Weekends
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Days of the Month</MudText>
|
||||
</div>
|
||||
<MudSelect T="int" SelectedValues="@_selectedItem.DaysOfMonth" SelectedValuesChanged="OnSelectedDaysChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Days">
|
||||
@foreach (int day in Enumerable.Range(1, 31))
|
||||
{
|
||||
<MudSelectItem Value="@day">@day</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Months</MudText>
|
||||
</div>
|
||||
<MudSelect T="int" SelectedValues="@_selectedItem.MonthsOfYear" SelectedValuesChanged="OnSelectedMonthsChanged" MultiSelection="true" Clearable="true" SelectAll="true" SelectAllText="Select All Months">
|
||||
@foreach (int month in Enumerable.Range(1, 12))
|
||||
{
|
||||
<MudSelectItem Value="@month">@_dtf.GetMonthName(month)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudCalendar ShowWeek="false"
|
||||
ShowDay="false"
|
||||
DateRangeChanged="@(range => DateRangeChanged(range))"
|
||||
MonthCellMinHeight="115"
|
||||
Items="_previewItems"/>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly List<TemplateGroupViewModel> _templateGroups = [];
|
||||
private readonly List<TemplateViewModel> _templates = [];
|
||||
|
||||
private readonly List<DecoTemplateGroupViewModel> _decoTemplateGroups = [];
|
||||
private readonly List<DecoTemplateViewModel> _decoTemplates = [];
|
||||
|
||||
private string _channelName;
|
||||
private List<PlayoutTemplateEditViewModel> _items = [];
|
||||
private TemplateGroupViewModel _selectedGroup;
|
||||
private DecoTemplateGroupViewModel _selectedDecoTemplateGroup;
|
||||
private PlayoutTemplateEditViewModel _selectedItem;
|
||||
private readonly List<CalendarItem> _previewItems = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_channelName = (await Mediator.Send(new GetChannelNameByPlayoutId(Id), token)).IfNone(string.Empty);
|
||||
|
||||
_templateGroups.Clear();
|
||||
_templateGroups.AddRange(await Mediator.Send(new GetAllTemplateGroups(), token));
|
||||
|
||||
_decoTemplateGroups.Clear();
|
||||
_decoTemplateGroups.AddRange(await Mediator.Send(new GetAllDecoTemplateGroups(), token));
|
||||
|
||||
List<PlayoutTemplateViewModel> results = await Mediator.Send(new GetPlayoutTemplates(Id), token);
|
||||
_items = results.Map(ProjectToEditViewModel).ToList();
|
||||
if (_items.Count == 1)
|
||||
{
|
||||
await SelectedItemChanged(_items.Head());
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private PlayoutTemplateEditViewModel ProjectToEditViewModel(PlayoutTemplateViewModel item) =>
|
||||
new()
|
||||
{
|
||||
Id = item.Id,
|
||||
Template = item.Template,
|
||||
DecoTemplate = item.DecoTemplate,
|
||||
Index = item.Index,
|
||||
DaysOfWeek = item.DaysOfWeek.ToList(),
|
||||
DaysOfMonth = item.DaysOfMonth.ToList(),
|
||||
MonthsOfYear = item.MonthsOfYear.ToList(),
|
||||
LimitToDateRange = item.LimitToDateRange,
|
||||
StartMonth = item.StartMonth,
|
||||
StartDay = item.StartDay,
|
||||
StartYear = item.StartYear,
|
||||
EndMonth = item.EndMonth,
|
||||
EndDay = item.EndDay,
|
||||
EndYear = item.EndYear
|
||||
};
|
||||
|
||||
private async Task UpdateTemplateGroupItems(TemplateGroupViewModel templateGroup)
|
||||
{
|
||||
_selectedGroup = templateGroup;
|
||||
|
||||
_templates.Clear();
|
||||
_templates.AddRange(await Mediator.Send(new GetTemplatesByTemplateGroupId(_selectedGroup.Id), _cts.Token));
|
||||
}
|
||||
|
||||
private async Task UpdateDecoTemplateGroupItems(DecoTemplateGroupViewModel decoTemplateGroup)
|
||||
{
|
||||
_selectedDecoTemplateGroup = decoTemplateGroup;
|
||||
|
||||
_decoTemplates.Clear();
|
||||
_decoTemplates.AddRange(await Mediator.Send(new GetDecoTemplatesByDecoTemplateGroupId(_selectedDecoTemplateGroup.Id), _cts.Token));
|
||||
}
|
||||
|
||||
private async Task SelectedItemChanged(PlayoutTemplateEditViewModel template)
|
||||
{
|
||||
_selectedItem = template;
|
||||
|
||||
foreach (TemplateGroupViewModel group in Optional(_templateGroups.Find(group => group.Id == _selectedItem?.Template?.TemplateGroupId)))
|
||||
{
|
||||
await UpdateTemplateGroupItems(group);
|
||||
}
|
||||
|
||||
foreach (DecoTemplateGroupViewModel group in Optional(_decoTemplateGroups.Find(group => group.Id == _selectedItem?.DecoTemplate?.DecoTemplateGroupId)))
|
||||
{
|
||||
await UpdateDecoTemplateGroupItems(group);
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectWeekdays()
|
||||
{
|
||||
_selectedItem?.DaysOfWeek.Clear();
|
||||
_selectedItem?.DaysOfWeek.AddRange(
|
||||
[
|
||||
DayOfWeek.Monday,
|
||||
DayOfWeek.Tuesday,
|
||||
DayOfWeek.Wednesday,
|
||||
DayOfWeek.Thursday,
|
||||
DayOfWeek.Friday
|
||||
]);
|
||||
}
|
||||
|
||||
private void SelectWeekends()
|
||||
{
|
||||
_selectedItem?.DaysOfWeek.Clear();
|
||||
_selectedItem?.DaysOfWeek.AddRange(
|
||||
[
|
||||
DayOfWeek.Saturday,
|
||||
DayOfWeek.Sunday
|
||||
]);
|
||||
}
|
||||
|
||||
private void AddTemplate()
|
||||
{
|
||||
var item = new PlayoutTemplateEditViewModel
|
||||
{
|
||||
Index = _items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
|
||||
DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
|
||||
DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
|
||||
MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
|
||||
};
|
||||
|
||||
_items.Add(item);
|
||||
_selectedItem = item;
|
||||
}
|
||||
|
||||
private void DeleteTemplate(PlayoutTemplateEditViewModel item)
|
||||
{
|
||||
_selectedItem = null;
|
||||
_items.Remove(item);
|
||||
}
|
||||
|
||||
private void MoveItemUp(PlayoutTemplateEditViewModel item)
|
||||
{
|
||||
// swap with lower index
|
||||
PlayoutTemplateEditViewModel toSwap = _items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
|
||||
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
|
||||
}
|
||||
|
||||
private void MoveItemDown(PlayoutTemplateEditViewModel item)
|
||||
{
|
||||
// swap with higher index
|
||||
PlayoutTemplateEditViewModel toSwap = _items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
|
||||
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
|
||||
}
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
if (_items.Any(i => i.Template is null))
|
||||
{
|
||||
Snackbar.Add("Unable to save; item has no template selected", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var items = _items.Map(item => new ReplacePlayoutTemplate(
|
||||
item.Id,
|
||||
item.Index,
|
||||
item.Template.Id,
|
||||
item.DecoTemplate?.Id,
|
||||
item.DaysOfWeek,
|
||||
item.DaysOfMonth,
|
||||
item.MonthsOfYear,
|
||||
item.LimitToDateRange,
|
||||
item.StartMonth,
|
||||
item.StartDay,
|
||||
item.StartYear,
|
||||
item.EndMonth,
|
||||
item.EndDay,
|
||||
item.EndYear)).ToList();
|
||||
|
||||
Option<BaseError> maybeError = await Mediator.Send(new ReplacePlayoutTemplateItems(Id, items), _cts.Token);
|
||||
|
||||
maybeError.Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving playout templates: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving playout templates: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("playouts"));
|
||||
}
|
||||
|
||||
private async Task PreviewCalendar()
|
||||
{
|
||||
_selectedItem = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task DateRangeChanged(DateRange dateRange)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
|
||||
_previewItems.Clear();
|
||||
|
||||
var prioritized = _items.Filter(i => i.Template is not null).OrderBy(t => t.Index).ToList();
|
||||
|
||||
if (dateRange.Start.HasValue && dateRange.End.HasValue)
|
||||
{
|
||||
DateTime current = dateRange.Start.Value.Date;
|
||||
while (current <= dateRange.End.Value.Date)
|
||||
{
|
||||
foreach (PlayoutTemplateEditViewModel template in prioritized)
|
||||
{
|
||||
if (template.AppliesToDate(current))
|
||||
{
|
||||
_previewItems.Add(
|
||||
new CalendarItem
|
||||
{
|
||||
AllDay = true,
|
||||
Start = current,
|
||||
Text = template.Template.Name
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
current = current.AddDays(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string ToDaysOfWeekString(List<DayOfWeek> daysOfWeek)
|
||||
{
|
||||
if (daysOfWeek.Count is 0 or 7)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
daysOfWeek.Sort();
|
||||
|
||||
return string.Join(", ", daysOfWeek.Map(_dtf.GetAbbreviatedDayName));
|
||||
}
|
||||
|
||||
private string ToDaysOfMonthString(List<int> daysOfMonth)
|
||||
{
|
||||
if (daysOfMonth.Count is 0 or 31)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
return ToRangeString(daysOfMonth);
|
||||
}
|
||||
|
||||
private string ToMonthsOfYearString(List<int> monthsOfYear)
|
||||
{
|
||||
if (monthsOfYear.Count is 0 or 12)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
monthsOfYear.Sort();
|
||||
|
||||
return string.Join(", ", monthsOfYear.Map(_dtf.GetAbbreviatedMonthName));
|
||||
}
|
||||
|
||||
private static string ToRangeString(List<int> list)
|
||||
{
|
||||
list = list.Distinct().ToList();
|
||||
list.Sort();
|
||||
|
||||
var result = new StringBuilder();
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
int temp = list[i];
|
||||
|
||||
//add a number
|
||||
result.Append(list[i]);
|
||||
|
||||
//skip number(s) between a range
|
||||
while (i < list.Count - 1 && list[i + 1] == list[i] + 1)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
//add the range
|
||||
if (temp != list[i])
|
||||
{
|
||||
result.Append("-").Append(list[i]);
|
||||
}
|
||||
|
||||
//add comma
|
||||
if (i != list.Count - 1)
|
||||
{
|
||||
result.Append(", ");
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private string SelectedRowClassFunc(PlayoutTemplateEditViewModel element, int rowNumber) => _selectedItem != null && _selectedItem == element ? "selected" : string.Empty;
|
||||
|
||||
private void OnSelectedDaysChanged(IEnumerable<DayOfWeek> selectedDays)
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Clear();
|
||||
_selectedItem.DaysOfWeek.AddRange(selectedDays);
|
||||
}
|
||||
|
||||
private void OnSelectedDaysChanged(IEnumerable<int> selectedDays)
|
||||
{
|
||||
_selectedItem.DaysOfMonth.Clear();
|
||||
_selectedItem.DaysOfMonth.AddRange(selectedDays);
|
||||
}
|
||||
|
||||
private void OnSelectedMonthsChanged(IEnumerable<int> selectedMonths)
|
||||
{
|
||||
_selectedItem.MonthsOfYear.Clear();
|
||||
_selectedItem.MonthsOfYear.AddRange(selectedMonths);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
@page "/playouts"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.Playouts
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Core.Notifications
|
||||
@using ErsatzTV.Core.Scheduling
|
||||
@using MediatR.Courier
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> WorkerChannel
|
||||
@inject IEntityLocker EntityLocker;
|
||||
@inject ICourier Courier;
|
||||
|
||||
<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="flex-grow-1"></div>
|
||||
<div style="margin-left: auto" class="d-none d-md-flex">
|
||||
<MudMenu>
|
||||
<ActivatorContent>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Filled" Color="Color.Primary">Add Playout</MudButton>
|
||||
</ActivatorContent>
|
||||
<ChildContent>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Classic Schedule" Href="playouts/add"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Block Schedule" Href="@($"playouts/add/{PlayoutKind.Block}")"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Sequential Schedule" Href="@($"playouts/add/{PlayoutKind.Sequential}")"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Scripted Schedule" Href="@($"playouts/add/{PlayoutKind.Scripted}")"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Warning" Label="JSON (dizqueTV) Schedule" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")"/>
|
||||
</ChildContent>
|
||||
</MudMenu>
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Warning" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@ResetAllPlayouts">
|
||||
Reset All Playouts
|
||||
</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.Add" Label="Add Classic Playout" Href="playouts/add"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Block Playout" Href="@($"playouts/add/{PlayoutKind.Block}")"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Sequential Playout" Href="@($"playouts/add/{PlayoutKind.Sequential}")"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add Scripted Playout" Href="@($"playouts/add/{PlayoutKind.Scripted}")"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Warning" Label="Add JSON (dizqueTV) Playout" Href="@($"playouts/add/{PlayoutKind.ExternalJson}")"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Refresh" Label="Reset All Playouts" OnClick="@ResetAllPlayouts"/>
|
||||
</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">Playouts</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
Dense="true"
|
||||
SelectedItemChanged="@(async (PlayoutNameViewModel x) => await PlayoutSelected(x))"
|
||||
@bind-RowsPerPage="@_rowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<PlayoutNameViewModel>>>(ServerReload))"
|
||||
@ref="_table"
|
||||
RowClassFunc="@SelectedRowClassFunc">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 225px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string"
|
||||
ValueChanged="@(s => OnSearch(s))"
|
||||
Placeholder="Search for playouts"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.FilterList"
|
||||
Clearable="true">
|
||||
</MudTextField>
|
||||
</ToolBarContent>
|
||||
<HeaderContent>
|
||||
<MudTh>
|
||||
Channel
|
||||
</MudTh>
|
||||
<MudTh Class="d-none d-md-table-cell">
|
||||
Default Schedule
|
||||
</MudTh>
|
||||
<MudTh Class="d-none d-md-table-cell">Schedule Kind</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.ChannelNumber - @context.ChannelName</MudTd>
|
||||
<MudTd Class="d-none d-md-table-cell">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ScheduleName))
|
||||
{
|
||||
@context.ScheduleName
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(context.ScheduleFile))
|
||||
{
|
||||
<MudTooltip Text="@context.ScheduleFile">
|
||||
@Path.GetFileName(context.ScheduleFile)
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Class="d-none d-md-table-cell">
|
||||
@switch (context.ScheduleKind)
|
||||
{
|
||||
case PlayoutScheduleKind.Block:
|
||||
<span>Block</span>
|
||||
break;
|
||||
case PlayoutScheduleKind.Sequential:
|
||||
<span>Sequential</span>
|
||||
break;
|
||||
case PlayoutScheduleKind.Scripted:
|
||||
<span>Scripted</span>
|
||||
break;
|
||||
case PlayoutScheduleKind.ExternalJson:
|
||||
<span>JSON (dizqueTV)</span>
|
||||
break;
|
||||
default:
|
||||
<span></span>
|
||||
break;
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
|
||||
@if (EntityLocker.IsPlayoutLocked(context.PlayoutId))
|
||||
{
|
||||
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
|
||||
}
|
||||
</div>
|
||||
@if (context.ScheduleKind == PlayoutScheduleKind.Classic)
|
||||
{
|
||||
<MudTooltip Text="Edit Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
Href="@($"playouts/classic/{context.PlayoutId}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Refresh Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
OnClick="@(_ => ResetPlayout(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Schedule Reset">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Update"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
OnClick="@(_ => ScheduleReset(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else if (context.ScheduleKind == PlayoutScheduleKind.ExternalJson)
|
||||
{
|
||||
<MudTooltip Text="Edit External Json File">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
OnClick="@(_ => EditExternalJsonFile(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<div style="width: 48px"></div>
|
||||
<div style="width: 48px"></div>
|
||||
}
|
||||
else if (context.ScheduleKind == PlayoutScheduleKind.Sequential)
|
||||
{
|
||||
<MudTooltip Text="Edit Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
Href="@($"playouts/sequential/{context.PlayoutId}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Reset Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
OnClick="@(_ => ResetPlayout(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<div style="width: 48px"></div>
|
||||
}
|
||||
else if (context.ScheduleKind == PlayoutScheduleKind.Scripted)
|
||||
{
|
||||
<MudTooltip Text="Edit Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
Href="@($"playouts/scripted/{context.PlayoutId}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Reset Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
OnClick="@(_ => ResetPlayout(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<div style="width: 48px"></div>
|
||||
}
|
||||
else if (context.ScheduleKind == PlayoutScheduleKind.Block)
|
||||
{
|
||||
<MudTooltip Text="Edit Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
Href="@($"playouts/block/{context.PlayoutId}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Refresh Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
OnClick="@(_ => ResetPlayout(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<div style="width: 48px"></div>
|
||||
}
|
||||
<MudTooltip Text="Delete Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Disabled="@EntityLocker.IsPlayoutLocked(context.PlayoutId)"
|
||||
OnClick="@(_ => DeletePlayout(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
@if (_selectedPlayoutId != null)
|
||||
{
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playout Detail</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
@if (!string.IsNullOrWhiteSpace(_buildMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">@_buildMessage</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Hover="true"
|
||||
Dense="true"
|
||||
@bind-RowsPerPage="@_detailRowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<PlayoutItemViewModel>>>(DetailServerReload))"
|
||||
@ref="_detailTable"
|
||||
RowClassFunc="PlayoutItemViewRowClassFunc">
|
||||
<ToolBarContent>
|
||||
<MudSwitch T="bool" Class="ml-6" @bind-Value="@ShowFiller" Color="Color.Secondary" Label="Show Filler"/>
|
||||
</ToolBarContent>
|
||||
<HeaderContent>
|
||||
<MudTh>Start</MudTh>
|
||||
<MudTh>Finish</MudTh>
|
||||
<MudTh>Media Item</MudTh>
|
||||
<MudTh>Duration</MudTh>
|
||||
<MudTh />
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Start">@context.Start.ToString("G", _dtf)</MudTd>
|
||||
<MudTd DataLabel="Finish">@context.Finish.ToString("G", _dtf)</MudTd>
|
||||
<MudTd DataLabel="Media Item">@context.Title</MudTd>
|
||||
<MudTd DataLabel="Duration">@context.Duration</MudTd>
|
||||
<MudTd>
|
||||
@if (!string.IsNullOrWhiteSpace(context.SchedulingContext))
|
||||
{
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Troubleshoot">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Info"
|
||||
OnClick="@(_ => ShowSchedulingContext(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
}
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat;
|
||||
|
||||
private readonly List<PlayoutNameViewModel> _currentPage = [];
|
||||
private MudTable<PlayoutNameViewModel> _table;
|
||||
private MudTable<PlayoutItemViewModel> _detailTable;
|
||||
private int _rowsPerPage = 10;
|
||||
private int _detailRowsPerPage = 10;
|
||||
private string _searchString;
|
||||
private int? _selectedPlayoutId;
|
||||
private bool _showFiller;
|
||||
private string _buildMessage;
|
||||
|
||||
private bool ShowFiller
|
||||
{
|
||||
get => _showFiller;
|
||||
set
|
||||
{
|
||||
if (_showFiller != value)
|
||||
{
|
||||
_showFiller = value;
|
||||
if (_detailTable != null && _selectedPlayoutId != null)
|
||||
{
|
||||
_detailTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized() => Courier.Subscribe<PlayoutUpdatedNotification>(HandlePlayoutUpdated);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Courier.UnSubscribe<PlayoutUpdatedNotification>(HandlePlayoutUpdated);
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
public async Task HandlePlayoutUpdated(PlayoutUpdatedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
// only refresh detail table on unlock operations (after playout has been modified)
|
||||
if (!notification.IsLocked)
|
||||
{
|
||||
int? previouslySelected = _selectedPlayoutId;
|
||||
|
||||
await InvokeAsync(() => _table.ReloadServerData());
|
||||
_buildMessage = string.Empty;
|
||||
_selectedPlayoutId = null;
|
||||
|
||||
if (notification.PlayoutId == previouslySelected)
|
||||
{
|
||||
Option<PlayoutNameViewModel> maybeItem = _currentPage.Find(i => i.PlayoutId == previouslySelected);
|
||||
foreach (var item in maybeItem)
|
||||
{
|
||||
await InvokeAsync(() => PlayoutSelected(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
_detailRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
_showFiller = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailShowFiller), token)
|
||||
.Map(maybeShow => maybeShow.Match(ce => bool.TryParse(ce.Value, out bool show) && show, () => false));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PlayoutSelected(PlayoutNameViewModel playout)
|
||||
{
|
||||
// only show details for classic, block, sequential and scripted schedules
|
||||
_selectedPlayoutId = playout.ScheduleKind is PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted
|
||||
? playout.PlayoutId
|
||||
: null;
|
||||
|
||||
_buildMessage = !(playout.BuildStatus?.Success ?? false) && !string.IsNullOrWhiteSpace(playout.BuildStatus?.Message)
|
||||
? playout.BuildStatus.Message
|
||||
: string.Empty;
|
||||
|
||||
if (_detailTable != null)
|
||||
{
|
||||
await _detailTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EditExternalJsonFile(PlayoutNameViewModel playout)
|
||||
{
|
||||
var parameters = new DialogParameters { { "ScheduleFile", $"{playout.ScheduleFile}" } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<EditExternalJsonFileDialog>("Edit External Json File", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new UpdateExternalJsonPlayout(playout.PlayoutId, result.Data as string ?? playout.ScheduleFile), _cts.Token);
|
||||
if (_table != null)
|
||||
{
|
||||
await _table.ReloadServerData();
|
||||
}
|
||||
|
||||
_selectedPlayoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeletePlayout(PlayoutNameViewModel playout)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "playout" }, { "EntityName", $"{playout.ScheduleName} on {playout.ChannelNumber} - {playout.ChannelName}" } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playout", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new DeletePlayout(playout.PlayoutId), _cts.Token);
|
||||
if (_table != null)
|
||||
{
|
||||
await _table.ReloadServerData();
|
||||
}
|
||||
|
||||
if (_selectedPlayoutId == playout.PlayoutId)
|
||||
{
|
||||
_selectedPlayoutId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetAllPlayouts()
|
||||
{
|
||||
_selectedPlayoutId = null;
|
||||
await Mediator.Send(new ResetAllPlayouts(), _cts.Token);
|
||||
}
|
||||
|
||||
private async Task ResetPlayout(PlayoutNameViewModel playout)
|
||||
{
|
||||
PlayoutBuildMode mode = playout.ScheduleKind switch
|
||||
{
|
||||
PlayoutScheduleKind.Classic => PlayoutBuildMode.Refresh,
|
||||
_ => PlayoutBuildMode.Reset
|
||||
};
|
||||
|
||||
await WorkerChannel.WriteAsync(new BuildPlayout(playout.PlayoutId, mode), _cts.Token);
|
||||
}
|
||||
|
||||
private async Task ScheduleReset(PlayoutNameViewModel playout)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
{ "PlayoutId", playout.PlayoutId },
|
||||
{ "ChannelName", playout.ChannelName },
|
||||
{ "ScheduleName", playout.ScheduleName },
|
||||
{ "DailyResetTime", playout.DailyRebuildTime }
|
||||
};
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<SchedulePlayoutReset>("Schedule Playout Reset", parameters, options);
|
||||
await dialog.Result;
|
||||
|
||||
if (_table != null)
|
||||
{
|
||||
await _table.ReloadServerData();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<PlayoutNameViewModel>> ServerReload(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
PagedPlayoutsViewModel data = await Mediator.Send(new GetPagedPlayouts(_searchString, state.Page, state.PageSize), cancellationToken);
|
||||
_currentPage.Clear();
|
||||
_currentPage.AddRange(data.Page);
|
||||
return new TableData<PlayoutNameViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
private async Task<TableData<PlayoutItemViewModel>> DetailServerReload(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailShowFiller, _showFiller.ToString()), cancellationToken);
|
||||
|
||||
if (_selectedPlayoutId.HasValue)
|
||||
{
|
||||
PagedPlayoutItemsViewModel data =
|
||||
await Mediator.Send(new GetFuturePlayoutItemsById(_selectedPlayoutId.Value, _showFiller, state.Page, state.PageSize), cancellationToken);
|
||||
|
||||
return new TableData<PlayoutItemViewModel>
|
||||
{
|
||||
TotalItems = data.TotalCount,
|
||||
Items = data.Page
|
||||
};
|
||||
}
|
||||
|
||||
return new TableData<PlayoutItemViewModel> { TotalItems = 0 };
|
||||
}
|
||||
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
_selectedPlayoutId = null;
|
||||
_searchString = query;
|
||||
_table.ReloadServerData();
|
||||
}
|
||||
|
||||
private string PlayoutItemViewRowClassFunc(PlayoutItemViewModel item, int index)
|
||||
{
|
||||
return "playout-filler-" + item.FillerKind.Match(
|
||||
fk => fk.ToString().ToLower(),
|
||||
() => "unscheduled");
|
||||
}
|
||||
|
||||
private string SelectedRowClassFunc(PlayoutNameViewModel element, int rowNumber)
|
||||
{
|
||||
if (_selectedPlayoutId != null && _selectedPlayoutId == element.PlayoutId)
|
||||
{
|
||||
if (element.BuildStatus is { Success: false })
|
||||
{
|
||||
return "playout-build-failure-selected";
|
||||
}
|
||||
|
||||
return "selected";
|
||||
}
|
||||
|
||||
if (element.BuildStatus is { Success: false })
|
||||
{
|
||||
return "playout-build-failure";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private async Task ShowSchedulingContext(PlayoutItemViewModel item)
|
||||
{
|
||||
Option<string> maybeResults = await Mediator.Send(new ProcessSchedulingContext(item.SchedulingContext), _cts.Token);
|
||||
foreach (string result in maybeResults)
|
||||
{
|
||||
var parameters = new DialogParameters { { "Context", result } };
|
||||
var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
|
||||
IDialogReference dialog = await Dialog.ShowAsync<SchedulingContextDialog>(item.Title, parameters, options);
|
||||
DialogResult _ = await dialog.Result;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
@page "/media/sources/plex/{Id:int}/libraries"
|
||||
@using ErsatzTV.Application.MediaSources
|
||||
@using ErsatzTV.Application.Plex
|
||||
@inject IMediator Mediator
|
||||
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
|
||||
|
||||
<RemoteMediaSourceLibrariesEditor
|
||||
Id="@Id"
|
||||
Name="Plex"
|
||||
GetUpdateLibraryRequest="GetUpdateLibraryRequest"
|
||||
GetLibrariesBySourceId="GetLibrariesBySourceId"
|
||||
GetMediaSourceById="GetMediaSourceById"
|
||||
SynchronizeLibraryByIdIfNeeded="SynchronizeLibraryByIdIfNeeded"/>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private IRequest<Either<BaseError, Unit>> GetUpdateLibraryRequest(List<RemoteMediaSourceLibraryEditViewModel> libraries) =>
|
||||
new UpdatePlexLibraryPreferences(
|
||||
libraries.Map(l => new PlexLibraryPreference(l.Id, l.ShouldSyncItems)).ToList());
|
||||
|
||||
private async Task<List<RemoteMediaSourceLibraryEditViewModel>> GetLibrariesBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
await Mediator.Send(new GetPlexLibrariesBySourceId(Id), cancellationToken)
|
||||
.Map(list => list.Map(ProjectToEditViewModel).OrderBy(x => x.MediaKind).ThenBy(x => x.Name).ToList());
|
||||
|
||||
private async Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
await Mediator.Send(new GetPlexMediaSourceById(Id), cancellationToken)
|
||||
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
|
||||
|
||||
private RemoteMediaSourceLibraryEditViewModel ProjectToEditViewModel(PlexLibraryViewModel library) => new()
|
||||
{
|
||||
Id = library.Id,
|
||||
Name = library.Name,
|
||||
MediaKind = library.MediaKind,
|
||||
ShouldSyncItems = library.ShouldSyncItems
|
||||
};
|
||||
|
||||
private async Task<Unit> SynchronizeLibraryByIdIfNeeded(RemoteMediaSourceLibrariesEditor.SynchronizeParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraryByIdIfNeeded(parameters.LibraryId), cancellationToken);
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexNetworks(parameters.LibraryId, false), cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
@page "/media/sources/plex"
|
||||
@using ErsatzTV.Application.Plex
|
||||
@using ErsatzTV.Core.Interfaces.Plex
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
@inject IEntityLocker Locker
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<PlexMediaSources> Logger
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject IPlexSecretStore PlexSecretStore
|
||||
@inject ChannelWriter<IScannerBackgroundServiceRequest> ScannerWorkerChannel
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
|
||||
@if (_mediaSources.Any())
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
OnClick="@(_ => SignOutOfPlex())"
|
||||
Disabled="@Locker.IsPlexLocked()"
|
||||
Class="ml-8">
|
||||
Sign out of plex
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="@(_ => AddPlexMediaSource())"
|
||||
Disabled="@Locker.IsPlexLocked()"
|
||||
Class="ml-8">
|
||||
Sign in to plex
|
||||
</MudButton>
|
||||
}
|
||||
|
||||
@if (_mediaSources.Any() && !_isAuthorized)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
OnClick="@(_ => AddPlexMediaSource())"
|
||||
Disabled="@Locker.IsPlexLocked()"
|
||||
Class="ml-4">
|
||||
Fix Plex Credentials
|
||||
</MudButton>
|
||||
}
|
||||
</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">Plex Media Sources</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable T="PlexMediaSourceViewModel" Hover="true" Dense="true" Items="_mediaSources">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Name</MudTd>
|
||||
<MudTd Style="overflow-wrap: anywhere;">@context.Address</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Refresh Libraries">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Disabled="@(Locker.IsPlexLocked())"
|
||||
OnClick="@(_ => RefreshLibraries(context.Id))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Edit Libraries">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.VideoLibrary"
|
||||
Href="@($"media/sources/plex/{context.Id}/libraries")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Edit Path Replacements">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Folder"
|
||||
Href="@($"media/sources/plex/{context.Id}/paths")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private List<PlexMediaSourceViewModel> _mediaSources = new();
|
||||
|
||||
private bool _isAuthorized;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await LoadMediaSources();
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
Locker.OnPlexChanged += PlexChanged;
|
||||
|
||||
private async Task LoadMediaSources()
|
||||
{
|
||||
_isAuthorized = await PlexSecretStore.GetUserAuthTokens().Map(list => Optional(list).Flatten().Any());
|
||||
_mediaSources = await Mediator.Send(new GetAllPlexMediaSources());
|
||||
}
|
||||
|
||||
private async Task SignOutOfPlex()
|
||||
{
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
|
||||
IDialogReference dialog = await Dialog.ShowAsync<SignOutOfPlexDialog>("Sign out of Plex", options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
if (Locker.LockPlex())
|
||||
{
|
||||
await Mediator.Send(new SignOutOfPlex());
|
||||
await LoadMediaSources();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddPlexMediaSource()
|
||||
{
|
||||
if (Locker.LockPlex())
|
||||
{
|
||||
Either<BaseError, string> maybeUrl = await Mediator.Send(new StartPlexPinFlow());
|
||||
await maybeUrl.Match(
|
||||
async url =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await JsRuntime.InvokeAsync<object>("open", [url, "_blank"]);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
Locker.UnlockPlex();
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error generating plex auth app url: {Error}", error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async void PlexChanged(object sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
await InvokeAsync(LoadMediaSources);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshLibraries(int mediaSourceId) =>
|
||||
await ScannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(mediaSourceId));
|
||||
|
||||
void IDisposable.Dispose() => Locker.OnPlexChanged -= PlexChanged;
|
||||
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
@page "/media/sources/plex/{Id:int}/paths"
|
||||
@using ErsatzTV.Application.MediaSources
|
||||
@using ErsatzTV.Application.Plex
|
||||
@inject IMediator Mediator
|
||||
|
||||
<RemoteMediaSourcePathReplacementsEditor
|
||||
Id="@Id"
|
||||
Name="Plex"
|
||||
GetMediaSourceById="GetMediaSourceById"
|
||||
GetUpdatePathReplacementsRequest="GetUpdatePathReplacementsRequest"
|
||||
GetPathReplacementsBySourceId="GetPathReplacementsBySourceId"/>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private async Task<Option<RemoteMediaSourceViewModel>> GetMediaSourceById(int id, CancellationToken cancellationToken) =>
|
||||
await Mediator.Send(new GetPlexMediaSourceById(Id), cancellationToken)
|
||||
.MapT(vm => new RemoteMediaSourceViewModel(vm.Id, vm.Name, vm.Address));
|
||||
|
||||
private async Task<List<RemoteMediaSourcePathReplacementEditViewModel>> GetPathReplacementsBySourceId(int mediaSourceId, CancellationToken cancellationToken) =>
|
||||
await Mediator.Send(new GetPlexPathReplacementsBySourceId(Id), cancellationToken)
|
||||
.Map(list => list.Map(ProjectToEditViewModel).ToList());
|
||||
|
||||
private RemoteMediaSourcePathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) =>
|
||||
new() { Id = item.Id, RemotePath = item.PlexPath, LocalPath = item.LocalPath };
|
||||
|
||||
private IRequest<Either<BaseError, Unit>> GetUpdatePathReplacementsRequest(List<RemoteMediaSourcePathReplacementEditViewModel> pathReplacements)
|
||||
{
|
||||
var items = pathReplacements
|
||||
.Map(item => new PlexPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
|
||||
.ToList();
|
||||
|
||||
return new UpdatePlexPathReplacements(Id, items);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
@page "/media/remote/streams"
|
||||
@page "/media/remote/streams/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits MultiSelectBase<RemoteStreamList>
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="RemoteStreamCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/remote/streams"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private RemoteStreamCardResultsViewModel _data = new(0, new List<RemoteStreamCardViewModel>(), null);
|
||||
private string _query;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:remote_stream" : $"type:remote_stream AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexRemoteStreams(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/remote/streams/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/remote/streams/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is RemoteStreamCardViewModel remoteStream)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "remote stream" }, { "EntityName", remoteStream.Title } };
|
||||
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 AddMediaItemToCollection(collection.Id, remoteStream.RemoteStreamId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding remote stream to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding remote stream to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {remoteStream.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
@page "/media/rerun-collections/{Id:int}/edit"
|
||||
@page "/media/rerun-collections/add"
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.Search
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<RerunCollectionEditor> Logger
|
||||
|
||||
<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" OnClick="@(_ => HandleSubmitAsync())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">
|
||||
@(IsEdit ? "Save Rerun Collection" : "Add Rerun Collection")
|
||||
</MudButton>
|
||||
</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">Rerun Collection</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Rerun collection name is required!"/>
|
||||
</MudStack>
|
||||
<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="_model.CollectionType" For="@(() => _model.CollectionType)">
|
||||
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
|
||||
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
@if (_model.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="_model.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 (_model.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="_model.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 (_model.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="_model.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 (_model.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="_model.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 (_model.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="_model.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>
|
||||
}
|
||||
|
||||
@if (_model.CollectionType == CollectionType.Artist)
|
||||
{
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Artist</MudText>
|
||||
</div>
|
||||
<MudAutocomplete T="NamedMediaItemViewModel"
|
||||
@bind-Value="_model.MediaItem" SearchFunc="@SearchArtists"
|
||||
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
|
||||
MaxItems="10">
|
||||
<MoreItemsTemplate>
|
||||
<MudText Align="Align.Center" Class="px-4 py-1">
|
||||
Only the first 10 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>First Run Playback Order</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="@_model.FirstRunPlaybackOrder" For="@(() => _model.FirstRunPlaybackOrder)">
|
||||
@switch (_model.CollectionType)
|
||||
{
|
||||
case CollectionType.MultiCollection:
|
||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||
break;
|
||||
case CollectionType.Collection:
|
||||
case CollectionType.SmartCollection:
|
||||
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||
break;
|
||||
case CollectionType.TelevisionShow:
|
||||
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||
break;
|
||||
default:
|
||||
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||
break;
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Rerun Playback Order</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="@_model.RerunPlaybackOrder" For="@(() => _model.RerunPlaybackOrder)">
|
||||
@switch (_model.CollectionType)
|
||||
{
|
||||
case CollectionType.MultiCollection:
|
||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||
break;
|
||||
case CollectionType.Collection:
|
||||
case CollectionType.SmartCollection:
|
||||
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||
break;
|
||||
case CollectionType.TelevisionShow:
|
||||
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||
break;
|
||||
default:
|
||||
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
|
||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||
break;
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly RerunCollectionEditViewModel _model = new();
|
||||
|
||||
private MudForm _form;
|
||||
private bool _success;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
if (IsEdit)
|
||||
{
|
||||
Option<RerunCollectionViewModel> maybeCollection = await Mediator.Send(new GetRerunCollectionById(Id), token);
|
||||
maybeCollection.IfSome(collection =>
|
||||
{
|
||||
_model.Id = collection.Id;
|
||||
_model.Name = collection.Name;
|
||||
_model.CollectionType = collection.CollectionType;
|
||||
_model.Collection = collection.Collection;
|
||||
_model.MultiCollection = collection.MultiCollection;
|
||||
_model.SmartCollection = collection.SmartCollection;
|
||||
_model.MediaItem = collection.MediaItem;
|
||||
_model.FirstRunPlaybackOrder = collection.FirstRunPlaybackOrder;
|
||||
_model.RerunPlaybackOrder = collection.RerunPlaybackOrder;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_model.Name = "New Rerun Collection";
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEdit => Id != 0;
|
||||
|
||||
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 async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_success)
|
||||
{
|
||||
Seq<BaseError> errorMessage = IsEdit
|
||||
? (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq()
|
||||
: (await Mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
|
||||
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Error saving collection: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("media/rerun-collections"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
@page "/media/rerun-collections"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@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%" class="ml-6 mr-6">
|
||||
<div style="margin-right: auto" class="d-none d-md-flex">
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Href="media/rerun-collections/add">
|
||||
Add Rerun Collection
|
||||
</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.Add" Label="Add Rerun Collection" Href="media/rerun-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">Rerun Collections</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
@bind-RowsPerPage="@_rerunCollectionsRowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<RerunCollectionViewModel>>>(ServerReloadRerunCollections))"
|
||||
Dense="true"
|
||||
@ref="_rerunCollectionsTable">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col style="width: 120px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string"
|
||||
ValueChanged="@(s => OnSearch(s))"
|
||||
Placeholder="Search for rerun collections"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.FilterList"
|
||||
Clearable="true">
|
||||
</MudTextField>
|
||||
</ToolBarContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Rerun Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"/media/rerun-collections/{context.Id}/edit")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Rerun Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteRerunCollection(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
<div class="mt-6"></div>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudTable<RerunCollectionViewModel> _rerunCollectionsTable;
|
||||
|
||||
private int _rerunCollectionsRowsPerPage = 10;
|
||||
private string _searchString;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_rerunCollectionsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.RerunCollectionsPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteRerunCollection(RerunCollectionViewModel collection)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "rerun collection" }, { "EntityName", collection.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Rerun Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new DeleteRerunCollection(collection.Id), _cts.Token);
|
||||
if (_rerunCollectionsTable != null)
|
||||
{
|
||||
await _rerunCollectionsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<RerunCollectionViewModel>> ServerReloadRerunCollections(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.RerunCollectionsPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
PagedRerunCollectionsViewModel data = await Mediator.Send(new GetPagedRerunCollections(_searchString, state.Page, state.PageSize), cancellationToken);
|
||||
return new TableData<RerunCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
_searchString = query;
|
||||
_rerunCollectionsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
@page "/schedules/{Id:int}"
|
||||
@page "/schedules/add"
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Core.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ScheduleEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@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-6" OnClick="HandleSubmitAsync" StartIcon="@(IsEdit ? Icons.Material.Filled.Save : Icons.Material.Filled.Add)">@(IsEdit ? "Save Schedule" : "Add Schedule")</MudButton>
|
||||
</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">Schedule</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Schedule name is required!"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Keep Multi-Part Episodes Together</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.KeepMultiPartEpisodesTogether" For="@(() => _model.KeepMultiPartEpisodesTogether)" Dense="true">
|
||||
<MudText Typo="Typo.caption" Style="font-weight: normal">Group multi-part episodes when shuffling</MudText>
|
||||
</MudCheckBox>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Treat Collections As Shows</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.TreatCollectionsAsShows"
|
||||
Disabled="@(_model.KeepMultiPartEpisodesTogether == false)"
|
||||
For="@(() => _model.TreatCollectionsAsShows)" Dense="true">
|
||||
<MudText Typo="Typo.caption" Style="font-weight: normal">This is useful for multi-part crossover episodes</MudText>
|
||||
</MudCheckBox>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Shuffle Schedule Items</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.ShuffleScheduleItems" For="@(() => _model.ShuffleScheduleItems)" Dense="true">
|
||||
<MudText Typo="Typo.caption" Style="font-weight: normal">Note: this disables fixed start times and flood mode</MudText>
|
||||
</MudCheckBox>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Random Start Point</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="@_model.RandomStartPoint" For="@(() => _model.RandomStartPoint)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Fixed Start Time Behavior</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="@_model.FixedStartTimeBehavior" For="@(() => _model.FixedStartTimeBehavior)">
|
||||
<MudSelectItem Value="FixedStartTimeBehavior.Strict">Strict - Wait For Exact Start Time</MudSelectItem>
|
||||
<MudSelectItem Value="FixedStartTimeBehavior.Flexible">Flexible - Start As Soon As Possible</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly ProgramScheduleEditViewModel _model = new();
|
||||
private MudForm _form;
|
||||
private bool _success;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
if (IsEdit)
|
||||
{
|
||||
Option<ProgramScheduleViewModel> maybeProgramSchedule = await Mediator.Send(new GetProgramScheduleById(Id), token);
|
||||
maybeProgramSchedule.Match(
|
||||
viewModel =>
|
||||
{
|
||||
_model.Id = viewModel.Id;
|
||||
_model.Name = viewModel.Name;
|
||||
_model.ShuffleScheduleItems = viewModel.ShuffleScheduleItems;
|
||||
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
|
||||
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
|
||||
_model.RandomStartPoint = viewModel.RandomStartPoint;
|
||||
_model.FixedStartTimeBehavior = viewModel.FixedStartTimeBehavior;
|
||||
},
|
||||
() => NavigationManager.NavigateTo("404"));
|
||||
}
|
||||
else
|
||||
{
|
||||
_model.Name = "New Schedule";
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEdit => Id > 0;
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_success)
|
||||
{
|
||||
Either<BaseError, EntityIdResult> result = IsEdit
|
||||
? await Mediator.Send(_model.ToUpdate(), _cts.Token).MapT(r => r as EntityIdResult)
|
||||
: await Mediator.Send(_model.ToCreate(), _cts.Token).MapT(r => r as EntityIdResult);
|
||||
|
||||
result.Match(
|
||||
programSchedule =>
|
||||
{
|
||||
string destination = IsEdit ? "/schedules" : $"/schedules/{programSchedule.Id}/items";
|
||||
NavigationManager.NavigateTo(destination);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving schedule: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,235 +0,0 @@
|
||||
@page "/schedules"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm 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-6" StartIcon="@Icons.Material.Filled.Add" Href="schedules/add">
|
||||
Add Schedule
|
||||
</MudButton>
|
||||
</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">Schedules</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
Dense="true"
|
||||
Breakpoint="Breakpoint.None"
|
||||
SelectedItemChanged="@(async (ProgramScheduleViewModel x) => await ScheduleSelected(x))"
|
||||
@bind-RowsPerPage="@_rowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<ProgramScheduleViewModel>>>(ServerReload))"
|
||||
@ref="_table"
|
||||
RowClassFunc="@SelectedRowClassFunc">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col style="width: 300px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string"
|
||||
ValueChanged="@(s => OnSearch(s))"
|
||||
Placeholder="Search for schedules"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.FilterList"
|
||||
Clearable="true">
|
||||
</MudTextField>
|
||||
</ToolBarContent>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Name</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Properties">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"schedules/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Edit Schedule Items">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FormatListNumbered"
|
||||
Href="@($"schedules/{context.Id}/items")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Copy Schedule">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
OnClick="@(_ => CopySchedule(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Troubleshoot">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Info"
|
||||
OnClick="@(_ => Troubleshoot(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Schedule">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteSchedule(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
@if (_selectedSchedule != null)
|
||||
{
|
||||
<MudHidden Breakpoint="Breakpoint.SmAndDown">
|
||||
<MudTable Hover="true"
|
||||
Class="mt-8"
|
||||
@bind-RowsPerPage="@_detailRowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<ProgramScheduleItemViewModel>>>(DetailServerReload))"
|
||||
@ref="_detailTable">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">@_selectedSchedule.Name Items</MudText>
|
||||
</ToolBarContent>
|
||||
<HeaderContent>
|
||||
<MudTh>Start Time</MudTh>
|
||||
<MudTh>Collection</MudTh>
|
||||
<MudTh>Playout Mode</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Start Time">
|
||||
@(context.StartType == StartType.Fixed ? context.StartTime == null ? string.Empty : DateTime.Today.Add(context.StartTime.Value).ToShortTimeString() : "Dynamic")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Collection">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Playout Mode">@context.PlayoutMode</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
</MudHidden>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudTable<ProgramScheduleViewModel> _table;
|
||||
private MudTable<ProgramScheduleItemViewModel> _detailTable;
|
||||
private int _rowsPerPage = 10;
|
||||
private int _detailRowsPerPage = 10;
|
||||
private string _searchString;
|
||||
private ProgramScheduleViewModel _selectedSchedule;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
_detailRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScheduleSelected(ProgramScheduleViewModel schedule)
|
||||
{
|
||||
_selectedSchedule = schedule;
|
||||
if (_selectedSchedule != null && _detailTable != null)
|
||||
{
|
||||
await _detailTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSchedule(ProgramScheduleViewModel programSchedule)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "schedule" }, { "EntityName", programSchedule.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Schedule", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new DeleteProgramSchedule(programSchedule.Id), _cts.Token);
|
||||
if (_table != null)
|
||||
{
|
||||
await _table.ReloadServerData();
|
||||
}
|
||||
|
||||
if (_selectedSchedule == programSchedule)
|
||||
{
|
||||
_selectedSchedule = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopySchedule(ProgramScheduleViewModel programSchedule)
|
||||
{
|
||||
var parameters = new DialogParameters { { "ProgramScheduleId", programSchedule.Id } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<CopyScheduleDialog>("Copy Schedule", parameters, options);
|
||||
DialogResult dialogResult = await dialog.Result;
|
||||
if (dialogResult is { Canceled: false, Data: ProgramScheduleViewModel data })
|
||||
{
|
||||
NavigationManager.NavigateTo($"schedules/{data.Id}/items");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<ProgramScheduleViewModel>> ServerReload(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
PagedProgramSchedulesViewModel data = await Mediator.Send(new GetPagedProgramSchedules(_searchString, state.Page, state.PageSize), cancellationToken);
|
||||
return new TableData<ProgramScheduleViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
private async Task<TableData<ProgramScheduleItemViewModel>> DetailServerReload(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
List<ProgramScheduleItemViewModel> scheduleItems = await Mediator.Send(new GetProgramScheduleItems(_selectedSchedule.Id), cancellationToken);
|
||||
IOrderedEnumerable<ProgramScheduleItemViewModel> sorted = scheduleItems.OrderBy(s => s.Index);
|
||||
|
||||
// TODO: properly page this data
|
||||
return new TableData<ProgramScheduleItemViewModel>
|
||||
{
|
||||
TotalItems = scheduleItems.Count,
|
||||
Items = sorted.Skip(state.Page * state.PageSize).Take(state.PageSize)
|
||||
};
|
||||
}
|
||||
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
_selectedSchedule = null;
|
||||
_searchString = query;
|
||||
_table.ReloadServerData();
|
||||
}
|
||||
|
||||
private async Task Troubleshoot(ProgramScheduleViewModel schedule)
|
||||
{
|
||||
Option<IEnumerable<ProgramScheduleItemViewModel>> maybeResults = await Mediator.Send(new GetProgramScheduleItems(schedule.Id), _cts.Token);
|
||||
foreach (IEnumerable<ProgramScheduleItemViewModel> results in maybeResults)
|
||||
{
|
||||
var sorted = results.OrderBy(i => i.Index).ToList();
|
||||
var parameters = new DialogParameters { { "Schedule", schedule }, { "Items", sorted } };
|
||||
var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
|
||||
IDialogReference dialog = await Dialog.ShowAsync<ScheduleItemsDialog>(schedule.Name, parameters, options);
|
||||
DialogResult _ = await dialog.Result;
|
||||
}
|
||||
}
|
||||
|
||||
private string SelectedRowClassFunc(ProgramScheduleViewModel element, int rowNumber) => _selectedSchedule != null && _selectedSchedule == element ? "selected" : string.Empty;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
@page "/playouts/scripted/{Id:int}"
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Playouts
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IEntityLocker EntityLocker;
|
||||
@inject ILogger<ScriptedPlayoutEditor> Logger
|
||||
|
||||
<MudForm 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-6" OnClick="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
|
||||
Save Scripted Schedule
|
||||
</MudButton>
|
||||
</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">@_channelName - Scripted Schedule</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>Scripted Schedule</MudText>
|
||||
</div>
|
||||
@if (_playout is not null)
|
||||
{
|
||||
<MudTextField @bind-Value="@_playout.ScheduleFile" For="@(() => _playout.ScheduleFile)"/>
|
||||
}
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Maintenance</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>Playout Items and History</MudText>
|
||||
</div>
|
||||
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Error" OnClick="@(_ => EraseItems(eraseHistory: true))" StartIcon="@Icons.Material.Filled.Delete">
|
||||
Erase Items and History
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private PlayoutNameViewModel _playout;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private string _channelName;
|
||||
|
||||
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<string> maybeName = await Mediator.Send(new GetChannelNameByPlayoutId(Id), token);
|
||||
if (maybeName.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("playouts");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string name in maybeName)
|
||||
{
|
||||
_channelName = name;
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await Mediator.Send(new GetPlayoutById(Id), token);
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
_playout = playout;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EraseItems(bool eraseHistory)
|
||||
{
|
||||
IRequest request = eraseHistory ? new ErasePlayoutHistory(Id) : new ErasePlayoutItems(Id);
|
||||
await Mediator.Send(request, _cts.Token);
|
||||
|
||||
string message = eraseHistory ? "Erased playout items and history" : "Erased playout items";
|
||||
Snackbar.Add(message, Severity.Info);
|
||||
}
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
if (_playout is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Either<BaseError, PlayoutNameViewModel> result =
|
||||
await Mediator.Send(new UpdateScriptedPlayout(_playout.PlayoutId, _playout.ScheduleFile), _cts.Token);
|
||||
|
||||
result.Match(
|
||||
_ => { Snackbar.Add($"Saved scripted schedule for playout {_channelName}", Severity.Success); },
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving scripted schedule: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving scripted schedule: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,974 +0,0 @@
|
||||
@page "/search"
|
||||
@inherits MultiSelectBase<Search>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject PersistentComponentState ApplicationState
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@implements IDisposable
|
||||
|
||||
<MudForm Style="max-height: 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%; align-items: center" class="ml-6 mr-6">
|
||||
@if (IsSelectMode())
|
||||
{
|
||||
<div class="flex-grow-1">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
|
||||
</div>
|
||||
<div style="margin-left: auto" class="d-none d-md-flex">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(_ => AddSelectionToCollection())">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.PlaylistAdd"
|
||||
OnClick="@(_ => AddSelectionToPlaylist())">
|
||||
Add To Playlist
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</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.Add" Label="Add To Collection" OnClick="@AddSelectionToCollection"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add To Playlist" OnClick="AddSelectionToPlaylist"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Check" Label="Clear Selection" OnClick="ClearSelection"/>
|
||||
</MudMenu>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="align-items: center; display: flex; width: 100%" class="d-none d-md-flex">
|
||||
<MudText>@_query</MudText>
|
||||
@if (_movies?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")" Style="margin-bottom: auto; margin-top: auto">@_movies.Count Movies</MudLink>
|
||||
}
|
||||
|
||||
@if (_shows?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
|
||||
}
|
||||
|
||||
@if (_seasons?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#seasons")" Style="margin-bottom: auto; margin-top: auto">@_seasons.Count Seasons</MudLink>
|
||||
}
|
||||
|
||||
@if (_episodes?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
|
||||
}
|
||||
|
||||
@if (_artists?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
|
||||
}
|
||||
|
||||
@if (_musicVideos?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
|
||||
}
|
||||
|
||||
@if (_otherVideos?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#other_videos")" Style="margin-bottom: auto; margin-top: auto">@_otherVideos.Count Other Videos</MudLink>
|
||||
}
|
||||
|
||||
@if (_songs?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#songs")" Style="margin-bottom: auto; margin-top: auto">@_songs.Count Songs</MudLink>
|
||||
}
|
||||
|
||||
@if (_images?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#images")" Style="margin-bottom: auto; margin-top: auto">@_images.Count Images</MudLink>
|
||||
}
|
||||
|
||||
@if (_remoteStreams?.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#remote_streams")" Style="margin-bottom: auto; margin-top: auto">@_remoteStreams.Count Remote Streams</MudLink>
|
||||
}
|
||||
<div class="flex-grow-1 d-none d-md-flex"></div>
|
||||
<div>
|
||||
<MudTooltip Text="Add All To Collection">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddAllToCollection">
|
||||
Add All
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Add All To Playlist">
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.PlaylistAdd"
|
||||
OnClick="@AddAllToPlaylist">
|
||||
Add All
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Save As Smart Collection">
|
||||
<MudButton Class="ml-3" Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="@SaveAsSmartCollection">
|
||||
Save As
|
||||
</MudButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div style="align-items: center; display: flex; width: 100%" class="d-md-none">
|
||||
<div class="flex-grow-1"></div>
|
||||
<MudMenu Icon="@Icons.Material.Filled.MoreVert">
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Add" Label="Add All To Collection" OnClick="@AddAllToCollection"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.PlaylistAdd" Label="Add All To Playlist" OnClick="AddAllToPlaylist"/>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Save" Label="Save As Smart Collection" OnClick="SaveAsSmartCollection"/>
|
||||
</MudMenu>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
@if (_movies?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
|
||||
Movies
|
||||
</MudText>
|
||||
@if (_movies.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/movies/{card.MovieId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_shows?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
|
||||
Shows
|
||||
</MudText>
|
||||
@if (_shows.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/tv/shows/{card.TelevisionShowId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_seasons?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
|
||||
Seasons
|
||||
</MudText>
|
||||
@if (_seasons.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_episodes?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
|
||||
Episodes
|
||||
</MudText>
|
||||
@if (_episodes.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
Href="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
|
||||
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_artists?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
|
||||
Artists
|
||||
</MudText>
|
||||
@if (_artists.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href="@($"media/music/artists/{card.ArtistId}")"
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_musicVideos?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
|
||||
Music Videos
|
||||
</MudText>
|
||||
@if (_musicVideos.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_otherVideos?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
|
||||
Other Videos
|
||||
</MudText>
|
||||
@if (_otherVideos.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_songs?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
|
||||
Songs
|
||||
</MudText>
|
||||
@if (_songs.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetSongsLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (SongCardViewModel card in _songs.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_images?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "images" } })">
|
||||
Images
|
||||
</MudText>
|
||||
@if (_images.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetImagesLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (ImageCardViewModel card in _images.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@if (_remoteStreams?.Count > 0)
|
||||
{
|
||||
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||
<MudText Typo="Typo.h4" UserAttributes="@(new Dictionary<string, object> { { "id", "remote_streams" } })">
|
||||
Remote Streams
|
||||
</MudText>
|
||||
@if (_remoteStreams.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetRemoteStreamsLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
<MudDivider Class="mb-6"/>
|
||||
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mb-10">
|
||||
@foreach (RemoteStreamCardViewModel card in _remoteStreams.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private string _query;
|
||||
private MovieCardResultsViewModel _movies;
|
||||
private TelevisionShowCardResultsViewModel _shows;
|
||||
private TelevisionSeasonCardResultsViewModel _seasons;
|
||||
private TelevisionEpisodeCardResultsViewModel _episodes;
|
||||
private MusicVideoCardResultsViewModel _musicVideos;
|
||||
private OtherVideoCardResultsViewModel _otherVideos;
|
||||
private SongCardResultsViewModel _songs;
|
||||
private ImageCardResultsViewModel _images;
|
||||
private RemoteStreamCardResultsViewModel _remoteStreams;
|
||||
private ArtistCardResultsViewModel _artists;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
if (!ApplicationState.TryTakeFromJson("_movies", out MovieCardResultsViewModel restoredMovies))
|
||||
{
|
||||
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_movies = restoredMovies;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_shows", out TelevisionShowCardResultsViewModel restoredShows))
|
||||
{
|
||||
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_shows = restoredShows;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_seasons", out TelevisionSeasonCardResultsViewModel restoredSeasons))
|
||||
{
|
||||
_seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_seasons = restoredSeasons;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_episodes", out TelevisionEpisodeCardResultsViewModel restoredEpisodes))
|
||||
{
|
||||
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_episodes = restoredEpisodes;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_musicVideos", out MusicVideoCardResultsViewModel restoredMusicVideos))
|
||||
{
|
||||
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_musicVideos = restoredMusicVideos;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_otherVideos", out OtherVideoCardResultsViewModel restoredOtherVideos))
|
||||
{
|
||||
_otherVideos = await Mediator.Send(new QuerySearchIndexOtherVideos($"type:other_video AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_otherVideos = restoredOtherVideos;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_songs", out SongCardResultsViewModel restoredSongs))
|
||||
{
|
||||
_songs = await Mediator.Send(new QuerySearchIndexSongs($"type:song AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_songs = restoredSongs;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_images", out ImageCardResultsViewModel restoredImages))
|
||||
{
|
||||
_images = await Mediator.Send(new QuerySearchIndexImages($"type:image AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_images = restoredImages;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_remoteStreams", out RemoteStreamCardResultsViewModel restoredRemoteStreams))
|
||||
{
|
||||
_remoteStreams = await Mediator.Send(new QuerySearchIndexRemoteStreams($"type:remote_stream AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_remoteStreams = restoredRemoteStreams;
|
||||
}
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_artists", out ArtistCardResultsViewModel restoredArtists))
|
||||
{
|
||||
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50), CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_artists = restoredArtists;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task PersistData()
|
||||
{
|
||||
ApplicationState.PersistAsJson("_movies", _movies);
|
||||
ApplicationState.PersistAsJson("_shows", _shows);
|
||||
ApplicationState.PersistAsJson("_seasons", _seasons);
|
||||
ApplicationState.PersistAsJson("_episodes", _episodes);
|
||||
ApplicationState.PersistAsJson("_musicVideos", _musicVideos);
|
||||
ApplicationState.PersistAsJson("_otherVideos", _otherVideos);
|
||||
ApplicationState.PersistAsJson("_songs", _songs);
|
||||
ApplicationState.PersistAsJson("_images", _images);
|
||||
ApplicationState.PersistAsJson("_remoteStreams", _remoteStreams);
|
||||
ApplicationState.PersistAsJson("_artists", _artists);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _movies.Cards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
|
||||
.Append(_seasons.Cards.OrderBy(s => s.SortTitle))
|
||||
.Append(_episodes.Cards.OrderBy(ep => ep.SortTitle))
|
||||
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
|
||||
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
|
||||
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
|
||||
.Append(_images.Cards.OrderBy(ov => ov.SortTitle))
|
||||
.Append(_remoteStreams.Cards.OrderBy(ov => ov.SortTitle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is MovieCardViewModel movie)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", movie.Title } };
|
||||
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 AddMovieToCollection(collection.Id, movie.MovieId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding movie to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding movie to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {movie.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
if (card is TelevisionShowCardViewModel show)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", show.Title } };
|
||||
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 AddShowToCollection(collection.Id, show.TelevisionShowId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding show to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding show to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
if (card is TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
|
||||
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 AddEpisodeToCollection(collection.Id, episode.EpisodeId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } };
|
||||
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 AddArtistToCollection(collection.Id, artist.ArtistId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding artist to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding artist to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {artist.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
if (card is MusicVideoCardViewModel musicVideo)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } };
|
||||
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 AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding music video to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding music video to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
if (card is OtherVideoCardViewModel otherVideo)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "other video" }, { "EntityName", otherVideo.Title } };
|
||||
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 AddOtherVideoToCollection(collection.Id, otherVideo.OtherVideoId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding other video to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding other video to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {otherVideo.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
if (card is SongCardViewModel song)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "song" }, { "EntityName", song.Title } };
|
||||
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 AddSongToCollection(collection.Id, song.SongId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding song to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding song to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {song.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
if (card is ImageCardViewModel image)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "image" }, { "EntityName", image.Title } };
|
||||
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 AddImageToCollection(collection.Id, image.ImageId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding image to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding image to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {image.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
if (card is RemoteStreamCardViewModel remoteStream)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "remote stream" }, { "EntityName", remoteStream.Title } };
|
||||
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 AddMediaItemToCollection(collection.Id, remoteStream.RemoteStreamId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding remote stream to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding remote stream to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {remoteStream.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetMoviesLink()
|
||||
{
|
||||
var uri = "media/movies/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetShowsLink()
|
||||
{
|
||||
var uri = "media/tv/shows/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetSeasonsLink()
|
||||
{
|
||||
var uri = "media/tv/seasons/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetEpisodesLink()
|
||||
{
|
||||
var uri = "media/tv/episodes/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetArtistsLink()
|
||||
{
|
||||
var uri = "media/music/artists/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetMusicVideosLink()
|
||||
{
|
||||
var uri = "media/music/videos/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetOtherVideosLink()
|
||||
{
|
||||
var uri = "media/other/videos/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetSongsLink()
|
||||
{
|
||||
var uri = "media/music/songs/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetImagesLink()
|
||||
{
|
||||
var uri = "media/images/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetRemoteStreamsLink()
|
||||
{
|
||||
var uri = "media/remote/streams/page/1";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private async Task AddAllToCollection(MouseEventArgs _)
|
||||
{
|
||||
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query), CancellationToken);
|
||||
await AddItemsToCollection(
|
||||
results.MovieIds,
|
||||
results.ShowIds,
|
||||
results.SeasonIds,
|
||||
results.EpisodeIds,
|
||||
results.ArtistIds,
|
||||
results.MusicVideoIds,
|
||||
results.OtherVideoIds,
|
||||
results.SongIds,
|
||||
results.ImageIds,
|
||||
results.RemoteStreamIds,
|
||||
"search results");
|
||||
}
|
||||
|
||||
private async Task AddAllToPlaylist(MouseEventArgs _)
|
||||
{
|
||||
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query), CancellationToken);
|
||||
await AddItemsToPlaylist(
|
||||
results.MovieIds,
|
||||
results.ShowIds,
|
||||
results.SeasonIds,
|
||||
results.EpisodeIds,
|
||||
results.ArtistIds,
|
||||
results.MusicVideoIds,
|
||||
results.OtherVideoIds,
|
||||
results.SongIds,
|
||||
results.ImageIds,
|
||||
results.RemoteStreamIds,
|
||||
"search results");
|
||||
}
|
||||
|
||||
private async Task SaveAsSmartCollection(MouseEventArgs _)
|
||||
{
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
var parameters = new DialogParameters<SaveAsSmartCollectionDialog>
|
||||
{
|
||||
{ x => x.Query, _query }
|
||||
};
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<SaveAsSmartCollectionDialog>("Save As Smart Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: Tuple<SmartCollectionViewModel, bool> collectionResult })
|
||||
{
|
||||
// Item2 => update is needed
|
||||
if (collectionResult.Item2)
|
||||
{
|
||||
var request = new UpdateSmartCollection(
|
||||
collectionResult.Item1.Id,
|
||||
collectionResult.Item1.Name,
|
||||
_query);
|
||||
|
||||
Either<BaseError, UpdateSmartCollectionResult> updateResult = await Mediator.Send(request, CancellationToken);
|
||||
updateResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving smart collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error saving smart collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ =>
|
||||
{
|
||||
Snackbar.Add(
|
||||
$"Saved smart collection {collectionResult.Item1.Name}",
|
||||
Severity.Success);
|
||||
ClearSelection();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
@page "/playouts/sequential/{Id:int}"
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Playouts
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IEntityLocker EntityLocker;
|
||||
@inject ILogger<SequentialPlayoutEditor> Logger
|
||||
|
||||
<MudForm 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-6" OnClick="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
|
||||
Save Sequential Schedule
|
||||
</MudButton>
|
||||
</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">@_channelName - Sequential Schedule</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>Sequential Schedule</MudText>
|
||||
</div>
|
||||
@if (_playout is not null)
|
||||
{
|
||||
<MudTextField @bind-Value="@_playout.ScheduleFile" For="@(() => _playout.ScheduleFile)"/>
|
||||
}
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Maintenance</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>Playout Items and History</MudText>
|
||||
</div>
|
||||
<MudButton Disabled="@EntityLocker.IsPlayoutLocked(Id)" Variant="Variant.Filled" Color="Color.Error" OnClick="@(_ => EraseItems(eraseHistory: true))" StartIcon="@Icons.Material.Filled.Delete">
|
||||
Erase Items and History
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private PlayoutNameViewModel _playout;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private string _channelName;
|
||||
|
||||
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<string> maybeName = await Mediator.Send(new GetChannelNameByPlayoutId(Id), token);
|
||||
if (maybeName.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("playouts");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string name in maybeName)
|
||||
{
|
||||
_channelName = name;
|
||||
}
|
||||
|
||||
Option<PlayoutNameViewModel> maybePlayout = await Mediator.Send(new GetPlayoutById(Id), token);
|
||||
foreach (PlayoutNameViewModel playout in maybePlayout)
|
||||
{
|
||||
_playout = playout;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EraseItems(bool eraseHistory)
|
||||
{
|
||||
IRequest request = eraseHistory ? new ErasePlayoutHistory(Id) : new ErasePlayoutItems(Id);
|
||||
await Mediator.Send(request, _cts.Token);
|
||||
|
||||
string message = eraseHistory ? "Erased playout items and history" : "Erased playout items";
|
||||
Snackbar.Add(message, Severity.Info);
|
||||
}
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
if (_playout is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Either<BaseError, PlayoutNameViewModel> result =
|
||||
await Mediator.Send(new UpdateSequentialPlayout(_playout.PlayoutId, _playout.ScheduleFile), _cts.Token);
|
||||
|
||||
result.Match(
|
||||
_ => { Snackbar.Add($"Saved sequential schedule for playout {_channelName}", Severity.Success); },
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving sequential schedule: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving sequential schedule: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
@page "/settings/ffmpeg"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.Filler
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.Resolutions
|
||||
@using ErsatzTV.Application.Watermarks
|
||||
@using ErsatzTV.Core.Domain.Filler
|
||||
@using ErsatzTV.Core.FFmpeg
|
||||
@using ErsatzTV.FFmpeg.OutputFormat
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<FFmpegSettings> Logger
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<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" OnClick="@(_ => SaveFFmpegSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</MudButton>
|
||||
</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">FFmpeg</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>FFmpeg Path</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_ffmpegSettings.FFmpegPath" HelperText="The full path to the ffmpeg executable file" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>FFprobe Path</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_ffmpegSettings.FFprobePath" HelperText="The full path to the ffprobe executable file" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Default FFmpeg Profile</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" HelperText="The FFmpeg Profile to use when creating new channels">
|
||||
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
|
||||
{
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Preferred Audio Language</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_ffmpegSettings.PreferredAudioLanguageCode" Required="true" RequiredError="Preferred Language Code is required!">
|
||||
@foreach (LanguageCodeViewModel culture in _availableCultures)
|
||||
{
|
||||
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Use Embedded Subtitles</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="_ffmpegSettings.UseEmbeddedSubtitles" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Extract Embedded (Text) Subtitles</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="_ffmpegSettings.ExtractEmbeddedSubtitles" Disabled="@(_ffmpegSettings.UseEmbeddedSubtitles == false)" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Probe For Interlaced Frames</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="_ffmpegSettings.ProbeForInterlacedFrames" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Save Troubleshooting Reports To Disk</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="_ffmpegSettings.SaveReports" Dense="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Global Watermark</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_ffmpegSettings.GlobalWatermarkId" Clearable="true">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
||||
@foreach (WatermarkViewModel watermark in _watermarks)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@watermark.Id">@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>Global Fallback Filler</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_ffmpegSettings.GlobalFallbackFillerId" Clearable="true">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
|
||||
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>HLS Segmenter Idle Timeout</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_ffmpegSettings.HlsSegmenterIdleTimeout" Validation="@(new Func<int, string>(ValidateHlsSegmenterIdleTimeout))" Required="true" RequiredError="HLS Segmenter idle timeout is required!" Adornment="Adornment.End" AdornmentText="seconds" HelperText="The number of seconds to continue transcoding a channel while no requests have been received from any client"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Work-Ahead HLS Segmenter Limit</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_ffmpegSettings.WorkAheadSegmenterLimit" Validation="@(new Func<int, string>(ValidateWorkAheadSegmenterLimit))" Required="true" RequiredError="Work-ahead HLS Segmenter limit is required!" HelperText="The number of segmenters (channels) that will work-ahead (transcode at maximum speed) simultaneously, if multiple channels are being watched"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>HLS Segmenter Initial Segment Count</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_ffmpegSettings.InitialSegmentCount" Validation="@(new Func<int, string>(ValidateInitialSegmentCount))" Required="true" RequiredError="HLS Segmenter initial segment count is required!" HelperText="Delays stream start until the specified number of (4-second) segments have been transcoded; a larger number will mean slower initial playback, but potentially less buffering"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>HLS Direct Output Format</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_ffmpegSettings.HlsDirectOutputFormat" Clearable="true">
|
||||
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.MpegTs">MPEG-TS</MudSelectItem>
|
||||
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mp4">MP4</MudSelectItem>
|
||||
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mkv">MKV</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Default MPEG-TS Script</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_ffmpegSettings.DefaultMpegTsScript" HelperText="The MPEG-TS script to use when streaming">
|
||||
@foreach (MpegTsScript script in _mpegTsScripts)
|
||||
{
|
||||
<MudSelectItem Value="@script.Id">@script.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mb-2 mt-10">Custom Resolutions</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddCustomResolution())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Custom Resolution
|
||||
</MudButton>
|
||||
<MudTable Hover="true" Items="_customResolutions" Dense="true" Class="mt-6 mb-12" Style="max-width: 800px">
|
||||
<ColGroup>
|
||||
<col/>
|
||||
<col style="width: 60px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Resolution">@context.Name</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(() => DeleteCustomResolution(context))">
|
||||
</MudIconButton>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudForm _form;
|
||||
private bool _success;
|
||||
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
|
||||
private FFmpegSettingsViewModel _ffmpegSettings = new();
|
||||
private List<LanguageCodeViewModel> _availableCultures = [];
|
||||
private List<WatermarkViewModel> _watermarks = [];
|
||||
private List<FillerPresetViewModel> _fillerPresets = [];
|
||||
private List<ResolutionViewModel> _customResolutions = [];
|
||||
private readonly List<MpegTsScript> _mpegTsScripts = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), token);
|
||||
|
||||
_ffmpegSettings = await Mediator.Send(new GetFFmpegSettings(), token);
|
||||
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
|
||||
_availableCultures = await Mediator.Send(new GetAllLanguageCodes(), token);
|
||||
_watermarks = await Mediator.Send(new GetAllWatermarks(), token);
|
||||
_fillerPresets = await Mediator.Send(new GetAllFillerPresets(), token)
|
||||
.Map(list => list.Filter(fp => fp.FillerKind == FillerKind.Fallback).ToList());
|
||||
|
||||
_mpegTsScripts.Clear();
|
||||
_mpegTsScripts.AddRange(await Mediator.Send(new GetMpegTsScripts(), token));
|
||||
|
||||
await RefreshCustomResolutions(token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
|
||||
|
||||
private static string ValidateHlsSegmenterIdleTimeout(int idleTimeout) => idleTimeout < 30 ? "HLS Segmenter idle timeout must be greater than or equal to 30" : null;
|
||||
|
||||
private static string ValidateWorkAheadSegmenterLimit(int limit) => limit < 0 ? "Work-Ahead HLS Segmenter limit must be greater than or equal to 0" : null;
|
||||
|
||||
private static string ValidateInitialSegmentCount(int count) => count < 1 ? "HLS Segmenter initial segment count must be greater than or equal to 1" : null;
|
||||
|
||||
|
||||
private async Task SaveFFmpegSettings()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_success)
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateFFmpegSettings(_ffmpegSettings), _cts.Token);
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving FFmpeg settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ =>
|
||||
{
|
||||
Snackbar.Add("Successfully saved FFmpeg settings", Severity.Success);
|
||||
_success = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshCustomResolutions(CancellationToken cancellationToken) =>
|
||||
_customResolutions = await Mediator.Send(new GetAllResolutions(), cancellationToken)
|
||||
.Map(list => list.Filter(r => r.IsCustom).ToList());
|
||||
|
||||
private async Task AddCustomResolution()
|
||||
{
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<AddCustomResolutionDialog>("Add Custom Resolution", options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: ResolutionEditViewModel resolution })
|
||||
{
|
||||
Option<BaseError> saveResult = await Mediator.Send(
|
||||
new CreateCustomResolution(resolution.Width, resolution.Height),
|
||||
_cts.Token);
|
||||
foreach (BaseError error in saveResult)
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding custom resolution: {Error}", error.Value);
|
||||
}
|
||||
|
||||
if (saveResult.IsNone)
|
||||
{
|
||||
await RefreshCustomResolutions(_cts.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteCustomResolution(ResolutionViewModel resolution)
|
||||
{
|
||||
Option<BaseError> result = await Mediator.Send(new DeleteCustomResolution(resolution.Id), _cts.Token);
|
||||
foreach (BaseError error in result)
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error deleting custom resolution: {Error}", error.Value);
|
||||
}
|
||||
|
||||
if (result.IsNone)
|
||||
{
|
||||
await RefreshCustomResolutions(_cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
@page "/settings/hdhr"
|
||||
@using ErsatzTV.Annotations
|
||||
@using ErsatzTV.Application.HDHR
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<HDHRSettings> Logger
|
||||
|
||||
<MudForm @ref="_form" @bind-IsValid="@_hdhrSuccess" 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" OnClick="@(_ => SaveHDHRSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</MudButton>
|
||||
</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">HDHomeRun</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>UUID</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_uuid" Disabled="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Tuner Count</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_tunerCount" Validation="@(new Func<int, string>(ValidateTunerCount))" Required="true" RequiredError="Tuner count is required!"/>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudForm _form;
|
||||
private bool _hdhrSuccess;
|
||||
private int _tunerCount;
|
||||
|
||||
[UsedImplicitly]
|
||||
private Guid _uuid;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_tunerCount = await Mediator.Send(new GetHDHRTunerCount(), token);
|
||||
_uuid = await Mediator.Send(new GetHDHRUUID(), token);
|
||||
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private static string ValidateTunerCount(int tunerCount) => tunerCount <= 0 ? "Tuner count must be greater than zero" : null;
|
||||
|
||||
private async Task SaveHDHRSettings()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_hdhrSuccess)
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateHDHRTunerCount(_tunerCount), _cts.Token);
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving HDHomeRun settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved HDHomeRun settings", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
@page "/settings/logging"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using Serilog.Events
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<LoggingSettings> Logger
|
||||
|
||||
<MudForm 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" OnClick="@(_ => SaveLoggingSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</MudButton>
|
||||
</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">Logging</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>Default Minimum Log Level</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_loggingSettings.DefaultMinimumLogLevel">
|
||||
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Scanning Minimum Log Level</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_loggingSettings.ScanningMinimumLogLevel">
|
||||
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Scheduling Minimum Log Level</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_loggingSettings.SchedulingMinimumLogLevel">
|
||||
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Searching Minimum Log Level</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_loggingSettings.SearchingMinimumLogLevel">
|
||||
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Streaming Minimum Log Level</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_loggingSettings.StreamingMinimumLogLevel">
|
||||
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Request Logging Minimum Log Level</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_loggingSettings.HttpMinimumLogLevel">
|
||||
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
|
||||
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private LoggingSettingsViewModel _loggingSettings = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_loggingSettings = await Mediator.Send(new GetLoggingSettings(), token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveLoggingSettings()
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateLoggingSettings(_loggingSettings), _cts.Token);
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving logging settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved logging settings", Severity.Success));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
@page "/settings/playout"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<PlayoutSettings> Logger
|
||||
|
||||
<MudForm @ref="_form" @bind-IsValid="@_playoutSuccess" 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" OnClick="@(_ => SavePlayoutSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</MudButton>
|
||||
</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">Playout</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>Days To Build</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_playoutSettings.DaysToBuild" Validation="@(new Func<int, string>(ValidatePlayoutDaysToBuild))" Required="true" RequiredError="Playout days to build is required!" Adornment="Adornment.End" AdornmentText="days"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Skip Missing Items</MudText>
|
||||
</div>
|
||||
<MudCheckBox @bind-Value="_playoutSettings.SkipMissingItems" Dense="true">
|
||||
<MudText Typo="Typo.caption" Style="font-weight: normal">Controls whether file-not-found or unavailable items should be included in playouts</MudText>
|
||||
</MudCheckBox>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Scripted Schedule Timeout</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_playoutSettings.ScriptedScheduleTimeoutSeconds" Validation="@(new Func<int, string>(ValidateScriptedScheduleTimeoutSeconds))" Required="true" RequiredError="Scripted schedule timeout is required!" Adornment="Adornment.End" AdornmentText="seconds"/>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudForm _form;
|
||||
private bool _playoutSuccess;
|
||||
private PlayoutSettingsViewModel _playoutSettings = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_playoutSettings = await Mediator.Send(new GetPlayoutSettings(), token);
|
||||
_playoutSuccess = _playoutSettings.DaysToBuild > 0 && _playoutSettings.ScriptedScheduleTimeoutSeconds >= 10;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private static string ValidatePlayoutDaysToBuild(int daysToBuild) => daysToBuild <= 0 ? "Playout days to build must be greater than zero" : null;
|
||||
|
||||
private static string ValidateScriptedScheduleTimeoutSeconds(int seconds) => seconds < 10 ? "Scripted schedule timeout seconds must be at least 10" : null;
|
||||
|
||||
private async Task SavePlayoutSettings()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_playoutSuccess)
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdatePlayoutSettings(_playoutSettings), _cts.Token);
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving playout settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved playout settings", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
@page "/settings/scanner"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<ScannerSettings> Logger
|
||||
|
||||
<MudForm @ref="_form" @bind-IsValid="@_scannerSuccess" 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" OnClick="@(_ => SaveScannerSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</MudButton>
|
||||
</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">Scanner</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>Library Refresh Interval</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_libraryRefreshInterval" Validation="@(new Func<int, string>(ValidateLibraryRefreshInterval))" Required="true" RequiredError="Library refresh interval is required!" Adornment="Adornment.End" AdornmentText="hours"/>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudForm _form;
|
||||
private bool _scannerSuccess;
|
||||
private int _libraryRefreshInterval;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_libraryRefreshInterval = await Mediator.Send(new GetLibraryRefreshInterval(), token);
|
||||
_scannerSuccess = _libraryRefreshInterval is >= 0 and < 1_000_000;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private static string ValidateLibraryRefreshInterval(int libraryRefreshInterval) => libraryRefreshInterval switch
|
||||
{
|
||||
<= -1 => "Library refresh interval must be 0 (do not refresh) or greater than zero",
|
||||
>= 1_000_000 => "Library refresh interval must be less than 1,000,000. Use 0 to disable automatic refresh",
|
||||
_ => null
|
||||
};
|
||||
|
||||
private async Task SaveScannerSettings()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_scannerSuccess)
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateLibraryRefreshInterval(_libraryRefreshInterval), _cts.Token);
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving scanner settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved scanner settings", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
@page "/settings/ui"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Core.Notifications
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<UISettings> Logger
|
||||
|
||||
<MudForm 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" OnClick="@(_ => SaveUiSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</MudButton>
|
||||
</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">UI</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>Theme</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_uiSettings.IsDarkMode">
|
||||
<MudSelectItem Value="@true">Dark</MudSelectItem>
|
||||
<MudSelectItem Value="@false">Light</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Language</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_uiSettings.Language">
|
||||
@foreach (Localization.CultureLanguage cultureLanguage in Localization.SupportedLanguages)
|
||||
{
|
||||
<MudSelectItem Value="@cultureLanguage.Culture">@cultureLanguage.Language</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private UiSettingsViewModel _uiSettings = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_uiSettings = await Mediator.Send(new GetUiSettings(), token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveUiSettings()
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateUiSettings(_uiSettings), _cts.Token);
|
||||
|
||||
foreach (var error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving ui settings: {Error}", error.Value);
|
||||
}
|
||||
|
||||
if (result.IsRight)
|
||||
{
|
||||
NavigationManager.Refresh(true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
@page "/settings/xmltv"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ISnackbar Snackbar
|
||||
@inject ILogger<XMLTVSettings> Logger
|
||||
|
||||
<MudForm 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" OnClick="@(_ => SaveXmltvSettings())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">Save Settings</MudButton>
|
||||
</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">XMLTV</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>Days To Build</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_xmltvSettings.DaysToBuild" Validation="@(new Func<int, string>(ValidateXmltvDaysToBuild))" Required="true" RequiredError="XMLTV days to build is required!" Adornment="Adornment.End" AdornmentText="days"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>XMLTV Time Zone</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_xmltvSettings.TimeZone">
|
||||
<MudSelectItem Value="@XmltvTimeZone.Local">Local</MudSelectItem>
|
||||
<MudSelectItem Value="@XmltvTimeZone.Utc">UTC</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>XMLTV Block Behavior</MudText>
|
||||
</div>
|
||||
<MudSelect @bind-Value="_xmltvSettings.BlockBehavior">
|
||||
<MudSelectItem Value="@XmltvBlockBehavior.SplitTimeEvenly">Split Time Evenly</MudSelectItem>
|
||||
<MudSelectItem Value="@XmltvBlockBehavior.UseActualTimes">Use Actual Times</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private XmltvSettingsViewModel _xmltvSettings = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_xmltvSettings = await Mediator.Send(new GetXmltvSettings(), token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private static string ValidateXmltvDaysToBuild(int daysToBuild) => daysToBuild <= 0 ? "XMLTV days to build must be greater than zero" : null;
|
||||
|
||||
private async Task SaveXmltvSettings()
|
||||
{
|
||||
Either<BaseError, Unit> result = await Mediator.Send(new UpdateXmltvSettings(_xmltvSettings), _cts.Token);
|
||||
result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving xmltv settings: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add("Successfully saved xmltv settings", Severity.Success));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
@page "/media/smart-collections/{Id:int}/edit"
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<CollectionEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@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-6" OnClick="@HandleSubmitAsync" StartIcon="@Icons.Material.Filled.Save">
|
||||
Save Smart Collection
|
||||
</MudButton>
|
||||
</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">Smart Collection</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>Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Smart collection name is required!"/>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private readonly SmartCollectionEditViewModel _model = new();
|
||||
private MudForm _form;
|
||||
private bool _success;
|
||||
|
||||
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<SmartCollectionViewModel> maybeSmartCollection = await Mediator.Send(new GetSmartCollectionById(Id), token);
|
||||
foreach (SmartCollectionViewModel smartCollection in maybeSmartCollection)
|
||||
{
|
||||
_model.Id = smartCollection.Id;
|
||||
_model.Name = smartCollection.Name;
|
||||
_model.Query = smartCollection.Query;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleSubmitAsync()
|
||||
{
|
||||
await _form.Validate();
|
||||
if (_success)
|
||||
{
|
||||
Either<BaseError, UpdateSmartCollectionResult> result = await Mediator.Send(new UpdateSmartCollection(Id, _model.Name, _model.Query), _cts.Token);
|
||||
result.Match(
|
||||
_ => NavigationManager.NavigateTo("media/smart-collections"),
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Error saving smart collection: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
@page "/media/smart-collections"
|
||||
@using ErsatzTV.Application.Configuration
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Extensions
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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">Smart Collections</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudTable Hover="true"
|
||||
@bind-RowsPerPage="@_smartCollectionsRowsPerPage"
|
||||
ServerData="@(new Func<TableState, CancellationToken, Task<TableData<SmartCollectionViewModel>>>(ServerReloadSmartCollections))"
|
||||
Dense="true"
|
||||
@ref="_smartCollectionsTable">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col/>
|
||||
<col style="width: 180px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string"
|
||||
ValueChanged="@(s => OnSearch(s))"
|
||||
Placeholder="Search for smart collections"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.FilterList"
|
||||
Clearable="true">
|
||||
</MudTextField>
|
||||
</ToolBarContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Smart Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"/media/smart-collections/{context.Id}/edit")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Search Smart Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Search"
|
||||
Href="@context.Query.GetRelativeSearchQuery()">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Smart Collection">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteSmartCollection(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager/>
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
<div class="mt-6"></div>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
private MudTable<SmartCollectionViewModel> _smartCollectionsTable;
|
||||
|
||||
private int _smartCollectionsRowsPerPage = 10;
|
||||
private string _searchString;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_smartCollectionsRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.SmartCollectionsPageSize), token)
|
||||
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSmartCollection(SmartCollectionViewModel collection)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "smart collection" }, { "EntityName", collection.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Smart Collection", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false })
|
||||
{
|
||||
await Mediator.Send(new DeleteSmartCollection(collection.Id), _cts.Token);
|
||||
if (_smartCollectionsTable != null)
|
||||
{
|
||||
await _smartCollectionsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TableData<SmartCollectionViewModel>> ServerReloadSmartCollections(TableState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SmartCollectionsPageSize, state.PageSize.ToString()), cancellationToken);
|
||||
|
||||
PagedSmartCollectionsViewModel data = await Mediator.Send(new GetPagedSmartCollections(_searchString, state.Page, state.PageSize), cancellationToken);
|
||||
return new TableData<SmartCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
|
||||
}
|
||||
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
_searchString = query;
|
||||
_smartCollectionsTable.ReloadServerData();
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
@page "/media/music/songs"
|
||||
@page "/media/music/songs/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits MultiSelectBase<SongList>
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="SongCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href=""
|
||||
ArtworkKind="ArtworkKind.Thumbnail"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/music/songs"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private SongCardResultsViewModel _data = new(0, new List<SongCardViewModel>(), null);
|
||||
private string _query;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:song" : $"type:song AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexSongs(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/music/songs/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/music/songs/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is SongCardViewModel song)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "song" }, { "EntityName", song.Title } };
|
||||
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 AddSongToCollection(collection.Id, song.SongId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding song to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding song to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {song.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
@page "/media/tv/seasons/{SeasonId:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Application.Television
|
||||
@using ErsatzTV.Extensions
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<TelevisionEpisodeList> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JsRuntime
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
|
||||
<div class="fanart-tint"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(_season?.FanArt))
|
||||
{
|
||||
if (_season.FanArt.StartsWith("http://") || _season.FanArt.StartsWith("https://"))
|
||||
{
|
||||
<img src="@_season.FanArt" alt="fan art"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img src="@($"artwork/fanart/{_season.FanArt}")" alt="fan art"/>
|
||||
}
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
|
||||
@if (!string.IsNullOrWhiteSpace(_season?.Poster))
|
||||
{
|
||||
<MudImage Src="@GetPosterUrl(_season.Poster)" Elevation="2" Class="rounded-lg z-10" Style="max-height: 325px; margin-left: auto; margin-right: auto"/>
|
||||
}
|
||||
<MudStack Row="false" Style="flex: 1" Class="z-10">
|
||||
<MudStack Row="false">
|
||||
<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>
|
||||
</MudHidden>
|
||||
</MudLink>
|
||||
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_season?.Name</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.PlaylistAdd"
|
||||
OnClick="@AddToPlaylist">
|
||||
Add To Playlist
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Schedule"
|
||||
OnClick="@AddToSchedule">
|
||||
Add To Schedule
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
|
||||
<MudStack Row="false" Spacing="6">
|
||||
@foreach (TelevisionEpisodeCardViewModel episode in _data.Cards)
|
||||
{
|
||||
<MudCard>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" id="@($"episode-{episode.EpisodeId}")">
|
||||
@if (!string.IsNullOrWhiteSpace(episode.Poster))
|
||||
{
|
||||
<div style="display: flex; flex-direction: column; position: relative">
|
||||
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
|
||||
<MudImage Src="@GetThumbnailUrl(episode.Poster)" Style="max-height: 220px; max-width: 265px; margin-left: auto; margin-right: auto;"/>
|
||||
</MudHidden>
|
||||
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
|
||||
<MudImage Src="@GetThumbnailUrl(episode.Poster)" Style="max-height: 220px; max-width: 392px; margin-left: auto; margin-right: auto;"/>
|
||||
</MudHidden>
|
||||
@if (episode.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 (episode.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>
|
||||
}
|
||||
<MudCardContent Class="ml-3">
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h4" Class="mb-6">@episode.Episode. @episode.Title</MudText>
|
||||
<MudText Class="d-none d-md-flex" Style="flex-grow: 1">@episode.Plot</MudText>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown">
|
||||
<MudMenu Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Label="Add To ...">
|
||||
<MudMenuItem OnClick="@(_ => AddEpisodeToCollection(episode))" Icon="@Icons.Material.Filled.Add">
|
||||
Add To Collection
|
||||
</MudMenuItem>
|
||||
<MudMenuItem OnClick="@(_ => AddEpisodeToPlaylist(episode))" Icon="@Icons.Material.Filled.PlaylistAdd">
|
||||
Add To Playlist
|
||||
</MudMenuItem>
|
||||
</MudMenu>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Info"
|
||||
OnClick="@(_ => ShowInfo(episode))">
|
||||
Show Media Info
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Troubleshoot"
|
||||
Href="@($"system/troubleshooting/playback?mediaItem={episode.EpisodeId}")">
|
||||
Troubleshoot Playback
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudStack>
|
||||
<div class="pl-3 pt-3">
|
||||
@if (episode.State == MediaItemState.FileNotFound)
|
||||
{
|
||||
<div class="mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
|
||||
<MudText>File Not Found: </MudText>
|
||||
<MudText>@episode.Path</MudText>
|
||||
</div>
|
||||
}
|
||||
else if (episode.State == MediaItemState.Unavailable)
|
||||
{
|
||||
<div class="mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
|
||||
<MudText>Unavailable: </MudText>
|
||||
<MudText>@episode.LocalPath</MudText>
|
||||
</div>
|
||||
}
|
||||
<div class="d-none d-md-flex" style="flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText>
|
||||
</div>
|
||||
@if (episode.Directors.Any())
|
||||
{
|
||||
var sorted = episode.Directors.OrderBy(w => w).ToList();
|
||||
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Directors: </MudText>
|
||||
<MudLink Href="@(@$"director:""{sorted.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@sorted.Head()</MudLink>
|
||||
@foreach (string director in sorted.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"director:""{director.ToLowerInvariant()}""".GetRelativeSearchQuery())">@director</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (episode.Writers.Any())
|
||||
{
|
||||
var sorted = episode.Writers.OrderBy(w => w).ToList();
|
||||
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Writers: </MudText>
|
||||
<MudLink Href="@(@$"writer:""{sorted.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@sorted.Head()</MudLink>
|
||||
@foreach (string writer in sorted.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"writer:""{writer.ToLowerInvariant()}""".GetRelativeSearchQuery())">@writer</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudCard>
|
||||
}
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int SeasonId { get; set; }
|
||||
|
||||
private TelevisionSeasonViewModel _season;
|
||||
|
||||
private static int PageSize => 100;
|
||||
private const int PageNumber = 1;
|
||||
|
||||
private TelevisionEpisodeCardResultsViewModel _data = new(0, new List<TelevisionEpisodeCardViewModel>(), null);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
await NavigationManager.NavigateToFragmentAsync(JsRuntime);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await Mediator.Send(new GetTelevisionSeasonById(SeasonId), token).IfSomeAsync(vm => _season = vm);
|
||||
_data = await Mediator.Send(new GetTelevisionEpisodeCards(SeasonId, PageNumber, PageSize), token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToCollection()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Name}" } };
|
||||
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 })
|
||||
{
|
||||
await Mediator.Send(new AddSeasonToCollection(collection.Id, SeasonId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToPlaylist()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Name}" } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
|
||||
{
|
||||
await Mediator.Send(new AddSeasonToPlaylist(playlist.Id, SeasonId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"media/playlists/{playlist.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToSchedule()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", $"{_season.Title} - {_season.Name}" } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
|
||||
{
|
||||
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, CollectionType.TelevisionSeason, SeasonId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddEpisodeToCollection(TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
|
||||
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 AddEpisodeToCollection(collection.Id, episode.EpisodeId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddEpisodeToPlaylist(TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
|
||||
{
|
||||
var request = new AddEpisodeToPlaylist(playlist.Id, episode.EpisodeId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding episode to playlist: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding episode to playlist: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {episode.Title} to playlist {playlist.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowInfo(TelevisionEpisodeCardViewModel episode)
|
||||
{
|
||||
Either<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(episode.EpisodeId));
|
||||
|
||||
foreach (BaseError error in maybeInfo.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add("Unexpected error loading media info");
|
||||
Logger.LogError("Unexpected error loading media info: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemInfo info in maybeInfo.RightToSeq())
|
||||
{
|
||||
var parameters = new DialogParameters { { "MediaItemInfo", info } };
|
||||
var options = new DialogOptions { CloseButton = true, CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
|
||||
IDialogReference dialog = await Dialog.ShowAsync<MediaItemInfoDialog>(
|
||||
$"{episode.ShowTitle} - s{episode.Season:00}e{episode.Episode:00} - {episode.Title}",
|
||||
parameters,
|
||||
options);
|
||||
DialogResult _ = await dialog.Result;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetPosterUrl(string poster) => poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}";
|
||||
|
||||
private static string GetThumbnailUrl(string poster) => poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/thumbnails/{poster}";
|
||||
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
@page "/media/tv/shows/{ShowId:int}"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Libraries
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Application.Television
|
||||
@using ErsatzTV.Extensions
|
||||
@implements IDisposable
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<TelevisionSeasonList> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
|
||||
<div class="fanart-tint"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(_show?.FanArt))
|
||||
{
|
||||
if (_show.FanArt.StartsWith("http://") || _show.FanArt.StartsWith("https://"))
|
||||
{
|
||||
<img src="@_show.FanArt" alt="fan art"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img src="@($"artwork/fanart/{_show.FanArt}")" alt="fan art"/>
|
||||
}
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
|
||||
<MudImage Src="@GetPosterUrl(_show?.Poster)" Elevation="2" Class="rounded-lg z-10" Style="max-height: 325px; margin-left: auto; margin-right: auto"/>
|
||||
<MudStack Row="false" Style="flex: 1" Class="z-10">
|
||||
<MudStack Row="false">
|
||||
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
|
||||
<MudText Typo="Typo.h4" Class="media-item-title">@_show?.Title</MudText>
|
||||
</MudHidden>
|
||||
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
|
||||
<MudText Typo="Typo.h2" Class="media-item-title">@_show?.Title</MudText>
|
||||
</MudHidden>
|
||||
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_show?.Year</MudText>
|
||||
</MudStack>
|
||||
@if (!string.IsNullOrWhiteSpace(_show?.Plot))
|
||||
{
|
||||
<MudCard Elevation="2" Class="mb-6">
|
||||
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
|
||||
<MudText Style="flex-grow: 1">@_show.Plot</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
|
||||
<MudMenu>
|
||||
<ActivatorContent>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Filled" Color="Color.Primary">Add To</MudButton>
|
||||
</ActivatorContent>
|
||||
<ChildContent>
|
||||
<MudMenuItem Label="Collection" Icon="@Icons.Material.Filled.Add" OnClick="@AddToCollection"/>
|
||||
<MudMenuItem Label="Playlist" Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="@AddToPlaylist"/>
|
||||
<MudMenuItem Label="Schedule" Icon="@Icons.Material.Filled.Schedule" OnClick="@AddToSchedule"/>
|
||||
</ChildContent>
|
||||
</MudMenu>
|
||||
@if (_show?.MediaSourceKind is MediaSourceKind.Plex or MediaSourceKind.Jellyfin or MediaSourceKind.Emby)
|
||||
{
|
||||
<MudMenu>
|
||||
<ActivatorContent>
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Color="Color.Primary">Scan</MudButton>
|
||||
</ActivatorContent>
|
||||
<ChildContent>
|
||||
<MudMenuItem Label="Quick Scan" Icon="@Icons.Material.Filled.Search" OnClick="@(_ => ScanShow(false))"/>
|
||||
<MudMenuItem Label="Deep Scan" Icon="@Icons.Material.Filled.ManageSearch" OnClick="@(_ => ScanShow(true))"/>
|
||||
</ChildContent>
|
||||
</MudMenu>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
<MudCard Class="mt-6 mb-6">
|
||||
<MudCardContent>
|
||||
@if (_sortedContentRatings.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Content Ratings: </MudText>
|
||||
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
|
||||
@foreach (string contentRating in _sortedContentRatings.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedLanguages.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Languages: </MudText>
|
||||
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
|
||||
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedStudios.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Studios: </MudText>
|
||||
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
|
||||
@foreach (string studio in _sortedStudios.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedNetworks.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Networks: </MudText>
|
||||
<MudLink Href="@(@$"network:""{_sortedNetworks.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedNetworks.Head()</MudLink>
|
||||
@foreach (string network in _sortedNetworks.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"network:""{network.ToLowerInvariant()}""".GetRelativeSearchQuery())">@network</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedGenres.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Genres: </MudText>
|
||||
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
|
||||
@foreach (string genre in _sortedGenres.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedTags.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Tags: </MudText>
|
||||
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
|
||||
@foreach (string tag in _sortedTags.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large">
|
||||
<MudText Class="mb-4">Seasons</MudText>
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _data.Cards)
|
||||
{
|
||||
<MediaCard Data="@card" Placeholder="@card.Placeholder"
|
||||
Href="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
AddToCollectionClicked="@AddSeasonToCollection"/>
|
||||
}
|
||||
</MudStack>
|
||||
@if ((_show?.Actors?.Count ?? 0) > 0)
|
||||
{
|
||||
<MudText Class="mb-4">Actors</MudText>
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
@foreach (ActorCardViewModel actor in _show.Actors)
|
||||
{
|
||||
<MediaCard Data="@actor"
|
||||
Href="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
|
||||
ArtworkKind="ArtworkKind.Thumbnail"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
|
||||
[Parameter]
|
||||
public int ShowId { get; set; }
|
||||
|
||||
private TelevisionShowViewModel _show;
|
||||
private List<string> _sortedContentRatings = [];
|
||||
private List<CultureInfo> _sortedLanguages = [];
|
||||
private List<string> _sortedStudios = [];
|
||||
private List<string> _sortedNetworks = [];
|
||||
private List<string> _sortedGenres = [];
|
||||
private List<string> _sortedTags = [];
|
||||
|
||||
private static int PageSize => 100;
|
||||
private const int PageNumber = 1;
|
||||
|
||||
private TelevisionSeasonCardResultsViewModel _data = new(0, [], null);
|
||||
|
||||
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<TelevisionShowViewModel> maybeShow = await Mediator.Send(new GetTelevisionShowById(ShowId), token);
|
||||
foreach (TelevisionShowViewModel show in maybeShow)
|
||||
{
|
||||
_show = show;
|
||||
_sortedContentRatings = _show.ContentRatings.OrderBy(cr => cr).ToList();
|
||||
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
|
||||
_sortedStudios = _show.Studios.OrderBy(s => s).ToList();
|
||||
_sortedGenres = _show.Genres.OrderBy(g => g).ToList();
|
||||
_sortedTags = _show.Tags.OrderBy(t => t).ToList();
|
||||
_sortedNetworks = _show.Networks.OrderBy(n => n).ToList();
|
||||
}
|
||||
|
||||
_data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, PageNumber, PageSize), token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToCollection()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
|
||||
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 })
|
||||
{
|
||||
await Mediator.Send(new AddShowToCollection(collection.Id, ShowId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToPlaylist()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
|
||||
{
|
||||
await Mediator.Send(new AddShowToPlaylist(playlist.Id, ShowId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"media/playlists/{playlist.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddToSchedule()
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
|
||||
{
|
||||
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, CollectionType.TelevisionShow, ShowId), _cts.Token);
|
||||
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSeasonToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is TelevisionSeasonCardViewModel season)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.Title } };
|
||||
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, _cts.Token);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetPosterUrl(string poster)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
return poster;
|
||||
}
|
||||
|
||||
return poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}";
|
||||
}
|
||||
|
||||
private async Task ScanShow(bool deepScan)
|
||||
{
|
||||
QueueShowScanResult result =
|
||||
await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan));
|
||||
if (result != QueueShowScanResult.Queued)
|
||||
{
|
||||
Snackbar.Add($"Unable to scan show {_show.Title}", Severity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Done scanning show {_show.Title}", Severity.Success);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
@page "/media/tv/seasons"
|
||||
@page "/media/tv/seasons/page/{PageNumber:int}"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@inherits MultiSelectBase<TelevisionShowList>
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
|
||||
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
|
||||
@if (IsSelectMode())
|
||||
{
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
|
||||
<div style="margin-left: auto">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@(_ => AddSelectionToCollection())">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
<MudButton Class="ml-3"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Check"
|
||||
OnClick="@(_ => ClearSelection())">
|
||||
Clear Selection
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
|
||||
<div style="max-width: 300px; width: 33%;">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
Disabled="@(PageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
<FragmentLetterAnchor TCard="TelevisionSeasonCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href="@($"media/tv/seasons/{context.TelevisionSeasonId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/tv/seasons"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private TelevisionSeasonCardResultsViewModel _data = new(0, new List<TelevisionSeasonCardViewModel>(), null);
|
||||
private string _query;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
await RefreshData(CancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:season" : $"type:season AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexSeasons(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/tv/seasons/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/tv/seasons/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is TelevisionSeasonCardViewModel season)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.Title } };
|
||||
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} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
@page "/media/tv/shows"
|
||||
@page "/media/tv/shows/page/{PageNumber:int}"
|
||||
@inherits MultiSelectBase<TelevisionShowList>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject PersistentComponentState ApplicationState
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.Search
|
||||
@using ErsatzTV.Extensions
|
||||
@implements IDisposable
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100;">
|
||||
<MediaCardPager Query="@_query"
|
||||
PageNumber="@PageNumber"
|
||||
PageSize="@PageSize"
|
||||
TotalCount="@_data.Count"
|
||||
NextPage="@NextPage"
|
||||
PrevPage="@PrevPage"
|
||||
AddSelectionToCollection="@AddSelectionToCollection"
|
||||
AddSelectionToPlaylist="@AddSelectionToPlaylist"
|
||||
ClearSelection="@ClearSelection"
|
||||
CanSelectAllOnPage="@(() => _data.Cards.Count > 0)"
|
||||
SelectAllOnPage="@(() => SelectAllPageItems(_data.Cards))"
|
||||
IsSelectMode="@IsSelectMode"
|
||||
SelectionLabel="@SelectionLabel"/>
|
||||
</MudPaper>
|
||||
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudStack Row="true" Wrap="Wrap.Wrap">
|
||||
<FragmentLetterAnchor TCard="TelevisionShowCardViewModel" Cards="@_data.Cards">
|
||||
<MediaCard Data="@context"
|
||||
Href="@($"media/tv/shows/{context.TelevisionShowId}")"
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(context, e))"
|
||||
IsSelected="@IsSelected(context)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
</FragmentLetterAnchor>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
@if (_data.PageMap is not null)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap"
|
||||
BaseUri="media/tv/shows"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private TelevisionShowCardResultsViewModel _data = new(0, new List<TelevisionShowCardViewModel>(), null);
|
||||
private string _query;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
{
|
||||
_persistingSubscription = ApplicationState.RegisterOnPersisting(PersistData);
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
return base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!ApplicationState.TryTakeFromJson("_data", out TelevisionShowCardResultsViewModel restored))
|
||||
{
|
||||
_data = await RefreshData(CancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_data = restored;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private Task PersistData()
|
||||
{
|
||||
ApplicationState.PersistAsJson("_data", _data);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
_persistingSubscription.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task<TelevisionShowCardResultsViewModel> RefreshData(CancellationToken cancellationToken)
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:show" : $"type:show AND ({_query})";
|
||||
return await Mediator.Send(new QuerySearchIndexShows(searchQuery, PageNumber, PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"media/tv/shows/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"media/tv/shows/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
(string key, string value) = _query.EncodeQuery();
|
||||
uri = $"{uri}?{key}={value}";
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is TelevisionShowCardViewModel show)
|
||||
{
|
||||
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", show.Title } };
|
||||
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 AddShowToCollection(collection.Id, show.TelevisionShowId);
|
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request, CancellationToken);
|
||||
addResult.Match(
|
||||
Left: error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error adding show to collection: {error.Value}");
|
||||
Logger.LogError("Unexpected error adding show to collection: {Error}", error.Value);
|
||||
},
|
||||
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
@page "/templates/{Id:int}"
|
||||
@using System.Globalization
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<TemplateEditor> 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">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="ml-6" OnClick="@(_ => SaveChanges())" StartIcon="@Icons.Material.Filled.Save">
|
||||
Save Template
|
||||
</MudButton>
|
||||
</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">General</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>Template Group Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_template.GroupName" Disabled="true"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Template Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_template.Name" For="@(() => _template.Name)"/>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Add Content</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</MudText>
|
||||
</div>
|
||||
<MudSelect T="string" ValueChanged="@(name => UpdateBlockGroupItems(name))">
|
||||
@foreach (BlockGroupViewModel blockGroup in _blockGroups)
|
||||
{
|
||||
<MudSelectItem Value="@blockGroup.Name">@blockGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Block</MudText>
|
||||
</div>
|
||||
<MudSelect T="string" ValueChanged="@(name => UpdateSelectedBlock(name))">
|
||||
@foreach (BlockViewModel block in _blocks)
|
||||
{
|
||||
<MudSelectItem Value="@block.Name">@block.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Start Time On Or After</MudText>
|
||||
</div>
|
||||
<MudSelect T="DateTime" @bind-value="_selectedBlockStart">
|
||||
@foreach (DateTime startTime in _startTimes)
|
||||
{
|
||||
<MudSelectItem Value="@startTime">
|
||||
@startTime.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern)
|
||||
</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddBlockToTemplate())" Disabled="@(_selectedBlock is null)" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Block To Template
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Remove Content</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 To Remove</MudText>
|
||||
</div>
|
||||
<MudSelect T="TemplateItemEditViewModel" @bind-Value="_blockToRemove">
|
||||
<MudSelectItem Value="@((TemplateItemEditViewModel)null)">(none)</MudSelectItem>
|
||||
@foreach (TemplateItemEditViewModel item in _template.Items.OrderBy(i => i.Start))
|
||||
{
|
||||
<MudSelectItem Value="@item">@item.Start.ToShortTimeString() - @item.Text</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => RemoveBlockFromTemplate())" Disabled="@(_blockToRemove is null)" StartIcon="@Icons.Material.Filled.Remove">
|
||||
Remove Block From Template
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Content</MudText>
|
||||
<MudDivider Class="mb-6"/>
|
||||
<MudCalendar @ref="_calendar"
|
||||
T="CalendarItem"
|
||||
Class="mb-6"
|
||||
Items="@_template.Items"
|
||||
ShowMonth="false"
|
||||
ShowWeek="false"
|
||||
ShowPrevNextButtons="false"
|
||||
ShowDatePicker="false"
|
||||
ShowTodayButton="false"
|
||||
DayTimeInterval="CalendarTimeInterval.Minutes10"
|
||||
Use24HourClock="@(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains("H"))"
|
||||
EnableDragItems="true"
|
||||
EnableResizeItems="false"
|
||||
ItemChanged="@(ci => CalendarItemChanged(ci))"/>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly List<BlockGroupViewModel> _blockGroups = [];
|
||||
private readonly List<BlockViewModel> _blocks = [];
|
||||
private readonly List<DateTime> _startTimes = [];
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private TemplateItemsEditViewModel _template = new();
|
||||
private TemplateItemEditViewModel _blockToRemove;
|
||||
private BlockGroupViewModel _selectedBlockGroup;
|
||||
private BlockViewModel _selectedBlock;
|
||||
private DateTime _selectedBlockStart;
|
||||
private MudCalendar<CalendarItem> _calendar;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
await LoadTemplateItems(token);
|
||||
|
||||
DateTime start = DateTime.Today;
|
||||
_selectedBlockStart = start;
|
||||
while (start.Date == DateTime.Today.Date)
|
||||
{
|
||||
_startTimes.Add(start);
|
||||
start = start.AddMinutes(5);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadTemplateItems(CancellationToken cancellationToken)
|
||||
{
|
||||
Option<TemplateViewModel> maybeTemplate = await Mediator.Send(new GetTemplateById(Id), cancellationToken);
|
||||
if (maybeTemplate.IsNone)
|
||||
{
|
||||
NavigationManager.NavigateTo("templates");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TemplateViewModel template in maybeTemplate)
|
||||
{
|
||||
_template = new TemplateItemsEditViewModel
|
||||
{
|
||||
GroupId = template.TemplateGroupId,
|
||||
GroupName = template.GroupName,
|
||||
Name = template.Name
|
||||
};
|
||||
}
|
||||
|
||||
Option<IEnumerable<TemplateItemViewModel>> maybeResults = await Mediator.Send(new GetTemplateItems(Id), cancellationToken);
|
||||
foreach (IEnumerable<TemplateItemViewModel> items in maybeResults)
|
||||
{
|
||||
_template.Items.AddRange(items.Map(ProjectToEditViewModel));
|
||||
}
|
||||
|
||||
_blockGroups.AddRange(await Mediator.Send(new GetAllBlockGroups(), cancellationToken));
|
||||
}
|
||||
|
||||
private static TemplateItemEditViewModel ProjectToEditViewModel(TemplateItemViewModel item) =>
|
||||
new()
|
||||
{
|
||||
BlockId = item.BlockId,
|
||||
BlockName = item.BlockName,
|
||||
Start = item.StartTime,
|
||||
End = item.EndTime
|
||||
};
|
||||
|
||||
private async Task UpdateBlockGroupItems(string blockGroupName)
|
||||
{
|
||||
Option<BlockGroupViewModel> maybeBlockGroup = Optional(_blockGroups.Find(bg => bg.Name == blockGroupName));
|
||||
foreach (var blockGroup in maybeBlockGroup)
|
||||
{
|
||||
_selectedBlockGroup = blockGroup;
|
||||
|
||||
_blocks.Clear();
|
||||
_blocks.AddRange(await Mediator.Send(new GetBlocksByBlockGroupId(_selectedBlockGroup.Id), _cts.Token));
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSelectedBlock(string blockName)
|
||||
{
|
||||
_selectedBlock = _blocks.Find(bg => bg.Name == blockName);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void AddBlockToTemplate()
|
||||
{
|
||||
// find first time where this block will fit
|
||||
DateTime maybeStart = _selectedBlockStart;
|
||||
while (maybeStart.Date == DateTime.Today)
|
||||
{
|
||||
DateTime maybeEnd = maybeStart.AddMinutes(_selectedBlock.Minutes);
|
||||
if (!IntersectsOthers(null, maybeStart, maybeEnd))
|
||||
{
|
||||
var item = new TemplateItemEditViewModel
|
||||
{
|
||||
BlockId = _selectedBlock.Id,
|
||||
BlockName = _selectedBlock.Name,
|
||||
Start = maybeStart,
|
||||
End = maybeEnd,
|
||||
LastStart = maybeStart,
|
||||
LastEnd = maybeEnd
|
||||
};
|
||||
|
||||
_template.Items.Add(item);
|
||||
|
||||
InvokeAsync(() => { _calendar.ScrollToTime(TimeOnly.FromDateTime(item.Start)); });
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
maybeStart = maybeStart.AddMinutes(5);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveBlockFromTemplate()
|
||||
{
|
||||
if (_blockToRemove is not null)
|
||||
{
|
||||
_template.Items.Remove(_blockToRemove);
|
||||
|
||||
await InvokeAsync(() => { _calendar.ScrollToTime(TimeOnly.FromDateTime(_blockToRemove.Start)); });
|
||||
|
||||
_blockToRemove = null;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void CalendarItemChanged(CalendarItem calendarItem)
|
||||
{
|
||||
// don't allow any overlap
|
||||
if (calendarItem is TemplateItemEditViewModel item)
|
||||
{
|
||||
bool intersects = item.End.HasValue && IntersectsOthers(item, item.Start, item.End.Value);
|
||||
bool crossesMidnight = item.End.HasValue && item.End.Value.TimeOfDay > TimeSpan.Zero && item.End.Value.TimeOfDay < item.Start.TimeOfDay;
|
||||
if (intersects || crossesMidnight)
|
||||
{
|
||||
// roll back
|
||||
item.Start = item.LastStart;
|
||||
item.End = item.LastEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
// commit
|
||||
item.LastStart = item.Start;
|
||||
item.LastEnd = item.End;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IntersectsOthers(TemplateItemEditViewModel item, DateTime start, DateTime end)
|
||||
{
|
||||
var willFit = true;
|
||||
|
||||
foreach (TemplateItemEditViewModel existing in _template.Items)
|
||||
{
|
||||
if (existing == item)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (start < existing.End && existing.Start < end)
|
||||
{
|
||||
willFit = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return !willFit;
|
||||
}
|
||||
|
||||
// private void RemoveTemplateItem(TemplateItemEditViewModel item)
|
||||
// {
|
||||
// _selectedItem = null;
|
||||
// _template.Items.Remove(item);
|
||||
// }
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
var items = _template.Items.Map(item => new ReplaceTemplateItem(item.BlockId, item.Start.TimeOfDay)).ToList();
|
||||
|
||||
Seq<BaseError> errorMessages = await Mediator.Send(new ReplaceTemplateItems(_template.GroupId, Id, _template.Name, items), _cts.Token)
|
||||
.Map(e => e.LeftToSeq());
|
||||
|
||||
errorMessages.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving template: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving template: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("templates"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
@page "/templates"
|
||||
@using ErsatzTV.Application.Scheduling
|
||||
@implements IDisposable
|
||||
@inject ILogger<Templates> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudForm Style="max-height: 100%">
|
||||
<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">Template Groups</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>Template Group Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_templateGroupName" For="@(() => _templateGroupName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddTemplateGroup())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Template Group
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Templates</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>Template Group</MudText>
|
||||
</div>
|
||||
<MudSelect T="string" ValueChanged="@UpdateSelectedTemplateGroup">
|
||||
@foreach (TemplateGroupViewModel templateGroup in _templateGroups)
|
||||
{
|
||||
<MudSelectItem Value="@templateGroup.Name">@templateGroup.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex">
|
||||
<MudText>Template Name</MudText>
|
||||
</div>
|
||||
<MudTextField @bind-Value="_templateName" For="@(() => _templateName)"/>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
|
||||
<div class="d-flex"></div>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddTemplate())" StartIcon="@Icons.Material.Filled.Add">
|
||||
Add Template
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudTable Hover="true"
|
||||
Dense="true"
|
||||
Class="mt-8"
|
||||
Items="@_templates"
|
||||
GroupBy="@_groupDefinition"
|
||||
GroupHeaderStyle="background-color:var(--mud-palette-appbarbackground)"
|
||||
RowStyle="background-color:var(--mud-palette-background-gray)"
|
||||
Filter="new Func<TemplateViewModel,bool>(FilterTemplates)">
|
||||
<ColGroup>
|
||||
<MudHidden Breakpoint="Breakpoint.Xs">
|
||||
<col style="width: 60px;"/>
|
||||
<col/>
|
||||
<col style="width: 180px;"/>
|
||||
</MudHidden>
|
||||
</ColGroup>
|
||||
<ToolBarContent>
|
||||
<MudTextField T="string"
|
||||
ValueChanged="@(s => OnSearch(s))"
|
||||
Placeholder="Search for templates"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.FilterList"
|
||||
Clearable="true">
|
||||
</MudTextField>
|
||||
</ToolBarContent>
|
||||
<GroupHeaderTemplate>
|
||||
<MudTd Class="mud-table-cell-custom-group">
|
||||
@($"{context.Key}")
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<div style="width: 48px;"></div>
|
||||
<div style="width: 48px;"></div>
|
||||
<MudTooltip Text="Delete Template Group">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteTemplateGroup(context.Items?.FirstOrDefault()))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</GroupHeaderTemplate>
|
||||
<RowTemplate>
|
||||
<MudTd>@context.Name</MudTd>
|
||||
<MudTd>
|
||||
<div class="d-flex">
|
||||
@if (context.Id >= 0)
|
||||
{
|
||||
<MudTooltip Text="Edit Template">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Href="@($"templates/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Copy Template">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
OnClick="@(_ => CopyTemplate(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Template">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteTemplate(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="height: 48px; width: 48px"></div>
|
||||
}
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudContainer>
|
||||
</div>
|
||||
</MudForm>
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly List<TemplateViewModel> _templates = [];
|
||||
private List<TemplateGroupViewModel> _templateGroups = [];
|
||||
private TemplateGroupViewModel _selectedTemplateGroup;
|
||||
private string _templateGroupName;
|
||||
private string _templateName;
|
||||
private string _searchString;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
_templateGroups = await Mediator.Send(new GetAllTemplateGroups(), token);
|
||||
|
||||
_templates.Clear();
|
||||
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), token));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private readonly TableGroupDefinition<TemplateViewModel> _groupDefinition = new()
|
||||
{
|
||||
GroupName = "Group",
|
||||
Indentation = false,
|
||||
Expandable = true,
|
||||
Selector = (e) => e.GroupName
|
||||
};
|
||||
|
||||
private async Task AddTemplateGroup()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_templateGroupName))
|
||||
{
|
||||
Either<BaseError, TemplateGroupViewModel> result = await Mediator.Send(new CreateTemplateGroup(_templateGroupName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding template group: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (TemplateGroupViewModel templateGroup in result.RightToSeq())
|
||||
{
|
||||
_templateGroupName = null;
|
||||
_templateGroups = await Mediator.Send(new GetAllTemplateGroups(), _cts.Token);
|
||||
_selectedTemplateGroup = _templateGroups.Find(tg => tg.Id == templateGroup.Id);
|
||||
|
||||
_templates.Clear();
|
||||
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), _cts.Token));
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSelectedTemplateGroup(string templateGroupName)
|
||||
{
|
||||
_selectedTemplateGroup = _templateGroups.Find(tg => tg.Name == templateGroupName);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task AddTemplate()
|
||||
{
|
||||
if (_selectedTemplateGroup is not null && !string.IsNullOrWhiteSpace(_templateName))
|
||||
{
|
||||
Either<BaseError, TemplateViewModel> result = await Mediator.Send(new CreateTemplate(_selectedTemplateGroup.Id, _templateName), _cts.Token);
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error adding template: {Error}", error.Value);
|
||||
}
|
||||
|
||||
if (result.IsRight)
|
||||
{
|
||||
_templates.Clear();
|
||||
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), _cts.Token));
|
||||
|
||||
_templateName = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
_searchString = query;
|
||||
}
|
||||
|
||||
private bool FilterTemplates(TemplateViewModel template) => FilterTemplates(template, _searchString);
|
||||
|
||||
private bool FilterTemplates(TemplateViewModel template, string searchString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchString))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (template.Name.Contains(searchString, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task DeleteTemplateGroup(TemplateViewModel template)
|
||||
{
|
||||
if (template is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new DialogParameters { { "EntityType", "template group" }, { "EntityName", template.GroupName } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Template Group", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
await Mediator.Send(new DeleteTemplateGroup(template.TemplateGroupId), _cts.Token);
|
||||
if (_selectedTemplateGroup?.Id == template.TemplateGroupId)
|
||||
{
|
||||
_selectedTemplateGroup = null;
|
||||
}
|
||||
|
||||
_templateGroups = await Mediator.Send(new GetAllTemplateGroups(), _cts.Token);
|
||||
_templates.Clear();
|
||||
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), _cts.Token));
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyTemplate(TemplateViewModel template)
|
||||
{
|
||||
var parameters = new DialogParameters { { "TemplateId", template.Id } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<CopyTemplateDialog>("Copy Template", parameters, options);
|
||||
DialogResult dialogResult = await dialog.Result;
|
||||
if (dialogResult is { Canceled: false, Data: TemplateViewModel data })
|
||||
{
|
||||
NavigationManager.NavigateTo($"templates/{data.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteTemplate(TemplateViewModel template)
|
||||
{
|
||||
if (template is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parameters = new DialogParameters { { "EntityType", "template" }, { "EntityName", template.Name } };
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
|
||||
|
||||
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Template", parameters, options);
|
||||
DialogResult result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled)
|
||||
{
|
||||
await Mediator.Send(new DeleteTemplate(template.Id), _cts.Token);
|
||||
_templates.Clear();
|
||||
_templates.AddRange(await Mediator.Send(new GetAllTemplates(), _cts.Token));
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user