diff --git a/README.md b/README.md index f7b3ca0..cde42d5 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Traditional photo scenery often requires downloading and building large areas be - **Scenery on demand** — download imagery as X-Plane needs it, instead of preparing entire regions in advance. - **Less disk space wasted** — keep the areas you fly and automatically remove older scenery when your cache reaches its limit. - **Better scenery ahead** — OrthoStreamXP follows your aircraft and downloads upcoming areas before you reach them. +- **SimBrief flights ready sooner** — optionally, with your SimBrief username, the departure is built while X-Plane loads and the arrival and climb while you sit at the gate. - **Simple quality settings** — choose Balanced, Sharp, Ultra, or Extra without learning dozens of technical parameters. - **Settings that adapt to your setup** — test your connection before the first flight to get a recommended preset, and after a sufficiently long flight, get recommendations based on how the scenery performed on your PC and internet connection. You decide whether to apply them. - **Even colours** — optionally soften the patchwork look where neighbouring photos were taken on different days, so a grey, hazy patch stands out less. diff --git a/build-user-guide.ps1 b/build-user-guide.ps1 index 438bee1..90839b9 100644 --- a/build-user-guide.ps1 +++ b/build-user-guide.ps1 @@ -24,7 +24,7 @@ if (-not $browser) { throw 'Google Chrome or Microsoft Edge is needed to print t # The order of the guide's contents (docs\README.md). The pages keep their names so that pandoc's --file-scope # turns the links between them (settings.md#quality) into links inside the PDF. -$pages = 'README', 'installation', 'first-setup', 'flying', 'settings', 'cache', 'packages', 'troubleshooting', 'faq' +$pages = 'README', 'installation', 'first-setup', 'flying', 'simbrief', 'settings', 'cache', 'packages', 'troubleshooting', 'faq' $work = 'artifacts\user-guide' if (Test-Path $work) { Remove-Item $work -Recurse -Force } New-Item -ItemType Directory $work | Out-Null diff --git a/docs/README.md b/docs/README.md index 988bbce..20941b7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,13 +7,14 @@ OrthoStreamXP adds real-world aerial photos to your X-Plane 12 scenery while you ## Contents 1. [Installation](installation.md): what you need and how to install it. -2. [First setup](first-setup.md): the six-step setup that runs the first time you open OrthoStreamXP. +2. [First setup](first-setup.md): the seven-step setup that runs the first time you open OrthoStreamXP. 3. [Flying](flying.md): the start order, the Home page, and the report you get after each flight. -4. [Settings](settings.md): quality and ground detail, folders, downloads, colours, seasons and exposure, night lights and photos at night. -5. [Cache](cache.md): how much disk space the scenery uses and how to free it. -6. [Packages](packages.md): downloading the ground mesh of a whole region, the quickest way to get scenery packages. -7. [Troubleshooting](troubleshooting.md): common problems, the log, updates, uninstalling and reporting a bug. -8. [Questions and answers](faq.md): what OrthoStreamXP does and does not do, choosing the packages folder, switching from AutoOrtho. +4. [SimBrief](simbrief.md): building your SimBrief flight's scenery before X-Plane needs it (optional). +5. [Settings](settings.md): quality and ground detail, folders, downloads, colours, seasons and exposure, night lights and photos at night. +6. [Cache](cache.md): how much disk space the scenery uses and how to free it. +7. [Packages](packages.md): downloading the ground mesh of a whole region, the quickest way to get scenery packages. +8. [Troubleshooting](troubleshooting.md): common problems, the log, updates, uninstalling and reporting a bug. +9. [Questions and answers](faq.md): what OrthoStreamXP does and does not do, choosing the packages folder, switching from AutoOrtho. ## The short version diff --git a/docs/first-setup.md b/docs/first-setup.md index 03cdb4d..8152b08 100644 --- a/docs/first-setup.md +++ b/docs/first-setup.md @@ -2,7 +2,7 @@ [User guide](README.md) › First setup -The setup opens the first time you start OrthoStreamXP. It has six steps and takes a minute or two. Use **Next** and **Back** to move between the steps; nothing is saved until you click **Finish**. +The setup opens the first time you start OrthoStreamXP. It has seven steps and takes a minute or two. Use **Next** and **Back** to move between the steps; nothing is saved until you click **Finish**. You can run it again later from **Settings › Run the setup again**. A second run starts from your current settings, and **Cancel** leaves them unchanged. @@ -87,9 +87,13 @@ Pick how sharp the ground should be: **Balanced**, **Sharp**, **Ultra** or **Ext Internet speed changes during the day, so you can repeat the test later in [Settings › Pictures and download](settings.md#pictures-and-download). -## 6. Ready +## 6. SimBrief -![Setup step 6: Ready](images/setup-ready.png) +Optional. If you plan your flights with SimBrief, enter your SimBrief username or Pilot ID: OrthoStreamXP then builds your flight's scenery before X-Plane needs it, the departure when streaming starts and the arrival and climb while you sit at the gate. Click **Test SimBrief connection** to check that your latest flight plan is found. Once a username is entered, you can also choose the **download limit per session** and whether to **also build the alternate airport** (see [Why there is a download limit](simbrief.md#why-there-is-a-download-limit)). Leave the field empty to skip this. See [SimBrief](simbrief.md). + +## 7. Ready + +![Setup step 7: Ready](images/setup-ready.png) This step summarises your choices: the X-Plane folder, the scenery packages, the quality and the cache folder. Tick **Start OrthoStreamXP now** if you want to fly straight away, then click **Finish**. diff --git a/docs/settings.md b/docs/settings.md index 6674496..cfa0096 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -139,6 +139,13 @@ How dark it must be is set in the settings file (**Show the file**), not on the After flights of 10 minutes or more, Home may suggest settings for your preset (see [Flying › Tuning suggestions](flying.md#tuning-suggestions)). The tab lists each setting the tuning changed, with the preset's own value beside it. **Reset to defaults** puts every setting back to the preset's tested values and forgets what was tuned; it asks first. The folders, the picture source and *Downloads at once* stay as they are. +## SimBrief + +- **SimBrief username:** your SimBrief username or Pilot ID. Empty: SimBrief is off. +- **Test SimBrief connection:** looks up your latest flight plan, so you know OrthoStreamXP finds it. +- **Download limit per session:** the most the SimBrief prefetch may download per streaming session, in MB (0: no limit). See [Why there is a download limit](simbrief.md#why-there-is-a-download-limit). +- **Also build the alternate airport:** builds the alternate too, after the arrival. Off by default. + ## Other ![Settings: Other](images/settings-other.png) diff --git a/docs/simbrief.md b/docs/simbrief.md new file mode 100644 index 0000000..b6b105d --- /dev/null +++ b/docs/simbrief.md @@ -0,0 +1,75 @@ +# SimBrief + +[User guide](README.md) › SimBrief + +If you plan your flights with [SimBrief](https://www.simbrief.com/), OrthoStreamXP can build your flight's scenery before X-Plane needs it. It is optional and off until you enter your SimBrief username. + +## What it does + +When streaming starts and while you sit at the gate, OrthoStreamXP builds the scenery of your latest SimBrief flight plan ahead of time: + +1. **The departure airport, when streaming starts.** While X-Plane is still loading, OrthoStreamXP builds the photos of the scenery tiles X-Plane loads around your departure airport, each as sharp as X-Plane will want it from the gate. The view at the gate is sharp sooner. +2. **The arrival and the climb, while the aircraft stands at the gate.** About 30 seconds after the aircraft has come to a stop at the departure airport, OrthoStreamXP starts on the arrival airport, then the climb from the departure to the top of climb. This uses the quiet time before pushback, at the lowest priority: what X-Plane needs now always comes first. +3. **The alternate airport, if you switch it on.** It is rarely flown, so it is off unless you set `simbrief-alternate = on` (see [Settings in the ini file](#settings-in-the-ini-file)). + +When the aircraft rolls for takeoff (faster than 40 kt), whatever has not started yet is dropped and the normal scenery loading takes over. Photos already in the cache are never downloaded again. + +The gate and the takeoff roll are told by the aircraft's own ground speed and X-Plane's "on the ground" flag, so moving or lifting the camera in an outside view does not count as a takeoff. + +**With mip streaming off** only the departure is built, whole textures within `simbrief-radius-km` of the airport, and only as many as the background build queue takes at once (4 × `dds-build-workers`). These builds do not count against the download limit, are not in the per-phase summary in the log, and are not dropped on the takeoff roll. The arrival, the climb and the alternate need mip streaming. + +## Setting it up + +Enter your SimBrief username, or your Pilot ID (the number in your SimBrief account settings): + +- in the **SimBrief** step of the [first setup](first-setup.md#6-simbrief), or +- later in **Settings › SimBrief**. + +Click **Test SimBrief connection** (in the setup or in Settings) to check that OrthoStreamXP finds your latest flight plan. Both places also have the **download limit per session** and **Also build the alternate airport**. + +## Why there is a download limit + +The photos come from free map services (Bing, Esri). They are made for looking at maps, not for bulk downloads, and a program that downloads too much can be slowed down or blocked. That would hit every OrthoStreamXP user, not only you. Building a whole flight ahead means thousands of photos before you need any of them, so the SimBrief prefetch gets a budget per streaming session. It also protects a metered or slow connection. + +When the limit is reached nothing breaks: the rest loads as usual while you fly. Your own flying never counts against it. + +| Your situation | Limit | +|---|---| +| Metered or mobile connection | 300 MB | +| Slow connection | 500 MB | +| A typical flight at High or Ultra | 1000 MB (default) | +| Extra quality (about four times the downloads near airports), or the alternate airport on | 3000 MB | + +If the log says *the download budget ... is used up* on your flights, raise the limit a step. **No limit** (`0`) is not recommended. + +## On the Home page + +While **Build my SimBrief flight's scenery ahead** is on, the line below the buttons says what OrthoStreamXP knows about your flight: before the start that the plan is read then, after it the flight found (for example *Flight plan: EDDM → EGLL, generated 12 min ago*) or why there is none, and after a reload what the reload found. + +- **Build my SimBrief flight's scenery ahead.** Turn this off to skip SimBrief for one session. It can be changed while streaming is stopped and is on again the next time OrthoStreamXP opens. With **Start when OrthoStreamXP opens** on, streaming starts before you can turn it off. +- **Reload SimBrief flight plan.** OrthoStreamXP reads your flight plan once, when streaming starts. If you generated it afterwards, or planned another flight, press this button: OrthoStreamXP asks SimBrief for your latest flight plan and builds its scenery. The text next to the button says what it found, for example *Loaded EDDM → EGLL, generated 12 min ago*. SimBrief is asked only when you press it, never on its own. + +## When the flight plan is skipped + +- **An old flight plan.** A plan generated more than 6 hours ago is taken for an earlier flight: its departure is not built. Generate a new one, then press **Reload SimBrief flight plan**. +- **The aircraft is somewhere else.** If X-Plane places your aircraft more than 50 km from the plan's departure airport, the plan is not this flight's: the departure textures still waiting are dropped. +- **The download limit.** The SimBrief prefetch downloads at most 1000 MB per streaming session (`simbrief-download-limit`). After that, the rest is left to the normal scenery loading. Your own flying never counts against the limit. + +The log says each time why something was skipped. + +## Settings in the ini file + +These are in `orthostreamxp.ini` (see [Settings](settings.md)): + +| Setting | What it does | Default | +|---|---|---| +| `simbrief-user` | Your SimBrief username or Pilot ID. Empty: off. | empty | +| `simbrief-alternate` | `on`: build the alternate airport too, after the arrival. | `off` | +| `simbrief-download-limit` | The most the SimBrief prefetch may download per streaming session, in MB. `0`: no limit. | `1000` | +| `simbrief-radius-km` | With mip streaming off: how far around the departure airport textures are built at full detail, 1-50 km. | `10` | + +## Privacy + +OrthoStreamXP sends your SimBrief username (or Pilot ID) to simbrief.com over HTTPS, once when streaming starts and each time you press **Reload SimBrief flight plan**. Nothing else is sent. The username is never written to the log, the flight reports or the setup summary. + +It is stored in `orthostreamxp.ini` (`simbrief-user`). If you attach that file to a bug report, delete that line first. diff --git a/mkdocs.yml b/mkdocs.yml index 2d70127..2a258af 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ nav: - installation.md - first-setup.md - flying.md + - simbrief.md - settings.md - cache.md - packages.md diff --git a/src/OrthoStreamXP/Configuration/MountSettings.cs b/src/OrthoStreamXP/Configuration/MountSettings.cs index 5e09105..c4589ef 100644 --- a/src/OrthoStreamXP/Configuration/MountSettings.cs +++ b/src/OrthoStreamXP/Configuration/MountSettings.cs @@ -74,10 +74,20 @@ internal sealed record MountSettings public bool ReadLog { get; init; } /// Every texture and terrain file X-Plane asks for, and every build, in the log too (for a bug hunt). public bool LogDetail { get; init; } + /// SimBrief username or Pilot ID: the latest flight plan's departure is prefetched (null: off). + public string? SimBriefUser { get; init; } + /// How far around the SimBrief departure textures are prefetched. + public double SimBriefRadiusKm { get; init; } = DefaultSimBriefRadiusKm; + /// The most the SimBrief prefetch may download in a session, in MB; 0: no limit. + public int SimBriefDownloadLimitMb { get; init; } = DefaultSimBriefDownloadLimitMb; + /// The ground pre-warm builds the alternate's scenery too (rarely flown: off unless chosen). + public bool SimBriefAlternate { get; init; } public const int DefaultFileSystemThreads = 64; public const double DefaultNearWait = 60, DefaultMaxWait = 6, DefaultLookaheadSeconds = 120; public const int DefaultBuildMemoryMb = 512; + public const double DefaultSimBriefRadiusKm = 10; + public const int DefaultSimBriefDownloadLimitMb = 1000; /// Reads and checks the mount options; a mistake is a in plain words. /// False for the window: the log never goes to a console there. @@ -153,6 +163,13 @@ public static MountSettings FromValues(SettingValues values, bool console = true bool skipPhotosAtNight = ParseOnOff(skipValue) ?? throw new SettingsException($"skip-photos-at-night must be on or off, not '{skipValue}'"); double skipSunBelow = values.Double("skip-photos-sun-below", -Scenery.Daylight.DefaultDarkBelow); if (skipSunBelow is < 0 or > 18) throw new SettingsException("skip-photos-sun-below must be between 0 and 18 degrees"); + string? simBriefUser = values.String("simbrief-user")?.Trim() is { Length: > 0 } user ? user : null; + double simBriefRadiusKm = values.Double("simbrief-radius-km", DefaultSimBriefRadiusKm); + if (simBriefRadiusKm is < 1 or > 50) throw new SettingsException("simbrief-radius-km must be between 1 and 50"); + int simBriefDownloadLimitMb = values.Int("simbrief-download-limit", DefaultSimBriefDownloadLimitMb); + string alternateValue = values.String("simbrief-alternate") ?? "off"; + bool simBriefAlternate = ParseOnOff(alternateValue) ?? throw new SettingsException($"simbrief-alternate must be on or off, not '{alternateValue}'"); + if (simBriefDownloadLimitMb is < 0 or > 100_000) throw new SettingsException("simbrief-download-limit must be between 0 (no limit) and 100000"); if (downloadWorkers < 1) throw new SettingsException("download-workers must be at least 1"); if (downloadTimeout is < 1 or > 120) throw new SettingsException("download-timeout must be between 1 and 120 seconds"); @@ -222,6 +239,10 @@ public static MountSettings FromValues(SettingValues values, bool console = true CacheLimitBytes = (long)(cacheLimitGb * (1L << 30)), ReadLog = readLog, LogDetail = logDetail, + SimBriefUser = simBriefUser, + SimBriefRadiusKm = simBriefRadiusKm, + SimBriefDownloadLimitMb = simBriefDownloadLimitMb, + SimBriefAlternate = simBriefAlternate, }; } diff --git a/src/OrthoStreamXP/Configuration/SettingsOptions.cs b/src/OrthoStreamXP/Configuration/SettingsOptions.cs index 5c2973f..42824d5 100644 --- a/src/OrthoStreamXP/Configuration/SettingsOptions.cs +++ b/src/OrthoStreamXP/Configuration/SettingsOptions.cs @@ -42,6 +42,10 @@ internal static class SettingsOptions .Add("prefetch-radius-km", "Width either side of the predicted path, and the radius around a slow or stationary aircraft (default: 5 km)") .Add("prefetch-lookahead-seconds", $"How far ahead to prefetch, as flying time at the current ground speed: 120 s is 7.4 km at 120 kt, 28 km at 450 kt (default: {MountSettings.DefaultLookaheadSeconds})") .Add("prefetch-textures", "Maximum DDS files to queue per position update, nearest along the path first (default: 8)") + .Add("simbrief-user", "SimBrief username (or the numeric Pilot ID; all digits are taken as one). When streaming starts, the photos around the departure airport of your latest SimBrief flight plan are built before X-Plane loads them; while the aircraft stands at the gate, the arrival and the climb follow. With mip streaming every texture of the scenery tiles X-Plane loads there, each to the detail X-Plane will want; what the cache has already is skipped (default: none, off)") + .Add("simbrief-radius-km", $"With mip streaming off (or a package whose DSFs cannot be read): how far around the SimBrief departure airport textures are built ahead at full detail, 1-50 km (default: {MountSettings.DefaultSimBriefRadiusKm})") + .Add("simbrief-alternate", "on or off: while the aircraft stands at the gate, build the SimBrief flight plan's alternate airport too, after the arrival (rarely flown, so it seldom pays for its downloads; default: off)") + .Add("simbrief-download-limit", $"The most the SimBrief prefetch (departure and ground pre-warm) may download per streaming session, in MB; then the rest is left to load-ahead. It keeps the free map services from slowing down or blocking the program, and protects a metered connection. 0: no limit (default: {MountSettings.DefaultSimBriefDownloadLimitMb})") .Add("level-change", "Fetch tiles at (zoom in the .ter/DDS file name + N), -4..2. -1 downloads a quarter of the tiles of 0; +1 four times as many (default: 0). Each level keeps its textures in its own cache subfolder. It also sets the texture size: 4096 px at level 0 and above, half per level below (-2: 1024, -1: 2048, 0 and +1: 4096)") .Add("provisional-lod", "Cold-run speedup: if the full DDS is not ready within provisional-wait seconds, serve a same-size image built from tiles N zoom levels coarser (N=2 downloads 16 tiles instead of 256). 0 disables. Needs background-textures or track-aircraft (default: 2)") .Add("provisional-wait", "Seconds to wait for the full-detail DDS before serving the provisional one (default: 2.0)") diff --git a/src/OrthoStreamXP/Gui/MountController.cs b/src/OrthoStreamXP/Gui/MountController.cs index 4e3d2db..811c3b1 100644 --- a/src/OrthoStreamXP/Gui/MountController.cs +++ b/src/OrthoStreamXP/Gui/MountController.cs @@ -87,6 +87,12 @@ public async Task RestartAsync() await StartAsync().ConfigureAwait(true); } + /// + /// Builds the SimBrief flight plan's departure ahead at the next start (when a SimBrief user is set). For this + /// window only, not saved: each time OrthoStreamXP opens it is on again. + /// + public bool DeparturePrefetch { get; set; } = true; + /// X-Plane was already running when the mount started, so it has not seen the photo scenery. public bool XPlaneWasRunningAtStart { get; private set; } @@ -95,6 +101,15 @@ public async Task RestartAsync() public MountStatus? Status() => _session?.Status(); + /// The running session uses SimBrief (a SimBrief user is set and the switch is on). + public bool UsesSimBrief => State == MountState.Running && _session is { UsesSimBrief: true }; + + /// Asks SimBrief for the latest flight plan again (the Home page's button); what came of it, or null without one. + public Task? ReloadFlightPlanAsync() => State == MountState.Running ? _session?.ReloadFlightPlanAsync() : null; + + /// The flight plan the running session fetched as it started (Home's SimBrief line), or null without one. + public Task? FirstFlightPlanAsync() => State == MountState.Running ? _session?.FirstFlightPlanAsync() : null; + /// The flight report of the session stopped last in this window (null before one stops, or without a flight). public Tuning.FlightReport? LastReport { get; private set; } @@ -132,6 +147,11 @@ public async Task StartAsync() Log.Configure(settings.LogOutput, settings.LogPath, settings.LogDetail); if (settings.LogOutput.HasFlag(LogOutput.File)) Log.Info($"Log file: {settings.LogPath}{(settings.LogDetail ? " (detailed)" : "")}"); Log.Info($"Settings file: {path}"); + if (!DeparturePrefetch && settings.SimBriefUser is not null) + { + settings = settings with { SimBriefUser = null }; + Log.Info("SimBrief: the departure prefetch is off for this session"); + } return MountSession.Start(settings); }).ConfigureAwait(true); XPlaneWasRunningAtStart = SystemCheck.IsXPlaneRunning(); @@ -161,7 +181,7 @@ await Task.Run(async () => session.Dispose(); // The streamed textures' RAM copies were large arrays. An ordinary collection frees them but keeps the // memory for reuse (3.9 GB stayed with an idle window); an aggressive one gives it back to Windows. - GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); + PrewarmMemory.GiveBack(); }).ConfigureAwait(true); Log.Step("OrthoStreamXP stopped: the photo scenery is no longer shown to X-Plane"); } diff --git a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs index db47421..e38a7ae 100644 --- a/src/OrthoStreamXP/Gui/Settings/SettingNames.cs +++ b/src/OrthoStreamXP/Gui/Settings/SettingNames.cs @@ -25,6 +25,10 @@ internal static class SettingNames ["scenery-packs"] = "Keep the photo scenery below airports", ["xplane-address"] = "X-Plane address", ["xplane-arguments"] = "X-Plane start options", + ["simbrief-user"] = "SimBrief departure prefetch", + ["simbrief-radius-km"] = "SimBrief departure prefetch", + ["simbrief-download-limit"] = "SimBrief prefetch download limit", + ["simbrief-alternate"] = "SimBrief alternate airport", }; /// Settings the running streaming does not use: changing them never needs a restart. diff --git a/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs b/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs new file mode 100644 index 0000000..00db8f4 --- /dev/null +++ b/src/OrthoStreamXP/Gui/Settings/SimBriefText.cs @@ -0,0 +1,37 @@ +namespace OrthoStreamXP.Gui; + +/// +/// The SimBrief prefetch's explanations, shared by the setup's SimBrief step and the Settings page's SimBrief tab so +/// both say the same. +/// +internal static class SimBriefText +{ + /// What the download limit (simbrief-download-limit) does, in one line. + public const string LimitHelp = + "The most the prefetch may download per session. Beyond it, scenery loads as usual while you fly."; + + /// Why there is a limit at all, and how to choose it. + public const string LimitWhy = + "Why a limit? The free map services can block heavy downloaders, which would hit every user. 1000 MB fits a typical flight; " + + "choose less on a metered connection, more with Extra quality or the alternate on."; + + /// What the alternate switch (simbrief-alternate) does. + public const string AlternateHelp = + "At the gate, build the alternate airport too, after the arrival. Rarely flown, so off by default."; + + /// The limits offered in the setup; a value set by hand in the file is added to them. + public static IReadOnlyList LimitChoices(int? current) + { + List choices = + [ + new("300 MB: a metered or mobile connection", "300"), + new("500 MB: a slow connection", "500"), + new($"{MountSettings.DefaultSimBriefDownloadLimitMb} MB: a typical flight (recommended)", MountSettings.DefaultSimBriefDownloadLimitMb.ToString(System.Globalization.CultureInfo.InvariantCulture)), + new("3000 MB: Extra quality, or the alternate airport on", "3000"), + new("No limit (not recommended)", "0"), + ]; + string? own = current?.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (own is not null && choices.All(c => c.Value != own)) choices.Insert(0, new Choice($"{own} MB (set in the settings file)", own)); + return choices; + } +} diff --git a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs index f24dde6..4961dc0 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/HomeViewModel.cs @@ -26,6 +26,9 @@ internal sealed class HomeViewModel : ObservableObject, IPage private System.Windows.Media.ImageSource? _reportBackdrop; private string? _reviewPath; private Notice? _tuningNotice; + private bool _reloading; + /// The running session's flight plan, as fetched at the start or by the last reload; null until known. + private string? _planStatus; private readonly AppUpdates _updates = new(); /// Looks for a new version 10 seconds after the window opens, then every 6 hours. private readonly DispatcherTimer _updateTimer = new() { Interval = TimeSpan.FromSeconds(10) }; @@ -38,6 +41,7 @@ public HomeViewModel(MainViewModel main) ChangeQuality = new RelayCommand(() => _main.Show(_main.Settings)); OpenLog = new RelayCommand(() => _main.Show(_main.LogPage)); RestartMount = new RelayCommand(() => _ = Mount.RestartAsync(), () => Mount.IsRunning); + ReloadFlightPlan = new RelayCommand(() => _ = ReloadFlightPlanAsync(), () => Mount.UsesSimBrief && !_reloading); ApplyReview = new RelayCommand(Apply); DismissReview = new RelayCommand(() => { @@ -60,11 +64,13 @@ public HomeViewModel(MainViewModel main) { LoadReview(); LoadReport(); + PlanStatus = null; // the next session starts with its own plan } if (Mount.State == MountState.Starting) _tuningNotice = null; if (Mount.State == MountState.Running) { _hasRun = true; + _ = ShowFirstFlightPlanAsync(); _previous = null; _mbits = _tilesPerSecond = 0; _failureWindow.Clear(); @@ -149,6 +155,87 @@ public bool AutoStart } } + /// The SimBrief departure prefetch for the next start (this window only, not saved). + public bool DeparturePrefetch + { + get => Mount.DeparturePrefetch; + set + { + Mount.DeparturePrefetch = value; + Raise(); + Raise(nameof(ShowFlightPlanStatus)); + } + } + + /// + /// "Reload SimBrief flight plan": asks SimBrief again whenever the pilot wants (generated the OFP after starting, or + /// planned another flight). Never automatic; disabled while a reload runs, so a double click sends one request. + /// + public ICommand ReloadFlightPlan { get; } + + /// Streaming runs with a SimBrief flight plan to reload. + public bool ShowReloadFlightPlan => Mount.UsesSimBrief; + + /// + /// Home's SimBrief line, shown whenever the SimBrief switch is on: before the start that the plan is read then, + /// while starting that SimBrief is asked, then the flight found ("Flight plan: EDDV → ENGM, generated 12 min ago.") + /// or what the last reload found. + /// + public string FlightPlanStatus => Mount.State switch + { + MountState.Stopped => "Your latest SimBrief flight plan is read when streaming starts.", + MountState.Running when !Mount.UsesSimBrief => "SimBrief is off for this run; the log says why.", + MountState.Running or MountState.Starting => _planStatus ?? "Asking SimBrief for your latest flight plan…", + _ => _planStatus ?? "", + }; + + public bool ShowFlightPlanStatus => ShowDeparturePrefetch && DeparturePrefetch && FlightPlanStatus.Length > 0; + + private string? PlanStatus + { + set + { + _planStatus = value; + Raise(nameof(FlightPlanStatus)); + Raise(nameof(ShowFlightPlanStatus)); + } + } + + /// Streaming started: shows the plan it fetched, unless a reload already said more. + private async Task ShowFirstFlightPlanAsync() + { + if (Mount.FirstFlightPlanAsync() is not { } first) + { + PlanStatus = null; + return; + } + string text = await first.ConfigureAwait(true); + if (Mount.State == MountState.Running && !_reloading && _planStatus is null) PlanStatus = text; + } + + private async Task ReloadFlightPlanAsync() + { + if (Mount.ReloadFlightPlanAsync() is not { } reload) return; + _reloading = true; + RelayCommand.Refresh(); + PlanStatus = "Asking SimBrief for your latest flight plan…"; + try + { + PlanStatus = await reload.ConfigureAwait(true); + } + finally + { + _reloading = false; + RelayCommand.Refresh(); + } + } + + /// A SimBrief user is set, so there is a departure prefetch to turn off. + public bool ShowDeparturePrefetch { get; private set; } + + /// The switch applies when streaming starts: it can be changed only while stopped. + public bool CanChangeDeparturePrefetch => Mount.State == MountState.Stopped; + public string StartStopText => Mount.State switch { MountState.Running => "Stop", @@ -379,6 +466,9 @@ private void LoadSettingsSummary() .Select(name => new PackageRow(name, xplane is null ? "" : Path.Join(xplane, "Custom Scenery", name)))]; } Raise(nameof(PackagesCaption)); + ShowDeparturePrefetch = !string.IsNullOrWhiteSpace(settings.Get("simbrief-user")); + Raise(nameof(ShowDeparturePrefetch)); + Raise(nameof(ShowFlightPlanStatus)); } // ---------------------------------------------------------------- the last flight's report @@ -518,7 +608,8 @@ private void Update() Notices = BuildNotices(); foreach (string name in new[] { - nameof(ShowFly), nameof(FlyText), nameof(FlyToolTip), + nameof(ShowFly), nameof(FlyText), nameof(FlyToolTip), nameof(CanChangeDeparturePrefetch), nameof(ShowReloadFlightPlan), + nameof(FlightPlanStatus), nameof(ShowFlightPlanStatus), nameof(StartStopText), nameof(StartStopGlyph), nameof(IsBusy), nameof(StatusKind), nameof(StatusGlyph), nameof(StatusTitle), nameof(StatusText), nameof(ShowGettingStarted), nameof(ShowStats), nameof(StatsTitle), nameof(TexturesValue), nameof(ReadyAheadValue), nameof(ReadyAheadCaption), nameof(BuildingValue), nameof(SpeedValue), nameof(SpeedCaption), nameof(XPlaneTitle), diff --git a/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs index 2fdac3b..0c7b9c2 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/PackagesViewModel.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Reflection; using System.Windows.Input; using OrthoStreamXP.Setup; @@ -238,8 +237,7 @@ public static HttpClient CreateHttpClient() { Timeout = Timeout.InfiniteTimeSpan, // each download watches for silence itself }; - string version = typeof(PackagesViewModel).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0"; - http.DefaultRequestHeaders.UserAgent.ParseAdd($"orthostreamxp/{version}"); + http.DefaultRequestHeaders.UserAgent.ParseAdd(HttpIdentity.UserAgent); return http; } } diff --git a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs index 813e0f2..9875c2b 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/SettingsViewModel.cs @@ -64,6 +64,22 @@ public SettingsViewModel(MainViewModel main) "Added when OrthoStreamXP starts X-Plane (Start and fly), such as --allow_reshade. Takes effect the next time X-Plane is started.", FieldKind.Text, placeholder: "--allow_reshade"), ]; + SimBriefUser = Field("simbrief-user", "SimBrief username", + "Your username or Pilot ID. Your latest flight plan's departure, arrival and climb are built ahead. Empty: off.", + FieldKind.Text, placeholder: "username or Pilot ID"); + SimBriefTest = new SimBriefTestViewModel(_main.MeshHttp, () => SimBriefUser.Value); + SimBriefUser.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(SettingField.Value)) SimBriefTest.Reset(); + }; + SimBrief = + [ + Field("simbrief-download-limit", "Download limit per session", + SimBriefText.LimitHelp + " Enter the MB, from 0 (no limit) to 100000.", + FieldKind.Number, min: 0, max: 100_000), + Field("simbrief-alternate", "Also build the alternate airport", SimBriefText.AlternateHelp, + FieldKind.Toggle, on: "on", off: "off"), + ]; ConnectionTest = new ConnectionTestViewModel( () => _document.Get("texture-provider") ?? "bing,esri", () => _main.Mount.State == MountState.Stopped, @@ -98,6 +114,15 @@ public SettingsViewModel(MainViewModel main) public IReadOnlyList Presets { get; } public IReadOnlyList Providers { get; } public IReadOnlyList Other { get; } + + /// The SimBrief tab's username; the test below it asks SimBrief with what is typed there. + public SettingField SimBriefUser { get; } + + /// The SimBrief tab's "Test SimBrief connection". + public SimBriefTestViewModel SimBriefTest { get; } + + /// The SimBrief tab's download limit and alternate airport. + public IReadOnlyList SimBrief { get; } public ICommand BrowseXPlane { get; } public ICommand BrowsePackages { get; } public ICommand BrowseCache { get; } @@ -503,7 +528,7 @@ public void Reload() CustomLevelNote = QualityPreset.ForLevel(level) is null ? $"Your settings file uses level-change {level}, which is not one of these. Choosing one replaces it." : null; - foreach (SettingField field in Other) + foreach (SettingField field in Other.Append(SimBriefUser).Concat(SimBrief)) field.Load(_document.Get(field.Key), Default(field.Key)); ConfirmingReset = false; foreach (string name in new[] @@ -574,6 +599,8 @@ private SettingField Field(string key, string label, string help, FieldKind kind private static string Default(string key) => key switch { "scenery-packs" => "on", + "simbrief-download-limit" => MountSettings.DefaultSimBriefDownloadLimitMb.ToString(CultureInfo.InvariantCulture), + "simbrief-alternate" => "off", _ => "", }; } diff --git a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs index cabd626..61a3969 100644 --- a/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs +++ b/src/OrthoStreamXP/Gui/ViewModels/SetupViewModel.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Collections.ObjectModel; using System.Windows.Input; using OrthoStreamXP.Setup; @@ -30,17 +31,21 @@ public void Show(bool selected) /// /// The first-run setup: checks WinFsp, finds X-Plane, asks for the folder of the person's own scenery packages, -/// and lets them pick a cache folder and a quality; then writes orthostreamxp.ini. +/// lets them pick a cache folder and a quality, and optionally their SimBrief account (departure prefetch); then writes +/// orthostreamxp.ini. /// internal sealed class SetupViewModel : ObservableObject { - public static readonly string[] StepTitles = ["Welcome", "X-Plane", "Scenery", "Cache", "Quality", "Ready"]; + public static readonly string[] StepTitles = ["Welcome", "X-Plane", "Scenery", "Cache", "Quality", "SimBrief", "Ready"]; private readonly MainViewModel _main; private int _step; private bool _winFsp; private string? _xplane, _packages; private string _cache; + private string _simBriefUser; + private Choice _simBriefLimit; + private bool _simBriefAlternate; /// The cache folder came from the settings or the person: it no longer follows the packages folder. private bool _cacheChosen; private QualityPreset _preset = PresetMemory.Recommended.Preset; @@ -71,6 +76,13 @@ public SetupViewModel(MainViewModel main) QualityPreset recommended = PresetMemory.Recommended.Preset; Preset = QualityPreset.ForLevel(existing.GetInt("level-change") ?? recommended.Level) ?? recommended; IsRerun = existing.Get("cache") is not null; + _simBriefUser = existing.Get("simbrief-user") ?? ""; + SimBriefTest = new SimBriefTestViewModel(_main.MeshHttp, () => SimBriefUser); + int? limit = existing.GetInt("simbrief-download-limit"); + SimBriefLimits = SimBriefText.LimitChoices(limit); + string chosen = (limit ?? MountSettings.DefaultSimBriefDownloadLimitMb).ToString(CultureInfo.InvariantCulture); + _simBriefLimit = SimBriefLimits.First(c => c.Value == chosen); + _simBriefAlternate = existing.GetBool("simbrief-alternate") == true; ConnectionTest = new ConnectionTestViewModel( () => existing.Get("texture-provider") ?? "bing,esri", () => _main.Mount.State == MountState.Stopped, @@ -146,6 +158,8 @@ private void RegionInstalled(string folder) public ObservableCollection XPlaneOptions { get; } public ObservableCollection PackageOptions { get; } public IReadOnlyList Presets { get; } + /// The SimBrief step's "Test SimBrief connection". + public SimBriefTestViewModel SimBriefTest { get; } /// The connection test above the presets: the recommendation follows its speed. public ConnectionTestViewModel ConnectionTest { get; } public ICommand Back { get; } @@ -275,6 +289,46 @@ public string CacheHint } } + /// SimBrief username or Pilot ID (optional): the departure of the latest flight plan is built ahead. + public string SimBriefUser + { + get => _simBriefUser; + set + { + if (!Set(ref _simBriefUser, value?.Trim() ?? "")) return; + SimBriefTest.Reset(); + Raise(nameof(HasSimBriefUser)); + Raise(nameof(Summary)); + } + } + + /// The limit and the alternate apply only with a username. + public bool HasSimBriefUser => SimBriefUser.Length > 0; + + /// The SimBrief step's download limits; a value set by hand in the file is one of them. + public IReadOnlyList SimBriefLimits { get; } + + /// The most the SimBrief prefetch may download per session (simbrief-download-limit). + public Choice SimBriefLimit + { + get => _simBriefLimit; + set + { + if (value is not null && Set(ref _simBriefLimit, value)) Raise(nameof(Summary)); + } + } + + /// Build the alternate airport too (simbrief-alternate). + public bool SimBriefAlternate + { + get => _simBriefAlternate; + set + { + if (Set(ref _simBriefAlternate, value)) Raise(nameof(Summary)); + } + } + + public bool StartNow { get => _startNow; @@ -283,7 +337,10 @@ public bool StartNow public string Summary => $"X-Plane: {_xplane}\nScenery packages: {string.Join(", ", SystemCheck.PackagesIn(_packages))} in {_packages}\n" + - $"Quality: {Preset.Name}\nCache: {CacheFolder}"; + $"Quality: {Preset.Name}\nCache: {CacheFolder}\n" + + $"SimBrief: {(SimBriefUser.Length > 0 ? $"on (your flight's scenery is built ahead, {LimitShort}{(SimBriefAlternate ? ", with the alternate" : "")})" : "not used")}"; + + private string LimitShort => SimBriefLimit.Value == "0" ? "no download limit" : $"up to {SimBriefLimit.Value} MB per session"; public string? Error { @@ -373,6 +430,11 @@ private async Task FinishAsync() settings = SettingsDocument.CreateNew(path, _xplane!, _packages!, CacheFolder, Preset); } if (_testedWorkers is { } workers) settings.Set("download-workers", workers); + settings.Set("simbrief-user", SimBriefUser.Length > 0 ? SimBriefUser : null); + // Defaults stay out of the file, so a later default applies. + bool defaultLimit = SimBriefLimit.Value == MountSettings.DefaultSimBriefDownloadLimitMb.ToString(CultureInfo.InvariantCulture); + settings.Set("simbrief-download-limit", defaultLimit ? null : SimBriefLimit.Value); + settings.Set("simbrief-alternate", SimBriefAlternate ? "on" : null); Directory.CreateDirectory(CacheFolder); settings.Save(); Log.Info($"Setup saved the settings to {path}"); diff --git a/src/OrthoStreamXP/Gui/ViewModels/SimBriefTestViewModel.cs b/src/OrthoStreamXP/Gui/ViewModels/SimBriefTestViewModel.cs new file mode 100644 index 0000000..4ed9891 --- /dev/null +++ b/src/OrthoStreamXP/Gui/ViewModels/SimBriefTestViewModel.cs @@ -0,0 +1,104 @@ +using System.Windows.Input; +using OrthoStreamXP.Scenery; + +namespace OrthoStreamXP.Gui; + +/// +/// "Test SimBrief connection": fetches the latest flight plan of the username entered and says which flight it found, +/// or why there is none, so a typo shows before the first flight. +/// +internal sealed class SimBriefTestViewModel : ObservableObject +{ + private readonly SimBriefClient _client; + private readonly Func _user; + private bool _isRunning; + private string? _status; + private bool _failed; + private bool _succeeded; + + /// The username or Pilot ID entered now. + public SimBriefTestViewModel(HttpClient http, Func user) + { + _client = new SimBriefClient(http); + _user = user; + Run = new RelayCommand(() => _ = RunAsync(), () => !IsRunning && !string.IsNullOrWhiteSpace(_user())); + } + + public ICommand Run { get; } + + public bool IsRunning + { + get => _isRunning; + private set + { + if (!Set(ref _isRunning, value)) return; + Raise(nameof(RunText)); + RelayCommand.Refresh(); + } + } + + public string RunText => IsRunning ? "Testing…" : "Test SimBrief connection"; + + /// The flight found, or why there is none; null before a test. + public string? Status + { + get => _status; + private set => Set(ref _status, value); + } + + /// The last test found no flight plan (shown in red). + public bool Failed + { + get => _failed; + private set => Set(ref _failed, value); + } + + /// The last test found a flight plan (shown in green). + public bool Succeeded + { + get => _succeeded; + private set => Set(ref _succeeded, value); + } + + /// The username changed: the last result no longer applies. + public void Reset() + { + Status = null; + Failed = false; + Succeeded = false; + RelayCommand.Refresh(); + } + + private async Task RunAsync() + { + IsRunning = true; + Status = "Asking SimBrief for your latest flight plan…"; + Failed = false; + Succeeded = false; + try + { + SimBriefAnswer answer = await _client.FetchAsync(_user() ?? "", CancellationToken.None).ConfigureAwait(true); + Failed = answer.Origin is null; + Succeeded = !Failed; + Status = Describe(answer); + } +#pragma warning disable CA1031 // The click's task is not awaited: an unexpected failure must show and be logged, not vanish. + catch (Exception ex) +#pragma warning restore CA1031 + { + Log.Warning($"SimBrief connection test failed unexpectedly ({ex.GetType().Name}: {ex.Message})"); + Failed = true; + Status = "The test failed unexpectedly; see the log."; + } + finally + { + IsRunning = false; + } + } + + internal static string Describe(SimBriefAnswer answer) + { + if (answer.Plan is not { } plan) return answer.NoPlan; + return $"Connected. Your latest flight plan is {plan.Describe()}."; + } +} diff --git a/src/OrthoStreamXP/Gui/Views/HomeView.xaml b/src/OrthoStreamXP/Gui/Views/HomeView.xaml index f4b40e2..a591377 100644 --- a/src/OrthoStreamXP/Gui/Views/HomeView.xaml +++ b/src/OrthoStreamXP/Gui/Views/HomeView.xaml @@ -86,13 +86,14 @@ - + + - + + + + diff --git a/src/OrthoStreamXP/Gui/Views/SettingsView.xaml b/src/OrthoStreamXP/Gui/Views/SettingsView.xaml index c71f436..2633018 100644 --- a/src/OrthoStreamXP/Gui/Views/SettingsView.xaml +++ b/src/OrthoStreamXP/Gui/Views/SettingsView.xaml @@ -1,6 +1,7 @@ @@ -534,6 +535,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +