This document provides guidelines for developers working on the Playnite Overlay plugin, including project structure, architecture, build commands, and coding conventions.
playnite-overlay/
├── src/
│ ├── OverlayPlugin/ # Main plugin project
│ │ ├── Input/ # Input handling (hotkeys, controllers)
│ │ │ └── InputListener.cs
│ │ ├── Interop/ # Win32 API interop
│ │ │ ├── HotkeyManager.cs
│ │ │ ├── Monitors.cs
│ │ │ ├── Win32Window.cs
│ │ │ └── XInput.cs
│ │ ├── Models/ # Data models
│ │ │ ├── OverlayItem.cs
│ │ │ └── RunningApp.cs
│ │ ├── Services/ # Core business logic
│ │ │ ├── GameSwitcher.cs
│ │ │ ├── OverlayService.cs
│ │ │ └── RunningAppsDetector.cs
│ │ ├── Settings/ # Plugin settings
│ │ │ ├── OverlaySettings.cs
│ │ │ ├── OverlaySettingsView.xaml
│ │ │ ├── OverlaySettingsView.xaml.cs
│ │ │ └── OverlaySettingsViewModel.cs
│ │ ├── Utils/ # Shared utilities
│ │ │ └── ProcessMatchingUtils.cs
│ │ ├── OverlayPlugin.cs # Plugin entry point
│ │ ├── OverlayWindow.xaml # Overlay UI
│ │ └── OverlayWindow.xaml.cs
├── tests/
│ └── OverlayPlugin.Tests/ # Unit tests
│ ├── ComboMaskTests.cs
│ ├── GameSwitcherTests.cs
│ ├── InputListenerTests.cs
│ └── OverlayItemTests.cs
├── extension/
│ └── extension.yaml # Plugin manifest
├── tools/
│ └── pack.ps1 # Build and packaging script
└── .github/workflows/ # CI/CD pipelines
- Implements Playnite's
GenericPlugininterface - Coordinates all services:
InputListener,GameSwitcher,RunningAppsDetector,OverlayService - Manages plugin lifecycle (OnGameStarted, OnGameStopped)
- Handles settings application
- Single source of truth for active app tracking (simplified from dual tracking in v0.3)
- Manages
activeAppstate withActivatedTimeandTotalPlaytime - Process detection with three strategies:
- Install directory matching
- Process name fuzzy matching
- Window title matching
- Game launching and termination (graceful → forceful)
- Session duration formatting (
GetSessionDuration()) - Recent games retrieval
- Auto-detection of foreground window (
DetectForegroundApp())
- Detects all running applications with visible windows
- Three-tier detection strategy:
- PlayniteGame: Games tracked by Playnite
- DetectedGame: Running processes matched to library
- GenericApp: Other applications (browsers, editors, etc.)
- Filters system processes and launchers
- Fires
AppSwitchedevent when user switches apps - Window validation and switching (
SwitchToApp())
- Creates and displays WPF overlay window
- Window lifecycle management (show/hide)
- Visibility state tracking
- Split lifecycle management (v0.3 improvement):
- Hotkey: Always active
- Controller: Configurable (always-active or gameplay-only)
- XInput polling for Xbox controllers (100ms interval)
- Hotkey registration with retry logic (up to 10 attempts)
- Supports multiple controller combos:
- Guide button (via keystroke API)
- Start+Back, LB+RB (via button mask polling)
public sealed class RunningApp
{
public string Title { get; set; }
public string? ImagePath { get; set; }
public IntPtr WindowHandle { get; set; }
public Guid? GameId { get; set; }
public int ProcessId { get; set; }
public AppType Type { get; set; } // PlayniteGame | DetectedGame | GenericApp
public Action? OnSwitch { get; set; }
public DateTime ActivatedTime { get; set; } // New in v0.3
public ulong TotalPlaytime { get; set; } // For Playnite games (seconds)
}- Factory methods for different item types:
FromRunningApp(): For NOW PLAYING sectionFromRecentGame(): For RECENT GAMES sectionFromGame(): Legacy/test compatibility
- Private
GetBestImagePath()helper (duplicated across components for encapsulation)
EnableCustomHotkey: bool (default: true)CustomHotkey: string (default: "Ctrl+Alt+O")UseControllerToOpen: bool (default: true)ControllerCombo: string (default: "Guide")ControllerAlwaysActive: bool (default: false) - New in v0.3ShowGenericApps: bool (default: true) - New in v0.3MaxRunningApps: int (default: 10, range: 1-50) - New in v0.3
- .NET 6.0 SDK or higher (for building)
- .NET Framework 4.7.2 runtime (target framework)
- Visual Studio 2022 or VS Code with C# extension
- Windows OS (for WPF and Win32 APIs)
Restore dependencies:
dotnet restore src/OverlayPlugin/OverlayPlugin.csprojBuild debug:
dotnet build src/OverlayPlugin/OverlayPlugin.csproj --configuration DebugBuild release:
dotnet build src/OverlayPlugin/OverlayPlugin.csproj --configuration ReleaseRun tests:
dotnet test tests/OverlayPlugin.Tests/OverlayPlugin.Tests.csprojPackage plugin:
# Windows PowerShell
.\tools\pack.ps1This creates playnite-overlay.pext in extension/ directory.
- Make changes in
src/OverlayPlugin/ - Build with
dotnet build - Run tests with
dotnet test - Test manually in Playnite:
- Copy
bin/Debug/net472/contents to Playnite's extensions folder - Or use extension dev mode:
Playnite.DesktopApp.exe --dev
- Copy
- Package with
pack.ps1before release
- Indentation: 4 spaces (no tabs)
- File encoding: UTF-8
- Line endings: Unix (LF) - configured in
.editorconfig - Namespace: File-scoped namespace declarations (C# 10+)
- Nullable: Enabled for reference types
- Public types/members: PascalCase (
GameSwitcher,ActiveApp) - Private fields: camelCase (
activeApp,pollTimer) - Constants: UPPER_CASE (
GracefulExitTimeoutMs,PollIntervalMs) - Interfaces: IPascalCase (
IPlayniteAPI) - Async methods: Suffix with
Async(if truly async)
- Favor async APIs: Use
async/awaitfor I/O operations - Avoid blocking UI thread: Use
Dispatcher.Invoke()orDispatcher.BeginInvoke() - MVVM in WPF: Use
ObservableObjectfrom CommunityToolkit.Mvvm - Dispose pattern: Implement
IDisposablefor unmanaged resources - Null safety: Use nullable reference types and null-conditional operators
- Log everything: Use
LogManager.GetLogger()and appropriate levels (Debug, Info, Warn, Error) - Try-catch patterns:
- Catch specific exceptions first (Win32Exception, ArgumentException)
- Log with context (
logger.Error(ex, "Failed to ...")) - Show user-friendly notifications via
api.Notifications
- Graceful degradation: Plugin should never crash Playnite
- xUnit: Primary test framework
- Moq: Not currently used, but available for mocking
- Coverage: Target ≥70% for core services (GameSwitcher, RunningAppsDetector)
- Test names:
MethodName_Scenario_ExpectedBehavior- Example:
ResolveComboMask_StartBack_ReturnsCorrectMask
- Example:
- Test location:
tests/OverlayPlugin.Tests/ - Test files: Match source file names (e.g.,
GameSwitcher.cs→GameSwitcherTests.cs)
- Core logic: GameSwitcher, RunningAppsDetector (high priority)
- Input parsing: Combo mask resolution, hotkey parsing
- Data models: OverlayItem factory methods
- UI: Lightweight smoke tests where feasible (WPF testing is complex)
# Run all tests
dotnet test
# Run with coverage (if configured)
dotnet test /p:CollectCoverage=trueCI publishes coverage artifacts via GitHub Actions.
Follow Conventional Commits:
Format: <type>(<scope>): <description>
Types:
feat: New featurefix: Bug fixdocs: Documentation changeschore: Maintenance (dependencies, cleanup)refactor: Code restructuring without behavior changetest: Adding or updating testsperf: Performance improvements
Scopes (examples):
input: InputListener, controller/hotkey handlingdetection: RunningAppsDetector, app detection logicui: Overlay window, XAML, visual changestracking: GameSwitcher, active app trackingsettings: Configuration and settings
Examples:
feat(detection): add auto-detection of foreground app
fix(input): hotkey now works at all times
docs: update README with v0.3 features
chore: remove unused System.IO import
- Branch naming:
feat/description,fix/description,docs/description - Target branch:
- Features →
develop - Hotfixes →
main(then merge back to develop)
- Features →
- PR description should include:
- Summary of changes
- Linked issues (if applicable)
- Test results (unit tests passed, manual testing done)
- Breaking changes (if any)
- Keep PRs focused: One feature/fix per PR
- Update docs: If behavior changes, update README.md or CONTRIBUTING.md
main (stable, tagged releases)
↑
develop (integration branch)
↑
feat/split-layout-ui (feature branches)
IMPORTANT: Never commit directly to main or develop branches.
All changes must go through a Pull Request, even single-line fixes:
-
Create a feature branch first:
git checkout develop git pull origin develop git checkout -b feat/my-feature # or fix/my-fix, docs/my-docs -
Make changes and commit to the feature branch
-
Push and create a PR:
git push -u origin feat/my-feature gh pr create --base develop --title "feat(scope): description" -
Wait for CI to pass before merging
-
Merge via GitHub (squash merge preferred for feature branches)
Why?
- PRs enable code review and CI validation before integration
- Direct pushes bypass CI checks and can break the build
- History stays clean with squash merges
- Easier to revert changes if needed
| Step | Command |
|---|---|
| Create branch | git checkout -b fix/description |
| Commit | git commit -m "fix(scope): message" |
| Push | git push -u origin fix/description |
| Create PR | gh pr create --base develop |
| Merge | gh pr merge --squash --delete-branch |
Before (v0.2):
- Dual tracking:
CurrentGame(Playnite tracking) +ActiveApp(user focus) - Complex state management with edge cases
- ~100 lines of conditional logic in
ToggleOverlay()
After (v0.3):
- Single tracking:
ActiveApponly (single source of truth) - Added
ActivatedTimeandTotalPlaytimetoRunningApp - Simplified
ToggleOverlay()by 33% - Auto-detection fills the gap (detects foreground app automatically)
Rationale: Simpler mental model, fewer bugs, easier to maintain.
Problem: Hotkey stopped working after game exit because controller and hotkey were tied together.
Solution:
- Hotkey: Always active (registered on plugin load)
- Controller: Configurable (always-active OR gameplay-only)
- New setting:
ControllerAlwaysActive
Result: Hotkey works at all times, controller behavior is user-controlled.
- Never inject into game processes: Overlay runs as separate WPF window (topmost, transparent backdrop)
- Process termination:
- Try graceful (
CloseMainWindow(), 3s timeout) - Fall back to forceful (
Kill(), 1s timeout) - Skip launcher processes (Steam, Epic, etc.)
- Try graceful (
- Access denied handling: Suggest running Playnite as administrator
- Debounce Guide button: Use XInput keystroke API to avoid double-triggers
- Allow opt-out: All input features are configurable
- No injection required: XInput polling is external to game processes
- Match Playnite's runtime: .NET Framework 4.7.2 (same as Playnite)
- SDK version: PlayniteSDK 6.12.0 (pinned in .csproj)
- Win32 APIs: Only for window management (GetForegroundWindow, SetForegroundWindow, etc.)
- PlayniteSDK: 6.12.0 (Playnite plugin API)
- CommunityToolkit.Mvvm: 8.4.0 (MVVM helpers)
- xUnit: 2.9.3 (testing framework)
- Microsoft.NET.Test.Sdk: 17.14.1 (test runner)
- Coverlet.collector: 6.0.4 (code coverage)
- Plugin: net472 (.NET Framework 4.7.2)
- Tests: net6.0 (for modern testing features)
- "WindowsDesktop SDK not found": Requires Windows SDK, cannot build on macOS/Linux
- "PlayniteSDK not found": Run
dotnet restore - XAML errors: Ensure VS has WPF workload installed
- Hotkey not registering: Another app may be using the same hotkey (retry logic handles this)
- Games not detected: Check install directory, process name, and window title matching logic
- Access denied on Kill(): User needs to run Playnite as administrator
- UI tests failing: WPF requires STA thread, some tests may be environment-dependent
- Process tests on CI: Mock Process objects or skip on non-Windows
- Controller polling: 100ms interval (10 polls/sec) - acceptable for input responsiveness
- Process enumeration: Only on overlay open (not continuous) - minimal impact
- Image loading: Async with try-catch, cached by WPF
- Window animations: 200ms fade-in, 60ms fade-out - smooth without lag
- Customizable overlay themes
- Gamepad button remapping UI
- Playtime statistics in overlay
- Window position persistence
- Multi-monitor support improvements
- Configurable recent games count
- Game grouping/favorites in overlay
- Voice command integration (Cortana/Alexa)
For user-facing documentation, see README.md.