From 5ba7d3664d884859df5d4f300c8b136e8e04fdbd Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Thu, 24 Sep 2026 12:34:15 -0300 Subject: [PATCH] feat: in-session menu over the game (Select + Y) A console-style quick menu centered on the game screen, always on top, driven by the controller (GamepadNavigator) or the keyboard: volume (Left/Right, A mutes), resolution, audio output, FPS limit (RTSS) and HDR as pickers/toggles that apply live, "Record the last 30 s" as a coming-soon row, and two ways out: back to the PC or exit the app. Header shows the clock, elapsed time and the pad. Also reachable via consolemode://menu. - RtssService.SetLimit backs the user's settings up only once per session. - VideoFeaturesService.SetHdrFromMenu tracks what to undo at Stop(), including an HDR the user turned off that was on before. - WindowPlacement (from ConfirmWindow) centers windows in a screen's DPI. - NativeWindows.ForceForeground so the menu can take focus from a game. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.en-US.md | 4 + CHANGELOG.md | 4 + README.md | 4 +- docs/TESTING.md | 19 ++ src/ConsoleMode/App.xaml | 12 + src/ConsoleMode/App.xaml.cs | 23 +- src/ConsoleMode/ConfirmWindow.xaml.cs | 43 +-- src/ConsoleMode/Models/Models.cs | 3 + src/ConsoleMode/Native/NativeWindows.cs | 34 +++ src/ConsoleMode/Native/WindowPlacement.cs | 49 ++++ src/ConsoleMode/Resources/Strings.en-US.json | 12 + src/ConsoleMode/Resources/Strings.es-ES.json | 12 + src/ConsoleMode/Resources/Strings.pt-BR.json | 12 + .../Services/ControllerHoldWatcher.cs | 1 + src/ConsoleMode/Services/GamepadNavigator.cs | 8 +- .../Services/LocalizationService.cs | 9 + src/ConsoleMode/Services/ProtocolService.cs | 4 +- src/ConsoleMode/Services/RtssService.cs | 31 ++ src/ConsoleMode/Services/SessionMenuMath.cs | 26 ++ .../Services/VideoFeaturesService.cs | 25 ++ src/ConsoleMode/SessionMenuWindow.xaml | 177 ++++++++++++ src/ConsoleMode/SessionMenuWindow.xaml.cs | 142 ++++++++++ .../ViewModels/MainViewModel.SessionMenu.cs | 266 ++++++++++++++++++ src/ConsoleMode/ViewModels/MainViewModel.cs | 1 + src/ConsoleMode/Views/SettingsView.xaml | 6 + .../ConsoleMode.Tests.csproj | 1 + .../ConsoleMode.Tests/ProtocolServiceTests.cs | 1 + tests/ConsoleMode.Tests/SessionMenuTests.cs | 38 +++ 28 files changed, 916 insertions(+), 51 deletions(-) create mode 100644 src/ConsoleMode/Native/WindowPlacement.cs create mode 100644 src/ConsoleMode/Services/SessionMenuMath.cs create mode 100644 src/ConsoleMode/SessionMenuWindow.xaml create mode 100644 src/ConsoleMode/SessionMenuWindow.xaml.cs create mode 100644 src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs create mode 100644 tests/ConsoleMode.Tests/SessionMenuTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 25a2536..94f9750 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -2,6 +2,10 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the workflow publishes the section matching the tag from each one and fails if either is missing. +## [Unreleased] +### What's new +- Session menu: while the game is open, hold Select + Y for a menu over the game with volume, resolution, audio output, FPS limit, HDR, "Back to the PC" and "Exit Console Mode". Also via `consolemode://menu`. + ## [1.5.0-beta.5] ### What's new - Console interface: list settings (launcher, audio, resolution, FPS, hide strategy, language, interface) open a picker with every option, focused on the current one; A picks, B goes back. Before, you had to keep pressing A to cycle. diff --git a/CHANGELOG.md b/CHANGELOG.md index db3d06c..b0c7031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` com o changelog daquela versão **nos dois arquivos**. O workflow publica as duas seções correspondentes à tag na mesma release e falha se faltar alguma. +## [Unreleased] +### Novidades +- Menu da sessão: com o jogo aberto, segure Select + Y para abrir um menu sobre o jogo com volume, resolução, saída de áudio, limite de FPS, HDR, "Voltar ao PC" e "Sair do Console Mode". Também por `consolemode://menu`. + ## [1.5.0-beta.5] ### Novidades - Interface Console: os ajustes de lista (abrir em, áudio, resolução, FPS, como desligar, idioma, interface) abrem um seletor com todas as opções, com foco na atual; A escolhe, B volta. Antes era preciso ir apertando A para ciclar. diff --git a/README.md b/README.md index cf98f81..d79f82e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Typical setup: two desk monitors and a distant HDMI TV that was off. Console Mod - **`ConsoleMode-Portable-x64.exe`** — a single exe that keeps its data in `ConsoleMode_Data\` next to it 2. On first launch a short tour shows the screen map: pick the screen you play on (the others turn off) 3. From then on it is one click: **Play now**, or the **Console Mode 1 Click** desktop shortcut (Settings → Create shortcut). The first time with a new game screen, the TV asks "Can you see this screen?" and everything reverts on its own if nobody answers (mouse, keyboard or Xbox/PlayStation controller) -4. Or skip the PC altogether: with the app in the tray (turn on **Start with Windows**), **hold the Xbox button on the controller for 1 second** and console mode starts from the couch. **Hold Start + Select for 1 second** during a session to go back to the PC. Xbox (XInput) controllers only. In Settings you can switch to a **short press**: the app then turns off the controller shortcut for Game Bar in Windows (Win+G keeps working); if Steam is open, also turn off "Guide button focuses Steam" in Steam +4. Or skip the PC altogether: with the app in the tray (turn on **Start with Windows**), **hold the Xbox button on the controller for 1 second** and console mode starts from the couch. **Hold Start + Select for 1 second** during a session to go back to the PC. **Hold Select + Y** during a session for a menu over the game: volume, resolution, audio output, FPS limit, HDR, back to the PC or exit (not over exclusive-fullscreen games). Xbox (XInput) controllers only for the Home button. In Settings you can switch to a **short press**: the app then turns off the controller shortcut for Game Bar in Windows (Win+G keeps working); if Steam is open, also turn off "Guide button focuses Steam" in Steam 5. Automation: `consolemode://start`, `consolemode://stop` and `consolemode://show` links work from Stream Deck, launchers, scripts or any shortcut 6. Two interfaces: the **Desktop** one (mouse, screen map) and the **Console** one (full screen, big cards, D-pad/stick + A/B, works with Xbox and PlayStation pads). By default the app picks Console whenever a controller is connected; change it in Settings → Interface or with the switch button on either screen 4. When you are done, exit Big Picture / Playnite (or restore manually in Xbox mode) @@ -190,7 +190,7 @@ Transforme seu PC Windows em um **console de jogos** com um clique: foque na TV, - **`ConsoleMode-Portable-x64.exe`** — um único exe que guarda os dados em `ConsoleMode_Data\` ao lado dele 2. Na primeira vez, um tour curto mostra o mapa das telas: escolha a tela onde você joga (as outras desligam) 3. Depois é 1 clique: **Jogar agora**, ou o atalho **Console Mode 1 Click** na Área de Trabalho (Ajustes → Criar atalho). Na primeira vez com uma tela de jogo nova, a TV pergunta "Está vendo esta tela?" e tudo volta sozinho se ninguém responder (mouse, teclado ou controle de Xbox/PlayStation) -4. Ou nem encoste no PC: com o app na bandeja (ligue **Iniciar com o Windows**), **segure o botão Xbox do controle por 1 segundo** e o modo console entra direto do sofá. **Segure Start + Select por 1 segundo** durante a sessão para voltar ao PC. Só controles Xbox (XInput). Em Ajustes dá para trocar por um **toque curto**: aí o app desliga o atalho do controle para a Game Bar no Windows (Win+G continua funcionando); se a Steam estiver aberta, desligue também "Botão Guide foca a Steam" na Steam +4. Ou nem encoste no PC: com o app na bandeja (ligue **Iniciar com o Windows**), **segure o botão Xbox do controle por 1 segundo** e o modo console entra direto do sofá. **Segure Start + Select por 1 segundo** durante a sessão para voltar ao PC. **Segure Select + Y** durante a sessão para um menu sobre o jogo: volume, resolução, saída de áudio, limite de FPS, HDR, voltar ao PC ou sair (não funciona sobre jogos em tela cheia exclusiva). Só controles Xbox (XInput) para o botão Home. Em Ajustes dá para trocar por um **toque curto**: aí o app desliga o atalho do controle para a Game Bar no Windows (Win+G continua funcionando); se a Steam estiver aberta, desligue também "Botão Guide foca a Steam" na Steam 5. Automação: os links `consolemode://start`, `consolemode://stop` e `consolemode://show` funcionam em Stream Deck, launchers, scripts ou qualquer atalho 6. Duas interfaces: a **Desktop** (mouse, mapa de telas) e a **Console** (tela cheia, cartões grandes, D-pad/analógico + A/B, funciona com controles Xbox e PlayStation). Por padrão o app escolhe Console sempre que há um controle conectado; mude em Ajustes → Interface ou pelo botão de troca em qualquer uma das telas 4. Ao terminar, saia do Big Picture / Playnite (ou restaure manualmente no Modo Xbox) diff --git a/docs/TESTING.md b/docs/TESTING.md index 4c87fad..8f21faf 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -35,6 +35,25 @@ Pré-condições: Ajustes → "Entrar ao conectar um controle" **ligado**; app n - [ ] O log mostra `Controle conectado: ; auto-start sim/não` a cada conexão. Resultado: ______ - [ ] Com a opção **desligada** (padrão), nada disso acontece. Resultado: ______ +## 1c. Menu da sessão (Select + Y) + +Pré-condições: sessão ativa com Big Picture (ou jogo borderless) na tela; controle Xbox ou PlayStation. + +- [ ] Segurar **Select + Y** por ~0,3 s abre o menu centralizado na tela de jogo, por cima do Big Picture, com foco em "Voltar ao jogo". Resultado: ______ +- [ ] Select + Y de novo (ou B, ou Esc) fecha o menu e o jogo continua onde estava. Resultado: ______ +- [ ] Cabeçalho mostra o relógio, "Jogando há X min" e o controle detectado. Resultado: ______ +- [ ] **Volume:** ◀/▶ na linha muda de 5 em 5 e o Windows reflete; A alterna mudo. O valor inicial é o atual do Windows. Resultado: ______ +- [ ] **Resolução:** abre a lista com a atual marcada; escolher outra aplica na TV, o Big Picture continua aberto e a sessão não se encerra sozinha. Ao restaurar no fim, a resolução original volta. Resultado: ______ +- [ ] **Saída de áudio:** lista só saídas ativas; escolher troca o som na hora e o "ao conectar" não desfaz. Resultado: ______ +- [ ] **Limite de FPS** (só com RTSS): trocar o limite vale no jogo; "Sem limite" desliga o limitador; ao restaurar, o RTSS volta ao que era antes da sessão. Resultado: ______ +- [ ] **HDR:** alternar liga/desliga na TV; ao restaurar, o HDR volta ao estado de antes da sessão (inclusive se você desligou um que estava ligado). Resultado: ______ +- [ ] "Gravar os últimos 30 s" aparece desativado com "em breve". Resultado: ______ +- [ ] **Voltar ao PC** restaura e o app fica na bandeja; **Sair do Console Mode** restaura e fecha o app. Resultado: ______ +- [ ] Start + Select continua restaurando direto, sem abrir o menu. Resultado: ______ +- [ ] Com **PlayStation**: D-pad/✕/○ funcionam no menu (a janela toma o primeiro plano). Resultado: ______ +- [ ] Jogo em **tela cheia exclusiva**: o menu não aparece por cima (limitação documentada); Select + Y não quebra nada. Resultado: ______ +- [ ] `start consolemode://menu` no cmd abre o menu; fora da sessão, não faz nada. Resultado: ______ + ## 2. Links `consolemode://` - [ ] `start consolemode://start` no `cmd` com o app **fechado**: abre e entra no modo console. Resultado: ______ diff --git a/src/ConsoleMode/App.xaml b/src/ConsoleMode/App.xaml index b8afa1e..9d61069 100644 --- a/src/ConsoleMode/App.xaml +++ b/src/ConsoleMode/App.xaml @@ -94,6 +94,18 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ConsoleMode/SessionMenuWindow.xaml.cs b/src/ConsoleMode/SessionMenuWindow.xaml.cs new file mode 100644 index 0000000..a2fe49f --- /dev/null +++ b/src/ConsoleMode/SessionMenuWindow.xaml.cs @@ -0,0 +1,142 @@ +using System.ComponentModel; +using ConsoleMode.Models; +using ConsoleMode.Native; +using ConsoleMode.Services; +using ConsoleMode.ViewModels; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using WinRT.Interop; + +namespace ConsoleMode; + +/// +/// The Select + Y menu over the game (see MainViewModel.SessionMenu). Centered on the game +/// screen, always on top, driven by the controller through GamepadNavigator or by keyboard. +/// Exclusive-fullscreen games can't be covered; Big Picture and borderless games can. +/// +public sealed partial class SessionMenuWindow : Window +{ + private const double WidthDip = 760; + private const double HeightDip = 680; + + private readonly nint _hwnd; + private readonly GamepadNavigator _navigator; + + public MainViewModel ViewModel { get; } + + public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) + { + ViewModel = viewModel; + InitializeComponent(); + + _hwnd = WindowNative.GetWindowHandle(this); + var appWindow = AppWindow.GetFromWindowId(Microsoft.UI.Win32Interop.GetWindowIdFromWindow(_hwnd)); + if (appWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsAlwaysOnTop = true; + presenter.IsResizable = false; + presenter.IsMaximizable = false; + presenter.IsMinimizable = false; + presenter.SetBorderAndTitleBar(false, false); + } + WindowPlacement.CenterOn(appWindow, target, WidthDip, HeightDip); + + _navigator = new GamepadNavigator(DispatcherQueue, Root) + { + // Left/Right on the volume row adjust it instead of moving focus. + BeforeMove = direction => + { + if (Root.XamlRoot is not { } root || !ReferenceEquals(FocusManager.GetFocusedElement(root), VolumeRow)) return false; + if (direction == FocusNavigationDirection.Left) { ViewModel.ChangeVolume(-1); return true; } + if (direction == FocusNavigationDirection.Right) { ViewModel.ChangeVolume(1); return true; } + return false; + } + }; + _navigator.BackRequested += ViewModel.SessionMenuBack; + _navigator.Start(); + + // PreviewKeyDown (tunneling): the ScrollViewer would otherwise handle the arrows as scrolling + // before they ever bubble up to Root. + Root.PreviewKeyDown += (_, e) => + { + // Arrows go through the same path as the D-pad. + var direction = e.Key switch + { + Windows.System.VirtualKey.Up => FocusNavigationDirection.Up, + Windows.System.VirtualKey.Down => FocusNavigationDirection.Down, + Windows.System.VirtualKey.Left => FocusNavigationDirection.Left, + Windows.System.VirtualKey.Right => FocusNavigationDirection.Right, + _ => FocusNavigationDirection.None + }; + if (direction != FocusNavigationDirection.None) + { + e.Handled = true; + if (_navigator.BeforeMove?.Invoke(direction) != true) + FocusManager.TryMoveFocus(direction, new FindNextElementOptions { SearchRoot = Root }); + return; + } + if (e.Key is not (Windows.System.VirtualKey.Escape or Windows.System.VirtualKey.GamepadB)) return; + e.Handled = true; + ViewModel.SessionMenuBack(); + }; + ViewModel.PropertyChanged += OnViewModelChanged; + // BringToFront runs before the tree exists; the real first focus happens here. + Root.Loaded += (_, _) => FirstRow.Focus(FocusState.Keyboard); + Activated += (_, args) => + { + // XamlRoot is null on the very first activation; GetFocusedElement(null) throws. + if (args.WindowActivationState != WindowActivationState.Deactivated && Root.XamlRoot is { } root + && FocusManager.GetFocusedElement(root) is null) + FirstRow.Focus(FocusState.Keyboard); + }; + Closed += (_, _) => + { + ViewModel.PropertyChanged -= OnViewModelChanged; + _navigator.Dispose(); + }; + } + + /// Windows won't hand a background process the foreground; this forces it (PlayStation pads need it). + public void BringToFront() + { + if (!NativeWindows.ForceForeground(_hwnd)) AppLog.Write("Menu da sessão: não conseguiu vir para frente"); + FirstRow.Focus(FocusState.Keyboard); + } + + private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(MainViewModel.IsConsoleActive) when !ViewModel.IsConsoleActive: + Close(); + break; + case nameof(MainViewModel.IsSessionPickerOpen): + DispatcherQueue.TryEnqueue(() => + { + if (ViewModel.IsSessionPickerOpen) FocusPickerSelection(); + else FirstRow.Focus(FocusState.Keyboard); + }); + break; + } + } + + private void FocusPickerSelection() + { + var index = Math.Max(ViewModel.SessionPickerOptions.ToList().FindIndex(o => o.IsSelected), 0); + if (TryFocusPickerRow(index)) return; + void OnLayout(object? s, object e) + { + PickerList.LayoutUpdated -= OnLayout; + TryFocusPickerRow(index); + } + PickerList.LayoutUpdated += OnLayout; + } + + private bool TryFocusPickerRow(int index) + { + if (PickerList.ContainerFromIndex(index) is not { } container) return false; + return (FocusManager.FindFirstFocusableElement(container) as Control)?.Focus(FocusState.Keyboard) == true; + } +} diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs new file mode 100644 index 0000000..3802eae --- /dev/null +++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs @@ -0,0 +1,266 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ConsoleMode.Models; +using ConsoleMode.Native; +using ConsoleMode.Services; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; + +namespace ConsoleMode.ViewModels; + +// The in-session menu (Select + Y): a window over the game with volume, resolution, audio, +// FPS, HDR and the two ways out. Everything here runs only while a session is active. +public partial class MainViewModel +{ + private SessionMenuWindow? _sessionMenu; + private DispatcherQueueTimer? _sessionClockTimer; + private string _sessionPickerKey = ""; + + public ObservableCollection SessionPickerOptions { get; } = []; + + [ObservableProperty] private bool _isSessionMenuOpen; + [ObservableProperty] private bool _isSessionMenuBusy; + [ObservableProperty] private bool _isSessionPickerOpen; + [ObservableProperty] private string _sessionPickerTitle = ""; + [ObservableProperty] private string _sessionClock = ""; + [ObservableProperty] private string _sessionElapsed = ""; + [ObservableProperty] private string _sessionControllerText = ""; + [ObservableProperty] private int _volumePercent = -1; + [ObservableProperty] private bool _isMuted; + [ObservableProperty] private bool _sessionHdrOn; + [ObservableProperty] private string _sessionModeText = ""; + [ObservableProperty] private string _sessionAudioText = ""; + [ObservableProperty] private string _sessionFpsText = ""; + + public bool IsFpsMenuAvailable => Engine.Rtss.IsReady; + public string VolumeText => VolumePercent < 0 ? "—" : IsMuted ? LocalizationService.Get("Muted") : $"{VolumePercent}%"; + public string RecordRowText => LocalizationService.Get("RecordLast30") + LocalizationService.Get("ComingSoonSuffix"); + + partial void OnVolumePercentChanged(int value) => OnPropertyChanged(nameof(VolumeText)); + partial void OnIsMutedChanged(bool value) => OnPropertyChanged(nameof(VolumeText)); + + /// Select + Y, the tray, or consolemode://menu. + public void ToggleSessionMenu() + { + if (!IsConsoleActive) return; + if (IsSessionMenuOpen) { CloseSessionMenu(); return; } + + _sessionMenu = new SessionMenuWindow(this, Engine.State.FocusMonitorRect); + _sessionMenu.Closed += (_, _) => { _sessionMenu = null; IsSessionMenuOpen = false; StopSessionClock(); }; + IsSessionMenuOpen = true; + IsSessionPickerOpen = false; + RefreshSessionHeader(); + StartSessionClock(); + _sessionMenu.Activate(); + _sessionMenu.BringToFront(); + _ = LoadSessionValuesAsync(); + AppLog.Write("Menu da sessão: aberto"); + } + + [RelayCommand] + private void CloseSessionMenu() + { + IsSessionPickerOpen = false; + _sessionMenu?.Close(); + } + + /// B: closes the picker first, then the menu. + public void SessionMenuBack() + { + if (IsSessionPickerOpen) IsSessionPickerOpen = false; + else CloseSessionMenu(); + } + + private void StartSessionClock() + { + _sessionClockTimer ??= _dispatcher.CreateTimer(); + _sessionClockTimer.Interval = TimeSpan.FromSeconds(1); + _sessionClockTimer.Tick += OnSessionClockTick; + _sessionClockTimer.Start(); + } + + private void StopSessionClock() + { + if (_sessionClockTimer is null) return; + _sessionClockTimer.Stop(); + _sessionClockTimer.Tick -= OnSessionClockTick; + } + + private void OnSessionClockTick(DispatcherQueueTimer sender, object args) => RefreshSessionHeader(); + + private void RefreshSessionHeader() + { + SessionClock = DateTime.Now.ToString("HH:mm"); + var started = Engine.State.SessionStartedAt ?? DateTime.Now; + SessionElapsed = LocalizationService.Get("SessionElapsed", SessionMenuMath.FormatElapsed(DateTime.Now - started)); + SessionControllerText = ControllerInput.DetectFamily() switch + { + ControllerFamily.Xbox => LocalizationService.Get("ControllerXbox"), + ControllerFamily.PlayStation => LocalizationService.Get("ControllerPlaystation"), + ControllerFamily.Other => LocalizationService.Get("ControllerOther"), + _ => LocalizationService.Get("ControllerNone") + }; + } + + private string? SessionAudioId => string.IsNullOrWhiteSpace(Engine.State.AudioDeviceId) ? Engine.Audio.GetDefaultId() : Engine.State.AudioDeviceId; + + private async Task LoadSessionValuesAsync() + { + var state = Engine.State; + var focus = state.FocusMonitor ?? ""; + var (mode, hdr, audioName, volume) = await Task.Run(() => + { + var current = NativeWindows.GetCurrentDisplayMode(focus); + var modeText = current is null ? "" : $"{current.Width} × {current.Height} @ {current.Frequency} Hz"; + var hdrOn = Engine.Video.IsHdrOn(focus); + var id = SessionAudioId; + var name = Engine.Audio.GetDevices().FirstOrDefault(d => d.FriendlyId == id)?.Name ?? LocalizationService.Get("AudioNoChange"); + int? vol = null; + try { if (id is not null) vol = SessionMenuMath.ParseVolumeExitCode(Engine.Audio.InvokeSvv("/GetPercent", id)); } catch { /* svv optional */ } + return (modeText, hdrOn, name, vol); + }); + SessionModeText = mode; + SessionHdrOn = hdr; + SessionAudioText = audioName; + VolumePercent = volume ?? -1; + SessionFpsText = state.FpsLimit > 0 ? $"{state.FpsLimit} FPS" : LocalizationService.Get("FpsNoLimit"); + OnPropertyChanged(nameof(IsFpsMenuAvailable)); + } + + /// Left/Right on the volume row. + public void ChangeVolume(int direction) + { + if (IsSessionMenuBusy || SessionAudioId is not { } id) return; + var next = SessionMenuMath.StepVolume(Math.Max(VolumePercent, 0), direction); + VolumePercent = next; + IsMuted = false; + _ = Task.Run(() => + { + try { Engine.Audio.InvokeSvv("/SetVolume", id, next.ToString()); Engine.Audio.InvokeSvv("/Unmute", id); } + catch (Exception ex) { AppLog.Write($"Volume: {ex.Message}"); } + }); + } + + [RelayCommand] + private void ToggleMute() + { + if (IsSessionMenuBusy || SessionAudioId is not { } id) return; + IsMuted = !IsMuted; + var muted = IsMuted; + _ = Task.Run(() => + { + try { Engine.Audio.InvokeSvv(muted ? "/Mute" : "/Unmute", id); } + catch (Exception ex) { AppLog.Write($"Mudo: {ex.Message}"); } + }); + } + + [RelayCommand] + private void OpenSessionPicker(string key) + { + if (IsSessionMenuBusy) return; + var state = Engine.State; + var focus = state.FocusMonitor ?? ""; + IEnumerable<(string Text, string Value, bool Selected)> options; + string title; + switch (key) + { + case "mode": + title = LocalizationService.Get("ResolutionRefreshCard"); + var current = NativeWindows.GetCurrentDisplayMode(focus); + options = NativeWindows.EnumerateDisplayModes(focus) + .Select(m => ($"{m.Width} × {m.Height} @ {m.Frequency} Hz", $"{m.Width}x{m.Height}@{m.Frequency}", + current is not null && m.Width == current.Width && m.Height == current.Height && m.Frequency == current.Frequency)); + break; + case "audio": + title = LocalizationService.Get("AudioOutputCard"); + var id = SessionAudioId; + options = Engine.Audio.GetDevices().Where(d => d.IsActive).Select(d => (d.Name, d.FriendlyId, d.FriendlyId == id)); + break; + case "fps": + title = LocalizationService.Get("FpsCard"); + options = new[] { (LocalizationService.Get("FpsNoLimit"), "0", state.FpsLimit == 0) } + .Concat(FpsPresets.Select(f => ($"{f} FPS", f.ToString(), state.FpsLimit == f))); + break; + default: + return; + } + _sessionPickerKey = key; + SessionPickerTitle = title; + SessionPickerOptions.Clear(); + foreach (var (text, value, selected) in options) SessionPickerOptions.Add(new PickerItem(text, value, selected)); + IsSessionPickerOpen = true; + } + + [RelayCommand] + private async Task PickSessionOptionAsync(PickerItem? item) + { + if (item is null) return; + IsSessionPickerOpen = false; + var key = _sessionPickerKey; + await RunSessionActionAsync(() => + { + var state = Engine.State; + switch (key) + { + case "mode": + var parts = item.Value.Split('x', '@'); + Engine.Monitors.ApplyFocusMode(state.FocusMonitor ?? "", new SavedDisplayMode + { + Width = int.Parse(parts[0]), Height = int.Parse(parts[1]), Frequency = int.Parse(parts[2]) + }); + Engine.Monitors.UpdateFocusRect(state.FocusMonitor ?? "", state, true); + break; + case "audio": + Engine.Audio.SetOutput(item.Value); + state.AudioDeviceId = item.Value; + state.AudioWatchComplete = true; // the auto-switch must not undo a manual choice + break; + case "fps": + var result = Engine.Rtss.SetLimit(int.Parse(item.Value), state); + if (!result.Success) throw new InvalidOperationException(result.Message); + break; + } + }); + await LoadSessionValuesAsync(); + } + + [RelayCommand] + private async Task ToggleSessionHdrAsync() + { + var focus = Engine.State.FocusMonitor ?? ""; + var target = !SessionHdrOn; + await RunSessionActionAsync(() => + { + if (!Engine.Video.SetHdrFromMenu(focus, target, Engine.State)) + throw new InvalidOperationException($"HDR {focus}"); + }); + await LoadSessionValuesAsync(); + } + + /// Blocking engine call off the UI thread, never overlapping the session Tick. + private async Task RunSessionActionAsync(Action action) + { + if (IsSessionMenuBusy || _busy) return; + IsSessionMenuBusy = true; + _busy = true; + try { await Task.Run(action); } + catch (Exception ex) { AppLog.Write($"Menu da sessão: {ex.Message}"); } + finally { _busy = false; IsSessionMenuBusy = false; } + } + + [RelayCommand] + private async Task RestoreFromMenuAsync() + { + CloseSessionMenu(); + await RestoreNowAsync(); + } + + [RelayCommand] + private async Task ExitFromMenuAsync() + { + CloseSessionMenu(); + await RestoreNowAsync(); + Application.Current.Exit(); + } +} diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs index 99a0afb..3606d32 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.cs @@ -631,6 +631,7 @@ public async Task StartAsync() { var focus = FocusRow?.Monitor; await Task.Run(() => Engine.Start(config, focus, confirm)); + Engine.State.SessionStartedAt = DateTime.Now; IsConsoleActive = true; ShowPage(settings: false); ActiveDescText = DescribeActive(config); diff --git a/src/ConsoleMode/Views/SettingsView.xaml b/src/ConsoleMode/Views/SettingsView.xaml index 18fc8e1..b30344e 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml +++ b/src/ConsoleMode/Views/SettingsView.xaml @@ -75,6 +75,12 @@ + + + + + diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index 3fa6091..5952dc5 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/ConsoleMode.Tests/ProtocolServiceTests.cs b/tests/ConsoleMode.Tests/ProtocolServiceTests.cs index 8fac01d..e154e6b 100644 --- a/tests/ConsoleMode.Tests/ProtocolServiceTests.cs +++ b/tests/ConsoleMode.Tests/ProtocolServiceTests.cs @@ -11,6 +11,7 @@ public class ProtocolServiceTests [InlineData("consolemode:show", "show")] [InlineData("consolemode://start?preset=sala", "start")] [InlineData("consolemode://start#x", "start")] + [InlineData("consolemode://menu", "menu")] public void ParseAction_ReadsTheActionFromTheUri(string argument, string expected) => Assert.Equal(expected, ProtocolService.ParseAction(argument)); diff --git a/tests/ConsoleMode.Tests/SessionMenuTests.cs b/tests/ConsoleMode.Tests/SessionMenuTests.cs new file mode 100644 index 0000000..ec9a52b --- /dev/null +++ b/tests/ConsoleMode.Tests/SessionMenuTests.cs @@ -0,0 +1,38 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class SessionMenuTests +{ + [Theory] + [InlineData(50, 1, 55)] + [InlineData(50, -1, 45)] + [InlineData(98, 1, 100)] + [InlineData(2, -1, 0)] + [InlineData(0, -1, 0)] + public void Volume_steps_by_five_within_bounds(int current, int direction, int expected) => + Assert.Equal(expected, SessionMenuMath.StepVolume(current, direction)); + + [Theory] + [InlineData(500, 50)] + [InlineData(1000, 100)] + [InlineData(0, 0)] + [InlineData(-1, null)] + [InlineData(1001, null)] + public void GetPercent_exit_code_is_the_level_times_ten(int exitCode, int? expected) => + Assert.Equal(expected, SessionMenuMath.ParseVolumeExitCode(exitCode)); + + [Theory] + [InlineData(0, 12, "12 min")] + [InlineData(1, 5, "1 h 05 min")] + [InlineData(2, 0, "2 h 00 min")] + public void Elapsed_reads_like_a_console(int hours, int minutes, string expected) => + Assert.Equal(expected, SessionMenuMath.FormatElapsed(new TimeSpan(hours, minutes, 30))); + + [Fact] + public void Rtss_backup_happens_only_the_first_time() + { + Assert.True(SessionMenuMath.NeedsRtssBackup(limitAlreadyApplied: false)); + Assert.False(SessionMenuMath.NeedsRtssBackup(limitAlreadyApplied: true)); + } +}