From 607d9b0662b8be59ca40f5facdd0e30a5409a838 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:52:52 -0600 Subject: [PATCH] add ui localization framework (#2809) * move dark/light mode toggle to ui settings page * separate current culture (formatting) and ui culture (language) * add some more sample translations * update changelog * fix cancellation token --- CHANGELOG.md | 6 ++ .../Commands/UpdateUiSettings.cs | 5 ++ .../Commands/UpdateUiSettingsHandler.cs | 28 +++++++ .../Configuration/Queries/GetUiSettings.cs | 3 + .../Queries/GetUiSettingsHandler.cs | 25 ++++++ .../Configuration/UiSettingsViewModel.cs | 8 ++ .../GetFuturePlayoutItemsByIdHandler.cs | 2 +- ErsatzTV.Core/Domain/ConfigElementKey.cs | 1 + ErsatzTV/DatabaseRequestCultureProvider.cs | 27 ++++++ ErsatzTV/Locals/Resources.cs | 5 ++ ErsatzTV/Locals/Resources.pt-br.resx | 35 ++++++++ ErsatzTV/Locals/Resources.resx | 24 ++++++ ErsatzTV/Pages/DecoTemplateEditor.razor | 4 +- ErsatzTV/Pages/Logs.razor | 2 +- .../PlayoutAlternateSchedulesEditor.razor | 2 +- ErsatzTV/Pages/PlayoutTemplatesEditor.razor | 2 +- ErsatzTV/Pages/Playouts.razor | 2 +- ErsatzTV/Pages/Settings/UISettings.razor | 84 +++++++++++++++++++ ErsatzTV/Pages/TemplateEditor.razor | 4 +- .../Troubleshooting/BlockPlayoutHistory.razor | 2 +- .../PlaybackTroubleshooting.razor | 2 +- ErsatzTV/Shared/MainLayout.razor | 31 +++---- ErsatzTV/Startup.cs | 9 +- 23 files changed, 280 insertions(+), 33 deletions(-) create mode 100644 ErsatzTV.Application/Configuration/Commands/UpdateUiSettings.cs create mode 100644 ErsatzTV.Application/Configuration/Commands/UpdateUiSettingsHandler.cs create mode 100644 ErsatzTV.Application/Configuration/Queries/GetUiSettings.cs create mode 100644 ErsatzTV.Application/Configuration/Queries/GetUiSettingsHandler.cs create mode 100644 ErsatzTV.Application/Configuration/UiSettingsViewModel.cs create mode 100644 ErsatzTV/DatabaseRequestCultureProvider.cs create mode 100644 ErsatzTV/Locals/Resources.cs create mode 100644 ErsatzTV/Locals/Resources.pt-br.resx create mode 100644 ErsatzTV/Locals/Resources.resx create mode 100644 ErsatzTV/Pages/Settings/UISettings.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 516238176..4ae427e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback - Log messages will include media item id, channel number and transcoding speed +- Add UI language setting to **Settings** > **UI** + - A small number of menu translations have been added for `Português (Brasil)` + - Translation contributions are always welcome! + +### Changed +- Move dark/light mode toggle to **Settings** > **UI** ## [26.2.0] - 2026-02-02 ### Added diff --git a/ErsatzTV.Application/Configuration/Commands/UpdateUiSettings.cs b/ErsatzTV.Application/Configuration/Commands/UpdateUiSettings.cs new file mode 100644 index 000000000..919016bd9 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdateUiSettings.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Configuration; + +public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest>; diff --git a/ErsatzTV.Application/Configuration/Commands/UpdateUiSettingsHandler.cs b/ErsatzTV.Application/Configuration/Commands/UpdateUiSettingsHandler.cs new file mode 100644 index 000000000..5cd471d4c --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdateUiSettingsHandler.cs @@ -0,0 +1,28 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; + +namespace ErsatzTV.Application.Configuration; + +public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepository) + : IRequestHandler> +{ + public async Task> Handle( + UpdateUiSettings request, + CancellationToken cancellationToken) + { + return await ApplyUpdate(request.UiSettings, cancellationToken); + } + + private async Task ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken) + { + await configElementRepository.Upsert( + ConfigElementKey.PagesIsDarkMode, + uiSettings.IsDarkMode, + cancellationToken); + + await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken); + + return Unit.Default; + } +} diff --git a/ErsatzTV.Application/Configuration/Queries/GetUiSettings.cs b/ErsatzTV.Application/Configuration/Queries/GetUiSettings.cs new file mode 100644 index 000000000..608c06fa1 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetUiSettings.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Configuration; + +public record GetUiSettings : IRequest; diff --git a/ErsatzTV.Application/Configuration/Queries/GetUiSettingsHandler.cs b/ErsatzTV.Application/Configuration/Queries/GetUiSettingsHandler.cs new file mode 100644 index 000000000..ce07bc0c0 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetUiSettingsHandler.cs @@ -0,0 +1,25 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; + +namespace ErsatzTV.Application.Configuration; + +public class GetUiSettingsHandler(IConfigElementRepository configElementRepository) + : IRequestHandler +{ + public async Task Handle(GetUiSettings request, CancellationToken cancellationToken) + { + Option pagesIsDarkMode = await configElementRepository.GetValue( + ConfigElementKey.PagesIsDarkMode, + cancellationToken); + + Option pagesLanguage = await configElementRepository.GetValue( + ConfigElementKey.PagesLanguage, + cancellationToken); + + return new UiSettingsViewModel + { + IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true), + Language = await pagesLanguage.IfNoneAsync("en") + }; + } +} diff --git a/ErsatzTV.Application/Configuration/UiSettingsViewModel.cs b/ErsatzTV.Application/Configuration/UiSettingsViewModel.cs new file mode 100644 index 000000000..bfa3bab24 --- /dev/null +++ b/ErsatzTV.Application/Configuration/UiSettingsViewModel.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Application.Configuration; + +public class UiSettingsViewModel +{ + public bool IsDarkMode { get; set; } + + public string Language { get; set; } +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs index 6e26de7ae..468f173f1 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs @@ -112,7 +112,7 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory dbCon gap.FinishOffset, TimeSpan.FromSeconds(Math.Round(gapDuration.TotalSeconds)).ToString( gapDuration.TotalHours >= 1 ? @"h\:mm\:ss" : @"mm\:ss", - CultureInfo.CurrentUICulture.DateTimeFormat), + CultureInfo.CurrentCulture.DateTimeFormat), None ); }).ToList(); diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index c1f89cdbf..181aa8a99 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -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 PagesLanguage => new("pages.language"); public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size"); public static ConfigElementKey ChannelsShowDisabled => new("pages.channels.show_disabled"); public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size"); diff --git a/ErsatzTV/DatabaseRequestCultureProvider.cs b/ErsatzTV/DatabaseRequestCultureProvider.cs new file mode 100644 index 000000000..0140364e0 --- /dev/null +++ b/ErsatzTV/DatabaseRequestCultureProvider.cs @@ -0,0 +1,27 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using Microsoft.AspNetCore.Localization; + +namespace ErsatzTV; + +public class DatabaseRequestCultureProvider(AcceptLanguageHeaderRequestCultureProvider defaultProvider) + : RequestCultureProvider +{ + public override async Task DetermineProviderCultureResult(HttpContext httpContext) + { + var configElementRepository = httpContext.RequestServices.GetService(); + + var defaultResult = await defaultProvider.DetermineProviderCultureResult(httpContext); + string configuredLanguage = await configElementRepository.GetValue( + ConfigElementKey.PagesLanguage, + httpContext.RequestAborted) + .IfNoneAsync("en"); + + if (defaultResult != null) + { + return new ProviderCultureResult(defaultResult.Cultures, [configuredLanguage]); + } + + return new ProviderCultureResult(configuredLanguage); + } +} diff --git a/ErsatzTV/Locals/Resources.cs b/ErsatzTV/Locals/Resources.cs new file mode 100644 index 000000000..bf1e68b9a --- /dev/null +++ b/ErsatzTV/Locals/Resources.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV; + +public class Resources +{ +} diff --git a/ErsatzTV/Locals/Resources.pt-br.resx b/ErsatzTV/Locals/Resources.pt-br.resx new file mode 100644 index 000000000..25e61049c --- /dev/null +++ b/ErsatzTV/Locals/Resources.pt-br.resx @@ -0,0 +1,35 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Canais + + + Perfis FFmpeg + + + Marcas d'água + + + Fontes de mídia + + + Mídias + + + Listas + + + Definições + + \ No newline at end of file diff --git a/ErsatzTV/Locals/Resources.resx b/ErsatzTV/Locals/Resources.resx new file mode 100644 index 000000000..c88411598 --- /dev/null +++ b/ErsatzTV/Locals/Resources.resx @@ -0,0 +1,24 @@ + + + + Channels + + + FFmpeg Profiles + + + Watermarks + + + Media Sources + + + Media + + + Lists + + + Settings + + diff --git a/ErsatzTV/Pages/DecoTemplateEditor.razor b/ErsatzTV/Pages/DecoTemplateEditor.razor index 078f92b35..1cfbd5fdd 100644 --- a/ErsatzTV/Pages/DecoTemplateEditor.razor +++ b/ErsatzTV/Pages/DecoTemplateEditor.razor @@ -61,7 +61,7 @@ @foreach (DateTime startTime in _startTimes) { - @startTime.ToString(CultureInfo.CurrentUICulture.DateTimeFormat.ShortTimePattern) + @startTime.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern) } @@ -129,7 +129,7 @@ ShowDatePicker="false" ShowTodayButton="false" DayTimeInterval="CalendarTimeInterval.Minutes10" - Use24HourClock="@(CultureInfo.CurrentUICulture.DateTimeFormat.ShortTimePattern.Contains("H"))" + Use24HourClock="@(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains("H"))" EnableDragItems="true" EnableResizeItems="false" ItemChanged="@(ci => CalendarItemChanged(ci))"/> diff --git a/ErsatzTV/Pages/Logs.razor b/ErsatzTV/Pages/Logs.razor index 4e2a07cc7..4ce5f7b3c 100644 --- a/ErsatzTV/Pages/Logs.razor +++ b/ErsatzTV/Pages/Logs.razor @@ -58,7 +58,7 @@ @code { private CancellationTokenSource _cts; - private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat; + private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat; private MudTable _table; private int _rowsPerPage = 10; diff --git a/ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor b/ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor index 41b279a18..0752f5ebc 100644 --- a/ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor +++ b/ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor @@ -224,7 +224,7 @@ @code { private CancellationTokenSource _cts; - private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat; + private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat; [Parameter] public int Id { get; set; } diff --git a/ErsatzTV/Pages/PlayoutTemplatesEditor.razor b/ErsatzTV/Pages/PlayoutTemplatesEditor.razor index 73c4ed049..350d58fce 100644 --- a/ErsatzTV/Pages/PlayoutTemplatesEditor.razor +++ b/ErsatzTV/Pages/PlayoutTemplatesEditor.razor @@ -273,7 +273,7 @@ @code { private CancellationTokenSource _cts; - private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat; + private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat; [Parameter] public int Id { get; set; } diff --git a/ErsatzTV/Pages/Playouts.razor b/ErsatzTV/Pages/Playouts.razor index b6ba0d239..3889254a1 100644 --- a/ErsatzTV/Pages/Playouts.razor +++ b/ErsatzTV/Pages/Playouts.razor @@ -263,7 +263,7 @@ @code { private CancellationTokenSource _cts; - private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat; + private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat; private readonly List _currentPage = []; private MudTable _table; diff --git a/ErsatzTV/Pages/Settings/UISettings.razor b/ErsatzTV/Pages/Settings/UISettings.razor new file mode 100644 index 000000000..d6067bcdc --- /dev/null +++ b/ErsatzTV/Pages/Settings/UISettings.razor @@ -0,0 +1,84 @@ +@page "/settings/ui" +@using ErsatzTV.Application.Configuration +@using ErsatzTV.Core.Notifications +@implements IDisposable +@inject IMediator Mediator +@inject ISnackbar Snackbar +@inject NavigationManager NavigationManager +@inject ILogger Logger + + + + Save Settings + +
+ + UI + + +
+ Theme +
+ + Dark + Light + +
+ +
+ Language +
+ + English + Português (Brasil) + +
+
+
+
+ +@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 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); + } + } + +} diff --git a/ErsatzTV/Pages/TemplateEditor.razor b/ErsatzTV/Pages/TemplateEditor.razor index 504a67b70..6f3c8b190 100644 --- a/ErsatzTV/Pages/TemplateEditor.razor +++ b/ErsatzTV/Pages/TemplateEditor.razor @@ -61,7 +61,7 @@ @foreach (DateTime startTime in _startTimes) { - @startTime.ToString(CultureInfo.CurrentUICulture.DateTimeFormat.ShortTimePattern) + @startTime.ToString(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern) } @@ -104,7 +104,7 @@ ShowDatePicker="false" ShowTodayButton="false" DayTimeInterval="CalendarTimeInterval.Minutes10" - Use24HourClock="@(CultureInfo.CurrentUICulture.DateTimeFormat.ShortTimePattern.Contains("H"))" + Use24HourClock="@(CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains("H"))" EnableDragItems="true" EnableResizeItems="false" ItemChanged="@(ci => CalendarItemChanged(ci))"/> diff --git a/ErsatzTV/Pages/Troubleshooting/BlockPlayoutHistory.razor b/ErsatzTV/Pages/Troubleshooting/BlockPlayoutHistory.razor index 85f182aef..3b9cd729a 100644 --- a/ErsatzTV/Pages/Troubleshooting/BlockPlayoutHistory.razor +++ b/ErsatzTV/Pages/Troubleshooting/BlockPlayoutHistory.razor @@ -91,7 +91,7 @@ public int? BlockId { get; set; } private CancellationTokenSource _cts; - private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat; + private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat; private int _rowsPerPage = 10; private int? _selectedPlayoutHistoryId; private PlayoutHistoryDetailsViewModel _decodedHistory; diff --git a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor index 4713c4765..c77676d26 100644 --- a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor @@ -193,7 +193,7 @@ @code { private CancellationTokenSource _cts; - private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat; + private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentCulture.DateTimeFormat; private readonly List _ffmpegProfiles = []; private readonly List _streamSelectors = []; private readonly List _watermarks = []; diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index 708a7add3..4d8596d55 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -8,6 +8,7 @@ @using ErsatzTV.Core.Notifications @using ErsatzTV.Extensions @using MediatR.Courier +@using Microsoft.Extensions.Localization @implements IDisposable @inject NavigationManager NavigationManager @inject IMediator Mediator @@ -15,6 +16,7 @@ @inject ISearchTargets SearchTargets; @inject ICourier Courier @inject IHealthCheckService HealthCheckService; +@inject IStringLocalizer StringLocalizer; @@ -103,7 +105,6 @@ -
@@ -117,16 +118,16 @@ { - Channels - FFmpeg Profiles - Watermarks - + @StringLocalizer["ButtonMainMenuChannels"] + @StringLocalizer["ButtonMainMenuFFmpegProfiles"] + @StringLocalizer["ButtonMainMenuWatermarks"] + Local Emby Jellyfin Plex - + Libraries Trash TV Shows @@ -137,7 +138,7 @@ Images Remote Streams - + Manual Collections Smart Collections Multi Collections @@ -183,12 +184,13 @@ - + FFmpeg Logging HDHomeRun Scanner Playout + UI XMLTV @@ -294,18 +296,6 @@ 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; @@ -314,6 +304,7 @@ SearchTargets.OnSearchTargetsChanged -= OnSearchTargetsChanged; Courier.UnSubscribe(HandleHealthCheckSummary); + Courier.UnSubscribe(HandlePlayoutUpdated); _cts?.Cancel(); _cts?.Dispose(); diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index d09a766a5..b33410376 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -87,6 +87,7 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.FileProviders; @@ -604,9 +605,13 @@ public class Startup { CultureInfo[] cinfo = CultureInfo.GetCultures(CultureTypes.AllCultures & ~CultureTypes.NeutralCultures); string[] supportedCultures = cinfo.Select(t => t.Name).Distinct().ToArray(); + string[] supportedUiCultures = ["en-us", "pt-br"]; options.AddSupportedCultures(supportedCultures) - .AddSupportedUICultures(supportedCultures) - .SetDefaultCulture("en-US"); + .AddSupportedUICultures(supportedUiCultures) + .SetDefaultCulture("en-us"); + options.AddInitialRequestCultureProvider( + new DatabaseRequestCultureProvider( + new AcceptLanguageHeaderRequestCultureProvider())); }); app.UseStaticFiles();