Browse Source

feat: add system theme option to ui settings

Adds a `System` choice to Settings > UI > Theme that follows the
light/dark preference of the browser viewing ErsatzTV, and updates
live when that preference changes.

The theme setting is now stored as an enum in `pages.theme_mode`.
Existing installs fall back to the legacy `pages.is_dark_mode` flag
until the setting is saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull/2962/head
Ministorm3 4 days ago
parent
commit
21aae38691
  1. 4
      CHANGELOG.md
  2. 7
      ErsatzTV.Application/Configuration/Commands/UpdateUiSettingsHandler.cs
  3. 10
      ErsatzTV.Application/Configuration/Queries/GetUiSettingsHandler.cs
  4. 8
      ErsatzTV.Application/Configuration/ThemeMode.cs
  5. 2
      ErsatzTV.Application/Configuration/UiSettingsViewModel.cs
  6. 1
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  7. 7
      ErsatzTV/Pages/Settings/UISettings.razor
  8. 50
      ErsatzTV/Shared/MainLayout.razor

4
CHANGELOG.md

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add `System` option to **Settings** > **UI** > **Theme**
- Follows the light or dark preference of the browser that is viewing ErsatzTV, and updates without a refresh when that preference changes
### Fixed
- Fix regression from `v26.2.0` that caused channel logo watermarks to be ignored when the logo is a url
- This affected external logo urls and generated channel logos

7
ErsatzTV.Application/Configuration/Commands/UpdateUiSettingsHandler.cs

@ -17,10 +17,13 @@ public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepos @@ -17,10 +17,13 @@ public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepos
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
{
await configElementRepository.Upsert(
ConfigElementKey.PagesIsDarkMode,
uiSettings.IsDarkMode,
ConfigElementKey.PagesThemeMode,
uiSettings.ThemeMode,
cancellationToken);
// the legacy dark mode flag is only used to migrate the theme mode setting
await configElementRepository.Delete(ConfigElementKey.PagesIsDarkMode, cancellationToken);
await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
return Unit.Default;

10
ErsatzTV.Application/Configuration/Queries/GetUiSettingsHandler.cs

@ -8,6 +8,11 @@ public class GetUiSettingsHandler(IConfigElementRepository configElementReposito @@ -8,6 +8,11 @@ public class GetUiSettingsHandler(IConfigElementRepository configElementReposito
{
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
{
Option<ThemeMode> pagesThemeMode = await configElementRepository.GetValue<ThemeMode>(
ConfigElementKey.PagesThemeMode,
cancellationToken);
// fall back to the legacy dark mode flag for installs that predate the theme mode setting
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
ConfigElementKey.PagesIsDarkMode,
cancellationToken);
@ -18,7 +23,10 @@ public class GetUiSettingsHandler(IConfigElementRepository configElementReposito @@ -18,7 +23,10 @@ public class GetUiSettingsHandler(IConfigElementRepository configElementReposito
return new UiSettingsViewModel
{
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
ThemeMode = pagesThemeMode.IfNone(
() => pagesIsDarkMode.Match(
isDarkMode => isDarkMode ? ThemeMode.Dark : ThemeMode.Light,
() => ThemeMode.Dark)),
Language = await pagesLanguage.IfNoneAsync("en")
};
}

8
ErsatzTV.Application/Configuration/ThemeMode.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Application.Configuration;
public enum ThemeMode
{
Dark = 0,
Light = 1,
System = 2
}

2
ErsatzTV.Application/Configuration/UiSettingsViewModel.cs

@ -2,7 +2,7 @@ namespace ErsatzTV.Application.Configuration; @@ -2,7 +2,7 @@ namespace ErsatzTV.Application.Configuration;
public class UiSettingsViewModel
{
public bool IsDarkMode { get; set; }
public ThemeMode ThemeMode { get; set; }
public string Language { get; set; }
}

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -32,6 +32,7 @@ public class ConfigElementKey @@ -32,6 +32,7 @@ public class ConfigElementKey
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey HDHRUUID => new("hdhr.uuid");
public static ConfigElementKey PagesIsDarkMode => new("pages.is_dark_mode");
public static ConfigElementKey PagesThemeMode => new("pages.theme_mode");
public static ConfigElementKey PagesLanguage => new("pages.language");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey ChannelsShowDisabled => new("pages.channels.show_disabled");

7
ErsatzTV/Pages/Settings/UISettings.razor

@ -19,9 +19,10 @@ @@ -19,9 +19,10 @@
<div class="d-flex">
<MudText>Theme</MudText>
</div>
<MudSelect @bind-Value="_uiSettings.IsDarkMode">
<MudSelectItem Value="@true">Dark</MudSelectItem>
<MudSelectItem Value="@false">Light</MudSelectItem>
<MudSelect @bind-Value="_uiSettings.ThemeMode">
<MudSelectItem Value="@ThemeMode.Dark">Dark</MudSelectItem>
<MudSelectItem Value="@ThemeMode.Light">Light</MudSelectItem>
<MudSelectItem Value="@ThemeMode.System">System</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">

50
ErsatzTV/Shared/MainLayout.razor

@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
@inject IHealthCheckService HealthCheckService;
@inject IStringLocalizer<ErsatzTV.Locals.Shared.MainLayout> StringLocalizer;
<MudThemeProvider Theme="ErsatzTvTheme" IsDarkMode="_isDarkMode"/>
<MudThemeProvider @ref="_mudThemeProvider" Theme="ErsatzTvTheme" IsDarkMode="_isDarkMode" ObserveSystemDarkModeChange="@(_themeMode is ThemeMode.System)"/>
<MudDialogProvider BackdropClick="false"/>
<MudSnackbarProvider/>
<MudPopoverProvider/>
@ -279,7 +279,10 @@ @@ -279,7 +279,10 @@
private int _playoutWarnings;
private int _errors;
private int _warnings;
private MudThemeProvider _mudThemeProvider;
private ThemeMode _themeMode = ThemeMode.Dark;
private bool _isDarkMode = true;
private bool _systemIsDarkMode = true;
protected override void OnInitialized()
{
@ -298,6 +301,45 @@ @@ -298,6 +301,45 @@
await HandleHealthCheckSummary(HealthCheckService.GetHealthCheckSummary(), CancellationToken.None);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender || _mudThemeProvider is null)
{
return;
}
// the browser's color scheme is only available once we can use js interop
_systemIsDarkMode = await _mudThemeProvider.GetSystemDarkModeAsync();
if (_themeMode is ThemeMode.System)
{
ApplyThemeMode();
StateHasChanged();
}
await _mudThemeProvider.WatchSystemDarkModeAsync(OnSystemDarkModeChanged);
}
private async Task OnSystemDarkModeChanged(bool isDarkMode)
{
_systemIsDarkMode = isDarkMode;
if (_themeMode is ThemeMode.System)
{
ApplyThemeMode();
await InvokeAsync(StateHasChanged);
}
}
private void ApplyThemeMode() =>
_isDarkMode = _themeMode switch
{
ThemeMode.Light => false,
ThemeMode.System => _systemIsDarkMode,
_ => true
};
public void Dispose()
{
SystemStartup.OnDatabaseReady -= OnStartupProgress;
@ -415,9 +457,9 @@ @@ -415,9 +457,9 @@
{
_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);
UiSettingsViewModel uiSettings = await Mediator.Send(new GetUiSettings(), token);
_themeMode = uiSettings.ThemeMode;
ApplyThemeMode();
_playoutWarnings = await Mediator.Send(new GetPlayoutWarningsCount(), token);
}

Loading…
Cancel
Save