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.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [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 ### Fixed
- Fix regression from `v26.2.0` that caused channel logo watermarks to be ignored when the logo is a url - 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 - 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
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken) private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
{ {
await configElementRepository.Upsert( await configElementRepository.Upsert(
ConfigElementKey.PagesIsDarkMode, ConfigElementKey.PagesThemeMode,
uiSettings.IsDarkMode, uiSettings.ThemeMode,
cancellationToken); 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); await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
return Unit.Default; return Unit.Default;

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

@ -8,6 +8,11 @@ public class GetUiSettingsHandler(IConfigElementRepository configElementReposito
{ {
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken) 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>( Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
ConfigElementKey.PagesIsDarkMode, ConfigElementKey.PagesIsDarkMode,
cancellationToken); cancellationToken);
@ -18,7 +23,10 @@ public class GetUiSettingsHandler(IConfigElementRepository configElementReposito
return new UiSettingsViewModel 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") Language = await pagesLanguage.IfNoneAsync("en")
}; };
} }

8
ErsatzTV.Application/Configuration/ThemeMode.cs

@ -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;
public class UiSettingsViewModel public class UiSettingsViewModel
{ {
public bool IsDarkMode { get; set; } public ThemeMode ThemeMode { get; set; }
public string Language { get; set; } public string Language { get; set; }
} }

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

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

7
ErsatzTV/Pages/Settings/UISettings.razor

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

50
ErsatzTV/Shared/MainLayout.razor

@ -17,7 +17,7 @@
@inject IHealthCheckService HealthCheckService; @inject IHealthCheckService HealthCheckService;
@inject IStringLocalizer<ErsatzTV.Locals.Shared.MainLayout> StringLocalizer; @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"/> <MudDialogProvider BackdropClick="false"/>
<MudSnackbarProvider/> <MudSnackbarProvider/>
<MudPopoverProvider/> <MudPopoverProvider/>
@ -279,7 +279,10 @@
private int _playoutWarnings; private int _playoutWarnings;
private int _errors; private int _errors;
private int _warnings; private int _warnings;
private MudThemeProvider _mudThemeProvider;
private ThemeMode _themeMode = ThemeMode.Dark;
private bool _isDarkMode = true; private bool _isDarkMode = true;
private bool _systemIsDarkMode = true;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@ -298,6 +301,45 @@
await HandleHealthCheckSummary(HealthCheckService.GetHealthCheckSummary(), CancellationToken.None); 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() public void Dispose()
{ {
SystemStartup.OnDatabaseReady -= OnStartupProgress; SystemStartup.OnDatabaseReady -= OnStartupProgress;
@ -415,9 +457,9 @@
{ {
_searchTargets ??= await Mediator.Send(new QuerySearchTargets(), token); _searchTargets ??= await Mediator.Send(new QuerySearchTargets(), token);
_isDarkMode = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.PagesIsDarkMode), token) UiSettingsViewModel uiSettings = await Mediator.Send(new GetUiSettings(), token);
.MapT(result => !bool.TryParse(result.Value, out bool value) || value) _themeMode = uiSettings.ThemeMode;
.IfNoneAsync(true); ApplyThemeMode();
_playoutWarnings = await Mediator.Send(new GetPlayoutWarningsCount(), token); _playoutWarnings = await Mediator.Send(new GetPlayoutWarningsCount(), token);
} }

Loading…
Cancel
Save