Stream custom live channels using your own media
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

532 lines
23 KiB

@inherits LayoutComponentBase
@using System.Reflection
@using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.Playouts
@using ErsatzTV.Application.Search
@using ErsatzTV.Core.Health
@using ErsatzTV.Core.Interfaces.Search
@using ErsatzTV.Core.Notifications
@using ErsatzTV.Extensions
@using MediatR.Courier
@implements IDisposable
@inject NavigationManager NavigationManager
@inject IMediator Mediator
@inject SystemStartup SystemStartup
@inject ISearchTargets SearchTargets;
@inject ICourier Courier
@inject IHealthCheckService HealthCheckService;
<MudThemeProvider Theme="ErsatzTvTheme" IsDarkMode="_isDarkMode"/>
<MudDialogProvider BackdropClick="false"/>
<MudSnackbarProvider/>
<MudPopoverProvider/>
<MudLayout @onclick="@(() => _isOpen = false)" Class="@(_isDarkMode ? "d-flex d-flex-column ersatztv-dark" : "d-flex d-flex-column ersatztv-light")" Style="height: 100vh">
<MudAppBar Elevation="1" Class="app-bar">
@if (SystemStartup.IsDatabaseReady && SystemStartup.IsSearchIndexReady)
{
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@ToggleDrawer"/>
}
<div style="min-width: 180px" class="ml-1 d-none d-md-flex align-center">
<a href="" class="d-flex">
<img src="images/ersatztv.png" alt="ErsatzTV"/>
</a>
</div>
@if (SystemStartup.IsDatabaseReady && SystemStartup.IsSearchIndexReady)
{
<div class="search-form">
<EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())">
<MudTextField T="string"
@bind-Value="@Query"
AdornmentIcon="@Icons.Material.Filled.Search"
Adornment="Adornment.Start"
Variant="Variant.Outlined"
Immediate="true"
Class="search-bar"
@onclick="@(() => _isOpen = true)"
OnKeyUp="@OnKeyUp">
</MudTextField>
<MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="DropdownWidth.Relative">
@if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3)
{
var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
if (matches.Any())
{
<MudList T="SearchTargetViewModel" ReadOnly="false" Dense="true">
@foreach (SearchTargetViewModel searchTarget in matches)
{
<MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))">
<MudText Typo="Typo.body1">@searchTarget.Name</MudText>
<MudText Typo="Typo.subtitle1" Class="mud-text-disabled">
@(
searchTarget.Kind switch
{
SearchTargetKind.Channel => "Channel",
SearchTargetKind.FFmpegProfile => "FFmpeg Profile",
SearchTargetKind.ChannelWatermark => "Channel Watermark",
SearchTargetKind.Collection => "Collection",
SearchTargetKind.MultiCollection => "Multi Collection",
SearchTargetKind.SmartCollection => "Smart Collection",
SearchTargetKind.Schedule => "Schedule",
SearchTargetKind.ScheduleItems => "Schedule Items",
_ => string.Empty
})
</MudText>
</MudListItem>
}
</MudList>
}
}
</MudPopover>
</EditForm>
</div>
}
<div class="flex-grow-1 d-none d-md-flex"></div>
<div style="align-items: center; display: flex;" class="d-none d-md-flex">
@if (SystemStartup.IsDatabaseReady && SystemStartup.IsSearchIndexReady)
{
@if (BuildConfiguration != "release")
{
<MudText Color="Color.Warning" Class="mx-4">@System.Environment.MachineName</MudText>
}
<MudLink Color="Color.Info" Href="iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Color="Color.Info" Href="iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
<MudLink Color="Color.Primary" Href="docs" Target="_blank" Underline="Underline.None" Style="font-weight: bold">API</MudLink>
}
@* <MudLink Color="Color.Info" Href="/swagger" Target="_blank" Class="mr-4" Underline="Underline.None">API</MudLink> *@
<MudTooltip Text="Documentation">
<MudIconButton Icon="@Icons.Material.Filled.Help" Color="Color.Primary" Href="https://ersatztv.org" Target="_blank"/>
</MudTooltip>
<MudTooltip Text="Discord">
<MudIconButton Icon="fab fa-discord" Color="Color.Primary" Href="https://discord.ersatztv.org" Target="_blank"/>
</MudTooltip>
<MudTooltip Text="GitHub">
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Primary" Href="https://github.com/ErsatzTV/ErsatzTV" Target="_blank"/>
</MudTooltip>
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Primary" OnClick="@DarkModeToggle"/>
<AuthorizeView>
<form action="/account/logout" method="post">
<MudTooltip Text="Logout">
<MudIconButton Icon="@Icons.Material.Filled.Logout" Color="Color.Secondary" ButtonType="ButtonType.Submit"/>
</MudTooltip>
</form>
</AuthorizeView>
</div>
</MudAppBar>
@if (SystemStartup.IsDatabaseReady && SystemStartup.IsSearchIndexReady)
{
<MudDrawer @bind-Open="@_drawerIsOpen" Elevation="2" ClipMode="DrawerClipMode.Always">
<MudNavMenu>
<MudNavLink Href="channels">Channels</MudNavLink>
<MudNavLink Href="ffmpeg">FFmpeg Profiles</MudNavLink>
<MudNavLink Href="watermarks">Watermarks</MudNavLink>
<MudNavGroup Title="Media Sources">
<MudNavLink Href="media/sources/local">Local</MudNavLink>
<MudNavLink Href="media/sources/emby">Emby</MudNavLink>
<MudNavLink Href="media/sources/jellyfin">Jellyfin</MudNavLink>
<MudNavLink Href="media/sources/plex">Plex</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Media">
<MudNavLink Href="media/libraries">Libraries</MudNavLink>
<MudNavLink Href="media/trash">Trash</MudNavLink>
<MudNavLink Href="media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="media/movies">Movies</MudNavLink>
<MudNavLink Href="media/music/artists">Music</MudNavLink>
<MudNavLink Href="media/other/videos">Other Videos</MudNavLink>
<MudNavLink Href="media/music/songs">Songs</MudNavLink>
<MudNavLink Href="media/browser/images">Images</MudNavLink>
<MudNavLink Href="media/remote/streams">Remote Streams</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Lists">
<MudNavLink Href="media/collections">Manual Collections</MudNavLink>
<MudNavLink Href="media/smart-collections">Smart Collections</MudNavLink>
<MudNavLink Href="media/multi-collections">Multi Collections</MudNavLink>
<MudNavLink Href="media/rerun-collections">Rerun Collections</MudNavLink>
<MudNavLink Href="media/playlists">Playlists</MudNavLink>
<MudNavLink Href="media/trakt/lists">Trakt Lists</MudNavLink>
<MudNavLink Href="media/filler/presets">Filler Presets</MudNavLink>
</MudNavGroup>
<MudNavGroup>
<TitleContent>
@if (_playoutWarnings > 0)
{
<div style="align-items: center; display: flex">
Scheduling
<MudIcon Color="@Color.Warning" Icon="@Icons.Material.Filled.Warning" Class="mx-2"/>
</div>
}
else
{
@:Scheduling
}
</TitleContent>
<ChildContent>
<MudNavLink Href="schedules">Schedules</MudNavLink>
<MudNavLink Href="blocks">Blocks</MudNavLink>
<MudNavLink Href="templates">Templates</MudNavLink>
<MudNavLink Href="decos">Decos</MudNavLink>
<MudNavLink Href="deco-templates">Deco Templates</MudNavLink>
<MudNavLink Href="playouts">
@if (_playoutWarnings > 0)
{
<MudBadge Content="_playoutWarnings"
Color="Color.Warning"
Origin="Origin.CenterRight"
BadgeClass="mx-3">
Playouts
</MudBadge>
}
else
{
@:Playouts
}
</MudNavLink>
</ChildContent>
</MudNavGroup>
<MudNavGroup Title="Settings">
<MudNavLink Href="settings/ffmpeg">FFmpeg</MudNavLink>
<MudNavLink Href="settings/logging">Logging</MudNavLink>
<MudNavLink Href="settings/hdhr">HDHomeRun</MudNavLink>
<MudNavLink Href="settings/scanner">Scanner</MudNavLink>
<MudNavLink Href="settings/playout">Playout</MudNavLink>
<MudNavLink Href="settings/xmltv">XMLTV</MudNavLink>
</MudNavGroup>
<MudNavGroup Expanded="true">
<TitleContent>
@if (_errors > 0)
{
<div style="align-items: center; display: flex">
Support
<MudIcon Color="@Color.Error" Icon="@Icons.Material.Filled.Error" Class="mx-2"/>
</div>
}
else if (_warnings > 0)
{
<div style="align-items: center; display: flex">
Support
<MudIcon Color="@Color.Warning" Icon="@Icons.Material.Filled.Warning" Class="mx-2"/>
</div>
}
else
{
@:Support
}
</TitleContent>
<ChildContent>
<MudNavLink Href="system/health">
@if (_errors > 0)
{
<MudBadge Content="_errors"
Color="Color.Error"
Origin="Origin.CenterRight"
BadgeClass="mx-3">
Health Checks
</MudBadge>
}
else if (_warnings > 0)
{
<MudBadge Content="_warnings"
Color="Color.Warning"
Origin="Origin.CenterRight"
BadgeClass="mx-3">
Health Checks
</MudBadge>
}
else
{
@:Health Checks
}
</MudNavLink>
<MudNavLink Href="system/logs">Logs</MudNavLink>
<MudNavLink Href="system/troubleshooting">Troubleshooting</MudNavLink>
</ChildContent>
</MudNavGroup>
<MudDivider Class="my-6" DividerType="DividerType.Middle"/>
<MudContainer Style="text-align: right" Class="mr-6">
<MudText Typo="Typo.body2">ErsatzTV Version</MudText>
<MudText Typo="Typo.body2" Color="Color.Info">@InfoVersion</MudText>
@if (BuildConfiguration != "release")
{
<MudText Typo="Typo.body2" Color="Color.Warning">@BuildConfiguration</MudText>
}
</MudContainer>
</MudNavMenu>
</MudDrawer>
}
<MudMainContent>
@Body
</MudMainContent>
</MudLayout>
@code {
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private static readonly string BuildConfiguration = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyConfigurationAttribute>()?.Configuration?.ToLower() ?? "unset";
private CancellationTokenSource _cts;
private string _query;
private record SearchModel;
private readonly SearchModel _dummyModel = new();
private bool _drawerIsOpen = true;
private bool _isOpen;
private List<SearchTargetViewModel> _searchTargets;
private int _playoutWarnings;
private int _errors;
private int _warnings;
private bool _isDarkMode = true;
protected override void OnInitialized()
{
SystemStartup.OnDatabaseReady += OnStartupProgress;
SystemStartup.OnSearchIndexReady += OnStartupProgress;
SearchTargets.OnSearchTargetsChanged += OnSearchTargetsChanged;
Courier.Subscribe<HealthCheckSummary>(HandleHealthCheckSummary);
Courier.Subscribe<PlayoutUpdatedNotification>(HandlePlayoutUpdated);
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await HandleHealthCheckSummary(HealthCheckService.GetHealthCheckSummary(), CancellationToken.None);
}
private async Task DarkModeToggle()
{
_isDarkMode = !_isDarkMode;
await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PagesIsDarkMode, _isDarkMode.ToString()));
}
public string DarkLightModeButtonIcon => _isDarkMode switch
{
true => Icons.Material.Rounded.DarkMode,
false => Icons.Material.Outlined.LightMode
};
public void Dispose()
{
SystemStartup.OnDatabaseReady -= OnStartupProgress;
SystemStartup.OnSearchIndexReady -= OnStartupProgress;
SearchTargets.OnSearchTargetsChanged -= OnSearchTargetsChanged;
Courier.UnSubscribe<HealthCheckSummary>(HandleHealthCheckSummary);
_cts?.Cancel();
_cts?.Dispose();
}
private static MudTheme ErsatzTvTheme => new()
{
PaletteDark = new PaletteDark
{
ActionDefault = "rgba(255,255,255, 0.80)",
Primary = "#009000",
Secondary = "#009090",
Surface = "#1f1f1f",
AppbarBackground = "#121212",
AppbarText = "rgba(255,255,255, 0.80)",
DrawerBackground = "#1f1f1f",
DrawerText = "rgba(255,255,255, 0.80)",
Divider = "rgba(255,255,255, 0.40)",
Background = "#272727",
BackgroundGray = "#272727",
TextPrimary = "rgba(255,255,255, 0.90)",
TextSecondary = "rgba(255,255,255, 0.80)",
TextDisabled = "rgba(255,255,255, 0.40)",
ActionDisabled = "rgba(255,255,255, 0.40)",
TableHover = "rgba(255,255,255, 0.10)",
TableLines = "rgba(255,255,255,0.11)",
Info = "#00c0c0",
Tertiary = "#00c000",
White = Colors.Shades.White
},
PaletteLight = new PaletteLight
{
ActionDefault = "#546E7A",
Primary = "#546E7A",
Secondary = "#EC407A",
AppbarBackground = "#ECEFF1",
AppbarText = "#424242",
DrawerBackground = "#FFFFFF",
DrawerText = "#424242",
Surface = "#FFFFFF",
Background = "#F5F5F5",
TextPrimary = "#212121",
TextSecondary = "rgba(0,0,0, 0.7)",
TextDisabled = "rgba(0,0,0, 0.5)",
ActionDisabled = "rgba(0,0,0, 0.3)",
Divider = "rgba(0,0,0, 0.12)",
TableHover = "rgba(0,0,0, 0.02)",
TableLines = "rgba(0,0,0,0.08)",
Info = "#00c0c0",
Tertiary = "#00c000"
}
};
private string Query
{
get => _query;
set
{
if (_query == value)
{
return;
}
_query = value;
_isOpen = true;
StateHasChanged();
}
}
private async void OnStartupProgress(object sender, EventArgs e)
{
try
{
await InvokeAsync(StateHasChanged);
}
catch
{
// do nothing
}
}
protected override async Task OnParametersSetAsync()
{
if (!SystemStartup.IsDatabaseReady || !SystemStartup.IsSearchIndexReady)
{
string currentUri = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
if (!string.IsNullOrEmpty(currentUri))
{
NavigationManager.NavigateTo(string.Empty);
return;
}
}
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
await base.OnParametersSetAsync();
_query = NavigationManager.Uri.GetSearchQuery();
if (SystemStartup.IsDatabaseReady)
{
_searchTargets ??= await Mediator.Send(new QuerySearchTargets(), token);
_isDarkMode = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PagesIsDarkMode), token)
.MapT(result => !bool.TryParse(result.Value, out bool value) || value)
.IfNoneAsync(true);
_playoutWarnings = await Mediator.Send(new GetPlayoutWarningsCount(), token);
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
protected async void OnSearchTargetsChanged(object sender, EventArgs e)
{
try
{
_searchTargets = await Mediator.Send(new QuerySearchTargets(), _cts.Token);
}
catch (Exception)
{
// do nothing
}
}
private void PerformSearch()
{
NavigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true);
StateHasChanged();
}
private void OnKeyUp(KeyboardEventArgs args)
{
switch (args.Key)
{
case "Enter":
case "NumpadEnter":
_isOpen = false;
break;
case "Escape":
_isOpen = false;
break;
}
}
private void NavigateTo(SearchTargetViewModel searchTarget) =>
// need to force smart collections to navigate since the query string is all that differs
NavigationManager.NavigateTo(UrlFor(searchTarget), searchTarget.Kind is SearchTargetKind.SmartCollection);
private string UrlFor(SearchTargetViewModel searchTarget) =>
searchTarget.Kind switch
{
SearchTargetKind.Channel => $"channels/{searchTarget.Id}",
SearchTargetKind.FFmpegProfile => $"ffmpeg/{searchTarget.Id}",
SearchTargetKind.ChannelWatermark => $"watermarks/{searchTarget.Id}",
SearchTargetKind.Collection => $"media/collections/{searchTarget.Id}",
SearchTargetKind.MultiCollection => $"media/multi-collections/{searchTarget.Id}/edit",
SearchTargetKind.SmartCollection when searchTarget is SmartCollectionSearchTargetViewModel sc =>
sc.Query.GetRelativeSearchQuery(),
SearchTargetKind.Schedule => $"schedules/{searchTarget.Id}",
SearchTargetKind.ScheduleItems => $"schedules/{searchTarget.Id}/items",
_ => null
};
private void ToggleDrawer() => _drawerIsOpen = !_drawerIsOpen;
private async Task HandleHealthCheckSummary(HealthCheckSummary healthCheckSummary, CancellationToken cancellationToken)
{
try
{
if (healthCheckSummary.Errors > 0)
{
_errors = healthCheckSummary.Errors;
_warnings = 0;
}
else if (healthCheckSummary.Warnings > 0)
{
_warnings = healthCheckSummary.Warnings;
_errors = 0;
}
else
{
_warnings = 0;
_errors = 0;
}
await InvokeAsync(StateHasChanged);
}
catch (Exception)
{
// ignore
}
}
private async Task HandlePlayoutUpdated(PlayoutUpdatedNotification _, CancellationToken cancellationToken)
{
try
{
_playoutWarnings = await Mediator.Send(new GetPlayoutWarningsCount(), cancellationToken);
await InvokeAsync(StateHasChanged);
}
catch (Exception)
{
// do nothing
}
}
}