Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
### Fixes
- Playnite: a slow start no longer sends you back to the PC after a few seconds. When Playnite swaps its loading window for the main one, Console Mode now follows the new window and only restores the desk once Playnite actually closes. (#81)

## [1.5.0]
### What's new
- Console interface: a full-screen, controller-first home and Settings, driven by the D-pad/stick and A/B with Xbox and PlayStation pads. Automatic mode picks it whenever a controller is connected. (#41, #42, #47)
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
### Correções
- Playnite: quando ele demora para abrir, o app não volta mais para o PC depois de alguns segundos. Se o Playnite troca a janela de carregamento pela principal, o Console Mode passa a acompanhar a nova e só restaura a mesa quando o Playnite fecha de verdade. (#81)

## [1.5.0]
### Novidades
- Interface Console: tela inicial e Ajustes em tela cheia, pensados para o controle (direcional/analógico, A/B) com controles Xbox e PlayStation. O modo Automático escolhe essa interface sempre que há um controle conectado. (#41, #42, #47)
Expand Down
1 change: 1 addition & 0 deletions src/ConsoleMode/Models/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ public sealed class ConsoleRuntimeState
public string? LastAudioSwitchName { get; set; }
public bool AudioPendingTarget { get; set; }
public nint CachedBigPictureHandle { get; set; }
public DateTime? PlayniteWindowMissingSince { get; set; }
public nint CachedXboxHandle { get; set; }
public bool BigPictureWatchActive { get; set; }
public bool AudioWatchComplete { get; set; }
Expand Down
30 changes: 27 additions & 3 deletions src/ConsoleMode/Services/ConsoleEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public void Start(AppConfig config, MonitorInfo? focusInfo, Func<ScreenRect?, bo
State.LaunchTime = null;
State.AbsenceCount = 0;
State.CachedBigPictureHandle = 0;
State.PlayniteWindowMissingSince = null;
State.CachedXboxHandle = 0;
State.AudioWatchComplete = false;
State.BigPictureWatchActive = false;
Expand Down Expand Up @@ -410,9 +411,10 @@ private void MoveToFocus(string monitorName, nint[] handles, ScreenRect? rect)

private bool IsExitSignaled()
{
if (NativeWindows.ConsumeBigPictureExitRequest()) return true;
if (State.CachedBigPictureHandle != 0 && !NativeWindows.IsWindowStillVisible(State.CachedBigPictureHandle))
return true;
var watchedGone = NativeWindows.ConsumeBigPictureExitRequest() ||
(State.CachedBigPictureHandle != 0 && !NativeWindows.IsWindowStillVisible(State.CachedBigPictureHandle));
if (State.FullscreenMode == "playnite" && State.HasAppeared) return IsPlayniteExitSignaled(watchedGone);
if (watchedGone) return true;
if (!State.HasAppeared) return false;
if (Launch.IsFullscreenActive(State.FullscreenMode, State))
{
Expand All @@ -423,6 +425,28 @@ private bool IsExitSignaled()
return State.AbsenceCount >= 2;
}

/// <summary>
/// Playnite may replace the window being watched (loading screen → main window). Follow the new
/// window while the process runs, and end the session only when it exits or stays windowless.
/// </summary>
private bool IsPlayniteExitSignaled(bool watchedGone)
{
if (watchedGone) State.CachedBigPictureHandle = 0;
var showing = Launch.IsPlayniteActive(State);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] A newly discovered candidate is accepted as active based only on GetWindowArea; unlike the cached-handle branch in IsPlayniteActive, this path does not require IsWindowStillVisible. During splash teardown or with a hidden/stale large main window, showing can remain true while no fullscreen window is present, clearing PlayniteWindowMissingSince at line 443 and preventing the 20-second fallback, so the desktop is never restored.

Technical details
# Invalid replacement HWND prevents restoration

## Affected sites
- `src/ConsoleMode/Services/ConsoleEngine.cs:435,443-445` — treats the result of `IsPlayniteActive` as proof that a visible Playnite fullscreen window exists.
- `src/ConsoleMode/Services/LaunchService.cs:127-131` — accepts a fresh `MainWindowHandle` when its rectangle exceeds 200,000 pixels, without the visibility check used for the cached handle.

## Required outcome
- Only clear `PlayniteWindowMissingSince` and re-arm the watch for a currently valid, visible, sufficiently large Playnite fullscreen window.

if (showing && watchedGone)
{
var handle = State.CachedBigPictureHandle;
AppLog.Write("Loop: Playnite trocou de janela; acompanhando a nova");
OnUi(() => State.BigPictureWatchActive = NativeWindows.StartBigPictureExitWatch(handle));
}

if (showing) State.PlayniteWindowMissingSince = null;
else State.PlayniteWindowMissingSince ??= DateTime.Now;
var exit = PlayniteExitPolicy.IsExit(Launch.IsPlayniteRunning(), showing, State.PlayniteWindowMissingSince, DateTime.Now);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] IsPlayniteRunning() is global to the executable name and is not tied to the HWND selected by IsPlayniteActive. If an old, restarting, or concurrent Playnite.FullscreenApp process retains a large window after the launched session closes, the replacement lookup can adopt that other window and this process check keeps the policy from exiting, so the desktop is not restored.

Technical details
# Playnite session is not identified

## Affected sites
- `src/ConsoleMode/Services/LaunchService.cs:104-110` — enumerates any `Playnite.FullscreenApp` process rather than identifying the launched process.
- `src/ConsoleMode/Services/ConsoleEngine.cs:435,443-445` — accepts any matching process window as the active session and uses any same-named process to suppress exit.

## Required outcome
- Keep the tracked Playnite process/window identity associated with the console-mode launch, and use that identity consistently when evaluating replacement windows and process exit.

if (exit) AppLog.Write("Loop: Playnite fechou");
return exit;
}

private bool AudioWatchNeeded()
{
if (!AppPaths.HasSvv || State.AudioWatchComplete) return false;
Expand Down
2 changes: 2 additions & 0 deletions src/ConsoleMode/Services/LaunchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ public nint[] GetPlayniteHandles()
.ToArray();
}

public bool IsPlayniteRunning() => Process.GetProcessesByName("Playnite.FullscreenApp").Length > 0;

public nint[] GetFullscreenHandles(string mode) =>
mode == "playnite" ? GetPlayniteHandles() : GetBigPictureHandles();

Expand Down
19 changes: 19 additions & 0 deletions src/ConsoleMode/Services/PlayniteExitPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace ConsoleMode.Services;

/// <summary>
/// When a Playnite session counts as over. Playnite can swap its first big window (a loading
/// screen on slow starts) for the main one, so a vanished window alone is not an exit. Pure, so it's tested.
/// </summary>
public static class PlayniteExitPolicy
{
/// <summary>How long Playnite may run without a big window before the session ends anyway.</summary>
public static readonly TimeSpan WindowGrace = TimeSpan.FromSeconds(20);

/// <param name="windowMissingSince">When the big window went missing, or null if it is showing.</param>
public static bool IsExit(bool processRunning, bool bigWindowShowing, DateTime? windowMissingSince, DateTime now)
{
if (!processRunning) return true;
if (bigWindowShowing || windowMissingSince is null) return false;
return now - windowMissingSince.Value >= WindowGrace;
}
}
1 change: 1 addition & 0 deletions tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<Compile Include="../../src/ConsoleMode/Services/UiModeResolver.cs" Link="UiModeResolver.cs" />
<Compile Include="../../src/ConsoleMode/Services/PlaynitePaths.cs" Link="PlaynitePaths.cs" />
<Compile Include="../../src/ConsoleMode/Services/ControllerConnectPolicy.cs" Link="ControllerConnectPolicy.cs" />
<Compile Include="../../src/ConsoleMode/Services/PlayniteExitPolicy.cs" Link="PlayniteExitPolicy.cs" />
<Compile Include="../../src/ConsoleMode/Services/SessionMenuMath.cs" Link="SessionMenuMath.cs" />
<Compile Include="../../src/ConsoleMode/Services/ControllerMapping.cs" Link="ControllerMapping.cs" />
<Compile Include="../../src/ConsoleMode/Models/Models.cs" Link="Models.cs" />
Expand Down
24 changes: 24 additions & 0 deletions tests/ConsoleMode.Tests/PlayniteExitPolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using ConsoleMode.Services;

namespace ConsoleMode.Tests;

public class PlayniteExitPolicyTests
{
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);

[Fact]
public void Exits_when_the_process_is_gone() =>
Assert.True(PlayniteExitPolicy.IsExit(processRunning: false, bigWindowShowing: false, windowMissingSince: null, T0));

[Fact]
public void Stays_while_the_big_window_shows() =>
Assert.False(PlayniteExitPolicy.IsExit(processRunning: true, bigWindowShowing: true, windowMissingSince: null, T0));

[Fact]
public void Waits_while_the_loading_window_is_swapped_for_the_main_one() =>
Assert.False(PlayniteExitPolicy.IsExit(processRunning: true, bigWindowShowing: false, windowMissingSince: T0, T0 + TimeSpan.FromSeconds(5)));

[Fact]
public void Exits_when_the_process_stays_windowless_past_the_grace() =>
Assert.True(PlayniteExitPolicy.IsExit(processRunning: true, bigWindowShowing: false, windowMissingSince: T0, T0 + PlayniteExitPolicy.WindowGrace));
}
Loading